Stabilize buyback flow and single-row buyback UI

- keep vendor buyback as one-item LIFO row in vendor window

- add robust pending buyback tracking with wire slot probing (74..85)

- recover from stale optimistic sell/buyback entries and refresh vendor list

- keep buy packet construction branch-compatible (direct packet build)
This commit is contained in:
Kelsi 2026-02-19 05:48:40 -08:00
parent 1fdf450c5e
commit 3c754801c4
3 changed files with 136 additions and 9 deletions

View file

@ -1542,6 +1542,7 @@ private:
std::deque<BuybackItem> buybackItems_;
std::unordered_map<uint64_t, BuybackItem> pendingSellToBuyback_;
int pendingBuybackSlot_ = -1;
uint32_t pendingBuybackWireSlot_ = 0;
uint32_t pendingBuyItemId_ = 0;
uint32_t pendingBuyItemSlot_ = 0;

View file

@ -977,6 +977,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")");
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
// Refresh vendor list so UI state stays in sync after buyback result.
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
@ -1722,6 +1723,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (result == 0) {
pendingSellToBuyback_.erase(itemGuid);
} else {
bool removedPending = false;
auto it = pendingSellToBuyback_.find(itemGuid);
if (it != pendingSellToBuyback_.end()) {
for (auto bit = buybackItems_.begin(); bit != buybackItems_.end(); ++bit) {
@ -1731,6 +1733,23 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
}
pendingSellToBuyback_.erase(it);
removedPending = true;
}
if (!removedPending) {
// Some cores return a non-item GUID on sell failure; drop the newest
// optimistic entry if it is still pending so stale rows don't block buyback.
if (!buybackItems_.empty()) {
uint64_t frontGuid = buybackItems_.front().itemGuid;
if (pendingSellToBuyback_.erase(frontGuid) > 0) {
buybackItems_.pop_front();
removedPending = true;
}
}
}
if (!removedPending && !pendingSellToBuyback_.empty()) {
// Last-resort desync recovery.
pendingSellToBuyback_.clear();
buybackItems_.clear();
}
static const char* sellErrors[] = {
"OK", "Can't find item", "Can't sell item",
@ -1827,8 +1846,42 @@ void GameHandler::handlePacket(network::Packet& packet) {
" item/slot=", itemIdOrSlot,
" err=", static_cast<int>(errCode),
" pendingBuybackSlot=", pendingBuybackSlot_,
" pendingBuybackWireSlot=", pendingBuybackWireSlot_,
" pendingBuyItemId=", pendingBuyItemId_,
" pendingBuyItemSlot=", pendingBuyItemSlot_);
if (pendingBuybackSlot_ >= 0) {
// Some cores require probing absolute buyback slots until a live entry is found.
if (errCode == 0) {
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
constexpr uint32_t kBuybackSlotEnd = 85;
if (pendingBuybackWireSlot_ >= 74 && pendingBuybackWireSlot_ < kBuybackSlotEnd &&
socket && state == WorldState::IN_WORLD && currentVendorItems.vendorGuid != 0) {
++pendingBuybackWireSlot_;
LOG_INFO("Buyback retry: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
std::dec, " uiSlot=", pendingBuybackSlot_,
" wireSlot=", pendingBuybackWireSlot_);
network::Packet retry(kWotlkCmsgBuybackItemOpcode);
retry.writeUInt64(currentVendorItems.vendorGuid);
retry.writeUInt32(pendingBuybackWireSlot_);
socket->send(retry);
break;
}
// Exhausted slot probe: drop stale local row and advance.
if (pendingBuybackSlot_ < static_cast<int>(buybackItems_.size())) {
buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_);
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
if (currentVendorItems.vendorGuid != 0 && socket && state == WorldState::IN_WORLD) {
auto pkt = ListInventoryPacket::build(currentVendorItems.vendorGuid);
socket->send(pkt);
}
break;
}
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
}
const char* msg = "Purchase failed.";
switch (errCode) {
case 0: msg = "Purchase failed: item not found."; break;
@ -9078,6 +9131,7 @@ void GameHandler::closeVendor() {
buybackItems_.clear();
pendingSellToBuyback_.clear();
pendingBuybackSlot_ = -1;
pendingBuybackWireSlot_ = 0;
pendingBuyItemId_ = 0;
pendingBuyItemSlot_ = 0;
}
@ -9089,7 +9143,14 @@ void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, u
" wire=0x", std::hex, wireOpcode(Opcode::CMSG_BUY_ITEM), std::dec);
pendingBuyItemId_ = itemId;
pendingBuyItemSlot_ = slot;
auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count);
// Build directly to avoid helper-signature drift across branches (3-arg vs 4-arg helper).
network::Packet packet(wireOpcode(Opcode::CMSG_BUY_ITEM));
packet.writeUInt64(vendorGuid);
packet.writeUInt32(itemId); // item entry
packet.writeUInt32(slot); // vendor slot index
packet.writeUInt32(count);
// WotLK/AzerothCore expects a trailing byte here.
packet.writeUInt8(0);
socket->send(packet);
}
@ -9102,13 +9163,18 @@ void GameHandler::buyBackItem(uint32_t buybackSlot) {
// This request is independent from normal buy path; avoid stale pending buy context in logs.
pendingBuyItemId_ = 0;
pendingBuyItemSlot_ = 0;
// Build directly so this compiles even when Opcode::CMSG_BUYBACK_ITEM / BuybackItemPacket
// are not available in some branches.
constexpr uint16_t kWotlkCmsgBuybackItemOpcode = 0x290;
LOG_INFO("Buyback request: vendorGuid=0x", std::hex, currentVendorItems.vendorGuid,
std::dec, " uiSlot=", buybackSlot, " wireSlot=", wireSlot,
" source=absolute-buyback-slot",
" wire=0x", std::hex,
wireOpcode(Opcode::CMSG_BUYBACK_ITEM), std::dec);
" wire=0x", std::hex, kWotlkCmsgBuybackItemOpcode, std::dec);
pendingBuybackSlot_ = static_cast<int>(buybackSlot);
auto packet = BuybackItemPacket::build(currentVendorItems.vendorGuid, wireSlot);
pendingBuybackWireSlot_ = wireSlot;
network::Packet packet(kWotlkCmsgBuybackItemOpcode);
packet.writeUInt64(currentVendorItems.vendorGuid);
packet.writeUInt32(wireSlot);
socket->send(packet);
}

