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

@ -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();
}
}