This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1334 lines
50 KiB
C#
1334 lines
50 KiB
C#
using System;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Result of offering a primary click to active item-target mode.</summary>
|
|
public enum ItemPrimaryClickResult
|
|
{
|
|
NotActive,
|
|
ConsumedSuccess,
|
|
ConsumedRejected,
|
|
}
|
|
|
|
public readonly record struct PendingBackpackPlacement(
|
|
ulong Token,
|
|
uint ItemId,
|
|
uint ContainerId,
|
|
int Placement,
|
|
ClientObject? ItemIdentity);
|
|
|
|
/// <summary>
|
|
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
|
|
/// target acquisition, and drag-out drops here instead of duplicating
|
|
/// ItemHolder::UseObject fragments in each panel.
|
|
/// </summary>
|
|
public sealed class ItemInteractionController : IDisposable
|
|
{
|
|
internal const string InventoryRequestBusyMessage =
|
|
"You can only move or use one item at a time";
|
|
private const long RetailDoubleClickMs = 500;
|
|
|
|
private readonly ClientObjectTable _objects;
|
|
private readonly Func<uint> _playerGuid;
|
|
private readonly Func<long> _nowMs;
|
|
private readonly Action<uint>? _sendUse;
|
|
private readonly Action<uint>? _sendExamine;
|
|
private readonly Action<uint, uint>? _sendUseWithTarget;
|
|
private readonly Action<uint, uint>? _sendWield;
|
|
private readonly Action<uint>? _sendDrop;
|
|
private readonly Action<uint, uint>? _sendSplitToWorld;
|
|
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
|
|
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
|
|
private readonly Action<uint, uint, uint>? _sendGive;
|
|
private readonly Action<string>? _toast;
|
|
private readonly Func<bool> _readyForInventoryRequest;
|
|
private readonly Func<uint> _activeVendorId;
|
|
private readonly Func<uint> _groundObjectId;
|
|
private readonly Func<bool> _playerOnGround;
|
|
private readonly Func<bool> _inNonCombatMode;
|
|
private readonly Func<uint, bool> _isComponentPack;
|
|
private readonly Action<uint, uint, int>? _placeInBackpack;
|
|
private readonly Func<uint> _backpackContainerId;
|
|
private readonly Action<uint>? _requestExternalContainer;
|
|
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
|
private readonly InteractionState _interactionState;
|
|
private readonly Func<uint> _selectedObjectId;
|
|
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
|
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
|
|
private readonly Action<string>? _systemMessage;
|
|
private readonly AutoWieldController _autoWield;
|
|
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
|
|
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
|
private readonly InventoryTransactionState _transactions;
|
|
|
|
private uint _consumedPrimaryClickTarget;
|
|
private long _consumedPrimaryClickMs = long.MinValue / 2;
|
|
private PendingBackpackPlacement? _pendingBackpackPlacement;
|
|
private bool _disposed;
|
|
|
|
public ItemInteractionController(
|
|
ClientObjectTable objects,
|
|
RuntimeInteractionTransactionState runtimeTransactions,
|
|
InteractionState interactionState,
|
|
Func<uint> playerGuid,
|
|
Action<uint>? sendUse,
|
|
Action<uint, uint>? sendUseWithTarget,
|
|
Action<uint, uint>? sendWield,
|
|
Action<uint>? sendDrop,
|
|
Action<uint>? sendExamine = null,
|
|
Func<long>? nowMs = null,
|
|
Action<string>? toast = null,
|
|
Func<bool>? readyForInventoryRequest = null,
|
|
Func<uint>? activeVendorId = null,
|
|
Func<uint>? groundObjectId = null,
|
|
Func<bool>? playerOnGround = null,
|
|
Func<bool>? inNonCombatMode = null,
|
|
Func<uint, bool>? isComponentPack = null,
|
|
Action<uint, uint, int>? placeInBackpack = null,
|
|
Func<uint>? backpackContainerId = null,
|
|
Action<ItemPolicyAction>? auxiliaryAction = null,
|
|
Action<uint, uint>? sendSplitToWorld = null,
|
|
Func<uint>? selectedObjectId = null,
|
|
StackSplitQuantityState? stackSplitQuantity = null,
|
|
Action<uint, uint, int>? sendPutItemInContainer = null,
|
|
Action<uint, uint, uint>? sendGive = null,
|
|
Func<bool>? dragOnPlayerOpensSecureTrade = null,
|
|
Action<string>? systemMessage = null,
|
|
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
|
|
Action<uint>? requestExternalContainer = null,
|
|
CombatState? combatState = null,
|
|
Action<CombatMode>? sendChangeCombatMode = null,
|
|
Action<uint, ItemUseRequestReservation>? requestUse = null)
|
|
{
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
|
_sendUse = sendUse;
|
|
_sendExamine = sendExamine;
|
|
_sendUseWithTarget = sendUseWithTarget;
|
|
_sendWield = sendWield;
|
|
_sendDrop = sendDrop;
|
|
_sendSplitToWorld = sendSplitToWorld;
|
|
_sendPutItemInContainer = sendPutItemInContainer;
|
|
_sendSplitToContainer = sendSplitToContainer;
|
|
_sendGive = sendGive;
|
|
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
|
_toast = toast;
|
|
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
|
|
_activeVendorId = activeVendorId ?? (() => 0u);
|
|
_groundObjectId = groundObjectId ?? (() => 0u);
|
|
_playerOnGround = playerOnGround ?? (() => true);
|
|
_inNonCombatMode = inNonCombatMode ?? (() => false);
|
|
_isComponentPack = isComponentPack ?? (_ => false);
|
|
_placeInBackpack = placeInBackpack;
|
|
_backpackContainerId = backpackContainerId ?? _playerGuid;
|
|
_requestExternalContainer = requestExternalContainer;
|
|
_auxiliaryAction = auxiliaryAction;
|
|
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
|
_stackSplitQuantity = stackSplitQuantity;
|
|
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
|
|
_systemMessage = systemMessage;
|
|
_requestUse = requestUse;
|
|
_interactionState = interactionState
|
|
?? throw new ArgumentNullException(nameof(interactionState));
|
|
_runtimeTransactions = runtimeTransactions
|
|
?? throw new ArgumentNullException(nameof(runtimeTransactions));
|
|
_transactions = _runtimeTransactions.Inventory;
|
|
if (!ReferenceEquals(_transactions.Objects, _objects))
|
|
{
|
|
throw new ArgumentException(
|
|
"The inventory transaction owner must borrow the controller's exact object table.",
|
|
nameof(runtimeTransactions));
|
|
}
|
|
_autoWield = new AutoWieldController(
|
|
_objects,
|
|
_playerGuid,
|
|
_sendWield,
|
|
sendPutItemInContainer,
|
|
_toast,
|
|
_systemMessage,
|
|
combatState,
|
|
sendChangeCombatMode);
|
|
_interactionState.Changed += OnInteractionModeChanged;
|
|
_transactions.StateChanged += OnTransactionStateChanged;
|
|
_transactions.RequestCompleted += OnInventoryRequestCompleted;
|
|
_transactions.ObjectTableCleared += OnInventoryObjectsCleared;
|
|
}
|
|
|
|
public event Action? StateChanged;
|
|
|
|
/// <summary>
|
|
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: the inventory
|
|
/// panel inserts a waiting projection before the pickup request is sent.
|
|
/// The destination and placement are the same values sent on the wire.
|
|
/// </summary>
|
|
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
|
|
|
|
/// <summary>
|
|
/// Withdraws a waiting pickup projection when the approach is rejected or
|
|
/// cancelled before a request reaches the server.
|
|
/// </summary>
|
|
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
|
|
|
|
/// <summary>
|
|
/// Resolves the exact waiting projection after an authoritative response.
|
|
/// The token prevents a late response for a recycled GUID from erasing a
|
|
/// newer incarnation's projection in the inventory panel.
|
|
/// </summary>
|
|
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
|
|
|
|
/// <summary>
|
|
/// Publishes the exact split-to-world request after its wire send has
|
|
/// succeeded. Retail <c>ACCWeenieObject::UIAttemptSplitTo3D @
|
|
/// 0x0058D850</c> retains the source class, selected amount, and request
|
|
/// time so the subsequently created ground stack can be recognized.
|
|
/// </summary>
|
|
public event Action<WorldDropDispatch>? WorldDropDispatched;
|
|
|
|
public uint PlayerGuid => _playerGuid();
|
|
|
|
public InteractionState InteractionState => _interactionState;
|
|
public RuntimeInteractionTransactionState RuntimeTransactions =>
|
|
_runtimeTransactions;
|
|
|
|
public int BusyCount => _transactions.BusyCount;
|
|
public uint CurrentAppraisalId =>
|
|
_runtimeTransactions.CurrentAppraisalId;
|
|
|
|
public bool CanMakeInventoryRequest =>
|
|
BaseCanMakeInventoryRequest && !_transactions.HasPendingRequest;
|
|
|
|
private bool BaseCanMakeInventoryRequest =>
|
|
_readyForInventoryRequest()
|
|
&& _transactions.BusyCount == 0
|
|
&& !_autoWield.IsBusy;
|
|
|
|
/// <summary>
|
|
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
|
|
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
|
|
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
|
|
/// </summary>
|
|
public bool EnsureInventoryRequestReady()
|
|
{
|
|
if (CanMakeInventoryRequest)
|
|
return true;
|
|
|
|
if (_transactions.HasPendingRequest
|
|
|| _transactions.BusyCount != 0
|
|
|| _autoWield.IsBusy)
|
|
_systemMessage?.Invoke(InventoryRequestBusyMessage);
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
|
|
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
|
|
/// list which already displays the waiting projection.
|
|
/// </summary>
|
|
public void ReportPendingBackpackPlacementConflict()
|
|
{
|
|
if (_pendingBackpackPlacement is not { } pending)
|
|
return;
|
|
string? itemName = _objects.Get(pending.ItemId)?.Name;
|
|
string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName;
|
|
_systemMessage?.Invoke($"Already attempting to place {name} here");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispatches one retail inventory request. A provisional global
|
|
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
|
|
/// then becomes dispatched immediately after a successful wire send.
|
|
/// The callback returns false when it could not dispatch anything.
|
|
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
|
|
/// <c>0x0058D590..0x0058D850</c>.
|
|
/// </summary>
|
|
public bool TryDispatchInventoryRequest(
|
|
InventoryRequestKind kind,
|
|
uint itemId,
|
|
Func<bool> dispatch,
|
|
ulong reservationToken = 0u)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dispatch);
|
|
if (itemId == 0u)
|
|
return false;
|
|
|
|
if (reservationToken != 0u)
|
|
{
|
|
try
|
|
{
|
|
return _transactions.TryDispatch(
|
|
kind,
|
|
itemId,
|
|
dispatch,
|
|
reservationToken);
|
|
}
|
|
catch
|
|
{
|
|
CancelPendingBackpackPlacement(itemId, reservationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
return _transactions.TryDispatch(kind, itemId, dispatch);
|
|
}
|
|
|
|
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
|
|
=> _transactions.TryGetPending(out pending);
|
|
|
|
/// <summary>
|
|
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
|
/// request issued by another retained controller has been sent. The
|
|
/// corresponding UseDone releases it. Retail spell casts complete through
|
|
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
|
|
/// </summary>
|
|
public void IncrementBusyCount()
|
|
=> _runtimeTransactions.IncrementBusyCount();
|
|
|
|
/// <summary>
|
|
/// Raised for retail confirmation/auxiliary actions whose retained panel is
|
|
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
|
|
/// </summary>
|
|
public event Action<ItemPolicyAction>? PolicyActionRequested;
|
|
|
|
public uint PendingSourceItem
|
|
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
|
|
? mode.SourceObjectId
|
|
: 0u;
|
|
|
|
public bool IsTargetModeActive
|
|
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
|
|
|
|
public bool IsAnyTargetModeActive
|
|
=> _interactionState.Current.Kind != InteractionModeKind.None;
|
|
|
|
public bool IsPendingSource(uint itemGuid)
|
|
=> itemGuid != 0 && itemGuid == PendingSourceItem;
|
|
|
|
/// <summary>
|
|
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
|
/// toolbar shortcut creation.
|
|
/// </summary>
|
|
public bool IsOwnedByPlayer(uint itemGuid)
|
|
=> _objects.IsOwnedByObject(itemGuid, _playerGuid());
|
|
|
|
public bool IsCurrentTargetCompatible(uint targetGuid)
|
|
{
|
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
|
|
|
var source = _objects.Get(PendingSourceItem);
|
|
return source?.Useability is not null
|
|
&& TargetCompatible(source, targetGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The single target-mode interception point for world and retained item
|
|
/// clicks. A rejected target is still consumed and must never fall through
|
|
/// to selection, container opening, or ordinary item activation.
|
|
/// </summary>
|
|
public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid)
|
|
{
|
|
InteractionModeKind mode = _interactionState.Current.Kind;
|
|
if (mode == InteractionModeKind.None)
|
|
{
|
|
long now = _nowMs();
|
|
if (targetGuid != 0
|
|
&& targetGuid == _consumedPrimaryClickTarget
|
|
&& now - _consumedPrimaryClickMs <= RetailDoubleClickMs)
|
|
return ItemPrimaryClickResult.ConsumedRejected;
|
|
_consumedPrimaryClickTarget = 0;
|
|
return ItemPrimaryClickResult.NotActive;
|
|
}
|
|
|
|
bool accepted;
|
|
switch (mode)
|
|
{
|
|
case InteractionModeKind.Use:
|
|
ClearTargetMode();
|
|
accepted = ActivateItem(targetGuid);
|
|
break;
|
|
case InteractionModeKind.Examine:
|
|
ClearTargetMode();
|
|
accepted = targetGuid != 0 && _sendExamine is not null;
|
|
if (accepted)
|
|
RequestAppraisal(targetGuid);
|
|
break;
|
|
case InteractionModeKind.UseItemOnTarget:
|
|
accepted = AcquireTarget(targetGuid);
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Unknown interaction mode {mode}.");
|
|
}
|
|
_consumedPrimaryClickTarget = targetGuid;
|
|
_consumedPrimaryClickMs = _nowMs();
|
|
return accepted
|
|
? ItemPrimaryClickResult.ConsumedSuccess
|
|
: ItemPrimaryClickResult.ConsumedRejected;
|
|
}
|
|
|
|
public ItemPrimaryClickResult OfferSelfPrimaryClick()
|
|
=> OfferPrimaryClick(_playerGuid());
|
|
|
|
/// <summary>
|
|
/// Retail toolbar Use button: use the current selection immediately, or arm
|
|
/// one-shot <c>TARGET_MODE_USE</c> when no object is selected.
|
|
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
|
|
/// </summary>
|
|
public bool UseSelectedOrEnterMode(uint selectedObjectId)
|
|
{
|
|
if (selectedObjectId != 0)
|
|
return ActivateItem(selectedObjectId);
|
|
return _interactionState.EnterUse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail toolbar Examine button: appraise the current selection immediately,
|
|
/// or arm one-shot <c>TARGET_MODE_EXAMINE</c> when no object is selected.
|
|
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
|
|
/// </summary>
|
|
public bool ExamineSelectedOrEnterMode(uint selectedObjectId)
|
|
{
|
|
if (selectedObjectId == 0)
|
|
return _interactionState.EnterExamine();
|
|
if (_sendExamine is null)
|
|
return false;
|
|
RequestAppraisal(selectedObjectId);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0</c>.
|
|
/// One shared UI-busy reference covers the currently pending appraisal,
|
|
/// even when another examine request replaces its GUID before a response.
|
|
/// </summary>
|
|
private void RequestAppraisal(uint objectId)
|
|
{
|
|
if (objectId == 0 || _sendExamine is null)
|
|
return;
|
|
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts only the pending or current appraisal, matching
|
|
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
|
/// </summary>
|
|
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
|
|
{
|
|
bool ownedBusyReference = _transactions.BusyCount > 0;
|
|
RuntimeAppraisalResponseAcceptance acceptance =
|
|
_runtimeTransactions.AcceptAppraisalResponse(objectId);
|
|
if (acceptance.FirstResponse && !ownedBusyReference)
|
|
StateChanged?.Invoke();
|
|
return new AppraisalResponseAcceptance(
|
|
acceptance.Accepted,
|
|
acceptance.FirstResponse);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail combat-time creature/player refresh sends CM_Item directly and
|
|
/// therefore does not acquire a second UI-busy reference.
|
|
/// </summary>
|
|
public bool RefreshCurrentAppraisal()
|
|
{
|
|
if (_sendExamine is null)
|
|
return false;
|
|
return _runtimeTransactions.RefreshCurrentAppraisal(_sendExamine);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Releases only the object-appraisal transaction before the local spell
|
|
/// examination subview takes ownership of the shared floaty window.
|
|
///
|
|
/// Retail <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> decrements the
|
|
/// appraisal busy reference when a response is pending, clears both pending
|
|
/// and current object ids, and sends <c>CM_Item::Event_Appraise(0)</c>. It
|
|
/// does not clear unrelated use/inventory busy references.
|
|
/// </summary>
|
|
public void CancelObjectAppraisalForSpell()
|
|
{
|
|
if (_sendExamine is null)
|
|
return;
|
|
bool ownedBusyReference = _transactions.BusyCount > 0;
|
|
if (_runtimeTransactions.CancelObjectAppraisalForSpell(_sendExamine)
|
|
&& !ownedBusyReference)
|
|
{
|
|
StateChanged?.Invoke();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
|
|
/// from the live selected object's public description. This is deliberately
|
|
/// a read-only query; activation still flows through <see cref="ActivateItem"/>.
|
|
/// </summary>
|
|
public bool IsToolbarUseEnabled(uint selectedObjectId)
|
|
{
|
|
if (selectedObjectId == 0
|
|
|| _objects.Get(selectedObjectId) is not { } item)
|
|
return false;
|
|
|
|
return ItemInteractionPolicy.IsToolbarUseEnabled(
|
|
item.Type,
|
|
item.CombatUse ?? 0,
|
|
item.Useability ?? ItemUseability.Undef);
|
|
}
|
|
|
|
public bool ActivateItem(uint itemGuid)
|
|
{
|
|
if (itemGuid == 0) return false;
|
|
|
|
if (IsTargetModeActive)
|
|
return OfferPrimaryClick(itemGuid) == ItemPrimaryClickResult.ConsumedSuccess;
|
|
|
|
var item = _objects.Get(itemGuid);
|
|
if (item is null) return false;
|
|
|
|
if (!ConsumeUseThrottle())
|
|
return true;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
var input = new ItemUsePolicyInput(
|
|
Snapshot(item),
|
|
_playerGuid(),
|
|
_groundObjectId(),
|
|
CanMakeInventoryRequest,
|
|
_activeVendorId(),
|
|
BypassClassification: false,
|
|
UseCurrentSelection: false,
|
|
SelectedTarget: null,
|
|
ConfirmVolatileRareUses: true,
|
|
InNonCombatMode: _inNonCombatMode());
|
|
var decision = ItemInteractionPolicy.DecideUse(input);
|
|
return ExecuteUseActions(decision.Actions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
|
|
/// publishes the waiting destination slot before issuing the move request,
|
|
/// exactly like double-click pickup through ItemHolder.
|
|
/// </summary>
|
|
public bool PlaceWorldItemInBackpack(uint itemGuid)
|
|
{
|
|
if (itemGuid == 0u || _placeInBackpack is null)
|
|
return false;
|
|
|
|
uint containerId = _backpackContainerId();
|
|
if (containerId == 0u)
|
|
containerId = _playerGuid();
|
|
const int placement = 0;
|
|
if (!TryBeginPendingBackpackPlacement(
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
out _))
|
|
{
|
|
// Retail permits only one inventory request at a time. Consume the
|
|
// input without replacing a pending projection or issuing a wire
|
|
// request while another inventory transaction owns the gate.
|
|
return true;
|
|
}
|
|
// This callback may install a retail MoveToObject approach instead of
|
|
// sending immediately. SelectionInteractionController promotes the
|
|
// reservation to Dispatched only when it actually emits the wire move.
|
|
_placeInBackpack(itemGuid, containerId, placement);
|
|
return true;
|
|
}
|
|
|
|
public bool TryBeginPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
uint containerId,
|
|
int placement,
|
|
out PendingBackpackPlacement pending)
|
|
=> TryBeginPendingBackpackPlacement(
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
InventoryRequestKind.Pickup,
|
|
out pending);
|
|
|
|
private bool TryBeginPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
uint containerId,
|
|
int placement,
|
|
InventoryRequestKind kind,
|
|
out PendingBackpackPlacement pending)
|
|
{
|
|
if (itemGuid == 0u || containerId == 0u)
|
|
{
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
if (_pendingBackpackPlacement is { } existing)
|
|
{
|
|
ReportPendingBackpackPlacementConflict();
|
|
pending = existing;
|
|
return false;
|
|
}
|
|
if (!EnsureInventoryRequestReady())
|
|
{
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
PendingBackpackPlacement candidate = default;
|
|
bool reserved;
|
|
PendingInventoryRequest published;
|
|
try
|
|
{
|
|
reserved = _transactions.TryReserve(
|
|
kind,
|
|
itemGuid,
|
|
out published,
|
|
request =>
|
|
{
|
|
candidate = new PendingBackpackPlacement(
|
|
request.Token,
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
request.ItemIdentity);
|
|
_pendingBackpackPlacement = candidate;
|
|
List<Exception> failures = [];
|
|
DispatchAll(
|
|
PendingBackpackPlacementRequested,
|
|
candidate,
|
|
failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more pending-placement observers failed.",
|
|
failures);
|
|
}
|
|
});
|
|
}
|
|
catch (Exception failure)
|
|
{
|
|
if (candidate.Token == 0u)
|
|
throw;
|
|
|
|
_pendingBackpackPlacement = null;
|
|
_transactions.CancelBeforeDispatch(candidate.Token);
|
|
List<Exception> failures = [failure];
|
|
DispatchAll(
|
|
PendingBackpackPlacementCancelled,
|
|
candidate,
|
|
failures);
|
|
throw new AggregateException(
|
|
"Pending backpack placement publication failed.",
|
|
failures);
|
|
}
|
|
pending = candidate;
|
|
if (!reserved
|
|
|| _pendingBackpackPlacement != candidate
|
|
|| published.Token != candidate.Token
|
|
|| published.ItemId != itemGuid
|
|
|| published.Kind != kind
|
|
|| published.Dispatched)
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes retail's destination waiting projection, dispatches the move,
|
|
/// then records the global request. Failed dispatch withdraws only the
|
|
/// projection created by this call.
|
|
/// </summary>
|
|
public bool TryDispatchPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
uint containerId,
|
|
int placement,
|
|
InventoryRequestKind kind,
|
|
Func<bool> dispatch)
|
|
{
|
|
// Callers such as gmToolbarUI check the global prevRequest before they
|
|
// ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the
|
|
// generic busy wording when both states happen to be present.
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
if (!TryBeginPendingBackpackPlacement(
|
|
itemGuid,
|
|
containerId,
|
|
placement,
|
|
kind,
|
|
out PendingBackpackPlacement pending))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_pendingBackpackPlacement != pending
|
|
|| !_transactions.TryGetPending(out PendingInventoryRequest reserved)
|
|
|| reserved.Token != pending.Token
|
|
|| reserved.ItemId != pending.ItemId
|
|
|| reserved.Kind != kind
|
|
|| reserved.Dispatched)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (TryDispatchInventoryRequest(
|
|
kind,
|
|
itemGuid,
|
|
dispatch,
|
|
pending.Token))
|
|
return true;
|
|
|
|
CancelPendingBackpackPlacement(itemGuid, pending.Token);
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetPendingBackpackPlacement(
|
|
uint itemGuid,
|
|
out PendingBackpackPlacement pending)
|
|
{
|
|
if (_pendingBackpackPlacement is { } current
|
|
&& current.ItemId == itemGuid)
|
|
{
|
|
pending = current;
|
|
return true;
|
|
}
|
|
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u)
|
|
{
|
|
if (_pendingBackpackPlacement is not { } pending
|
|
|| (itemGuid != 0u && pending.ItemId != itemGuid)
|
|
|| (token != 0u && pending.Token != token))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_pendingBackpackPlacement = null;
|
|
_transactions.CancelBeforeDispatch(pending.Token);
|
|
List<Exception> failures = [];
|
|
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more pending-placement cancellation observers failed.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
|
|
{
|
|
item = _objects.Get(objectId)!;
|
|
return item is not null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether the object is a member of retail's current
|
|
/// <c>ClientUISystem::groundObject</c>. Such objects are container
|
|
/// contents, not independently projected world objects, so
|
|
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> sends their
|
|
/// container transfer directly without a 3-D approach lookup.
|
|
/// </summary>
|
|
internal bool IsInCurrentGroundObject(uint objectId)
|
|
{
|
|
uint groundObjectId = _groundObjectId();
|
|
return groundObjectId != 0u
|
|
&& _objects.Get(objectId) is { } item
|
|
&& item.ContainerId == groundObjectId;
|
|
}
|
|
|
|
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
|
|
=> ReferenceEquals(_objects.Get(objectId), item);
|
|
|
|
/// <summary>
|
|
/// Retail paperdoll drop entry point. The paperdoll resolves the exact
|
|
/// target side/location; this shared interaction owner performs AutoWield's
|
|
/// confirmed blocker transaction before sending GetAndWieldItem.
|
|
/// </summary>
|
|
public bool WieldFromPaperdoll(uint itemGuid, EquipMask targetMask)
|
|
{
|
|
if (itemGuid == 0u || targetMask == EquipMask.None)
|
|
return false;
|
|
if (_objects.Get(itemGuid) is not { } item)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
return _autoWield.TryWield(item, targetMask);
|
|
}
|
|
|
|
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
|
|
public void NotifyExplicitCombatModeRequest()
|
|
=> _autoWield.NotifyExplicitCombatModeRequest();
|
|
|
|
public bool AcquireTarget(uint targetGuid)
|
|
{
|
|
if (!IsTargetModeActive || targetGuid == 0) return false;
|
|
|
|
uint sourceGuid = PendingSourceItem;
|
|
ClearTargetMode();
|
|
|
|
var source = _objects.Get(sourceGuid);
|
|
if (source?.Useability is not { } useability)
|
|
return false;
|
|
|
|
bool compatible = TargetCompatible(source, targetGuid);
|
|
var target = _objects.Get(targetGuid);
|
|
Console.WriteLine(
|
|
$"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}"
|
|
+ $" tgt=0x{targetGuid:X8} tgtKind={(target is null ? "none" : $"0x{(uint)target.Type:X8}")}"
|
|
+ $" -> {(compatible ? "SEND UseWithTarget" : "refused")}");
|
|
if (!compatible)
|
|
return false;
|
|
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
_runtimeTransactions.TryDispatchTargetedUse(
|
|
sourceGuid,
|
|
targetGuid,
|
|
_sendUseWithTarget,
|
|
incrementBusy: true);
|
|
return true;
|
|
}
|
|
|
|
public bool AcquireSelfTarget()
|
|
=> IsTargetModeActive && AcquireTarget(_playerGuid());
|
|
|
|
/// <summary>
|
|
/// Completes the positive branch of retail
|
|
/// <c>ClientUISystem::UsageCallback @ 0x00565B20</c>. Confirmation policy has
|
|
/// already consumed the original activation, so acceptance sends the retained
|
|
/// object id and enters the same busy state as an ordinary use.
|
|
/// </summary>
|
|
public bool ExecuteConfirmedUse(uint objectId)
|
|
{
|
|
if (objectId == 0u || (_requestUse is null && _sendUse is null))
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
if (_requestUse is not null)
|
|
{
|
|
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
|
try
|
|
{
|
|
_requestUse(objectId, reservation);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_sendUse!(objectId);
|
|
_runtimeTransactions.IncrementBusyCount();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public void CancelTargetMode()
|
|
{
|
|
if (!IsAnyTargetModeActive) return;
|
|
ClearTargetMode();
|
|
}
|
|
|
|
public bool DropToWorld(ItemDragPayload payload)
|
|
=> PlaceIn3D(payload, targetGuid: 0u);
|
|
|
|
/// <summary>
|
|
/// Retail inventory drag released into SmartBox. The release target is the
|
|
/// world object under the cursor, or zero for empty ground. This is the live
|
|
/// adapter for <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>.
|
|
/// </summary>
|
|
public bool PlaceIn3D(ItemDragPayload payload, uint targetGuid)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(payload);
|
|
|
|
if (payload.SourceKind == ItemDragSource.ShortcutBar)
|
|
return false;
|
|
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
|
|
return false;
|
|
if (!EnsureInventoryRequestReady())
|
|
return false;
|
|
|
|
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
|
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
|
|
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
|
|
ClientObject? target = targetGuid == 0u ? null : _objects.Get(targetGuid);
|
|
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
|
Snapshot(item),
|
|
_playerGuid(),
|
|
_groundObjectId(),
|
|
CanMakeInventoryRequest,
|
|
TargetId: targetGuid,
|
|
Target: target is null ? null : Snapshot(target),
|
|
AllowGroundFallback: true,
|
|
MergeAccepted: false,
|
|
DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade(),
|
|
PlayerOnGround: _playerOnGround(),
|
|
SplitSize: splitSize));
|
|
ExecutePlacementActions(decision.Actions);
|
|
return decision.ReturnValue;
|
|
}
|
|
|
|
private bool ExecuteUseActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
|
|
{
|
|
uint openedOrUsed = 0;
|
|
bool acted = false;
|
|
bool busyOwnedByUseReservation = false;
|
|
foreach (var action in actions)
|
|
{
|
|
switch (action.Kind)
|
|
{
|
|
case ItemPolicyActionKind.PlaceInBackpack:
|
|
acted |= PlaceWorldItemInBackpack(action.ObjectId);
|
|
break;
|
|
case ItemPolicyActionKind.WieldRight:
|
|
case ItemPolicyActionKind.WieldLeft:
|
|
case ItemPolicyActionKind.AutoSort:
|
|
if (_objects.Get(action.ObjectId) is { } item)
|
|
acted |= _autoWield.TryWield(item);
|
|
break;
|
|
case ItemPolicyActionKind.OpenContainedContainer:
|
|
case ItemPolicyActionKind.SendUse:
|
|
// Retail's wire Use and local OpenContainedContainer notice are
|
|
// distinct. acdream currently represents both with SendUse, so
|
|
// coalesce the pair to one packet.
|
|
if (openedOrUsed != action.ObjectId)
|
|
{
|
|
if (_requestUse is not null)
|
|
{
|
|
ItemUseRequestReservation reservation =
|
|
BeginUseRequestReservation();
|
|
try
|
|
{
|
|
_requestUse(action.ObjectId, reservation);
|
|
}
|
|
catch
|
|
{
|
|
reservation.CancelBeforeDispatch();
|
|
throw;
|
|
}
|
|
busyOwnedByUseReservation = true;
|
|
}
|
|
else
|
|
{
|
|
_sendUse?.Invoke(action.ObjectId);
|
|
}
|
|
openedOrUsed = action.ObjectId;
|
|
acted |= _requestUse is not null || _sendUse is not null;
|
|
}
|
|
break;
|
|
case ItemPolicyActionKind.SendUseWithTarget:
|
|
acted |= _runtimeTransactions.TryDispatchTargetedUse(
|
|
action.ObjectId,
|
|
action.TargetId,
|
|
_sendUseWithTarget,
|
|
incrementBusy: false);
|
|
break;
|
|
case ItemPolicyActionKind.SetGroundObject:
|
|
_requestExternalContainer?.Invoke(action.ObjectId);
|
|
acted |= _requestExternalContainer is not null;
|
|
break;
|
|
case ItemPolicyActionKind.EnterTargetMode:
|
|
EnterTargetMode(action.ObjectId);
|
|
acted = true;
|
|
break;
|
|
case ItemPolicyActionKind.IncrementBusy:
|
|
if (busyOwnedByUseReservation)
|
|
{
|
|
busyOwnedByUseReservation = false;
|
|
}
|
|
else
|
|
{
|
|
_runtimeTransactions.IncrementBusyCount();
|
|
}
|
|
break;
|
|
case ItemPolicyActionKind.Reject:
|
|
if (!string.IsNullOrWhiteSpace(action.Message))
|
|
_toast?.Invoke(action.Message);
|
|
break;
|
|
default:
|
|
_auxiliaryAction?.Invoke(action);
|
|
PolicyActionRequested?.Invoke(action);
|
|
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
|
|
if (!handled)
|
|
_toast?.Invoke(PolicyActionMessage(action));
|
|
acted |= handled || _toast is not null;
|
|
break;
|
|
}
|
|
}
|
|
return acted;
|
|
}
|
|
|
|
private ItemUseRequestReservation BeginUseRequestReservation()
|
|
=> _runtimeTransactions.BeginUseRequestReservation();
|
|
|
|
private void ExecutePlacementActions(System.Collections.Generic.IReadOnlyList<ItemPolicyAction> actions)
|
|
{
|
|
foreach (var action in actions)
|
|
{
|
|
switch (action.Kind)
|
|
{
|
|
case ItemPolicyActionKind.DropToWorld:
|
|
TryDispatchInventoryRequest(
|
|
InventoryRequestKind.DropToWorld,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (_sendDrop is null
|
|
|| !_objects.MoveItemOptimistic(
|
|
action.ObjectId,
|
|
newContainerId: 0u,
|
|
newSlot: -1))
|
|
{
|
|
return false;
|
|
}
|
|
_sendDrop(action.ObjectId);
|
|
return true;
|
|
});
|
|
break;
|
|
case ItemPolicyActionKind.SplitToWorld:
|
|
// UIAttemptSplitTo3D leaves the original object in its container;
|
|
// the server assigns the split-off ground stack a new guid.
|
|
TryDispatchInventoryRequest(
|
|
InventoryRequestKind.SplitToWorld,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (_sendSplitToWorld is null)
|
|
return false;
|
|
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
|
|
if (_transactions.TryGetPending(out PendingInventoryRequest pending)
|
|
&& pending.Kind is InventoryRequestKind.SplitToWorld
|
|
&& pending.ItemId == action.ObjectId)
|
|
{
|
|
WorldDropDispatched?.Invoke(new WorldDropDispatch(
|
|
pending,
|
|
(uint)action.Amount));
|
|
}
|
|
return true;
|
|
});
|
|
break;
|
|
case ItemPolicyActionKind.GiveToTarget:
|
|
// UIAttemptGive @ 0x0058D620 sends the request and leaves
|
|
// the source untouched until the authoritative server
|
|
// InventoryRemoveObject / SetStackSize response arrives.
|
|
TryDispatchInventoryRequest(
|
|
InventoryRequestKind.Give,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (_sendGive is null)
|
|
return false;
|
|
_sendGive(
|
|
action.TargetId,
|
|
action.ObjectId,
|
|
(uint)Math.Max(1, action.Amount));
|
|
return true;
|
|
});
|
|
break;
|
|
case ItemPolicyActionKind.PlaceInContainer:
|
|
{
|
|
uint fullStack = (uint)Math.Max(
|
|
1,
|
|
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
|
|
uint amount = (uint)Math.Max(1, action.Amount);
|
|
InventoryRequestKind kind = amount < fullStack
|
|
? InventoryRequestKind.SplitToContainer
|
|
: InventoryRequestKind.PutInContainer;
|
|
TryDispatchInventoryRequest(
|
|
kind,
|
|
action.ObjectId,
|
|
() =>
|
|
{
|
|
if (amount < fullStack)
|
|
{
|
|
if (_sendSplitToContainer is null)
|
|
return false;
|
|
_sendSplitToContainer(
|
|
action.ObjectId,
|
|
action.TargetId,
|
|
0u,
|
|
amount);
|
|
}
|
|
else
|
|
{
|
|
if (_sendPutItemInContainer is null)
|
|
return false;
|
|
_sendPutItemInContainer(
|
|
action.ObjectId,
|
|
action.TargetId,
|
|
0);
|
|
}
|
|
return true;
|
|
});
|
|
break;
|
|
}
|
|
case ItemPolicyActionKind.Reject:
|
|
if (!string.IsNullOrWhiteSpace(action.Message))
|
|
_toast?.Invoke(action.Message);
|
|
break;
|
|
default:
|
|
_auxiliaryAction?.Invoke(action);
|
|
PolicyActionRequested?.Invoke(action);
|
|
if (_auxiliaryAction is null && PolicyActionRequested is null)
|
|
_toast?.Invoke(PolicyActionMessage(action));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void EnterTargetMode(uint sourceGuid)
|
|
{
|
|
_consumedPrimaryClickTarget = 0;
|
|
_interactionState.EnterUseItemOnTarget(sourceGuid);
|
|
var name = _objects.Get(sourceGuid)?.Name;
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
_toast?.Invoke($"Choose a target for the {name}");
|
|
}
|
|
|
|
private void ClearTargetMode()
|
|
{
|
|
_interactionState.Clear();
|
|
}
|
|
|
|
private static string PolicyActionMessage(ItemPolicyAction action)
|
|
=> action.Kind switch
|
|
{
|
|
ItemPolicyActionKind.ConfirmPlayerKillerSwitch =>
|
|
"Confirm using this Player Killer altar before continuing.",
|
|
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch =>
|
|
"Confirm using this Non-Player Killer altar before continuing.",
|
|
ItemPolicyActionKind.ConfirmVolatileRare =>
|
|
"Confirm using this volatile rare before continuing.",
|
|
ItemPolicyActionKind.OpenSecureTrade or ItemPolicyActionKind.StartSecureTrade =>
|
|
"Secure trade is not open.",
|
|
ItemPolicyActionKind.OpenSalvage => "Open the salvage panel to use that item.",
|
|
_ => "That item action is not available here.",
|
|
};
|
|
|
|
private void OnInteractionModeChanged(InteractionModeTransition _)
|
|
{
|
|
List<Exception> failures = [];
|
|
DispatchAll(StateChanged, failures);
|
|
if (failures.Count != 0)
|
|
throw new AggregateException(
|
|
"One or more item-interaction observers failed.",
|
|
failures);
|
|
}
|
|
|
|
private void OnTransactionStateChanged()
|
|
{
|
|
List<Exception> failures = [];
|
|
DispatchAll(StateChanged, failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more item-interaction observers failed.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private void OnInventoryRequestCompleted(PendingInventoryRequest request)
|
|
{
|
|
if (_pendingBackpackPlacement is { } placement
|
|
&& placement.Token == request.Token
|
|
&& placement.ItemId == request.ItemId)
|
|
{
|
|
_pendingBackpackPlacement = null;
|
|
List<Exception> failures = [];
|
|
DispatchAll(PendingBackpackPlacementResolved, placement, failures);
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException(
|
|
"One or more pending-placement resolution observers failed.",
|
|
failures);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnInventoryObjectsCleared()
|
|
{
|
|
_pendingBackpackPlacement = null;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_interactionState.Changed -= OnInteractionModeChanged;
|
|
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
|
|
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
|
|
_transactions.StateChanged -= OnTransactionStateChanged;
|
|
WorldDropDispatched = null;
|
|
_autoWield.Dispose();
|
|
}
|
|
|
|
/// <summary>Retail UseDone (0x01C7) releases one UI busy reference.</summary>
|
|
public void CompleteUse(uint error)
|
|
=> _runtimeTransactions.CompleteUse(error);
|
|
|
|
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
|
|
public void ClearBusy()
|
|
=> _transactions.ClearBusy();
|
|
|
|
/// <summary>
|
|
/// Ends all session-scoped ItemHolder state. Target modes, click
|
|
/// suppression, and use throttles must not survive a reconnect where the
|
|
/// same numeric GUID can identify a different object.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
=> ResetSessionCore(resetRuntime: true);
|
|
|
|
/// <summary>
|
|
/// Clears only retained UI transaction presentation. Runtime's canonical
|
|
/// transaction, interaction, and busy owners are reset once by the shared
|
|
/// generation-reset transaction.
|
|
/// </summary>
|
|
internal void ResetGenerationPresentation()
|
|
=> ResetSessionCore(resetRuntime: false);
|
|
|
|
private void ResetSessionCore(bool resetRuntime)
|
|
{
|
|
PendingBackpackPlacement? pendingPlacement = _pendingBackpackPlacement;
|
|
_pendingBackpackPlacement = null;
|
|
if (resetRuntime)
|
|
{
|
|
// Runtime owns the transaction reset. This controller preserves
|
|
// the pre-J4 single UI notification emitted by InteractionState.
|
|
_transactions.StateChanged -= OnTransactionStateChanged;
|
|
try
|
|
{
|
|
_runtimeTransactions.ResetSession();
|
|
}
|
|
finally
|
|
{
|
|
if (!_disposed)
|
|
_transactions.StateChanged += OnTransactionStateChanged;
|
|
}
|
|
}
|
|
_consumedPrimaryClickTarget = 0u;
|
|
_consumedPrimaryClickMs = long.MinValue / 2;
|
|
|
|
List<Exception> failures = [];
|
|
if (resetRuntime)
|
|
{
|
|
try { _interactionState.ResetSession(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
|
|
if (pendingPlacement is { } pending)
|
|
DispatchAll(PendingBackpackPlacementCancelled, pending, failures);
|
|
|
|
if (failures.Count != 0)
|
|
throw new AggregateException(
|
|
"One or more item-interaction reset observers failed.",
|
|
failures);
|
|
}
|
|
|
|
public readonly record struct AppraisalResponseAcceptance(
|
|
bool Accepted,
|
|
bool FirstResponse);
|
|
|
|
private static void DispatchAll(Action? listeners, List<Exception> failures)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action listener in listeners.GetInvocationList())
|
|
{
|
|
try { listener(); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
}
|
|
|
|
private static void DispatchAll<T>(
|
|
Action<T>? listeners,
|
|
T value,
|
|
List<Exception> failures)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action<T> listener in listeners.GetInvocationList())
|
|
{
|
|
try { listener(value); }
|
|
catch (Exception error) { failures.Add(error); }
|
|
}
|
|
}
|
|
|
|
private bool ConsumeUseThrottle()
|
|
=> _runtimeTransactions.TryConsumeUseThrottle(_nowMs());
|
|
|
|
private static bool IsContainer(ClientObject item)
|
|
=> item.ContainerTypeHint != 0
|
|
|| item.Type.HasFlag(ItemType.Container)
|
|
|| item.ItemsCapacity > 0;
|
|
|
|
private ItemPolicyObject Snapshot(ClientObject item)
|
|
{
|
|
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
|
|
if (item.ObjectId == _playerGuid())
|
|
flags |= PublicWeenieFlags.Player;
|
|
|
|
bool owned = item.ObjectId == _playerGuid()
|
|
|| IsCarriedByPlayer(item)
|
|
|| IsEquippedByPlayer(item);
|
|
int stackSize = Math.Max(1, item.StackSize);
|
|
return new ItemPolicyObject(
|
|
item.ObjectId,
|
|
item.Type,
|
|
flags,
|
|
item.ContainerId,
|
|
item.WielderId,
|
|
item.ValidLocations,
|
|
item.CurrentlyEquippedLocation,
|
|
item.CombatUse ?? 0,
|
|
item.ItemsCapacity,
|
|
item.ContainersCapacity,
|
|
item.Useability ?? 0u,
|
|
item.TargetType ?? 0u,
|
|
owned,
|
|
IsContainer(item),
|
|
item.IsComponentPack || _isComponentPack(item.WeenieClassId),
|
|
item.TradeState,
|
|
stackSize,
|
|
stackSize,
|
|
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
|
|
&& item.ObjectId != _playerGuid());
|
|
}
|
|
|
|
/// <summary>
|
|
/// App adapter for Core's pure port of
|
|
/// <c>ItemHolder::IsTargetCompatibleWithTargetingObject @ 0x00588070</c>.
|
|
/// </summary>
|
|
private bool TargetCompatible(ClientObject source, uint targetGuid)
|
|
{
|
|
var target = _objects.Get(targetGuid);
|
|
if (target is null)
|
|
return false; // retail: GetWeenieObject(target) null → incompatible
|
|
return ItemInteractionPolicy.IsTargetCompatible(
|
|
Snapshot(source), Snapshot(target), _playerGuid());
|
|
}
|
|
|
|
private bool IsEquippedByPlayer(ClientObject item)
|
|
{
|
|
uint player = _playerGuid();
|
|
return item.CurrentlyEquippedLocation != EquipMask.None
|
|
&& (item.WielderId == player || item.ContainerId == player);
|
|
}
|
|
|
|
private bool IsCarriedByPlayer(ClientObject item)
|
|
{
|
|
uint player = _playerGuid();
|
|
uint container = item.ContainerId;
|
|
for (int hops = 0; container != 0 && hops < 8; hops++)
|
|
{
|
|
if (container == player) return true;
|
|
container = _objects.Get(container)?.ContainerId ?? 0u;
|
|
}
|
|
return false;
|
|
}
|
|
}
|