feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
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

C1 an out-of-range Use now approaches first via the existing
client-predicted BeginApproach (Pickup's far-range shape mirrored;
retail's ItemHolder::UseObject @0x00588A80 has no range check and the
dispatch stays immediate). C2 Add-to-List stages into the Buying tab
via VendorStagingList (RemoveProfileFromList's two shapes,
pc:200497-200537), Buy All sends ONE batched 0x005F and flushes
staging on send exactly as retail does (SendShopEvent -> Flush,
pc:204075-204076 — not UseDone-gated), and X-close over a non-empty
staging list shows retail's confirm string recovered verbatim from the
binary data segment (0x007b5bd8) through the existing dialog factory.
C3 the Selling tab's list is the sole drop target (retail's single
IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports
InqAcceptability with all rejection strings recovered verbatim from
the raw data segment; the sell side prices with BuyPrice (retail's
inverted naming: what the vendor PAYS) and 0x0060 carries no trailing
currency field, unlike Buy. C4 the status-bar reproduction test PASSES
against the production toolbar mount — retail's toolbar shows count +
name with the split bar and NO price parenthetical (that figure is the
vendor row's own cost text); no code change, the live gate referees.
C5 pack order verified correct, untouched.

Register: AP-161 narrowed to its two pre-existing cosmetic gaps;
AP-162 extended over Buy All; AP-164 (non-sellable bitfield
unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal
test, bounded), AP-166 (purse text + pending-sell highlight cosmetic)
filed.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 11:43:11 +02:00
parent ab3146ba88
commit 92ea3977b6
18 changed files with 2578 additions and 78 deletions

View file

@ -5,9 +5,9 @@ using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound vendor GameActions. Slice 6.3: Buy (<c>0x005F</c>) only — Sell
/// (<c>0x0060</c>) is research-only scope (Slice 6 research doc §A.3), not
/// implemented here.
/// Outbound vendor GameActions. Buy (<c>0x005F</c>, Slice 6.3) and Sell
/// (<c>0x0060</c>, Slice 6b/6c) — both a batched item list; see
/// <see cref="BuildSell"/> for the Sell-specific wire shape.
///
/// <para>
/// Wire layout, confirmed FOUR ways with zero disagreement (research doc
@ -55,6 +55,7 @@ public static class VendorRequests
{
public const uint GameActionEnvelope = 0xF7B1u;
public const uint BuyOpcode = 0x005Fu;
public const uint SellOpcode = 0x0060u;
/// <summary>
/// Build a Buy GameAction for <paramref name="items"/> — retail's
@ -107,4 +108,47 @@ public static class VendorRequests
vendorGuid,
new[] { (amount, itemGuid) },
alternateCurrencyId);
/// <summary>
/// Build a Sell GameAction for <paramref name="items"/> — retail's
/// 2-argument <c>CM_Vendor::Event_Sell(vendorGuid, &amp;list)</c>
/// (<c>pc:689229</c>, <c>0x006AA000</c>). Slice 6b/6c research doc §A.3/
/// §Q4: unlike Buy, Sell's body never writes a trailing field — no
/// currency id, confirmed both by the retail decompiled sender and by
/// ACE's reader (<c>GameActionSellItems.Handle</c> reads only
/// <c>vendorGuid</c>, <c>numItems</c>, then per-item <c>amount</c>(i32)/
/// <c>objectGuid</c>(u32)) and by Chorizite's/holtburger's independent
/// generated <c>Vendor_Sell</c>/<c>SellActionData</c> shapes, neither of
/// which carries an <c>AlternateCurrencyId</c> member at all. Used by
/// both the "Sell Item" (a one-entry list) and "Sell All" (n-entry list)
/// buttons — retail's own <c>Event_Sell</c> has no separate single-item
/// opcode, unlike Buy's asymmetric client-side "immediate single" vs
/// "batched all" naming.
/// </summary>
public static byte[] BuildSell(
uint gameActionSequence,
uint vendorGuid,
IReadOnlyList<(int Amount, uint ItemGuid)> items)
{
ArgumentNullException.ThrowIfNull(items);
int itemCount = items.Count;
byte[] body = new byte[20 + (itemCount * 8)];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SellOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), vendorGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)itemCount);
int offset = 20;
for (int i = 0; i < itemCount; i++)
{
(int amount, uint itemGuid) = items[i];
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset), amount);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset + 4), itemGuid);
offset += 8;
}
return body;
}
}

View file

@ -2406,6 +2406,33 @@ public sealed class WorldSession : IDisposable
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId));
}
/// <summary>
/// Slice 6b: send a batched retail Buy (0x005F) — the "Buy All" path,
/// one wire call for every staged entry. See <see cref="SendBuy(uint, uint, int, uint)"/>
/// for the single-item convenience overload the "Items"/"Buying" tabs'
/// immediate Buy buttons keep using unchanged.
/// </summary>
public void SendBuy(
uint vendorGuid,
IReadOnlyList<(int Amount, uint ItemGuid)> items,
uint alternateCurrencyId)
{
uint seq = NextGameActionSequence();
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, items, alternateCurrencyId));
}
/// <summary>
/// Slice 6c: send retail Sell (0x0060) — <see cref="VendorRequests.BuildSell"/>.
/// Used by both the "Sell Item" (one-entry list) and "Sell All" (n-entry
/// list) buttons; Sell has no separate single-item opcode the way Buy
/// does.
/// </summary>
public void SendSell(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items)
{
uint seq = NextGameActionSequence();
SendGameAction(VendorRequests.BuildSell(seq, vendorGuid, items));
}
/// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary>
public void SendAppraise(uint targetGuid)
{