diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3ff4be06..509133a8 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -211,7 +211,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
-| AP-108 | Paperdoll omits Aetheria, full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, optimistic wield, exact DAT body hit map, authoritative upper-item priority, normal selection, and target-self dispatch work | Special slots, invalid drops, doll examine/drag, and selection lighting still differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
+| AP-108 | Paperdoll/AutoWield still omit Aetheria, full `AutoWieldIsLegal`/dual-wield rules, incompatible-shield/ammo blocker sequencing, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots and live doll work; inventory double-click now faithfully replaces the occupied primary weapon-ready group through server-confirmed dequip→wield in both peace and war | Special slots, illegal/off-hand cases, incompatible shield/ammo auto-removal, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index d41294f6..53f25008 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -495,6 +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, the controller waits for authoritative `InventoryPutObjInContainer`, then sends `GetAndWieldItem` for the requested bow/sword/caster/two-hander. 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`.
- **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 b122e6b2..fb997374 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -454,6 +454,12 @@ include dungeons.
- **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact
equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail
CreateObject/ParentEvent child attachment and per-frame hand-follow rendering.
+- **L.1c inventory weapon switching (implemented 2026-07-12; live gate pending)** —
+ inventory double-click ports retail's two-stage `AutoWield`: return the occupied
+ primary weapon to the main pack, wait for the server move confirmation, then
+ wield 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
+ 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
funnel and action-stamp gate, so ACE's server-selected melee/missile action
diff --git a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md
new file mode 100644
index 00000000..dbd47a99
--- /dev/null
+++ b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md
@@ -0,0 +1,108 @@
+# Retail inventory weapon-switch pseudocode
+
+## Scope
+
+Inventory double-click of a weapon while another primary weapon is equipped,
+in both peace and combat modes. The UI request sequence is shared; combat-mode
+animation remains server-authoritative.
+
+## Retail oracle
+
+- `ItemHolder::DetermineUseResult @ 0x00588460` classifies an owned combat-use
+ item as `WieldRight`.
+- `CPlayerSystem::UsingItem @ 0x00562F70` dispatches that result to
+ `AutoWield(item, SLOT_SIDE_RIGHT, 0, 1, 0, 1)`.
+- `CPlayerSystem::AutoWield @ 0x00560A60` treats
+ `0x03500000` (`MeleeWeapon | MissileWeapon | Held | TwoHanded`) as one
+ weapon-ready group. If occupied, it records the requested item, sends the
+ blocking weapon to the player container, and does not wield the requested
+ item yet.
+- `CPlayerSystem::RecvNotice_ServerSaysMoveItem @ 0x00563260` retries
+ `AutoWield` only after the server confirms the blocking item moved.
+- `ACCWeenieObject::UIAttemptWield @ 0x0058D590` sends
+ `GetAndWieldItem(item, equipMask)`.
+- `ClientCombatSystem::OnQualityChanged @ 0x0056C1B0` reads player
+ PropertyInt 40 (`CombatMode`) and applies it through `SetCombatMode`.
+
+Addresses are from `docs/research/named-retail/symbols.json`; pseudo-C is in
+`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
+
+## Cross-reference
+
+- ACE `Player_Inventory.cs` says the client normally sends the conflicting
+ item's dequip before `GetAndWieldItem`. `TryShuffleStance` changes a player
+ who is already in combat to Missile, Magic, or Melee after the new item is
+ equipped; it leaves a player in peace mode in peace.
+- ACE `Creature_Combat.cs::SwitchCombatStyles` performs the visible
+ old-stance -> peace -> new-stance motion chain and sends
+ `PrivateUpdatePropertyInt(CombatMode, ...)`.
+- holtburger independently confirms the action/event shapes:
+ `GetAndWieldItem { item_guid, equip_mask }`, `WieldObject`, and
+ PropertyInt 40 as `CombatMode`.
+
+## Faithful pseudocode
+
+```text
+on inventory item double-click(item):
+ decision = DetermineUseResult(item)
+ if decision is WieldRight or WieldLeft or AutoSort:
+ auto_wield(item)
+
+auto_wield(item):
+ if another auto-wield transaction is waiting for the server:
+ consume the request without sending another inventory action
+
+ if item has no valid equip location or is already equipped:
+ return false
+
+ if item.validLocations intersects WEAPON_READY_MASK:
+ desiredMask = item.validLocations & WEAPON_READY_MASK
+ blocker = player's equipped item intersecting WEAPON_READY_MASK
+
+ if blocker exists:
+ pending = (requestedItem = item.id, blockingItem = blocker.id)
+ send PutItemInContainer(blocker.id, player.id, placement = 0)
+ return true
+
+ send_wield(item, desiredMask)
+ return true
+
+ use the existing retail slot/auto-wear selection rules
+ send_wield(item, selectedMask)
+
+on server-confirmed item move(blocker):
+ if blocker matches pending.blockingItem
+ and blocker is now loose in the player's main pack:
+ requested = pending.requestedItem
+ clear pending before re-entry
+ auto_wield(requested)
+
+on server inventory-request failure(item, error):
+ if item matches pending.blockingItem or pending.requestedItem:
+ clear pending
+
+send_wield(item, mask):
+ update the retained inventory/paperdoll projection optimistically
+ send GetAndWieldItem(item.id, mask)
+
+on PrivateUpdatePropertyInt(property = CombatMode, value):
+ if value is exactly NonCombat, Melee, Missile, or Magic:
+ CombatState.SetCombatMode(value)
+```
+
+## Animation ownership
+
+The client must not synthesize a local bow-to-sword animation. In peace mode,
+ACE leaves `CombatMode` as `NonCombat`. In combat mode, ACE selects the new
+weapon's stance and sends the authoritative motion stream plus PropertyInt 40.
+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.
+
+## Known remaining retail surface
+
+This port covers the primary weapon-ready group requested here. Retail
+`AutoWield` also has extra blocker logic for incompatible shields and missile
+ammo, plus its full `AutoWieldIsLegal` requirement checks. Those pre-existing
+paperdoll/auto-wield gaps remain tracked by divergence row AP-108 rather than
+being guessed from incomplete item properties.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index eed616b5..5a0ab1d3 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2032,7 +2032,9 @@ public sealed class GameWindow : IDisposable
sendSplitToWorld: (item, amount) =>
_liveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
- stackSplitQuantity: _stackSplitQuantity);
+ stackSplitQuantity: _stackSplitQuantity,
+ sendPutItemInContainer: (item, container, placement) =>
+ _liveSession?.SendPutItemInContainer(item, container, placement));
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
@@ -2527,6 +2529,7 @@ public sealed class GameWindow : IDisposable
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
// the table is populated before the render handler runs.
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
+ AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
_liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs
new file mode 100644
index 00000000..c4c549fe
--- /dev/null
+++ b/src/AcDream.App/UI/AutoWieldController.cs
@@ -0,0 +1,288 @@
+using System;
+using AcDream.Core.Items;
+
+namespace AcDream.App.UI;
+
+///
+/// Retail CPlayerSystem::AutoWield @ 0x00560A60 transaction owner.
+/// A conflicting primary weapon is first returned to the player container;
+/// the requested weapon is wielded only after
+/// RecvNotice_ServerSaysMoveItem @ 0x00563260 confirms that move.
+///
+internal sealed class AutoWieldController : IDisposable
+{
+ // Retail AutoWield immediate 0x03500000: all mutually-exclusive
+ // primary weapon-ready locations (shield and missile ammo are separate).
+ internal const EquipMask WeaponReadyMask =
+ EquipMask.MeleeWeapon
+ | EquipMask.MissileWeapon
+ | EquipMask.Held
+ | EquipMask.TwoHanded;
+
+ private static readonly EquipMask[] AutoEquipOrder =
+ {
+ EquipMask.HeadWear,
+ EquipMask.ChestWear,
+ EquipMask.AbdomenWear,
+ EquipMask.UpperArmWear,
+ EquipMask.LowerArmWear,
+ EquipMask.HandWear,
+ EquipMask.UpperLegWear,
+ EquipMask.LowerLegWear,
+ EquipMask.FootWear,
+ EquipMask.ChestArmor,
+ EquipMask.AbdomenArmor,
+ EquipMask.UpperArmArmor,
+ EquipMask.LowerArmArmor,
+ EquipMask.UpperLegArmor,
+ EquipMask.LowerLegArmor,
+ EquipMask.NeckWear,
+ EquipMask.WristWearLeft,
+ EquipMask.WristWearRight,
+ EquipMask.FingerWearLeft,
+ EquipMask.FingerWearRight,
+ EquipMask.Shield,
+ EquipMask.MissileAmmo,
+ EquipMask.MeleeWeapon,
+ EquipMask.MissileWeapon,
+ EquipMask.Held,
+ EquipMask.TwoHanded,
+ EquipMask.TrinketOne,
+ EquipMask.Cloak,
+ EquipMask.SigilOne,
+ EquipMask.SigilTwo,
+ EquipMask.SigilThree,
+ };
+
+ private readonly ClientObjectTable _objects;
+ private readonly Func _playerGuid;
+ private readonly Action? _sendWield;
+ private readonly Action? _sendPutItemInContainer;
+ private readonly Action? _toast;
+
+ private PendingSwitch? _pendingSwitch;
+ private bool _disposed;
+
+ public AutoWieldController(
+ ClientObjectTable objects,
+ Func playerGuid,
+ Action? sendWield,
+ Action? sendPutItemInContainer,
+ Action? toast)
+ {
+ _objects = objects ?? throw new ArgumentNullException(nameof(objects));
+ _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
+ _sendWield = sendWield;
+ _sendPutItemInContainer = sendPutItemInContainer;
+ _toast = toast;
+
+ _objects.ObjectMoved += OnObjectMoved;
+ _objects.ObjectRemoved += OnObjectRemoved;
+ _objects.MoveRequestFailed += OnMoveRequestFailed;
+ _objects.Cleared += OnObjectsCleared;
+ }
+
+ ///
+ /// Execute the UI-facing AutoWield request. A request received while the
+ /// prior conflicting weapon is awaiting its server move is consumed, just
+ /// as retail's inventory-ready gate prevents a second concurrent request.
+ ///
+ public bool TryWield(ClientObject item)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+
+ if (_pendingSwitch is not null)
+ return true;
+ if (item.ValidLocations == EquipMask.None
+ || item.CurrentlyEquippedLocation != EquipMask.None)
+ return false;
+
+ EquipMask weaponLocation = item.ValidLocations & WeaponReadyMask;
+ if (weaponLocation != EquipMask.None)
+ {
+ ClientObject? blocker = GetEquippedObjectAtLocation(
+ WeaponReadyMask, priority: 0, item.ObjectId);
+ if (blocker is not null)
+ return BeginWeaponReplacement(item.ObjectId, blocker.ObjectId);
+
+ return SendWield(item, weaponLocation);
+ }
+
+ EquipMask mask = BestAvailableEquipMask(item);
+ if (mask == EquipMask.None)
+ {
+ _toast?.Invoke("That slot is already in use");
+ return false;
+ }
+
+ return SendWield(item, mask);
+ }
+
+ private bool BeginWeaponReplacement(uint requestedItemId, uint blockingItemId)
+ {
+ if (_sendPutItemInContainer is null)
+ {
+ _toast?.Invoke("That slot is already in use");
+ return false;
+ }
+
+ uint player = _playerGuid();
+ if (player == 0)
+ return false;
+
+ _pendingSwitch = new PendingSwitch(requestedItemId, blockingItemId);
+
+ // Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0).
+ // Do not change the local equip projection yet: the server's move event
+ // is the transaction boundary and preserves its stance-specific motion.
+ _sendPutItemInContainer(blockingItemId, player, 0);
+ return true;
+ }
+
+ private bool SendWield(ClientObject item, EquipMask mask)
+ {
+ if (_sendWield is null)
+ return false;
+ if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
+ return false;
+
+ // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
+ _sendWield(item.ObjectId, (uint)mask);
+ return true;
+ }
+
+ private void OnObjectMoved(ClientObject item, uint _, uint newContainerId)
+ {
+ if (_pendingSwitch is not { } pending
+ || item.ObjectId != pending.BlockingItemId
+ || newContainerId != _playerGuid()
+ || item.CurrentlyEquippedLocation != EquipMask.None)
+ return;
+
+ // Clear before re-entry: the second AutoWield pass must see the newly
+ // vacant ready slot and send GetAndWieldItem for the requested weapon.
+ _pendingSwitch = null;
+ if (_objects.Get(pending.RequestedItemId) is { } requested)
+ TryWield(requested);
+ }
+
+ private void OnMoveRequestFailed(MoveRequestFailure failure)
+ {
+ if (_pendingSwitch is { } pending
+ && (failure.ItemId == pending.BlockingItemId
+ || failure.ItemId == pending.RequestedItemId))
+ _pendingSwitch = null;
+ }
+
+ private void OnObjectRemoved(ClientObject item)
+ {
+ if (_pendingSwitch is { } pending
+ && (item.ObjectId == pending.BlockingItemId
+ || item.ObjectId == pending.RequestedItemId))
+ _pendingSwitch = null;
+ }
+
+ private void OnObjectsCleared()
+ => _pendingSwitch = null;
+
+ private EquipMask BestAvailableEquipMask(ClientObject item)
+ {
+ if (ItemEquipRules.IsAutoWearItem(item))
+ return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
+
+ foreach (EquipMask mask in AutoEquipOrder)
+ {
+ if ((item.ValidLocations & mask) == EquipMask.None)
+ continue;
+ if (!EquipMaskOccupied(mask, item.ObjectId))
+ return mask;
+ }
+ return EquipMask.None;
+ }
+
+ private bool AutoWearIsLegal(ClientObject item)
+ {
+ uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
+ if ((item.Priority & priorityMask) == 0)
+ return true;
+
+ EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId)
+ & item.ValidLocations;
+ return GetEquippedObjectAtLocation(
+ occupiedLocations, item.Priority, item.ObjectId) is null;
+ }
+
+ private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
+ => GetEquippedObjectAtLocation(mask, priority: 0, exceptGuid) is not null;
+
+ private uint EquippedAutoWearPriorityMask(uint exceptGuid)
+ {
+ uint mask = 0;
+ foreach (ClientObject item in _objects.Objects)
+ {
+ if (item.ObjectId == exceptGuid
+ || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None
+ || !IsEquippedByPlayer(item))
+ continue;
+ mask |= item.Priority;
+ }
+ return mask;
+ }
+
+ private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
+ {
+ EquipMask mask = EquipMask.None;
+ foreach (ClientObject item in _objects.Objects)
+ {
+ if (item.ObjectId == exceptGuid
+ || (item.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None
+ || !IsEquippedByPlayer(item))
+ continue;
+ mask |= item.CurrentlyEquippedLocation;
+ }
+ return mask;
+ }
+
+ private ClientObject? GetEquippedObjectAtLocation(
+ EquipMask locationMask,
+ uint priority,
+ uint exceptGuid)
+ {
+ if (locationMask == EquipMask.None)
+ return null;
+
+ foreach (ClientObject item in _objects.Objects)
+ {
+ if (item.ObjectId == exceptGuid
+ || !IsEquippedByPlayer(item)
+ || (item.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
+ continue;
+ if ((item.Priority & priority) != 0 || priority == 0)
+ return item;
+ }
+ return null;
+ }
+
+ private bool IsEquippedByPlayer(ClientObject item)
+ {
+ uint player = _playerGuid();
+ return item.CurrentlyEquippedLocation != EquipMask.None
+ && (item.WielderId == player || item.ContainerId == player);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _pendingSwitch = null;
+ _objects.ObjectMoved -= OnObjectMoved;
+ _objects.ObjectRemoved -= OnObjectRemoved;
+ _objects.MoveRequestFailed -= OnMoveRequestFailed;
+ _objects.Cleared -= OnObjectsCleared;
+ }
+
+ private readonly record struct PendingSwitch(
+ uint RequestedItemId,
+ uint BlockingItemId);
+}
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index fdd9e4a4..4a67a863 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -18,41 +18,6 @@ public enum ItemPrimaryClickResult
///
public sealed class ItemInteractionController : IDisposable
{
- private static readonly EquipMask[] AutoEquipOrder =
- {
- EquipMask.HeadWear,
- EquipMask.ChestWear,
- EquipMask.AbdomenWear,
- EquipMask.UpperArmWear,
- EquipMask.LowerArmWear,
- EquipMask.HandWear,
- EquipMask.UpperLegWear,
- EquipMask.LowerLegWear,
- EquipMask.FootWear,
- EquipMask.ChestArmor,
- EquipMask.AbdomenArmor,
- EquipMask.UpperArmArmor,
- EquipMask.LowerArmArmor,
- EquipMask.UpperLegArmor,
- EquipMask.LowerLegArmor,
- EquipMask.NeckWear,
- EquipMask.WristWearLeft,
- EquipMask.WristWearRight,
- EquipMask.FingerWearLeft,
- EquipMask.FingerWearRight,
- EquipMask.Shield,
- EquipMask.MissileAmmo,
- EquipMask.MeleeWeapon,
- EquipMask.MissileWeapon,
- EquipMask.Held,
- EquipMask.TwoHanded,
- EquipMask.TrinketOne,
- EquipMask.Cloak,
- EquipMask.SigilOne,
- EquipMask.SigilTwo,
- EquipMask.SigilThree,
- };
-
private const long RetailUseThrottleMs = 200;
private const long RetailDoubleClickMs = 500;
@@ -77,6 +42,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly InteractionState _interactionState;
private readonly Func _selectedObjectId;
private readonly StackSplitQuantityState? _stackSplitQuantity;
+ private readonly AutoWieldController _autoWield;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget;
@@ -105,7 +71,8 @@ public sealed class ItemInteractionController : IDisposable
Action? auxiliaryAction = null,
Action? sendSplitToWorld = null,
Func? selectedObjectId = null,
- StackSplitQuantityState? stackSplitQuantity = null)
+ StackSplitQuantityState? stackSplitQuantity = null,
+ Action? sendPutItemInContainer = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@@ -129,6 +96,12 @@ public sealed class ItemInteractionController : IDisposable
_stackSplitQuantity = stackSplitQuantity;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
+ _autoWield = new AutoWieldController(
+ _objects,
+ _playerGuid,
+ _sendWield,
+ sendPutItemInContainer,
+ _toast);
}
public event Action? StateChanged;
@@ -364,7 +337,7 @@ public sealed class ItemInteractionController : IDisposable
case ItemPolicyActionKind.WieldLeft:
case ItemPolicyActionKind.AutoSort:
if (_objects.Get(action.ObjectId) is { } item)
- acted |= TryAutoWield(item);
+ acted |= _autoWield.TryWield(item);
break;
case ItemPolicyActionKind.OpenContainedContainer:
case ItemPolicyActionKind.SendUse:
@@ -472,6 +445,7 @@ public sealed class ItemInteractionController : IDisposable
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
+ _autoWield.Dispose();
}
/// Retail UseDone (0x01C7) releases one UI busy reference.
@@ -491,119 +465,6 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
- private bool TryAutoWield(ClientObject item)
- {
- if (item.ValidLocations == EquipMask.None
- || item.CurrentlyEquippedLocation != EquipMask.None)
- return false;
-
- EquipMask mask = BestAvailableEquipMask(item);
- if (mask == EquipMask.None)
- {
- _toast?.Invoke("That slot is already in use");
- return false;
- }
-
- if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
- return false;
-
- _sendWield?.Invoke(item.ObjectId, (uint)mask);
- return true;
- }
-
- private EquipMask BestAvailableEquipMask(ClientObject item)
- {
- if (ItemEquipRules.IsAutoWearItem(item))
- return AutoWearIsLegal(item) ? item.ValidLocations : EquipMask.None;
-
- return FirstAvailableEquipMask(item);
- }
-
- private bool AutoWearIsLegal(ClientObject item)
- {
- uint priorityMask = EquippedAutoWearPriorityMask(item.ObjectId);
- if ((item.Priority & priorityMask) == 0)
- return true;
-
- EquipMask occupiedLocations = EquippedAutoWearLocationMask(item.ObjectId) & item.ValidLocations;
- return GetEquippedObjectAtLocation(occupiedLocations, item.Priority, item.ObjectId) is null;
- }
-
- private EquipMask FirstAvailableEquipMask(ClientObject item)
- {
- foreach (var mask in AutoEquipOrder)
- {
- if ((item.ValidLocations & mask) == EquipMask.None)
- continue;
- if (!EquipMaskOccupied(mask, item.ObjectId))
- return mask;
- }
- return EquipMask.None;
- }
-
- private bool EquipMaskOccupied(EquipMask mask, uint exceptGuid)
- {
- foreach (var o in _objects.Objects)
- {
- if (o.ObjectId == exceptGuid)
- continue;
- if ((o.CurrentlyEquippedLocation & mask) == EquipMask.None)
- continue;
- if (IsEquippedByPlayer(o))
- return true;
- }
- return false;
- }
-
- private uint EquippedAutoWearPriorityMask(uint exceptGuid)
- {
- uint mask = 0;
- foreach (var o in _objects.Objects)
- {
- if (o.ObjectId == exceptGuid)
- continue;
- if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
- continue;
- if (IsEquippedByPlayer(o))
- mask |= o.Priority;
- }
- return mask;
- }
-
- private EquipMask EquippedAutoWearLocationMask(uint exceptGuid)
- {
- EquipMask mask = EquipMask.None;
- foreach (var o in _objects.Objects)
- {
- if (o.ObjectId == exceptGuid)
- continue;
- if ((o.CurrentlyEquippedLocation & ItemEquipRules.AutoWearMask) == EquipMask.None)
- continue;
- if (IsEquippedByPlayer(o))
- mask |= o.CurrentlyEquippedLocation;
- }
- return mask;
- }
-
- private ClientObject? GetEquippedObjectAtLocation(EquipMask locationMask, uint priority, uint exceptGuid)
- {
- if (locationMask == EquipMask.None)
- return null;
-
- foreach (var o in _objects.Objects)
- {
- if (o.ObjectId == exceptGuid)
- continue;
- if (!IsEquippedByPlayer(o))
- continue;
- if ((o.CurrentlyEquippedLocation & locationMask) == EquipMask.None)
- continue;
- if ((o.Priority & priority) != 0 || priority == 0)
- return o;
- }
- return null;
- }
-
private static bool IsContainer(ClientObject item)
=> item.ContainerTypeHint != 0
|| item.Type.HasFlag(ItemType.Container)
diff --git a/src/AcDream.Core.Net/CombatStateWiring.cs b/src/AcDream.Core.Net/CombatStateWiring.cs
new file mode 100644
index 00000000..44f2514b
--- /dev/null
+++ b/src/AcDream.Core.Net/CombatStateWiring.cs
@@ -0,0 +1,47 @@
+using AcDream.Core.Combat;
+
+namespace AcDream.Core.Net;
+
+///
+/// Routes the local player's server-owned combat-mode quality into
+/// . Retail
+/// ClientCombatSystem::OnQualityChanged @ 0x0056C1B0 reads
+/// PropertyInt 40 and calls SetCombatMode on every change.
+///
+public static class CombatStateWiring
+{
+ public const uint CombatModePropertyId = 40u;
+
+ public static void Wire(WorldSession session, CombatState combat)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(combat);
+
+ session.PlayerIntPropertyUpdated += update =>
+ ApplyPlayerIntProperty(combat, update.Property, update.Value);
+ }
+
+ ///
+ /// Pure event adapter exposed for conformance tests. Composite or unknown
+ /// values are ignored: retail's quality contains one concrete mode.
+ ///
+ public static bool ApplyPlayerIntProperty(
+ CombatState combat,
+ uint property,
+ int value)
+ {
+ ArgumentNullException.ThrowIfNull(combat);
+ if (property != CombatModePropertyId)
+ return false;
+
+ CombatMode mode = (CombatMode)value;
+ if (mode is not (CombatMode.NonCombat
+ or CombatMode.Melee
+ or CombatMode.Missile
+ or CombatMode.Magic))
+ return false;
+
+ combat.SetCombatMode(mode);
+ return true;
+ }
+}
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index d89e41d8..580614c5 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -314,7 +314,7 @@ public static class GameEventWiring
string itemInfo = item is null
? "unknown"
: $"'{item.Name}' valid=0x{(uint)item.ValidLocations:X8} equip=0x{(uint)item.CurrentlyEquippedLocation:X8} priority=0x{item.Priority:X8} container=0x{item.ContainerId:X8} wielder=0x{item.WielderId:X8}";
- bool rolledBack = items.RollbackMove(p.Value.ItemGuid);
+ bool rolledBack = items.RejectMove(p.Value.ItemGuid, p.Value.WeenieError);
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}");
});
diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs
index 5ea4588d..70dc5483 100644
--- a/src/AcDream.Core/Items/ClientObjectTable.cs
+++ b/src/AcDream.Core/Items/ClientObjectTable.cs
@@ -10,6 +10,17 @@ namespace AcDream.Core.Items;
///
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
+///
+/// Server rejection of an inventory move request.
+/// is true when the table restored a locally optimistic move; false for
+/// server-confirmed transactions which deliberately left the local projection
+/// unchanged while waiting.
+///
+public readonly record struct MoveRequestFailure(
+ uint ItemId,
+ uint WeenieError,
+ bool RolledBack);
+
///
/// The client's table of every server object (retail weenie_object_table /
/// CObjectMaint). Resolve by guid via Get.
@@ -74,12 +85,22 @@ public sealed class ClientObjectTable
///
public event Action? MoveRolledBack;
+ ///
+ /// Fires for every InventoryServerSaveFailed response, including requests
+ /// which did not mutate the table optimistically. Transaction coordinators
+ /// use this to abandon server-confirmed multi-step moves without timers.
+ ///
+ public event Action? MoveRequestFailed;
+
/// Fires when an object is removed from the session.
public event Action? ObjectRemoved;
/// Fires when an object's properties are updated (typically after Appraise).
public event Action? ObjectUpdated;
+ /// Fires after all session object and pending-move state is flushed.
+ public event Action? Cleared;
+
/// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
/// the typed mirror maintains on
/// .
@@ -254,6 +275,19 @@ public sealed class ClientObjectTable
return true;
}
+ ///
+ /// Reconcile an InventoryServerSaveFailed response and publish the failure
+ /// even when no optimistic snapshot exists. Retail's blocked AutoWield path
+ /// waits for the server before changing its local item lists, so failure is
+ /// still a meaningful transaction event in that case.
+ ///
+ public bool RejectMove(uint itemId, uint weenieError)
+ {
+ bool rolledBack = RollbackMove(itemId);
+ MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack));
+ return rolledBack;
+ }
+
///
/// Handle a server-driven remove (destroyed item, dropped into 3D
/// space, stolen, etc).
@@ -592,5 +626,6 @@ public sealed class ClientObjectTable
_containers.Clear();
_containerIndex.Clear();
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
+ Cleared?.Invoke();
}
}
diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
index a08c19b4..a04aa8f6 100644
--- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
@@ -18,11 +18,13 @@ public sealed class ItemInteractionControllerTests
public readonly List Examines = 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 Container, int Placement)> Puts = new();
public readonly List Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List Toasts = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public uint SelectedObject;
+ public bool NonCombatMode;
public long Now = 1_000;
public Harness()
@@ -54,7 +56,10 @@ public sealed class ItemInteractionControllerTests
toast: Toasts.Add,
sendSplitToWorld: (item, amount) => SplitDrops.Add((item, amount)),
selectedObjectId: () => SelectedObject,
- stackSplitQuantity: SplitQuantity);
+ stackSplitQuantity: SplitQuantity,
+ inNonCombatMode: () => NonCombatMode,
+ sendPutItemInContainer: (item, container, placement) =>
+ Puts.Add((item, container, placement)));
}
public ItemInteractionController Controller { get; }
@@ -412,6 +417,112 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WeaponReplacement_inPeaceOrWar_unwieldsThenWieldsAfterServerConfirm(
+ bool nonCombatMode)
+ {
+ var h = new Harness { NonCombatMode = nonCombatMode };
+ const uint sword = 0x50000B01u;
+ const uint bow = 0x50000B02u;
+ h.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = sword,
+ Name = "Sword",
+ Type = ItemType.MeleeWeapon,
+ CombatUse = 1,
+ ValidLocations = EquipMask.MeleeWeapon,
+ });
+ h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon);
+ h.AddContained(bow, item =>
+ {
+ item.Name = "Bow";
+ item.Type = ItemType.MissileWeapon;
+ item.CombatUse = 1;
+ item.ValidLocations = EquipMask.MissileWeapon;
+ });
+
+ Assert.True(h.Controller.ActivateItem(bow));
+
+ Assert.Equal(new[] { (sword, Player, 0) }, h.Puts);
+ Assert.Empty(h.Wields);
+ Assert.Equal(EquipMask.MeleeWeapon,
+ h.Objects.Get(sword)!.CurrentlyEquippedLocation);
+ Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
+
+ // Authoritative 0x0022: only now does retail retry AutoWield.
+ h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
+
+ Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
+ Assert.Equal(EquipMask.None,
+ h.Objects.Get(sword)!.CurrentlyEquippedLocation);
+ Assert.Equal(EquipMask.MissileWeapon,
+ h.Objects.Get(bow)!.CurrentlyEquippedLocation);
+ Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
+ }
+
+ [Fact]
+ public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer()
+ {
+ var h = new Harness();
+ const uint sword = 0x50000B11u;
+ const uint bow = 0x50000B12u;
+ 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 = 1;
+ item.ValidLocations = EquipMask.MissileWeapon;
+ });
+
+ Assert.True(h.Controller.ActivateItem(bow));
+ h.Now += 200;
+ Assert.True(h.Controller.ActivateItem(bow));
+
+ Assert.Single(h.Puts);
+ Assert.Empty(h.Wields);
+ }
+
+ [Fact]
+ public void WeaponReplacement_serverReject_clearsPendingTransactionForRetry()
+ {
+ var h = new Harness();
+ const uint sword = 0x50000B21u;
+ const uint bow = 0x50000B22u;
+ 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 = 1;
+ item.ValidLocations = EquipMask.MissileWeapon;
+ });
+
+ Assert.True(h.Controller.ActivateItem(bow));
+ Assert.False(h.Objects.RejectMove(sword, 0x04FFu));
+ h.Now += 200;
+ Assert.True(h.Controller.ActivateItem(bow));
+
+ Assert.Equal(2, h.Puts.Count);
+ Assert.Empty(h.Wields);
+ Assert.Equal(EquipMask.MeleeWeapon,
+ h.Objects.Get(sword)!.CurrentlyEquippedLocation);
+ }
+
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
{
diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
index 29d7f5db..85621e6e 100644
--- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
+++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
@@ -11,6 +11,8 @@ public sealed class RetailUiInteractionFlowTests
private const uint Player = 0x50000001u;
private const uint Hauberk = 0x50001001u;
private const uint HealthKit = 0x50001002u;
+ private const uint Sword = 0x50001003u;
+ private const uint Bow = 0x50001004u;
private const uint SlotsButtonId = 0x100005BEu;
private const uint ChestArmorSlotId = 0x100005ACu;
// USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value.
@@ -32,6 +34,7 @@ public sealed class RetailUiInteractionFlowTests
public readonly List Uses = 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 Container, int Placement)> Puts = new();
public readonly AcDream.Core.Selection.SelectionState Selection = new();
public readonly List Drops = new();
public long Now = 10_000;
@@ -165,7 +168,9 @@ public sealed class RetailUiInteractionFlowTests
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
- nowMs: () => Now);
+ nowMs: () => Now,
+ sendPutItemInContainer: (item, container, placement) =>
+ Puts.Add((item, container, placement)));
InventoryController.Bind(
Layout,
@@ -285,6 +290,46 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(state.Success, state.Message);
}
+ [Fact]
+ public void InventoryDoubleClick_bow_replacesSwordOnlyAfterConfirmedUnwield()
+ {
+ var h = new Harness();
+ h.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = Sword,
+ Name = "Sword",
+ Type = ItemType.MeleeWeapon,
+ CombatUse = 1,
+ ValidLocations = EquipMask.MeleeWeapon,
+ IconId = 0x06001234u,
+ });
+ h.Objects.MoveItem(Sword, Player, -1, EquipMask.MeleeWeapon);
+ h.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = Bow,
+ Name = "Bow",
+ Type = ItemType.MissileWeapon,
+ CombatUse = 1,
+ ValidLocations = EquipMask.MissileWeapon,
+ IconId = 0x06001235u,
+ });
+ h.Objects.MoveItem(Bow, Player, 0);
+ h.BindInventoryInteraction();
+ var probe = h.Probe();
+
+ Assert.True(probe.DoubleClickItem(Bow, ItemDragSource.Inventory));
+ Assert.Equal(new[] { (Sword, Player, 0) }, h.Puts);
+ Assert.Empty(h.Wields);
+ Assert.Equal(EquipMask.MeleeWeapon,
+ h.Objects.Get(Sword)!.CurrentlyEquippedLocation);
+
+ h.Objects.MoveItem(Sword, Player, 0, EquipMask.None);
+
+ Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
+ Assert.Equal(EquipMask.MissileWeapon,
+ h.Objects.Get(Bow)!.CurrentlyEquippedLocation);
+ }
+
[Fact]
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
{
diff --git a/tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs b/tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs
new file mode 100644
index 00000000..b7e52df1
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/CombatStateWiringTests.cs
@@ -0,0 +1,42 @@
+using AcDream.Core.Combat;
+
+namespace AcDream.Core.Net.Tests;
+
+///
+/// Conformance pins for retail ClientCombatSystem::OnQualityChanged
+/// (0x0056C1B0): player PropertyInt 40 is the authoritative combat mode.
+///
+public sealed class CombatStateWiringTests
+{
+ [Theory]
+ [InlineData((int)CombatMode.NonCombat)]
+ [InlineData((int)CombatMode.Melee)]
+ [InlineData((int)CombatMode.Missile)]
+ [InlineData((int)CombatMode.Magic)]
+ public void CombatModeProperty_appliesConcreteRetailMode(int value)
+ {
+ var combat = new CombatState();
+
+ Assert.True(CombatStateWiring.ApplyPlayerIntProperty(
+ combat,
+ CombatStateWiring.CombatModePropertyId,
+ value));
+
+ Assert.Equal((CombatMode)value, combat.CurrentMode);
+ }
+
+ [Theory]
+ [InlineData(39u, (int)CombatMode.Missile)]
+ [InlineData(40u, (int)CombatMode.Undef)]
+ [InlineData(40u, (int)CombatMode.ValidCombat)]
+ [InlineData(40u, 0x10)]
+ public void NonCombatQualityOrInvalidValue_isIgnored(uint property, int value)
+ {
+ var combat = new CombatState();
+
+ Assert.False(CombatStateWiring.ApplyPlayerIntProperty(
+ combat, property, value));
+
+ Assert.Equal(CombatMode.NonCombat, combat.CurrentMode);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
index b95b361b..81ee27a3 100644
--- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
+++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
@@ -693,6 +693,40 @@ public sealed class ClientObjectTableTests
public void WieldItemOptimistic_unknownItem_false()
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear));
+ [Fact]
+ public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure()
+ {
+ var table = new ClientObjectTable();
+ MoveRequestFailure? seen = null;
+ table.MoveRequestFailed += failure => seen = failure;
+
+ Assert.False(table.RejectMove(0xDEADu, 0x04FFu));
+
+ Assert.Equal(
+ new MoveRequestFailure(0xDEADu, 0x04FFu, RolledBack: false),
+ seen);
+ }
+
+ [Fact]
+ public void RejectMove_withOptimisticMutation_rollsBackAndPublishesFailure()
+ {
+ var table = new ClientObjectTable();
+ const uint item = 0x943u, player = 0x50000001u, pack = 0x40000005u;
+ table.AddOrUpdate(new ClientObject { ObjectId = item });
+ table.MoveItem(item, pack, 2);
+ table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
+ MoveRequestFailure? seen = null;
+ table.MoveRequestFailed += failure => seen = failure;
+
+ Assert.True(table.RejectMove(item, 0x04FFu));
+
+ Assert.Equal(pack, table.Get(item)!.ContainerId);
+ Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation);
+ Assert.Equal(
+ new MoveRequestFailure(item, 0x04FFu, RolledBack: true),
+ seen);
+ }
+
[Fact]
public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending)
{