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

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