diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6c4996a1..d562c822 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -45,6 +45,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery; + private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws /// in OnLoad. @@ -183,7 +184,6 @@ public sealed class GameWindow : IDisposable private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; - private readonly AcDream.App.Input.OutboundInteractionQueue _outboundInteractions = new(); private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; @@ -1162,20 +1162,6 @@ public sealed class GameWindow : IDisposable /// private IReadOnlyDictionary LastSpawns => _liveEntities?.Snapshots ?? EmptyLiveSpawnMap; - // B.6/B.7 (2026-05-16): pending close-range action that will be fired - // once the local auto-walk overlay reports arrival (body has finished - // rotating to face the target). Only set for close-range Use/PickUp; - // far-range sends fire the wire packet immediately at SendUse/SendPickUp - // time. Cleared before the deferred send fires — single-fire, no retry. - private PendingPostArrivalAction? _pendingPostArrivalAction; - - private readonly record struct PendingPostArrivalAction( - uint Guid, - uint LocalEntityId, - bool IsPickup, - uint DestinationContainerId = 0u, - int Placement = 0); - // R5-V2: the local player's CPhysicsObj stand-in — owns the player's // TargetManager voyeur system, registered in _physicsHosts so remote // entities chasing the player resolve it. Replaces the AP-79 @@ -2114,63 +2100,64 @@ public sealed class GameWindow : IDisposable Objects, guid => _liveSession?.SendNoLongerViewingContents(guid)); + AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; + _itemInteractionController = new AcDream.App.UI.ItemInteractionController( + Objects, + playerGuid: () => _playerServerGuid, + // ItemHolder::UseObject is the common policy owner, but world + // activation still has to pass through the application-level + // use adapter. It owns speculative turn/move, the close-range + // deferred send, and ACE's far-range MoveTo callback. Calling + // WorldSession directly here made keyboard R approach a corpse + // without completing the same open transaction as double-click. + sendUse: SendUse, + sendExamine: g => _liveSession?.SendAppraise(g), + sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), + sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), + sendDrop: item => _liveSession?.SendDropItem(item), + sendGive: (target, item, amount) => + _liveSession?.SendGiveObject(target, item, amount), + dragOnPlayerOpensSecureTrade: () => + (_characterOptions1 + & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 + .DragItemOnPlayerOpensSecureTrade) != 0, + toast: text => _debugVm?.AddToast(text), + readyForInventoryRequest: () => _liveSession is not null + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, + playerOnGround: GetDebugPlayerOnGround, + inNonCombatMode: () => Combat.CurrentMode + == AcDream.Core.Combat.CombatMode.NonCombat, + combatState: Combat, + sendChangeCombatMode: mode => + _liveSession?.SendChangeCombatMode(mode), + isComponentPack: magicCatalog.IsComponentPack, + placeInBackpack: SendPickUp, + backpackContainerId: () => + _retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId + ?? _playerServerGuid, + // Retail ItemHolder::DetermineUseResult treats children of the + // current ground object as loot. Keep this late-bound because + // ViewContents can replace/close the external container while + // the retained controller remains alive. + groundObjectId: () => _externalContainers.CurrentContainerId, + sendSplitToWorld: (item, amount) => + _liveSession?.SendStackableSplitTo3D(item, amount), + selectedObjectId: () => _selection.SelectedObjectId ?? 0u, + stackSplitQuantity: _stackSplitQuantity, + systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), + sendPutItemInContainer: (item, container, placement) => + _liveSession?.SendPutItemInContainer(item, container, placement), + sendSplitToContainer: (item, container, placement, amount) => + _liveSession?.SendStackableSplitToContainer( + item, container, placement, amount), + requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); + // Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1). if (_options.RetailUi) { _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost.Root.UiLocked = _persistedGameplay.LockUI; - AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!; - _itemInteractionController = new AcDream.App.UI.ItemInteractionController( - Objects, - playerGuid: () => _playerServerGuid, - // ItemHolder::UseObject is the common policy owner, but world - // activation still has to pass through the application-level - // use adapter. It owns speculative turn/move, the close-range - // deferred send, and ACE's far-range MoveTo callback. Calling - // WorldSession directly here made keyboard R approach a corpse - // without completing the same open transaction as double-click. - sendUse: SendUse, - sendExamine: g => _liveSession?.SendAppraise(g), - sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), - sendDrop: item => _liveSession?.SendDropItem(item), - sendGive: (target, item, amount) => - _liveSession?.SendGiveObject(target, item, amount), - dragOnPlayerOpensSecureTrade: () => - (_characterOptions1 - & AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1 - .DragItemOnPlayerOpensSecureTrade) != 0, - toast: text => _debugVm?.AddToast(text), - readyForInventoryRequest: () => _liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld, - playerOnGround: GetDebugPlayerOnGround, - inNonCombatMode: () => Combat.CurrentMode - == AcDream.Core.Combat.CombatMode.NonCombat, - combatState: Combat, - sendChangeCombatMode: mode => - _liveSession?.SendChangeCombatMode(mode), - isComponentPack: magicCatalog.IsComponentPack, - placeInBackpack: SendPickUp, - backpackContainerId: () => - _retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId - ?? _playerServerGuid, - // Retail ItemHolder::DetermineUseResult treats children of the - // current ground object as loot. Keep this late-bound because - // ViewContents can replace/close the external container while - // the retained controller remains alive. - groundObjectId: () => _externalContainers.CurrentContainerId, - sendSplitToWorld: (item, amount) => - _liveSession?.SendStackableSplitTo3D(item, amount), - selectedObjectId: () => _selection.SelectedObjectId ?? 0u, - stackSplitQuantity: _stackSplitQuantity, - systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), - sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement), - sendSplitToContainer: (item, container, placement, amount) => - _liveSession?.SendStackableSplitToContainer( - item, container, placement, amount), - requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, // Retail UpdateCursorState (0x00564630) keys target-mode @@ -2780,6 +2767,18 @@ public sealed class GameWindow : IDisposable return (sphere.Origin, sphere.Radius); } }); + if (_itemInteractionController is { } itemInteractions) + { + _selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController( + _selection, + _worldSelectionQuery, + itemInteractions, + new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport( + () => _liveSession), + new AcDream.App.Interaction.PlayerInteractionMovementSink( + () => _playerController), + text => _debugVm?.AddToast(text)); + } // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; @@ -3020,10 +3019,13 @@ public sealed class GameWindow : IDisposable Objects.Clear(); SpellBook.Clear(); _magicRuntime?.Reset(); - _itemInteractionController?.ResetSession(); - _selection.Reset(); - _outboundInteractions.Clear(); - _pendingPostArrivalAction = null; + if (_selectionInteractions is { } interactions) + interactions.ResetSession(); + else + { + _itemInteractionController?.ResetSession(); + _selection.Reset(); + } _retailSelectionScene?.Reset(); _particleVisibility.Reset(); try @@ -5493,16 +5495,12 @@ public sealed class GameWindow : IDisposable private void ClearTargetForHiddenEntity(uint serverGuid) { - if (_pendingPostArrivalAction is { Guid: var pendingGuid } - && pendingGuid == serverGuid) - _pendingPostArrivalAction = null; - - if (_selection.SelectedObjectId == serverGuid) - { + if (_selectionInteractions is { } interactions) + interactions.OnEntityHidden(serverGuid); + else if (_selection.SelectedObjectId == serverGuid) _selection.Clear( AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeReason.Cleared); - } if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost) && hiddenHost is EntityPhysicsHost hiddenEntityHost) @@ -5531,11 +5529,18 @@ public sealed class GameWindow : IDisposable () => _remoteTeleportController?.Forget(record), () => { - if (_pendingPostArrivalAction is { Guid: var pendingGuid } - && pendingGuid == serverGuid - && _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId) + bool replacementExists = + _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true + && !ReferenceEquals(current, record); + if (_selectionInteractions is { } interactions) { - _pendingPostArrivalAction = null; + interactions.OnEntityRemoved(record, replacementExists); + } + else if (!replacementExists && _selection.SelectedObjectId == serverGuid) + { + _selection.Clear( + AcDream.Core.Selection.SelectionChangeSource.System, + AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); } }, }; @@ -5599,16 +5604,6 @@ public sealed class GameWindow : IDisposable // incarnation. cleanups.Add(() => incarnation.RunIfNoReplacement( () => _remoteLastMove.Remove(serverGuid))); - cleanups.Add(() => incarnation.RunIfNoReplacement(() => - { - if (_selection.SelectedObjectId == serverGuid) - { - _selection.Clear( - AcDream.Core.Selection.SelectionChangeSource.System, - AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved); - } - })); - cleanups.Add(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188 cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); // Logical teardown ends the retained shadow payload too. The weaker @@ -5948,7 +5943,7 @@ public sealed class GameWindow : IDisposable // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled - && IsDoorName(LiveName(update.Guid))) + && IsDoorName(Objects.Get(update.Guid)?.Name)) { Console.WriteLine(System.FormattableString.Invariant( $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); @@ -11495,7 +11490,7 @@ public sealed class GameWindow : IDisposable // active server MoveTo chain when it receives MoveToState, so sending // Use directly from Silk's key callback inverted that order and left // the client walking to a corpse whose server callback was cancelled. - _outboundInteractions.Drain(); + _selectionInteractions?.DrainOutbound(); _scriptRunner?.PublishTime(_physicsScriptGameTime); System.Numerics.Vector3? playerPosition = _entitiesByServerGuid.TryGetValue( @@ -13503,6 +13498,9 @@ public sealed class GameWindow : IDisposable if (_retailUiRuntime?.HandleInputAction(action) == true) return; + if (_selectionInteractions?.HandleInputAction(action) == true) + return; + // K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press // Q to start, press Q again to stop. Pressing Backup / Stop / // StrafeLeft / StrafeRight while autorun is active also cancels it @@ -13599,38 +13597,10 @@ public sealed class GameWindow : IDisposable _settingsPanel.IsVisible = !_settingsPanel.IsVisible; break; - case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster: - SelectClosestCombatTarget(showToast: true); - break; - - case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection: - _selection.SelectPrevious(); - break; - case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: ToggleLiveCombatMode(); break; - case AcDream.UI.Abstractions.Input.InputAction.SelectLeft: - PickAndStoreSelection(useImmediately: false); - break; - - case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft: - PickAndStoreSelection(useImmediately: true); - break; - - case AcDream.UI.Abstractions.Input.InputAction.UseSelected: - UseCurrentSelection(); - break; - - case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: - if (_selection.SelectedObjectId is uint pickupTarget) - _outboundInteractions.Enqueue(() => - _itemInteractionController?.PlaceWorldItemInBackpack(pickupTarget)); - else - _debugVm?.AddToast("Nothing selected"); - break; - case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: if (_itemInteractionController?.IsAnyTargetModeActive == true) _itemInteractionController.CancelTargetMode(); @@ -13757,15 +13727,8 @@ public sealed class GameWindow : IDisposable result); private uint? GetSelectedOrClosestCombatTarget() - { - if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected)) - return selected; - - if (!_persistedGameplay.AutoTarget) - return null; - - return SelectClosestCombatTarget(showToast: false); - } + => _selectionInteractions?.GetSelectedOrClosestCombatTarget( + _persistedGameplay.AutoTarget); /// /// Resolves retail's combat-camera target. ClientCombatSystem:: @@ -13798,450 +13761,44 @@ public sealed class GameWindow : IDisposable /// pick the local player while plain selection excludes it. /// private uint? PickWorldGuidAtCursor(bool includeSelf) - => _worldSelectionQuery?.PickAtCursor(includeSelf); + => _selectionInteractions?.PickAtCursor(includeSelf) + ?? _worldSelectionQuery?.PickAtCursor(includeSelf); private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf) - => _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); + => _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf) + ?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf); private void BeginSelectionLightingPulse(uint serverGuid) => _worldSelectionQuery?.BeginLightingPulse(serverGuid); - private void PickAndStoreSelection(bool useImmediately) - { - if (_cameraController is null || _window is null) return; - - // Target-use mode picks WITH self (retail: kit-heal yourself by - // clicking your own toon); plain selection keeps the self-exclusion. - var picked = PickWorldGuidAtCursor( - includeSelf: _itemInteractionController?.IsAnyTargetModeActive == true); - - if (picked is uint guid) - { - // Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound - // @ 0x004E5AD0 pulses every successfully found click before it - // branches into select/use/targeted-use behavior. - BeginSelectionLightingPulse(guid); - - if (_itemInteractionController?.OfferPrimaryClick(guid) - is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive) - return; - - _selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World); - string label = DescribeLiveEntity(guid); - Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); - // B.7 (2026-05-15): one-shot per-pick diagnostic so we can - // see exactly which ItemType + PWD bitfield bits + resolved - // RadarBlipColor are produced for the just-picked entity. - // Helps verify whether a "green NPC" really is flagged as - // Vendor server-side or whether our lookup is wrong. - uint rawItemType = (uint)LiveItemType(guid); - uint pwdBits = 0; - uint? pickUseability = null; - float? pickUseRadius = null; - float pickScale = 1f; - uint? pickSetupId = null; - if (LastSpawns.TryGetValue(guid, out var spawn)) - { - if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf; - pickUseability = spawn.Useability; - pickUseRadius = spawn.UseRadius; - pickScale = spawn.ObjScale ?? 1f; - pickSetupId = spawn.SetupTableId; - } - var col = AcDream.Core.Ui.RadarBlipColors.For(rawItemType, pwdBits); - string useStr = pickUseability.HasValue ? $"0x{pickUseability.Value:X4}" : "null"; - string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null"; - string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null"; - Console.WriteLine(System.FormattableString.Invariant( - $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})")); - _debugVm?.AddToast($"Selected: {label}"); - if (useImmediately) - { - // Route world double-click through the same retail - // ItemHolder policy as keyboard Use. This both establishes - // ClientUISystem::groundObject for containers and preserves - // movement-release-before-Use wire ordering. - _outboundInteractions.Enqueue(() => - _itemInteractionController?.ActivateItem(guid)); - } - } - else - { - if (_itemInteractionController?.IsAnyTargetModeActive == true) - return; - _debugVm?.AddToast("Nothing to select"); - } - } - - private void UseCurrentSelection() - { - if (_selection.SelectedObjectId is not uint sel) - { - _debugVm?.AddToast("Nothing selected"); - return; - } - - // Retail sends keyboard Use through ItemHolder::UseObject @ 0x00588A80, - // the same policy owner used by the toolbar. Keeping a second classifier here - // made BF_CORPSE containers look pickupable and sent PutItemInContainer, - // which ACE correctly rejected as Stuck (0x0029). - uint selected = sel; - _outboundInteractions.Enqueue(() => - _itemInteractionController?.UseSelectedOrEnterMode(selected)); - } - - private void SendUse(uint guid) - { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - { - _debugVm?.AddToast("Not in world"); - return; - } - - // Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455 - // ItemUses::IsUseable). Signs / banners with useability=0 silently - // ignore Use. - if (!IsUseableTarget(guid)) - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}"); - return; - } - - // B.6/R4-V5: install a speculative local TurnToObject/MoveToObject - // through the player's MoveToManager so close-range Use rotates the - // body to face before the action fires. For FAR targets, ACE's - // CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound - // MovementType=6, whose PerformMovement re-targets with ACE's - // wire-supplied radius. - // - // Close-range deferral fires the wire packet ONCE on - // MoveToComplete(None) (turn-first done), not a retry of an - // earlier failed send. No re-send path. - if (_worldSelectionQuery?.TryGetInteractionTarget( - guid, - out AcDream.App.Interaction.WorldInteractionTarget useTarget) != true) - { - return; - } - - bool closeRange = IsCloseRangeTarget(guid); - InstallSpeculativeTurnToTarget(guid); - - if (closeRange) - { - // Defer the wire packet — OnAutoWalkArrivedSendDeferredAction - // will fire it after rotation completes. - _pendingPostArrivalAction = new PendingPostArrivalAction( - guid, - useTarget.LocalEntityId, - IsPickup: false); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}"); - return; - } - - // Far range: send Use ONCE. ACE's CreateMoveToChain - // (Player_Use.cs:205) holds a callback (TryUseItem) and fires - // it server-side when WithinUseRadius passes during the - // MoveToChain poll (Player_Move.cs:150). No client-side retry - // needed — the Phase B.6 MoveToState-suppression fix - // (GameWindow.cs:6410) keeps ACE's chain alive during the - // walk. - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}"); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string label = DescribeLiveEntity(guid); - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}")); - } - } - - // Phase D.5.1 — direct use-by-guid for toolbar shortcut clicks. - // Mirrors the B.4b far-range send path; no proximity / auto-walk needed - // for items already in the player's inventory. + // Contained/toolbar activation is not a world-selection interaction: it + // sends directly without speculative TurnToObject/MoveToObject. private void UseItemByGuid(uint guid) { if (_liveSession is null || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) return; - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); + uint sequence = _liveSession.NextGameActionSequence(); + _liveSession.SendGameAction( + AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid)); + Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}"); } - private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) - { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - { - _debugVm?.AddToast("Not in world"); - return; - } - - // Creature-pickup block (with retail toast). Comes BEFORE the - // generic IsPickupableTarget gate so creatures get the specific - // "cannot pick up creatures!" message instead of the generic - // "can't be picked up!". - // Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4). - if (IsLiveCreatureTarget(itemGuid)) - { - _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.5] SendPickUp ignored — creature item=0x{itemGuid:X8}"); - return; - } - - // Generic non-pickupable gate (signs, banners, decorative scenery). - // Retail string acclient_2013_pseudo_c.txt:401589 (sprintf - // "The %s can't be picked up!"). - if (!IsPickupableTarget(itemGuid)) - { - string label = DescribeLiveEntity(itemGuid); - _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label)); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}"); - return; - } - - // B.6 (2026-05-15): same speculative turn-to-target + deferral as - // SendUse — close-range pickup rotates locally to face the - // item first, then the wire packet fires when the local - // overlay reports arrival. - // - // 2026-05-16: simplified — FIRST send on arrival, not a retry. - if (_worldSelectionQuery?.TryGetInteractionTarget( - itemGuid, - out AcDream.App.Interaction.WorldInteractionTarget pickupTarget) != true) - { - return; - } - - bool closeRange = IsCloseRangeTarget(itemGuid); - InstallSpeculativeTurnToTarget(itemGuid); - - if (closeRange) - { - _pendingPostArrivalAction = new PendingPostArrivalAction( - itemGuid, - pickupTarget.LocalEntityId, - IsPickup: true, - destinationContainerId, - placement); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}"); - return; - } - - // Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain - // callback pattern as SendUse — ACE's chain fires - // PutItemInContainer/Move server-side when in range. No - // client-side retry; Phase B.6 MoveToState suppression keeps - // ACE's chain alive. - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( - seq, itemGuid, destinationContainerId, placement); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={seq}"); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string label = DescribeLiveEntity(itemGuid); - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}")); - } - } - - /// - /// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action - /// once the player's moveto completes naturally (the MoveToManager's - /// MoveToComplete(None) client seam — the body has finished - /// rotating to face / walking to the target; a user-input cancel never - /// fires it). This is a FIRST send — not a retry of an earlier failed - /// send. Far-range Use/PickUp paths fire the wire packet immediately at - /// / time and never touch - /// _pendingPostArrivalAction. - /// - private void OnAutoWalkArrivedSendDeferredAction() - { - if (_pendingPostArrivalAction is not { } pending) - return; - _pendingPostArrivalAction = null; - - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - return; - if (_worldSelectionQuery?.IsCurrent( - pending.Guid, - pending.LocalEntityId) != true) - { - return; - } - - var seq = _liveSession.NextGameActionSequence(); - if (pending.IsPickup) - { - var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp( - seq, - pending.Guid, - pending.DestinationContainerId, - pending.Placement); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.5] pickup-deferred item=0x{pending.Guid:X8} container=0x{pending.DestinationContainerId:X8} placement={pending.Placement} seq={seq}"); - } - else - { - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, pending.Guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[B.4b] use-deferred guid=0x{pending.Guid:X8} seq={seq}"); - } - } - - /// - /// B.6 (2026-05-15). Install a local auto-walk overlay against the - /// given target entity at Use / PickUp send time. ACE's response - /// branches by distance: - /// - /// Far target → ACE broadcasts a MovementType=6 - /// MoveToObject which arrives shortly after and overwrites - /// our speculative state with ACE's wire-supplied use-radius - /// and origin. No conflict; same target either way. - /// Close target → ACE skips MoveToChain (WithinUseRadius - /// shortcut at Player_Move.cs:66) and rotates the body - /// server-side via Rotate(target). ACE doesn't broadcast - /// anything actionable to us, so our pre-installed overlay - /// handles the local rotation — body turns to face the target - /// in place, then ends. - /// - /// - /// Per-type use radius mirrors the picker's radius heuristic: - /// 3 m for creatures, 2 m for doors / lifestones / portals, - /// 0.6 m for everything else (ground items). - /// - /// - /// - /// B.6 (2026-05-15). True if the local player is already inside the - /// target's use radius right now — i.e. ACE will NOT auto-walk us - /// when we send the action. Used to gate the close-range deferral - /// in SendUse / SendPickUp: when close, we hold the wire packet - /// until our local rotation overlay reports alignment, then fire. - /// - private bool IsCloseRangeTarget(uint targetGuid) - => _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true - && approach.IsCloseRange; - private bool IsWithinExternalContainerUseRange(uint targetGuid) => _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true; - private void InstallSpeculativeTurnToTarget(uint targetGuid) - { - if (_playerController is not { } pc - || pc.MoveTo is null - || _worldSelectionQuery?.TryGetApproach( - targetGuid, - out AcDream.App.Interaction.InteractionApproach approach) != true) - return; + private void SendUse(uint guid) + => _selectionInteractions?.SendUse(guid); - // Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit - // from local distance so the speculative moveto uses the - // same walk/run as the wire-triggered overwrite that arrives - // moments later. ACE's Creature.SetWalkRunThreshold sets - // CanCharge when player→target distance >= WalkRunThreshold / - // 2 = 7.5 m (the 15 m wire default halved). Match exactly so - // the speculative install doesn't flip walk↔run when ACE's - // MoveToObject broadcast overwrites it (PerformMovement - // cancels + restarts — retail-consistent re-target). - // R4-V5: retail's client-initiated use flow issues TurnToObject / - // MoveToObject through the SAME manager the wire path uses (decomp - // §9a/§9b callers) — in-range targets get a pure turn-to-face; - // out-of-range targets get the local moveto that ACE's mt-6 - // broadcast will re-target moments later. MovementParameters ctor - // defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15, - // MinDistance 0) match the old BeginServerAutoWalk install's - // semantics; only the AP-23 radius + the #77 CanCharge prediction - // are non-default. - var p = new AcDream.Core.Physics.Motion.MovementParameters - { - DistanceToObject = approach.UseRadius, - CanCharge = approach.CanCharge, - }; - var ms = new AcDream.Core.Physics.MovementStruct - { - ObjectId = targetGuid, - TopLevelId = targetGuid, - Pos = new AcDream.Core.Physics.Position( - approach.Player.CellId, approach.Target.Entity.Position, - System.Numerics.Quaternion.Identity), - Params = p, - Type = approach.IsCloseRange - ? AcDream.Core.Physics.MovementType.TurnToObject - : AcDream.Core.Physics.MovementType.MoveToObject, - }; - // R5-V3 (#171, diff-review find): retail resolves the TARGET's - // PartArray radius/height at EVERY MoveToObject call site — the - // client-initiated use flow included (CPhysicsObj::MoveToObject - // 0x005128e9/0x00512903), not just the wire mt-6 route. Without - // this, the player's now-real own radius made the UseSpheres - // arrival hybrid (centerDist − playerRadius ≤ useRadius) — the - // auto-walk stopped ~one player-radius farther out than the AP-23 - // constants intended, and the two MoveToObject sites disagreed. - (ms.Radius, ms.Height) = (approach.TargetRadius, approach.TargetHeight); - // Part of this install's AP-23 adaptation: store the autonomy flag - // exactly as the wire mt-6 this install anticipates would (the P1 - // unpack store, IsAutonomous=false) — without it the per-tick - // pump's A3 dispatch stays on the raw branch (the user's last input - // set it autonomous) and clobbers this moveto's dispatched motions - // with the idle raw state. - _playerController.SetLastMoveWasAutonomous(false); - // R5-V5: through the facade (MovementManager::PerformMovement - // 0x005240d0) — retail's client-initiated use flow reaches the same - // manager the wire path uses. - pc.Movement.PerformMovement(ms); - } + private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement) + => _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement); + + private void OnAutoWalkArrivedSendDeferredAction() + => _selectionInteractions?.OnNaturalMoveToComplete(); private uint? SelectClosestCombatTarget(bool showToast) - { - AcDream.App.Interaction.ClosestCombatTarget? closest = - _worldSelectionQuery?.FindClosestHostileMonster(); - uint? bestGuid = closest?.ServerGuid; + => _selectionInteractions?.SelectClosestCombatTarget(showToast); - if (bestGuid is { } selection) - _selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard); - else - _selection.Clear(AcDream.Core.Selection.SelectionChangeSource.Keyboard); - if (bestGuid is { } selected) - { - string label = DescribeLiveEntity(selected); - float distance = MathF.Sqrt(closest!.Value.DistanceSquared); - Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}"); - if (showToast) - _debugVm?.AddToast($"Target {label}"); - } - else if (showToast) - { - _debugVm?.AddToast("No monster target"); - Console.WriteLine("combat: no creature target found"); - } - - return bestGuid; - } - - private bool IsLiveCreatureTarget(uint guid) - => _worldSelectionQuery?.IsCreature(guid) == true; - - private bool IsLiveHostileMonsterTarget(uint guid) - => _worldSelectionQuery?.IsHostileMonster(guid) == true; - - /// - /// True if the selected-object strip should show a Health meter for . - /// Exact projection of retail's IsPlayer() || pet_owner || - /// ClientCombatSystem::ObjectIsAttackable() gate from - /// gmToolbarUI::HandleSelectionChanged @ 0x004BF380. - /// private bool IsHealthBarTarget(uint guid) => _worldSelectionQuery?.ShouldShowHealth(guid) == true; @@ -14288,91 +13845,6 @@ public sealed class GameWindow : IDisposable /// why our previous mesh-AABB indicator was smaller than retail's. /// /// - private bool TryGetEntitySelectionSphere(uint guid, - out System.Numerics.Vector3 worldCenter, - out float worldRadius) - { - if (_worldSelectionQuery is { } query) - return query.TryGetSelectionSphere(guid, out worldCenter, out worldRadius); - worldCenter = default; - worldRadius = 0f; - return false; - } - - /// - /// 2026-05-15. Retail-faithful gate for R-key Use / F-key PickUp. - /// Returns true when the entity is interactable from the world per - /// the server-supplied ITEM_USEABLE _useability field - /// (acclient.h:6478) — specifically when the USEABLE_REMOTE - /// (0x20) bit is set OR a composite form containing it - /// (USEABLE_REMOTE_NEVER_WALK = 0x60, the - /// SOURCE_X_TARGET_REMOTE variants in the 0x200000+ range). - /// - /// - /// Retail behaviour for non-useable entities (signs, banners, - /// decorative scenery): the R-key does nothing — no walk, no Use - /// packet, no toast. The retail client checks useability before - /// any action and silently ignores the press. We honor that with - /// a silent early return at the call site. - /// - /// - /// - /// Fallback when useability is unknown. The wire's - /// weenieFlags & 0x10 bit gates whether ACE serializes - /// useability at all. If absent, - /// is null. Conservatively we permit Use for entities we've - /// historically been able to interact with — creatures, doors, - /// lifestones, portals, corpses — to avoid regressing the existing - /// M1 flows. Pure-scenery untyped entities (the sign case) fall - /// through to "blocked". - /// - /// - private bool IsUseableTarget(uint guid) - => _worldSelectionQuery?.IsUseable(guid) == true; - - /// - /// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based. - /// - /// - /// The earlier (useability & USEABLE_REMOTE) != 0u check - /// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE - /// action ("can the player activate this item from the world"); - /// PICKUP is a separate action governed by retail's - /// PutItemInContainer handler, which accepts any small-item-class - /// entity from the world regardless of useability bits. A spell - /// component with useability=USEABLE_NO=1 is still pickupable in - /// retail — USEABLE_NO blocks using the component (you can't - /// "activate" it standalone), not picking it up. - /// - /// - /// - /// Now matches retail: a non-Stuck small-item ItemType class - /// → pickupable. Server validates the request server-side - /// (in-range, target-still-exists, container-has-room). - /// - /// - private bool IsPickupableTarget(uint guid) - => _worldSelectionQuery?.IsPickupable(guid) == true; - - private AcDream.Core.Items.ItemType LiveItemType(uint guid) => - _worldSelectionQuery?.GetItemType(guid) ?? AcDream.Core.Items.ItemType.None; - - private string? LiveName(uint guid) => Objects.Get(guid)?.Name; - - private string DescribeLiveEntity(uint guid) - { - return _worldSelectionQuery?.Describe(guid) ?? $"0x{guid:X8}"; - } - - /// - /// K.1b: Tab handler extracted into a method so the dispatcher - /// subscriber can call it. Same body as the previous Tab branch in - /// the legacy direct keyboard handler — toggle player↔fly mode, set - /// up + - /// when entering player mode, tear them down on exit. - /// K.2: also disarms the auto-entry trigger when the user toggles - /// manually (their choice wins). - /// private void TogglePlayerMode() { // Phase B.2 guard: only active when a live session is in-world.