Web-Shop2

This commit is contained in:
luis
2026-09-23 18:46:35 -03:00
parent 764eed6848
commit dbf1b1c0fe
5 changed files with 203 additions and 32 deletions
+72 -18
View File
@@ -445,6 +445,10 @@ void WorldSession::SendBattlePayDeliveryNotifications(ShopProduct const& product
SendPacket(notification.Write());
}
// Shop 2 learns ownership from SMSG_CATALOG_SHOP_OBTAIN_LICENSE (one license id per packet).
// The product id is the license id we answer in CATALOG_SHOP_LICENSE_DATA.
SendCatalogShopObtainLicense(product.ProductID);
WorldPackets::BattlePay::DeliveryEnded ended;
ended.PurchaseID = purchaseID;
ended.Products.emplace_back().ProductID = product.ProductID;
@@ -655,29 +659,73 @@ void WorldSession::HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenChec
}
}
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. The client sends this mid-checkout carrying its own
// license / game data (variable body: 876/140/52/36/24 bytes in the 12.1.0.69382 capture). Retail answers
// on SMSG opcode 0x4202C1 - which our opcode map currently mis-names SMSG_VAS_GET_QUEUE_MINUTES_RESPONSE
// (the sniff's 0x4502C1 is that opcode plus the sniff's fixed 0x030000 server-opcode bias) - with an
// 11465/1025/423/403/40-byte payload.
//
// That response is a nested reflection bitstream whose FIELD LAYOUT is not recovered - the capture gives
// only the sizes, not the member types. Emitting a guessed layout on that opcode would risk the client's
// reflection parser rejecting it and disconnecting, which is exactly the "do not invent wire" hazard. So
// this handler does the honest, safe thing: it makes the opcode an EXPECTED, real handler (no more
// STATUS_UNHANDLED / Handle_NULL), logs the body size so a future capture can be matched to a variant, and
// sends nothing. The 69382 capture shows the checkout proceeds (the client drives its own web overlay from
// OPEN_CHECKOUT) without the realm answering this, so withholding the un-modeled response does not stall
// the client. When the response wire is recovered, this is where it is sent.
namespace
{
// A shop product the client asked about by license id. Our catalog uses the product id as the
// license id (see SendCatalogShopObtainLicense). Only an item deliverable has a 49-byte type-14
// record that matches the 12.1.0.69933 capture; every other id is reported missing, which is the
// form retail itself uses for licenses it has no game data for.
bool BuildCatalogShopLicense(uint32 licenseId, WorldPackets::BattlePay::CatalogShopLicenseEntry& entry)
{
ShopProduct const* product = sBattlePayMgr->GetProduct(licenseId);
if (!product)
product = sBattlePayMgr->GetProductByAdvertisedId(licenseId);
if (!product)
return false;
for (ShopDeliverable const& deliverable : product->Deliverables)
{
if (deliverable.Type != 1 || !deliverable.Id)
continue;
entry = {};
entry.LicenseID = licenseId;
entry.Type = 14; // Item/Toy, the catalog deliverable vocabulary
entry.PrimaryID = deliverable.Id;
return true;
}
return false;
}
}
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. The 12.1 client sends batches of license ids (255, then
// the ones the first answer did not cover). Retail answers on SMSG_CATALOG_SHOP_LICENSE_DATA with the
// licenses it can describe and a 5-byte "missing" record for the rest. Answering is what lets the
// Shop 2 frame finish resolving product cards; a license we do not sell is reported missing, exactly
// as the 36-byte all-missing response in the 69933 capture.
void WorldSession::HandleCatalogShopLicenseGameDataRequest(WorldPackets::BattlePay::CatalogShopLicenseGameDataRequest& request)
{
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED))
return;
TC_LOG_INFO("network", "BattlePay: CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST from {} ({} body bytes). The paired "
"response (SMSG 0x4202C1) is an un-modeled reflection payload and is deliberately not answered - see the "
"handler note. If the client stalls waiting for it, capture the response bytes and model them here.",
GetPlayerInfo(), request.Data.size());
WorldPackets::BattlePay::CatalogShopLicenseData response;
response.Found.reserve(request.LicenseIDs.size());
response.Missing.reserve(request.LicenseIDs.size());
for (uint32 licenseId : request.LicenseIDs)
{
WorldPackets::BattlePay::CatalogShopLicenseEntry entry;
if (BuildCatalogShopLicense(licenseId, entry))
response.Found.push_back(std::move(entry));
else
response.Missing.push_back(licenseId);
}
TC_LOG_DEBUG("network", "BattlePay: CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST from {} ({} licenses, {} resolved).",
GetPlayerInfo(), request.LicenseIDs.size(), response.Found.size());
SendPacket(response.Write());
}
void WorldSession::SendCatalogShopObtainLicense(uint32 licenseId)
{
if (!sWorld->getBoolConfig(CONFIG_SHOP_ENABLED) || !licenseId)
return;
WorldPackets::BattlePay::CatalogShopObtainLicense packet;
packet.LicenseID = licenseId;
SendPacket(packet.Write());
}
namespace
@@ -1515,6 +1563,7 @@ void WorldSession::HandleBattlePayGetPurchaseList(WorldPackets::BattlePay::GetPu
if (result)
{
std::set<uint32> ownedLicenses;
do
{
Field* fields = result->Fetch();
@@ -1526,7 +1575,12 @@ void WorldSession::HandleBattlePayGetPurchaseList(WorldPackets::BattlePay::GetPu
rec.BasePrice = fields[4].GetUInt64();
rec.UserPrice = fields[5].GetUInt64();
rec.TimeCreated = fields[6].GetInt64();
if (rec.Status == STATUS_DONE && rec.ProductID)
ownedLicenses.insert(rec.ProductID);
} while (result->NextRow());
for (uint32 licenseId : ownedLicenses)
SendCatalogShopObtainLicense(licenseId);
}
SendPacket(response.Write());
@@ -183,13 +183,65 @@ namespace WorldPackets::BattlePay
void CatalogShopLicenseGameDataRequest::Read()
{
// The response wire is not yet modeled, so this body is retained for diagnostics only (its size
// identifies which of the captured request variants it is). Consume the whole body so the packet
// is not reported as under-read.
size_t const len = _worldPacket.size();
Data.resize(len);
if (len)
_worldPacket.read(Data.data(), len);
uint32 count = 0;
_worldPacket >> count;
// 255 and 65 are the batches in the 69933 capture. Anything larger than the remaining body,
// or an absurd count, is not a request we can answer honestly.
constexpr uint32 MAX_LICENSES = 4096;
if (count > MAX_LICENSES || _worldPacket.rpos() + size_t(count) * sizeof(uint32) > _worldPacket.size())
{
LicenseIDs.clear();
_worldPacket.rfinish();
return;
}
LicenseIDs.resize(count);
for (uint32& licenseId : LicenseIDs)
_worldPacket >> licenseId;
}
WorldPacket const* CatalogShopLicenseData::Write()
{
_worldPacket << Result;
_worldPacket << uint32(Found.size());
_worldPacket << uint32(Missing.size());
_worldPacket << Unknown;
for (CatalogShopLicenseEntry const& entry : Found)
{
_worldPacket << entry.Flags;
_worldPacket << entry.LicenseID;
_worldPacket << entry.Type;
_worldPacket << entry.PrimaryID;
_worldPacket << entry.Param2;
_worldPacket << entry.Param3;
_worldPacket << uint32(entry.Children.size());
_worldPacket << entry.SecondaryID;
_worldPacket << entry.Param6;
_worldPacket << entry.Param7;
_worldPacket << entry.Param8;
_worldPacket << entry.Param9;
_worldPacket << entry.Param10;
for (uint32 child : entry.Children)
_worldPacket << child;
for (uint32 extra : entry.Tail)
_worldPacket << extra;
}
for (uint32 licenseId : Missing)
{
_worldPacket << licenseId;
_worldPacket << uint8(0);
}
return &_worldPacket;
}
WorldPacket const* CatalogShopObtainLicense::Write()
{
_worldPacket << LicenseID;
return &_worldPacket;
}
WorldPacket const* StartPurchaseResponse::Write()
@@ -47,11 +47,8 @@ namespace WorldPackets
void Read() override {}
};
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. Sent by the client mid-checkout carrying its own
// license / game data. The body is variable (876/140/52/36/24 bytes across the 12.1.0.69382
// capture). We keep the whole body so the handler can log its size for future response modeling;
// the paired response wire (SMSG 0x4202C1) is not yet recovered - see
// WorldSession::HandleCatalogShopLicenseGameDataRequest.
// CMSG_CATALOG_SHOP_LICENSE_GAME_DATA_REQUEST. Body is a uint32 count followed by that many
// license ids. Proven on 12.1.0.69933: 1024 bytes = 1 + 255 ids, 264 = 1 + 65, 20 = 1 + 4.
class CatalogShopLicenseGameDataRequest final : public ClientPacket
{
public:
@@ -61,7 +58,62 @@ namespace WorldPackets
void Read() override;
std::vector<uint8> Data; // raw request body, retained for diagnostics only
std::vector<uint32> LicenseIDs;
};
// One resolved license inside SMSG_CATALOG_SHOP_LICENSE_DATA.
//
// Layout recovered from the 12.1.0.69933 capture (newshop.pkt). Every found record is
// u8 flags, u32 licenseId, then 11 uint32 slots, then ChildCount extra uint32s, then any
// type-specific tail dwords.
// Slot 0 is the deliverable type (14 = item). Slot 4 is the child count and is written from
// Children.size(), not stored twice. A type-14 item with no children is 49 bytes, which is
// the size of every item record in that capture.
struct CatalogShopLicenseEntry
{
uint8 Flags = 0;
uint32 LicenseID = 0;
uint32 Type = 0;
uint32 PrimaryID = 0;
uint32 Param2 = 0;
uint32 Param3 = 0;
uint32 SecondaryID = 0;
uint32 Param6 = 0;
uint32 Param7 = 0;
uint32 Param8 = 0;
uint32 Param9 = 0;
uint32 Param10 = 0;
std::vector<uint32> Children;
std::vector<uint32> Tail;
};
// SMSG_CATALOG_SHOP_LICENSE_DATA. Header proven on all three 69933 responses:
// u32 result (1), u32 foundCount, u32 missingCount, u32 unknown (1).
// Found records follow, then missing licenses as (u32 licenseId, u8 0) each. The 36-byte
// response in that capture is this header plus four missing licenses and nothing else.
class CatalogShopLicenseData final : public ServerPacket
{
public:
explicit CatalogShopLicenseData() : ServerPacket(SMSG_CATALOG_SHOP_LICENSE_DATA) {}
WorldPacket const* Write() override;
uint32 Result = 1;
uint32 Unknown = 1;
std::vector<CatalogShopLicenseEntry> Found;
std::vector<uint32> Missing;
};
// SMSG_CATALOG_SHOP_OBTAIN_LICENSE. Four bytes, one license id. Retail pushes one packet per
// license the account holds (69933 char-select and login both send two of these).
class CatalogShopObtainLicense final : public ServerPacket
{
public:
explicit CatalogShopObtainLicense() : ServerPacket(SMSG_CATALOG_SHOP_OBTAIN_LICENSE, 4) {}
WorldPacket const* Write() override;
uint32 LicenseID = 0;
};
// The 12.0.7 catalog is a nested reflection bitstream that cannot be re-serialized field-by-field
+1
View File
@@ -2245,6 +2245,7 @@ public:
void HandleBattlePayStartPurchase(WorldPackets::BattlePay::StartPurchase& startPurchase);
void HandleBattlePayOpenCheckout(WorldPackets::BattlePay::OpenCheckout & openCheckout);
void HandleCatalogShopLicenseGameDataRequest(WorldPackets::BattlePay::CatalogShopLicenseGameDataRequest& request);
void SendCatalogShopObtainLicense(uint32 licenseId);
void BattlePayProcessPurchase(uint32 productID);
void HandleBattlePayConfirmPurchaseResponse(WorldPackets::BattlePay::ConfirmPurchaseResponse& confirmPurchaseResponse);
void SendBattlePayDistributionList();
+13 -1
View File
@@ -231,7 +231,19 @@ void WriteProduct(JsonWriter& writer, ShopProduct const& product, CatalogEntry c
writer.Bool(true); // BattlePayMgr::IsPurchasable is the authority
WriteKeyUint(writer, "termTypeId", 0);
WriteKeyUint(writer, "termDuration", 0);
WriteKeyUint(writer, "serviceItemId", 0);
// The parser requires a number here. An item product publishes its item id so the Shop 2 card
// can resolve a preview; everything else stays 0, which is what an empty product already sent.
uint32 serviceItemId = 0;
for (ShopDeliverable const& deliverable : product.Deliverables)
{
if (deliverable.Type == 1 && deliverable.Id)
{
serviceItemId = deliverable.Id;
break;
}
}
WriteKeyUint(writer, "serviceItemId", serviceItemId);
WriteKey(writer, "licenses");
writer.StartArray();