fix(vendor): grand-gate findings — wire-truth container counts, the live split bar, arrival-gated use, prepend-order race
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

Four live findings, each with the paper-verification failure named:

G1 the container-capacity guard counted containers by a local
type/capacity heuristic that over-classifies ordinary items;
retail buckets from the wire's ContainerProperties at insert. Now
reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed
to the shop-stock half; a pre-check must never false-block).
G2 the amount bar never showed live because ACE never sets StackSize
on browse listings — DescStackSize is null for every real vendor item
and the C4 paper test hand-set the field, bypassing the materializer.
The materializer now falls back to the packed supply count (AP-169,
ACE adaptation); the new test drives the REAL materializer.
G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape):
ACE's HandleActionUseItem only opens the vendor when the Use finds
the player in range — a click-time send is greeted and dropped
(AP-170, ACE adaptation; retail's server walks the player, ACE
does not).
G4 bought items appended because ACE's placement echo (UIQueue) can
beat the CreateObject (SmartboxQueue) — cross-queue, no ordering
guarantee — and the early echo was silently dropped. ClientObjectTable
now stashes unresolved placements and replays them at Ingest: buys
land at the retail list head. No register row — this RESTORES parity.

Clean-room complete solution: 11,521 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 14:03:57 +02:00
parent c68ad1e646
commit 68568a3a59
12 changed files with 1140 additions and 90 deletions

View file

