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

@ -1,3 +1,4 @@
using System.Linq;
using AcDream.Core.Items;
using Xunit;
@ -765,6 +766,208 @@ public sealed class ClientObjectTableTests
Assert.Equal(new[] { item }, table.GetContents(pack));
}
/// <summary>
/// SCRATCH G4 reproduction: a vendor BUY's real wire shape is a fresh
/// CreateObject (a BRAND NEW guid our client has never seen before —
/// ACE's Player_Commerce.cs FinalizeBuyTransaction/ItemProfileToWorldObjects
/// mints a new WorldObject for common stock, distinct from the shop-list
/// guid) carrying ContainerId=player already (TryAddToInventory sets it
/// before TryCreateInInventoryWithNetworking sends CreateObject), followed
/// by the SAME InventoryPutObjInContainer (0x0022) placement=0 message a
/// pickup gets. This drives that EXACT two-message sequence — Ingest
/// (simulating CreateObject) THEN ApplyConfirmedServerMove (simulating
/// the 0x0022 echo) — against a pack that already has other items, to see
/// whether the fresh-guid case actually prepends the way
/// AuthoritativePickup_PlacementZeroInsertsAtRetailListHead's
/// already-known-guid case (built via ReplaceContents, which creates the
/// object FIRST) already proves.
/// </summary>
[Fact]
public void SCRATCH_BuyShapedFreshGuidCreateObjectThenContainId_FinalOrder()
{
var table = new ClientObjectTable();
const uint pack = 0x50000001u;
const uint existingA = 0xA01u;
const uint existingB = 0xA02u;
const uint boughtItem = 0xB01u;
table.InitializeInventoryManifest(pack, new[]
{
new ContainerContentEntry(existingA, 0u),
new ContainerContentEntry(existingB, 0u),
});
// Simulates the buy's CreateObject: a guid NEVER SEEN BEFORE,
// already carrying ContainerId=pack (matching TryAddToInventory's
// pre-send assignment), routed through the SAME ApplyEntitySpawn ->
// Ingest path production CreateObject handling uses.
table.Ingest(new WeenieData(
Guid: boughtItem,
Name: "Arrow",
Type: ItemType.MissileWeapon,
WeenieClassId: 5u,
IconId: 0u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: 100,
StackSize: 100,
StackSizeMax: null,
Burden: 1,
ContainerId: pack,
WielderId: 0u,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null));
// Simulates the SAME wire echo a pickup gets (0x0022,
// InventoryPutObjInContainer / Item_ServerSaysContainId), which ACE
// sends unconditionally after TryAddToInventory for a buy exactly
// the same way it does for a pickup — with Placement=0.
Assert.True(table.ApplyConfirmedServerMove(
boughtItem,
pack,
newWielderId: 0u,
newSlot: 0,
containerTypeHint: 0u));
Assert.Equal(
new[] { boughtItem, existingA, existingB },
table.GetContents(pack));
}
/// <summary>
/// G4 (grand-gate finding): ACE's <c>GameMessageCreateObject</c> rides
/// <c>GameMessageGroup.SmartboxQueue</c>
/// (references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageCreateObject.cs:8)
/// while <c>GameEventItemServerSaysContainId</c> (our
/// InventoryPutObjInContainer, 0x0022) rides
/// <c>GameMessageGroup.UIQueue</c> — TWO independent reliable queues
/// with no cross-queue ordering guarantee. A vendor buy of common stock
/// mints a brand-new guid (ACE's <c>ItemProfileToWorldObjects</c>) the
/// client has never seen, so unlike an ordinary pickup (whose item was
/// already visible/known), its final resting slot depends entirely on
/// which queue's message the client happens to process first. This
/// drives the 0x0022 echo BEFORE its item's CreateObject — root cause,
/// live-verified: before the fix this returned <c>applied=false</c> and
/// silently dropped the placement, leaving the item appended at the
/// list tail once its later CreateObject-only <c>Ingest</c> ran.
/// </summary>
[Fact]
public void ContainIdArrivingBeforeCreateObject_StillInsertsAtRetailListHead()
{
var table = new ClientObjectTable();
const uint pack = 0x50000001u;
const uint existingA = 0xA01u;
const uint existingB = 0xA02u;
const uint boughtItem = 0xB02u;
table.InitializeInventoryManifest(pack, new[]
{
new ContainerContentEntry(existingA, 0u),
new ContainerContentEntry(existingB, 0u),
});
// The 0x0022 echo arrives FIRST — boughtItem does not exist in the
// table yet. ApplyConfirmedServerMove itself still reports failure
// (nothing to move YET) — the fix is that it no longer drops the
// request on the floor.
Assert.False(table.ApplyConfirmedServerMove(
boughtItem,
pack,
newWielderId: 0u,
newSlot: 0,
containerTypeHint: 0u));
// THEN the CreateObject arrives.
table.Ingest(new WeenieData(
Guid: boughtItem,
Name: "Arrow",
Type: ItemType.MissileWeapon,
WeenieClassId: 5u,
IconId: 0u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: 100,
StackSize: 100,
StackSizeMax: null,
Burden: 1,
ContainerId: pack,
WielderId: 0u,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null));
// The stashed placement replays: the bought item lands at the head
// (retail slot 0), matching the in-order case exactly.
Assert.Equal(
new[] { boughtItem, existingA, existingB },
table.GetContents(pack));
Assert.Equal(pack, table.Get(boughtItem)!.ContainerId);
Assert.Equal(0u, table.Get(boughtItem)!.ContainerTypeHint);
}
/// <summary>
/// G4 companion: a stashed placement for a guid that never actually
/// arrives (e.g. a stale/unrelated echo) must not leak forever, or
/// silently resurrect a placement for some LATER, unrelated recycled
/// guid. <see cref="ClientObjectTable.Clear"/> (session teardown) drops
/// it.
/// </summary>
[Fact]
public void PendingUnresolvedPlacement_IsDroppedByClear()
{
var table = new ClientObjectTable();
const uint pack = 0x50000001u;
const uint neverArrives = 0xB03u;
Assert.False(table.ApplyConfirmedServerMove(
neverArrives, pack, newWielderId: 0u, newSlot: 0, containerTypeHint: 0u));
table.Clear();
// A later, unrelated Ingest of the SAME (recycled) guid must not
// resurrect the old stashed placement — it should append normally
// (no other contents to reposition ahead of, so this just proves no
// exception/misplacement occurs after a session boundary).
table.Ingest(new WeenieData(
Guid: neverArrives,
Name: "Something Else",
Type: ItemType.Misc,
WeenieClassId: 9u,
IconId: 0u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: 1,
StackSize: null,
StackSizeMax: null,
Burden: 1,
ContainerId: pack,
WielderId: 0u,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null));
Assert.Equal(new[] { neverArrives }, table.GetContents(pack));
}
[Fact]
public void AuthoritativePickup_PlacementZeroInsertsAtRetailListHead()
{