Shop: tell the client when a purchase has actually been delivered
This commit is contained in:
@@ -219,7 +219,9 @@ void LoginDatabaseConnection::DoPrepareStatements()
|
||||
PrepareStatement(LOGIN_SEL_BATTLEPAY_ENTITLEMENT_ACCOUNT, "SELECT id, productId, serviceType, status, purchaseId, createTime FROM account_battlepay_entitlement WHERE account = ? AND status = 1 ORDER BY id ASC", CONNECTION_ASYNC);
|
||||
PrepareStatement(LOGIN_SEL_BATTLEPAY_ENTITLEMENT_MAXID, "SELECT MAX(id) FROM account_battlepay_entitlement WHERE id BETWEEN ? AND ?", CONNECTION_SYNCH);
|
||||
PrepareStatement(LOGIN_SEL_BATTLEPAY_ENTITLEMENT_BY_ID, "SELECT account, productId, serviceType, status, claimToken, realmId, targetCharacter FROM account_battlepay_entitlement WHERE id = ?", CONNECTION_SYNCH);
|
||||
PrepareStatement(LOGIN_SEL_BATTLEPAY_ENTITLEMENT_PENDING_CHAR, "SELECT id, productId, serviceType FROM account_battlepay_entitlement WHERE realmId = ? AND targetCharacter = ? AND status = 3 ORDER BY id ASC", CONNECTION_ASYNC);
|
||||
// purchaseId is selected so the delivery notification can name the purchase it completes. The column
|
||||
// already exists (see LOGIN_INS_BATTLEPAY_ENTITLEMENT above), so this needs NO schema change.
|
||||
PrepareStatement(LOGIN_SEL_BATTLEPAY_ENTITLEMENT_PENDING_CHAR, "SELECT id, productId, serviceType, purchaseId FROM account_battlepay_entitlement WHERE realmId = ? AND targetCharacter = ? AND status = 3 ORDER BY id ASC", CONNECTION_ASYNC);
|
||||
PrepareStatement(LOGIN_UPD_BATTLEPAY_ENTITLEMENT_CLAIM, "UPDATE account_battlepay_entitlement SET status = 2, claimToken = ?, realmId = ?, targetCharacter = ?, updateTime = ? WHERE id = ? AND account = ? AND status = 1", CONNECTION_SYNCH);
|
||||
PrepareStatement(LOGIN_UPD_BATTLEPAY_ENTITLEMENT_STATUS, "UPDATE account_battlepay_entitlement SET status = ?, claimToken = ?, updateTime = ? WHERE id = ? AND status = ? AND claimToken = ?", CONNECTION_SYNCH);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "BattlePayMgr.h"
|
||||
#include "BattlePayPackets.h"
|
||||
#include "DatabaseEnv.h"
|
||||
#include "DB2Stores.h"
|
||||
#include "DBCEnums.h"
|
||||
#include "GameTime.h"
|
||||
#include "Item.h"
|
||||
@@ -407,9 +408,55 @@ void WorldSession::BattlePayProcessPurchase(uint32 productID)
|
||||
TC_LOG_INFO("network", "BattlePay: {} purchased product {} ({}) for {} (currency {}).",
|
||||
GetPlayerInfo(), productID, product->Name, product->Price, product->Currency);
|
||||
|
||||
// Announce the delivery before the completing PURCHASE_UPDATE - see SendBattlePayDeliveryNotifications.
|
||||
SendBattlePayDeliveryNotifications(*product, purchaseID);
|
||||
|
||||
respond(STATUS_DONE, RESULT_OK, product->Price);
|
||||
}
|
||||
|
||||
// Tells the client that the payload of `product` has just been handed over, so its collection UI
|
||||
// refreshes instead of waiting for a reload.
|
||||
//
|
||||
// WHY HERE: this is called from the two places where delivery actually happens - the immediate grant loop
|
||||
// in BattlePayProcessPurchase, and the entitlement redemption loop in RedeemBattlePayEntitlements. Both
|
||||
// call it AFTER every deliverable has succeeded and after the charge has settled, and BEFORE the
|
||||
// completing SMSG_BATTLE_PAY_PURCHASE_UPDATE goes out. That ordering is deliberate: the client's Shop
|
||||
// closes the purchase on the PURCHASE_UPDATE, so the collection refresh has to have been requested
|
||||
// before then for the new mount or toy to be present when the frame comes back. It also means a purchase
|
||||
// that fails or gets refunded never announces a delivery, because those paths return earlier.
|
||||
//
|
||||
// The per-deliverable opcode is chosen by what was actually granted, but note (see BattlePayPackets.h)
|
||||
// that the 12.0.7 client routes MOUNT_DELIVERED and COLLECTION_ITEM_DELIVERED to the same handler and the
|
||||
// same Lua event, so the distinction is honesty about what we sent rather than a behavioural difference.
|
||||
void WorldSession::SendBattlePayDeliveryNotifications(ShopProduct const& product, uint64 purchaseID)
|
||||
{
|
||||
for (ShopDeliverable const& d : product.Deliverables)
|
||||
{
|
||||
OpcodeServer opcode;
|
||||
switch (d.Type)
|
||||
{
|
||||
case 1: // item / toy -> a collection item
|
||||
opcode = SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED;
|
||||
break;
|
||||
case 2: // spell: a mount spell is a mount, anything else lands in the collection
|
||||
opcode = sDB2Manager.GetMount(d.Id) ? SMSG_BATTLE_PAY_MOUNT_DELIVERED
|
||||
: SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED;
|
||||
break;
|
||||
default: // type 3 (WoW Token) already pushes its own SendCommerceTokenUpdate; nothing else
|
||||
// in this vocabulary delivers into a collection.
|
||||
continue;
|
||||
}
|
||||
|
||||
WorldPackets::BattlePay::DeliveryNotification notification(opcode);
|
||||
SendPacket(notification.Write());
|
||||
}
|
||||
|
||||
WorldPackets::BattlePay::DeliveryEnded ended;
|
||||
ended.PurchaseID = purchaseID;
|
||||
ended.Products.emplace_back().ProductID = product.ProductID;
|
||||
SendPacket(ended.Write());
|
||||
}
|
||||
|
||||
void WorldSession::HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPurchase& startPurchase)
|
||||
{
|
||||
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED))
|
||||
@@ -934,6 +981,7 @@ void WorldSession::RedeemBattlePayEntitlements()
|
||||
uint64 const distributionId = fields[0].GetUInt64();
|
||||
uint32 const productId = fields[1].GetUInt32();
|
||||
uint8 const serviceType = fields[2].GetUInt8();
|
||||
uint64 const purchaseId = fields[3].GetUInt64();
|
||||
|
||||
// Consume first (see the ordering note above). A losing racer simply finds nothing to do.
|
||||
if (!sBattlePayMgr->TransitionEntitlement(distributionId, SHOP_ENTITLEMENT_BOUND, 0,
|
||||
@@ -988,6 +1036,10 @@ void WorldSession::RedeemBattlePayEntitlements()
|
||||
}
|
||||
}
|
||||
|
||||
// Same delivery announcement as the immediate-purchase path: the payload has just landed in
|
||||
// this character's collection, so tell the client to refresh it.
|
||||
SendBattlePayDeliveryNotifications(*product, purchaseId);
|
||||
|
||||
TC_LOG_INFO("network", "BattlePay: delivered entitlement {} (product {} '{}') to {}.",
|
||||
distributionId, productId, product->Name, target->GetName());
|
||||
} while (result->NextRow());
|
||||
|
||||
@@ -265,6 +265,28 @@ namespace WorldPackets::BattlePay
|
||||
return &_worldPacket;
|
||||
}
|
||||
|
||||
// Mirrors the client's element parser at rva 0x72BD40 exactly, including the detail that the two bit
|
||||
// fields are separately flushed bytes rather than one packed group (the parser reads each with its own
|
||||
// READ_U8 and then shifts: `shr dl,7` for the optional flag, `shr rdx,1` for the 7-bit choice count).
|
||||
// Writing them as one group would shift every following byte and desynchronise the whole vector.
|
||||
WorldPacket const* DeliveryEnded::Write()
|
||||
{
|
||||
_worldPacket << PurchaseID;
|
||||
_worldPacket << uint32(Products.size());
|
||||
for (DeliveredProduct const& product : Products)
|
||||
{
|
||||
_worldPacket << product.ProductID;
|
||||
|
||||
_worldPacket.WriteBit(false); // hasUnlockList - no unlock-id list to send (see the header)
|
||||
_worldPacket.FlushBits();
|
||||
|
||||
_worldPacket << Bits<7>(0); // choiceCount - our products have no client-chosen variants
|
||||
_worldPacket.FlushBits();
|
||||
}
|
||||
|
||||
return &_worldPacket;
|
||||
}
|
||||
|
||||
WorldPacket const* EnumVasPurchaseStatesResponse::Write()
|
||||
{
|
||||
// Six-bit count, then flush. With no purchases this is the single 0x00 byte retail sends.
|
||||
|
||||
@@ -177,6 +177,125 @@ namespace WorldPackets
|
||||
// it for entitlements whose product Type is 12 or 20 (rule matched exactly on both captured
|
||||
// accounts: 9-of-77 and 1-of-7) - types our catalog never emits anyway. Send the product through
|
||||
// 0x42021E or the distribution list instead; those have registered handlers.
|
||||
//
|
||||
// RE-VERIFIED against the 68275 image (2026-08-14), by a stronger method than the original
|
||||
// finding: the 12.0.7 client dispatches every message in this group through a per-opcode thunk
|
||||
// that indirects through a writable function-pointer slot, and the slot for 0x420224 is
|
||||
// rva 0x44028E0 (VA 0x7FF72CEA28E0). A linear scan of the WHOLE image for RIP-relative stores
|
||||
// (`48/4C 89 /r mod=00 rm=101` and `48 C7 05 ...`) targeting that slot finds ZERO write sites,
|
||||
// while every one of its neighbours has exactly two (a register in the BattlePay registrar at
|
||||
// rva 0x23D4360 and a null-out in the matching unregister at rva 0x23D4574). The dispatch is
|
||||
// therefore a permanent no-op. THE FINDING STILL HOLDS - do not wire this opcode.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Purchase delivery notifications (0x42021F - 0x420223).
|
||||
//
|
||||
// HOW THESE WERE RECOVERED (no capture exists and none ever will - retail is on 12.1.0):
|
||||
// the client's SMSG dispatcher is one giant switch, sub_7FF729103660, keyed on the raw opcode.
|
||||
// Each case runs three calls in order: a per-opcode PARSER, then a dispatch THUNK, then the
|
||||
// message destructor. The parser gives the wire layout; the thunk indirects through a global
|
||||
// slot that the BattlePay registrar (rva 0x23D4360) fills in, and THAT is what decides whether
|
||||
// the client does anything at all with the message.
|
||||
//
|
||||
// What the five delivery opcodes actually do in 12.0.7:
|
||||
//
|
||||
// 0x42021F DELIVERY_STARTED parser rva 0x608110 slot 0x4402910 -> rva 0x1D80E0
|
||||
// *** rva 0x1D80E0 is the three bytes `C2 00 00` = `ret 0`. ***
|
||||
// The client parses the body and calls a function that returns
|
||||
// immediately. NOT WIRED - see the refusal note below.
|
||||
// 0x420220 DELIVERY_ENDED parser rva 0x608190 slot 0x4402908 -> rva 0x23CD870 (real)
|
||||
// 0x420221 MOUNT_DELIVERED parser rva 0x608270 slot 0x4402900 -> rva 0x23CD930 (real)
|
||||
// 0x420222 BATTLE_PET_DELIV. parser rva 0x6082F0 slot 0x44028F0 -> rva 0x23CD930 (real)
|
||||
// 0x420223 COLLECTION_ITEM_D. parser rva 0x608380 slot 0x44028F8 -> rva 0x23CD930 (real)
|
||||
//
|
||||
// The three *_DELIVERED opcodes share ONE handler, rva 0x23CD930, whose entire body is
|
||||
// "fire the Lua event whose id is 0xF8EB3D280E974224" - it never touches the parsed payload.
|
||||
// DELIVERY_ENDED's handler (rva 0x23CD870) walks its element vector under a feature-flag gate
|
||||
// and then fires THE SAME event id. So all four live opcodes collapse to a single client-visible
|
||||
// effect: one "a delivery happened, refresh" event. That is exactly the signal a freshly granted
|
||||
// mount or toy needs in order to appear without a UI reload, and it is what this server was
|
||||
// missing: the grant happened silently and only PURCHASE_UPDATE went out.
|
||||
//
|
||||
// DELIVERY_STARTED is deliberately NOT implemented. Wiring it would put bytes on the wire for a
|
||||
// handler that is a bare `ret` - the same situation as SMSG_BATTLE_PAY_TENDER_GRANTED above, and
|
||||
// refused for the same reason. It is not "unsupported"; it is proven inert.
|
||||
//
|
||||
// SMSG_BATTLE_PAY_BATTLE_PET_DELIVERED is also not implemented, and for a different reason worth
|
||||
// stating: its layout IS recovered (parser rva 0x6082F0 = `uint32; PackedGuid`), but the handler
|
||||
// provably ignores both fields, and this server has no proven source for either of them. Wiring
|
||||
// it would mean inventing two field values to obtain a client effect that
|
||||
// SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED - which has no fields to invent - already produces
|
||||
// identically. Battle-pet grants therefore ride the collection-item notification.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// SMSG_BATTLE_PAY_MOUNT_DELIVERED (0x420221) and SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED
|
||||
// (0x420223) have IDENTICAL wire shape. Their parsers (rva 0x608270 / 0x608380) make exactly one
|
||||
// call, to rva 0x33CC980, with the count argument computed as `packet[0x18] - packet[0x1C]`,
|
||||
// i.e. "however many bytes are left". That primitive (disassembled at rva 0x33CC980) zeroes the
|
||||
// out-pointer first, bounds-checks the request, and on success stores a POINTER into the packet
|
||||
// buffer and advances the read cursor - it is a zero-copy blob view, and the length is never
|
||||
// stored anywhere in the message object. The handler then never reads it.
|
||||
//
|
||||
// So these messages carry NO fields. An empty body is the complete and correct encoding, not a
|
||||
// placeholder: there is no value the client could observe, and because the primitive zeroes its
|
||||
// output before the bounds check, an empty body cannot leave the client reading uninitialised
|
||||
// memory either (that was checked specifically, since the parser does not pre-zero its own slot).
|
||||
class DeliveryNotification final : public ServerPacket
|
||||
{
|
||||
public:
|
||||
explicit DeliveryNotification(OpcodeServer opcode) : ServerPacket(opcode, 0) {}
|
||||
|
||||
WorldPacket const* Write() override { return &_worldPacket; }
|
||||
};
|
||||
|
||||
// One entry of SMSG_BATTLE_PAY_DELIVERY_ENDED's element vector. Shape proven from the element
|
||||
// parser at rva 0x72BD40 (memory stride 0x70) and the nested reader it calls at rva 0x72C010:
|
||||
//
|
||||
// uint32 ProductID (READ_U32 -> elem+0x00)
|
||||
// Bits<1> hasUnlockList ; FlushBits (READ_U8 + `shr dl,7` -> optional flag elem+0x28)
|
||||
// Bits<7> choiceCount ; FlushBits (nested, READ_U8 + `shr rdx,1`)
|
||||
// choiceCount x { uint8; uint32 } (nested loop, 8-byte stride)
|
||||
// if (hasUnlockList) {
|
||||
// uint8 kind (READ_U8 -> elem+0x08)
|
||||
// uint32 count (READ_U32)
|
||||
// count x uint32 (loop -> vector at elem+0x10)
|
||||
// }
|
||||
//
|
||||
// Note both bit fields are read as whole bytes with a shift, i.e. each is written and flushed on
|
||||
// its own - they are NOT one packed group. Nothing here is guessed: every read is a call to a
|
||||
// known primitive (0x33CC410 = uint32, 0x33CC370 = uint8, both confirmed by disassembly, the
|
||||
// latter being the byte a bit group is flushed into).
|
||||
//
|
||||
// We emit ProductID and leave both sub-lists empty, because this server genuinely has neither: a
|
||||
// "choice" only exists for products the client picks a variant of, and the unlock list is a set of
|
||||
// ids we do not produce. Empty is a legal encoding of both - the client's loops are plain
|
||||
// `for (i < count)` and its per-element call is behind a feature gate that an empty element
|
||||
// satisfies trivially.
|
||||
struct DeliveredProduct
|
||||
{
|
||||
uint32 ProductID = 0;
|
||||
};
|
||||
|
||||
// SMSG_BATTLE_PAY_DELIVERY_ENDED (0x420220). Layout proven from the parser at rva 0x608190:
|
||||
//
|
||||
// uint64 PurchaseID (READ_U64, primitive rva 0x33CC460 - disassembled and
|
||||
// confirmed to read exactly 8 bytes and advance by 8)
|
||||
// uint32 Count (READ_U32)
|
||||
// Count x DeliveredProduct
|
||||
//
|
||||
// The client's handler (rva 0x23CD870) reads only the vector pointer and the count; PurchaseID is
|
||||
// parsed and never touched. We send the real PurchaseID anyway - it is the id of the purchase
|
||||
// whose delivery just ended, it costs nothing, and inventing was never necessary here.
|
||||
class DeliveryEnded final : public ServerPacket
|
||||
{
|
||||
public:
|
||||
explicit DeliveryEnded() : ServerPacket(SMSG_BATTLE_PAY_DELIVERY_ENDED, 8 + 4) {}
|
||||
|
||||
WorldPacket const* Write() override;
|
||||
|
||||
uint64 PurchaseID = 0;
|
||||
std::vector<DeliveredProduct> Products;
|
||||
};
|
||||
|
||||
// One row of the account's entitlement ledger: which deliverable the account owns, and for how
|
||||
// long. Stride is exactly 25 bytes on the wire (4 + 8 + 8 + 4 + 1), which is what makes the
|
||||
|
||||
@@ -1280,9 +1280,9 @@ void OpcodeTable::InitializeServerOpcodes()
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_NET_CONNECTION_STATUS, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_ACK_FAILED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_BATTLE_PET_DELIVERED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_CONFIRM_PURCHASE, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_DELIVERY_ENDED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_DELIVERY_ENDED, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_DELIVERY_STARTED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_DISTRIBUTION_ASSIGN_VAS_RESPONSE, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_DISTRIBUTION_UNREVOKED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
@@ -1290,7 +1290,7 @@ void OpcodeTable::InitializeServerOpcodes()
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_GET_DISTRIBUTION_LIST_RESPONSE, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_GET_PRODUCT_LIST_RESPONSE, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_GET_PURCHASE_LIST_RESPONSE, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_MOUNT_DELIVERED, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_MOUNT_DELIVERED, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_PURCHASE_UPDATE, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_START_CHECKOUT, STATUS_UNHANDLED, CONNECTION_TYPE_REALM);
|
||||
DEFINE_SERVER_OPCODE_HANDLER(SMSG_BATTLE_PAY_START_DISTRIBUTION_ASSIGN_TO_TARGET_RESPONSE, STATUS_NEVER, CONNECTION_TYPE_REALM);
|
||||
|
||||
@@ -2394,6 +2394,9 @@ public:
|
||||
void BattlePayProcessPurchase(uint32 productID);
|
||||
void HandleBattlePayConfirmPurchaseResponse(WorldPackets::BattlePay::ConfirmPurchaseResponse& confirmPurchaseResponse);
|
||||
void SendBattlePayDistributionList();
|
||||
// Purchase delivery notifications: SMSG_BATTLE_PAY_MOUNT_DELIVERED /
|
||||
// SMSG_BATTLE_PAY_COLLECTION_ITEM_DELIVERED per deliverable, then SMSG_BATTLE_PAY_DELIVERY_ENDED.
|
||||
void SendBattlePayDeliveryNotifications(ShopProduct const& product, uint64 purchaseID);
|
||||
|
||||
// In-game Shop entitlements ("distributions"): buy now, apply to a character later.
|
||||
void HandleBattlePayDistributionAssignToTarget(WorldPackets::BattlePay::DistributionAssignToTarget& assign);
|
||||
|
||||
Reference in New Issue
Block a user