@ -214,34 +214,49 @@ internal sealed class SelectionInteractionController
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();
// ItemHolder::UseObject @ 0x00588A80 has no distance/range check —
// retail's client sends Use unconditionally regardless of range; the
// walk-in is entirely server-driven (ACE's CreateMoveToChain,
// Player_Move.cs:37-65) and arrives back as an ordinary broadcast
// motion command (Q2, docs/research/2026-08-08-slice6b-vendor-
// completion-research.md). This mirrors SendPickup's !IsCloseRange
// branch below: kick off the SAME local client-predicted
// MoveToObject animation for immediate visual feel, but never gate
// the wire send on its arrival — unlike Pickup's close-range
// TurnToObject branch, Use keeps sending immediately either way (the
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
// comment: "consume the strict 0.2-second gate, send immediately").
//
// F11 (Slice 6b/6c review): the eligibility TryDispatchUse itself
// gates on (ownedByPlayer || useable) is computed ONCE, up front,
// and checked BEFORE BeginApproach — a prior version of this method
// called BeginApproach unconditionally whenever the target was out
// of close range, kicking off a client-predicted walk toward a
// target the dispatch below was always going to refuse anyway (a
// wasted, visually confusing approach with no possible Use at the
// end of it). Reordering does not change the dispatch itself: an
// ELIGIBLE target still sends immediately, in the same order,
// exactly as before.
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
@ -249,9 +264,48 @@ internal sealed class SelectionInteractionController
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
&& !approach.IsCloseRange)
{
_movement.BeginApproach(approach);
// 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 (!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.
RuntimeInteractionDispatchResult result =
_transactions.TryDispatchUse(
serverGuid,
@ -407,20 +461,44 @@ internal sealed class SelectionInteractionController
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
public void OnNaturalMoveToComplete()
{
if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pending))
HandleApproachCompletion(pending.ApproachToken, natural: true);
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 accepted = _transactions.TryResolveApproachCompletion(
bool pickupAccepted = _transactions.TryResolveApproachCompletion(
approachToken,
natural,
out RuntimePendingPickup pending);
if (pending.Token == 0u)
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(
@ -455,6 +533,41 @@ internal sealed class SelectionInteractionController
}
}
/// <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 (!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))
@ -482,6 +595,11 @@ internal sealed class SelectionInteractionController
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(
@ -505,6 +623,10 @@ internal sealed class SelectionInteractionController
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(
@ -634,12 +756,18 @@ internal sealed class SelectionInteractionController
private void CancelPendingApproach()
{
if (!_transactions.TryCancelPendingPickup(
if (_transactions.TryCancelPendingPickup(
out RuntimePendingPickup pending))
return;
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
{
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(

View file

@ -1623,16 +1623,28 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// reference <c>0x007b57b4</c>; <c>pc:204056</c>/<c>204068</c> both
/// reference <c>0x007b5750</c> via the shared <c>label_4c5509</c>).
/// <para>
/// <b>Container-vs-item slot classification (register AP-168).</b>
/// Retail's own split tests a bitfield bit this codebase does not
/// currently thread onto <see cref="VendorShopItem"/>
/// (<c>gmVendorUI::InqListSlotCount</c>, <c>pc:200038-200065</c>) — this
/// port approximates "is this shop item a container" with
/// <see cref="ItemType.Container"/> instead, correct for the ordinary
/// case (a real backpack/pouch DOES carry that type bit) but not
/// byte-identical for the theoretical case of a non-<c>Container</c>-typed
/// item that still authors nonzero pack/side capacities. See the
/// register.
/// <b>Container-vs-item slot classification (register AP-168, narrowed
/// G1 gate-finding fix 2026-08-08).</b> Retail's own split tests a
/// bitfield bit this codebase does not currently thread onto
/// <see cref="VendorShopItem"/> (<c>gmVendorUI::InqListSlotCount</c>,
/// <c>pc:200038-200065</c>) — this port approximates "is this shop item
/// a container" with <see cref="ItemType.Container"/> instead, correct
/// for the ordinary case (a real backpack/pouch DOES carry that type
/// bit) but not byte-identical for the theoretical case of a
/// non-<c>Container</c>-typed item that still authors nonzero pack/side
/// capacities. This residual applies ONLY to the shop-stock side
/// (<see cref="ComputeBuySlotsNeeded"/>) — <see cref="VendorShopItem"/>
/// genuinely has no wire-carried classification field to read instead.
/// The player's-OWN-pack side (<see cref="CountPlayerContents"/>) no
/// longer shares this approximation: it now reads
/// <see cref="ClientObject.ContainerTypeHint"/> (retail's actual wire
/// <c>ContainerProperties</c>, already threaded onto every owned object)
/// first, matching retail's real <c>_itemsList</c>/<c>_containersList</c>
/// bucketing exactly for anything that ever received a hint. Live
/// testing showed the OLD dual-heuristic (also checking nonzero
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c>) could over-classify a
/// non-container object as an occupied container slot and false-block a
/// purchase with real free slots — see the register.
/// </para>
/// On a successful DISPATCH the whole staged list is flushed
/// UNCONDITIONALLY and immediately, matching retail's literal order:
@ -1733,7 +1745,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return (itemSlots, containerSlots);
}
/// <summary>F1: the player's CURRENT occupied item/container slot counts.</summary>
/// <summary>
/// F1 (G1 gate-finding fix, 2026-08-08): the player's CURRENT occupied
/// item/container slot counts. Retail's own <c>GetNumContainedItems</c>/
/// <c>GetNumContainedContainers</c> (<c>0x0058beb0</c>/<c>0x0058bec0</c>)
/// don't reclassify anything at count time — they just report the length
/// of two ALREADY-BUCKETED <c>IDList</c>s (<c>_itemsList</c>/
/// <c>_containersList</c>). The bucketing happens once, at INSERT time
/// (<c>ACCWeenieObject::ServerSaysContainID @ 0x0058be40</c>), from the
/// wire's own <c>ContainerProperties</c> field (<c>Item_ServerSaysContainId</c>
/// 0x0022's <c>ContainerType</c>; also carried by <c>ContentProfile</c> /
/// <c>PlayerDescription</c>'s per-entry container-kind byte) — a
/// None/Container/Foci discriminator the SERVER computes, not something
/// the client reverse-engineers from the item's own type bits.
/// <see cref="ClientObject.ContainerTypeHint"/> is exactly that wire
/// field, already threaded through every membership path
/// (<c>InitializeInventoryManifest</c>, <c>InventoryPutObjInContainer</c>,
/// <c>ViewContents</c>) and already used for this identical
/// container-vs-item question elsewhere
/// (<c>ClientObjectTable.IsContainerListMember</c>). This port previously
/// used ONLY the local <see cref="ItemType.Container"/>/capacity-field
/// heuristic here (AP-168) and never consulted the hint — live testing
/// showed that guessing wrong in the OVER-classify direction (a
/// non-container object whose capacity fields happen to read nonzero)
/// false-blocks a purchase with real free slots (#G1). The hint is now
/// authoritative when present; the heuristic is a narrower fallback
/// (Container-typed only, matching <see cref="ComputeBuySlotsNeeded"/>'s
/// single signal) for the rare object that reached the table without
/// ever threading a hint.
/// </summary>
private (int Items, int Containers) CountPlayerContents()
{
int items = 0, containers = 0;
@ -1741,9 +1781,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
{
ClientObject? obj = _objects.Get(guid);
bool isContainer = obj is not null
&& (obj.ItemsCapacity > 0
|| obj.ContainersCapacity > 0
|| (obj.Type & ItemType.Container) != 0);
&& (obj.ContainerTypeHint != 0u || (obj.Type & ItemType.Container) != 0);
if (isContainer) containers++; else items++;
}
return (items, containers);

View file

@ -123,6 +123,28 @@ public sealed class ClientObjectTable
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
/// <summary>
/// G4 (grand-gate finding, 2026-08-08, register AP-171): an authoritative
/// placement (<see cref="ApplyServerMove"/>/<see cref="ApplyConfirmedServerMove"/>)
/// whose item guid does not exist in <see cref="_objects"/> YET, keyed by
/// that guid — stashed instead of silently dropped, and replayed by
/// <see cref="Ingest"/> the moment that guid's CreateObject arrives.
/// <para>
/// ACE's <c>GameMessageCreateObject</c> rides <c>GameMessageGroup.SmartboxQueue</c>
/// while <c>Item_ServerSaysContainId</c>/<c>InventoryPutObjInContainer</c>
/// (0x0022) rides <c>GameMessageGroup.UIQueue</c> — two independent
/// reliable queues with NO cross-queue ordering guarantee. An ordinary
/// pickup's item guid is already known (it was visible in the 3D world
/// first), so this race can't bite it; a vendor BUY of common stock
/// mints a brand-new guid the client has never seen
/// (<c>Player_Commerce.cs</c> <c>ItemProfileToWorldObjects</c>), so its
/// existence depends entirely on which queue's message the client
/// processes first.
/// </para>
/// </summary>
private readonly Dictionary<uint, (ClientObjectPlacement Placement, uint? ContainerTypeHint)>
_pendingUnresolvedPlacements = new();
private ulong _mutationRevision;
public ClientObjectTable()
@ -451,6 +473,19 @@ public sealed class ClientObjectTable
/// request is reconciled before <see cref="ObjectMoved"/> is published,
/// matching retail <c>ServerSaysMoveItem</c>: a reentrant listener may start
/// a new request without the old confirmation consuming it afterward.
/// <para>
/// G4 (grand-gate finding, register AP-171): if <paramref name="itemId"/>
/// does not exist yet — the cross-queue race documented on
/// <see cref="_pendingUnresolvedPlacements"/>, most visibly a vendor
/// buy's brand-new guid whose <c>InventoryPutObjInContainer</c> (UIQueue)
/// echo can arrive before its own <c>CreateObject</c> (SmartboxQueue) —
/// the requested placement is STASHED rather than silently dropped, and
/// replayed the moment <see cref="Ingest"/> creates that guid. Without
/// this, the item's placement=0 request is lost, and its later
/// CreateObject-only <c>Ingest</c> leaves it wherever the naive
/// container-index append put it (the list tail) instead of retail's
/// requested slot 0 (list head).
/// </para>
/// </summary>
public bool ApplyConfirmedServerMove(
uint itemId,
@ -472,6 +507,17 @@ public sealed class ClientObjectTable
return true;
}
if (itemId != 0u && newContainerId != 0u && !_objects.ContainsKey(itemId))
{
_pendingUnresolvedPlacements[itemId] = (
new ClientObjectPlacement(
newContainerId,
newSlot,
newWielderId,
newEquipLocation),
containerTypeHint);
}
ObjectMoved?.Invoke(new ClientObjectMove(
itemId,
Item: null,
@ -929,6 +975,28 @@ public sealed class ClientObjectTable
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
// G4: this guid's CreateObject just arrived. If an authoritative
// placement (Item_ServerSaysContainId/InventoryPutObjInContainer)
// for it had already arrived and been stashed — the cross-queue
// race documented on _pendingUnresolvedPlacements — replay it now
// via the SAME ordered-insert path ApplyServerMove already uses, so
// the retail-requested slot (e.g. 0 — the pack head) wins over the
// naive append Reindex just performed above. Only ever possible for
// a brand-new guid: an existing object's ApplyConfirmedServerMove
// would have found it immediately and never stashed anything.
if (!existed
&& _pendingUnresolvedPlacements.Remove(
d.Guid, out var pending))
{
ApplyServerMove(
d.Guid,
pending.Placement.ContainerId,
pending.Placement.WielderId,
pending.Placement.ContainerSlot,
pending.Placement.EquipLocation,
pending.ContainerTypeHint);
}
return obj;
}
@ -1506,6 +1574,7 @@ public sealed class ClientObjectTable
_containerIndex.Clear();
_equipmentIndex.Clear();
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
_pendingUnresolvedPlacements.Clear(); // G4: drop stashed placements for a session that's ending anyway
Cleared?.Invoke();
}
}

View file

@ -31,6 +31,23 @@ public readonly record struct RuntimePendingPickup(
ulong PendingPlacementToken,
RuntimeInteractionApproachToken ApproachToken);
/// <summary>
/// G3 (grand-gate finding): an out-of-range Use armed to dispatch on
/// arrival, mirroring <see cref="RuntimePendingPickup"/>'s shape. Holds the
/// eligibility snapshot computed at request time (retail's own
/// <c>ItemHolder::UseObject</c> eligibility test runs once, before the
/// walk-in) plus the caller's <see cref="ItemUseRequestReservation"/>, which
/// crosses the approach boundary unresolved until arrival (dispatch) or
/// cancellation.
/// </summary>
public readonly record struct RuntimePendingUse(
ulong Token,
uint ServerGuid,
bool OwnedByPlayer,
bool Useable,
ItemUseRequestReservation? Reservation,
RuntimeInteractionApproachToken ApproachToken);
public readonly record struct RuntimeAppraisalResponseAcceptance(
bool Accepted,
bool FirstResponse);
@ -53,7 +70,13 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
int OutboundCount,
bool HasPendingPickup,
ulong PendingPickupToken,
long DispatchFailureCount)
long DispatchFailureCount,
// G3 (grand-gate finding): an armed out-of-range Use holds a live
// ItemUseRequestReservation (a busy-count reference) until arrival or
// cancellation resolves it — it must reach zero at teardown exactly
// like HasPendingPickup.
bool HasPendingUse = false,
ulong PendingUseToken = 0u)
{
public bool IsConverged =>
IsDisposed
@ -62,7 +85,8 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
&& AwaitingAppraisalId == 0u
&& CurrentAppraisalId == 0u
&& OutboundCount == 0
&& !HasPendingPickup;
&& !HasPendingPickup
&& !HasPendingUse;
}
/// <summary>
@ -71,10 +95,23 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
/// <see cref="InventoryTransactionState"/> and is borrowed exactly.
/// </summary>
/// <remarks>
/// Ordinary Use follows <c>ItemHolder::UseObject @ 0x00588A80</c>: consume the
/// strict 0.2-second gate, send immediately, then transfer the busy reference
/// to <c>ClientUISystem::Handle_Item__UseDone @ 0x00564900</c>. Pickup keeps
/// the existing local approach transaction and exact post-arrival token.
/// Ordinary (already-in-range) Use follows <c>ItemHolder::UseObject @
/// 0x00588A80</c>: consume the strict 0.2-second gate, send immediately,
/// then transfer the busy reference to <c>ClientUISystem::Handle_Item__UseDone
/// @ 0x00564900</c>. Pickup keeps the existing local approach transaction and
/// exact post-arrival token.
/// <para>
/// G3 (grand-gate finding, 2026-08-08): an OUT-OF-RANGE Use does NOT send
/// immediately — ACE's <c>Player.HandleActionUseItem</c>
/// (<c>Player_Use.cs:176-215</c>) only calls <c>ActOnUse</c> once its own
/// <c>CreateMoveToChain</c> confirms the player is within the target's use
/// radius; a Use that arrives while still out of range never opens the
/// vendor panel (see register AP-170). <see cref="TryArmPostArrivalUse"/>/
/// <see cref="TryResolveUseApproachCompletion"/> mirror
/// <see cref="RuntimePendingPickup"/>'s exact arrival-gated shape for this
/// case; an already-in-range Use is unaffected and still dispatches via
/// <see cref="TryDispatchUse"/> immediately.
/// </para>
/// </remarks>
public sealed class RuntimeInteractionTransactionState : IDisposable
{
@ -89,6 +126,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
private uint _currentAppraisalId;
private RuntimePendingPickup? _pendingPickup;
private ulong _nextPickupToken;
private RuntimePendingUse? _pendingUse;
private ulong _nextUseToken;
private uint _clearEpoch;
private long _revision;
private long _dispatchFailureCount;
@ -106,6 +145,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
public uint CurrentAppraisalId => _currentAppraisalId;
public int OutboundCount => _outbound.Count;
public bool HasPendingPickup => _pendingPickup is not null;
public bool HasPendingUse => _pendingUse is not null;
public bool IsDisposed => _disposed;
public long Revision => Interlocked.Read(ref _revision);
public long DispatchFailureCount =>
@ -122,7 +162,9 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
_outbound.Count,
_pendingPickup is not null,
_pendingPickup?.Token ?? 0u,
DispatchFailureCount);
DispatchFailureCount,
_pendingUse is not null,
_pendingUse?.Token ?? 0u);
public bool TryConsumeUseThrottle(long nowMs)
{
@ -517,6 +559,124 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
return dispatched;
}
/// <summary>
/// G3: arms an out-of-range Use to dispatch once the approach completes
/// naturally, mirroring <see cref="TryArmPostArrivalPickup"/>. The
/// <paramref name="reservation"/> (if any) crosses the approach boundary
/// unresolved — it is released only by
/// <see cref="TryResolveUseApproachCompletion"/> (via the caller's
/// dispatch/cancel), <see cref="TryCancelPendingUse(out RuntimePendingUse)"/>,
/// or a reset/dispose, never here.
/// </summary>
public bool TryArmPostArrivalUse(
uint serverGuid,
bool ownedByPlayer,
bool useable,
ItemUseRequestReservation? reservation,
RuntimeInteractionApproachToken approachToken,
out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (serverGuid == 0u
|| approachToken.ControllerLifetime == 0u
|| approachToken.ApproachGeneration == 0u)
{
pending = default;
return false;
}
if (_pendingUse is not null)
{
pending = default;
return false;
}
ulong token = ++_nextUseToken;
if (token == 0u)
token = ++_nextUseToken;
pending = new RuntimePendingUse(
token,
serverGuid,
ownedByPlayer,
useable,
reservation,
approachToken);
_pendingUse = pending;
IncrementRevision();
return true;
}
/// <summary>
/// G3: resolves an armed Use's approach completion, mirroring
/// <see cref="TryResolveApproachCompletion"/>. The caller is responsible
/// for dispatching (via <see cref="TryDispatchUse"/>, which itself
/// resolves <see cref="RuntimePendingUse.Reservation"/>) or cancelling
/// (<see cref="ItemUseRequestReservation.CancelBeforeDispatch"/>)
/// depending on the returned bool and whether the target is still
/// current.
/// </summary>
public bool TryResolveUseApproachCompletion(
RuntimeInteractionApproachToken approachToken,
bool natural,
out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingUse is not { } current
|| current.ApproachToken != approachToken)
{
pending = default;
return false;
}
_pendingUse = null;
pending = current;
IncrementRevision();
return natural;
}
public bool TryGetPendingUse(out RuntimePendingUse pending)
{
if (_pendingUse is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
/// <summary>Unconditional cancel — used when a NEW approach supersedes whatever was armed.</summary>
public bool TryCancelPendingUse(out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingUse is not { } current)
{
pending = default;
return false;
}
_pendingUse = null;
pending = current;
IncrementRevision();
return true;
}
/// <summary>Guid-matching cancel — used when the target itself vanishes (hidden/removed).</summary>
public bool TryCancelPendingUse(
uint serverGuid,
out RuntimePendingUse pending)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_pendingUse is not { } current
|| current.ServerGuid != serverGuid)
{
pending = default;
return false;
}
_pendingUse = null;
pending = current;
IncrementRevision();
return true;
}
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
@ -538,16 +698,25 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|| _currentAppraisalId != 0u
|| _outbound.Count != 0
|| _pendingPickup is not null
|| _pendingUse is not null
|| _lastUseSourceId != 0u
|| _lastUseTargetId != 0u
|| _lastUseMs != long.MinValue / 2;
// G3: an armed Use's reservation is a live busy-count reference —
// release it here unconditionally so a reset/dispose that runs
// without a preceding CancelPendingApproach() (e.g. a headless/
// no-window teardown with no SelectionInteractionController) can
// never leak it. Idempotent: a no-op if already resolved.
_pendingUse?.Reservation?.CancelBeforeDispatch();
_lastUseSourceId = 0u;
_lastUseTargetId = 0u;
_awaitingAppraisalId = 0u;
_currentAppraisalId = 0u;
_outbound.Clear();
_pendingPickup = null;
_pendingUse = null;
_lastUseMs = long.MinValue / 2;
_clearEpoch++;
if (resetInventory)

View file

@ -224,15 +224,47 @@ public sealed class VendorShopItemMaterializer : IDisposable
/// <summary>
/// Field mapping from the domain-shaped <see cref="VendorShopItem"/> to
/// the wire-shaped merge patch <see cref="ClientObjectTable.Ingest"/>
/// expects. <see cref="VendorShopItem.DescStackSize"/> — not
/// <see cref="VendorShopItem.StackSize"/>, ItemProfile's separate packed
/// SUPPLY-count field — is the wire equivalent of an ordinary
/// CreateObject's own StackSize field (see the doc comment on
/// <see cref="VendorShopItem.DescStackSize"/>). Every field
/// <see cref="VendorShopItem"/> doesn't carry (capacity, equip mask,
/// combat use, etc.) is passed null, leaving it untouched on a refresh
/// and defaulted on a fresh object per <see cref="WeenieData"/>'s
/// null-preserving merge contract.
/// expects.
/// <para>
/// G2 gate-finding fix (2026-08-08, register AP-169): retail's own
/// client (<c>gmToolbarUI::HandleSelectionChanged</c>, <c>pc:198688</c>/
/// <c>198744</c>/<c>198774</c>/<c>198791</c>) reads
/// <c>eax_5-&gt;pwd._stackSize</c> — our <see cref="VendorShopItem.DescStackSize"/>
/// — uniformly for BOTH owned-inventory and vendor-owned selections to
/// decide whether the toolbar split slider shows and what it caps at.
/// The user's local ACE server never carries that value for a browse-
/// list row: <c>Vendor.LoadInventoryItem</c>
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172</c>)
/// builds the listing <c>WorldObject</c> via
/// <c>WorldObjectFactory.CreateNewWorldObject</c> and sets ONLY
/// <c>wo.VendorShopCreateListStackSize</c> (our
/// <see cref="VendorShopItem.StackSize"/>, the packed "how many for
/// sale" ItemProfile dword) — it never calls <c>wo.SetStackSize(...)</c>,
/// so <c>PublicWeenieDesc</c>'s own conditional <c>StackSize</c> field
/// (walked by <c>GameEventApproachVendor.cs:60</c>'s
/// <c>obj.SerializeGameDataOnly</c>) comes back null on the real wire
/// for every vendor listing. Reading only <c>DescStackSize</c> here
/// therefore left <see cref="ClientObject.StackSize"/> at its default
/// for every materialized shop item, so the toolbar split slider never
/// appeared for ANY vendor stack, matching the live report exactly (it
/// DID appear for owned-inventory stacks, which ACE populates normally
/// via ordinary pickup/loot <c>SetStackSize</c> calls). This now prefers
/// <c>DescStackSize</c> when present (retail-faithful first, and
/// forward-compatible with any server that DOES populate it), falling
/// back to the packed <c>StackSize</c> supply-count field clamped to a
/// sane positive bound — the field that IS reliably populated against
/// ACE. The <c>StackSize == -1</c> (unlimited-supply) sentinel has no
/// bounded per-row purchase cap in <see cref="VendorShopItem"/>'s wire
/// shape today (no <c>_maxStackSize</c> field carried), so it falls
/// through to the conservative "1" default rather than inventing an
/// arbitrary ceiling — see the register.
/// </para>
/// <para>
/// Every other field <see cref="VendorShopItem"/> doesn't carry
/// (capacity, equip mask, combat use, etc.) is passed null, leaving it
/// untouched on a refresh and defaulted on a fresh object per
/// <see cref="WeenieData"/>'s null-preserving merge contract.
/// </para>
/// </summary>
private static WeenieData ToWeenieData(VendorShopItem item, uint vendorId) => new(
Guid: item.ItemGuid,
@ -244,7 +276,7 @@ public sealed class VendorShopItemMaterializer : IDisposable
IconUnderlayId: item.IconUnderlayId,
Effects: item.Effects,
Value: item.Value,
StackSize: item.DescStackSize,
StackSize: ResolveDisplayStackSize(item),
StackSizeMax: null,
Burden: null,
ContainerId: vendorId,
@ -259,6 +291,23 @@ public sealed class VendorShopItemMaterializer : IDisposable
Workmanship: null,
PluralName: item.PluralName);
/// <summary>
/// G2 fix: <see cref="VendorShopItem.DescStackSize"/> when the wire
/// actually carried it (nonzero — a genuinely retail-faithful server),
/// else the packed <see cref="VendorShopItem.StackSize"/> supply count
/// (what ACE reliably sends) when it names a real bounded quantity,
/// else 1 (non-splittable — the safe default for the unlimited-supply
/// sentinel or a genuinely single-unit listing).
/// </summary>
private static int? ResolveDisplayStackSize(VendorShopItem item)
{
if (item.DescStackSize is { } desc && desc > 0)
return desc;
if (item.StackSize > 0)
return item.StackSize;
return 1;
}
public void Dispose()
{
if (_disposed) return;