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>
154 lines
6.9 KiB
C#
154 lines
6.9 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// §A.1: ACE's reader, Chorizite's generated reader/writer, holtburger's
|
|
/// independent client, and the retail decompiled sender —
|
|
/// <c>CM_Vendor::Event_Buy</c>, <c>pc:689288</c>, <c>0x006AA0F0</c>):
|
|
/// <code>
|
|
/// u32 0xF7B1 // GameAction envelope
|
|
/// u32 gameActionSequence
|
|
/// u32 0x005F // Buy opcode
|
|
/// u32 vendorGuid
|
|
/// u32 itemCount
|
|
/// per item:
|
|
/// i32 amount // quantity to buy (plain positive int32,
|
|
/// // NOT ItemProfile's packed sign-extended
|
|
/// // supply-count field)
|
|
/// u32 objectGuid // the SHOP ITEM's guid
|
|
/// u32 alternateCurrencyId // TRAILING — 0 for a pyreal vendor
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>The trailing <c>alternateCurrencyId</c> field — a deliberate,
|
|
/// evidence-backed divergence from ACE.</b> Retail's client
|
|
/// (<c>CM_Vendor::Event_Buy</c>) writes this field on EVERY Buy, after the
|
|
/// packed item list, every time (<c>pc:689335-689336</c>). ACE's current
|
|
/// server-side reader has the matching line PRESENT but COMMENTED OUT
|
|
/// (<c>GameActionBuyItems.cs:32</c>,
|
|
/// <c>//var altCurrencyWcid = message.Payload.ReadUInt32();</c>) — it simply
|
|
/// never reads the trailing bytes. holtburger, a real client written and
|
|
/// tested against ACE's actual accepted wire shape, omits the field
|
|
/// entirely and round-trips fine against ACE. Both are correct for what
|
|
/// they target: ACE demonstrably does not NEED this field today. We port
|
|
/// the field anyway because retail — the top oracle per this project's
|
|
/// CLAUDE.md — sends it unconditionally, it costs one <c>u32</c>, and it
|
|
/// costs ACE nothing to ignore (forward-compatible with any future ACE
|
|
/// build that un-comments its read). Do not "fix" this by dropping the
|
|
/// field without re-reading
|
|
/// <c>docs/research/2026-08-08-slice6-vendor-transactions-research.md</c>
|
|
/// §A.1 first — that document's "Resolution of the ACE/holtburger vs.
|
|
/// retail disagreement" section is the full reasoning trail.
|
|
/// </para>
|
|
/// </summary>
|
|
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
|
|
/// <c>CM_Vendor::Event_Buy(vendorGuid, &list, currencyId)</c>. Slice
|
|
/// 6.3's Buy button always passes a ONE-entry list (retail
|
|
/// <c>BuySingleItem</c>, <c>pc:201661</c>, has no staging-list
|
|
/// dependency — see the Slice 6 research doc §B.1); the list shape is
|
|
/// kept general because that is literally the wire message's own shape
|
|
/// (a future "Buy All" staged-purchase path would reuse this builder
|
|
/// unchanged, not because Slice 6.3 needs it today).
|
|
/// </summary>
|
|
public static byte[] BuildBuy(
|
|
uint gameActionSequence,
|
|
uint vendorGuid,
|
|
IReadOnlyList<(int Amount, uint ItemGuid)> items,
|
|
uint alternateCurrencyId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(items);
|
|
|
|
int itemCount = items.Count;
|
|
byte[] body = new byte[24 + (itemCount * 8)];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), BuyOpcode);
|
|
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;
|
|
}
|
|
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset), alternateCurrencyId);
|
|
return body;
|
|
}
|
|
|
|
/// <summary>Convenience overload for the single-item Buy Slice 6.3 sends.</summary>
|
|
public static byte[] BuildBuy(
|
|
uint gameActionSequence,
|
|
uint vendorGuid,
|
|
int amount,
|
|
uint itemGuid,
|
|
uint alternateCurrencyId)
|
|
=> BuildBuy(
|
|
gameActionSequence,
|
|
vendorGuid,
|
|
new[] { (amount, itemGuid) },
|
|
alternateCurrencyId);
|
|
|
|
/// <summary>
|
|
/// Build a Sell GameAction for <paramref name="items"/> — retail's
|
|
/// 2-argument <c>CM_Vendor::Event_Sell(vendorGuid, &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;
|
|
}
|
|
}
|