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.