refactor(interaction): cut GameWindow over to controller

Route selection input, frame-ordered dispatch, movement completion, hidden/delete cleanup, and session reset through SelectionInteractionController. Keep ItemInteractionController backend-independent by constructing it outside the retained-UI gate.
This commit is contained in:
Erik 2026-07-21 07:26:02 +02:00
parent fa8d5232cc
commit d2bb5af453

View file

@ -45,6 +45,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene; private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery; private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws /// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary> /// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
@ -183,7 +184,6 @@ public sealed class GameWindow : IDisposable
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; 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.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
@ -1162,20 +1162,6 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns => private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap; _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 // R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, registered in _physicsHosts so remote // TargetManager voyeur system, registered in _physicsHosts so remote
// entities chasing the player resolve it. Replaces the AP-79 // entities chasing the player resolve it. Replaces the AP-79
@ -2114,12 +2100,6 @@ public sealed class GameWindow : IDisposable
Objects, Objects,
guid => _liveSession?.SendNoLongerViewingContents(guid)); guid => _liveSession?.SendNoLongerViewingContents(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!; AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController( _itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects, Objects,
@ -2171,6 +2151,13 @@ public sealed class GameWindow : IDisposable
_liveSession?.SendStackableSplitToContainer( _liveSession?.SendStackableSplitToContainer(
item, container, placement, amount), item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); 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;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController, _itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode // Retail UpdateCursorState (0x00564630) keys target-mode
@ -2780,6 +2767,18 @@ public sealed class GameWindow : IDisposable
return (sphere.Origin, sphere.Radius); 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. // A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -3020,10 +3019,13 @@ public sealed class GameWindow : IDisposable
Objects.Clear(); Objects.Clear();
SpellBook.Clear(); SpellBook.Clear();
_magicRuntime?.Reset(); _magicRuntime?.Reset();
if (_selectionInteractions is { } interactions)
interactions.ResetSession();
else
{
_itemInteractionController?.ResetSession(); _itemInteractionController?.ResetSession();
_selection.Reset(); _selection.Reset();
_outboundInteractions.Clear(); }
_pendingPostArrivalAction = null;
_retailSelectionScene?.Reset(); _retailSelectionScene?.Reset();
_particleVisibility.Reset(); _particleVisibility.Reset();
try try
@ -5493,16 +5495,12 @@ public sealed class GameWindow : IDisposable
private void ClearTargetForHiddenEntity(uint serverGuid) private void ClearTargetForHiddenEntity(uint serverGuid)
{ {
if (_pendingPostArrivalAction is { Guid: var pendingGuid } if (_selectionInteractions is { } interactions)
&& pendingGuid == serverGuid) interactions.OnEntityHidden(serverGuid);
_pendingPostArrivalAction = null; else if (_selection.SelectedObjectId == serverGuid)
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear( _selection.Clear(
AcDream.Core.Selection.SelectionChangeSource.System, AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.Cleared); AcDream.Core.Selection.SelectionChangeReason.Cleared);
}
if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost) if (_physicsHosts.TryGetValue(serverGuid, out var hiddenHost)
&& hiddenHost is EntityPhysicsHost hiddenEntityHost) && hiddenHost is EntityPhysicsHost hiddenEntityHost)
@ -5531,11 +5529,18 @@ public sealed class GameWindow : IDisposable
() => _remoteTeleportController?.Forget(record), () => _remoteTeleportController?.Forget(record),
() => () =>
{ {
if (_pendingPostArrivalAction is { Guid: var pendingGuid } bool replacementExists =
&& pendingGuid == serverGuid _liveEntities?.TryGetRecord(serverGuid, out LiveEntityRecord current) == true
&& _pendingPostArrivalAction.Value.LocalEntityId == record.LocalEntityId) && !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. // incarnation.
cleanups.Add(() => incarnation.RunIfNoReplacement( cleanups.Add(() => incarnation.RunIfNoReplacement(
() => _remoteLastMove.Remove(serverGuid))); () => _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(() => _translucencyFades.ClearEntity(existingEntity.Id)); // #188
cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record)); cleanups.Add(() => LeaveWorldLiveEntityRuntimeComponents(record));
// Logical teardown ends the retained shadow payload too. The weaker // 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. // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& IsDoorName(LiveName(update.Guid))) && IsDoorName(Objects.Get(update.Guid)?.Name))
{ {
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); $"[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 // active server MoveTo chain when it receives MoveToState, so sending
// Use directly from Silk's key callback inverted that order and left // Use directly from Silk's key callback inverted that order and left
// the client walking to a corpse whose server callback was cancelled. // the client walking to a corpse whose server callback was cancelled.
_outboundInteractions.Drain(); _selectionInteractions?.DrainOutbound();
_scriptRunner?.PublishTime(_physicsScriptGameTime); _scriptRunner?.PublishTime(_physicsScriptGameTime);
System.Numerics.Vector3? playerPosition = System.Numerics.Vector3? playerPosition =
_entitiesByServerGuid.TryGetValue( _entitiesByServerGuid.TryGetValue(
@ -13503,6 +13498,9 @@ public sealed class GameWindow : IDisposable
if (_retailUiRuntime?.HandleInputAction(action) == true) if (_retailUiRuntime?.HandleInputAction(action) == true)
return; return;
if (_selectionInteractions?.HandleInputAction(action) == true)
return;
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press // 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 / // Q to start, press Q again to stop. Pressing Backup / Stop /
// StrafeLeft / StrafeRight while autorun is active also cancels it // StrafeLeft / StrafeRight while autorun is active also cancels it
@ -13599,38 +13597,10 @@ public sealed class GameWindow : IDisposable
_settingsPanel.IsVisible = !_settingsPanel.IsVisible; _settingsPanel.IsVisible = !_settingsPanel.IsVisible;
break; 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: case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode(); ToggleLiveCombatMode();
break; 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: case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_itemInteractionController?.IsAnyTargetModeActive == true) if (_itemInteractionController?.IsAnyTargetModeActive == true)
_itemInteractionController.CancelTargetMode(); _itemInteractionController.CancelTargetMode();
@ -13757,15 +13727,8 @@ public sealed class GameWindow : IDisposable
result); result);
private uint? GetSelectedOrClosestCombatTarget() private uint? GetSelectedOrClosestCombatTarget()
{ => _selectionInteractions?.GetSelectedOrClosestCombatTarget(
if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected)) _persistedGameplay.AutoTarget);
return selected;
if (!_persistedGameplay.AutoTarget)
return null;
return SelectClosestCombatTarget(showToast: false);
}
/// <summary> /// <summary>
/// Resolves retail's combat-camera target. ClientCombatSystem:: /// 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. /// pick the local player while plain selection excludes it.
/// </summary> /// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf) private uint? PickWorldGuidAtCursor(bool includeSelf)
=> _worldSelectionQuery?.PickAtCursor(includeSelf); => _selectionInteractions?.PickAtCursor(includeSelf)
?? _worldSelectionQuery?.PickAtCursor(includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool 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) private void BeginSelectionLightingPulse(uint serverGuid)
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid); => _worldSelectionQuery?.BeginLightingPulse(serverGuid);
private void PickAndStoreSelection(bool useImmediately) // Contained/toolbar activation is not a world-selection interaction: it
{ // sends directly without speculative TurnToObject/MoveToObject.
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.
private void UseItemByGuid(uint guid) private void UseItemByGuid(uint guid)
{ {
if (_liveSession is null if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return; return;
var seq = _liveSession.NextGameActionSequence(); uint sequence = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); _liveSession.SendGameAction(
_liveSession.SendGameAction(body); AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); 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}"));
}
}
/// <summary>
/// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action
/// once the player's moveto completes naturally (the MoveToManager's
/// <c>MoveToComplete(None)</c> 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
/// <see cref="SendUse"/>/<see cref="SendPickUp"/> time and never touch
/// <c>_pendingPostArrivalAction</c>.
/// </summary>
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}");
}
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item>Far target → ACE broadcasts a <c>MovementType=6</c>
/// 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.</item>
/// <item>Close target → ACE skips MoveToChain (WithinUseRadius
/// shortcut at <c>Player_Move.cs:66</c>) and rotates the body
/// server-side via <c>Rotate(target)</c>. 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.</item>
/// </list>
/// <para>
/// 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).
/// </para>
/// </summary>
/// <summary>
/// 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.
/// </summary>
private bool IsCloseRangeTarget(uint targetGuid)
=> _worldSelectionQuery?.TryGetApproach(targetGuid, out var approach) == true
&& approach.IsCloseRange;
private bool IsWithinExternalContainerUseRange(uint targetGuid) private bool IsWithinExternalContainerUseRange(uint targetGuid)
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true; => _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
private void InstallSpeculativeTurnToTarget(uint targetGuid) private void SendUse(uint guid)
{ => _selectionInteractions?.SendUse(guid);
if (_playerController is not { } pc
|| pc.MoveTo is null
|| _worldSelectionQuery?.TryGetApproach(
targetGuid,
out AcDream.App.Interaction.InteractionApproach approach) != true)
return;
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
// from local distance so the speculative moveto uses the => _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
// same walk/run as the wire-triggered overwrite that arrives
// moments later. ACE's Creature.SetWalkRunThreshold sets private void OnAutoWalkArrivedSendDeferredAction()
// CanCharge when player→target distance >= WalkRunThreshold / => _selectionInteractions?.OnNaturalMoveToComplete();
// 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 uint? SelectClosestCombatTarget(bool showToast) private uint? SelectClosestCombatTarget(bool showToast)
{ => _selectionInteractions?.SelectClosestCombatTarget(showToast);
AcDream.App.Interaction.ClosestCombatTarget? closest =
_worldSelectionQuery?.FindClosestHostileMonster();
uint? bestGuid = closest?.ServerGuid;
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;
/// <summary>
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
/// Exact projection of retail's <c>IsPlayer() || pet_owner ||
/// ClientCombatSystem::ObjectIsAttackable()</c> gate from
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>.
/// </summary>
private bool IsHealthBarTarget(uint guid) private bool IsHealthBarTarget(uint guid)
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true; => _worldSelectionQuery?.ShouldShowHealth(guid) == true;
@ -14288,91 +13845,6 @@ public sealed class GameWindow : IDisposable
/// why our previous mesh-AABB indicator was smaller than retail's. /// why our previous mesh-AABB indicator was smaller than retail's.
/// </para> /// </para>
/// </summary> /// </summary>
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;
}
/// <summary>
/// 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 <c>ITEM_USEABLE _useability</c> field
/// (acclient.h:6478) — specifically when the <c>USEABLE_REMOTE
/// (0x20)</c> bit is set OR a composite form containing it
/// (<c>USEABLE_REMOTE_NEVER_WALK = 0x60</c>, the
/// <c>SOURCE_X_TARGET_REMOTE</c> variants in the 0x200000+ range).
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// <b>Fallback when useability is unknown.</b> The wire's
/// <c>weenieFlags &amp; 0x10</c> bit gates whether ACE serializes
/// useability at all. If absent, <see cref="CreateObject.Parsed.Useability"/>
/// 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".
/// </para>
/// </summary>
private bool IsUseableTarget(uint guid)
=> _worldSelectionQuery?.IsUseable(guid) == true;
/// <summary>
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
///
/// <para>
/// The earlier <c>(useability &amp; USEABLE_REMOTE) != 0u</c> 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.
/// </para>
///
/// <para>
/// 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).
/// </para>
/// </summary>
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}";
}
/// <summary>
/// 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 <see cref="PlayerMovementController"/> + <see cref="ChaseCamera"/>
/// 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).
/// </summary>
private void TogglePlayerMode() private void TogglePlayerMode()
{ {
// Phase B.2 guard: only active when a live session is in-world. // Phase B.2 guard: only active when a live session is in-world.