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(