Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Both chains pinned by the live [vendor-diag] run (vendor-diag.log) after three code-reading rounds each failed: The split bar: ACE serializes descStackSize=1 for EVERY browse row (live wire, log 343-348) — the R1-era "ACE never populates desc" claim is retracted with the line quoted. Retail's vendor sites read pwd._maxStackSize directly (four sites, incl. UpdateItemsList @0x004c1ea0 stamping min(remaining, _maxStackSize)); ResolveAuthoredStackSize flips to max-first for its vendor-only consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt. Pricing still reads the desc (per-1 values on ACE). Walk-to-use: the local player's getObjectA seam was bound to TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a never-animated vendor has none, so TargetManager.SetTarget got null, the MoveToObject armed with zero nodes, and UseTime never dispatched. The log's natural=False completions were the user's own movement keys (retail-correct input-edge cancels); attempt 4 worked because the greeting animation had installed a host. RuntimePhysicsState gains the retail CObjectMaint::GetObjectA seam (bound canonical resolver with installed-host fallback); the graphical host binds the SAME lazy-minimal-host resolver every remote already uses — whose own doc comment names this exact never-animated hazard. The reservation release was already correct (2b premise refuted with evidence); the production-wiring invariants are now pinned by four new tests including the pre-fix pathology as a permanent sabotage control. AP-169 rewritten a second time, honestly. The [vendor-diag] probe family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage. Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
873 lines
34 KiB
C#
873 lines
34 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)
|
|
{
|
|
uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive);
|
|
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()
|
|
{
|
|
uint? picked = _query.PickAtCursor(includeSelf: false);
|
|
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) => 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;
|
|
}
|