View file

@ -361,8 +361,18 @@ void GameScreen::render(game::GameHandler& gameHandler) {
inventoryScreen.setVendorMode(gameHandler.isVendorWindowOpen(), &gameHandler);
// Auto-open bags when vendor window opens
if (gameHandler.isVendorWindowOpen() && !inventoryScreen.isOpen()) {
inventoryScreen.setOpen(true);
if (gameHandler.isVendorWindowOpen()) {
if (inventoryScreen.isSeparateBags()) {
if (!inventoryScreen.isBackpackOpen() &&
!inventoryScreen.isBagOpen(0) &&
!inventoryScreen.isBagOpen(1) &&
!inventoryScreen.isBagOpen(2) &&
!inventoryScreen.isBagOpen(3)) {
inventoryScreen.openAllBags();
}
} else if (!inventoryScreen.isOpen()) {
inventoryScreen.setOpen(true);
}
}
// Bags (B key toggle handled inside)
@ -800,7 +810,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
if (!bonusLine.empty()) {
ImGui::TextColored(green, "%s", bonusLine.c_str());
}
if (!isWeapon && info->armor > 0) {
if (info->armor > 0) {
ImGui::Text("%d Armor", info->armor);
}
if (info->sellPrice > 0) {
@ -826,7 +836,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) {
float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed;
ImGui::Text("%.1f DPS", dps);
}
if (!isWeaponInventoryType(eq->item.inventoryType) && eq->item.armor > 0) {
if (eq->item.armor > 0) {
ImGui::Text("%d Armor", eq->item.armor);
}
std::string eqBonusLine;
@ -4944,6 +4954,55 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell");
ImGui::Separator();
const auto& buyback = gameHandler.getBuybackItems();
if (!buyback.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Buy Back");
if (ImGui::BeginTable("BuybackTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f);
ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f);
ImGui::TableHeadersRow();
// Show only the most recently sold item (LIFO).
const int i = 0;
const auto& entry = buyback[0];
uint32_t sellPrice = entry.item.sellPrice;
if (sellPrice == 0) {
if (auto* info = gameHandler.getItemInfo(entry.item.itemId); info && info->valid) {
sellPrice = info->sellPrice;
}
}
uint64_t price = static_cast<uint64_t>(sellPrice) *
static_cast<uint64_t>(entry.count > 0 ? entry.count : 1);
uint32_t g = static_cast<uint32_t>(price / 10000);
uint32_t s = static_cast<uint32_t>((price / 100) % 100);
uint32_t c = static_cast<uint32_t>(price % 100);
bool canAfford = money >= price;
ImGui::TableNextRow();
ImGui::PushID(8000 + i);
ImGui::TableSetColumnIndex(0);
const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str();
if (entry.count > 1) {
ImGui::Text("%s x%u", name, entry.count);
} else {
ImGui::Text("%s", name);
}
ImGui::TableSetColumnIndex(1);
if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::Text("%ug %us %uc", g, s, c);
if (!canAfford) ImGui::PopStyleColor();
ImGui::TableSetColumnIndex(2);
if (!canAfford) ImGui::BeginDisabled();
if (ImGui::SmallButton("Buy Back##buyback_0")) {
gameHandler.buyBackItem(0);
}
if (!canAfford) ImGui::EndDisabled();
ImGui::PopID();
ImGui::EndTable();
}
ImGui::Separator();
}
if (vendor.items.empty()) {
ImGui::TextDisabled("This vendor has nothing for sale.");
} else {
@ -5021,7 +5080,8 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) {
}
ImGui::TableSetColumnIndex(3);
if (ImGui::SmallButton("Buy")) {
std::string buyBtnId = "Buy##vendor_" + std::to_string(vi);
if (ImGui::SmallButton(buyBtnId.c_str())) {
gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1);
}