Core bpay fixes

This commit is contained in:
luis
2026-09-20 17:39:29 -03:00
parent dc7c72ccfe
commit 195c203d7c
3 changed files with 373 additions and 23 deletions
+362 -22
View File
@@ -41,6 +41,7 @@
#include <fstream>
#include <memory>
#include <set>
#include <unordered_set>
#include <utility>
namespace
@@ -720,7 +721,7 @@ bool BattlePayMgr::AssembleCatalog(std::vector<uint8>& outBlob, std::unordered_m
outRouting.clear();
if (_templateBlob.empty())
return false;
return BuildCatalogFromDB(outBlob, outRouting);
// The writer now decodes ALL FOUR arrays (94 products, 116 deliverables, 21 groups, 97 shop
// entries) with a byte-exact round trip, instead of only the first 9 "simple shape" records.
@@ -810,33 +811,46 @@ bool BattlePayMgr::AssembleCatalog(std::vector<uint8>& outBlob, std::unordered_m
rec.DisplayInfo->Flags = displayInfoFlags;
};
// Build a name->shop_product index for automatic matching of retail catalog products to our
// gold-priced shop_product rows. This lets the template's display art (FileDataID, ModelSceneID,
// DisplayCards) survive while we patch prices and deliverables.
std::unordered_map<std::string, ShopProduct const*> nameIndex;
for (auto const& [id, product] : _products)
if (product.Enabled && !product.Name.empty())
nameIndex[product.Name] = &product;
// Track which shop_products have been assigned so we can emit unpinned ones as extras.
std::unordered_set<uint32> assignedProducts;
size_t candIdx = 0;
for (size_t slot = 0; slot < catalog.Products.size(); ++slot)
{
// The id the client actually purchases by. The previous code read record+81, which is really
// DisplayInfo.fileDataID - the card ARTWORK id - so the routing map was built from FileDataIDs
// and never resolved. Purchases only worked because GetProductByAdvertisedId falls through to
// GetProduct(id).
uint32 const slotProductId = catalog.Products[slot].ProductID;
ShopProduct const* assigned = nullptr;
// Priority 1: explicit slot override
auto ovr = _slotOverrides.find(uint8(slot));
if (ovr != _slotOverrides.end())
{
if (ovr->second != 0) // pinned; 0 = forced placeholder
assigned = GetProduct(ovr->second);
}
// NOTE: no automatic slot filling any more. The catalog we ship already describes 94 real
// products with our own gold prices patched in, and shop_product is keyed on those same
// wire productIDs, so every card resolves on its own. Auto-assigning candidates to slots
// here would stamp one product's name and price onto a different product's card - harmless
// when only 9 rows existed, actively wrong now there are 66. Explicit shop_slot_override
// pins still work above for anyone who wants to re-badge a specific slot.
// Priority 2: match by DisplayInfo name against shop_product names.
// This picks up the remaining ~57 shop_products that have no slot override but whose
// names appear in the retail catalog (e.g. "Anima Wyrmling", "Lil' Ursoc", etc.).
if (!assigned && catalog.Products[slot].DisplayInfo)
{
auto it = nameIndex.find(catalog.Products[slot].DisplayInfo->Name1);
if (it != nameIndex.end())
assigned = it->second;
}
if (assigned)
{
reskin(catalog.Products[slot], *assigned);
outRouting[slotProductId] = assigned->ProductID;
assignedProducts.insert(assigned->ProductID);
if (report)
report->append(Trinity::StringFormat(" slot {}: [{}] '{}' -> product {} (price {}, {}{})\n",
slot, slotProductId, assigned->Name, assigned->ProductID, assigned->Price,
@@ -844,22 +858,126 @@ bool BattlePayMgr::AssembleCatalog(std::vector<uint8>& outBlob, std::unordered_m
}
else
{
// Not pinned. Leave the shipped record's price alone - the blob already describes a real
// product with our gold price patched in, and shop_product is keyed on the same wire
// productID. Only give it a DisplayInfo when it has none, so the confirmation dialog can
// render a name instead of erroring on nil.
if (!catalog.Products[slot].DisplayInfo)
{
if (ShopProduct const* known = GetProduct(slotProductId))
catalog.Products[slot].DisplayInfo = BattlePayCatalogWriter::MakeDisplayInfo(known->Name, known->Description);
else
// Not pinned and no name match. Hide this retail-only card by clearing its
// DisplayInfo so the client treats it as an empty slot. This prevents the user
// from clicking a $-priced product that cannot be delivered.
catalog.Products[slot].DisplayInfo = BattlePayCatalogWriter::MakeDisplayInfo(placeholderName, std::string());
}
catalog.Products[slot].Flags = 0; // clear buyableHere bits so Buy button is greyed out
if (report)
report->append(Trinity::StringFormat(" slot {}: [{}] <placeholder - not purchasable>\n", slot, slotProductId));
report->append(Trinity::StringFormat(" slot {}: [{}] <hidden - not routable>\n", slot, slotProductId));
}
}
// Append shop_products that did NOT match any catalog slot as new entries at the end.
// These are products in the DB that have no retail template counterpart.
for (auto const& [id, product] : _products)
{
if (!product.Enabled || assignedProducts.count(product.ProductID))
continue;
BattlePayCatalogProduct catalogProd;
catalogProd.ProductID = product.ProductID;
catalogProd.Flags = 10;
catalogProd.Type = 0;
catalogProd.Eligibility = 0;
catalogProd.PmtProductID = product.ProductID;
catalogProd.RequiredDeliverableID = 0;
catalogProd.PadBits = 0;
catalogProd.BundledProductIDs = {};
uint64 displayPrice;
if (product.HasDisplayPrice)
displayPrice = product.DisplayPrice;
else if (product.Currency == 1)
displayPrice = (product.Price / 10000) * 100000;
else
displayPrice = 0;
catalogProd.NormalPriceFixedPoint = displayPrice;
catalogProd.CurrentPriceFixedPoint = displayPrice;
catalogProd.DisplayInfo = BattlePayCatalogWriter::MakeDisplayInfo(product.Name, product.Description);
// Build deliverables for this product
std::vector<uint32> delivIds;
for (size_t i = 0; i < product.Deliverables.size(); ++i)
{
uint32 delivId = uint32(catalog.Deliverables.size()) + 1;
ShopDeliverable const& dv = product.Deliverables[i];
BattlePayDeliverable catalogDv;
catalogDv.DeliverableID = delivId;
catalogDv.Name = product.Name;
catalogDv.AlreadyOwns = 0;
catalogDv.HasPetResult = 0;
catalogDv.PetResult = 0;
catalogDv.PadBits = 0;
switch (dv.Type)
{
case 1: catalogDv.Type = 14; catalogDv.ItemID = dv.Id; catalogDv.Quantity = dv.Count; break;
case 2: catalogDv.Type = 3; catalogDv.MountSpellID = dv.Id; break;
case 3: catalogDv.Type = 4; catalogDv.Quantity = dv.Count; break;
case 5: catalogDv.Type = dv.Id;
if (dv.Id == 1) catalogDv.BoostID = 11;
break;
default: catalogDv.Type = dv.Type; catalogDv.ItemID = dv.Id; catalogDv.Quantity = dv.Count; break;
}
catalog.Deliverables.push_back(catalogDv);
delivIds.push_back(delivId);
}
if (delivIds.empty())
{
uint32 delivId = uint32(catalog.Deliverables.size()) + 1;
BattlePayDeliverable catalogDv;
catalogDv.DeliverableID = delivId;
catalogDv.Type = 0;
catalogDv.ItemID = 0;
catalogDv.Quantity = 0;
catalogDv.Name = product.Name;
catalog.Deliverables.push_back(catalogDv);
delivIds.push_back(delivId);
}
catalogProd.DeliverableIDs = std::move(delivIds);
catalog.Products.push_back(catalogProd);
// Add a group if needed
bool groupExists = false;
for (auto const& g : catalog.Groups)
if (g.GroupID == product.GroupId) { groupExists = true; break; }
if (!groupExists)
{
BattlePayProductGroup group;
group.GroupID = product.GroupId;
group.Ordering = product.GroupId;
group.Flags = 0;
group.ParentGroupID = 0;
group.DisplayType = 0;
group.IconFileDataID = 0;
group.Name = "Category " + std::to_string(product.GroupId);
group.DisabledDescription = "";
catalog.Groups.push_back(group);
}
// Add a shop entry
BattlePayShopEntry entry;
entry.EntryID = uint32(catalog.Entries.size()) + 1;
entry.GroupID = product.GroupId;
entry.ProductID = product.ProductID;
entry.Ordering = product.Ordering;
entry.Flags = 0;
entry.BannerType = 0;
entry.DisplayInfo = BattlePayCatalogWriter::MakeDisplayInfo(product.Name, product.Description);
entry.PadBits = 0;
catalog.Entries.push_back(entry);
outRouting[product.ProductID] = product.ProductID;
if (report)
report->append(Trinity::StringFormat(" slot {}: [NEW] '{}' -> product {} (price {})\n",
catalog.Products.size() - 1, product.Name, product.ProductID, displayPrice));
}
if (report && candIdx < candidates.size())
report->append(Trinity::StringFormat(" OVERFLOW: {} enabled product(s) could not be shown (only {} slots).\n",
candidates.size() - candIdx, catalog.Products.size()));
@@ -1014,3 +1132,225 @@ std::string BattlePayMgr::BuildStatusReport() const
report.append(Trinity::StringFormat("Assembled blob: {} bytes, generation {}.", blob.size(), _catalogGeneration));
return report;
}
bool BattlePayMgr::BuildCatalogFromDB(std::vector<uint8>& outBlob, std::unordered_map<uint32, uint32>& outRouting) const
{
BattlePayCatalog catalog;
catalog.Result = 0;
catalog.CurrencyID = 0;
std::string const placeholderName = std::string(sConfigMgr->GetStringDefault("Shop.PlaceholderName", "Currently unavailable"));
// Collect all unique groupIds
std::set<uint32> groupIds;
for (auto const& [id, product] : _products)
if (product.Enabled)
groupIds.insert(product.GroupId);
// Build deliverable map: productId -> vector of deliverable IDs in the catalog
std::unordered_map<uint32, std::vector<uint32>> productDeliverableIds;
// Build deliverables ? mirror the type mapping in BuildDeliverable (BattlePayHandler.cpp:717).
// DB types: 1=item, 2=mount/spell, 3=WoW Token, 5=service.
// Catalog wire types: 14=Item/Toy, 3=Mount, 4=WowToken, service-id passthrough for type 5.
uint32 deliverableId = 1;
for (auto const& [id, product] : _products)
{
for (size_t i = 0; i < product.Deliverables.size(); ++i)
{
ShopDeliverable const& dv = product.Deliverables[i];
BattlePayDeliverable catalogDv;
catalogDv.DeliverableID = deliverableId;
catalogDv.Name = product.Name;
catalogDv.AlreadyOwns = 0;
catalogDv.HasPetResult = 0;
catalogDv.PetResult = 0;
catalogDv.PadBits = 0;
switch (dv.Type)
{
case 1: // item / toy
catalogDv.Type = 14;
catalogDv.ItemID = dv.Id;
catalogDv.Quantity = dv.Count;
break;
case 2: // mount / toy spell
catalogDv.Type = 3;
catalogDv.MountSpellID = dv.Id;
break;
case 3: // WoW Token
catalogDv.Type = 4;
catalogDv.Quantity = dv.Count;
break;
case 5: // service ? id is the catalog service type (1=Boost, 5=NameChange, etc.)
catalogDv.Type = dv.Id;
if (dv.Id == 1 /* SHOP_SERVICE_CHARACTER_BOOST */)
catalogDv.BoostID = 11;
break;
default:
catalogDv.Type = dv.Type;
catalogDv.ItemID = dv.Id;
catalogDv.Quantity = dv.Count;
break;
}
catalog.Deliverables.push_back(catalogDv);
productDeliverableIds[id].push_back(deliverableId);
++deliverableId;
}
// If no deliverables, add an empty one so the product is structurally valid
if (product.Deliverables.empty())
{
BattlePayDeliverable catalogDv;
catalogDv.DeliverableID = deliverableId;
catalogDv.Type = 0;
catalogDv.ItemID = 0;
catalogDv.Quantity = 0;
catalogDv.Name = product.Name;
catalog.Deliverables.push_back(catalogDv);
productDeliverableIds[id].push_back(deliverableId);
++deliverableId;
}
}
// Build groups
for (uint32 gid : groupIds)
{
BattlePayProductGroup group;
group.GroupID = gid;
group.Ordering = gid;
group.Flags = 0;
group.ParentGroupID = 0;
group.DisplayType = 0;
group.IconFileDataID = 0;
// Name the groups based on typical WoW store categories
switch (gid)
{
case 0: group.Name = "Services"; break;
case 13: group.Name = "Pets"; break;
case 15: group.Name = "Mounts"; break;
case 30: group.Name = "WoW Token"; break;
case 138: group.Name = "Toys"; break;
case 139: group.Name = "Transmog"; break;
case 158: group.Name = "Subscriptions"; break;
case 195: group.Name = "Hunter Pets"; break;
case 197: group.Name = "Vendor Mounts"; break;
case 209: group.Name = "Expansions"; break;
case 225: group.Name = "Bundles"; break;
default: group.Name = "Category " + std::to_string(gid); break;
}
group.DisabledDescription = "";
catalog.Groups.push_back(group);
}
// Build products + shop entries
uint32 entryId = 1;
for (auto const& [id, product] : _products)
{
if (!product.Enabled)
continue;
BattlePayCatalogProduct catalogProd;
catalogProd.ProductID = product.ProductID;
catalogProd.Flags = 10; // bits 1+3 drive buyableHere
catalogProd.Type = 0;
catalogProd.Eligibility = 0;
catalogProd.PmtProductID = product.ProductID;
catalogProd.RequiredDeliverableID = 0;
catalogProd.DeliverableIDs = productDeliverableIds[id];
catalogProd.BundledProductIDs = {};
catalogProd.PadBits = 0;
// Price
uint64 displayPrice;
if (product.HasDisplayPrice)
displayPrice = product.DisplayPrice;
else if (product.Currency == 1)
displayPrice = (product.Price / 10000) * 100000;
else
displayPrice = 0;
catalogProd.NormalPriceFixedPoint = displayPrice;
catalogProd.CurrentPriceFixedPoint = displayPrice;
// DisplayInfo
catalogProd.DisplayInfo = BattlePayCatalogWriter::MakeDisplayInfo(product.Name, product.Description);
catalog.Products.push_back(catalogProd);
// ShopEntry: one tile per product
BattlePayShopEntry entry;
entry.EntryID = entryId++;
entry.GroupID = product.GroupId;
entry.ProductID = product.ProductID;
entry.Ordering = product.Ordering;
entry.Flags = 0;
entry.BannerType = 0;
entry.DisplayInfo = BattlePayCatalogWriter::MakeDisplayInfo(product.Name, product.Description);
entry.PadBits = 0;
catalog.Entries.push_back(entry);
// Every product routes to itself
outRouting[product.ProductID] = product.ProductID;
}
outBlob = BattlePayCatalogWriter::Serialize(catalog);
TC_LOG_INFO("server.loading", "BattlePay: built catalog from DB ({} bytes, {} products, {} deliverables, {} groups, {} entries).",
outBlob.size(), catalog.Products.size(), catalog.Deliverables.size(),
catalog.Groups.size(), catalog.Entries.size());
// Hex dump first 64 bytes for format verification
if (outBlob.size() >= 64)
{
std::string hex;
hex.reserve(192);
for (size_t i = 0; i < 64; ++i)
{
char buf[4];
snprintf(buf, sizeof(buf), "%02x ", outBlob[i]);
hex += buf;
if (i == 15 || i == 31 || i == 47)
hex += "| ";
}
TC_LOG_INFO("server.loading", "BattlePay: blob hex (first 64): {}", hex);
}
// Self-check: Parse the blob back and re-serialize to verify round-trip integrity
{
BattlePayCatalog reparsed;
std::string parseError;
if (!BattlePayCatalogWriter::Parse(outBlob, reparsed, &parseError))
{
TC_LOG_ERROR("server.loading", "BattlePay: SelfCheck FAILED - parse error: {}", parseError);
}
else
{
std::vector<uint8> reSerialized = BattlePayCatalogWriter::Serialize(reparsed);
if (reSerialized != outBlob)
{
TC_LOG_ERROR("server.loading", "BattlePay: SelfCheck FAILED - round-trip mismatch! Original {} bytes, re-serialized {} bytes.",
outBlob.size(), reSerialized.size());
}
else
{
TC_LOG_INFO("server.loading", "BattlePay: SelfCheck OK - round-trip matches ({} bytes, {} products, {} deliverables, {} groups, {} entries).",
reSerialized.size(), reparsed.Products.size(), reparsed.Deliverables.size(),
reparsed.Groups.size(), reparsed.Entries.size());
// Log first product details
if (!reparsed.Products.empty())
{
auto const& p = reparsed.Products.front();
TC_LOG_INFO("server.loading", "BattlePay: First product: ID={}, Type={}, Flags={}, Eligibility={}, Price={}, CurrencyID={}, DeliverableIDs={}",
p.ProductID, p.Type, p.Flags, p.Eligibility, p.NormalPriceFixedPoint,
reparsed.CurrencyID, p.DeliverableIDs.size());
}
}
}
}
return true;
}
+3
View File
@@ -273,6 +273,9 @@ private:
bool AssembleCatalog(std::vector<uint8>& outBlob, std::unordered_map<uint32, uint32>& outRouting,
std::string* report) const;
// Builds a complete catalog blob from DB data alone, without the retail template blob.
bool BuildCatalogFromDB(std::vector<uint8>& outBlob, std::unordered_map<uint32, uint32>& outRouting) const;
std::vector<uint8> _templateBlob;
std::vector<uint8> _productListBlob;
std::vector<uint8> _distributionListBlob;
@@ -783,7 +783,14 @@ void WorldSession::SendBattlePayDistributionList()
}
if (!sBattlePayMgr->HasDistributionList())
{
// Build an empty distribution list to unblock the client's shop panel loading state.
WorldPackets::BattlePay::GetDistributionListResponse response;
response.BuildFromObjects = true;
response.Result = RESULT_OK;
SendPacket(response.Write());
return;
}
WorldPackets::BattlePay::GetDistributionListResponse response;
response.RawData = &sBattlePayMgr->GetDistributionListBlob();