acdream/src/AcDream.App/Interaction/SelectionInteractionController.cs
Erik 72ceddce2e fix: social panel completion batch (user gate 2026-08-13, "fix all")
One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):

1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
   can't change any options"): the authored checkboxes carry DAT
   ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
   handlers read the flipped value and wrote the ORIGINAL back, snapping
   every click to where it started (the probe recorded (id, oldValue)).
   Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
   next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
   name text is display-text ClickThrough=true, which the hit-test walk
   skips regardless of HandlesClick - the wired OnClick was unreachable.
   Fix: UiText.OnClick assignment now clears ClickThrough (central,
   documented); the stats text gains the same select handler so most of
   the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
   the authored string resolves COMPLETE (three sentences) but embedded
   '\n's rendered as one clipped line. DatWidgetFactory now splits
   authored strings into one Line per newline, with the provider still
   re-reading DefaultColor live (the state-color contract - caught by
   BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
   selection-blue tints are deleted; names always white (register row
   narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
   (user-specified format; the full retail StringInfo composition stays
   AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
   0x0018, retail clears the box - Request_AddFriend @0x0048D240),
   Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
   0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
   to your friend-of list), Squelch Character/Account add-by-name
   (0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
   row. The wire beneath (builders, WorldSession sends, Runtime commands,
   parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
   (docs/research/2026-08-13-social-wire-completion.md, committed here).
   Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
   retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
   every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
   unconditionally) - the includeSelf gate was an unregistered
   divergence, now removed on both the left-click and right-click paths.

Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).

Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).

Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:30:51 +02:00

882 lines
35 KiB
C#

using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Interaction;
/// <summary>
/// Owns world selection mutations and one-shot Use/PickUp interaction state.
/// Read-only classification stays in <see cref="WorldSelectionQuery"/>;
/// retained item policy stays in <see cref="ItemInteractionController"/>.
/// </summary>
internal sealed class SelectionInteractionController
{
private readonly SelectionState _selection;
private readonly IWorldSelectionQuery _query;
private readonly ItemInteractionController _items;
private readonly RuntimeInteractionTransactionState _transactions;
private readonly IRuntimeInteractionTransport _transport;
private readonly IPlayerInteractionMovementSink _movement;
private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast;
public SelectionInteractionController(
SelectionState selection,
IWorldSelectionQuery query,
ItemInteractionController items,
IRuntimeInteractionTransport transport,
IPlayerInteractionMovementSink movement,
Action<string>? toast = null,
PlayerApproachCompletionState? approachCompletions = null)
{
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query));
_items = items ?? throw new ArgumentNullException(nameof(items));
_transactions = _items.RuntimeTransactions;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_toast = toast;
_approachCompletions = approachCompletions
?? new PlayerApproachCompletionState();
}
public bool HandleInputAction(InputAction action)
{
switch (action)
{
case InputAction.SelectionClosestMonster:
SelectClosestCombatTarget(showToast: true);
return true;
case InputAction.SelectionPreviousSelection:
_selection.SelectPrevious();
return true;
case InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
return true;
case InputAction.SelectRight:
PickSelectAndExamine();
return true;
case InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
return true;
case InputAction.SelectionExamine:
_items.ExamineSelectedOrEnterMode(
_selection.SelectedObjectId ?? 0u);
return true;
case InputAction.UseSelected:
UseCurrentSelection();
return true;
case InputAction.SelectionPickUp:
if (_selection.SelectedObjectId is uint pickupTarget)
{
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Pickup,
pickupTarget,
requireLiveEntity: true);
}
else
{
_toast?.Invoke("Nothing selected");
}
return true;
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
_items.CancelTargetMode();
return true;
default:
return false;
}
}
public uint? PickAtCursor(bool includeSelf)
=> _query.PickAtCursor(includeSelf);
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
{
ArgumentNullException.ThrowIfNull(payload);
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
if (target != 0u)
_query.BeginLightingPulse(target);
_items.PlaceIn3D(payload, target);
}
/// <summary>
/// Explicit selection uses the wider <c>ObjectIsAttackable</c>-backed
/// admission (#298) so a compatible-PK player is a valid attack target;
/// falling back to auto-acquisition still uses the narrower
/// <see cref="IWorldSelectionQuery.IsHostileMonster"/> monster-only
/// policy (register row IA-19).
/// </summary>
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{
if (_selection.SelectedObjectId is { } selected
&& _query.IsAttackableTarget(selected))
{
return selected;
}
return autoTarget ? SelectClosestCombatTarget(showToast: false) : null;
}
public uint? SelectClosestCombatTarget(bool showToast)
{
ClosestCombatTarget? closest = _query.FindClosestHostileMonster();
uint? bestGuid = closest?.ServerGuid;
if (bestGuid is { } selected)
_selection.Select(selected, SelectionChangeSource.Keyboard);
else
_selection.Clear(SelectionChangeSource.Keyboard);
if (bestGuid is { } guid)
{
string label = _query.Describe(guid);
float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}");
if (showToast)
_toast?.Invoke($"Target {label}");
}
else if (showToast)
{
_toast?.Invoke("No monster target");
Console.WriteLine("combat: no creature target found");
}
return bestGuid;
}
public void PickAndStoreSelection(bool useImmediately)
{
// 2026-08-13 gate fix ("when I click on my own char it should select
// myself"): retail has NO self-exclusion anywhere in this chain —
// CPhysicsPart::Draw @0x0050D823 arms every physobj for the pick and
// RecvNotice_SmartBoxObjectFound @0x004E5BAE selects unconditionally
// (docs/research/2026-08-13-social-wire-completion.md §9/§10). The
// old includeSelf gate (target-mode-only) was an unregistered
// divergence, now removed.
uint? picked = _query.PickAtCursor(includeSelf: true);
if (picked is not uint guid)
{
if (!_items.IsAnyTargetModeActive)
_toast?.Invoke("Nothing to select");
return;
}
// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @ 0x004E5AD0 pulses before select/use/target-mode branching.
_query.BeginLightingPulse(guid);
if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive)
return;
_selection.Select(guid, SelectionChangeSource.World);
string label = _query.Describe(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
_toast?.Invoke($"Selected: {label}");
// The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0
// guards ItemHolder::UseObject with
// `if (found->pwd._wielderID != SmartBox::player_id)` at 0x004E5BE9.
// Clicking your own wielded weapon selects and flashes it but sends no
// Use. Equipped-child picking makes that click reachable, so the gate
// ships with it.
if (useImmediately && !_query.IsWieldedByPlayer(guid))
{
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate");
}
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Activate,
guid,
requireLiveEntity: true);
}
}
/// <summary>
/// Retail SmartBox right-click path:
/// <c>UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820</c> chooses
/// <c>sr_Examine</c>, then
/// <c>RecvNotice_SmartBoxObjectFound @ 0x004E5AD0</c> pulses, selects,
/// and calls <c>ClientUISystem::ExamineObject</c>. Empty space is a no-op,
/// and this path does not consume a left-click target mode.
/// </summary>
public void PickSelectAndExamine()
{
// Same self-inclusion as PickAndStoreSelection above — retail
// right-click examines yourself too.
uint? picked = _query.PickAtCursor(includeSelf: true);
if (picked is not uint guid)
return;
_query.BeginLightingPulse(guid);
_selection.Select(guid, SelectionChangeSource.World);
_items.ExamineSelectedOrEnterMode(guid);
}
public void UseCurrentSelection()
{
if (_selection.SelectedObjectId is not uint selected)
{
_toast?.Invoke("Nothing selected");
return;
}
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use");
}
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Use,
selected,
requireLiveEntity: false);
}
public void SendUse(uint serverGuid)
=> RequestUse(serverGuid, reservation: null);
/// <summary>
/// G3 (grand-gate finding, 2026-08-08, register AP-170): an out-of-range
/// Use no longer sends the wire request immediately — it arms on the
/// SAME arrival-gated shape <see cref="SendPickup"/>'s close-range
/// (turn-only) branch already uses, dispatching only once the approach
/// naturally completes.
/// <para>
/// <b>Why this deviates from retail's own literal
/// <c>ItemHolder::UseObject @ 0x00588A80</c> send-immediately shape.</b>
/// Retail's REAL server walks the player itself before the target's
/// <c>ActOnUse</c> handler ever sees the request — the client is free to
/// fire immediately because the server-side arrival gate is invisible to
/// it. ACE does not do this for a player-initiated Use: live testing
/// against the user's local ACE server (2026-08-08) showed a vendor
/// approached from out of range plays its cosmetic greeting (a
/// distance-only reaction independent of the Use action) but never opens
/// the shop panel — <c>ApproachVendor</c> never arrives. ACE's own
/// <c>Player.HandleActionUseItem</c> (<c>Player_Use.cs:176-215</c>)
/// confirms why: an out-of-range target routes through
/// <c>CreateMoveToChain(item, (success) =&gt; TryUseItem(item, success))</c>
/// (<c>Player_Move.cs:37-96</c>), which POLLS every 0.1s for the player
/// to reach <c>WithinUseRadius</c> and only then calls
/// <c>TryUseItem</c>/<c>ActOnUse</c> — it does not teleport or
/// server-move the player; it waits for the client's own walk to land.
/// Sending the wire Use before OUR client has actually arrived races
/// that poll and can lose. Retail's client-side immediacy assumption
/// (this method's ORIGINAL design, see the register) does not hold
/// against this server; arming on arrival closes the gap by construction
/// instead of racing it.
/// </para>
/// <para>
/// F11 (Slice 6b/6c review, preserved): the eligibility test
/// (<c>ownedByPlayer || useable</c>) is still computed ONCE, up front,
/// before any approach or arm — an ineligible target never kicks off a
/// wasted walk.
/// </para>
/// </summary>
public void RequestUse(
uint serverGuid,
ItemUseRequestReservation? reservation)
{
CancelPendingApproach();
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}");
}
if (useable
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
&& !approach.IsCloseRange)
{
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}");
}
// Genuinely out of range (a real walk, not just a turn) —
// mirror SendPickup's arrival-gated shape: arm the transaction
// on the approach token BEFORE the movement starts (so a
// synchronously-completing approach can't race the arm), then
// let HandleApproachCompletion dispatch on natural arrival.
bool armed = false;
bool started = _movement.BeginApproach(
approach,
token =>
{
armed = _transactions.TryArmPostArrivalUse(
serverGuid,
ownedByPlayer,
useable,
reservation,
new RuntimeInteractionApproachToken(
token.ControllerLifetime,
token.ApproachGeneration),
out _);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})");
}
});
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}");
}
if (!started || !armed)
{
// Release whatever got captured (or the caller's own
// reservation, if arming never stored it) — mirrors
// SendPickup's !started/!armed cleanup shape.
if (_transactions.TryCancelPendingUse(
serverGuid, out RuntimePendingUse cancelled))
{
cancelled.Reservation?.CancelBeforeDispatch();
}
else
{
reservation?.CancelBeforeDispatch();
}
}
return;
}
// Already in range (a turn at most, or no approach concept applies)
// — keep retail's immediate send; ACE's own "already within use
// distance" branch (Player_Move.cs:65-87) calls back synchronously,
// so there is no arrival gap to race here.
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}");
}
RuntimeInteractionDispatchResult result =
_transactions.TryDispatchUse(
serverGuid,
ownedByPlayer,
useable,
reservation,
_transport,
out uint sequence);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}");
}
if (result == RuntimeInteractionDispatchResult.NotInWorld)
_toast?.Invoke("Not in world");
if (result == RuntimeInteractionDispatchResult.Dispatched)
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
}
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
{
CancelPendingApproach();
if (!_transport.IsInWorld)
{
_toast?.Invoke("Not in world");
CancelPickupPresentation(itemGuid);
return;
}
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
itemGuid,
out PendingBackpackPlacement pendingPlacement)
? pendingPlacement.Token
: 0u;
if (!IsCurrentPickupPresentation(
itemGuid,
destinationContainerId,
placement,
pendingPlacementToken))
{
return;
}
// Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 delegates
// current-ground-object contents straight to
// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or
// chest child has no independent 3-D projection to approach.
//
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records the
// IR_PICK_UP world request only for PositionState.IN_3D_VIEW and
// treats WIELDED as a plain IR_PUT_IN_CONTAINER transfer, so the
// player's own wielded item is unwielded in place with no approach.
// The ownership conjunct keeps this shortcut behind IsItemLegal's
// 0x005872B7 arm, which ValidatePickupTarget enforces below: a wielded
// item that is not the player's must never reach a wire request.
if (_items.IsInCurrentGroundObject(itemGuid)
|| (_query.IsWieldedPositionState(itemGuid)
&& _items.IsOwnedByPlayer(itemGuid)))
{
var contained = new RuntimePendingPickup(
Token: 0u,
itemGuid,
LocalEntityId: 0u,
destinationContainerId,
placement,
pendingPlacementToken,
ApproachToken: default);
if (_transactions.TryDispatchPickup(
contained,
_transport,
out uint containedSequence))
{
Console.WriteLine(
$"[B.5] contained pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={containedSequence}");
}
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
return;
}
if (!ValidatePickupTarget(itemGuid, showToast: true)
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
return;
}
if (approach.IsCloseRange)
{
bool armed = false;
bool started = _movement.BeginApproach(
approach,
token =>
{
armed = _transactions.TryArmPostArrivalPickup(
itemGuid,
approach.Target.LocalEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
new RuntimeInteractionApproachToken(
token.ControllerLifetime,
token.ApproachGeneration),
out _);
});
if (!started || !armed)
{
if (_transactions.TryCancelPendingPickup(
itemGuid,
approach.Target.LocalEntityId,
out RuntimePendingPickup cancelled))
{
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
}
return;
}
_movement.BeginApproach(approach);
if (!IsCurrentPickupPresentation(
itemGuid,
destinationContainerId,
placement,
pendingPlacementToken))
{
return;
}
var immediate = new RuntimePendingPickup(
Token: 0u,
itemGuid,
approach.Target.LocalEntityId,
destinationContainerId,
placement,
pendingPlacementToken,
ApproachToken: default);
if (_transactions.TryDispatchPickup(
immediate,
_transport,
out uint sequence))
{
Console.WriteLine(
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
}
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
}
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
public void OnNaturalMoveToComplete()
{
if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pendingPickup))
{
HandleApproachCompletion(pendingPickup.ApproachToken, natural: true);
return;
}
// G3: at most one of {pendingPickup, pendingUse} is ever armed —
// CancelPendingApproach() clears any prior one before a new
// SendPickup/RequestUse arms another.
if (_transactions.TryGetPendingUse(out RuntimePendingUse pendingUse))
HandleApproachCompletion(pendingUse.ApproachToken, natural: true);
}
private void HandleApproachCompletion(
RuntimeInteractionApproachToken approachToken,
bool natural)
{
bool pickupAccepted = _transactions.TryResolveApproachCompletion(
approachToken,
natural,
out RuntimePendingPickup pendingPickup);
if (pendingPickup.Token != 0u)
{
HandlePickupApproachCompletion(pendingPickup, pickupAccepted);
return;
}
bool useAccepted = _transactions.TryResolveUseApproachCompletion(
approachToken,
natural,
out RuntimePendingUse pendingUse);
if (pendingUse.Token != 0u)
HandleUseApproachCompletion(pendingUse, useAccepted);
}
private void HandlePickupApproachCompletion(
RuntimePendingPickup pending,
bool accepted)
{
if (!accepted)
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return;
}
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return;
}
if (!IsCurrentPickupPresentation(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
pending.PendingPlacementToken))
{
return;
}
if (!_transactions.TryDispatchPickup(
pending,
_transport,
out _))
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
}
/// <summary>
/// G3: dispatches an armed Use on natural arrival. A cancelled approach
/// (<paramref name="accepted"/> false — supersede/move-away) releases
/// the reservation directly; <see cref="RuntimeInteractionTransactionState.TryDispatchUse"/>
/// already resolves the reservation on every one of its own outcomes
/// (dispatched or rejected), so no separate release is needed past that
/// point.
/// </summary>
private void HandleUseApproachCompletion(
RuntimePendingUse pending,
bool accepted)
{
if (VendorDiagnostics.DumpVendorEnabled)
{
// Diagnostic-only re-query — TryGetApproach is a pure read with
// no side effects, so an extra call here (gated off in
// production) cannot change RequestUse's own dispatch outcome.
string distanceText = "n/a";
if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach))
{
float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X;
float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y;
distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2");
}
Console.WriteLine(
$"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}");
}
if (!accepted)
{
pending.Reservation?.CancelBeforeDispatch();
return;
}
RuntimeInteractionDispatchResult result =
_transactions.TryDispatchUse(
pending.ServerGuid,
pending.OwnedByPlayer,
pending.Useable,
pending.Reservation,
_transport,
out uint sequence);
if (result == RuntimeInteractionDispatchResult.NotInWorld)
_toast?.Invoke("Not in world");
if (result == RuntimeInteractionDispatchResult.Dispatched)
{
Console.WriteLine(
$"[B.4b] use guid=0x{pending.ServerGuid:X8} seq={sequence} (arrival-gated)");
}
}
public void DrainOutbound()
{
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
{
HandleApproachCompletion(
new RuntimeInteractionApproachToken(
completion.Token.ControllerLifetime,
completion.Token.ApproachGeneration),
completion.IsNatural);
}
_transactions.DrainOutbound(DispatchQueuedInteraction);
}
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
public void OnEntityHidden(uint serverGuid)
{
_transactions.CancelQueuedInteractions(serverGuid);
if (_transactions.TryCancelPendingPickup(
serverGuid,
localEntityId: null,
out RuntimePendingPickup cancelled))
{
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
// G3: an armed out-of-range Use whose target vanished must release
// its reservation too — the approach it was waiting on will never
// naturally complete against a hidden target.
if (_transactions.TryCancelPendingUse(serverGuid, out RuntimePendingUse cancelledUse))
cancelledUse.Reservation?.CancelBeforeDispatch();
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.Cleared);
}
}
public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists)
{
ArgumentNullException.ThrowIfNull(record);
_transactions.CancelQueuedInteractions(
record.ServerGuid,
record.LocalEntityId);
if (_transactions.TryCancelPendingPickup(
record.ServerGuid,
record.LocalEntityId,
out RuntimePendingPickup cancelled))
{
CancelPickupPresentation(
cancelled.ServerGuid,
cancelled.PendingPlacementToken);
}
// G3: same as OnEntityHidden — a removed target's armed Use must
// not linger waiting for an approach that can never complete.
if (_transactions.TryCancelPendingUse(record.ServerGuid, out RuntimePendingUse cancelledUse))
cancelledUse.Reservation?.CancelBeforeDispatch();
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
}
public void ResetSession()
{
List<Exception> failures = [];
try { CancelPendingApproach(); }
catch (Exception error) { failures.Add(error); }
try { _items.ResetSession(); }
catch (Exception error) { failures.Add(error); }
try { _selection.Reset(); }
catch (Exception error) { failures.Add(error); }
try { _approachCompletions.Clear(); }
catch (Exception error) { failures.Add(error); }
if (failures.Count != 0)
throw new AggregateException(
"One or more selection-interaction reset stages failed.",
failures);
}
/// <summary>
/// Retires only App approach and retained item-interaction presentation.
/// Runtime resets the canonical interaction and selection owners once at
/// the shared generation boundary.
/// </summary>
internal void ResetGenerationPresentation()
{
List<Exception> failures = [];
try { CancelPendingApproach(); }
catch (Exception error) { failures.Add(error); }
try { _items.ResetGenerationPresentation(); }
catch (Exception error) { failures.Add(error); }
try { _approachCompletions.Clear(); }
catch (Exception error) { failures.Add(error); }
if (failures.Count != 0)
{
throw new AggregateException(
"One or more selection-interaction presentation reset stages failed.",
failures);
}
}
/// <summary>
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c> runs
/// <c>AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c> first, at
/// <c>0x00588173</c> — ahead of container legality, auto-merge, the
/// container walk, and the only <c>CM_Inventory::Event_PutItemInContainer</c>
/// emitter (<c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c>).
/// A rejection is therefore one local
/// <c>ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)</c> and nothing else:
/// no wire request and no movement.
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> then withdraws the
/// waiting slot it had published (<c>SetWaitingState(obj, 0)</c> plus
/// <c>CM_Item::SendNotice_EndPendingInPlayer</c> at <c>0x0055D918</c>),
/// which is what a <c>false</c> return drives here.
/// </summary>
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
{
if (_query.IsCreature(serverGuid))
{
if (showToast)
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
return false;
}
// IsItemLegal's arm at 0x005872B7 rejects
// `!ACCWeenieObject::IsOwnedByPlayer(item) && item->pwd._location != 0`
// with the notice at 0x005872DB. Retail runs it after the stuck arm at
// 0x00587240; the two are disjoint, because the stuck arm fires only
// when _containerID and _wielderID are both zero while this one needs a
// wielded _location, so acdream's fused stuck/type predicate may follow.
// Equipped-child picking made a remote character's wielded item
// selectable, which is what makes this arm reachable.
if (_query.IsWieldedPositionState(serverGuid)
&& !_items.IsOwnedByPlayer(serverGuid))
{
if (showToast)
{
_toast?.Invoke(RetailMessages.BeingWieldedBySomeoneElse(
_query.Describe(serverGuid)));
}
return false;
}
if (_query.IsPickupable(serverGuid))
return true;
if (showToast)
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
return false;
}
private bool EnqueueIdentityBound(
RuntimeQueuedInteractionKind kind,
uint serverGuid,
bool requireLiveEntity)
{
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
? localId
: null;
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
? captured
: null;
if ((requireLiveEntity && localEntityId is null)
|| (localEntityId is null && item is null))
{
return false;
}
var identity = new RuntimeInteractionIdentity(
serverGuid,
localEntityId,
item);
_transactions.Enqueue(new RuntimeQueuedInteraction(kind, identity));
return true;
}
private bool IsCurrent(RuntimeInteractionIdentity identity)
=> (identity.LocalEntityId is not uint localId
|| _query.IsCurrent(identity.ServerGuid, localId))
&& (identity.ClientObject is not { } item
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
private void CancelPendingApproach()
{
if (_transactions.TryCancelPendingPickup(
out RuntimePendingPickup pending))
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
// G3: a new SendPickup/RequestUse supersedes whatever approach was
// previously armed — release an in-flight Use's reservation too, not
// just pickup's presentation token.
if (_transactions.TryCancelPendingUse(out RuntimePendingUse pendingUse))
pendingUse.Reservation?.CancelBeforeDispatch();
}
private void DispatchQueuedInteraction(
RuntimeQueuedInteraction interaction)
{
RuntimeInteractionIdentity identity = interaction.Identity;
if (!IsCurrent(identity))
return;
switch (interaction.Kind)
{
case RuntimeQueuedInteractionKind.Activate:
_items.ActivateItem(identity.ServerGuid);
break;
case RuntimeQueuedInteractionKind.Use:
_items.UseSelectedOrEnterMode(identity.ServerGuid);
break;
case RuntimeQueuedInteractionKind.Pickup:
if (ValidatePickupTarget(identity.ServerGuid, showToast: true))
_items.PlaceWorldItemInBackpack(identity.ServerGuid);
break;
default:
throw new InvalidOperationException(
$"Unknown queued interaction kind {interaction.Kind}.");
}
}
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
private bool IsCurrentPickupPresentation(
uint itemGuid,
uint destinationContainerId,
int placement,
ulong token)
=> token != 0u
&& _items.TryGetPendingBackpackPlacement(
itemGuid,
out PendingBackpackPlacement pending)
&& pending.Token == token
&& pending.ContainerId == destinationContainerId
&& pending.Placement == placement;
}