2026-02-02 12:24:50 -08:00
|
|
|
|
#include "game/game_handler.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
|
#include "game/packet_parsers.hpp"
|
2026-02-10 21:29:10 -08:00
|
|
|
|
#include "game/transport_manager.hpp"
|
2026-02-12 02:09:15 -08:00
|
|
|
|
#include "game/warden_crypto.hpp"
|
2026-02-14 02:00:15 -08:00
|
|
|
|
#include "game/warden_memory.hpp"
|
2026-02-12 02:43:20 -08:00
|
|
|
|
#include "game/warden_module.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include "game/opcodes.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
|
#include "game/update_field_table.hpp"
|
2026-02-14 15:11:43 -08:00
|
|
|
|
#include "rendering/renderer.hpp"
|
2026-02-12 22:56:36 -08:00
|
|
|
|
#include "pipeline/dbc_layout.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include "network/world_socket.hpp"
|
|
|
|
|
|
#include "network/packet.hpp"
|
2026-02-12 02:22:04 -08:00
|
|
|
|
#include "auth/crypto.hpp"
|
2026-02-04 18:27:52 -08:00
|
|
|
|
#include "core/coordinates.hpp"
|
2026-02-07 14:21:50 -08:00
|
|
|
|
#include "core/application.hpp"
|
|
|
|
|
|
#include "pipeline/asset_manager.hpp"
|
|
|
|
|
|
#include "pipeline/dbc_loader.hpp"
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include "core/logger.hpp"
|
2026-02-12 00:04:53 -08:00
|
|
|
|
#include <glm/gtx/quaternion.hpp>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <algorithm>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <cmath>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <cctype>
|
2026-02-07 12:43:32 -08:00
|
|
|
|
#include <ctime>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <random>
|
2026-02-13 18:59:09 -08:00
|
|
|
|
#include <zlib.h>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
#include <chrono>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <filesystem>
|
|
|
|
|
|
#include <fstream>
|
|
|
|
|
|
#include <sstream>
|
|
|
|
|
|
#include <unordered_map>
|
2026-02-11 22:27:02 -08:00
|
|
|
|
#include <unordered_set>
|
2026-02-05 14:01:26 -08:00
|
|
|
|
#include <functional>
|
|
|
|
|
|
#include <cstdlib>
|
2026-02-13 18:59:09 -08:00
|
|
|
|
#include <cstring>
|
2026-02-12 03:50:28 -08:00
|
|
|
|
#include <openssl/sha.h>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
|
namespace game {
|
|
|
|
|
|
|
2026-02-12 02:27:59 -08:00
|
|
|
|
namespace {
|
|
|
|
|
|
const char* worldStateName(WorldState state) {
|
|
|
|
|
|
switch (state) {
|
|
|
|
|
|
case WorldState::DISCONNECTED: return "DISCONNECTED";
|
|
|
|
|
|
case WorldState::CONNECTING: return "CONNECTING";
|
|
|
|
|
|
case WorldState::CONNECTED: return "CONNECTED";
|
|
|
|
|
|
case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED";
|
|
|
|
|
|
case WorldState::AUTH_SENT: return "AUTH_SENT";
|
|
|
|
|
|
case WorldState::AUTHENTICATED: return "AUTHENTICATED";
|
|
|
|
|
|
case WorldState::READY: return "READY";
|
|
|
|
|
|
case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED";
|
|
|
|
|
|
case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED";
|
|
|
|
|
|
case WorldState::ENTERING_WORLD: return "ENTERING_WORLD";
|
|
|
|
|
|
case WorldState::IN_WORLD: return "IN_WORLD";
|
|
|
|
|
|
case WorldState::FAILED: return "FAILED";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "UNKNOWN";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
bool isAuthCharPipelineOpcode(LogicalOpcode op) {
|
|
|
|
|
|
switch (op) {
|
|
|
|
|
|
case Opcode::SMSG_AUTH_CHALLENGE:
|
|
|
|
|
|
case Opcode::SMSG_AUTH_RESPONSE:
|
|
|
|
|
|
case Opcode::SMSG_CLIENTCACHE_VERSION:
|
|
|
|
|
|
case Opcode::SMSG_TUTORIAL_FLAGS:
|
|
|
|
|
|
case Opcode::SMSG_WARDEN_DATA:
|
|
|
|
|
|
case Opcode::SMSG_CHAR_ENUM:
|
|
|
|
|
|
case Opcode::SMSG_CHAR_CREATE:
|
|
|
|
|
|
case Opcode::SMSG_CHAR_DELETE:
|
2026-02-12 02:27:59 -08:00
|
|
|
|
return true;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
GameHandler::GameHandler() {
|
|
|
|
|
|
LOG_DEBUG("GameHandler created");
|
2026-02-04 11:31:08 -08:00
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
// Initialize opcode table with WotLK defaults (may be overridden from JSON later)
|
|
|
|
|
|
opcodeTable_.loadWotlkDefaults();
|
|
|
|
|
|
setActiveOpcodeTable(&opcodeTable_);
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize update field table with WotLK defaults (may be overridden from JSON later)
|
|
|
|
|
|
updateFieldTable_.loadWotlkDefaults();
|
|
|
|
|
|
setActiveUpdateFieldTable(&updateFieldTable_);
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize packet parsers (WotLK default, may be replaced for other expansions)
|
|
|
|
|
|
packetParsers_ = std::make_unique<WotlkPacketParsers>();
|
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
|
// Initialize transport manager
|
|
|
|
|
|
transportManager_ = std::make_unique<TransportManager>();
|
|
|
|
|
|
|
2026-02-12 02:43:20 -08:00
|
|
|
|
// Initialize Warden module manager
|
|
|
|
|
|
wardenModuleManager_ = std::make_unique<WardenModuleManager>();
|
|
|
|
|
|
|
2026-02-04 11:31:08 -08:00
|
|
|
|
// Default spells always available
|
|
|
|
|
|
knownSpells.push_back(6603); // Attack
|
2026-02-08 03:05:38 -08:00
|
|
|
|
knownSpells.push_back(8690); // Hearthstone
|
2026-02-04 11:31:08 -08:00
|
|
|
|
|
|
|
|
|
|
// Default action bar layout
|
|
|
|
|
|
actionBar[0].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[0].id = 6603; // Attack in slot 1
|
2026-02-08 03:05:38 -08:00
|
|
|
|
actionBar[11].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[11].id = 8690; // Hearthstone in slot 12
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
GameHandler::~GameHandler() {
|
|
|
|
|
|
disconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
void GameHandler::setPacketParsers(std::unique_ptr<PacketParsers> parsers) {
|
|
|
|
|
|
packetParsers_ = std::move(parsers);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
bool GameHandler::connect(const std::string& host,
|
|
|
|
|
|
uint16_t port,
|
|
|
|
|
|
const std::vector<uint8_t>& sessionKey,
|
|
|
|
|
|
const std::string& accountName,
|
2026-02-13 01:51:49 -08:00
|
|
|
|
uint32_t build,
|
|
|
|
|
|
uint32_t realmId) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
if (sessionKey.size() != 40) {
|
|
|
|
|
|
LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)");
|
|
|
|
|
|
fail("Invalid session key");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" CONNECTING TO WORLD SERVER");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Host: ", host);
|
|
|
|
|
|
LOG_INFO("Port: ", port);
|
|
|
|
|
|
LOG_INFO("Account: ", accountName);
|
|
|
|
|
|
LOG_INFO("Build: ", build);
|
|
|
|
|
|
|
|
|
|
|
|
// Store authentication data
|
|
|
|
|
|
this->sessionKey = sessionKey;
|
|
|
|
|
|
this->accountName = accountName;
|
|
|
|
|
|
this->build = build;
|
2026-02-13 01:51:49 -08:00
|
|
|
|
this->realmId_ = realmId;
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
|
|
|
|
|
// Diagnostic: dump session key for AUTH_REJECT debugging
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string hex;
|
|
|
|
|
|
for (uint8_t b : sessionKey) { char buf[4]; snprintf(buf, sizeof(buf), "%02x", b); hex += buf; }
|
|
|
|
|
|
LOG_INFO("GameHandler session key (", sessionKey.size(), "): ", hex);
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
requiresWarden_ = false;
|
|
|
|
|
|
wardenGateSeen_ = false;
|
|
|
|
|
|
wardenGateElapsed_ = 0.0f;
|
|
|
|
|
|
wardenGateNextStatusLog_ = 2.0f;
|
|
|
|
|
|
wardenPacketsAfterGate_ = 0;
|
|
|
|
|
|
wardenCharEnumBlockedLogged_ = false;
|
2026-02-14 00:57:33 -08:00
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_USE;
|
|
|
|
|
|
wardenModuleHash_.clear();
|
|
|
|
|
|
wardenModuleKey_.clear();
|
|
|
|
|
|
wardenModuleSize_ = 0;
|
|
|
|
|
|
wardenModuleData_.clear();
|
2026-02-14 19:20:32 -08:00
|
|
|
|
wardenLoadedModule_.reset();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
// Generate random client seed
|
|
|
|
|
|
this->clientSeed = generateClientSeed();
|
|
|
|
|
|
LOG_DEBUG("Generated client seed: 0x", std::hex, clientSeed, std::dec);
|
|
|
|
|
|
|
|
|
|
|
|
// Create world socket
|
|
|
|
|
|
socket = std::make_unique<network::WorldSocket>();
|
|
|
|
|
|
|
|
|
|
|
|
// Set up packet callback
|
|
|
|
|
|
socket->setPacketCallback([this](const network::Packet& packet) {
|
|
|
|
|
|
network::Packet mutablePacket = packet;
|
|
|
|
|
|
handlePacket(mutablePacket);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Connect to world server
|
|
|
|
|
|
setState(WorldState::CONNECTING);
|
|
|
|
|
|
|
|
|
|
|
|
if (!socket->connect(host, port)) {
|
|
|
|
|
|
LOG_ERROR("Failed to connect to world server");
|
|
|
|
|
|
fail("Connection failed");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::CONNECTED);
|
|
|
|
|
|
LOG_INFO("Connected to world server, waiting for SMSG_AUTH_CHALLENGE...");
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::disconnect() {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (onTaxiFlight_) {
|
|
|
|
|
|
taxiRecoverPending_ = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
socket->disconnect();
|
|
|
|
|
|
socket.reset();
|
|
|
|
|
|
}
|
2026-02-06 20:49:17 -08:00
|
|
|
|
activeCharacterGuid_ = 0;
|
2026-02-06 21:25:35 -08:00
|
|
|
|
playerNameCache.clear();
|
|
|
|
|
|
pendingNameQueries.clear();
|
2026-02-12 00:04:53 -08:00
|
|
|
|
transportAttachments_.clear();
|
|
|
|
|
|
serverUpdatedTransportGuids_.clear();
|
2026-02-12 01:53:21 -08:00
|
|
|
|
requiresWarden_ = false;
|
|
|
|
|
|
wardenGateSeen_ = false;
|
|
|
|
|
|
wardenGateElapsed_ = 0.0f;
|
|
|
|
|
|
wardenGateNextStatusLog_ = 2.0f;
|
|
|
|
|
|
wardenPacketsAfterGate_ = 0;
|
|
|
|
|
|
wardenCharEnumBlockedLogged_ = false;
|
2026-02-14 00:57:33 -08:00
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_USE;
|
|
|
|
|
|
wardenModuleHash_.clear();
|
|
|
|
|
|
wardenModuleKey_.clear();
|
|
|
|
|
|
wardenModuleSize_ = 0;
|
|
|
|
|
|
wardenModuleData_.clear();
|
2026-02-14 19:20:32 -08:00
|
|
|
|
wardenLoadedModule_.reset();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
setState(WorldState::DISCONNECTED);
|
|
|
|
|
|
LOG_INFO("Disconnected from world server");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 19:27:35 -08:00
|
|
|
|
void GameHandler::resetDbcCaches() {
|
|
|
|
|
|
spellNameCacheLoaded_ = false;
|
|
|
|
|
|
spellNameCache_.clear();
|
|
|
|
|
|
skillLineDbcLoaded_ = false;
|
|
|
|
|
|
skillLineNames_.clear();
|
|
|
|
|
|
skillLineCategories_.clear();
|
|
|
|
|
|
skillLineAbilityLoaded_ = false;
|
|
|
|
|
|
spellToSkillLine_.clear();
|
|
|
|
|
|
taxiDbcLoaded_ = false;
|
|
|
|
|
|
taxiNodes_.clear();
|
|
|
|
|
|
taxiPathEdges_.clear();
|
|
|
|
|
|
taxiPathNodes_.clear();
|
|
|
|
|
|
talentDbcLoaded_ = false;
|
|
|
|
|
|
talentCache_.clear();
|
|
|
|
|
|
talentTabCache_.clear();
|
|
|
|
|
|
LOG_INFO("GameHandler: DBC caches cleared for expansion switch");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
bool GameHandler::isConnected() const {
|
|
|
|
|
|
return socket && socket->isConnected();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::update(float deltaTime) {
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Timing profiling (log every 60 frames to reduce spam)
|
|
|
|
|
|
static int profileCounter = 0;
|
|
|
|
|
|
static float socketTime = 0.0f;
|
|
|
|
|
|
static float taxiTime = 0.0f;
|
|
|
|
|
|
static float distanceCheckTime = 0.0f;
|
|
|
|
|
|
static float entityUpdateTime = 0.0f;
|
|
|
|
|
|
static float totalTime = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
auto updateStart = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
|
2026-02-05 14:18:41 -08:00
|
|
|
|
// Fire deferred char-create callback (outside ImGui render)
|
|
|
|
|
|
if (pendingCharCreateResult_) {
|
|
|
|
|
|
pendingCharCreateResult_ = false;
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(pendingCharCreateSuccess_, pendingCharCreateMsg_);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 23:52:16 -08:00
|
|
|
|
if (!socket) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update socket (processes incoming data and triggers callbacks)
|
2026-02-10 19:30:45 -08:00
|
|
|
|
auto socketStart = std::chrono::high_resolution_clock::now();
|
2026-02-05 12:01:03 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
socket->update();
|
|
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
auto socketEnd = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
socketTime += std::chrono::duration<float, std::milli>(socketEnd - socketStart).count();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-14 18:46:54 -08:00
|
|
|
|
// Detect server-side disconnect (socket closed during update)
|
|
|
|
|
|
if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) {
|
|
|
|
|
|
LOG_WARNING("Server closed connection in state: ", worldStateName(state));
|
|
|
|
|
|
disconnect();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
// Post-gate visibility: determine whether server goes silent or closes after Warden requirement.
|
2026-02-12 02:27:59 -08:00
|
|
|
|
if (wardenGateSeen_ && socket && socket->isConnected()) {
|
2026-02-12 01:53:21 -08:00
|
|
|
|
wardenGateElapsed_ += deltaTime;
|
|
|
|
|
|
if (wardenGateElapsed_ >= wardenGateNextStatusLog_) {
|
2026-02-16 00:45:47 -08:00
|
|
|
|
LOG_DEBUG("Warden gate status: elapsed=", wardenGateElapsed_,
|
2026-02-12 01:53:21 -08:00
|
|
|
|
"s connected=", socket->isConnected() ? "yes" : "no",
|
|
|
|
|
|
" packetsAfterGate=", wardenPacketsAfterGate_);
|
2026-02-16 00:45:47 -08:00
|
|
|
|
wardenGateNextStatusLog_ += 30.0f;
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Validate target still exists
|
|
|
|
|
|
if (targetGuid != 0 && !entityManager.hasEntity(targetGuid)) {
|
|
|
|
|
|
clearTarget();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
if (auctionSearchDelayTimer_ > 0.0f) {
|
|
|
|
|
|
auctionSearchDelayTimer_ -= deltaTime;
|
|
|
|
|
|
if (auctionSearchDelayTimer_ < 0.0f) auctionSearchDelayTimer_ = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 19:47:49 -08:00
|
|
|
|
if (pendingMoneyDeltaTimer_ > 0.0f) {
|
|
|
|
|
|
pendingMoneyDeltaTimer_ -= deltaTime;
|
|
|
|
|
|
if (pendingMoneyDeltaTimer_ <= 0.0f) {
|
|
|
|
|
|
pendingMoneyDeltaTimer_ = 0.0f;
|
|
|
|
|
|
pendingMoneyDelta_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
// Auto-inspect throttling (fallback for player equipment visuals).
|
|
|
|
|
|
if (inspectRateLimit_ > 0.0f) {
|
|
|
|
|
|
inspectRateLimit_ = std::max(0.0f, inspectRateLimit_ - deltaTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket && inspectRateLimit_ <= 0.0f && !pendingAutoInspect_.empty()) {
|
|
|
|
|
|
uint64_t guid = *pendingAutoInspect_.begin();
|
|
|
|
|
|
pendingAutoInspect_.erase(pendingAutoInspect_.begin());
|
|
|
|
|
|
if (guid != 0 && guid != playerGuid && entityManager.hasEntity(guid)) {
|
|
|
|
|
|
auto pkt = InspectPacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
2026-02-16 00:51:59 -08:00
|
|
|
|
inspectRateLimit_ = 2.0f; // throttle to avoid compositing stutter
|
|
|
|
|
|
LOG_DEBUG("Sent CMSG_INSPECT for player 0x", std::hex, guid, std::dec);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Send periodic heartbeat if in world
|
2026-02-06 23:52:16 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
timeSinceLastPing += deltaTime;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
timeSinceLastMoveHeartbeat_ += deltaTime;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
if (timeSinceLastPing >= pingInterval) {
|
2026-02-05 12:01:03 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendPing();
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
timeSinceLastPing = 0.0f;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_) ? 0.25f : moveHeartbeatInterval_;
|
|
|
|
|
|
if (timeSinceLastMoveHeartbeat_ >= heartbeatInterval) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
timeSinceLastMoveHeartbeat_ = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Update cast timer (Phase 3)
|
|
|
|
|
|
if (casting && castTimeRemaining > 0.0f) {
|
|
|
|
|
|
castTimeRemaining -= deltaTime;
|
|
|
|
|
|
if (castTimeRemaining <= 0.0f) {
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update spell cooldowns (Phase 3)
|
|
|
|
|
|
for (auto it = spellCooldowns.begin(); it != spellCooldowns.end(); ) {
|
|
|
|
|
|
it->second -= deltaTime;
|
|
|
|
|
|
if (it->second <= 0.0f) {
|
|
|
|
|
|
it = spellCooldowns.erase(it);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update action bar cooldowns
|
|
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.cooldownRemaining > 0.0f) {
|
|
|
|
|
|
slot.cooldownRemaining -= deltaTime;
|
|
|
|
|
|
if (slot.cooldownRemaining < 0.0f) slot.cooldownRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update combat text (Phase 2)
|
|
|
|
|
|
updateCombatText(deltaTime);
|
2026-02-05 12:01:03 -08:00
|
|
|
|
|
2026-02-08 23:15:26 -08:00
|
|
|
|
// Update taxi landing cooldown
|
|
|
|
|
|
if (taxiLandingCooldown_ > 0.0f) {
|
|
|
|
|
|
taxiLandingCooldown_ -= deltaTime;
|
|
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (taxiStartGrace_ > 0.0f) {
|
|
|
|
|
|
taxiStartGrace_ -= deltaTime;
|
|
|
|
|
|
}
|
2026-02-12 00:45:24 -08:00
|
|
|
|
if (playerTransportStickyTimer_ > 0.0f) {
|
|
|
|
|
|
playerTransportStickyTimer_ -= deltaTime;
|
|
|
|
|
|
if (playerTransportStickyTimer_ <= 0.0f) {
|
|
|
|
|
|
playerTransportStickyTimer_ = 0.0f;
|
|
|
|
|
|
playerTransportStickyGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 23:15:26 -08:00
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Taxi logic timing
|
|
|
|
|
|
auto taxiStart = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared
|
|
|
|
|
|
if (onTaxiFlight_) {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
updateClientTaxi(deltaTime);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
auto unit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
|
|
|
|
|
if (unit &&
|
|
|
|
|
|
(unit->getUnitFlags() & 0x00000100) == 0 &&
|
|
|
|
|
|
!taxiClientActive_ &&
|
2026-02-11 21:14:35 -08:00
|
|
|
|
!taxiActivatePending_ &&
|
|
|
|
|
|
taxiStartGrace_ <= 0.0f) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
onTaxiFlight_ = false;
|
|
|
|
|
|
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
|
|
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
|
LOG_INFO("Taxi flight landed");
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Safety: if taxi flight ended but mount is still active, force dismount.
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Guard against transient taxi-state flicker.
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (!onTaxiFlight_ && taxiMountActive_) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
bool serverStillTaxi = false;
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
|
|
|
|
|
if (playerUnit) {
|
|
|
|
|
|
serverStillTaxi = (playerUnit->getUnitFlags() & 0x00000100) != 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (taxiStartGrace_ > 0.0f || serverStillTaxi || taxiClientActive_ || taxiActivatePending_) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
onTaxiFlight_ = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (mountCallback_) mountCallback_(0);
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Taxi dismount cleanup");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Keep non-taxi mount state server-authoritative.
|
|
|
|
|
|
// Some server paths don't emit explicit mount field updates in lockstep
|
|
|
|
|
|
// with local visual state changes, so reconcile continuously.
|
|
|
|
|
|
if (!onTaxiFlight_ && !taxiMountActive_) {
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity);
|
|
|
|
|
|
if (playerUnit) {
|
|
|
|
|
|
uint32_t serverMountDisplayId = playerUnit->getMountDisplayId();
|
|
|
|
|
|
if (serverMountDisplayId != currentMountDisplayId_) {
|
|
|
|
|
|
LOG_INFO("Mount reconcile: server=", serverMountDisplayId,
|
|
|
|
|
|
" local=", currentMountDisplayId_);
|
|
|
|
|
|
currentMountDisplayId_ = serverMountDisplayId;
|
|
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(serverMountDisplayId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (taxiRecoverPending_ && state == WorldState::IN_WORLD) {
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
if (playerEntity) {
|
|
|
|
|
|
playerEntity->setPosition(taxiRecoverPos_.x, taxiRecoverPos_.y,
|
|
|
|
|
|
taxiRecoverPos_.z, movementInfo.orientation);
|
|
|
|
|
|
movementInfo.x = taxiRecoverPos_.x;
|
|
|
|
|
|
movementInfo.y = taxiRecoverPos_.y;
|
|
|
|
|
|
movementInfo.z = taxiRecoverPos_.z;
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
LOG_INFO("Taxi recovery applied");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (taxiActivatePending_) {
|
|
|
|
|
|
taxiActivateTimer_ += deltaTime;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
if (taxiActivateTimer_ > 5.0f) {
|
|
|
|
|
|
// If client taxi simulation is already active, server reply may be missing/late.
|
|
|
|
|
|
// Do not cancel the flight in that case; clear pending state and continue.
|
|
|
|
|
|
if (onTaxiFlight_ || taxiClientActive_ || taxiMountActive_) {
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
} else {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
onTaxiFlight_ = false;
|
|
|
|
|
|
LOG_WARNING("Taxi activation timed out");
|
2026-02-11 22:27:02 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
auto taxiEnd = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
taxiTime += std::chrono::duration<float, std::milli>(taxiEnd - taxiStart).count();
|
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
|
// Update transport manager
|
|
|
|
|
|
if (transportManager_) {
|
|
|
|
|
|
transportManager_->update(deltaTime);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
updateAttachedTransportChildren(deltaTime);
|
2026-02-10 21:29:10 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Distance check timing
|
|
|
|
|
|
auto distanceStart = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// Leave combat if auto-attack target is too far away (leash range)
|
|
|
|
|
|
if (autoAttacking && autoAttackTarget != 0) {
|
|
|
|
|
|
auto targetEntity = entityManager.getEntity(autoAttackTarget);
|
|
|
|
|
|
if (targetEntity) {
|
|
|
|
|
|
float dx = movementInfo.x - targetEntity->getX();
|
|
|
|
|
|
float dy = movementInfo.y - targetEntity->getY();
|
|
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
if (dist > 40.0f) {
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
LOG_INFO("Left combat: target too far (", dist, " yards)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
// Close vendor/gossip/taxi window if player walks too far from NPC
|
2026-02-07 18:33:14 -08:00
|
|
|
|
if (vendorWindowOpen && currentVendorItems.vendorGuid != 0) {
|
|
|
|
|
|
auto npc = entityManager.getEntity(currentVendorItems.vendorGuid);
|
|
|
|
|
|
if (npc) {
|
|
|
|
|
|
float dx = movementInfo.x - npc->getX();
|
|
|
|
|
|
float dy = movementInfo.y - npc->getY();
|
|
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
if (dist > 15.0f) {
|
|
|
|
|
|
closeVendor();
|
|
|
|
|
|
LOG_INFO("Vendor closed: walked too far from NPC");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 19:44:03 -08:00
|
|
|
|
if (gossipWindowOpen && currentGossip.npcGuid != 0) {
|
|
|
|
|
|
auto npc = entityManager.getEntity(currentGossip.npcGuid);
|
|
|
|
|
|
if (npc) {
|
|
|
|
|
|
float dx = movementInfo.x - npc->getX();
|
|
|
|
|
|
float dy = movementInfo.y - npc->getY();
|
|
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
if (dist > 15.0f) {
|
|
|
|
|
|
closeGossip();
|
|
|
|
|
|
LOG_INFO("Gossip closed: walked too far from NPC");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (taxiWindowOpen_ && taxiNpcGuid_ != 0) {
|
|
|
|
|
|
auto npc = entityManager.getEntity(taxiNpcGuid_);
|
|
|
|
|
|
if (npc) {
|
|
|
|
|
|
float dx = movementInfo.x - npc->getX();
|
|
|
|
|
|
float dy = movementInfo.y - npc->getY();
|
|
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
if (dist > 15.0f) {
|
|
|
|
|
|
closeTaxi();
|
|
|
|
|
|
LOG_INFO("Taxi window closed: walked too far from NPC");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 14:33:39 -08:00
|
|
|
|
if (trainerWindowOpen_ && currentTrainerList_.trainerGuid != 0) {
|
|
|
|
|
|
auto npc = entityManager.getEntity(currentTrainerList_.trainerGuid);
|
|
|
|
|
|
if (npc) {
|
|
|
|
|
|
float dx = movementInfo.x - npc->getX();
|
|
|
|
|
|
float dy = movementInfo.y - npc->getY();
|
|
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy);
|
|
|
|
|
|
if (dist > 15.0f) {
|
|
|
|
|
|
closeTrainer();
|
|
|
|
|
|
LOG_INFO("Trainer closed: walked too far from NPC");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
auto distanceEnd = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
distanceCheckTime += std::chrono::duration<float, std::milli>(distanceEnd - distanceStart).count();
|
|
|
|
|
|
|
|
|
|
|
|
// Entity update timing
|
|
|
|
|
|
auto entityStart = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
|
2026-02-06 14:24:38 -08:00
|
|
|
|
// Update entity movement interpolation (keeps targeting in sync with visuals)
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Only update entities within reasonable distance for performance
|
|
|
|
|
|
const float updateRadiusSq = 150.0f * 150.0f; // 150 unit radius
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
glm::vec3 playerPos = playerEntity ? glm::vec3(playerEntity->getX(), playerEntity->getY(), playerEntity->getZ()) : glm::vec3(0.0f);
|
|
|
|
|
|
|
2026-02-06 14:24:38 -08:00
|
|
|
|
for (auto& [guid, entity] : entityManager.getEntities()) {
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Always update player
|
|
|
|
|
|
if (guid == playerGuid) {
|
|
|
|
|
|
entity->updateMovement(deltaTime);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Distance cull other entities
|
|
|
|
|
|
glm::vec3 entityPos(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
|
float distSq = glm::dot(entityPos - playerPos, entityPos - playerPos);
|
|
|
|
|
|
if (distSq < updateRadiusSq) {
|
|
|
|
|
|
entity->updateMovement(deltaTime);
|
|
|
|
|
|
}
|
2026-02-06 14:24:38 -08:00
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
|
|
|
|
|
|
auto entityEnd = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
entityUpdateTime += std::chrono::duration<float, std::milli>(entityEnd - entityStart).count();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto updateEnd = std::chrono::high_resolution_clock::now();
|
|
|
|
|
|
totalTime += std::chrono::duration<float, std::milli>(updateEnd - updateStart).count();
|
|
|
|
|
|
|
|
|
|
|
|
// Log profiling every 60 frames
|
|
|
|
|
|
if (++profileCounter >= 60) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
LOG_DEBUG("UPDATE PROFILE (60 frames): socket=", socketTime / 60.0f, "ms taxi=", taxiTime / 60.0f,
|
2026-02-10 19:30:45 -08:00
|
|
|
|
"ms distance=", distanceCheckTime / 60.0f, "ms entity=", entityUpdateTime / 60.0f,
|
|
|
|
|
|
"ms TOTAL=", totalTime / 60.0f, "ms");
|
|
|
|
|
|
profileCounter = 0;
|
|
|
|
|
|
socketTime = 0.0f;
|
|
|
|
|
|
taxiTime = 0.0f;
|
|
|
|
|
|
distanceCheckTime = 0.0f;
|
|
|
|
|
|
entityUpdateTime = 0.0f;
|
|
|
|
|
|
totalTime = 0.0f;
|
2026-02-07 00:12:39 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handlePacket(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() < 1) {
|
|
|
|
|
|
LOG_WARNING("Received empty packet");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint16_t opcode = packet.getOpcode();
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto preLogicalOp = opcodeTable_.fromWire(opcode);
|
|
|
|
|
|
if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) {
|
2026-02-12 01:53:21 -08:00
|
|
|
|
++wardenPacketsAfterGate_;
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_INFO("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec,
|
|
|
|
|
|
" state=", worldStateName(state),
|
|
|
|
|
|
" size=", packet.getSize());
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec,
|
|
|
|
|
|
" size=", packet.getSize(), " bytes");
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
// Translate wire opcode to logical opcode via expansion table
|
|
|
|
|
|
auto logicalOp = opcodeTable_.fromWire(opcode);
|
|
|
|
|
|
if (!logicalOp) {
|
|
|
|
|
|
LOG_DEBUG("Unknown wire opcode 0x", std::hex, opcode, std::dec, " - ignoring");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
switch (*logicalOp) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_AUTH_CHALLENGE:
|
|
|
|
|
|
if (state == WorldState::CONNECTED) {
|
|
|
|
|
|
handleAuthChallenge(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_AUTH_RESPONSE:
|
|
|
|
|
|
if (state == WorldState::AUTH_SENT) {
|
|
|
|
|
|
handleAuthResponse(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
case Opcode::SMSG_CHAR_CREATE:
|
|
|
|
|
|
handleCharCreateResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-06 03:24:46 -08:00
|
|
|
|
case Opcode::SMSG_CHAR_DELETE: {
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
2026-02-06 18:34:45 -08:00
|
|
|
|
lastCharDeleteResult_ = result;
|
|
|
|
|
|
bool success = (result == 0x00 || result == 0x47); // Common success codes
|
2026-02-06 03:24:46 -08:00
|
|
|
|
LOG_INFO("SMSG_CHAR_DELETE result: ", (int)result, success ? " (success)" : " (failed)");
|
2026-02-06 18:34:45 -08:00
|
|
|
|
requestCharacterList();
|
2026-02-06 03:24:46 -08:00
|
|
|
|
if (charDeleteCallback_) charDeleteCallback_(success);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_CHAR_ENUM:
|
|
|
|
|
|
if (state == WorldState::CHAR_LIST_REQUESTED) {
|
|
|
|
|
|
handleCharEnum(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_LOGIN_VERIFY_WORLD:
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
handleLoginVerifyWorld(packet);
|
|
|
|
|
|
} else {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
case Opcode::SMSG_LOGIN_SETTIMESPEED:
|
|
|
|
|
|
// Can be received during login or at any time after
|
|
|
|
|
|
handleLoginSetTimeSpeed(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
case Opcode::SMSG_CLIENTCACHE_VERSION:
|
|
|
|
|
|
// Early pre-world packet in some realms (e.g. Warmane profile)
|
|
|
|
|
|
handleClientCacheVersion(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_TUTORIAL_FLAGS:
|
|
|
|
|
|
// Often sent during char-list stage (8x uint32 tutorial flags)
|
|
|
|
|
|
handleTutorialFlags(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_WARDEN_DATA:
|
|
|
|
|
|
handleWardenData(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_ACCOUNT_DATA_TIMES:
|
|
|
|
|
|
// Can be received at any time after authentication
|
|
|
|
|
|
handleAccountDataTimes(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_MOTD:
|
|
|
|
|
|
// Can be received at any time after entering world
|
|
|
|
|
|
handleMotd(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_PONG:
|
|
|
|
|
|
// Can be received at any time after entering world
|
|
|
|
|
|
handlePong(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_OBJECT:
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Can be received after entering world
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleUpdateObject(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-05 21:55:52 -08:00
|
|
|
|
case Opcode::SMSG_COMPRESSED_UPDATE_OBJECT:
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(state), " size=", packet.getSize());
|
2026-02-05 21:55:52 -08:00
|
|
|
|
// Compressed version of UPDATE_OBJECT
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleCompressedUpdateObject(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
case Opcode::SMSG_DESTROY_OBJECT:
|
|
|
|
|
|
// Can be received after entering world
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleDestroyObject(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_MESSAGECHAT:
|
|
|
|
|
|
// Can be received after entering world
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleMessageChat(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
case Opcode::SMSG_TEXT_EMOTE:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleTextEmote(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_CHANNEL_NOTIFY:
|
2026-02-16 20:16:14 -08:00
|
|
|
|
// Accept during ENTERING_WORLD too — server auto-joins channels before VERIFY_WORLD
|
|
|
|
|
|
if (state == WorldState::IN_WORLD || state == WorldState::ENTERING_WORLD) {
|
2026-02-14 14:30:09 -08:00
|
|
|
|
handleChannelNotify(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
case Opcode::SMSG_QUERY_TIME_RESPONSE:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleQueryTimeResponse(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_PLAYED_TIME:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handlePlayedTime(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_WHO:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleWho(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
case Opcode::SMSG_FRIEND_STATUS:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleFriendStatus(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::MSG_RANDOM_ROLL:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleRandomRoll(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-07 12:58:11 -08:00
|
|
|
|
case Opcode::SMSG_LOGOUT_RESPONSE:
|
|
|
|
|
|
handleLogoutResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_LOGOUT_COMPLETE:
|
|
|
|
|
|
handleLogoutComplete(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 1: Foundation ----
|
|
|
|
|
|
case Opcode::SMSG_NAME_QUERY_RESPONSE:
|
|
|
|
|
|
handleNameQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case Opcode::SMSG_CREATURE_QUERY_RESPONSE:
|
|
|
|
|
|
handleCreatureQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
case Opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE:
|
|
|
|
|
|
handleItemQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
case Opcode::SMSG_INSPECT_TALENT:
|
2026-02-13 20:26:55 -08:00
|
|
|
|
handleInspectResults(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-05 12:07:58 -08:00
|
|
|
|
// ---- XP ----
|
|
|
|
|
|
case Opcode::SMSG_LOG_XPGAIN:
|
|
|
|
|
|
handleXpGain(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
// ---- Creature Movement ----
|
|
|
|
|
|
case Opcode::SMSG_MONSTER_MOVE:
|
|
|
|
|
|
handleMonsterMove(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
|
case Opcode::SMSG_MONSTER_MOVE_TRANSPORT:
|
|
|
|
|
|
handleMonsterMoveTransport(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-07 17:59:40 -08:00
|
|
|
|
// ---- Speed Changes ----
|
|
|
|
|
|
case Opcode::SMSG_FORCE_RUN_SPEED_CHANGE:
|
|
|
|
|
|
handleForceRunSpeedChange(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 2: Combat ----
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSTART:
|
|
|
|
|
|
handleAttackStart(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ATTACKSTOP:
|
|
|
|
|
|
handleAttackStop(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ATTACKERSTATEUPDATE:
|
|
|
|
|
|
handleAttackerStateUpdate(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELLNONMELEEDAMAGELOG:
|
|
|
|
|
|
handleSpellDamageLog(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELLHEALLOG:
|
|
|
|
|
|
handleSpellHealLog(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Phase 3: Spells ----
|
|
|
|
|
|
case Opcode::SMSG_INITIAL_SPELLS:
|
|
|
|
|
|
handleInitialSpells(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_CAST_FAILED:
|
|
|
|
|
|
handleCastFailed(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELL_START:
|
|
|
|
|
|
handleSpellStart(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELL_GO:
|
|
|
|
|
|
handleSpellGo(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELL_FAILURE:
|
|
|
|
|
|
// Spell failed mid-cast
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SPELL_COOLDOWN:
|
|
|
|
|
|
handleSpellCooldown(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_COOLDOWN_EVENT:
|
|
|
|
|
|
handleCooldownEvent(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AURA_UPDATE:
|
|
|
|
|
|
handleAuraUpdate(packet, false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AURA_UPDATE_ALL:
|
|
|
|
|
|
handleAuraUpdate(packet, true);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LEARNED_SPELL:
|
|
|
|
|
|
handleLearnedSpell(packet);
|
|
|
|
|
|
break;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_SUPERCEDED_SPELL:
|
|
|
|
|
|
handleSupercededSpell(packet);
|
|
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_REMOVED_SPELL:
|
|
|
|
|
|
handleRemovedSpell(packet);
|
|
|
|
|
|
break;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_SEND_UNLEARN_SPELLS:
|
|
|
|
|
|
handleUnlearnSpells(packet);
|
|
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
// ---- Talents ----
|
|
|
|
|
|
case Opcode::SMSG_TALENTS_INFO:
|
|
|
|
|
|
handleTalentsInfo(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 4: Group ----
|
|
|
|
|
|
case Opcode::SMSG_GROUP_INVITE:
|
|
|
|
|
|
handleGroupInvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_DECLINE:
|
|
|
|
|
|
handleGroupDecline(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_LIST:
|
|
|
|
|
|
handleGroupList(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_UNINVITE:
|
|
|
|
|
|
handleGroupUninvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_PARTY_COMMAND_RESULT:
|
|
|
|
|
|
handlePartyCommandResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
// ---- Guild ----
|
|
|
|
|
|
case Opcode::SMSG_GUILD_INFO:
|
|
|
|
|
|
handleGuildInfo(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_ROSTER:
|
|
|
|
|
|
handleGuildRoster(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_QUERY_RESPONSE:
|
|
|
|
|
|
handleGuildQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_EVENT:
|
|
|
|
|
|
handleGuildEvent(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_INVITE:
|
|
|
|
|
|
handleGuildInvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GUILD_COMMAND_RESULT:
|
|
|
|
|
|
handleGuildCommandResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ---- Phase 5: Loot/Gossip/Vendor ----
|
|
|
|
|
|
case Opcode::SMSG_LOOT_RESPONSE:
|
|
|
|
|
|
handleLootResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LOOT_RELEASE_RESPONSE:
|
|
|
|
|
|
handleLootReleaseResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_LOOT_REMOVED:
|
|
|
|
|
|
handleLootRemoved(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GOSSIP_MESSAGE:
|
|
|
|
|
|
handleGossipMessage(packet);
|
|
|
|
|
|
break;
|
2026-02-08 03:32:00 -08:00
|
|
|
|
case Opcode::SMSG_BINDPOINTUPDATE: {
|
|
|
|
|
|
BindPointUpdateData data;
|
|
|
|
|
|
if (BindPointUpdateParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_INFO("Bindpoint updated: mapId=", data.mapId,
|
|
|
|
|
|
" pos=(", data.x, ", ", data.y, ", ", data.z, ")");
|
2026-02-08 03:39:02 -08:00
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(data.x, data.y, data.z));
|
2026-02-16 21:16:25 -08:00
|
|
|
|
// Only show message if bind point was already set (not initial login sync)
|
|
|
|
|
|
bool wasSet = hasHomeBind_;
|
2026-02-08 03:39:02 -08:00
|
|
|
|
hasHomeBind_ = true;
|
|
|
|
|
|
homeBindMapId_ = data.mapId;
|
|
|
|
|
|
homeBindPos_ = canonical;
|
2026-02-08 03:32:00 -08:00
|
|
|
|
if (bindPointCallback_) {
|
|
|
|
|
|
bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z);
|
|
|
|
|
|
}
|
2026-02-16 21:16:25 -08:00
|
|
|
|
if (wasSet) {
|
|
|
|
|
|
addSystemChatMessage("Your home has been set.");
|
|
|
|
|
|
}
|
2026-02-08 03:32:00 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_BINDPOINTUPDATE");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_GOSSIP_COMPLETE:
|
|
|
|
|
|
handleGossipComplete(packet);
|
|
|
|
|
|
break;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
case Opcode::SMSG_SPIRIT_HEALER_CONFIRM: {
|
2026-02-07 21:12:54 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) {
|
2026-02-07 23:12:24 -08:00
|
|
|
|
LOG_WARNING("SMSG_SPIRIT_HEALER_CONFIRM too short");
|
2026-02-07 21:12:54 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
|
|
|
|
LOG_INFO("Spirit healer confirm from 0x", std::hex, npcGuid, std::dec);
|
|
|
|
|
|
if (npcGuid) {
|
|
|
|
|
|
resurrectCasterGuid_ = npcGuid;
|
|
|
|
|
|
resurrectRequestPending_ = true;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
case Opcode::SMSG_RESURRECT_REQUEST: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) {
|
|
|
|
|
|
LOG_WARNING("SMSG_RESURRECT_REQUEST too short");
|
2026-02-07 21:47:14 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 23:12:24 -08:00
|
|
|
|
uint64_t casterGuid = packet.readUInt64();
|
|
|
|
|
|
LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec);
|
|
|
|
|
|
if (casterGuid) {
|
|
|
|
|
|
resurrectCasterGuid_ = casterGuid;
|
|
|
|
|
|
resurrectRequestPending_ = true;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_RESURRECT_CANCEL: {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
|
|
|
|
|
LOG_WARNING("SMSG_RESURRECT_CANCEL too short");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t reason = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("Resurrect cancel reason: ", reason);
|
|
|
|
|
|
resurrectPending_ = false;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
resurrectRequestPending_ = false;
|
2026-02-07 21:12:54 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_LIST_INVENTORY:
|
|
|
|
|
|
handleListInventory(packet);
|
|
|
|
|
|
break;
|
2026-02-08 14:33:39 -08:00
|
|
|
|
case Opcode::SMSG_TRAINER_LIST:
|
|
|
|
|
|
handleTrainerList(packet);
|
|
|
|
|
|
break;
|
2026-02-08 14:46:01 -08:00
|
|
|
|
case Opcode::SMSG_TRAINER_BUY_SUCCEEDED: {
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
(void)guid;
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Add to known spells immediately for prerequisite re-evaluation
|
|
|
|
|
|
// (SMSG_LEARNED_SPELL may come separately, but we need immediate update)
|
|
|
|
|
|
bool alreadyKnown = std::find(knownSpells.begin(), knownSpells.end(), spellId) != knownSpells.end();
|
|
|
|
|
|
if (!alreadyKnown) {
|
|
|
|
|
|
knownSpells.push_back(spellId);
|
|
|
|
|
|
LOG_INFO("Added spell ", spellId, " to known spells (trainer purchase)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
const std::string& name = getSpellName(spellId);
|
|
|
|
|
|
if (!name.empty())
|
|
|
|
|
|
addSystemChatMessage("You have learned " + name + ".");
|
|
|
|
|
|
else
|
|
|
|
|
|
addSystemChatMessage("Spell learned.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_TRAINER_BUY_FAILED: {
|
|
|
|
|
|
// Server rejected the spell purchase
|
|
|
|
|
|
// Packet format: uint64 trainerGuid, uint32 spellId, uint32 errorCode
|
|
|
|
|
|
uint64_t trainerGuid = packet.readUInt64();
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t errorCode = 0;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
errorCode = packet.readUInt32();
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_WARNING("Trainer buy spell failed: guid=", trainerGuid,
|
|
|
|
|
|
" spellId=", spellId, " error=", errorCode);
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& spellName = getSpellName(spellId);
|
|
|
|
|
|
std::string msg = "Cannot learn ";
|
|
|
|
|
|
if (!spellName.empty()) msg += spellName;
|
|
|
|
|
|
else msg += "spell #" + std::to_string(spellId);
|
|
|
|
|
|
|
|
|
|
|
|
// Common error reasons
|
|
|
|
|
|
if (errorCode == 0) msg += " (not enough money)";
|
|
|
|
|
|
else if (errorCode == 1) msg += " (not enough skill)";
|
|
|
|
|
|
else if (errorCode == 2) msg += " (already known)";
|
|
|
|
|
|
else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")";
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
// Silently ignore common packets we don't handle yet
|
|
|
|
|
|
case Opcode::SMSG_FEATURE_SYSTEM_STATUS:
|
|
|
|
|
|
case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER:
|
|
|
|
|
|
case Opcode::SMSG_SET_PCT_SPELL_MODIFIER:
|
|
|
|
|
|
case Opcode::SMSG_SPELL_DELAYED:
|
|
|
|
|
|
case Opcode::SMSG_UPDATE_AURA_DURATION:
|
|
|
|
|
|
case Opcode::SMSG_PERIODICAURALOG:
|
|
|
|
|
|
case Opcode::SMSG_SPELLENERGIZELOG:
|
|
|
|
|
|
case Opcode::SMSG_ENVIRONMENTALDAMAGELOG:
|
2026-02-13 19:52:49 -08:00
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
case Opcode::SMSG_LOOT_MONEY_NOTIFY: {
|
2026-02-13 19:52:49 -08:00
|
|
|
|
// Format: uint32 money + uint8 soleLooter
|
2026-02-06 13:47:03 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t amount = packet.readUInt32();
|
|
|
|
|
|
playerMoneyCopper_ += amount;
|
2026-02-13 19:47:49 -08:00
|
|
|
|
pendingMoneyDelta_ = amount;
|
|
|
|
|
|
pendingMoneyDeltaTimer_ = 2.0f;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
LOG_INFO("Looted ", amount, " copper (total: ", playerMoneyCopper_, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_LOOT_CLEAR_MONEY:
|
|
|
|
|
|
case Opcode::SMSG_NPC_TEXT_UPDATE:
|
2026-02-06 19:50:22 -08:00
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SELL_ITEM: {
|
|
|
|
|
|
// uint64 vendorGuid, uint64 itemGuid, uint8 result
|
|
|
|
|
|
if ((packet.getSize() - packet.getReadPos()) >= 17) {
|
|
|
|
|
|
packet.readUInt64(); // vendorGuid
|
|
|
|
|
|
packet.readUInt64(); // itemGuid
|
|
|
|
|
|
uint8_t result = packet.readUInt8();
|
|
|
|
|
|
if (result != 0) {
|
|
|
|
|
|
static const char* sellErrors[] = {
|
|
|
|
|
|
"OK", "Can't find item", "Can't sell item",
|
|
|
|
|
|
"Can't find vendor", "You don't own that item",
|
|
|
|
|
|
"Unknown error", "Only empty bag"
|
|
|
|
|
|
};
|
|
|
|
|
|
const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error";
|
|
|
|
|
|
addSystemChatMessage(std::string("Sell failed: ") + msg);
|
|
|
|
|
|
LOG_WARNING("SMSG_SELL_ITEM error: ", (int)result, " (", msg, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_INVENTORY_CHANGE_FAILURE: {
|
|
|
|
|
|
if ((packet.getSize() - packet.getReadPos()) >= 1) {
|
|
|
|
|
|
uint8_t error = packet.readUInt8();
|
|
|
|
|
|
if (error != 0) {
|
|
|
|
|
|
LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error);
|
2026-02-07 13:59:39 -08:00
|
|
|
|
// InventoryResult enum (AzerothCore 3.3.5a)
|
|
|
|
|
|
const char* errMsg = nullptr;
|
2026-02-06 19:50:22 -08:00
|
|
|
|
switch (error) {
|
2026-02-07 13:59:39 -08:00
|
|
|
|
case 1: errMsg = "You must reach level %d to use that item."; break;
|
|
|
|
|
|
case 2: errMsg = "You don't have the required skill."; break;
|
|
|
|
|
|
case 3: errMsg = "That item doesn't go in that slot."; break;
|
|
|
|
|
|
case 4: errMsg = "That bag is full."; break;
|
|
|
|
|
|
case 5: errMsg = "Can't put bags in bags."; break;
|
|
|
|
|
|
case 6: errMsg = "Can't trade equipped bags."; break;
|
|
|
|
|
|
case 7: errMsg = "That slot only holds ammo."; break;
|
|
|
|
|
|
case 8: errMsg = "You can't use that item."; break;
|
|
|
|
|
|
case 9: errMsg = "No equipment slot available."; break;
|
|
|
|
|
|
case 10: errMsg = "You can never use that item."; break;
|
|
|
|
|
|
case 11: errMsg = "You can never use that item."; break;
|
|
|
|
|
|
case 12: errMsg = "No equipment slot available."; break;
|
|
|
|
|
|
case 13: errMsg = "Can't equip with a two-handed weapon."; break;
|
|
|
|
|
|
case 14: errMsg = "Can't dual-wield."; break;
|
|
|
|
|
|
case 15: errMsg = "That item doesn't go in that bag."; break;
|
|
|
|
|
|
case 16: errMsg = "That item doesn't go in that bag."; break;
|
|
|
|
|
|
case 17: errMsg = "You can't carry any more of those."; break;
|
|
|
|
|
|
case 18: errMsg = "No equipment slot available."; break;
|
|
|
|
|
|
case 19: errMsg = "Can't stack those items."; break;
|
|
|
|
|
|
case 20: errMsg = "That item can't be equipped."; break;
|
|
|
|
|
|
case 21: errMsg = "Can't swap items."; break;
|
|
|
|
|
|
case 22: errMsg = "That slot is empty."; break;
|
|
|
|
|
|
case 23: errMsg = "Item not found."; break;
|
|
|
|
|
|
case 24: errMsg = "Can't drop soulbound items."; break;
|
|
|
|
|
|
case 25: errMsg = "Out of range."; break;
|
|
|
|
|
|
case 26: errMsg = "Need to split more than 1."; break;
|
|
|
|
|
|
case 27: errMsg = "Split failed."; break;
|
|
|
|
|
|
case 28: errMsg = "Not enough reagents."; break;
|
|
|
|
|
|
case 29: errMsg = "Not enough money."; break;
|
|
|
|
|
|
case 30: errMsg = "Not a bag."; break;
|
|
|
|
|
|
case 31: errMsg = "Can't destroy non-empty bag."; break;
|
|
|
|
|
|
case 32: errMsg = "You don't own that item."; break;
|
|
|
|
|
|
case 33: errMsg = "You can only have one quiver."; break;
|
|
|
|
|
|
case 34: errMsg = "No free bank slots."; break;
|
|
|
|
|
|
case 35: errMsg = "No bank here."; break;
|
|
|
|
|
|
case 36: errMsg = "Item is locked."; break;
|
|
|
|
|
|
case 37: errMsg = "You are stunned."; break;
|
|
|
|
|
|
case 38: errMsg = "You are dead."; break;
|
|
|
|
|
|
case 39: errMsg = "Can't do that right now."; break;
|
|
|
|
|
|
case 40: errMsg = "Internal bag error."; break;
|
|
|
|
|
|
case 49: errMsg = "Loot is gone."; break;
|
|
|
|
|
|
case 50: errMsg = "Inventory is full."; break;
|
|
|
|
|
|
case 51: errMsg = "Bank is full."; break;
|
|
|
|
|
|
case 52: errMsg = "That item is sold out."; break;
|
|
|
|
|
|
case 58: errMsg = "That object is busy."; break;
|
|
|
|
|
|
case 60: errMsg = "Can't do that in combat."; break;
|
|
|
|
|
|
case 61: errMsg = "Can't do that while disarmed."; break;
|
|
|
|
|
|
case 63: errMsg = "Requires a higher rank."; break;
|
|
|
|
|
|
case 64: errMsg = "Requires higher reputation."; break;
|
|
|
|
|
|
case 67: errMsg = "That item is unique-equipped."; break;
|
|
|
|
|
|
case 69: errMsg = "Not enough honor points."; break;
|
|
|
|
|
|
case 70: errMsg = "Not enough arena points."; break;
|
|
|
|
|
|
case 77: errMsg = "Too much gold."; break;
|
|
|
|
|
|
case 78: errMsg = "Can't do that during arena match."; break;
|
|
|
|
|
|
case 80: errMsg = "Requires a personal arena rating."; break;
|
|
|
|
|
|
case 87: errMsg = "Requires a higher level."; break;
|
|
|
|
|
|
case 88: errMsg = "Requires the right talent."; break;
|
|
|
|
|
|
default: break;
|
2026-02-06 19:50:22 -08:00
|
|
|
|
}
|
2026-02-07 13:59:39 -08:00
|
|
|
|
std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ").";
|
2026-02-06 19:50:22 -08:00
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_BUY_FAILED:
|
|
|
|
|
|
case Opcode::MSG_RAID_TARGET_UPDATE:
|
2026-02-06 13:47:03 -08:00
|
|
|
|
break;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE:
|
|
|
|
|
|
handleGameObjectQueryResponse(packet);
|
|
|
|
|
|
break;
|
2026-02-06 20:10:10 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_STATUS: {
|
|
|
|
|
|
// uint64 npcGuid + uint8 status
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 9) {
|
|
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t status = packet.readUInt8();
|
|
|
|
|
|
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
|
|
|
|
|
LOG_DEBUG("SMSG_QUESTGIVER_STATUS: guid=0x", std::hex, npcGuid, std::dec, " status=", (int)status);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_QUESTGIVER_STATUS_MULTIPLE: {
|
|
|
|
|
|
// uint32 count, then count * (uint64 guid + uint8 status)
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t count = packet.readUInt32();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 9) break;
|
|
|
|
|
|
uint64_t npcGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t status = packet.readUInt8();
|
|
|
|
|
|
npcQuestStatus_[npcGuid] = static_cast<QuestGiverStatus>(status);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("SMSG_QUESTGIVER_STATUS_MULTIPLE: ", count, " entries");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_DETAILS:
|
2026-02-06 11:59:51 -08:00
|
|
|
|
handleQuestDetails(packet);
|
|
|
|
|
|
break;
|
2026-02-09 23:22:14 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_INVALID: {
|
|
|
|
|
|
// Quest query failed - parse failure reason
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t failReason = packet.readUInt32();
|
|
|
|
|
|
const char* reasonStr = "Unknown";
|
|
|
|
|
|
switch (failReason) {
|
|
|
|
|
|
case 0: reasonStr = "Don't have quest"; break;
|
|
|
|
|
|
case 1: reasonStr = "Quest level too low"; break;
|
|
|
|
|
|
case 4: reasonStr = "Insufficient money"; break;
|
|
|
|
|
|
case 5: reasonStr = "Inventory full"; break;
|
|
|
|
|
|
case 13: reasonStr = "Already on that quest"; break;
|
|
|
|
|
|
case 18: reasonStr = "Already completed quest"; break;
|
|
|
|
|
|
case 19: reasonStr = "Can't take any more quests"; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_WARNING("Quest invalid: reason=", failReason, " (", reasonStr, ")");
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// Only show error to user for real errors (not informational messages)
|
|
|
|
|
|
if (failReason != 13 && failReason != 18) { // Don't spam "already on/completed"
|
|
|
|
|
|
addSystemChatMessage(std::string("Quest unavailable: ") + reasonStr);
|
|
|
|
|
|
}
|
2026-02-09 23:22:14 -08:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_QUEST_COMPLETE: {
|
|
|
|
|
|
// Mark quest as complete in local log
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
2026-02-09 23:22:14 -08:00
|
|
|
|
LOG_INFO("Quest completed: questId=", questId);
|
2026-02-06 20:16:38 -08:00
|
|
|
|
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
|
|
|
|
|
|
if (it->questId == questId) {
|
|
|
|
|
|
questLog_.erase(it);
|
2026-02-09 22:56:38 -08:00
|
|
|
|
LOG_INFO(" Removed quest ", questId, " from quest log");
|
2026-02-06 13:47:03 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 20:16:38 -08:00
|
|
|
|
// Re-query all nearby quest giver NPCs so markers refresh
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
|
|
|
|
|
if (entity->getType() != ObjectType::UNIT) continue;
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
|
|
|
|
|
if (unit->getNpcFlags() & 0x02) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 20:16:38 -08:00
|
|
|
|
qsPkt.writeUInt64(guid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 13:47:03 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_ADD_KILL: {
|
|
|
|
|
|
// Quest kill count update
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 16) {
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
|
|
|
|
|
uint32_t entry = packet.readUInt32(); // Creature entry
|
|
|
|
|
|
uint32_t count = packet.readUInt32(); // Current kills
|
|
|
|
|
|
uint32_t reqCount = packet.readUInt32(); // Required kills
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Quest kill update: questId=", questId, " entry=", entry,
|
|
|
|
|
|
" count=", count, "/", reqCount);
|
|
|
|
|
|
|
|
|
|
|
|
// Update quest log with kill count
|
|
|
|
|
|
for (auto& quest : questLog_) {
|
|
|
|
|
|
if (quest.questId == questId) {
|
|
|
|
|
|
// Store kill progress (using entry as objective index)
|
|
|
|
|
|
quest.killCounts[entry] = {count, reqCount};
|
|
|
|
|
|
|
|
|
|
|
|
// Show progress message
|
|
|
|
|
|
std::string progressMsg = quest.title + ": " +
|
|
|
|
|
|
std::to_string(count) + "/" +
|
|
|
|
|
|
std::to_string(reqCount);
|
|
|
|
|
|
addSystemChatMessage(progressMsg);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Updated kill count for quest ", questId, ": ",
|
|
|
|
|
|
count, "/", reqCount);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case Opcode::SMSG_QUESTUPDATE_COMPLETE: {
|
|
|
|
|
|
// Quest objectives completed - mark as ready to turn in
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("Quest objectives completed: questId=", questId);
|
|
|
|
|
|
|
|
|
|
|
|
for (auto& quest : questLog_) {
|
|
|
|
|
|
if (quest.questId == questId) {
|
|
|
|
|
|
quest.complete = true;
|
|
|
|
|
|
addSystemChatMessage("Quest Complete: " + quest.title);
|
|
|
|
|
|
LOG_INFO("Marked quest ", questId, " as complete");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-09 23:53:17 -08:00
|
|
|
|
case Opcode::SMSG_QUEST_QUERY_RESPONSE: {
|
|
|
|
|
|
// Quest data from server (big packet with title, objectives, rewards, etc.)
|
|
|
|
|
|
LOG_INFO("SMSG_QUEST_QUERY_RESPONSE: packet size=", packet.getSize());
|
|
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() < 8) {
|
|
|
|
|
|
LOG_WARNING("SMSG_QUEST_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t questId = packet.readUInt32();
|
|
|
|
|
|
uint32_t questMethod = packet.readUInt32(); // Quest method/type
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO(" questId=", questId, " questMethod=", questMethod);
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// Parse quest title (after method comes level, flags, type, etc., then title string)
|
|
|
|
|
|
// Skip intermediate fields to get to title
|
|
|
|
|
|
if (packet.getReadPos() + 16 < packet.getSize()) {
|
|
|
|
|
|
packet.readUInt32(); // quest level
|
|
|
|
|
|
packet.readUInt32(); // min level
|
|
|
|
|
|
packet.readUInt32(); // sort ID (zone)
|
|
|
|
|
|
packet.readUInt32(); // quest type/info
|
|
|
|
|
|
packet.readUInt32(); // suggested players
|
|
|
|
|
|
packet.readUInt32(); // reputation objective faction
|
|
|
|
|
|
packet.readUInt32(); // reputation objective value
|
|
|
|
|
|
packet.readUInt32(); // required opposite faction
|
|
|
|
|
|
packet.readUInt32(); // next quest in chain
|
|
|
|
|
|
packet.readUInt32(); // XP ID
|
|
|
|
|
|
packet.readUInt32(); // reward or required money
|
|
|
|
|
|
packet.readUInt32(); // reward money max level
|
|
|
|
|
|
packet.readUInt32(); // reward spell
|
|
|
|
|
|
packet.readUInt32(); // reward spell cast
|
|
|
|
|
|
packet.readUInt32(); // reward honor
|
|
|
|
|
|
packet.readUInt32(); // reward honor multiplier
|
|
|
|
|
|
packet.readUInt32(); // source item ID
|
|
|
|
|
|
packet.readUInt32(); // quest flags
|
|
|
|
|
|
// ... there are many more fields before title, let's try to read title string
|
|
|
|
|
|
if (packet.getReadPos() + 1 < packet.getSize()) {
|
|
|
|
|
|
std::string title = packet.readString();
|
|
|
|
|
|
LOG_INFO(" Quest title: ", title);
|
|
|
|
|
|
|
|
|
|
|
|
// Update quest log entry with title
|
|
|
|
|
|
for (auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId) {
|
|
|
|
|
|
q.title = title;
|
|
|
|
|
|
LOG_INFO("Updated quest log entry ", questId, " with title: ", title);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-09 23:53:17 -08:00
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
case Opcode::SMSG_QUESTLOG_FULL: {
|
|
|
|
|
|
LOG_INFO("***** RECEIVED SMSG_QUESTLOG_FULL *****");
|
|
|
|
|
|
LOG_INFO(" Packet size: ", packet.getSize());
|
|
|
|
|
|
LOG_INFO(" Server uses SMSG_QUESTLOG_FULL for quest log sync!");
|
|
|
|
|
|
// TODO: Parse quest log entries from this packet
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS:
|
2026-02-06 21:50:15 -08:00
|
|
|
|
handleQuestRequestItems(packet);
|
|
|
|
|
|
break;
|
2026-02-06 11:45:35 -08:00
|
|
|
|
case Opcode::SMSG_QUESTGIVER_OFFER_REWARD:
|
2026-02-06 21:50:15 -08:00
|
|
|
|
handleQuestOfferReward(packet);
|
|
|
|
|
|
break;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
case Opcode::SMSG_GROUP_SET_LEADER:
|
|
|
|
|
|
LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// ---- Teleport / Transfer ----
|
|
|
|
|
|
case Opcode::MSG_MOVE_TELEPORT_ACK:
|
|
|
|
|
|
handleTeleportAck(packet);
|
|
|
|
|
|
break;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
case Opcode::SMSG_TRANSFER_PENDING: {
|
|
|
|
|
|
// SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data
|
|
|
|
|
|
uint32_t pendingMapId = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
|
|
|
|
|
|
// Optional: if remaining data, there's a transport entry + mapId
|
|
|
|
|
|
if (packet.getReadPos() + 8 <= packet.getSize()) {
|
|
|
|
|
|
uint32_t transportEntry = packet.readUInt32();
|
|
|
|
|
|
uint32_t transportMapId = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO(" Transport entry=", transportEntry, " transportMapId=", transportMapId);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
break;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
case Opcode::SMSG_NEW_WORLD:
|
|
|
|
|
|
handleNewWorld(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_TRANSFER_ABORTED: {
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0;
|
|
|
|
|
|
LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason);
|
|
|
|
|
|
addSystemChatMessage("Transfer aborted.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
|
|
|
|
|
|
// ---- Taxi / Flight Paths ----
|
|
|
|
|
|
case Opcode::SMSG_SHOWTAXINODES:
|
|
|
|
|
|
handleShowTaxiNodes(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ACTIVATETAXIREPLY:
|
2026-02-08 03:05:38 -08:00
|
|
|
|
case Opcode::SMSG_ACTIVATETAXIREPLY_ALT:
|
2026-02-07 16:59:20 -08:00
|
|
|
|
handleActivateTaxiReply(packet);
|
|
|
|
|
|
break;
|
2026-02-07 17:59:40 -08:00
|
|
|
|
case Opcode::SMSG_NEW_TAXI_PATH:
|
|
|
|
|
|
// Empty packet - server signals a new flight path was learned
|
|
|
|
|
|
// The actual node details come in the next SMSG_SHOWTAXINODES
|
|
|
|
|
|
addSystemChatMessage("New flight path discovered!");
|
|
|
|
|
|
break;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
// ---- Arena / Battleground ----
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_STATUS:
|
|
|
|
|
|
handleBattlefieldStatus(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_LIST:
|
|
|
|
|
|
LOG_INFO("Received SMSG_BATTLEFIELD_LIST");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BATTLEFIELD_PORT_DENIED:
|
|
|
|
|
|
addSystemChatMessage("Battlefield port denied.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE:
|
|
|
|
|
|
addSystemChatMessage("You have been removed from the PvP queue.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_GROUP_JOINED_BATTLEGROUND:
|
|
|
|
|
|
addSystemChatMessage("Your group has joined the battleground.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE:
|
|
|
|
|
|
addSystemChatMessage("You have joined the battleground queue.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED:
|
|
|
|
|
|
LOG_INFO("Battleground player joined");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT:
|
|
|
|
|
|
LOG_INFO("Battleground player left");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT:
|
|
|
|
|
|
handleArenaTeamCommandResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_QUERY_RESPONSE:
|
|
|
|
|
|
handleArenaTeamQueryResponse(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_ROSTER:
|
|
|
|
|
|
LOG_INFO("Received SMSG_ARENA_TEAM_ROSTER");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_INVITE:
|
|
|
|
|
|
handleArenaTeamInvite(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_EVENT:
|
|
|
|
|
|
handleArenaTeamEvent(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_TEAM_STATS:
|
|
|
|
|
|
LOG_INFO("Received SMSG_ARENA_TEAM_STATS");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_ARENA_ERROR:
|
|
|
|
|
|
handleArenaError(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::MSG_PVP_LOG_DATA:
|
|
|
|
|
|
LOG_INFO("Received MSG_PVP_LOG_DATA");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::MSG_INSPECT_ARENA_TEAMS:
|
|
|
|
|
|
LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS");
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// ---- MSG_MOVE_* opcodes (server relays other players' movement) ----
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_FORWARD:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_BACKWARD:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_STRAFE_LEFT:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_STRAFE_RIGHT:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP_STRAFE:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_JUMP:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_TURN_LEFT:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_TURN_RIGHT:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP_TURN:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_SET_FACING:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_FALL_LAND:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_HEARTBEAT:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_SWIM:
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP_SWIM:
|
|
|
|
|
|
if (state == WorldState::IN_WORLD) {
|
|
|
|
|
|
handleOtherPlayerMovement(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
|
// ---- Mail ----
|
|
|
|
|
|
case Opcode::SMSG_SHOW_MAILBOX:
|
|
|
|
|
|
handleShowMailbox(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_MAIL_LIST_RESULT:
|
|
|
|
|
|
handleMailListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_SEND_MAIL_RESULT:
|
|
|
|
|
|
handleSendMailResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_RECEIVED_MAIL:
|
|
|
|
|
|
handleReceivedMail(packet);
|
|
|
|
|
|
break;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
case Opcode::MSG_QUERY_NEXT_MAIL_TIME:
|
|
|
|
|
|
handleQueryNextMailTime(packet);
|
|
|
|
|
|
break;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
// ---- Bank ----
|
|
|
|
|
|
case Opcode::SMSG_SHOW_BANK:
|
|
|
|
|
|
handleShowBank(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_BUY_BANK_SLOT_RESULT:
|
|
|
|
|
|
handleBuyBankSlotResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Guild Bank ----
|
|
|
|
|
|
case Opcode::SMSG_GUILD_BANK_LIST:
|
|
|
|
|
|
handleGuildBankList(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Auction House ----
|
|
|
|
|
|
case Opcode::MSG_AUCTION_HELLO:
|
|
|
|
|
|
handleAuctionHello(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_LIST_RESULT:
|
|
|
|
|
|
handleAuctionListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_OWNER_LIST_RESULT:
|
|
|
|
|
|
handleAuctionOwnerListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_BIDDER_LIST_RESULT:
|
|
|
|
|
|
handleAuctionBidderListResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::SMSG_AUCTION_COMMAND_RESULT:
|
|
|
|
|
|
handleAuctionCommandResult(packet);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
default:
|
2026-02-12 01:53:21 -08:00
|
|
|
|
// In pre-world states we need full visibility (char create/login handshakes).
|
|
|
|
|
|
// In-world we keep de-duplication to avoid heavy log I/O in busy areas.
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec,
|
|
|
|
|
|
" state=", static_cast<int>(state),
|
|
|
|
|
|
" size=", packet.getSize());
|
|
|
|
|
|
const auto& data = packet.getData();
|
|
|
|
|
|
std::string hex;
|
|
|
|
|
|
size_t limit = std::min<size_t>(data.size(), 48);
|
|
|
|
|
|
hex.reserve(limit * 3);
|
|
|
|
|
|
for (size_t i = 0; i < limit; ++i) {
|
|
|
|
|
|
char b[4];
|
|
|
|
|
|
snprintf(b, sizeof(b), "%02x ", data[i]);
|
|
|
|
|
|
hex += b;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Unhandled opcode payload hex (first ", limit, " bytes): ", hex);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
static std::unordered_set<uint16_t> loggedUnhandledOpcodes;
|
|
|
|
|
|
if (loggedUnhandledOpcodes.insert(static_cast<uint16_t>(opcode)).second) {
|
|
|
|
|
|
LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec);
|
|
|
|
|
|
}
|
2026-02-11 22:27:02 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuthChallenge(network::Packet& packet) {
|
|
|
|
|
|
LOG_INFO("Handling SMSG_AUTH_CHALLENGE");
|
|
|
|
|
|
|
|
|
|
|
|
AuthChallengeData challenge;
|
|
|
|
|
|
if (!AuthChallengeParser::parse(packet, challenge)) {
|
|
|
|
|
|
fail("Failed to parse SMSG_AUTH_CHALLENGE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!challenge.isValid()) {
|
|
|
|
|
|
fail("Invalid auth challenge data");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store server seed
|
|
|
|
|
|
serverSeed = challenge.serverSeed;
|
|
|
|
|
|
LOG_DEBUG("Server seed: 0x", std::hex, serverSeed, std::dec);
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::CHALLENGE_RECEIVED);
|
|
|
|
|
|
|
|
|
|
|
|
// Send authentication session
|
|
|
|
|
|
sendAuthSession();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendAuthSession() {
|
|
|
|
|
|
LOG_INFO("Sending CMSG_AUTH_SESSION");
|
|
|
|
|
|
|
|
|
|
|
|
// Build authentication packet
|
|
|
|
|
|
auto packet = AuthSessionPacket::build(
|
|
|
|
|
|
build,
|
|
|
|
|
|
accountName,
|
|
|
|
|
|
clientSeed,
|
|
|
|
|
|
sessionKey,
|
2026-02-13 01:51:49 -08:00
|
|
|
|
serverSeed,
|
|
|
|
|
|
realmId_
|
2026-02-02 12:24:50 -08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("CMSG_AUTH_SESSION packet size: ", packet.getSize(), " bytes");
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Send packet (unencrypted - this is the last unencrypted packet)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Enable encryption IMMEDIATELY after sending AUTH_SESSION
|
|
|
|
|
|
// AzerothCore enables encryption before sending AUTH_RESPONSE,
|
|
|
|
|
|
// so we need to be ready to decrypt the response
|
|
|
|
|
|
LOG_INFO("Enabling encryption immediately after AUTH_SESSION");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
socket->initEncryption(sessionKey, build);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
setState(WorldState::AUTH_SENT);
|
2026-02-05 21:03:11 -08:00
|
|
|
|
LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE...");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuthResponse(network::Packet& packet) {
|
|
|
|
|
|
LOG_INFO("Handling SMSG_AUTH_RESPONSE");
|
|
|
|
|
|
|
|
|
|
|
|
AuthResponseData response;
|
|
|
|
|
|
if (!AuthResponseParser::parse(packet, response)) {
|
|
|
|
|
|
fail("Failed to parse SMSG_AUTH_RESPONSE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.isSuccess()) {
|
|
|
|
|
|
std::string reason = std::string("Authentication failed: ") +
|
|
|
|
|
|
getAuthResultString(response.result);
|
|
|
|
|
|
fail(reason);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Encryption was already enabled after sending AUTH_SESSION
|
|
|
|
|
|
LOG_INFO("AUTH_RESPONSE OK - world authentication successful");
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
setState(WorldState::AUTHENTICATED);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" WORLD AUTHENTICATION SUCCESSFUL!");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Connected to world server");
|
|
|
|
|
|
LOG_INFO("Ready for character operations");
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::READY);
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
// Request character list automatically
|
|
|
|
|
|
requestCharacterList();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Call success callback
|
|
|
|
|
|
if (onSuccess) {
|
|
|
|
|
|
onSuccess();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestCharacterList() {
|
2026-02-12 01:53:21 -08:00
|
|
|
|
if (requiresWarden_) {
|
|
|
|
|
|
// Gate already surfaced via failure callback/chat; avoid per-frame warning spam.
|
|
|
|
|
|
wardenCharEnumBlockedLogged_ = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (state == WorldState::FAILED || !socket || !socket->isConnected()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:03:11 -08:00
|
|
|
|
if (state != WorldState::READY && state != WorldState::AUTHENTICATED &&
|
|
|
|
|
|
state != WorldState::CHAR_LIST_RECEIVED) {
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Cannot request character list in state: ", worldStateName(state));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Requesting character list from server...");
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
// Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM.
|
|
|
|
|
|
// This matters after character create/delete where the old list can linger for a few frames.
|
|
|
|
|
|
characters.clear();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Build CMSG_CHAR_ENUM packet (no body, just opcode)
|
|
|
|
|
|
auto packet = CharEnumPacket::build();
|
|
|
|
|
|
|
|
|
|
|
|
// Send packet
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::CHAR_LIST_REQUESTED);
|
|
|
|
|
|
LOG_INFO("CMSG_CHAR_ENUM sent, waiting for character list...");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCharEnum(network::Packet& packet) {
|
|
|
|
|
|
LOG_INFO("Handling SMSG_CHAR_ENUM");
|
|
|
|
|
|
|
|
|
|
|
|
CharEnumResponse response;
|
2026-02-13 19:40:54 -08:00
|
|
|
|
// IMPORTANT: Do not infer packet formats from numeric build alone.
|
|
|
|
|
|
// Turtle WoW uses a "high" build but classic-era world packet formats.
|
|
|
|
|
|
bool parsed = packetParsers_ ? packetParsers_->parseCharEnum(packet, response)
|
|
|
|
|
|
: CharEnumParser::parse(packet, response);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (!parsed) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
fail("Failed to parse SMSG_CHAR_ENUM");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store characters
|
|
|
|
|
|
characters = response.characters;
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::CHAR_LIST_RECEIVED);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" CHARACTER LIST RECEIVED");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Found ", characters.size(), " character(s)");
|
|
|
|
|
|
|
|
|
|
|
|
if (characters.empty()) {
|
|
|
|
|
|
LOG_INFO("No characters on this account");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("Characters:");
|
|
|
|
|
|
for (size_t i = 0; i < characters.size(); ++i) {
|
|
|
|
|
|
const auto& character = characters[i];
|
|
|
|
|
|
LOG_INFO(" [", i + 1, "] ", character.name);
|
|
|
|
|
|
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
|
|
|
|
|
|
LOG_INFO(" ", getRaceName(character.race), " ",
|
|
|
|
|
|
getClassName(character.characterClass));
|
|
|
|
|
|
LOG_INFO(" Level ", (int)character.level);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Ready to select character");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
void GameHandler::createCharacter(const CharCreateData& data) {
|
|
|
|
|
|
|
|
|
|
|
|
// Online mode: send packet to server
|
|
|
|
|
|
if (!socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot create character: not connected");
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(false, "Not connected to server");
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
if (requiresWarden_) {
|
|
|
|
|
|
std::string msg = "Server requires anti-cheat/Warden; character creation blocked.";
|
|
|
|
|
|
LOG_WARNING("Blocking CMSG_CHAR_CREATE while Warden gate is active");
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(false, msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (state != WorldState::CHAR_LIST_RECEIVED) {
|
|
|
|
|
|
std::string msg = "Character list not ready yet. Wait for SMSG_CHAR_ENUM.";
|
2026-02-12 02:27:59 -08:00
|
|
|
|
LOG_WARNING("Blocking CMSG_CHAR_CREATE in state=", worldStateName(state),
|
2026-02-12 01:53:21 -08:00
|
|
|
|
" (awaiting CHAR_LIST_RECEIVED)");
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(false, msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
auto packet = CharCreatePacket::build(data);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("CMSG_CHAR_CREATE sent for: ", data.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCharCreateResponse(network::Packet& packet) {
|
|
|
|
|
|
CharCreateResponseData data;
|
|
|
|
|
|
if (!CharCreateResponseParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_ERROR("Failed to parse SMSG_CHAR_CREATE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.result == CharCreateResult::SUCCESS) {
|
|
|
|
|
|
LOG_INFO("Character created successfully");
|
|
|
|
|
|
requestCharacterList();
|
|
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(true, "Character created!");
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
switch (data.result) {
|
2026-02-05 21:03:11 -08:00
|
|
|
|
case CharCreateResult::ERROR: msg = "Server error"; break;
|
|
|
|
|
|
case CharCreateResult::FAILED: msg = "Creation failed"; break;
|
2026-02-05 14:13:48 -08:00
|
|
|
|
case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break;
|
|
|
|
|
|
case CharCreateResult::DISABLED: msg = "Character creation disabled"; break;
|
2026-02-05 21:03:11 -08:00
|
|
|
|
case CharCreateResult::PVP_TEAMS_VIOLATION: msg = "PvP faction violation"; break;
|
2026-02-05 14:13:48 -08:00
|
|
|
|
case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break;
|
|
|
|
|
|
case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break;
|
2026-02-05 21:03:11 -08:00
|
|
|
|
case CharCreateResult::SERVER_QUEUE: msg = "Server is queued"; break;
|
|
|
|
|
|
case CharCreateResult::ONLY_EXISTING: msg = "Only existing characters allowed"; break;
|
|
|
|
|
|
case CharCreateResult::EXPANSION: msg = "Expansion required"; break;
|
|
|
|
|
|
case CharCreateResult::EXPANSION_CLASS: msg = "Expansion required for this class"; break;
|
|
|
|
|
|
case CharCreateResult::LEVEL_REQUIREMENT: msg = "Level requirement not met"; break;
|
|
|
|
|
|
case CharCreateResult::UNIQUE_CLASS_LIMIT: msg = "Unique class limit reached"; break;
|
|
|
|
|
|
case CharCreateResult::RESTRICTED_RACECLASS: msg = "Race/class combination not allowed"; break;
|
|
|
|
|
|
// Name validation errors
|
|
|
|
|
|
case CharCreateResult::NAME_FAILURE: msg = "Invalid name"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_NO_NAME: msg = "Please enter a name"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_TOO_SHORT: msg = "Name is too short"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_TOO_LONG: msg = "Name is too long"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_INVALID_CHARACTER: msg = "Name contains invalid characters"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_MIXED_LANGUAGES: msg = "Name mixes languages"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_PROFANE: msg = "Name contains profanity"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_RESERVED: msg = "Name is reserved"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_INVALID_APOSTROPHE: msg = "Invalid apostrophe in name"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_MULTIPLE_APOSTROPHES: msg = "Name has multiple apostrophes"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_THREE_CONSECUTIVE: msg = "Name has 3+ consecutive same letters"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_INVALID_SPACE: msg = "Invalid space in name"; break;
|
|
|
|
|
|
case CharCreateResult::NAME_CONSECUTIVE_SPACES: msg = "Name has consecutive spaces"; break;
|
|
|
|
|
|
default: msg = "Unknown error (code " + std::to_string(static_cast<int>(data.result)) + ")"; break;
|
2026-02-05 14:13:48 -08:00
|
|
|
|
}
|
2026-02-05 21:03:11 -08:00
|
|
|
|
LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast<int>(data.result), ")");
|
2026-02-05 14:13:48 -08:00
|
|
|
|
if (charCreateCallback_) {
|
|
|
|
|
|
charCreateCallback_(false, msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:24:46 -08:00
|
|
|
|
void GameHandler::deleteCharacter(uint64_t characterGuid) {
|
|
|
|
|
|
if (!socket) {
|
|
|
|
|
|
if (charDeleteCallback_) charDeleteCallback_(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE));
|
2026-02-06 03:24:46 -08:00
|
|
|
|
packet.writeUInt64(characterGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
|
const Character* GameHandler::getActiveCharacter() const {
|
|
|
|
|
|
if (activeCharacterGuid_ == 0) return nullptr;
|
|
|
|
|
|
for (const auto& ch : characters) {
|
|
|
|
|
|
if (ch.guid == activeCharacterGuid_) return &ch;
|
|
|
|
|
|
}
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const Character* GameHandler::getFirstCharacter() const {
|
|
|
|
|
|
if (characters.empty()) return nullptr;
|
|
|
|
|
|
return &characters.front();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 14:38:58 -08:00
|
|
|
|
|
2026-02-05 15:07:13 -08:00
|
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
|
|
2026-02-05 14:38:58 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 14:55:42 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 14:35:12 -08:00
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::selectCharacter(uint64_t characterGuid) {
|
|
|
|
|
|
if (state != WorldState::CHAR_LIST_RECEIVED) {
|
|
|
|
|
|
LOG_WARNING("Cannot select character in state: ", (int)state);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
// Make the selected character authoritative in GameHandler.
|
|
|
|
|
|
// This avoids relying on UI/Application ordering for appearance-dependent logic.
|
|
|
|
|
|
activeCharacterGuid_ = characterGuid;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" ENTERING WORLD");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec);
|
|
|
|
|
|
|
|
|
|
|
|
// Find character name for logging
|
|
|
|
|
|
for (const auto& character : characters) {
|
|
|
|
|
|
if (character.guid == characterGuid) {
|
|
|
|
|
|
LOG_INFO("Character: ", character.name);
|
|
|
|
|
|
LOG_INFO("Level ", (int)character.level, " ",
|
|
|
|
|
|
getRaceName(character.race), " ",
|
|
|
|
|
|
getClassName(character.characterClass));
|
2026-02-08 03:05:38 -08:00
|
|
|
|
playerRace_ = character.race;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Store player GUID
|
|
|
|
|
|
playerGuid = characterGuid;
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
// Reset per-character state so previous character data doesn't bleed through
|
|
|
|
|
|
inventory = Inventory();
|
|
|
|
|
|
onlineItems_.clear();
|
|
|
|
|
|
pendingItemQueries_.clear();
|
|
|
|
|
|
equipSlotGuids_ = {};
|
|
|
|
|
|
backpackSlotGuids_ = {};
|
|
|
|
|
|
invSlotBase_ = -1;
|
|
|
|
|
|
packSlotBase_ = -1;
|
|
|
|
|
|
lastPlayerFields_.clear();
|
|
|
|
|
|
onlineEquipDirty_ = false;
|
|
|
|
|
|
playerMoneyCopper_ = 0;
|
|
|
|
|
|
knownSpells.clear();
|
|
|
|
|
|
spellCooldowns.clear();
|
|
|
|
|
|
actionBar = {};
|
|
|
|
|
|
playerAuras.clear();
|
|
|
|
|
|
targetAuras.clear();
|
|
|
|
|
|
playerXp_ = 0;
|
|
|
|
|
|
playerNextLevelXp_ = 0;
|
|
|
|
|
|
serverPlayerLevel_ = 1;
|
2026-02-11 18:25:04 -08:00
|
|
|
|
std::fill(playerExploredZones_.begin(), playerExploredZones_.end(), 0u);
|
|
|
|
|
|
hasPlayerExploredZones_ = false;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
playerSkills_.clear();
|
|
|
|
|
|
questLog_.clear();
|
|
|
|
|
|
npcQuestStatus_.clear();
|
|
|
|
|
|
hostileAttackers_.clear();
|
|
|
|
|
|
combatText.clear();
|
|
|
|
|
|
autoAttacking = false;
|
|
|
|
|
|
autoAttackTarget = 0;
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
castTimeTotal = 0.0f;
|
|
|
|
|
|
playerDead_ = false;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
releasedSpirit_ = false;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
targetGuid = 0;
|
|
|
|
|
|
focusGuid = 0;
|
|
|
|
|
|
lastTargetGuid = 0;
|
|
|
|
|
|
tabCycleStale = true;
|
|
|
|
|
|
entityManager = EntityManager();
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Build CMSG_PLAYER_LOGIN packet
|
|
|
|
|
|
auto packet = PlayerLoginPacket::build(characterGuid);
|
|
|
|
|
|
|
|
|
|
|
|
// Send packet
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
|
|
|
|
|
|
setState(WorldState::ENTERING_WORLD);
|
|
|
|
|
|
LOG_INFO("CMSG_PLAYER_LOGIN sent, entering world...");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
void GameHandler::handleLoginSetTimeSpeed(network::Packet& packet) {
|
|
|
|
|
|
// SMSG_LOGIN_SETTIMESPEED (0x042)
|
|
|
|
|
|
// Structure: uint32 gameTime, float timeScale
|
|
|
|
|
|
// gameTime: Game time in seconds since epoch
|
|
|
|
|
|
// timeScale: Time speed multiplier (typically 0.0166 for 1 day = 1 hour)
|
|
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() < 8) {
|
|
|
|
|
|
LOG_WARNING("SMSG_LOGIN_SETTIMESPEED: packet too small (", packet.getSize(), " bytes)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t gameTimePacked = packet.readUInt32();
|
|
|
|
|
|
float timeScale = packet.readFloat();
|
|
|
|
|
|
|
|
|
|
|
|
// Store for celestial/sky system use
|
|
|
|
|
|
gameTime_ = static_cast<float>(gameTimePacked);
|
|
|
|
|
|
timeSpeed_ = timeScale;
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Server time: gameTime=", gameTime_, "s, timeSpeed=", timeSpeed_);
|
|
|
|
|
|
LOG_INFO(" (1 game day = ", (1.0f / timeSpeed_) / 60.0f, " real minutes)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
|
|
|
|
|
LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD");
|
|
|
|
|
|
|
|
|
|
|
|
LoginVerifyWorldData data;
|
|
|
|
|
|
if (!LoginVerifyWorldParser::parse(packet, data)) {
|
|
|
|
|
|
fail("Failed to parse SMSG_LOGIN_VERIFY_WORLD");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!data.isValid()) {
|
|
|
|
|
|
fail("Invalid world entry data");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Successfully entered the world (or teleported)
|
|
|
|
|
|
currentMapId_ = data.mapId;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
setState(WorldState::IN_WORLD);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" SUCCESSFULLY ENTERED WORLD!");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO("Map ID: ", data.mapId);
|
|
|
|
|
|
LOG_INFO("Position: (", data.x, ", ", data.y, ", ", data.z, ")");
|
|
|
|
|
|
LOG_INFO("Orientation: ", data.orientation, " radians");
|
|
|
|
|
|
LOG_INFO("Player is now in the game world");
|
|
|
|
|
|
|
2026-02-04 18:27:52 -08:00
|
|
|
|
// Initialize movement info with world entry position (server → canonical)
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z));
|
|
|
|
|
|
movementInfo.x = canonical.x;
|
|
|
|
|
|
movementInfo.y = canonical.y;
|
|
|
|
|
|
movementInfo.z = canonical.z;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = core::coords::serverToCanonicalYaw(data.orientation);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
movementInfo.time = 0;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
resurrectPending_ = false;
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
taxiStartGrace_ = 0.0f;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
// Send CMSG_SET_ACTIVE_MOVER (required by some servers)
|
|
|
|
|
|
if (playerGuid != 0 && socket) {
|
|
|
|
|
|
auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid);
|
|
|
|
|
|
socket->send(activeMoverPacket);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec);
|
|
|
|
|
|
}
|
2026-02-05 21:28:21 -08:00
|
|
|
|
|
|
|
|
|
|
// Notify application to load terrain for this map/position (online mode)
|
|
|
|
|
|
if (worldEntryCallback_) {
|
|
|
|
|
|
worldEntryCallback_(data.mapId, data.x, data.y, data.z);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
// Auto-join default chat channels
|
|
|
|
|
|
autoJoinDefaultChannels();
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
// Auto-query guild info on login
|
|
|
|
|
|
const Character* activeChar = getActiveCharacter();
|
|
|
|
|
|
if (activeChar && activeChar->hasGuild() && socket) {
|
|
|
|
|
|
auto gqPacket = GuildQueryPacket::build(activeChar->guildId);
|
|
|
|
|
|
socket->send(gqPacket);
|
|
|
|
|
|
auto grPacket = GuildRosterPacket::build();
|
|
|
|
|
|
socket->send(grPacket);
|
|
|
|
|
|
LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// If we disconnected mid-taxi, attempt to recover to destination after login.
|
|
|
|
|
|
if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) {
|
|
|
|
|
|
float dx = movementInfo.x - taxiRecoverPos_.x;
|
|
|
|
|
|
float dy = movementInfo.y - taxiRecoverPos_.y;
|
|
|
|
|
|
float dz = movementInfo.z - taxiRecoverPos_.z;
|
|
|
|
|
|
float dist = std::sqrt(dx * dx + dy * dy + dz * dz);
|
|
|
|
|
|
if (dist > 5.0f) {
|
|
|
|
|
|
// Keep pending until player entity exists; update() will apply.
|
|
|
|
|
|
LOG_INFO("Taxi recovery pending: dist=", dist);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
void GameHandler::handleClientCacheVersion(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() < 4) {
|
|
|
|
|
|
LOG_WARNING("SMSG_CLIENTCACHE_VERSION too short: ", packet.getSize(), " bytes");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t version = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("SMSG_CLIENTCACHE_VERSION: ", version);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTutorialFlags(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() < 32) {
|
|
|
|
|
|
LOG_WARNING("SMSG_TUTORIAL_FLAGS too short: ", packet.getSize(), " bytes");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::array<uint32_t, 8> flags{};
|
|
|
|
|
|
for (uint32_t& v : flags) {
|
|
|
|
|
|
v = packet.readUInt32();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_TUTORIAL_FLAGS: [",
|
|
|
|
|
|
flags[0], ", ", flags[1], ", ", flags[2], ", ", flags[3], ", ",
|
|
|
|
|
|
flags[4], ", ", flags[5], ", ", flags[6], ", ", flags[7], "]");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) {
|
|
|
|
|
|
wardenCREntries_.clear();
|
|
|
|
|
|
|
|
|
|
|
|
// Look for .cr file in warden cache
|
|
|
|
|
|
std::string homeDir;
|
|
|
|
|
|
if (const char* h = std::getenv("HOME")) homeDir = h;
|
|
|
|
|
|
else homeDir = ".";
|
|
|
|
|
|
|
|
|
|
|
|
std::string crPath = homeDir + "/.local/share/wowee/warden_cache/" + moduleHashHex + ".cr";
|
|
|
|
|
|
|
|
|
|
|
|
std::ifstream crFile(crPath, std::ios::binary);
|
|
|
|
|
|
if (!crFile) {
|
|
|
|
|
|
LOG_WARNING("Warden: No .cr file found at ", crPath);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get file size
|
|
|
|
|
|
crFile.seekg(0, std::ios::end);
|
|
|
|
|
|
auto fileSize = crFile.tellg();
|
|
|
|
|
|
crFile.seekg(0, std::ios::beg);
|
|
|
|
|
|
|
|
|
|
|
|
// Header: [4 memoryRead][4 pageScanCheck][9 opcodes] = 17 bytes
|
|
|
|
|
|
constexpr size_t CR_HEADER_SIZE = 17;
|
|
|
|
|
|
constexpr size_t CR_ENTRY_SIZE = 68; // seed[16]+reply[20]+clientKey[16]+serverKey[16]
|
|
|
|
|
|
|
|
|
|
|
|
if (static_cast<size_t>(fileSize) < CR_HEADER_SIZE) {
|
|
|
|
|
|
LOG_ERROR("Warden: .cr file too small (", fileSize, " bytes)");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Read header: [4 memoryRead][4 pageScanCheck][9 opcodes]
|
|
|
|
|
|
crFile.seekg(8); // skip memoryRead + pageScanCheck
|
|
|
|
|
|
crFile.read(reinterpret_cast<char*>(wardenCheckOpcodes_), 9);
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string opcHex;
|
|
|
|
|
|
const char* names[] = {"MEM","PAGE_A","PAGE_B","MPQ","LUA","DRIVER","TIMING","PROC","MODULE"};
|
|
|
|
|
|
for (int i = 0; i < 9; i++) {
|
|
|
|
|
|
char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Warden: Check opcodes: ", opcHex);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
size_t entryCount = (static_cast<size_t>(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE;
|
|
|
|
|
|
if (entryCount == 0) {
|
|
|
|
|
|
LOG_ERROR("Warden: .cr file has no entries");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wardenCREntries_.resize(entryCount);
|
|
|
|
|
|
for (size_t i = 0; i < entryCount; i++) {
|
|
|
|
|
|
auto& e = wardenCREntries_[i];
|
|
|
|
|
|
crFile.read(reinterpret_cast<char*>(e.seed), 16);
|
|
|
|
|
|
crFile.read(reinterpret_cast<char*>(e.reply), 20);
|
|
|
|
|
|
crFile.read(reinterpret_cast<char*>(e.clientKey), 16);
|
|
|
|
|
|
crFile.read(reinterpret_cast<char*>(e.serverKey), 16);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Warden: Loaded ", entryCount, " CR entries from ", crPath);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 01:53:21 -08:00
|
|
|
|
void GameHandler::handleWardenData(network::Packet& packet) {
|
|
|
|
|
|
const auto& data = packet.getData();
|
|
|
|
|
|
if (!wardenGateSeen_) {
|
|
|
|
|
|
wardenGateSeen_ = true;
|
|
|
|
|
|
wardenGateElapsed_ = 0.0f;
|
|
|
|
|
|
wardenGateNextStatusLog_ = 2.0f;
|
|
|
|
|
|
wardenPacketsAfterGate_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Initialize Warden crypto from session key on first packet
|
2026-02-12 02:09:15 -08:00
|
|
|
|
if (!wardenCrypto_) {
|
|
|
|
|
|
wardenCrypto_ = std::make_unique<WardenCrypto>();
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (sessionKey.size() != 40) {
|
|
|
|
|
|
LOG_ERROR("Warden: No valid session key (size=", sessionKey.size(), "), cannot init crypto");
|
2026-02-12 02:09:15 -08:00
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (!wardenCrypto_->initFromSessionKey(sessionKey)) {
|
|
|
|
|
|
LOG_ERROR("Warden: Failed to initialize crypto from session key");
|
|
|
|
|
|
wardenCrypto_.reset();
|
|
|
|
|
|
return;
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_USE;
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Decrypt the payload
|
|
|
|
|
|
std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data);
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Log decrypted data
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string hex;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
size_t logSize = std::min(decrypted.size(), size_t(256));
|
2026-02-13 16:53:28 -08:00
|
|
|
|
hex.reserve(logSize * 3);
|
|
|
|
|
|
for (size_t i = 0; i < logSize; ++i) {
|
|
|
|
|
|
char b[4]; snprintf(b, sizeof(b), "%02x ", decrypted[i]); hex += b;
|
2026-02-12 02:09:15 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (decrypted.size() > 64)
|
|
|
|
|
|
hex += "... (" + std::to_string(decrypted.size() - 64) + " more)";
|
|
|
|
|
|
LOG_INFO("Warden: Decrypted (", decrypted.size(), " bytes): ", hex);
|
|
|
|
|
|
}
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (decrypted.empty()) {
|
|
|
|
|
|
LOG_WARNING("Warden: Empty decrypted payload");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
uint8_t wardenOpcode = decrypted[0];
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Helper to send an encrypted Warden response
|
|
|
|
|
|
auto sendWardenResponse = [&](const std::vector<uint8_t>& plaintext) {
|
|
|
|
|
|
std::vector<uint8_t> encrypted = wardenCrypto_->encrypt(plaintext);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA));
|
2026-02-13 16:53:28 -08:00
|
|
|
|
for (uint8_t byte : encrypted) {
|
2026-02-12 02:09:15 -08:00
|
|
|
|
response.writeUInt8(byte);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (socket && socket->isConnected()) {
|
|
|
|
|
|
socket->send(response);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
LOG_INFO("Warden: Sent response (", plaintext.size(), " bytes plaintext)");
|
2026-02-12 02:09:15 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
switch (wardenOpcode) {
|
|
|
|
|
|
case 0x00: { // WARDEN_SMSG_MODULE_USE
|
|
|
|
|
|
// Format: [1 opcode][16 moduleHash][16 moduleKey][4 moduleSize]
|
|
|
|
|
|
if (decrypted.size() < 37) {
|
|
|
|
|
|
LOG_ERROR("Warden: MODULE_USE too short (", decrypted.size(), " bytes, need 37)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenModuleHash_.assign(decrypted.begin() + 1, decrypted.begin() + 17);
|
|
|
|
|
|
wardenModuleKey_.assign(decrypted.begin() + 17, decrypted.begin() + 33);
|
|
|
|
|
|
wardenModuleSize_ = static_cast<uint32_t>(decrypted[33])
|
|
|
|
|
|
| (static_cast<uint32_t>(decrypted[34]) << 8)
|
|
|
|
|
|
| (static_cast<uint32_t>(decrypted[35]) << 16)
|
|
|
|
|
|
| (static_cast<uint32_t>(decrypted[36]) << 24);
|
|
|
|
|
|
wardenModuleData_.clear();
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string hashHex, keyHex;
|
|
|
|
|
|
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
|
|
|
|
|
|
for (auto b : wardenModuleKey_) { char s[4]; snprintf(s, 4, "%02x", b); keyHex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: MODULE_USE hash=", hashHex,
|
|
|
|
|
|
" key=", keyHex, " size=", wardenModuleSize_);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// Try to load pre-computed challenge/response entries
|
|
|
|
|
|
loadWardenCRFile(hashHex);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Respond with MODULE_MISSING (opcode 0x00) to request the module data
|
|
|
|
|
|
std::vector<uint8_t> resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING
|
|
|
|
|
|
sendWardenResponse(resp);
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_MODULE_CACHE;
|
|
|
|
|
|
LOG_INFO("Warden: Sent MODULE_MISSING, waiting for module data chunks");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
case 0x01: { // WARDEN_SMSG_MODULE_CACHE (module data chunk)
|
|
|
|
|
|
// Format: [1 opcode][2 chunkSize LE][chunkSize bytes data]
|
|
|
|
|
|
if (decrypted.size() < 3) {
|
|
|
|
|
|
LOG_ERROR("Warden: MODULE_CACHE too short");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
uint16_t chunkSize = static_cast<uint16_t>(decrypted[1])
|
|
|
|
|
|
| (static_cast<uint16_t>(decrypted[2]) << 8);
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
if (decrypted.size() < 3u + chunkSize) {
|
|
|
|
|
|
LOG_ERROR("Warden: MODULE_CACHE chunk truncated (claimed ", chunkSize,
|
|
|
|
|
|
", have ", decrypted.size() - 3, ")");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenModuleData_.insert(wardenModuleData_.end(),
|
|
|
|
|
|
decrypted.begin() + 3,
|
|
|
|
|
|
decrypted.begin() + 3 + chunkSize);
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
LOG_INFO("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ",
|
|
|
|
|
|
wardenModuleData_.size(), "/", wardenModuleSize_);
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Check if module download is complete
|
|
|
|
|
|
if (wardenModuleData_.size() >= wardenModuleSize_) {
|
|
|
|
|
|
LOG_INFO("Warden: Module download complete (",
|
|
|
|
|
|
wardenModuleData_.size(), " bytes)");
|
|
|
|
|
|
wardenState_ = WardenState::WAIT_HASH_REQUEST;
|
2026-02-12 03:50:28 -08:00
|
|
|
|
|
2026-02-14 19:20:32 -08:00
|
|
|
|
// Cache raw module to disk
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string homeDir;
|
|
|
|
|
|
if (const char* h = std::getenv("HOME")) homeDir = h;
|
|
|
|
|
|
else homeDir = ".";
|
|
|
|
|
|
std::string cacheDir = homeDir + "/.local/share/wowee/warden_cache";
|
|
|
|
|
|
std::filesystem::create_directories(cacheDir);
|
|
|
|
|
|
|
|
|
|
|
|
std::string hashHex;
|
|
|
|
|
|
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; }
|
|
|
|
|
|
std::string cachePath = cacheDir + "/" + hashHex + ".wdn";
|
|
|
|
|
|
|
|
|
|
|
|
std::ofstream wf(cachePath, std::ios::binary);
|
|
|
|
|
|
if (wf) {
|
|
|
|
|
|
wf.write(reinterpret_cast<const char*>(wardenModuleData_.data()), wardenModuleData_.size());
|
|
|
|
|
|
LOG_INFO("Warden: Cached module to ", cachePath);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load the module (decrypt, decompress, parse, relocate)
|
|
|
|
|
|
wardenLoadedModule_ = std::make_shared<WardenModule>();
|
|
|
|
|
|
if (wardenLoadedModule_->load(wardenModuleData_, wardenModuleHash_, wardenModuleKey_)) {
|
|
|
|
|
|
LOG_INFO("Warden: Module loaded successfully (image size=",
|
|
|
|
|
|
wardenLoadedModule_->getModuleSize(), " bytes)");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_ERROR("Warden: Module loading FAILED");
|
|
|
|
|
|
wardenLoadedModule_.reset();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// Send MODULE_OK (opcode 0x01)
|
|
|
|
|
|
std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK
|
|
|
|
|
|
sendWardenResponse(resp);
|
|
|
|
|
|
LOG_INFO("Warden: Sent MODULE_OK");
|
2026-02-12 03:50:28 -08:00
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
// No response for intermediate chunks
|
|
|
|
|
|
break;
|
2026-02-12 03:50:28 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
case 0x05: { // WARDEN_SMSG_HASH_REQUEST
|
|
|
|
|
|
// Format: [1 opcode][16 seed]
|
|
|
|
|
|
if (decrypted.size() < 17) {
|
|
|
|
|
|
LOG_ERROR("Warden: HASH_REQUEST too short (", decrypted.size(), " bytes, need 17)");
|
|
|
|
|
|
return;
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::vector<uint8_t> seed(decrypted.begin() + 1, decrypted.begin() + 17);
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string seedHex;
|
|
|
|
|
|
for (auto b : seed) { char s[4]; snprintf(s, 4, "%02x", b); seedHex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: HASH_REQUEST seed=", seedHex);
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// --- Try CR lookup (pre-computed challenge/response entries) ---
|
|
|
|
|
|
if (!wardenCREntries_.empty()) {
|
|
|
|
|
|
const WardenCREntry* match = nullptr;
|
|
|
|
|
|
for (const auto& entry : wardenCREntries_) {
|
|
|
|
|
|
if (std::memcmp(entry.seed, seed.data(), 16) == 0) {
|
|
|
|
|
|
match = &entry;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (match) {
|
|
|
|
|
|
LOG_INFO("Warden: Found matching CR entry for seed");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// Log the reply we're sending
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string replyHex;
|
|
|
|
|
|
for (int i = 0; i < 20; i++) {
|
|
|
|
|
|
char s[4]; snprintf(s, 4, "%02x", match->reply[i]); replyHex += s;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Warden: Sending pre-computed reply=", replyHex);
|
2026-02-13 16:53:28 -08:00
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// Send HASH_RESULT (opcode 0x04 + 20-byte reply)
|
|
|
|
|
|
std::vector<uint8_t> resp;
|
|
|
|
|
|
resp.push_back(0x04);
|
|
|
|
|
|
resp.insert(resp.end(), match->reply, match->reply + 20);
|
|
|
|
|
|
sendWardenResponse(resp);
|
|
|
|
|
|
|
|
|
|
|
|
// Switch to new RC4 keys from the CR entry
|
|
|
|
|
|
// clientKey = encrypt (client→server), serverKey = decrypt (server→client)
|
|
|
|
|
|
std::vector<uint8_t> newEncryptKey(match->clientKey, match->clientKey + 16);
|
|
|
|
|
|
std::vector<uint8_t> newDecryptKey(match->serverKey, match->serverKey + 16);
|
|
|
|
|
|
wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey);
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string ekHex, dkHex;
|
|
|
|
|
|
for (int i = 0; i < 16; i++) { char s[4]; snprintf(s, 4, "%02x", newEncryptKey[i]); ekHex += s; }
|
|
|
|
|
|
for (int i = 0; i < 16; i++) { char s[4]; snprintf(s, 4, "%02x", newDecryptKey[i]); dkHex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: Switched to CR keys encrypt=", ekHex, " decrypt=", dkHex);
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
2026-02-12 02:22:04 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_CHECKS;
|
|
|
|
|
|
break;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Warden: Seed not found in ", wardenCREntries_.size(), " CR entries");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-14 19:20:32 -08:00
|
|
|
|
// --- Fallback: compute hash from loaded module ---
|
|
|
|
|
|
LOG_WARNING("Warden: No CR match, computing hash from loaded module");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-14 19:20:32 -08:00
|
|
|
|
if (!wardenLoadedModule_ || !wardenLoadedModule_->isLoaded()) {
|
|
|
|
|
|
LOG_ERROR("Warden: No loaded module and no CR match — cannot compute hash");
|
2026-02-13 18:59:09 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_CHECKS;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-13 16:53:28 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
{
|
2026-02-14 19:20:32 -08:00
|
|
|
|
const uint8_t* moduleImage = static_cast<const uint8_t*>(wardenLoadedModule_->getModuleMemory());
|
|
|
|
|
|
size_t moduleImageSize = wardenLoadedModule_->getModuleSize();
|
|
|
|
|
|
const auto& decompressedData = wardenLoadedModule_->getDecompressedData();
|
|
|
|
|
|
|
|
|
|
|
|
// --- Empirical test: try multiple SHA1 computations and check against first CR entry ---
|
|
|
|
|
|
if (!wardenCREntries_.empty()) {
|
|
|
|
|
|
const auto& firstCR = wardenCREntries_[0];
|
|
|
|
|
|
std::string expectedHex;
|
|
|
|
|
|
for (int i = 0; i < 20; i++) { char s[4]; snprintf(s, 4, "%02x", firstCR.reply[i]); expectedHex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: Empirical test — expected reply from CR[0]=", expectedHex);
|
|
|
|
|
|
|
|
|
|
|
|
// Test 1: SHA1(moduleImage)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::vector<uint8_t> data(moduleImage, moduleImage + moduleImageSize);
|
|
|
|
|
|
auto h = auth::Crypto::sha1(data);
|
|
|
|
|
|
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
|
|
|
|
|
|
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: SHA1(moduleImage)=", hex, match ? " MATCH!" : "");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Test 2: SHA1(seed || moduleImage)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::vector<uint8_t> data;
|
|
|
|
|
|
data.insert(data.end(), seed.begin(), seed.end());
|
|
|
|
|
|
data.insert(data.end(), moduleImage, moduleImage + moduleImageSize);
|
|
|
|
|
|
auto h = auth::Crypto::sha1(data);
|
|
|
|
|
|
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
|
|
|
|
|
|
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: SHA1(seed||image)=", hex, match ? " MATCH!" : "");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Test 3: SHA1(moduleImage || seed)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::vector<uint8_t> data(moduleImage, moduleImage + moduleImageSize);
|
|
|
|
|
|
data.insert(data.end(), seed.begin(), seed.end());
|
|
|
|
|
|
auto h = auth::Crypto::sha1(data);
|
|
|
|
|
|
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
|
|
|
|
|
|
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: SHA1(image||seed)=", hex, match ? " MATCH!" : "");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Test 4: SHA1(decompressedData)
|
|
|
|
|
|
{
|
|
|
|
|
|
auto h = auth::Crypto::sha1(decompressedData);
|
|
|
|
|
|
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
|
|
|
|
|
|
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: SHA1(decompressed)=", hex, match ? " MATCH!" : "");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Test 5: SHA1(rawModuleData)
|
|
|
|
|
|
{
|
|
|
|
|
|
auto h = auth::Crypto::sha1(wardenModuleData_);
|
|
|
|
|
|
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0);
|
|
|
|
|
|
std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: SHA1(rawModule)=", hex, match ? " MATCH!" : "");
|
|
|
|
|
|
}
|
|
|
|
|
|
// Test 6: Check if all CR replies are the same (constant hash)
|
|
|
|
|
|
{
|
|
|
|
|
|
bool allSame = true;
|
|
|
|
|
|
for (size_t i = 1; i < wardenCREntries_.size(); i++) {
|
|
|
|
|
|
if (std::memcmp(wardenCREntries_[i].reply, firstCR.reply, 20) != 0) {
|
|
|
|
|
|
allSame = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Warden: All ", wardenCREntries_.size(), " CR replies identical? ", allSame ? "YES" : "NO");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Compute the hash: SHA1(moduleImage) is the most likely candidate ---
|
|
|
|
|
|
// The module's hash response is typically SHA1 of the loaded module image.
|
|
|
|
|
|
// This is a constant per module (seed is not used in the hash, only for key derivation).
|
|
|
|
|
|
std::vector<uint8_t> imageData(moduleImage, moduleImage + moduleImageSize);
|
|
|
|
|
|
auto reply = auth::Crypto::sha1(imageData);
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string hex;
|
|
|
|
|
|
for (auto b : reply) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: Sending SHA1(moduleImage)=", hex);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send HASH_RESULT (opcode 0x04 + 20-byte hash)
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::vector<uint8_t> resp;
|
|
|
|
|
|
resp.push_back(0x04);
|
2026-02-14 19:20:32 -08:00
|
|
|
|
resp.insert(resp.end(), reply.begin(), reply.end());
|
2026-02-13 16:53:28 -08:00
|
|
|
|
sendWardenResponse(resp);
|
2026-02-14 19:20:32 -08:00
|
|
|
|
|
|
|
|
|
|
// Derive new RC4 keys from the seed using SHA1Randx
|
|
|
|
|
|
std::vector<uint8_t> seedVec(seed.begin(), seed.end());
|
|
|
|
|
|
// Pad seed to at least 2 bytes for SHA1Randx split
|
|
|
|
|
|
// SHA1Randx splits input in half: first_half and second_half
|
|
|
|
|
|
uint8_t newEncryptKey[16], newDecryptKey[16];
|
|
|
|
|
|
WardenCrypto::sha1RandxGenerate(seedVec, newEncryptKey, newDecryptKey);
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<uint8_t> ek(newEncryptKey, newEncryptKey + 16);
|
|
|
|
|
|
std::vector<uint8_t> dk(newDecryptKey, newDecryptKey + 16);
|
|
|
|
|
|
wardenCrypto_->replaceKeys(ek, dk);
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string ekHex, dkHex;
|
|
|
|
|
|
for (int i = 0; i < 16; i++) { char s[4]; snprintf(s, 4, "%02x", newEncryptKey[i]); ekHex += s; }
|
|
|
|
|
|
for (int i = 0; i < 16; i++) { char s[4]; snprintf(s, 4, "%02x", newDecryptKey[i]); dkHex += s; }
|
|
|
|
|
|
LOG_INFO("Warden: Derived keys from seed: encrypt=", ekHex, " decrypt=", dkHex);
|
|
|
|
|
|
}
|
2026-02-12 02:22:04 -08:00
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
wardenState_ = WardenState::WAIT_CHECKS;
|
|
|
|
|
|
break;
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
2026-02-12 02:09:15 -08:00
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
case 0x02: { // WARDEN_SMSG_CHEAT_CHECKS_REQUEST
|
|
|
|
|
|
LOG_INFO("Warden: CHEAT_CHECKS_REQUEST (", decrypted.size(), " bytes)");
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (decrypted.size() < 3) {
|
|
|
|
|
|
LOG_ERROR("Warden: CHEAT_CHECKS_REQUEST too short");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Parse string table ---
|
|
|
|
|
|
// Format: [1 opcode][string table: (len+data)*][0x00 end][check data][xorByte]
|
|
|
|
|
|
size_t pos = 1;
|
|
|
|
|
|
std::vector<std::string> strings;
|
|
|
|
|
|
while (pos < decrypted.size()) {
|
|
|
|
|
|
uint8_t slen = decrypted[pos++];
|
|
|
|
|
|
if (slen == 0) break; // end of string table
|
|
|
|
|
|
if (pos + slen > decrypted.size()) break;
|
|
|
|
|
|
strings.emplace_back(reinterpret_cast<const char*>(decrypted.data() + pos), slen);
|
|
|
|
|
|
pos += slen;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Warden: String table: ", strings.size(), " entries");
|
|
|
|
|
|
for (size_t i = 0; i < strings.size(); i++) {
|
|
|
|
|
|
LOG_INFO("Warden: [", i, "] = \"", strings[i], "\"");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// XOR byte is the last byte of the packet
|
|
|
|
|
|
uint8_t xorByte = decrypted.back();
|
|
|
|
|
|
LOG_INFO("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }());
|
|
|
|
|
|
|
|
|
|
|
|
// Check type enum indices
|
|
|
|
|
|
enum CheckType { CT_MEM=0, CT_PAGE_A=1, CT_PAGE_B=2, CT_MPQ=3, CT_LUA=4,
|
|
|
|
|
|
CT_DRIVER=5, CT_TIMING=6, CT_PROC=7, CT_MODULE=8, CT_UNKNOWN=9 };
|
|
|
|
|
|
const char* checkTypeNames[] = {"MEM","PAGE_A","PAGE_B","MPQ","LUA","DRIVER","TIMING","PROC","MODULE","UNKNOWN"};
|
|
|
|
|
|
|
|
|
|
|
|
auto decodeCheckType = [&](uint8_t raw) -> CheckType {
|
|
|
|
|
|
uint8_t decoded = raw ^ xorByte;
|
|
|
|
|
|
for (int i = 0; i < 9; i++) {
|
|
|
|
|
|
if (decoded == wardenCheckOpcodes_[i]) return static_cast<CheckType>(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
return CT_UNKNOWN;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- Parse check entries and build response ---
|
|
|
|
|
|
std::vector<uint8_t> resultData;
|
|
|
|
|
|
size_t checkEnd = decrypted.size() - 1; // exclude xorByte
|
|
|
|
|
|
int checkCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < checkEnd) {
|
|
|
|
|
|
CheckType ct = decodeCheckType(decrypted[pos]);
|
|
|
|
|
|
pos++;
|
|
|
|
|
|
checkCount++;
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Warden: Check #", checkCount, " type=", checkTypeNames[ct],
|
|
|
|
|
|
" at offset ", pos - 1);
|
|
|
|
|
|
|
|
|
|
|
|
switch (ct) {
|
|
|
|
|
|
case CT_TIMING: {
|
|
|
|
|
|
// No additional request data
|
|
|
|
|
|
// Response: [uint8 result=1][uint32 ticks]
|
|
|
|
|
|
resultData.push_back(0x01);
|
|
|
|
|
|
uint32_t ticks = static_cast<uint32_t>(
|
|
|
|
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
|
|
|
|
std::chrono::steady_clock::now().time_since_epoch()).count());
|
|
|
|
|
|
resultData.push_back(ticks & 0xFF);
|
|
|
|
|
|
resultData.push_back((ticks >> 8) & 0xFF);
|
|
|
|
|
|
resultData.push_back((ticks >> 16) & 0xFF);
|
|
|
|
|
|
resultData.push_back((ticks >> 24) & 0xFF);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_MEM: {
|
|
|
|
|
|
// Request: [1 stringIdx][4 offset][1 length]
|
|
|
|
|
|
if (pos + 6 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos++; // stringIdx
|
|
|
|
|
|
uint32_t offset = decrypted[pos] | (uint32_t(decrypted[pos+1])<<8)
|
|
|
|
|
|
| (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24);
|
|
|
|
|
|
pos += 4;
|
|
|
|
|
|
uint8_t readLen = decrypted[pos++];
|
|
|
|
|
|
LOG_INFO("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(),
|
|
|
|
|
|
" len=", (int)readLen);
|
2026-02-14 02:00:15 -08:00
|
|
|
|
|
|
|
|
|
|
// Lazy-load WoW.exe PE image on first MEM_CHECK
|
|
|
|
|
|
if (!wardenMemory_) {
|
|
|
|
|
|
wardenMemory_ = std::make_unique<WardenMemory>();
|
|
|
|
|
|
if (!wardenMemory_->load()) {
|
|
|
|
|
|
LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Read real bytes from PE image (falls back to zeros if unavailable)
|
|
|
|
|
|
std::vector<uint8_t> memBuf(readLen, 0);
|
|
|
|
|
|
if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) {
|
|
|
|
|
|
LOG_INFO("Warden: MEM_CHECK served from PE image");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x",
|
|
|
|
|
|
[&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}());
|
|
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
resultData.push_back(0x00);
|
2026-02-14 02:00:15 -08:00
|
|
|
|
resultData.insert(resultData.end(), memBuf.begin(), memBuf.end());
|
2026-02-13 18:59:09 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-14 01:43:40 -08:00
|
|
|
|
case CT_PAGE_A: {
|
|
|
|
|
|
// Vanilla: [4 seed][20 sha1] = 24 bytes (no addr+len)
|
2026-02-14 01:26:26 -08:00
|
|
|
|
// WotLK: [4 seed][20 sha1][4 addr][1 length] = 29 bytes
|
|
|
|
|
|
int pageSize = (build <= 6005) ? 24 : 29;
|
|
|
|
|
|
if (pos + pageSize > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos += pageSize;
|
2026-02-14 01:43:40 -08:00
|
|
|
|
resultData.push_back(0x00);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_PAGE_B: {
|
|
|
|
|
|
// PAGE_B always has [4 seed][20 sha1][4 addr][1 length] = 29 bytes
|
|
|
|
|
|
if (pos + 29 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos += 29;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
resultData.push_back(0x00);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_MPQ: {
|
|
|
|
|
|
// Request: [1 stringIdx]
|
|
|
|
|
|
if (pos + 1 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
uint8_t strIdx = decrypted[pos++];
|
|
|
|
|
|
LOG_INFO("Warden: MPQ file=\"",
|
|
|
|
|
|
(strIdx < strings.size() ? strings[strIdx] : "?"), "\"");
|
|
|
|
|
|
// Response: [uint8 result=0][20 sha1 zeros]
|
|
|
|
|
|
// Pretend file found with zero hash (may fail comparison)
|
|
|
|
|
|
resultData.push_back(0x00);
|
|
|
|
|
|
for (int i = 0; i < 20; i++) resultData.push_back(0x00);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_LUA: {
|
|
|
|
|
|
// Request: [1 stringIdx]
|
|
|
|
|
|
if (pos + 1 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
uint8_t strIdx = decrypted[pos++];
|
|
|
|
|
|
LOG_INFO("Warden: LUA str=\"",
|
|
|
|
|
|
(strIdx < strings.size() ? strings[strIdx] : "?"), "\"");
|
|
|
|
|
|
// Response: [uint8 result=0][uint16 len=0]
|
|
|
|
|
|
// Lua string doesn't exist
|
|
|
|
|
|
resultData.push_back(0x01); // not found
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_DRIVER: {
|
|
|
|
|
|
// Request: [4 seed][20 sha1][1 stringIdx]
|
|
|
|
|
|
if (pos + 25 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos += 24; // skip seed + sha1
|
|
|
|
|
|
uint8_t strIdx = decrypted[pos++];
|
|
|
|
|
|
LOG_INFO("Warden: DRIVER=\"",
|
|
|
|
|
|
(strIdx < strings.size() ? strings[strIdx] : "?"), "\"");
|
|
|
|
|
|
// Response: [uint8 result=1] (driver NOT found = clean)
|
|
|
|
|
|
resultData.push_back(0x01);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_MODULE: {
|
|
|
|
|
|
// Request: [4 seed][20 sha1]
|
|
|
|
|
|
if (pos + 24 > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos += 24;
|
|
|
|
|
|
// Response: [uint8 result=1] (module NOT loaded = clean)
|
|
|
|
|
|
resultData.push_back(0x01);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case CT_PROC: {
|
2026-02-14 01:26:26 -08:00
|
|
|
|
// Vanilla: [4 seed][20 sha1][1 stringIdx] = 25 bytes
|
|
|
|
|
|
// WotLK: [4 seed][20 sha1][1 stringIdx][1 stringIdx2][4 offset] = 30 bytes
|
|
|
|
|
|
int procSize = (build <= 6005) ? 25 : 30;
|
|
|
|
|
|
if (pos + procSize > checkEnd) { pos = checkEnd; break; }
|
|
|
|
|
|
pos += procSize;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// Response: [uint8 result=1] (proc NOT found = clean)
|
|
|
|
|
|
resultData.push_back(0x01);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
default: {
|
|
|
|
|
|
LOG_WARNING("Warden: Unknown check type, cannot parse remaining");
|
|
|
|
|
|
pos = checkEnd; // stop parsing
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Warden: Parsed ", checkCount, " checks, result data size=", resultData.size());
|
|
|
|
|
|
|
|
|
|
|
|
// --- Compute checksum: XOR of 5 uint32s from SHA1(resultData) ---
|
|
|
|
|
|
auto resultHash = auth::Crypto::sha1(resultData);
|
|
|
|
|
|
uint32_t checksum = 0;
|
|
|
|
|
|
for (int i = 0; i < 5; i++) {
|
|
|
|
|
|
uint32_t word = resultHash[i*4]
|
|
|
|
|
|
| (uint32_t(resultHash[i*4+1]) << 8)
|
|
|
|
|
|
| (uint32_t(resultHash[i*4+2]) << 16)
|
|
|
|
|
|
| (uint32_t(resultHash[i*4+3]) << 24);
|
|
|
|
|
|
checksum ^= word;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Build response: [0x02][uint16 length][uint32 checksum][resultData] ---
|
|
|
|
|
|
uint16_t resultLen = static_cast<uint16_t>(resultData.size());
|
2026-02-13 16:53:28 -08:00
|
|
|
|
std::vector<uint8_t> resp;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
resp.push_back(0x02);
|
|
|
|
|
|
resp.push_back(resultLen & 0xFF);
|
|
|
|
|
|
resp.push_back((resultLen >> 8) & 0xFF);
|
|
|
|
|
|
resp.push_back(checksum & 0xFF);
|
|
|
|
|
|
resp.push_back((checksum >> 8) & 0xFF);
|
|
|
|
|
|
resp.push_back((checksum >> 16) & 0xFF);
|
|
|
|
|
|
resp.push_back((checksum >> 24) & 0xFF);
|
|
|
|
|
|
resp.insert(resp.end(), resultData.begin(), resultData.end());
|
2026-02-13 16:53:28 -08:00
|
|
|
|
sendWardenResponse(resp);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
LOG_INFO("Warden: Sent CHEAT_CHECKS_RESULT (", resp.size(), " bytes, ",
|
|
|
|
|
|
checkCount, " checks, checksum=0x",
|
|
|
|
|
|
[&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")");
|
2026-02-13 16:53:28 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-12 01:53:21 -08:00
|
|
|
|
|
2026-02-14 01:26:26 -08:00
|
|
|
|
case 0x03: // WARDEN_SMSG_MODULE_INITIALIZE
|
|
|
|
|
|
LOG_INFO("Warden: MODULE_INITIALIZE (", decrypted.size(), " bytes, no response needed)");
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
2026-02-13 16:53:28 -08:00
|
|
|
|
default:
|
|
|
|
|
|
LOG_INFO("Warden: Unknown opcode 0x", std::hex, (int)wardenOpcode, std::dec,
|
|
|
|
|
|
" (state=", (int)wardenState_, ", size=", decrypted.size(), ")");
|
|
|
|
|
|
break;
|
2026-02-12 01:53:21 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::handleAccountDataTimes(network::Packet& packet) {
|
|
|
|
|
|
LOG_DEBUG("Handling SMSG_ACCOUNT_DATA_TIMES");
|
|
|
|
|
|
|
|
|
|
|
|
AccountDataTimesData data;
|
|
|
|
|
|
if (!AccountDataTimesParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_ACCOUNT_DATA_TIMES");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Account data times received (server time: ", data.serverTime, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleMotd(network::Packet& packet) {
|
|
|
|
|
|
LOG_INFO("Handling SMSG_MOTD");
|
|
|
|
|
|
|
|
|
|
|
|
MotdData data;
|
|
|
|
|
|
if (!MotdParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_MOTD");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!data.isEmpty()) {
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
LOG_INFO(" MESSAGE OF THE DAY");
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
for (const auto& line : data.lines) {
|
|
|
|
|
|
LOG_INFO(line);
|
2026-02-05 13:22:15 -08:00
|
|
|
|
addSystemChatMessage(std::string("MOTD: ") + line);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("========================================");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendPing() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Increment sequence number
|
|
|
|
|
|
pingSequence++;
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Sending CMSG_PING (heartbeat)");
|
|
|
|
|
|
LOG_DEBUG(" Sequence: ", pingSequence);
|
|
|
|
|
|
|
|
|
|
|
|
// Build and send ping packet
|
|
|
|
|
|
auto packet = PingPacket::build(pingSequence, lastLatency);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handlePong(network::Packet& packet) {
|
|
|
|
|
|
LOG_DEBUG("Handling SMSG_PONG");
|
|
|
|
|
|
|
|
|
|
|
|
PongData data;
|
|
|
|
|
|
if (!PongParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_PONG");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Verify sequence matches
|
|
|
|
|
|
if (data.sequence != pingSequence) {
|
|
|
|
|
|
LOG_WARNING("SMSG_PONG sequence mismatch: expected ", pingSequence,
|
|
|
|
|
|
", got ", data.sequence);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendMovement(Opcode opcode) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Cannot send movement in state: ", (int)state);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Block manual movement while taxi is active/mounted, but always allow
|
|
|
|
|
|
// stop/heartbeat opcodes so stuck states can be recovered.
|
|
|
|
|
|
bool taxiAllowed =
|
|
|
|
|
|
(opcode == Opcode::CMSG_MOVE_HEARTBEAT) ||
|
|
|
|
|
|
(opcode == Opcode::CMSG_MOVE_STOP) ||
|
|
|
|
|
|
(opcode == Opcode::CMSG_MOVE_STOP_STRAFE) ||
|
|
|
|
|
|
(opcode == Opcode::CMSG_MOVE_STOP_TURN) ||
|
2026-02-11 22:27:02 -08:00
|
|
|
|
(opcode == Opcode::CMSG_MOVE_STOP_SWIM);
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return;
|
|
|
|
|
|
if (resurrectPending_ && !taxiAllowed) return;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
|
2026-02-06 09:14:22 -08:00
|
|
|
|
// Use real millisecond timestamp (server validates for anti-cheat)
|
|
|
|
|
|
static auto startTime = std::chrono::steady_clock::now();
|
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
|
movementInfo.time = static_cast<uint32_t>(
|
|
|
|
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(now - startTime).count());
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
// Update movement flags based on opcode
|
|
|
|
|
|
switch (opcode) {
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_FORWARD:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FORWARD);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_BACKWARD:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::BACKWARD);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP:
|
|
|
|
|
|
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::FORWARD) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::BACKWARD));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_STRAFE_LEFT:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_LEFT);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_STRAFE_RIGHT:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP_STRAFE:
|
|
|
|
|
|
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_JUMP:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FALLING);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_TURN_LEFT:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_LEFT);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_START_TURN_RIGHT:
|
|
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_RIGHT);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_STOP_TURN:
|
|
|
|
|
|
movementInfo.flags &= ~(static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::TURN_RIGHT));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_FALL_LAND:
|
|
|
|
|
|
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::FALLING);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case Opcode::CMSG_MOVE_HEARTBEAT:
|
|
|
|
|
|
// No flag changes — just sends current position
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) {
|
|
|
|
|
|
sanitizeMovementForTaxi();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 02:23:37 -08:00
|
|
|
|
// Add transport data if player is on a transport
|
|
|
|
|
|
if (isOnTransport()) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
// Keep authoritative world position synchronized to parent transport transform
|
|
|
|
|
|
// so heartbeats/corrections don't drag the passenger through geometry.
|
|
|
|
|
|
if (transportManager_) {
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
|
|
|
|
|
|
movementInfo.x = composed.x;
|
|
|
|
|
|
movementInfo.y = composed.y;
|
|
|
|
|
|
movementInfo.z = composed.z;
|
|
|
|
|
|
}
|
2026-02-11 02:23:37 -08:00
|
|
|
|
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
|
|
|
|
|
|
movementInfo.transportGuid = playerTransportGuid_;
|
|
|
|
|
|
movementInfo.transportX = playerTransportOffset_.x;
|
|
|
|
|
|
movementInfo.transportY = playerTransportOffset_.y;
|
|
|
|
|
|
movementInfo.transportZ = playerTransportOffset_.z;
|
2026-02-11 15:24:05 -08:00
|
|
|
|
movementInfo.transportTime = movementInfo.time;
|
|
|
|
|
|
movementInfo.transportSeat = -1;
|
|
|
|
|
|
movementInfo.transportTime2 = movementInfo.time;
|
|
|
|
|
|
|
|
|
|
|
|
// ONTRANSPORT expects local orientation (player yaw relative to transport yaw).
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// Keep internal yaw canonical; convert to server yaw on the wire.
|
|
|
|
|
|
float transportYawCanonical = 0.0f;
|
2026-02-11 15:24:05 -08:00
|
|
|
|
if (transportManager_) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr) {
|
|
|
|
|
|
if (tr->hasServerYaw) {
|
2026-02-12 15:08:21 -08:00
|
|
|
|
transportYawCanonical = tr->serverYaw;
|
2026-02-12 00:04:53 -08:00
|
|
|
|
} else {
|
2026-02-12 15:08:21 -08:00
|
|
|
|
transportYawCanonical = glm::eulerAngles(tr->rotation).z;
|
2026-02-12 00:04:53 -08:00
|
|
|
|
}
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.transportO =
|
|
|
|
|
|
core::coords::normalizeAngleRad(movementInfo.orientation - transportYawCanonical);
|
2026-02-11 02:23:37 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
// Clear transport flag if not on transport
|
|
|
|
|
|
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
|
|
|
|
|
|
movementInfo.transportGuid = 0;
|
2026-02-11 15:24:05 -08:00
|
|
|
|
movementInfo.transportSeat = -1;
|
2026-02-11 02:23:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_DEBUG("Sending movement packet: opcode=0x", std::hex,
|
2026-02-12 22:56:36 -08:00
|
|
|
|
wireOpcode(opcode), std::dec,
|
2026-02-11 02:23:37 -08:00
|
|
|
|
(isOnTransport() ? " ONTRANSPORT" : ""));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-04 18:27:52 -08:00
|
|
|
|
// Convert canonical → server coordinates for the wire
|
|
|
|
|
|
MovementInfo wireInfo = movementInfo;
|
|
|
|
|
|
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wireInfo.x, wireInfo.y, wireInfo.z));
|
|
|
|
|
|
wireInfo.x = serverPos.x;
|
|
|
|
|
|
wireInfo.y = serverPos.y;
|
|
|
|
|
|
wireInfo.z = serverPos.z;
|
|
|
|
|
|
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// Convert canonical → server yaw for the wire
|
|
|
|
|
|
wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation);
|
|
|
|
|
|
|
2026-02-11 02:23:37 -08:00
|
|
|
|
// Also convert transport local position to server coordinates if on transport
|
|
|
|
|
|
if (isOnTransport()) {
|
|
|
|
|
|
glm::vec3 serverTransportPos = core::coords::canonicalToServer(
|
|
|
|
|
|
glm::vec3(wireInfo.transportX, wireInfo.transportY, wireInfo.transportZ));
|
|
|
|
|
|
wireInfo.transportX = serverTransportPos.x;
|
|
|
|
|
|
wireInfo.transportY = serverTransportPos.y;
|
|
|
|
|
|
wireInfo.transportZ = serverTransportPos.z;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// transportO is a local delta; server<->canonical swap negates delta yaw.
|
|
|
|
|
|
wireInfo.transportO = core::coords::normalizeAngleRad(-wireInfo.transportO);
|
2026-02-11 02:23:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
// Build and send movement packet (expansion-specific format)
|
|
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildMovementPacket(opcode, wireInfo, playerGuid)
|
|
|
|
|
|
: MovementPacket::build(opcode, wireInfo, playerGuid);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
void GameHandler::sanitizeMovementForTaxi() {
|
|
|
|
|
|
constexpr uint32_t kClearTaxiFlags =
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FORWARD) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::BACKWARD) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::TURN_LEFT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::TURN_RIGHT) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::PITCH_UP) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::PITCH_DOWN) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FALLING) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::FALLINGFAR) |
|
|
|
|
|
|
static_cast<uint32_t>(MovementFlags::SWIMMING);
|
|
|
|
|
|
|
|
|
|
|
|
movementInfo.flags &= ~kClearTaxiFlags;
|
|
|
|
|
|
movementInfo.fallTime = 0;
|
|
|
|
|
|
movementInfo.jumpVelocity = 0.0f;
|
|
|
|
|
|
movementInfo.jumpSinAngle = 0.0f;
|
|
|
|
|
|
movementInfo.jumpCosAngle = 0.0f;
|
|
|
|
|
|
movementInfo.jumpXYSpeed = 0.0f;
|
|
|
|
|
|
movementInfo.pitch = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
void GameHandler::forceClearTaxiAndMovementState() {
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
taxiStartGrace_ = 0.0f;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
|
|
|
|
|
|
|
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
resurrectPending_ = false;
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
|
|
|
|
|
playerDead_ = false;
|
|
|
|
|
|
releasedSpirit_ = false;
|
|
|
|
|
|
repopPending_ = false;
|
|
|
|
|
|
pendingSpiritHealerGuid_ = 0;
|
|
|
|
|
|
resurrectCasterGuid_ = 0;
|
|
|
|
|
|
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
movementInfo.transportGuid = 0;
|
|
|
|
|
|
clearPlayerTransport();
|
|
|
|
|
|
|
|
|
|
|
|
if (socket && state == WorldState::IN_WORLD) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP_STRAFE);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP_TURN);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP_SWIM);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Force-cleared taxi/movement state");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::setPosition(float x, float y, float z) {
|
|
|
|
|
|
movementInfo.x = x;
|
|
|
|
|
|
movementInfo.y = y;
|
|
|
|
|
|
movementInfo.z = z;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setOrientation(float orientation) {
|
|
|
|
|
|
movementInfo.orientation = orientation;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|
|
|
|
|
|
|
|
|
|
|
UpdateObjectData data;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (!packetParsers_->parseUpdateObject(packet, data)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 19:40:54 -08:00
|
|
|
|
auto extractPlayerAppearance = [&](const std::map<uint16_t, uint32_t>& fields,
|
|
|
|
|
|
uint8_t& outRace,
|
|
|
|
|
|
uint8_t& outGender,
|
|
|
|
|
|
uint32_t& outAppearanceBytes,
|
|
|
|
|
|
uint8_t& outFacial) -> bool {
|
|
|
|
|
|
outRace = 0;
|
|
|
|
|
|
outGender = 0;
|
|
|
|
|
|
outAppearanceBytes = 0;
|
|
|
|
|
|
outFacial = 0;
|
|
|
|
|
|
|
|
|
|
|
|
auto readField = [&](uint16_t idx, uint32_t& out) -> bool {
|
|
|
|
|
|
if (idx == 0xFFFF) return false;
|
|
|
|
|
|
auto it = fields.find(idx);
|
|
|
|
|
|
if (it == fields.end()) return false;
|
|
|
|
|
|
out = it->second;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t bytes0 = 0;
|
|
|
|
|
|
uint32_t pbytes = 0;
|
|
|
|
|
|
uint32_t pbytes2 = 0;
|
|
|
|
|
|
|
|
|
|
|
|
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
|
|
|
|
|
|
const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES);
|
|
|
|
|
|
const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2);
|
|
|
|
|
|
|
|
|
|
|
|
bool haveBytes0 = readField(ufBytes0, bytes0);
|
|
|
|
|
|
bool havePbytes = readField(ufPbytes, pbytes);
|
|
|
|
|
|
bool havePbytes2 = readField(ufPbytes2, pbytes2);
|
|
|
|
|
|
|
|
|
|
|
|
// Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing,
|
|
|
|
|
|
// try to locate plausible packed fields by scanning.
|
|
|
|
|
|
if (!haveBytes0) {
|
|
|
|
|
|
for (const auto& [idx, v] : fields) {
|
|
|
|
|
|
uint8_t race = static_cast<uint8_t>(v & 0xFF);
|
|
|
|
|
|
uint8_t cls = static_cast<uint8_t>((v >> 8) & 0xFF);
|
|
|
|
|
|
uint8_t gender = static_cast<uint8_t>((v >> 16) & 0xFF);
|
|
|
|
|
|
uint8_t power = static_cast<uint8_t>((v >> 24) & 0xFF);
|
|
|
|
|
|
if (race >= 1 && race <= 20 &&
|
|
|
|
|
|
cls >= 1 && cls <= 20 &&
|
|
|
|
|
|
gender <= 1 &&
|
|
|
|
|
|
power <= 10) {
|
|
|
|
|
|
bytes0 = v;
|
|
|
|
|
|
haveBytes0 = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!havePbytes) {
|
|
|
|
|
|
for (const auto& [idx, v] : fields) {
|
|
|
|
|
|
uint8_t skin = static_cast<uint8_t>(v & 0xFF);
|
|
|
|
|
|
uint8_t face = static_cast<uint8_t>((v >> 8) & 0xFF);
|
|
|
|
|
|
uint8_t hair = static_cast<uint8_t>((v >> 16) & 0xFF);
|
|
|
|
|
|
uint8_t color = static_cast<uint8_t>((v >> 24) & 0xFF);
|
|
|
|
|
|
if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) {
|
|
|
|
|
|
pbytes = v;
|
|
|
|
|
|
havePbytes = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!havePbytes2) {
|
|
|
|
|
|
for (const auto& [idx, v] : fields) {
|
|
|
|
|
|
uint8_t facial = static_cast<uint8_t>(v & 0xFF);
|
|
|
|
|
|
if (facial <= 100) {
|
|
|
|
|
|
pbytes2 = v;
|
|
|
|
|
|
havePbytes2 = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!haveBytes0 || !havePbytes) return false;
|
|
|
|
|
|
|
|
|
|
|
|
outRace = static_cast<uint8_t>(bytes0 & 0xFF);
|
|
|
|
|
|
outGender = static_cast<uint8_t>((bytes0 >> 16) & 0xFF);
|
|
|
|
|
|
outAppearanceBytes = pbytes;
|
|
|
|
|
|
outFacial = havePbytes2 ? static_cast<uint8_t>(pbytes2 & 0xFF) : 0;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-13 19:47:49 -08:00
|
|
|
|
auto maybeDetectCoinageIndex = [&](const std::map<uint16_t, uint32_t>& oldFields,
|
|
|
|
|
|
const std::map<uint16_t, uint32_t>& newFields) {
|
|
|
|
|
|
if (pendingMoneyDelta_ == 0 || pendingMoneyDeltaTimer_ <= 0.0f) return;
|
|
|
|
|
|
if (oldFields.empty() || newFields.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
constexpr uint32_t kMaxPlausibleCoinage = 2147483647u;
|
|
|
|
|
|
std::vector<uint16_t> candidates;
|
|
|
|
|
|
candidates.reserve(8);
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [idx, newVal] : newFields) {
|
|
|
|
|
|
auto itOld = oldFields.find(idx);
|
|
|
|
|
|
if (itOld == oldFields.end()) continue;
|
|
|
|
|
|
uint32_t oldVal = itOld->second;
|
|
|
|
|
|
if (newVal < oldVal) continue;
|
|
|
|
|
|
uint32_t delta = newVal - oldVal;
|
|
|
|
|
|
if (delta != pendingMoneyDelta_) continue;
|
|
|
|
|
|
if (newVal > kMaxPlausibleCoinage) continue;
|
|
|
|
|
|
candidates.push_back(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (candidates.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
uint16_t current = fieldIndex(UF::PLAYER_FIELD_COINAGE);
|
|
|
|
|
|
uint16_t chosen = candidates[0];
|
|
|
|
|
|
if (std::find(candidates.begin(), candidates.end(), current) != candidates.end()) {
|
|
|
|
|
|
chosen = current;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::sort(candidates.begin(), candidates.end());
|
|
|
|
|
|
chosen = candidates[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (chosen != current && current != 0xFFFF) {
|
|
|
|
|
|
updateFieldTable_.setIndex(UF::PLAYER_FIELD_COINAGE, chosen);
|
|
|
|
|
|
LOG_WARNING("Auto-detected PLAYER_FIELD_COINAGE index: ", chosen, " (was ", current, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pendingMoneyDelta_ = 0;
|
|
|
|
|
|
pendingMoneyDeltaTimer_ = 0.0f;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Process out-of-range objects first
|
|
|
|
|
|
for (uint64_t guid : data.outOfRangeGuids) {
|
|
|
|
|
|
if (entityManager.hasEntity(guid)) {
|
2026-02-11 17:30:57 -08:00
|
|
|
|
const bool isKnownTransport = transportGuids_.count(guid) > 0;
|
|
|
|
|
|
if (isKnownTransport) {
|
2026-02-12 00:45:24 -08:00
|
|
|
|
// Keep transports alive across out-of-range flapping.
|
|
|
|
|
|
// Boats/zeppelins are global movers and removing them here can make
|
|
|
|
|
|
// them disappear until a later movement snapshot happens to recreate them.
|
|
|
|
|
|
const bool playerAboardNow = (playerTransportGuid_ == guid);
|
|
|
|
|
|
const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f);
|
|
|
|
|
|
const bool movementSaysAboard = (movementInfo.transportGuid == guid);
|
|
|
|
|
|
LOG_INFO("Preserving transport on out-of-range: 0x",
|
|
|
|
|
|
std::hex, guid, std::dec,
|
|
|
|
|
|
" now=", playerAboardNow,
|
|
|
|
|
|
" sticky=", stickyAboard,
|
|
|
|
|
|
" movement=", movementSaysAboard);
|
|
|
|
|
|
continue;
|
2026-02-11 17:30:57 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec);
|
2026-02-07 19:44:03 -08:00
|
|
|
|
// Trigger despawn callbacks before removing entity
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
|
|
|
|
|
|
creatureDespawnCallback_(guid);
|
2026-02-13 19:40:54 -08:00
|
|
|
|
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
|
|
|
|
|
|
playerDespawnCallback_(guid);
|
2026-02-13 20:10:19 -08:00
|
|
|
|
otherPlayerVisibleItemEntries_.erase(guid);
|
|
|
|
|
|
otherPlayerVisibleDirty_.erase(guid);
|
2026-02-13 20:19:33 -08:00
|
|
|
|
otherPlayerMoveTimeMs_.erase(guid);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
inspectedPlayerItemEntries_.erase(guid);
|
|
|
|
|
|
pendingAutoInspect_.erase(guid);
|
2026-02-07 19:44:03 -08:00
|
|
|
|
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
|
|
|
|
|
|
gameObjectDespawnCallback_(guid);
|
|
|
|
|
|
}
|
2026-02-05 21:55:52 -08:00
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
transportGuids_.erase(guid);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
serverUpdatedTransportGuids_.erase(guid);
|
|
|
|
|
|
clearTransportAttachment(guid);
|
2026-02-08 00:59:40 -08:00
|
|
|
|
if (playerTransportGuid_ == guid) {
|
2026-02-12 00:45:24 -08:00
|
|
|
|
clearPlayerTransport();
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
entityManager.removeEntity(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Process update blocks
|
|
|
|
|
|
for (const auto& block : data.blocks) {
|
|
|
|
|
|
switch (block.updateType) {
|
|
|
|
|
|
case UpdateType::CREATE_OBJECT:
|
|
|
|
|
|
case UpdateType::CREATE_OBJECT2: {
|
|
|
|
|
|
// Create new entity
|
|
|
|
|
|
std::shared_ptr<Entity> entity;
|
|
|
|
|
|
|
|
|
|
|
|
switch (block.objectType) {
|
|
|
|
|
|
case ObjectType::PLAYER:
|
|
|
|
|
|
entity = std::make_shared<Player>(block.guid);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case ObjectType::UNIT:
|
|
|
|
|
|
entity = std::make_shared<Unit>(block.guid);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case ObjectType::GAMEOBJECT:
|
|
|
|
|
|
entity = std::make_shared<GameObject>(block.guid);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
entity = std::make_shared<Entity>(block.guid);
|
|
|
|
|
|
entity->setType(block.objectType);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 18:27:52 -08:00
|
|
|
|
// Set position from movement block (server → canonical)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (block.hasMovement) {
|
2026-02-04 18:27:52 -08:00
|
|
|
|
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
|
2026-02-12 15:08:21 -08:00
|
|
|
|
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
|
|
|
|
|
|
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
|
2026-02-04 18:27:52 -08:00
|
|
|
|
LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")");
|
2026-02-07 20:24:25 -08:00
|
|
|
|
if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
|
|
|
|
|
|
serverRunSpeed_ = block.runSpeed;
|
|
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// Track player-on-transport state
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
if (block.onTransport) {
|
2026-02-12 00:45:24 -08:00
|
|
|
|
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
|
2026-02-11 02:23:37 -08:00
|
|
|
|
// Convert transport offset from server → canonical coordinates
|
|
|
|
|
|
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
|
|
|
|
|
|
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
|
2026-02-12 15:08:21 -08:00
|
|
|
|
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
movementInfo.x = composed.x;
|
|
|
|
|
|
movementInfo.y = composed.y;
|
|
|
|
|
|
movementInfo.z = composed.z;
|
|
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec,
|
2026-02-12 00:45:24 -08:00
|
|
|
|
" offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")");
|
2026-02-08 00:59:40 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
if (playerTransportGuid_ != 0) {
|
|
|
|
|
|
LOG_INFO("Player left transport");
|
|
|
|
|
|
}
|
2026-02-12 00:45:24 -08:00
|
|
|
|
clearPlayerTransport();
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-11 15:24:05 -08:00
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
|
// Track transport-relative children so they follow parent transport motion.
|
|
|
|
|
|
if (block.guid != playerGuid &&
|
|
|
|
|
|
(block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) {
|
|
|
|
|
|
if (block.onTransport && block.transportGuid != 0) {
|
|
|
|
|
|
glm::vec3 localOffset = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(block.transportX, block.transportY, block.transportZ));
|
|
|
|
|
|
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
|
2026-02-12 15:08:21 -08:00
|
|
|
|
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
setTransportAttachment(block.guid, block.objectType, block.transportGuid,
|
2026-02-12 15:08:21 -08:00
|
|
|
|
localOffset, hasLocalOrientation, localOriCanonical);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (transportManager_ && transportManager_->getTransport(block.transportGuid)) {
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
|
|
|
|
|
|
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearTransportAttachment(block.guid);
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set fields
|
|
|
|
|
|
for (const auto& field : block.fields) {
|
|
|
|
|
|
entity->setField(field.first, field.second);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add to manager
|
|
|
|
|
|
entityManager.addEntity(block.guid, entity);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-13 20:13:37 -08:00
|
|
|
|
// For the local player, capture the full initial field state (CREATE_OBJECT carries the
|
|
|
|
|
|
// large baseline update-field set, including visible item fields on many cores).
|
|
|
|
|
|
// Later VALUES updates often only include deltas and may never touch visible item fields.
|
|
|
|
|
|
if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) {
|
|
|
|
|
|
lastPlayerFields_ = entity->getFields();
|
|
|
|
|
|
maybeDetectVisibleItemLayout();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Auto-query names (Phase 1)
|
|
|
|
|
|
if (block.objectType == ObjectType::PLAYER) {
|
|
|
|
|
|
queryPlayerName(block.guid);
|
2026-02-13 20:10:19 -08:00
|
|
|
|
if (block.guid != playerGuid) {
|
|
|
|
|
|
updateOtherPlayerVisibleItems(block.guid, entity->getFields());
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
} else if (block.objectType == ObjectType::UNIT) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (it != block.fields.end() && it->second != 0) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
|
|
|
|
|
unit->setEntry(it->second);
|
2026-02-06 14:24:38 -08:00
|
|
|
|
// Set name from cache immediately if available
|
|
|
|
|
|
std::string cached = getCachedCreatureName(it->second);
|
|
|
|
|
|
if (!cached.empty()) {
|
|
|
|
|
|
unit->setName(cached);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
queryCreatureInfo(it->second, block.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 11:31:08 -08:00
|
|
|
|
// Extract health/mana/power from fields (Phase 2) — single pass
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
2026-02-07 21:47:14 -08:00
|
|
|
|
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
|
|
|
|
|
|
const uint16_t ufPower = fieldIndex(UF::UNIT_FIELD_POWER1);
|
|
|
|
|
|
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
|
|
|
|
|
|
const uint16_t ufMaxPower = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
|
|
|
|
|
|
const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
|
|
|
|
|
const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE);
|
|
|
|
|
|
const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS);
|
|
|
|
|
|
const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS);
|
|
|
|
|
|
const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID);
|
|
|
|
|
|
const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID);
|
|
|
|
|
|
const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS);
|
2026-02-04 11:31:08 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (key == ufHealth) {
|
|
|
|
|
|
unit->setHealth(val);
|
|
|
|
|
|
if (block.guid == playerGuid && val == 0) {
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
LOG_INFO("Player logged in dead");
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key == ufPower) { unit->setPower(val); }
|
|
|
|
|
|
else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
|
|
|
|
|
|
else if (key == ufMaxPower) { unit->setMaxPower(val); }
|
|
|
|
|
|
else if (key == ufFaction) { unit->setFactionTemplate(val); }
|
|
|
|
|
|
else if (key == ufFlags) { unit->setUnitFlags(val); }
|
|
|
|
|
|
else if (key == ufDynFlags) { unit->setDynamicFlags(val); }
|
|
|
|
|
|
else if (key == ufLevel) { unit->setLevel(val); }
|
|
|
|
|
|
else if (key == ufDisplayId) { unit->setDisplayId(val); }
|
|
|
|
|
|
else if (key == ufMountDisplayId) {
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
uint32_t old = currentMountDisplayId_;
|
|
|
|
|
|
currentMountDisplayId_ = val;
|
|
|
|
|
|
if (val != old && mountCallback_) mountCallback_(val);
|
2026-02-14 16:42:47 -08:00
|
|
|
|
if (old == 0 && val != 0) {
|
|
|
|
|
|
// Just mounted — find the mount aura (indefinite duration, self-cast)
|
|
|
|
|
|
mountAuraSpellId_ = 0;
|
|
|
|
|
|
for (const auto& a : playerAuras) {
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
|
|
|
|
|
|
mountAuraSpellId_ = a.spellId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
|
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
|
|
|
|
|
|
if (mountAuraSpellId_ == 0) {
|
|
|
|
|
|
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
|
|
|
|
|
|
if (ufAuras != 0xFFFF) {
|
|
|
|
|
|
for (const auto& [fk, fv] : block.fields) {
|
|
|
|
|
|
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
|
|
|
|
|
|
mountAuraSpellId_ = fv;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 16:42:47 -08:00
|
|
|
|
LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_);
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (old != 0 && val == 0) {
|
2026-02-14 16:42:47 -08:00
|
|
|
|
mountAuraSpellId_ = 0;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
for (auto& a : playerAuras)
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
}
|
|
|
|
|
|
unit->setMountDisplayId(val);
|
|
|
|
|
|
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
2026-02-04 11:31:08 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100;
|
2026-02-08 23:15:26 -08:00
|
|
|
|
if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
onTaxiFlight_ = true;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
applyTaxiMountForCurrentNode();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 21:47:14 -08:00
|
|
|
|
if (block.guid == playerGuid &&
|
|
|
|
|
|
(unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) {
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
LOG_INFO("Player logged in dead (dynamic flags)");
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
// Detect ghost state on login via PLAYER_FLAGS
|
2026-02-07 23:12:24 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS));
|
2026-02-07 23:12:24 -08:00
|
|
|
|
if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) {
|
|
|
|
|
|
releasedSpirit_ = true;
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 14:24:38 -08:00
|
|
|
|
// Determine hostility from faction template for online creatures
|
|
|
|
|
|
if (unit->getFactionTemplate() != 0) {
|
|
|
|
|
|
unit->setHostile(isHostileFaction(unit->getFactionTemplate()));
|
|
|
|
|
|
}
|
2026-02-13 19:40:54 -08:00
|
|
|
|
// Trigger creature spawn callback for units/players with displayId
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) {
|
2026-02-15 20:53:01 -08:00
|
|
|
|
if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) {
|
|
|
|
|
|
// Skip local player — spawned separately via spawnPlayerCharacter()
|
|
|
|
|
|
} else if (block.objectType == ObjectType::PLAYER) {
|
2026-02-13 19:40:54 -08:00
|
|
|
|
if (playerSpawnCallback_) {
|
|
|
|
|
|
uint8_t race = 0, gender = 0, facial = 0;
|
|
|
|
|
|
uint32_t appearanceBytes = 0;
|
|
|
|
|
|
// Use the entity's accumulated field state, not just this block's changed fields.
|
|
|
|
|
|
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
|
|
|
|
|
|
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
|
|
|
|
|
|
appearanceBytes, facial,
|
|
|
|
|
|
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (creatureSpawnCallback_) {
|
2026-02-05 21:55:52 -08:00
|
|
|
|
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
|
|
|
|
|
|
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
|
|
|
|
|
|
}
|
2026-02-06 20:13:28 -08:00
|
|
|
|
// Query quest giver status for NPCs with questgiver flag (0x02)
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 20:13:28 -08:00
|
|
|
|
qsPkt.writeUInt64(block.guid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
2026-02-05 21:55:52 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8)
|
2026-02-07 19:44:03 -08:00
|
|
|
|
if (block.objectType == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
auto go = std::static_pointer_cast<GameObject>(entity);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID));
|
2026-02-07 19:44:03 -08:00
|
|
|
|
if (itDisp != block.fields.end()) {
|
|
|
|
|
|
go->setDisplayId(itDisp->second);
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
|
2026-02-08 00:59:40 -08:00
|
|
|
|
if (itEntry != block.fields.end() && itEntry->second != 0) {
|
|
|
|
|
|
go->setEntry(itEntry->second);
|
|
|
|
|
|
auto cacheIt = gameObjectInfoCache_.find(itEntry->second);
|
|
|
|
|
|
if (cacheIt != gameObjectInfoCache_.end()) {
|
|
|
|
|
|
go->setName(cacheIt->second.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
queryGameObjectInfo(itEntry->second, block.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
|
|
|
|
|
|
if (block.updateFlags & 0x0002) {
|
|
|
|
|
|
transportGuids_.insert(block.guid);
|
|
|
|
|
|
LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
|
2026-02-12 15:38:39 -08:00
|
|
|
|
" entry=", go->getEntry(),
|
2026-02-08 00:59:40 -08:00
|
|
|
|
" displayId=", go->getDisplayId(),
|
|
|
|
|
|
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
|
2026-02-11 00:54:38 -08:00
|
|
|
|
// Note: TransportSpawnCallback will be invoked from Application after WMO instance is created
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
2026-02-07 19:44:03 -08:00
|
|
|
|
if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) {
|
2026-02-11 00:54:38 -08:00
|
|
|
|
gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(),
|
2026-02-07 19:44:03 -08:00
|
|
|
|
go->getX(), go->getY(), go->getZ(), go->getOrientation());
|
|
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// Fire transport move callback for transports (position update on re-creation)
|
|
|
|
|
|
if (transportGuids_.count(block.guid) && transportMoveCallback_) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
serverUpdatedTransportGuids_.insert(block.guid);
|
2026-02-08 00:59:40 -08:00
|
|
|
|
transportMoveCallback_(block.guid,
|
|
|
|
|
|
go->getX(), go->getY(), go->getZ(), go->getOrientation());
|
|
|
|
|
|
}
|
2026-02-07 19:44:03 -08:00
|
|
|
|
}
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// Track online item objects (CONTAINER = bags, also tracked as items)
|
|
|
|
|
|
if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
|
|
|
|
|
|
auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT));
|
2026-02-06 03:11:43 -08:00
|
|
|
|
if (entryIt != block.fields.end() && entryIt->second != 0) {
|
|
|
|
|
|
OnlineItemInfo info;
|
|
|
|
|
|
info.entry = entryIt->second;
|
|
|
|
|
|
info.stackCount = (stackIt != block.fields.end()) ? stackIt->second : 1;
|
|
|
|
|
|
onlineItems_[block.guid] = info;
|
|
|
|
|
|
queryItemInfo(info.entry, block.guid);
|
|
|
|
|
|
}
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// Extract container slot GUIDs for bags
|
|
|
|
|
|
if (block.objectType == ObjectType::CONTAINER) {
|
|
|
|
|
|
extractContainerFields(block.guid, block.fields);
|
|
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
// Extract XP / inventory slot / skill fields for player entity
|
2026-02-05 12:07:58 -08:00
|
|
|
|
if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) {
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// Store baseline snapshot on first update
|
|
|
|
|
|
static bool baselineStored = false;
|
|
|
|
|
|
static std::map<uint16_t, uint32_t> baselineFields;
|
|
|
|
|
|
|
|
|
|
|
|
if (!baselineStored) {
|
|
|
|
|
|
baselineFields = block.fields;
|
|
|
|
|
|
baselineStored = true;
|
|
|
|
|
|
LOG_INFO("===== BASELINE PLAYER FIELDS STORED =====");
|
|
|
|
|
|
LOG_INFO(" Total fields: ", block.fields.size());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Diff against baseline to find changes
|
|
|
|
|
|
std::vector<uint16_t> changedIndices;
|
|
|
|
|
|
std::vector<uint16_t> newIndices;
|
|
|
|
|
|
std::vector<uint16_t> removedIndices;
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [idx, val] : block.fields) {
|
|
|
|
|
|
auto it = baselineFields.find(idx);
|
|
|
|
|
|
if (it == baselineFields.end()) {
|
|
|
|
|
|
newIndices.push_back(idx);
|
|
|
|
|
|
} else if (it->second != val) {
|
|
|
|
|
|
changedIndices.push_back(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [idx, val] : baselineFields) {
|
|
|
|
|
|
if (block.fields.find(idx) == block.fields.end()) {
|
|
|
|
|
|
removedIndices.push_back(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 19:47:49 -08:00
|
|
|
|
// Auto-detect coinage index using the previous snapshot vs this full snapshot.
|
|
|
|
|
|
maybeDetectCoinageIndex(lastPlayerFields_, block.fields);
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
lastPlayerFields_ = block.fields;
|
|
|
|
|
|
detectInventorySlotBases(block.fields);
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Debug: Show field changes
|
|
|
|
|
|
LOG_INFO("Player update with ", block.fields.size(), " fields");
|
|
|
|
|
|
|
|
|
|
|
|
if (!changedIndices.empty() || !newIndices.empty() || !removedIndices.empty()) {
|
|
|
|
|
|
LOG_INFO(" ===== FIELD CHANGES DETECTED =====");
|
|
|
|
|
|
if (!changedIndices.empty()) {
|
|
|
|
|
|
LOG_INFO(" Changed fields (", changedIndices.size(), "):");
|
|
|
|
|
|
std::sort(changedIndices.begin(), changedIndices.end());
|
|
|
|
|
|
for (size_t i = 0; i < std::min(size_t(30), changedIndices.size()); ++i) {
|
|
|
|
|
|
uint16_t idx = changedIndices[i];
|
|
|
|
|
|
uint32_t oldVal = baselineFields[idx];
|
|
|
|
|
|
uint32_t newVal = block.fields.at(idx);
|
|
|
|
|
|
LOG_INFO(" [", idx, "]: ", oldVal, " -> ", newVal,
|
|
|
|
|
|
" (0x", std::hex, oldVal, " -> 0x", newVal, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (changedIndices.size() > 30) {
|
|
|
|
|
|
LOG_INFO(" ... (", changedIndices.size() - 30, " more)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!newIndices.empty()) {
|
|
|
|
|
|
LOG_INFO(" New fields (", newIndices.size(), "):");
|
|
|
|
|
|
std::sort(newIndices.begin(), newIndices.end());
|
|
|
|
|
|
for (size_t i = 0; i < std::min(size_t(20), newIndices.size()); ++i) {
|
|
|
|
|
|
uint16_t idx = newIndices[i];
|
|
|
|
|
|
uint32_t val = block.fields.at(idx);
|
|
|
|
|
|
LOG_INFO(" [", idx, "]: ", val, " (0x", std::hex, val, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (newIndices.size() > 20) {
|
|
|
|
|
|
LOG_INFO(" ... (", newIndices.size() - 20, " more)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!removedIndices.empty()) {
|
|
|
|
|
|
LOG_INFO(" Removed fields (", removedIndices.size(), "):");
|
|
|
|
|
|
std::sort(removedIndices.begin(), removedIndices.end());
|
|
|
|
|
|
for (size_t i = 0; i < std::min(size_t(20), removedIndices.size()); ++i) {
|
|
|
|
|
|
uint16_t idx = removedIndices[i];
|
|
|
|
|
|
uint32_t val = baselineFields.at(idx);
|
|
|
|
|
|
LOG_INFO(" [", idx, "]: was ", val, " (0x", std::hex, val, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint16_t maxField = 0;
|
|
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
|
|
|
|
|
if (key > maxField) maxField = key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO(" Highest field index: ", maxField);
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
bool slotsChanged = false;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
|
|
|
|
|
|
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
|
|
|
|
|
|
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
|
|
|
|
|
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
|
|
|
|
|
|
const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START);
|
|
|
|
|
|
const uint16_t ufQuestEnd = ufQuestStart + 25 * 5; // 25 quest slots, stride 5
|
2026-02-05 12:07:58 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (key == ufPlayerXp) { playerXp_ = val; }
|
|
|
|
|
|
else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; }
|
|
|
|
|
|
else if (key == ufPlayerLevel) {
|
|
|
|
|
|
serverPlayerLevel_ = val;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
for (auto& ch : characters) {
|
|
|
|
|
|
if (ch.guid == playerGuid) { ch.level = val; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufCoinage) {
|
2026-02-10 01:24:37 -08:00
|
|
|
|
playerMoneyCopper_ = val;
|
|
|
|
|
|
LOG_INFO("Money set from update fields: ", val, " copper");
|
2026-02-12 22:56:36 -08:00
|
|
|
|
}
|
|
|
|
|
|
// Parse quest log fields (stride 5, 25 slots)
|
|
|
|
|
|
else if (key >= ufQuestStart && key < ufQuestEnd && (key - ufQuestStart) % 5 == 0) {
|
2026-02-10 01:24:37 -08:00
|
|
|
|
uint32_t questId = val;
|
|
|
|
|
|
if (questId != 0) {
|
|
|
|
|
|
// Check if quest is already in log
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
for (auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == questId) {
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
// Add quest to log and request quest details
|
|
|
|
|
|
QuestLogEntry entry;
|
|
|
|
|
|
entry.questId = questId;
|
|
|
|
|
|
entry.complete = false; // Will be updated by gossip or quest status packets
|
|
|
|
|
|
entry.title = "Quest #" + std::to_string(questId);
|
|
|
|
|
|
questLog_.push_back(entry);
|
|
|
|
|
|
LOG_INFO("Found quest in update fields: ", questId);
|
|
|
|
|
|
|
|
|
|
|
|
// Request quest details from server
|
|
|
|
|
|
if (socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qPkt(wireOpcode(Opcode::CMSG_QUEST_QUERY));
|
2026-02-10 01:24:37 -08:00
|
|
|
|
qPkt.writeUInt32(questId);
|
|
|
|
|
|
socket->send(qPkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (applyInventoryFields(block.fields)) slotsChanged = true;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
if (slotsChanged) rebuildOnlineInventory();
|
2026-02-13 20:26:55 -08:00
|
|
|
|
maybeDetectVisibleItemLayout();
|
2026-02-07 14:21:50 -08:00
|
|
|
|
extractSkillFields(lastPlayerFields_);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
extractExploredZoneFields(lastPlayerFields_);
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case UpdateType::VALUES: {
|
|
|
|
|
|
// Update existing entity fields
|
|
|
|
|
|
auto entity = entityManager.getEntity(block.guid);
|
|
|
|
|
|
if (entity) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (block.hasMovement) {
|
|
|
|
|
|
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
|
2026-02-12 15:08:21 -08:00
|
|
|
|
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
|
|
|
|
|
|
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
|
|
|
|
|
|
if (block.guid != playerGuid &&
|
|
|
|
|
|
(entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) {
|
|
|
|
|
|
if (block.onTransport && block.transportGuid != 0) {
|
|
|
|
|
|
glm::vec3 localOffset = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(block.transportX, block.transportY, block.transportZ));
|
|
|
|
|
|
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
|
2026-02-12 15:08:21 -08:00
|
|
|
|
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
setTransportAttachment(block.guid, entity->getType(), block.transportGuid,
|
2026-02-12 15:08:21 -08:00
|
|
|
|
localOffset, hasLocalOrientation, localOriCanonical);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (transportManager_ && transportManager_->getTransport(block.transportGuid)) {
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
|
|
|
|
|
|
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearTransportAttachment(block.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
for (const auto& field : block.fields) {
|
|
|
|
|
|
entity->setField(field.first, field.second);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-13 20:10:19 -08:00
|
|
|
|
if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) {
|
|
|
|
|
|
updateOtherPlayerVisibleItems(block.guid, entity->getFields());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 11:31:08 -08:00
|
|
|
|
// Update cached health/mana/power values (Phase 2) — single pass
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
2026-02-07 21:47:14 -08:00
|
|
|
|
constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008;
|
2026-02-11 18:25:04 -08:00
|
|
|
|
uint32_t oldDisplayId = unit->getDisplayId();
|
|
|
|
|
|
bool displayIdChanged = false;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH);
|
|
|
|
|
|
const uint16_t ufPower = fieldIndex(UF::UNIT_FIELD_POWER1);
|
|
|
|
|
|
const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH);
|
|
|
|
|
|
const uint16_t ufMaxPower = fieldIndex(UF::UNIT_FIELD_MAXPOWER1);
|
|
|
|
|
|
const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
|
|
|
|
|
const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE);
|
|
|
|
|
|
const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS);
|
|
|
|
|
|
const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS);
|
|
|
|
|
|
const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID);
|
|
|
|
|
|
const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID);
|
|
|
|
|
|
const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS);
|
2026-02-04 11:31:08 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (key == ufHealth) {
|
|
|
|
|
|
uint32_t oldHealth = unit->getHealth();
|
|
|
|
|
|
unit->setHealth(val);
|
|
|
|
|
|
if (val == 0) {
|
|
|
|
|
|
if (block.guid == autoAttackTarget) {
|
|
|
|
|
|
stopAutoAttack();
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
hostileAttackers_.erase(block.guid);
|
2026-02-07 21:47:14 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
releasedSpirit_ = false;
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
LOG_INFO("Player died!");
|
2026-02-07 21:47:14 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) {
|
|
|
|
|
|
npcDeathCallback_(block.guid);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
} else if (oldHealth == 0 && val > 0) {
|
2026-02-07 17:59:40 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
playerDead_ = false;
|
|
|
|
|
|
if (!releasedSpirit_) {
|
|
|
|
|
|
LOG_INFO("Player resurrected!");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("Player entered ghost form");
|
2026-02-08 00:18:47 -08:00
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) {
|
|
|
|
|
|
npcRespawnCallback_(block.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key == ufPower) { unit->setPower(val); }
|
|
|
|
|
|
else if (key == ufMaxHealth) { unit->setMaxHealth(val); }
|
|
|
|
|
|
else if (key == ufMaxPower) { unit->setMaxPower(val); }
|
|
|
|
|
|
else if (key == ufFlags) { unit->setUnitFlags(val); }
|
|
|
|
|
|
else if (key == ufDynFlags) {
|
|
|
|
|
|
uint32_t oldDyn = unit->getDynamicFlags();
|
|
|
|
|
|
unit->setDynamicFlags(val);
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
|
|
|
|
|
|
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
|
|
|
|
|
|
if (!wasDead && nowDead) {
|
|
|
|
|
|
playerDead_ = true;
|
|
|
|
|
|
releasedSpirit_ = false;
|
|
|
|
|
|
LOG_INFO("Player died (dynamic flags)");
|
|
|
|
|
|
} else if (wasDead && !nowDead) {
|
|
|
|
|
|
playerDead_ = false;
|
|
|
|
|
|
releasedSpirit_ = false;
|
|
|
|
|
|
LOG_INFO("Player resurrected (dynamic flags)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key == ufLevel) { unit->setLevel(val); }
|
|
|
|
|
|
else if (key == ufFaction) {
|
|
|
|
|
|
unit->setFactionTemplate(val);
|
|
|
|
|
|
unit->setHostile(isHostileFaction(val));
|
|
|
|
|
|
} else if (key == ufDisplayId) {
|
|
|
|
|
|
if (val != unit->getDisplayId()) {
|
|
|
|
|
|
unit->setDisplayId(val);
|
|
|
|
|
|
displayIdChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key == ufMountDisplayId) {
|
|
|
|
|
|
if (block.guid == playerGuid) {
|
|
|
|
|
|
uint32_t old = currentMountDisplayId_;
|
|
|
|
|
|
currentMountDisplayId_ = val;
|
|
|
|
|
|
if (val != old && mountCallback_) mountCallback_(val);
|
2026-02-14 16:42:47 -08:00
|
|
|
|
if (old == 0 && val != 0) {
|
|
|
|
|
|
mountAuraSpellId_ = 0;
|
|
|
|
|
|
for (const auto& a : playerAuras) {
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
|
|
|
|
|
|
mountAuraSpellId_ = a.spellId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
|
// Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block
|
|
|
|
|
|
if (mountAuraSpellId_ == 0) {
|
|
|
|
|
|
const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS);
|
|
|
|
|
|
if (ufAuras != 0xFFFF) {
|
|
|
|
|
|
for (const auto& [fk, fv] : block.fields) {
|
|
|
|
|
|
if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) {
|
|
|
|
|
|
mountAuraSpellId_ = fv;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 16:42:47 -08:00
|
|
|
|
LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_);
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (old != 0 && val == 0) {
|
2026-02-14 16:42:47 -08:00
|
|
|
|
mountAuraSpellId_ = 0;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
for (auto& a : playerAuras)
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
unit->setMountDisplayId(val);
|
|
|
|
|
|
} else if (key == ufNpcFlags) { unit->setNpcFlags(val); }
|
2026-02-04 11:31:08 -08:00
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// Some units/players are created without displayId and get it later via VALUES.
|
|
|
|
|
|
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) &&
|
2026-02-11 18:25:04 -08:00
|
|
|
|
displayIdChanged &&
|
|
|
|
|
|
unit->getDisplayId() != 0 &&
|
|
|
|
|
|
unit->getDisplayId() != oldDisplayId) {
|
2026-02-15 20:53:01 -08:00
|
|
|
|
if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) {
|
|
|
|
|
|
// Skip local player — spawned separately
|
|
|
|
|
|
} else if (entity->getType() == ObjectType::PLAYER) {
|
2026-02-13 19:40:54 -08:00
|
|
|
|
if (playerSpawnCallback_) {
|
|
|
|
|
|
uint8_t race = 0, gender = 0, facial = 0;
|
|
|
|
|
|
uint32_t appearanceBytes = 0;
|
|
|
|
|
|
// Use the entity's accumulated field state, not just this block's changed fields.
|
|
|
|
|
|
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
|
|
|
|
|
|
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
|
|
|
|
|
|
appearanceBytes, facial,
|
|
|
|
|
|
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (creatureSpawnCallback_) {
|
2026-02-11 18:25:04 -08:00
|
|
|
|
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
|
|
|
|
|
|
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
|
|
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-11 18:25:04 -08:00
|
|
|
|
qsPkt.writeUInt64(block.guid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
2026-02-07 14:21:50 -08:00
|
|
|
|
// Update XP / inventory slot / skill fields for player entity
|
2026-02-05 12:07:58 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
2026-02-13 19:47:49 -08:00
|
|
|
|
std::map<uint16_t, uint32_t> oldFieldsSnapshot = lastPlayerFields_;
|
2026-02-07 20:24:25 -08:00
|
|
|
|
if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) {
|
|
|
|
|
|
serverRunSpeed_ = block.runSpeed;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Some server dismount paths update run speed without updating mount display field.
|
|
|
|
|
|
if (!onTaxiFlight_ && !taxiMountActive_ &&
|
|
|
|
|
|
currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) {
|
|
|
|
|
|
LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed,
|
|
|
|
|
|
" displayId=", currentMountDisplayId_);
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 20:24:25 -08:00
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
|
|
|
|
|
lastPlayerFields_[key] = val;
|
|
|
|
|
|
}
|
2026-02-13 19:47:49 -08:00
|
|
|
|
maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_);
|
2026-02-13 20:10:19 -08:00
|
|
|
|
maybeDetectVisibleItemLayout();
|
2026-02-06 18:34:45 -08:00
|
|
|
|
detectInventorySlotBases(block.fields);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
bool slotsChanged = false;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
|
|
|
|
|
|
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
|
|
|
|
|
|
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
|
|
|
|
|
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
|
|
|
|
|
|
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
|
2026-02-05 12:07:58 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (key == ufPlayerXp) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
playerXp_ = val;
|
|
|
|
|
|
LOG_INFO("XP updated: ", val);
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufPlayerNextXp) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
playerNextLevelXp_ = val;
|
|
|
|
|
|
LOG_INFO("Next level XP updated: ", val);
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufPlayerLevel) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
serverPlayerLevel_ = val;
|
|
|
|
|
|
LOG_INFO("Level updated: ", val);
|
|
|
|
|
|
for (auto& ch : characters) {
|
|
|
|
|
|
if (ch.guid == playerGuid) {
|
|
|
|
|
|
ch.level = val;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufCoinage) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
playerMoneyCopper_ = val;
|
|
|
|
|
|
LOG_INFO("Money updated via VALUES: ", val, " copper");
|
|
|
|
|
|
}
|
2026-02-12 22:56:36 -08:00
|
|
|
|
else if (key == ufPlayerFlags) {
|
2026-02-07 23:12:24 -08:00
|
|
|
|
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
|
|
|
|
|
|
bool wasGhost = releasedSpirit_;
|
|
|
|
|
|
bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0;
|
|
|
|
|
|
if (!wasGhost && nowGhost) {
|
|
|
|
|
|
releasedSpirit_ = true;
|
|
|
|
|
|
LOG_INFO("Player entered ghost form (PLAYER_FLAGS)");
|
|
|
|
|
|
} else if (wasGhost && !nowGhost) {
|
|
|
|
|
|
releasedSpirit_ = false;
|
|
|
|
|
|
playerDead_ = false;
|
|
|
|
|
|
repopPending_ = false;
|
|
|
|
|
|
resurrectPending_ = false;
|
|
|
|
|
|
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (applyInventoryFields(block.fields)) slotsChanged = true;
|
2026-02-06 03:11:43 -08:00
|
|
|
|
if (slotsChanged) rebuildOnlineInventory();
|
2026-02-07 14:21:50 -08:00
|
|
|
|
extractSkillFields(lastPlayerFields_);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
extractExploredZoneFields(lastPlayerFields_);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update item stack count for online items
|
2026-02-13 22:14:34 -08:00
|
|
|
|
if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) {
|
2026-02-06 03:11:43 -08:00
|
|
|
|
for (const auto& [key, val] : block.fields) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
if (key == fieldIndex(UF::ITEM_FIELD_STACK_COUNT)) {
|
2026-02-06 03:11:43 -08:00
|
|
|
|
auto it = onlineItems_.find(block.guid);
|
|
|
|
|
|
if (it != onlineItems_.end()) it->second.stackCount = val;
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// Update container slot GUIDs on bag content changes
|
|
|
|
|
|
if (entity->getType() == ObjectType::CONTAINER) {
|
|
|
|
|
|
extractContainerFields(block.guid, block.fields);
|
|
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
rebuildOnlineInventory();
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
if (transportGuids_.count(block.guid) && transportMoveCallback_) {
|
|
|
|
|
|
serverUpdatedTransportGuids_.insert(block.guid);
|
|
|
|
|
|
transportMoveCallback_(block.guid, entity->getX(), entity->getY(),
|
|
|
|
|
|
entity->getZ(), entity->getOrientation());
|
|
|
|
|
|
} else if (gameObjectMoveCallback_) {
|
|
|
|
|
|
gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(),
|
|
|
|
|
|
entity->getZ(), entity->getOrientation());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case UpdateType::MOVEMENT: {
|
2026-02-11 00:54:38 -08:00
|
|
|
|
// Diagnostic: Log if we receive MOVEMENT blocks for transports
|
|
|
|
|
|
if (transportGuids_.count(block.guid)) {
|
|
|
|
|
|
LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec,
|
|
|
|
|
|
" pos=(", block.x, ", ", block.y, ", ", block.z, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 18:27:52 -08:00
|
|
|
|
// Update entity position (server → canonical)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
auto entity = entityManager.getEntity(block.guid);
|
|
|
|
|
|
if (entity) {
|
2026-02-04 18:27:52 -08:00
|
|
|
|
glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z));
|
2026-02-12 15:08:21 -08:00
|
|
|
|
float oCanonical = core::coords::serverToCanonicalYaw(block.orientation);
|
|
|
|
|
|
entity->setPosition(pos.x, pos.y, pos.z, oCanonical);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec);
|
2026-02-11 15:24:05 -08:00
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (block.guid != playerGuid &&
|
|
|
|
|
|
(entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) {
|
|
|
|
|
|
if (block.onTransport && block.transportGuid != 0) {
|
|
|
|
|
|
glm::vec3 localOffset = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(block.transportX, block.transportY, block.transportZ));
|
|
|
|
|
|
const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING
|
2026-02-12 15:08:21 -08:00
|
|
|
|
float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
setTransportAttachment(block.guid, entity->getType(), block.transportGuid,
|
2026-02-12 15:08:21 -08:00
|
|
|
|
localOffset, hasLocalOrientation, localOriCanonical);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (transportManager_ && transportManager_->getTransport(block.transportGuid)) {
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset);
|
|
|
|
|
|
entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation());
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearTransportAttachment(block.guid);
|
2026-02-11 15:24:05 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (block.guid == playerGuid) {
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = oCanonical;
|
2026-02-11 02:23:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Track player-on-transport state from MOVEMENT updates
|
|
|
|
|
|
if (block.onTransport) {
|
2026-02-12 00:45:24 -08:00
|
|
|
|
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
|
2026-02-11 02:23:37 -08:00
|
|
|
|
// Convert transport offset from server → canonical coordinates
|
|
|
|
|
|
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
|
|
|
|
|
|
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
|
2026-02-12 15:08:21 -08:00
|
|
|
|
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
|
2026-02-12 00:04:53 -08:00
|
|
|
|
movementInfo.x = composed.x;
|
|
|
|
|
|
movementInfo.y = composed.y;
|
|
|
|
|
|
movementInfo.z = composed.z;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
movementInfo.x = pos.x;
|
|
|
|
|
|
movementInfo.y = pos.y;
|
|
|
|
|
|
movementInfo.z = pos.z;
|
|
|
|
|
|
}
|
2026-02-11 02:23:37 -08:00
|
|
|
|
LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec);
|
|
|
|
|
|
} else {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
movementInfo.x = pos.x;
|
|
|
|
|
|
movementInfo.y = pos.y;
|
|
|
|
|
|
movementInfo.z = pos.z;
|
2026-02-11 02:23:37 -08:00
|
|
|
|
if (playerTransportGuid_ != 0) {
|
|
|
|
|
|
LOG_INFO("Player left transport (MOVEMENT)");
|
2026-02-12 00:45:24 -08:00
|
|
|
|
clearPlayerTransport();
|
2026-02-11 02:23:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
|
|
|
|
|
|
// Fire transport move callback if this is a known transport
|
|
|
|
|
|
if (transportGuids_.count(block.guid) && transportMoveCallback_) {
|
2026-02-12 00:04:53 -08:00
|
|
|
|
serverUpdatedTransportGuids_.insert(block.guid);
|
2026-02-12 15:08:21 -08:00
|
|
|
|
transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical);
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
|
// Fire move callback for non-transport gameobjects.
|
|
|
|
|
|
if (entity->getType() == ObjectType::GAMEOBJECT &&
|
|
|
|
|
|
transportGuids_.count(block.guid) == 0 &&
|
|
|
|
|
|
gameObjectMoveCallback_) {
|
|
|
|
|
|
gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(),
|
|
|
|
|
|
entity->getZ(), entity->getOrientation());
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tabCycleStale = true;
|
2026-02-09 14:50:14 -08:00
|
|
|
|
// Entity count logging disabled
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
// Late inventory base detection once items are known
|
|
|
|
|
|
if (playerGuid != 0 && invSlotBase_ < 0 && !lastPlayerFields_.empty() && !onlineItems_.empty()) {
|
|
|
|
|
|
detectInventorySlotBases(lastPlayerFields_);
|
|
|
|
|
|
if (invSlotBase_ >= 0) {
|
|
|
|
|
|
if (applyInventoryFields(lastPlayerFields_)) {
|
|
|
|
|
|
rebuildOnlineInventory();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 21:55:52 -08:00
|
|
|
|
void GameHandler::handleCompressedUpdateObject(network::Packet& packet) {
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize());
|
2026-02-05 21:55:52 -08:00
|
|
|
|
|
|
|
|
|
|
// First 4 bytes = decompressed size
|
|
|
|
|
|
if (packet.getSize() < 4) {
|
|
|
|
|
|
LOG_WARNING("SMSG_COMPRESSED_UPDATE_OBJECT too small");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t decompressedSize = packet.readUInt32();
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG(" Decompressed size: ", decompressedSize);
|
2026-02-05 21:55:52 -08:00
|
|
|
|
|
|
|
|
|
|
if (decompressedSize == 0 || decompressedSize > 1024 * 1024) {
|
|
|
|
|
|
LOG_WARNING("Invalid decompressed size: ", decompressedSize);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remaining data is zlib compressed
|
|
|
|
|
|
size_t compressedSize = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
const uint8_t* compressedData = packet.getData().data() + packet.getReadPos();
|
|
|
|
|
|
|
|
|
|
|
|
// Decompress
|
|
|
|
|
|
std::vector<uint8_t> decompressed(decompressedSize);
|
|
|
|
|
|
uLongf destLen = decompressedSize;
|
|
|
|
|
|
int ret = uncompress(decompressed.data(), &destLen, compressedData, compressedSize);
|
|
|
|
|
|
|
|
|
|
|
|
if (ret != Z_OK) {
|
|
|
|
|
|
LOG_WARNING("Failed to decompress UPDATE_OBJECT: zlib error ", ret);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_DEBUG(" Decompressed ", compressedSize, " -> ", destLen, " bytes");
|
|
|
|
|
|
|
|
|
|
|
|
// Create packet from decompressed data and parse it
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet decompressedPacket(wireOpcode(Opcode::SMSG_UPDATE_OBJECT), decompressed);
|
2026-02-05 21:55:52 -08:00
|
|
|
|
handleUpdateObject(decompressedPacket);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::handleDestroyObject(network::Packet& packet) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
LOG_DEBUG("Handling SMSG_DESTROY_OBJECT");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
DestroyObjectData data;
|
|
|
|
|
|
if (!DestroyObjectParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_DESTROY_OBJECT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove entity
|
|
|
|
|
|
if (entityManager.hasEntity(data.guid)) {
|
2026-02-11 17:30:57 -08:00
|
|
|
|
if (transportGuids_.count(data.guid) > 0) {
|
2026-02-12 00:45:24 -08:00
|
|
|
|
const bool playerAboardNow = (playerTransportGuid_ == data.guid);
|
|
|
|
|
|
const bool stickyAboard = (playerTransportStickyGuid_ == data.guid && playerTransportStickyTimer_ > 0.0f);
|
|
|
|
|
|
const bool movementSaysAboard = (movementInfo.transportGuid == data.guid);
|
|
|
|
|
|
if (playerAboardNow || stickyAboard || movementSaysAboard) {
|
|
|
|
|
|
serverUpdatedTransportGuids_.erase(data.guid);
|
|
|
|
|
|
LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec,
|
|
|
|
|
|
" now=", playerAboardNow,
|
|
|
|
|
|
" sticky=", stickyAboard,
|
|
|
|
|
|
" movement=", movementSaysAboard);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-11 17:30:57 -08:00
|
|
|
|
}
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal.
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.guid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
|
|
|
|
|
|
creatureDespawnCallback_(data.guid);
|
|
|
|
|
|
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
|
|
|
|
|
|
gameObjectDespawnCallback_(data.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 00:45:24 -08:00
|
|
|
|
if (transportGuids_.count(data.guid) > 0) {
|
|
|
|
|
|
transportGuids_.erase(data.guid);
|
|
|
|
|
|
serverUpdatedTransportGuids_.erase(data.guid);
|
|
|
|
|
|
if (playerTransportGuid_ == data.guid) {
|
|
|
|
|
|
clearPlayerTransport();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 00:04:53 -08:00
|
|
|
|
clearTransportAttachment(data.guid);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
entityManager.removeEntity(data.guid);
|
|
|
|
|
|
LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec,
|
|
|
|
|
|
" (", (data.isDeath ? "death" : "despawn"), ")");
|
|
|
|
|
|
} else {
|
2026-02-16 00:51:59 -08:00
|
|
|
|
LOG_DEBUG("Destroy object for unknown entity: 0x", std::hex, data.guid, std::dec);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
// Clean up auto-attack and target if destroyed entity was our target
|
|
|
|
|
|
if (data.guid == autoAttackTarget) {
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.guid == targetGuid) {
|
|
|
|
|
|
targetGuid = 0;
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
hostileAttackers_.erase(data.guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// Remove online item/container tracking
|
|
|
|
|
|
containerContents_.erase(data.guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
if (onlineItems_.erase(data.guid)) {
|
|
|
|
|
|
rebuildOnlineInventory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 20:10:10 -08:00
|
|
|
|
// Clean up quest giver status
|
|
|
|
|
|
npcQuestStatus_.erase(data.guid);
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
tabCycleStale = true;
|
2026-02-09 14:50:14 -08:00
|
|
|
|
// Entity count logging disabled
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Cannot send chat in state: ", (int)state);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.empty()) {
|
|
|
|
|
|
LOG_WARNING("Cannot send empty chat message");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Sending chat message: [", getChatTypeString(type), "] ", message);
|
|
|
|
|
|
|
|
|
|
|
|
// Determine language based on character (for now, use COMMON)
|
|
|
|
|
|
ChatLanguage language = ChatLanguage::COMMON;
|
|
|
|
|
|
|
|
|
|
|
|
// Build and send packet
|
|
|
|
|
|
auto packet = MessageChatPacket::build(type, language, message, target);
|
|
|
|
|
|
socket->send(packet);
|
2026-02-07 23:32:27 -08:00
|
|
|
|
|
|
|
|
|
|
// Add local echo so the player sees their own message immediately
|
|
|
|
|
|
MessageChatData echo;
|
|
|
|
|
|
echo.senderGuid = playerGuid;
|
|
|
|
|
|
echo.language = language;
|
|
|
|
|
|
echo.message = message;
|
|
|
|
|
|
|
|
|
|
|
|
// Look up player name
|
|
|
|
|
|
auto nameIt = playerNameCache.find(playerGuid);
|
|
|
|
|
|
if (nameIt != playerNameCache.end()) {
|
|
|
|
|
|
echo.senderName = nameIt->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (type == ChatType::WHISPER) {
|
|
|
|
|
|
echo.type = ChatType::WHISPER_INFORM;
|
|
|
|
|
|
echo.senderName = target; // "To [target]: message"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
echo.type = type;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 18:27:59 -08:00
|
|
|
|
if (type == ChatType::CHANNEL) {
|
|
|
|
|
|
echo.channelName = target;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:32:27 -08:00
|
|
|
|
addLocalChatMessage(echo);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleMessageChat(network::Packet& packet) {
|
|
|
|
|
|
LOG_DEBUG("Handling SMSG_MESSAGECHAT");
|
|
|
|
|
|
|
|
|
|
|
|
MessageChatData data;
|
2026-02-13 18:59:09 -08:00
|
|
|
|
if (!packetParsers_->parseMessageChat(packet, data)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
LOG_WARNING("Failed to parse SMSG_MESSAGECHAT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:32:27 -08:00
|
|
|
|
// Skip server echo of our own messages (we already added a local echo)
|
|
|
|
|
|
if (data.senderGuid == playerGuid && data.senderGuid != 0) {
|
|
|
|
|
|
// Still track whisper sender for /r even if it's our own whisper-inform
|
|
|
|
|
|
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
|
|
|
|
|
|
lastWhisperSender_ = data.senderName;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
// Resolve sender name from entity/cache if not already set by parser
|
|
|
|
|
|
if (data.senderName.empty() && data.senderGuid != 0) {
|
|
|
|
|
|
// Check player name cache first
|
|
|
|
|
|
auto nameIt = playerNameCache.find(data.senderGuid);
|
|
|
|
|
|
if (nameIt != playerNameCache.end()) {
|
|
|
|
|
|
data.senderName = nameIt->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Try entity name
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.senderGuid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto player = std::dynamic_pointer_cast<Player>(entity);
|
|
|
|
|
|
if (player && !player->getName().empty()) {
|
|
|
|
|
|
data.senderName = player->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (entity->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
auto unit = std::dynamic_pointer_cast<Unit>(entity);
|
|
|
|
|
|
if (unit && !unit->getName().empty()) {
|
|
|
|
|
|
data.senderName = unit->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-13 19:42:42 -08:00
|
|
|
|
|
|
|
|
|
|
// If still unknown, proactively query the server so the UI can show names soon after.
|
|
|
|
|
|
if (data.senderName.empty()) {
|
|
|
|
|
|
queryPlayerName(data.senderGuid);
|
|
|
|
|
|
}
|
2026-02-13 18:59:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Add to chat history
|
|
|
|
|
|
chatHistory.push_back(data);
|
|
|
|
|
|
|
|
|
|
|
|
// Limit chat history size
|
|
|
|
|
|
if (chatHistory.size() > maxChatHistory) {
|
|
|
|
|
|
chatHistory.erase(chatHistory.begin());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:17:01 -08:00
|
|
|
|
// Track whisper sender for /r command
|
|
|
|
|
|
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
|
|
|
|
|
|
lastWhisperSender_ = data.senderName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
// Trigger chat bubble for SAY/YELL messages from others
|
|
|
|
|
|
if (chatBubbleCallback_ && data.senderGuid != 0) {
|
|
|
|
|
|
if (data.type == ChatType::SAY || data.type == ChatType::YELL ||
|
|
|
|
|
|
data.type == ChatType::MONSTER_SAY || data.type == ChatType::MONSTER_YELL) {
|
|
|
|
|
|
bool isYell = (data.type == ChatType::YELL || data.type == ChatType::MONSTER_YELL);
|
|
|
|
|
|
chatBubbleCallback_(data.senderGuid, data.message, isYell);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Log the message
|
|
|
|
|
|
std::string senderInfo;
|
|
|
|
|
|
if (!data.senderName.empty()) {
|
|
|
|
|
|
senderInfo = data.senderName;
|
|
|
|
|
|
} else if (data.senderGuid != 0) {
|
2026-02-13 18:59:09 -08:00
|
|
|
|
senderInfo = "Unknown-" + std::to_string(data.senderGuid);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
senderInfo = "System";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string channelInfo;
|
|
|
|
|
|
if (!data.channelName.empty()) {
|
|
|
|
|
|
channelInfo = "[" + data.channelName + "] ";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 00:45:47 -08:00
|
|
|
|
LOG_DEBUG("[", getChatTypeString(data.type), "] ", channelInfo, senderInfo, ": ", data.message);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = TextEmotePacket::build(textEmoteId, targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTextEmote(network::Packet& packet) {
|
|
|
|
|
|
TextEmoteData data;
|
|
|
|
|
|
if (!TextEmoteParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Skip our own text emotes (we already have local echo)
|
|
|
|
|
|
if (data.senderGuid == playerGuid && data.senderGuid != 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve sender name
|
|
|
|
|
|
std::string senderName;
|
|
|
|
|
|
auto nameIt = playerNameCache.find(data.senderGuid);
|
|
|
|
|
|
if (nameIt != playerNameCache.end()) {
|
|
|
|
|
|
senderName = nameIt->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.senderGuid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
auto unit = std::dynamic_pointer_cast<Unit>(entity);
|
|
|
|
|
|
if (unit) senderName = unit->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (senderName.empty()) {
|
|
|
|
|
|
senderName = "Unknown";
|
|
|
|
|
|
queryPlayerName(data.senderGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:11:43 -08:00
|
|
|
|
// Resolve emote text from DBC using third-person "others see" templates
|
|
|
|
|
|
const std::string* targetPtr = data.targetName.empty() ? nullptr : &data.targetName;
|
|
|
|
|
|
std::string emoteText = rendering::Renderer::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr);
|
|
|
|
|
|
if (emoteText.empty()) {
|
|
|
|
|
|
// Fallback if DBC lookup fails
|
|
|
|
|
|
emoteText = data.targetName.empty()
|
|
|
|
|
|
? senderName + " performs an emote."
|
|
|
|
|
|
: senderName + " performs an emote at " + data.targetName + ".";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
MessageChatData chatMsg;
|
|
|
|
|
|
chatMsg.type = ChatType::TEXT_EMOTE;
|
|
|
|
|
|
chatMsg.language = ChatLanguage::COMMON;
|
|
|
|
|
|
chatMsg.senderGuid = data.senderGuid;
|
|
|
|
|
|
chatMsg.senderName = senderName;
|
2026-02-14 15:11:43 -08:00
|
|
|
|
chatMsg.message = emoteText;
|
2026-02-14 14:30:09 -08:00
|
|
|
|
|
|
|
|
|
|
chatHistory.push_back(chatMsg);
|
|
|
|
|
|
if (chatHistory.size() > maxChatHistory) {
|
|
|
|
|
|
chatHistory.erase(chatHistory.begin());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:11:43 -08:00
|
|
|
|
// Trigger emote animation on sender's entity via callback
|
|
|
|
|
|
uint32_t animId = rendering::Renderer::getEmoteAnimByDbcId(data.textEmoteId);
|
|
|
|
|
|
if (animId != 0 && emoteAnimCallback_) {
|
|
|
|
|
|
emoteAnimCallback_(data.senderGuid, animId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ", anim=", animId, ")");
|
2026-02-14 14:30:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::joinChannel(const std::string& channelName, const std::string& password) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-14 18:27:59 -08:00
|
|
|
|
auto packet = packetParsers_ ? packetParsers_->buildJoinChannel(channelName, password)
|
|
|
|
|
|
: JoinChannelPacket::build(channelName, password);
|
2026-02-14 14:30:09 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Requesting to join channel: ", channelName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::leaveChannel(const std::string& channelName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-14 18:27:59 -08:00
|
|
|
|
auto packet = packetParsers_ ? packetParsers_->buildLeaveChannel(channelName)
|
|
|
|
|
|
: LeaveChannelPacket::build(channelName);
|
2026-02-14 14:30:09 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Requesting to leave channel: ", channelName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string GameHandler::getChannelByIndex(int index) const {
|
|
|
|
|
|
if (index < 1 || index > static_cast<int>(joinedChannels_.size())) return "";
|
|
|
|
|
|
return joinedChannels_[index - 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 18:27:59 -08:00
|
|
|
|
int GameHandler::getChannelIndex(const std::string& channelName) const {
|
|
|
|
|
|
for (int i = 0; i < static_cast<int>(joinedChannels_.size()); ++i) {
|
|
|
|
|
|
if (joinedChannels_[i] == channelName) return i + 1; // 1-based
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 14:30:09 -08:00
|
|
|
|
void GameHandler::handleChannelNotify(network::Packet& packet) {
|
|
|
|
|
|
ChannelNotifyData data;
|
|
|
|
|
|
if (!ChannelNotifyParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_CHANNEL_NOTIFY");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (data.notifyType) {
|
|
|
|
|
|
case ChannelNotifyType::YOU_JOINED: {
|
|
|
|
|
|
// Add to active channels if not already present
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
for (const auto& ch : joinedChannels_) {
|
|
|
|
|
|
if (ch == data.channelName) { found = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
joinedChannels_.push_back(data.channelName);
|
|
|
|
|
|
}
|
|
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.message = "Joined channel: " + data.channelName;
|
|
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("Joined channel: ", data.channelName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case ChannelNotifyType::YOU_LEFT: {
|
|
|
|
|
|
joinedChannels_.erase(
|
|
|
|
|
|
std::remove(joinedChannels_.begin(), joinedChannels_.end(), data.channelName),
|
|
|
|
|
|
joinedChannels_.end());
|
|
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.message = "Left channel: " + data.channelName;
|
|
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("Left channel: ", data.channelName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-16 20:16:14 -08:00
|
|
|
|
case ChannelNotifyType::PLAYER_ALREADY_MEMBER: {
|
|
|
|
|
|
// Server says we're already in this channel (e.g. server auto-joined us)
|
|
|
|
|
|
// Still track it in our channel list
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
for (const auto& ch : joinedChannels_) {
|
|
|
|
|
|
if (ch == data.channelName) { found = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
joinedChannels_.push_back(data.channelName);
|
|
|
|
|
|
LOG_INFO("Already in channel: ", data.channelName);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case ChannelNotifyType::NOT_IN_AREA: {
|
|
|
|
|
|
LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-14 14:30:09 -08:00
|
|
|
|
default:
|
|
|
|
|
|
LOG_DEBUG("Channel notify type ", static_cast<int>(data.notifyType),
|
|
|
|
|
|
" for channel ", data.channelName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::autoJoinDefaultChannels() {
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("autoJoinDefaultChannels: general=", chatAutoJoin.general,
|
|
|
|
|
|
" trade=", chatAutoJoin.trade, " localDefense=", chatAutoJoin.localDefense,
|
|
|
|
|
|
" lfg=", chatAutoJoin.lfg, " local=", chatAutoJoin.local);
|
2026-02-14 18:27:59 -08:00
|
|
|
|
if (chatAutoJoin.general) joinChannel("General");
|
|
|
|
|
|
if (chatAutoJoin.trade) joinChannel("Trade");
|
|
|
|
|
|
if (chatAutoJoin.localDefense) joinChannel("LocalDefense");
|
|
|
|
|
|
if (chatAutoJoin.lfg) joinChannel("LookingForGroup");
|
2026-02-16 21:16:25 -08:00
|
|
|
|
if (chatAutoJoin.local) joinChannel("Local");
|
2026-02-14 14:30:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::setTarget(uint64_t guid) {
|
|
|
|
|
|
if (guid == targetGuid) return;
|
2026-02-07 13:44:36 -08:00
|
|
|
|
|
|
|
|
|
|
// Save previous target
|
|
|
|
|
|
if (targetGuid != 0) {
|
|
|
|
|
|
lastTargetGuid = targetGuid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
targetGuid = guid;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
// Inform server of target selection (Phase 1)
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = SetSelectionPacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (guid != 0) {
|
|
|
|
|
|
LOG_INFO("Target set: 0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::clearTarget() {
|
|
|
|
|
|
if (targetGuid != 0) {
|
|
|
|
|
|
LOG_INFO("Target cleared");
|
|
|
|
|
|
}
|
|
|
|
|
|
targetGuid = 0;
|
|
|
|
|
|
tabCycleIndex = -1;
|
|
|
|
|
|
tabCycleStale = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::shared_ptr<Entity> GameHandler::getTarget() const {
|
|
|
|
|
|
if (targetGuid == 0) return nullptr;
|
|
|
|
|
|
return entityManager.getEntity(targetGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:44:36 -08:00
|
|
|
|
void GameHandler::setFocus(uint64_t guid) {
|
|
|
|
|
|
focusGuid = guid;
|
|
|
|
|
|
if (guid != 0) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
std::string name = "Unknown";
|
|
|
|
|
|
if (entity->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto player = std::dynamic_pointer_cast<Player>(entity);
|
|
|
|
|
|
if (player && !player->getName().empty()) {
|
|
|
|
|
|
name = player->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage("Focus set: " + name);
|
|
|
|
|
|
LOG_INFO("Focus set: 0x", std::hex, guid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::clearFocus() {
|
|
|
|
|
|
if (focusGuid != 0) {
|
|
|
|
|
|
addSystemChatMessage("Focus cleared.");
|
|
|
|
|
|
LOG_INFO("Focus cleared");
|
|
|
|
|
|
}
|
|
|
|
|
|
focusGuid = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::shared_ptr<Entity> GameHandler::getFocus() const {
|
|
|
|
|
|
if (focusGuid == 0) return nullptr;
|
|
|
|
|
|
return entityManager.getEntity(focusGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::targetLastTarget() {
|
|
|
|
|
|
if (lastTargetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("No previous target.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Swap current and last target
|
|
|
|
|
|
uint64_t temp = targetGuid;
|
|
|
|
|
|
setTarget(lastTargetGuid);
|
|
|
|
|
|
lastTargetGuid = temp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::targetEnemy(bool reverse) {
|
|
|
|
|
|
// Get list of hostile entities
|
|
|
|
|
|
std::vector<uint64_t> hostiles;
|
|
|
|
|
|
auto& entities = entityManager.getEntities();
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [guid, entity] : entities) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
// Check if hostile (this is simplified - would need faction checking)
|
|
|
|
|
|
auto unit = std::dynamic_pointer_cast<Unit>(entity);
|
|
|
|
|
|
if (unit && guid != playerGuid) {
|
|
|
|
|
|
hostiles.push_back(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hostiles.empty()) {
|
|
|
|
|
|
addSystemChatMessage("No enemies in range.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find current target in list
|
|
|
|
|
|
auto it = std::find(hostiles.begin(), hostiles.end(), targetGuid);
|
|
|
|
|
|
|
|
|
|
|
|
if (it == hostiles.end()) {
|
|
|
|
|
|
// Not currently targeting a hostile, target first one
|
|
|
|
|
|
setTarget(reverse ? hostiles.back() : hostiles.front());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Cycle to next/previous
|
|
|
|
|
|
if (reverse) {
|
|
|
|
|
|
if (it == hostiles.begin()) {
|
|
|
|
|
|
setTarget(hostiles.back());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTarget(*(--it));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
if (it == hostiles.end()) {
|
|
|
|
|
|
setTarget(hostiles.front());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTarget(*it);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::targetFriend(bool reverse) {
|
|
|
|
|
|
// Get list of friendly entities (players)
|
|
|
|
|
|
std::vector<uint64_t> friendlies;
|
|
|
|
|
|
auto& entities = entityManager.getEntities();
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [guid, entity] : entities) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::PLAYER && guid != playerGuid) {
|
|
|
|
|
|
friendlies.push_back(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (friendlies.empty()) {
|
|
|
|
|
|
addSystemChatMessage("No friendly targets in range.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find current target in list
|
|
|
|
|
|
auto it = std::find(friendlies.begin(), friendlies.end(), targetGuid);
|
|
|
|
|
|
|
|
|
|
|
|
if (it == friendlies.end()) {
|
|
|
|
|
|
// Not currently targeting a friend, target first one
|
|
|
|
|
|
setTarget(reverse ? friendlies.back() : friendlies.front());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Cycle to next/previous
|
|
|
|
|
|
if (reverse) {
|
|
|
|
|
|
if (it == friendlies.begin()) {
|
|
|
|
|
|
setTarget(friendlies.back());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTarget(*(--it));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
++it;
|
|
|
|
|
|
if (it == friendlies.end()) {
|
|
|
|
|
|
setTarget(friendlies.front());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTarget(*it);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:37:13 -08:00
|
|
|
|
void GameHandler::inspectTarget() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot inspect: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must target a player to inspect.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto target = getTarget();
|
|
|
|
|
|
if (!target || target->getType() != ObjectType::PLAYER) {
|
|
|
|
|
|
addSystemChatMessage("You can only inspect players.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = InspectPacket::build(targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
|
|
|
|
|
|
auto player = std::static_pointer_cast<Player>(target);
|
|
|
|
|
|
std::string name = player->getName().empty() ? "Target" : player->getName();
|
|
|
|
|
|
addSystemChatMessage("Inspecting " + name + "...");
|
|
|
|
|
|
LOG_INFO("Sent inspect request for player: ", name, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
void GameHandler::queryServerTime() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot query time: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = QueryTimePacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Requested server time");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestPlayedTime() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot request played time: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = RequestPlayedTimePacket::build(true);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Requested played time");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryWho(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot query who: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = WhoPacket::build(0, 0, playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Sent WHO query", playerName.empty() ? "" : " for: " + playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
void GameHandler::addFriend(const std::string& playerName, const std::string& note) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot add friend: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = AddFriendPacket::build(playerName, note);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Sending friend request to " + playerName + "...");
|
|
|
|
|
|
LOG_INFO("Sent friend request to: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::removeFriend(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot remove friend: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Look up GUID from cache
|
|
|
|
|
|
auto it = friendsCache.find(playerName);
|
|
|
|
|
|
if (it == friendsCache.end()) {
|
|
|
|
|
|
addSystemChatMessage(playerName + " is not in your friends list.");
|
|
|
|
|
|
LOG_WARNING("Friend not found in cache: ", playerName);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = DelFriendPacket::build(it->second);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Removing " + playerName + " from friends list...");
|
|
|
|
|
|
LOG_INFO("Sent remove friend request for: ", playerName, " (GUID: 0x", std::hex, it->second, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setFriendNote(const std::string& playerName, const std::string& note) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot set friend note: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Look up GUID from cache
|
|
|
|
|
|
auto it = friendsCache.find(playerName);
|
|
|
|
|
|
if (it == friendsCache.end()) {
|
|
|
|
|
|
addSystemChatMessage(playerName + " is not in your friends list.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = SetContactNotesPacket::build(it->second, note);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Updated note for " + playerName);
|
|
|
|
|
|
LOG_INFO("Set friend note for: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot roll: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (minRoll > maxRoll) {
|
|
|
|
|
|
std::swap(minRoll, maxRoll);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (maxRoll > 10000) {
|
|
|
|
|
|
maxRoll = 10000; // Cap at reasonable value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = RandomRollPacket::build(minRoll, maxRoll);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Rolled ", minRoll, "-", maxRoll);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:58:11 -08:00
|
|
|
|
void GameHandler::addIgnore(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot add ignore: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = AddIgnorePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Adding " + playerName + " to ignore list...");
|
|
|
|
|
|
LOG_INFO("Sent ignore request for: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::removeIgnore(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot remove ignore: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Look up GUID from cache
|
|
|
|
|
|
auto it = ignoreCache.find(playerName);
|
|
|
|
|
|
if (it == ignoreCache.end()) {
|
|
|
|
|
|
addSystemChatMessage(playerName + " is not in your ignore list.");
|
|
|
|
|
|
LOG_WARNING("Ignored player not found in cache: ", playerName);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = DelIgnorePacket::build(it->second);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Removing " + playerName + " from ignore list...");
|
|
|
|
|
|
ignoreCache.erase(it);
|
|
|
|
|
|
LOG_INFO("Sent remove ignore request for: ", playerName, " (GUID: 0x", std::hex, it->second, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestLogout() {
|
|
|
|
|
|
if (!socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot logout: not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (loggingOut_) {
|
|
|
|
|
|
addSystemChatMessage("Already logging out.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = LogoutRequestPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
loggingOut_ = true;
|
|
|
|
|
|
LOG_INFO("Sent logout request");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::cancelLogout() {
|
|
|
|
|
|
if (!socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot cancel logout: not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!loggingOut_) {
|
|
|
|
|
|
addSystemChatMessage("Not currently logging out.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = LogoutCancelPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
loggingOut_ = false;
|
|
|
|
|
|
addSystemChatMessage("Logout cancelled.");
|
|
|
|
|
|
LOG_INFO("Cancelled logout");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setStandState(uint8_t standState) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot change stand state: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = StandStateChangePacket::build(standState);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Changed stand state to: ", (int)standState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:03:21 -08:00
|
|
|
|
void GameHandler::toggleHelm() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot toggle helm: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
helmVisible_ = !helmVisible_;
|
|
|
|
|
|
auto packet = ShowingHelmPacket::build(helmVisible_);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage(helmVisible_ ? "Helm is now visible." : "Helm is now hidden.");
|
|
|
|
|
|
LOG_INFO("Helm visibility toggled: ", helmVisible_);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::toggleCloak() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot toggle cloak: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cloakVisible_ = !cloakVisible_;
|
|
|
|
|
|
auto packet = ShowingCloakPacket::build(cloakVisible_);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage(cloakVisible_ ? "Cloak is now visible." : "Cloak is now hidden.");
|
|
|
|
|
|
LOG_INFO("Cloak visibility toggled: ", cloakVisible_);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::followTarget() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Cannot follow: not in world");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must target someone to follow.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto target = getTarget();
|
|
|
|
|
|
if (!target) {
|
|
|
|
|
|
addSystemChatMessage("Invalid target.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set follow target
|
|
|
|
|
|
followTargetGuid_ = targetGuid;
|
|
|
|
|
|
|
|
|
|
|
|
// Get target name
|
|
|
|
|
|
std::string targetName = "Target";
|
|
|
|
|
|
if (target->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto player = std::static_pointer_cast<Player>(target);
|
|
|
|
|
|
if (!player->getName().empty()) {
|
|
|
|
|
|
targetName = player->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (target->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(target);
|
|
|
|
|
|
targetName = unit->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage("Now following " + targetName + ".");
|
|
|
|
|
|
LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::assistTarget() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("Cannot assist: not in world");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must target someone to assist.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto target = getTarget();
|
|
|
|
|
|
if (!target) {
|
|
|
|
|
|
addSystemChatMessage("Invalid target.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get target name
|
|
|
|
|
|
std::string targetName = "Target";
|
|
|
|
|
|
if (target->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto player = std::static_pointer_cast<Player>(target);
|
|
|
|
|
|
if (!player->getName().empty()) {
|
|
|
|
|
|
targetName = player->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (target->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(target);
|
|
|
|
|
|
targetName = unit->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Try to read target GUID from update fields (UNIT_FIELD_TARGET)
|
|
|
|
|
|
uint64_t assistTargetGuid = 0;
|
|
|
|
|
|
const auto& fields = target->getFields();
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto it = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_LO));
|
2026-02-07 13:03:21 -08:00
|
|
|
|
if (it != fields.end()) {
|
|
|
|
|
|
assistTargetGuid = it->second;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
auto it2 = fields.find(fieldIndex(UF::UNIT_FIELD_TARGET_HI));
|
2026-02-07 13:03:21 -08:00
|
|
|
|
if (it2 != fields.end()) {
|
|
|
|
|
|
assistTargetGuid |= (static_cast<uint64_t>(it2->second) << 32);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (assistTargetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage(targetName + " has no target.");
|
|
|
|
|
|
LOG_INFO("Assist: ", targetName, " has no target");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set our target to their target
|
|
|
|
|
|
setTarget(assistTargetGuid);
|
|
|
|
|
|
LOG_INFO("Assisting ", targetName, ", now targeting GUID: 0x", std::hex, assistTargetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add Tier 3 commands: guild management, PvP, ready check, and duel forfeit
- Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, /ginvite
- PvP toggle: /pvp to toggle PvP flag
- Ready check system: /readycheck, /ready, /notready for group coordination
- Duel forfeit: /yield, /forfeit, /surrender to cancel active duels
2026-02-07 13:09:12 -08:00
|
|
|
|
void GameHandler::togglePvp() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot toggle PvP: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = TogglePvpPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// Check current PVP state from player's UNIT_FIELD_FLAGS (index 59)
|
|
|
|
|
|
// UNIT_FLAG_PVP = 0x00001000
|
|
|
|
|
|
auto entity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
bool currentlyPvp = false;
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
currentlyPvp = (entity->getField(59) & 0x00001000) != 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
// We're toggling, so report the NEW state
|
|
|
|
|
|
if (currentlyPvp) {
|
|
|
|
|
|
addSystemChatMessage("PvP flag disabled.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("PvP flag enabled.");
|
|
|
|
|
|
}
|
Add Tier 3 commands: guild management, PvP, ready check, and duel forfeit
- Guild commands: /ginfo, /groster, /gmotd, /gpromote, /gdemote, /gquit, /ginvite
- PvP toggle: /pvp to toggle PvP flag
- Ready check system: /readycheck, /ready, /notready for group coordination
- Duel forfeit: /yield, /forfeit, /surrender to cancel active duels
2026-02-07 13:09:12 -08:00
|
|
|
|
LOG_INFO("Toggled PvP flag");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestGuildInfo() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot request guild info: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildInfoPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Requested guild info");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestGuildRoster() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot request guild roster: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildRosterPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Requesting guild roster...");
|
|
|
|
|
|
LOG_INFO("Requested guild roster");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setGuildMotd(const std::string& motd) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot set guild MOTD: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildMotdPacket::build(motd);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Guild MOTD updated.");
|
|
|
|
|
|
LOG_INFO("Set guild MOTD: ", motd);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::promoteGuildMember(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot promote guild member: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildPromotePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Promoting " + playerName + "...");
|
|
|
|
|
|
LOG_INFO("Promoting guild member: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::demoteGuildMember(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot demote guild member: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildDemotePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Demoting " + playerName + "...");
|
|
|
|
|
|
LOG_INFO("Demoting guild member: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::leaveGuild() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot leave guild: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildLeavePacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Leaving guild...");
|
|
|
|
|
|
LOG_INFO("Leaving guild");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::inviteToGuild(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot invite to guild: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GuildInvitePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Inviting " + playerName + " to guild...");
|
|
|
|
|
|
LOG_INFO("Inviting to guild: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::initiateReadyCheck() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot initiate ready check: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isInGroup()) {
|
|
|
|
|
|
addSystemChatMessage("You must be in a group to initiate a ready check.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = ReadyCheckPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Ready check initiated.");
|
|
|
|
|
|
LOG_INFO("Initiated ready check");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::respondToReadyCheck(bool ready) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot respond to ready check: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = ReadyCheckConfirmPacket::build(ready);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage(ready ? "You are ready." : "You are not ready.");
|
|
|
|
|
|
LOG_INFO("Responded to ready check: ", ready ? "ready" : "not ready");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::forfeitDuel() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot forfeit duel: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = DuelCancelPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("You have forfeited the duel.");
|
|
|
|
|
|
LOG_INFO("Forfeited duel");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:17:01 -08:00
|
|
|
|
void GameHandler::toggleAfk(const std::string& message) {
|
|
|
|
|
|
afkStatus_ = !afkStatus_;
|
|
|
|
|
|
afkMessage_ = message;
|
|
|
|
|
|
|
|
|
|
|
|
if (afkStatus_) {
|
|
|
|
|
|
if (message.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You are now AFK.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("You are now AFK: " + message);
|
|
|
|
|
|
}
|
|
|
|
|
|
// If DND was active, turn it off
|
|
|
|
|
|
if (dndStatus_) {
|
|
|
|
|
|
dndStatus_ = false;
|
|
|
|
|
|
dndMessage_.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("You are no longer AFK.");
|
|
|
|
|
|
afkMessage_.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("AFK status: ", afkStatus_, ", message: ", message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::toggleDnd(const std::string& message) {
|
|
|
|
|
|
dndStatus_ = !dndStatus_;
|
|
|
|
|
|
dndMessage_ = message;
|
|
|
|
|
|
|
|
|
|
|
|
if (dndStatus_) {
|
|
|
|
|
|
if (message.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You are now DND (Do Not Disturb).");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("You are now DND: " + message);
|
|
|
|
|
|
}
|
|
|
|
|
|
// If AFK was active, turn it off
|
|
|
|
|
|
if (afkStatus_) {
|
|
|
|
|
|
afkStatus_ = false;
|
|
|
|
|
|
afkMessage_.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("You are no longer DND.");
|
|
|
|
|
|
dndMessage_.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("DND status: ", dndStatus_, ", message: ", message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::replyToLastWhisper(const std::string& message) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot send whisper: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (lastWhisperSender_.empty()) {
|
|
|
|
|
|
addSystemChatMessage("No one has whispered you yet.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a message to send.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send whisper using the standard message chat function
|
|
|
|
|
|
sendChatMessage(ChatType::WHISPER, message, lastWhisperSender_);
|
|
|
|
|
|
LOG_INFO("Replied to ", lastWhisperSender_, ": ", message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:28:46 -08:00
|
|
|
|
void GameHandler::uninvitePlayer(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot uninvite player: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (playerName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("You must specify a player name to uninvite.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GroupUninvitePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Removed " + playerName + " from the group.");
|
|
|
|
|
|
LOG_INFO("Uninvited player: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::leaveParty() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot leave party: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = GroupDisbandPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("You have left the group.");
|
|
|
|
|
|
LOG_INFO("Left party/raid");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setMainTank(uint64_t targetGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot set main tank: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must have a target selected.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Main tank uses index 0
|
|
|
|
|
|
auto packet = RaidTargetUpdatePacket::build(0, targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Main tank set.");
|
|
|
|
|
|
LOG_INFO("Set main tank: 0x", std::hex, targetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setMainAssist(uint64_t targetGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot set main assist: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must have a target selected.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Main assist uses index 1
|
|
|
|
|
|
auto packet = RaidTargetUpdatePacket::build(1, targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Main assist set.");
|
|
|
|
|
|
LOG_INFO("Set main assist: 0x", std::hex, targetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::clearMainTank() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot clear main tank: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear main tank by setting GUID to 0
|
|
|
|
|
|
auto packet = RaidTargetUpdatePacket::build(0, 0);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Main tank cleared.");
|
|
|
|
|
|
LOG_INFO("Cleared main tank");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::clearMainAssist() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot clear main assist: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear main assist by setting GUID to 0
|
|
|
|
|
|
auto packet = RaidTargetUpdatePacket::build(1, 0);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Main assist cleared.");
|
|
|
|
|
|
LOG_INFO("Cleared main assist");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::requestRaidInfo() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot request raid info: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = RequestRaidInfoPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Requesting raid lockout information...");
|
|
|
|
|
|
LOG_INFO("Requested raid info");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 13:36:50 -08:00
|
|
|
|
void GameHandler::proposeDuel(uint64_t targetGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot propose duel: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must target a player to challenge to a duel.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = DuelProposedPacket::build(targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("You have challenged your target to a duel.");
|
|
|
|
|
|
LOG_INFO("Proposed duel to target: 0x", std::hex, targetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::initiateTrade(uint64_t targetGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot initiate trade: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("You must target a player to trade with.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = InitiateTradePacket::build(targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
addSystemChatMessage("Requesting trade with target.");
|
|
|
|
|
|
LOG_INFO("Initiated trade with target: 0x", std::hex, targetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::stopCasting() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("Cannot stop casting: not in world or not connected");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!casting) {
|
|
|
|
|
|
return; // Not casting anything
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send cancel cast packet with current spell ID
|
|
|
|
|
|
auto packet = CancelCastPacket::build(currentCastSpellId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
|
|
|
|
|
|
// Reset casting state
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
castTimeTotal = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Cancelled spell cast");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 17:27:20 -08:00
|
|
|
|
void GameHandler::releaseSpirit() {
|
|
|
|
|
|
if (socket && state == WorldState::IN_WORLD) {
|
2026-02-07 21:47:14 -08:00
|
|
|
|
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
|
|
|
|
std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
|
|
|
|
if (repopPending_ && now - static_cast<int64_t>(lastRepopRequestMs_) < 1000) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-06 17:27:20 -08:00
|
|
|
|
auto packet = RepopRequestPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
2026-02-07 23:12:24 -08:00
|
|
|
|
releasedSpirit_ = true;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
repopPending_ = true;
|
|
|
|
|
|
lastRepopRequestMs_ = static_cast<uint64_t>(now);
|
2026-02-06 17:27:20 -08:00
|
|
|
|
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-07 21:47:14 -08:00
|
|
|
|
pendingSpiritHealerGuid_ = npcGuid;
|
2026-02-07 23:12:24 -08:00
|
|
|
|
auto packet = SpiritHealerActivatePacket::build(npcGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
resurrectPending_ = true;
|
|
|
|
|
|
LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", std::hex, npcGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::acceptResurrect() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || !resurrectRequestPending_) return;
|
|
|
|
|
|
// Send spirit healer activate (correct response to SMSG_SPIRIT_HEALER_CONFIRM)
|
|
|
|
|
|
auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_);
|
|
|
|
|
|
socket->send(activate);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE (0x21C) for 0x",
|
|
|
|
|
|
std::hex, resurrectCasterGuid_, std::dec);
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
|
|
|
|
|
resurrectPending_ = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::declineResurrect() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || !resurrectRequestPending_) return;
|
|
|
|
|
|
auto resp = ResurrectResponsePacket::build(resurrectCasterGuid_, false);
|
|
|
|
|
|
socket->send(resp);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (decline) for 0x",
|
|
|
|
|
|
std::hex, resurrectCasterGuid_, std::dec);
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void GameHandler::tabTarget(float playerX, float playerY, float playerZ) {
|
|
|
|
|
|
// Rebuild cycle list if stale
|
|
|
|
|
|
if (tabCycleStale) {
|
|
|
|
|
|
tabCycleList.clear();
|
|
|
|
|
|
tabCycleIndex = -1;
|
|
|
|
|
|
|
|
|
|
|
|
struct EntityDist {
|
|
|
|
|
|
uint64_t guid;
|
|
|
|
|
|
float distance;
|
|
|
|
|
|
};
|
|
|
|
|
|
std::vector<EntityDist> sortable;
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
|
|
|
|
|
auto t = entity->getType();
|
|
|
|
|
|
if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
if (guid == playerGuid) continue; // Don't tab-target self
|
2026-02-02 12:24:50 -08:00
|
|
|
|
float dx = entity->getX() - playerX;
|
|
|
|
|
|
float dy = entity->getY() - playerY;
|
|
|
|
|
|
float dz = entity->getZ() - playerZ;
|
|
|
|
|
|
float dist = std::sqrt(dx*dx + dy*dy + dz*dz);
|
|
|
|
|
|
sortable.push_back({guid, dist});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::sort(sortable.begin(), sortable.end(),
|
|
|
|
|
|
[](const EntityDist& a, const EntityDist& b) { return a.distance < b.distance; });
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& ed : sortable) {
|
|
|
|
|
|
tabCycleList.push_back(ed.guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
tabCycleStale = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tabCycleList.empty()) {
|
|
|
|
|
|
clearTarget();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tabCycleIndex = (tabCycleIndex + 1) % static_cast<int>(tabCycleList.size());
|
|
|
|
|
|
setTarget(tabCycleList[tabCycleIndex]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
|
|
|
|
|
|
chatHistory.push_back(msg);
|
|
|
|
|
|
if (chatHistory.size() > maxChatHistory) {
|
2026-02-04 11:31:08 -08:00
|
|
|
|
chatHistory.pop_front();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 1: Name Queries
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryPlayerName(uint64_t guid) {
|
|
|
|
|
|
if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return;
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingNameQueries.insert(guid);
|
|
|
|
|
|
auto packet = NameQueryPacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) {
|
|
|
|
|
|
if (creatureInfoCache.count(entry) || pendingCreatureQueries.count(entry)) return;
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingCreatureQueries.insert(entry);
|
|
|
|
|
|
auto packet = CreatureQueryPacket::build(entry, guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 00:59:40 -08:00
|
|
|
|
void GameHandler::queryGameObjectInfo(uint32_t entry, uint64_t guid) {
|
|
|
|
|
|
if (gameObjectInfoCache_.count(entry) || pendingGameObjectQueries_.count(entry)) return;
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingGameObjectQueries_.insert(entry);
|
|
|
|
|
|
auto packet = GameObjectQueryPacket::build(entry, guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
std::string GameHandler::getCachedPlayerName(uint64_t guid) const {
|
|
|
|
|
|
auto it = playerNameCache.find(guid);
|
|
|
|
|
|
return (it != playerNameCache.end()) ? it->second : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string GameHandler::getCachedCreatureName(uint32_t entry) const {
|
|
|
|
|
|
auto it = creatureInfoCache.find(entry);
|
|
|
|
|
|
return (it != creatureInfoCache.end()) ? it->second.name : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleNameQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
NameQueryResponseData data;
|
2026-02-13 19:52:49 -08:00
|
|
|
|
if (!packetParsers_ || !packetParsers_->parseNameQueryResponse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
pendingNameQueries.erase(data.guid);
|
|
|
|
|
|
|
|
|
|
|
|
if (data.isValid()) {
|
|
|
|
|
|
playerNameCache[data.guid] = data.name;
|
|
|
|
|
|
// Update entity name
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.guid);
|
|
|
|
|
|
if (entity && entity->getType() == ObjectType::PLAYER) {
|
|
|
|
|
|
auto player = std::static_pointer_cast<Player>(entity);
|
|
|
|
|
|
player->setName(data.name);
|
|
|
|
|
|
}
|
2026-02-13 19:42:42 -08:00
|
|
|
|
|
|
|
|
|
|
// Backfill chat history entries that arrived before we knew the name.
|
|
|
|
|
|
for (auto& msg : chatHistory) {
|
|
|
|
|
|
if (msg.senderGuid == data.guid && msg.senderName.empty()) {
|
|
|
|
|
|
msg.senderName = data.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-16 18:46:44 -08:00
|
|
|
|
|
|
|
|
|
|
// Backfill mail inbox sender names
|
|
|
|
|
|
for (auto& mail : mailInbox_) {
|
|
|
|
|
|
if (mail.messageType == 0 && mail.senderGuid == data.guid) {
|
|
|
|
|
|
mail.senderName = data.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCreatureQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
CreatureQueryResponseData data;
|
|
|
|
|
|
if (!CreatureQueryResponseParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingCreatureQueries.erase(data.entry);
|
|
|
|
|
|
|
|
|
|
|
|
if (data.isValid()) {
|
|
|
|
|
|
creatureInfoCache[data.entry] = data;
|
|
|
|
|
|
// Update all unit entities with this entry
|
|
|
|
|
|
for (auto& [guid, entity] : entityManager.getEntities()) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
auto unit = std::static_pointer_cast<Unit>(entity);
|
|
|
|
|
|
if (unit->getEntry() == data.entry) {
|
|
|
|
|
|
unit->setName(data.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 00:59:40 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// GameObject Query
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
GameObjectQueryResponseData data;
|
2026-02-14 20:20:43 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseGameObjectQueryResponse(packet, data)
|
|
|
|
|
|
: GameObjectQueryResponseParser::parse(packet, data);
|
|
|
|
|
|
if (!ok) return;
|
2026-02-08 00:59:40 -08:00
|
|
|
|
|
|
|
|
|
|
pendingGameObjectQueries_.erase(data.entry);
|
|
|
|
|
|
|
|
|
|
|
|
if (data.isValid()) {
|
|
|
|
|
|
gameObjectInfoCache_[data.entry] = data;
|
|
|
|
|
|
// Update all gameobject entities with this entry
|
|
|
|
|
|
for (auto& [guid, entity] : entityManager.getEntities()) {
|
|
|
|
|
|
if (entity->getType() == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
auto go = std::static_pointer_cast<GameObject>(entity);
|
|
|
|
|
|
if (go->getEntry() == data.entry) {
|
|
|
|
|
|
go->setName(data.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 20:20:43 -08:00
|
|
|
|
|
|
|
|
|
|
// MO_TRANSPORT (type 15): assign TaxiPathNode path if available
|
|
|
|
|
|
if (data.type == 15 && data.hasData && data.data[0] != 0 && transportManager_) {
|
|
|
|
|
|
uint32_t taxiPathId = data.data[0];
|
|
|
|
|
|
if (transportManager_->hasTaxiPath(taxiPathId)) {
|
|
|
|
|
|
if (transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) {
|
|
|
|
|
|
LOG_INFO("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId,
|
|
|
|
|
|
" not found in TaxiPathNode.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 00:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Item Query
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) {
|
|
|
|
|
|
if (itemInfoCache_.count(entry) || pendingItemQueries_.count(entry)) return;
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingItemQueries_.insert(entry);
|
2026-02-13 20:13:37 -08:00
|
|
|
|
// Some cores reject CMSG_ITEM_QUERY_SINGLE when the GUID is 0.
|
|
|
|
|
|
// If we don't have the item object's GUID (e.g. visible equipment decoding),
|
|
|
|
|
|
// fall back to the player's GUID to keep the request non-zero.
|
|
|
|
|
|
uint64_t queryGuid = (guid != 0) ? guid : playerGuid;
|
2026-02-17 01:05:15 -08:00
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildItemQuery(entry, queryGuid)
|
|
|
|
|
|
: ItemQueryPacket::build(entry, queryGuid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-17 01:22:34 -08:00
|
|
|
|
LOG_INFO("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
|
|
|
|
|
|
" pending=", pendingItemQueries_.size());
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleItemQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
ItemQueryResponseData data;
|
2026-02-17 01:00:04 -08:00
|
|
|
|
bool parsed = packetParsers_
|
|
|
|
|
|
? packetParsers_->parseItemQueryResponse(packet, data)
|
|
|
|
|
|
: ItemQueryResponseParser::parse(packet, data);
|
2026-02-17 01:22:34 -08:00
|
|
|
|
if (!parsed) {
|
|
|
|
|
|
LOG_WARNING("handleItemQueryResponse: parse failed, size=", packet.getSize());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
|
|
|
|
|
pendingItemQueries_.erase(data.entry);
|
2026-02-17 01:22:34 -08:00
|
|
|
|
LOG_INFO("handleItemQueryResponse: entry=", data.entry, " valid=", data.valid,
|
|
|
|
|
|
" name='", data.name, "' displayInfoId=", data.displayInfoId,
|
|
|
|
|
|
" pending=", pendingItemQueries_.size());
|
2026-02-06 03:11:43 -08:00
|
|
|
|
|
|
|
|
|
|
if (data.valid) {
|
|
|
|
|
|
itemInfoCache_[data.entry] = data;
|
|
|
|
|
|
rebuildOnlineInventory();
|
2026-02-13 20:10:19 -08:00
|
|
|
|
maybeDetectVisibleItemLayout();
|
2026-02-14 15:48:58 -08:00
|
|
|
|
|
|
|
|
|
|
// Selectively re-emit only players whose equipment references this item entry
|
|
|
|
|
|
const uint32_t resolvedEntry = data.entry;
|
|
|
|
|
|
for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) {
|
|
|
|
|
|
for (uint32_t e : entries) {
|
|
|
|
|
|
if (e == resolvedEntry) {
|
|
|
|
|
|
emitOtherPlayerEquipment(guid);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Same for inspect-based entries
|
2026-02-13 20:26:55 -08:00
|
|
|
|
if (playerEquipmentCallback_) {
|
|
|
|
|
|
for (const auto& [guid, entries] : inspectedPlayerItemEntries_) {
|
2026-02-14 15:48:58 -08:00
|
|
|
|
bool relevant = false;
|
|
|
|
|
|
for (uint32_t e : entries) {
|
|
|
|
|
|
if (e == resolvedEntry) { relevant = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!relevant) continue;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
std::array<uint32_t, 19> displayIds{};
|
|
|
|
|
|
std::array<uint8_t, 19> invTypes{};
|
|
|
|
|
|
for (int s = 0; s < 19; s++) {
|
|
|
|
|
|
uint32_t entry = entries[s];
|
|
|
|
|
|
if (entry == 0) continue;
|
|
|
|
|
|
auto infoIt = itemInfoCache_.find(entry);
|
|
|
|
|
|
if (infoIt == itemInfoCache_.end()) continue;
|
|
|
|
|
|
displayIds[s] = infoIt->second.displayInfoId;
|
|
|
|
|
|
invTypes[s] = static_cast<uint8_t>(infoIt->second.inventoryType);
|
|
|
|
|
|
}
|
|
|
|
|
|
playerEquipmentCallback_(guid, displayIds, invTypes);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleInspectResults(network::Packet& packet) {
|
2026-02-14 15:16:26 -08:00
|
|
|
|
// SMSG_TALENTS_INFO (0x3F4) format:
|
|
|
|
|
|
// uint8 talentType: 0 = own talents (sent on login/respec), 1 = inspect result
|
|
|
|
|
|
// If type==1: PackedGUID of inspected player
|
|
|
|
|
|
// Then: uint32 unspentTalents, uint8 talentGroupCount, uint8 activeTalentGroup
|
2026-02-14 15:05:18 -08:00
|
|
|
|
// Per talent group: uint8 talentCount, [talentId(u32) + rank(u8)]..., uint8 glyphCount, [glyphId(u16)]...
|
2026-02-14 15:16:26 -08:00
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
uint8_t talentType = packet.readUInt8();
|
|
|
|
|
|
|
|
|
|
|
|
if (talentType == 0) {
|
|
|
|
|
|
// Own talent info — silently consume (sent on login, talent changes, respecs)
|
|
|
|
|
|
LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// talentType == 1: inspect result
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) return;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
if (guid == 0) return;
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
size_t bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 6) {
|
2026-02-14 15:16:26 -08:00
|
|
|
|
LOG_WARNING("SMSG_TALENTS_INFO: too short after guid, ", bytesLeft, " bytes");
|
2026-02-14 15:05:18 -08:00
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
std::string name = "Target";
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
auto player = std::dynamic_pointer_cast<Player>(entity);
|
|
|
|
|
|
if (player && !player->getName().empty()) name = player->getName();
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
2026-02-14 15:05:18 -08:00
|
|
|
|
addSystemChatMessage("Inspecting " + name + " (no talent data available).");
|
2026-02-13 20:26:55 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
uint32_t unspentTalents = packet.readUInt32();
|
|
|
|
|
|
uint8_t talentGroupCount = packet.readUInt8();
|
|
|
|
|
|
uint8_t activeTalentGroup = packet.readUInt8();
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
// Resolve player name
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
std::string playerName = "Target";
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
auto player = std::dynamic_pointer_cast<Player>(entity);
|
|
|
|
|
|
if (player && !player->getName().empty()) playerName = player->getName();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse talent groups
|
|
|
|
|
|
uint32_t totalTalents = 0;
|
|
|
|
|
|
for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) {
|
|
|
|
|
|
bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 1) break;
|
|
|
|
|
|
|
|
|
|
|
|
uint8_t talentCount = packet.readUInt8();
|
|
|
|
|
|
for (uint8_t t = 0; t < talentCount; ++t) {
|
|
|
|
|
|
bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 5) break;
|
|
|
|
|
|
packet.readUInt32(); // talentId
|
|
|
|
|
|
packet.readUInt8(); // rank
|
|
|
|
|
|
totalTalents++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 1) break;
|
|
|
|
|
|
uint8_t glyphCount = packet.readUInt8();
|
|
|
|
|
|
for (uint8_t gl = 0; gl < glyphCount; ++gl) {
|
|
|
|
|
|
bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 2) break;
|
|
|
|
|
|
packet.readUInt16(); // glyphId
|
|
|
|
|
|
}
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:05:18 -08:00
|
|
|
|
// Parse enchantment slot mask + enchant IDs
|
|
|
|
|
|
bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft >= 4) {
|
|
|
|
|
|
uint32_t slotMask = packet.readUInt32();
|
|
|
|
|
|
for (int slot = 0; slot < 19; ++slot) {
|
|
|
|
|
|
if (slotMask & (1u << slot)) {
|
|
|
|
|
|
bytesLeft = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (bytesLeft < 2) break;
|
|
|
|
|
|
packet.readUInt16(); // enchantId
|
|
|
|
|
|
}
|
2026-02-13 20:26:55 -08:00
|
|
|
|
}
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
2026-02-14 15:05:18 -08:00
|
|
|
|
|
|
|
|
|
|
// Display inspect results
|
|
|
|
|
|
std::string msg = "Inspect: " + playerName;
|
|
|
|
|
|
msg += " - " + std::to_string(totalTalents) + " talent points spent";
|
|
|
|
|
|
if (unspentTalents > 0) {
|
|
|
|
|
|
msg += ", " + std::to_string(unspentTalents) + " unspent";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (talentGroupCount > 1) {
|
|
|
|
|
|
msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")";
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ",
|
|
|
|
|
|
unspentTalents, " unspent, ", (int)talentGroupCount, " specs");
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:52:28 -08:00
|
|
|
|
uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const {
|
|
|
|
|
|
if (itemId == 0) return 0;
|
|
|
|
|
|
uint64_t found = 0;
|
|
|
|
|
|
for (const auto& [guid, info] : onlineItems_) {
|
|
|
|
|
|
if (info.entry != itemId) continue;
|
|
|
|
|
|
if (found != 0) {
|
|
|
|
|
|
return 0; // Ambiguous
|
|
|
|
|
|
}
|
|
|
|
|
|
found = guid;
|
|
|
|
|
|
}
|
|
|
|
|
|
return found;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::detectInventorySlotBases(const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
if (invSlotBase_ >= 0 && packSlotBase_ >= 0) return;
|
2026-02-13 19:47:49 -08:00
|
|
|
|
if (fields.empty()) return;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
std::vector<uint16_t> matchingPairs;
|
|
|
|
|
|
matchingPairs.reserve(32);
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [idx, low] : fields) {
|
|
|
|
|
|
if ((idx % 2) != 0) continue;
|
|
|
|
|
|
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
|
|
|
|
|
|
if (itHigh == fields.end()) continue;
|
|
|
|
|
|
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
|
|
|
|
|
|
if (guid == 0) continue;
|
2026-02-13 19:47:49 -08:00
|
|
|
|
// Primary signal: GUID pairs that match spawned ITEM objects.
|
|
|
|
|
|
if (!onlineItems_.empty() && onlineItems_.count(guid)) {
|
|
|
|
|
|
matchingPairs.push_back(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback signal (when ITEM objects haven't been seen yet):
|
|
|
|
|
|
// collect any plausible non-zero GUID pairs and derive a base by density.
|
|
|
|
|
|
if (matchingPairs.empty()) {
|
|
|
|
|
|
for (const auto& [idx, low] : fields) {
|
|
|
|
|
|
if ((idx % 2) != 0) continue;
|
|
|
|
|
|
auto itHigh = fields.find(static_cast<uint16_t>(idx + 1));
|
|
|
|
|
|
if (itHigh == fields.end()) continue;
|
|
|
|
|
|
uint64_t guid = (uint64_t(itHigh->second) << 32) | low;
|
|
|
|
|
|
if (guid == 0) continue;
|
|
|
|
|
|
// Heuristic: item GUIDs tend to be non-trivial and change often; ignore tiny values.
|
|
|
|
|
|
if (guid < 0x10000ull) continue;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
matchingPairs.push_back(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (matchingPairs.empty()) return;
|
|
|
|
|
|
std::sort(matchingPairs.begin(), matchingPairs.end());
|
|
|
|
|
|
|
|
|
|
|
|
if (invSlotBase_ < 0) {
|
2026-02-06 19:13:38 -08:00
|
|
|
|
// The lowest matching field is the first EQUIPPED slot (not necessarily HEAD).
|
|
|
|
|
|
// With 2+ matches we can derive the true base: all matches must be at
|
|
|
|
|
|
// even offsets from the base, spaced 2 fields per slot.
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const int knownBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
|
2026-02-06 19:13:38 -08:00
|
|
|
|
constexpr int slotStride = 2;
|
|
|
|
|
|
bool allAlign = true;
|
|
|
|
|
|
for (uint16_t p : matchingPairs) {
|
|
|
|
|
|
if (p < knownBase || (p - knownBase) % slotStride != 0) {
|
|
|
|
|
|
allAlign = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (allAlign) {
|
|
|
|
|
|
invSlotBase_ = knownBase;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Fallback: if we have 2+ matches, derive base from their spacing
|
|
|
|
|
|
if (matchingPairs.size() >= 2) {
|
|
|
|
|
|
uint16_t lo = matchingPairs[0];
|
|
|
|
|
|
// lo must be base + 2*slotN, and slotN is 0..22
|
|
|
|
|
|
// Try each possible slot for 'lo' and see if all others also land on valid slots
|
|
|
|
|
|
for (int s = 0; s <= 22; s++) {
|
|
|
|
|
|
int candidate = lo - s * slotStride;
|
|
|
|
|
|
if (candidate < 0) break;
|
|
|
|
|
|
bool ok = true;
|
|
|
|
|
|
for (uint16_t p : matchingPairs) {
|
|
|
|
|
|
int off = p - candidate;
|
|
|
|
|
|
if (off < 0 || off % slotStride != 0 || off / slotStride > 22) {
|
|
|
|
|
|
ok = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
invSlotBase_ = candidate;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (invSlotBase_ < 0) invSlotBase_ = knownBase;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
invSlotBase_ = knownBase;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
packSlotBase_ = invSlotBase_ + (game::Inventory::NUM_EQUIP_SLOTS * 2);
|
|
|
|
|
|
LOG_INFO("Detected inventory field base: equip=", invSlotBase_,
|
|
|
|
|
|
" pack=", packSlotBase_);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool GameHandler::applyInventoryFields(const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
bool slotsChanged = false;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD));
|
|
|
|
|
|
int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast<int>(fieldIndex(UF::PLAYER_FIELD_PACK_SLOT_1));
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
for (const auto& [key, val] : fields) {
|
|
|
|
|
|
if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) {
|
|
|
|
|
|
int slotIndex = (key - equipBase) / 2;
|
|
|
|
|
|
bool isLow = ((key - equipBase) % 2 == 0);
|
|
|
|
|
|
if (slotIndex < static_cast<int>(equipSlotGuids_.size())) {
|
|
|
|
|
|
uint64_t& guid = equipSlotGuids_[slotIndex];
|
|
|
|
|
|
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
|
|
|
|
|
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
|
|
|
|
|
slotsChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (key >= packBase && key <= packBase + (game::Inventory::BACKPACK_SLOTS * 2 - 1)) {
|
|
|
|
|
|
int slotIndex = (key - packBase) / 2;
|
|
|
|
|
|
bool isLow = ((key - packBase) % 2 == 0);
|
|
|
|
|
|
if (slotIndex < static_cast<int>(backpackSlotGuids_.size())) {
|
|
|
|
|
|
uint64_t& guid = backpackSlotGuids_[slotIndex];
|
|
|
|
|
|
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
|
|
|
|
|
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
|
|
|
|
|
slotsChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
|
|
|
|
|
|
// Bank slots: 28 slots × 2 fields = 56 fields starting at PLAYER_FIELD_BANK_SLOT_1
|
|
|
|
|
|
int bankBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1));
|
|
|
|
|
|
if (bankBase != 0xFFFF && key >= static_cast<uint16_t>(bankBase) &&
|
|
|
|
|
|
key <= static_cast<uint16_t>(bankBase) + (game::Inventory::BANK_SLOTS * 2 - 1)) {
|
|
|
|
|
|
int slotIndex = (key - bankBase) / 2;
|
|
|
|
|
|
bool isLow = ((key - bankBase) % 2 == 0);
|
|
|
|
|
|
if (slotIndex < static_cast<int>(bankSlotGuids_.size())) {
|
|
|
|
|
|
uint64_t& guid = bankSlotGuids_[slotIndex];
|
|
|
|
|
|
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
|
|
|
|
|
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
|
|
|
|
|
slotsChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bank bag slots: 7 slots × 2 fields = 14 fields starting at PLAYER_FIELD_BANKBAG_SLOT_1
|
|
|
|
|
|
int bankBagBase = static_cast<int>(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1));
|
|
|
|
|
|
if (bankBagBase != 0xFFFF && key >= static_cast<uint16_t>(bankBagBase) &&
|
|
|
|
|
|
key <= static_cast<uint16_t>(bankBagBase) + (game::Inventory::BANK_BAG_SLOTS * 2 - 1)) {
|
|
|
|
|
|
int slotIndex = (key - bankBagBase) / 2;
|
|
|
|
|
|
bool isLow = ((key - bankBagBase) % 2 == 0);
|
|
|
|
|
|
if (slotIndex < static_cast<int>(bankBagSlotGuids_.size())) {
|
|
|
|
|
|
uint64_t& guid = bankBagSlotGuids_[slotIndex];
|
|
|
|
|
|
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
|
|
|
|
|
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
|
|
|
|
|
slotsChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return slotsChanged;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
void GameHandler::extractContainerFields(uint64_t containerGuid, const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
const uint16_t numSlotsIdx = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
|
|
|
|
|
|
const uint16_t slot1Idx = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
|
|
|
|
|
|
if (numSlotsIdx == 0xFFFF || slot1Idx == 0xFFFF) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto& info = containerContents_[containerGuid];
|
|
|
|
|
|
|
|
|
|
|
|
// Read number of slots
|
|
|
|
|
|
auto numIt = fields.find(numSlotsIdx);
|
|
|
|
|
|
if (numIt != fields.end()) {
|
|
|
|
|
|
info.numSlots = std::min(numIt->second, 36u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Read slot GUIDs (each is 2 uint32 fields: lo + hi)
|
|
|
|
|
|
for (const auto& [key, val] : fields) {
|
|
|
|
|
|
if (key < slot1Idx) continue;
|
|
|
|
|
|
int offset = key - slot1Idx;
|
|
|
|
|
|
int slotIndex = offset / 2;
|
|
|
|
|
|
if (slotIndex >= 36) continue;
|
|
|
|
|
|
bool isLow = (offset % 2 == 0);
|
|
|
|
|
|
uint64_t& guid = info.slotGuids[slotIndex];
|
|
|
|
|
|
if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val;
|
|
|
|
|
|
else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
void GameHandler::rebuildOnlineInventory() {
|
|
|
|
|
|
|
|
|
|
|
|
inventory = Inventory();
|
|
|
|
|
|
|
|
|
|
|
|
// Equipment slots
|
|
|
|
|
|
for (int i = 0; i < 23; i++) {
|
|
|
|
|
|
uint64_t guid = equipSlotGuids_[i];
|
|
|
|
|
|
if (guid == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto itemIt = onlineItems_.find(guid);
|
|
|
|
|
|
if (itemIt == onlineItems_.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
ItemDef def;
|
|
|
|
|
|
def.itemId = itemIt->second.entry;
|
|
|
|
|
|
def.stackCount = itemIt->second.stackCount;
|
|
|
|
|
|
def.maxStack = 1;
|
|
|
|
|
|
|
|
|
|
|
|
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
|
|
|
|
|
|
if (infoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
def.name = infoIt->second.name;
|
|
|
|
|
|
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
|
|
|
|
|
|
def.inventoryType = infoIt->second.inventoryType;
|
|
|
|
|
|
def.maxStack = std::max(1, infoIt->second.maxStack);
|
|
|
|
|
|
def.displayInfoId = infoIt->second.displayInfoId;
|
|
|
|
|
|
def.subclassName = infoIt->second.subclassName;
|
|
|
|
|
|
def.armor = infoIt->second.armor;
|
|
|
|
|
|
def.stamina = infoIt->second.stamina;
|
|
|
|
|
|
def.strength = infoIt->second.strength;
|
|
|
|
|
|
def.agility = infoIt->second.agility;
|
|
|
|
|
|
def.intellect = infoIt->second.intellect;
|
|
|
|
|
|
def.spirit = infoIt->second.spirit;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
queryItemInfo(def.itemId, guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setEquipSlot(static_cast<EquipSlot>(i), def);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Backpack slots
|
|
|
|
|
|
for (int i = 0; i < 16; i++) {
|
|
|
|
|
|
uint64_t guid = backpackSlotGuids_[i];
|
|
|
|
|
|
if (guid == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto itemIt = onlineItems_.find(guid);
|
|
|
|
|
|
if (itemIt == onlineItems_.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
ItemDef def;
|
|
|
|
|
|
def.itemId = itemIt->second.entry;
|
|
|
|
|
|
def.stackCount = itemIt->second.stackCount;
|
|
|
|
|
|
def.maxStack = 1;
|
|
|
|
|
|
|
|
|
|
|
|
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
|
|
|
|
|
|
if (infoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
def.name = infoIt->second.name;
|
|
|
|
|
|
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
|
|
|
|
|
|
def.inventoryType = infoIt->second.inventoryType;
|
|
|
|
|
|
def.maxStack = std::max(1, infoIt->second.maxStack);
|
|
|
|
|
|
def.displayInfoId = infoIt->second.displayInfoId;
|
|
|
|
|
|
def.subclassName = infoIt->second.subclassName;
|
|
|
|
|
|
def.armor = infoIt->second.armor;
|
|
|
|
|
|
def.stamina = infoIt->second.stamina;
|
|
|
|
|
|
def.strength = infoIt->second.strength;
|
|
|
|
|
|
def.agility = infoIt->second.agility;
|
|
|
|
|
|
def.intellect = infoIt->second.intellect;
|
|
|
|
|
|
def.spirit = infoIt->second.spirit;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
queryItemInfo(def.itemId, guid);
|
2026-02-06 03:11:43 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBackpackSlot(i, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 22:14:34 -08:00
|
|
|
|
// Bag contents (BAG1-BAG4 are equip slots 19-22)
|
|
|
|
|
|
for (int bagIdx = 0; bagIdx < 4; bagIdx++) {
|
|
|
|
|
|
uint64_t bagGuid = equipSlotGuids_[19 + bagIdx];
|
|
|
|
|
|
if (bagGuid == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Determine bag size from container fields or item template
|
|
|
|
|
|
int numSlots = 0;
|
|
|
|
|
|
auto contIt = containerContents_.find(bagGuid);
|
|
|
|
|
|
if (contIt != containerContents_.end()) {
|
|
|
|
|
|
numSlots = static_cast<int>(contIt->second.numSlots);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (numSlots <= 0) {
|
|
|
|
|
|
auto bagItemIt = onlineItems_.find(bagGuid);
|
|
|
|
|
|
if (bagItemIt != onlineItems_.end()) {
|
|
|
|
|
|
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
|
|
|
|
|
|
if (bagInfoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
numSlots = bagInfoIt->second.containerSlots;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (numSlots <= 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Set the bag size in the inventory bag data
|
|
|
|
|
|
inventory.setBagSize(bagIdx, numSlots);
|
|
|
|
|
|
|
|
|
|
|
|
// Also set bagSlots on the equipped bag item (for UI display)
|
|
|
|
|
|
auto& bagEquipSlot = inventory.getEquipSlot(static_cast<EquipSlot>(19 + bagIdx));
|
|
|
|
|
|
if (!bagEquipSlot.empty()) {
|
|
|
|
|
|
ItemDef bagDef = bagEquipSlot.item;
|
|
|
|
|
|
bagDef.bagSlots = numSlots;
|
|
|
|
|
|
inventory.setEquipSlot(static_cast<EquipSlot>(19 + bagIdx), bagDef);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Populate bag slot items
|
|
|
|
|
|
if (contIt == containerContents_.end()) continue;
|
|
|
|
|
|
const auto& container = contIt->second;
|
|
|
|
|
|
for (int s = 0; s < numSlots && s < 36; s++) {
|
|
|
|
|
|
uint64_t itemGuid = container.slotGuids[s];
|
|
|
|
|
|
if (itemGuid == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto itemIt = onlineItems_.find(itemGuid);
|
|
|
|
|
|
if (itemIt == onlineItems_.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
ItemDef def;
|
|
|
|
|
|
def.itemId = itemIt->second.entry;
|
|
|
|
|
|
def.stackCount = itemIt->second.stackCount;
|
|
|
|
|
|
def.maxStack = 1;
|
|
|
|
|
|
|
|
|
|
|
|
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
|
|
|
|
|
|
if (infoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
def.name = infoIt->second.name;
|
|
|
|
|
|
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
|
|
|
|
|
|
def.inventoryType = infoIt->second.inventoryType;
|
|
|
|
|
|
def.maxStack = std::max(1, infoIt->second.maxStack);
|
|
|
|
|
|
def.displayInfoId = infoIt->second.displayInfoId;
|
|
|
|
|
|
def.subclassName = infoIt->second.subclassName;
|
|
|
|
|
|
def.armor = infoIt->second.armor;
|
|
|
|
|
|
def.stamina = infoIt->second.stamina;
|
|
|
|
|
|
def.strength = infoIt->second.strength;
|
|
|
|
|
|
def.agility = infoIt->second.agility;
|
|
|
|
|
|
def.intellect = infoIt->second.intellect;
|
|
|
|
|
|
def.spirit = infoIt->second.spirit;
|
|
|
|
|
|
def.bagSlots = infoIt->second.containerSlots;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
|
|
|
|
|
queryItemInfo(def.itemId, itemGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBagSlot(bagIdx, s, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
// Bank slots (28 main slots)
|
|
|
|
|
|
for (int i = 0; i < 28; i++) {
|
|
|
|
|
|
uint64_t guid = bankSlotGuids_[i];
|
|
|
|
|
|
if (guid == 0) { inventory.clearBankSlot(i); continue; }
|
|
|
|
|
|
|
|
|
|
|
|
auto itemIt = onlineItems_.find(guid);
|
|
|
|
|
|
if (itemIt == onlineItems_.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
ItemDef def;
|
|
|
|
|
|
def.itemId = itemIt->second.entry;
|
|
|
|
|
|
def.stackCount = itemIt->second.stackCount;
|
|
|
|
|
|
def.maxStack = 1;
|
|
|
|
|
|
|
|
|
|
|
|
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
|
|
|
|
|
|
if (infoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
def.name = infoIt->second.name;
|
|
|
|
|
|
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
|
|
|
|
|
|
def.inventoryType = infoIt->second.inventoryType;
|
|
|
|
|
|
def.maxStack = std::max(1, infoIt->second.maxStack);
|
|
|
|
|
|
def.displayInfoId = infoIt->second.displayInfoId;
|
|
|
|
|
|
def.subclassName = infoIt->second.subclassName;
|
|
|
|
|
|
def.armor = infoIt->second.armor;
|
|
|
|
|
|
def.stamina = infoIt->second.stamina;
|
|
|
|
|
|
def.strength = infoIt->second.strength;
|
|
|
|
|
|
def.agility = infoIt->second.agility;
|
|
|
|
|
|
def.intellect = infoIt->second.intellect;
|
|
|
|
|
|
def.spirit = infoIt->second.spirit;
|
|
|
|
|
|
def.sellPrice = infoIt->second.sellPrice;
|
|
|
|
|
|
def.bagSlots = infoIt->second.containerSlots;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
|
|
|
|
|
queryItemInfo(def.itemId, guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBankSlot(i, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bank bag contents (7 bank bag slots)
|
|
|
|
|
|
for (int bagIdx = 0; bagIdx < 7; bagIdx++) {
|
|
|
|
|
|
uint64_t bagGuid = bankBagSlotGuids_[bagIdx];
|
|
|
|
|
|
if (bagGuid == 0) { inventory.setBankBagSize(bagIdx, 0); continue; }
|
|
|
|
|
|
|
|
|
|
|
|
int numSlots = 0;
|
|
|
|
|
|
auto contIt = containerContents_.find(bagGuid);
|
|
|
|
|
|
if (contIt != containerContents_.end()) {
|
|
|
|
|
|
numSlots = static_cast<int>(contIt->second.numSlots);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (numSlots <= 0) {
|
|
|
|
|
|
auto bagItemIt = onlineItems_.find(bagGuid);
|
|
|
|
|
|
if (bagItemIt != onlineItems_.end()) {
|
|
|
|
|
|
auto bagInfoIt = itemInfoCache_.find(bagItemIt->second.entry);
|
|
|
|
|
|
if (bagInfoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
numSlots = bagInfoIt->second.containerSlots;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (numSlots <= 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBankBagSize(bagIdx, numSlots);
|
|
|
|
|
|
|
|
|
|
|
|
if (contIt == containerContents_.end()) continue;
|
|
|
|
|
|
const auto& container = contIt->second;
|
|
|
|
|
|
for (int s = 0; s < numSlots && s < 36; s++) {
|
|
|
|
|
|
uint64_t itemGuid = container.slotGuids[s];
|
|
|
|
|
|
if (itemGuid == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto itemIt = onlineItems_.find(itemGuid);
|
|
|
|
|
|
if (itemIt == onlineItems_.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
ItemDef def;
|
|
|
|
|
|
def.itemId = itemIt->second.entry;
|
|
|
|
|
|
def.stackCount = itemIt->second.stackCount;
|
|
|
|
|
|
def.maxStack = 1;
|
|
|
|
|
|
|
|
|
|
|
|
auto infoIt = itemInfoCache_.find(itemIt->second.entry);
|
|
|
|
|
|
if (infoIt != itemInfoCache_.end()) {
|
|
|
|
|
|
def.name = infoIt->second.name;
|
|
|
|
|
|
def.quality = static_cast<ItemQuality>(infoIt->second.quality);
|
|
|
|
|
|
def.inventoryType = infoIt->second.inventoryType;
|
|
|
|
|
|
def.maxStack = std::max(1, infoIt->second.maxStack);
|
|
|
|
|
|
def.displayInfoId = infoIt->second.displayInfoId;
|
|
|
|
|
|
def.subclassName = infoIt->second.subclassName;
|
|
|
|
|
|
def.armor = infoIt->second.armor;
|
|
|
|
|
|
def.stamina = infoIt->second.stamina;
|
|
|
|
|
|
def.strength = infoIt->second.strength;
|
|
|
|
|
|
def.agility = infoIt->second.agility;
|
|
|
|
|
|
def.intellect = infoIt->second.intellect;
|
|
|
|
|
|
def.spirit = infoIt->second.spirit;
|
|
|
|
|
|
def.sellPrice = infoIt->second.sellPrice;
|
|
|
|
|
|
def.bagSlots = infoIt->second.containerSlots;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
def.name = "Item " + std::to_string(def.itemId);
|
|
|
|
|
|
queryItemInfo(def.itemId, itemGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inventory.setBankBagSlot(bagIdx, s, def);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 16:33:24 -08:00
|
|
|
|
// Only mark equipment dirty if equipped item displayInfoIds actually changed
|
|
|
|
|
|
std::array<uint32_t, 19> currentEquipDisplayIds{};
|
|
|
|
|
|
for (int i = 0; i < 19; i++) {
|
|
|
|
|
|
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
|
|
|
|
|
|
if (!slot.empty()) currentEquipDisplayIds[i] = slot.item.displayInfoId;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (currentEquipDisplayIds != lastEquipDisplayIds_) {
|
|
|
|
|
|
lastEquipDisplayIds_ = currentEquipDisplayIds;
|
|
|
|
|
|
onlineEquipDirty_ = true;
|
|
|
|
|
|
}
|
2026-02-06 03:13:42 -08:00
|
|
|
|
|
2026-02-06 03:11:43 -08:00
|
|
|
|
LOG_DEBUG("Rebuilt online inventory: equip=", [&](){
|
|
|
|
|
|
int c = 0; for (auto g : equipSlotGuids_) if (g) c++; return c;
|
|
|
|
|
|
}(), " backpack=", [&](){
|
|
|
|
|
|
int c = 0; for (auto g : backpackSlotGuids_) if (g) c++; return c;
|
|
|
|
|
|
}());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:10:19 -08:00
|
|
|
|
void GameHandler::maybeDetectVisibleItemLayout() {
|
2026-02-14 15:43:09 -08:00
|
|
|
|
if (visibleItemLayoutVerified_) return;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
if (lastPlayerFields_.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::array<uint32_t, 19> equipEntries{};
|
|
|
|
|
|
int nonZero = 0;
|
2026-02-13 20:19:33 -08:00
|
|
|
|
// Prefer authoritative equipped item entry IDs derived from item objects (onlineItems_),
|
|
|
|
|
|
// because Inventory::ItemDef may not be populated yet if templates haven't been queried.
|
2026-02-13 20:10:19 -08:00
|
|
|
|
for (int i = 0; i < 19; i++) {
|
2026-02-13 20:19:33 -08:00
|
|
|
|
uint64_t itemGuid = equipSlotGuids_[i];
|
|
|
|
|
|
if (itemGuid != 0) {
|
|
|
|
|
|
auto it = onlineItems_.find(itemGuid);
|
|
|
|
|
|
if (it != onlineItems_.end() && it->second.entry != 0) {
|
|
|
|
|
|
equipEntries[i] = it->second.entry;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (equipEntries[i] == 0) {
|
|
|
|
|
|
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
|
|
|
|
|
|
equipEntries[i] = slot.empty() ? 0u : slot.item.itemId;
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
if (equipEntries[i] != 0) nonZero++;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (nonZero < 2) return;
|
|
|
|
|
|
|
|
|
|
|
|
const uint16_t maxKey = lastPlayerFields_.rbegin()->first;
|
|
|
|
|
|
int bestBase = -1;
|
|
|
|
|
|
int bestStride = 0;
|
|
|
|
|
|
int bestMatches = 0;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
int bestMismatches = 9999;
|
|
|
|
|
|
int bestScore = -999999;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
|
|
|
|
|
const int strides[] = {2, 3, 4, 1};
|
|
|
|
|
|
for (int stride : strides) {
|
|
|
|
|
|
for (const auto& [baseIdxU16, _v] : lastPlayerFields_) {
|
|
|
|
|
|
const int base = static_cast<int>(baseIdxU16);
|
|
|
|
|
|
if (base + 18 * stride > static_cast<int>(maxKey)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
int matches = 0;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
int mismatches = 0;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
for (int s = 0; s < 19; s++) {
|
|
|
|
|
|
uint32_t want = equipEntries[s];
|
|
|
|
|
|
if (want == 0) continue;
|
|
|
|
|
|
const uint16_t idx = static_cast<uint16_t>(base + s * stride);
|
|
|
|
|
|
auto it = lastPlayerFields_.find(idx);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
if (it == lastPlayerFields_.end()) continue;
|
|
|
|
|
|
if (it->second == want) {
|
|
|
|
|
|
matches++;
|
|
|
|
|
|
} else if (it->second != 0) {
|
|
|
|
|
|
mismatches++;
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
int score = matches * 2 - mismatches * 3;
|
|
|
|
|
|
if (score > bestScore ||
|
|
|
|
|
|
(score == bestScore && matches > bestMatches) ||
|
|
|
|
|
|
(score == bestScore && matches == bestMatches && mismatches < bestMismatches) ||
|
|
|
|
|
|
(score == bestScore && matches == bestMatches && mismatches == bestMismatches && base < bestBase)) {
|
|
|
|
|
|
bestScore = score;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
bestMatches = matches;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
bestMismatches = mismatches;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
bestBase = base;
|
|
|
|
|
|
bestStride = stride;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 15:43:09 -08:00
|
|
|
|
if (bestMatches >= 2 && bestBase >= 0 && bestStride > 0 && bestMismatches <= 1) {
|
|
|
|
|
|
visibleItemEntryBase_ = bestBase;
|
|
|
|
|
|
visibleItemStride_ = bestStride;
|
|
|
|
|
|
visibleItemLayoutVerified_ = true;
|
|
|
|
|
|
LOG_INFO("Detected PLAYER_VISIBLE_ITEM entry layout: base=", visibleItemEntryBase_,
|
|
|
|
|
|
" stride=", visibleItemStride_, " (matches=", bestMatches,
|
|
|
|
|
|
" mismatches=", bestMismatches, " score=", bestScore, ")");
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
2026-02-14 15:43:09 -08:00
|
|
|
|
// Backfill existing player entities already in view.
|
|
|
|
|
|
for (const auto& [guid, ent] : entityManager.getEntities()) {
|
|
|
|
|
|
if (!ent || ent->getType() != ObjectType::PLAYER) continue;
|
|
|
|
|
|
if (guid == playerGuid) continue;
|
|
|
|
|
|
updateOtherPlayerVisibleItems(guid, ent->getFields());
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
2026-02-14 15:43:09 -08:00
|
|
|
|
// If heuristic didn't find a match, keep using the default WotLK layout (base=284, stride=2).
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
if (guid == 0 || guid == playerGuid) return;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
if (visibleItemEntryBase_ < 0 || visibleItemStride_ <= 0) {
|
|
|
|
|
|
// Layout not detected yet — queue this player for inspect as fallback.
|
|
|
|
|
|
if (socket && state == WorldState::IN_WORLD) {
|
|
|
|
|
|
pendingAutoInspect_.insert(guid);
|
2026-02-16 00:51:59 -08:00
|
|
|
|
LOG_DEBUG("Queued player 0x", std::hex, guid, std::dec, " for auto-inspect (layout not detected)");
|
2026-02-13 22:51:49 -08:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
|
|
|
|
|
std::array<uint32_t, 19> newEntries{};
|
|
|
|
|
|
for (int s = 0; s < 19; s++) {
|
|
|
|
|
|
uint16_t idx = static_cast<uint16_t>(visibleItemEntryBase_ + s * visibleItemStride_);
|
|
|
|
|
|
auto it = fields.find(idx);
|
|
|
|
|
|
if (it != fields.end()) newEntries[s] = it->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool changed = false;
|
|
|
|
|
|
auto& old = otherPlayerVisibleItemEntries_[guid];
|
|
|
|
|
|
if (old != newEntries) {
|
|
|
|
|
|
old = newEntries;
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Request item templates for any new visible entries.
|
|
|
|
|
|
for (uint32_t entry : newEntries) {
|
|
|
|
|
|
if (entry == 0) continue;
|
|
|
|
|
|
if (!itemInfoCache_.count(entry) && !pendingItemQueries_.count(entry)) {
|
|
|
|
|
|
queryItemInfo(entry, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
// If the server isn't sending visible item fields (all zeros), fall back to inspect.
|
|
|
|
|
|
bool any = false;
|
|
|
|
|
|
for (uint32_t e : newEntries) { if (e != 0) { any = true; break; } }
|
|
|
|
|
|
if (!any && socket && state == WorldState::IN_WORLD) {
|
|
|
|
|
|
pendingAutoInspect_.insert(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:10:19 -08:00
|
|
|
|
if (changed) {
|
|
|
|
|
|
otherPlayerVisibleDirty_.insert(guid);
|
|
|
|
|
|
emitOtherPlayerEquipment(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::emitOtherPlayerEquipment(uint64_t guid) {
|
|
|
|
|
|
if (!playerEquipmentCallback_) return;
|
|
|
|
|
|
auto it = otherPlayerVisibleItemEntries_.find(guid);
|
|
|
|
|
|
if (it == otherPlayerVisibleItemEntries_.end()) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::array<uint32_t, 19> displayIds{};
|
|
|
|
|
|
std::array<uint8_t, 19> invTypes{};
|
2026-02-13 20:26:55 -08:00
|
|
|
|
bool anyEntry = false;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
|
|
|
|
|
|
for (int s = 0; s < 19; s++) {
|
|
|
|
|
|
uint32_t entry = it->second[s];
|
|
|
|
|
|
if (entry == 0) continue;
|
2026-02-13 20:26:55 -08:00
|
|
|
|
anyEntry = true;
|
2026-02-13 20:10:19 -08:00
|
|
|
|
auto infoIt = itemInfoCache_.find(entry);
|
|
|
|
|
|
if (infoIt == itemInfoCache_.end()) continue;
|
|
|
|
|
|
displayIds[s] = infoIt->second.displayInfoId;
|
|
|
|
|
|
invTypes[s] = static_cast<uint8_t>(infoIt->second.inventoryType);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
playerEquipmentCallback_(guid, displayIds, invTypes);
|
|
|
|
|
|
otherPlayerVisibleDirty_.erase(guid);
|
2026-02-13 20:26:55 -08:00
|
|
|
|
|
|
|
|
|
|
// If we had entries but couldn't resolve any templates, also try inspect as a fallback.
|
|
|
|
|
|
bool anyResolved = false;
|
|
|
|
|
|
for (uint32_t did : displayIds) { if (did != 0) { anyResolved = true; break; } }
|
|
|
|
|
|
if (anyEntry && !anyResolved) {
|
|
|
|
|
|
pendingAutoInspect_.insert(guid);
|
|
|
|
|
|
}
|
2026-02-13 20:10:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::emitAllOtherPlayerEquipment() {
|
|
|
|
|
|
if (!playerEquipmentCallback_) return;
|
|
|
|
|
|
for (const auto& [guid, _] : otherPlayerVisibleItemEntries_) {
|
|
|
|
|
|
emitOtherPlayerEquipment(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 2: Combat
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::startAutoAttack(uint64_t targetGuid) {
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// Can't attack yourself
|
|
|
|
|
|
if (targetGuid == playerGuid) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Dismount when entering combat
|
|
|
|
|
|
if (isMounted()) {
|
|
|
|
|
|
dismount();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
autoAttacking = true;
|
|
|
|
|
|
autoAttackTarget = targetGuid;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
autoAttackOutOfRange_ = false;
|
2026-02-04 13:29:27 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = AttackSwingPacket::build(targetGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
LOG_INFO("Starting auto-attack on 0x", std::hex, targetGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::stopAutoAttack() {
|
|
|
|
|
|
if (!autoAttacking) return;
|
|
|
|
|
|
autoAttacking = false;
|
|
|
|
|
|
autoAttackTarget = 0;
|
2026-02-06 18:34:45 -08:00
|
|
|
|
autoAttackOutOfRange_ = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = AttackStopPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Stopping auto-attack");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) {
|
|
|
|
|
|
CombatTextEntry entry;
|
|
|
|
|
|
entry.type = type;
|
|
|
|
|
|
entry.amount = amount;
|
|
|
|
|
|
entry.spellId = spellId;
|
|
|
|
|
|
entry.age = 0.0f;
|
|
|
|
|
|
entry.isPlayerSource = isPlayerSource;
|
|
|
|
|
|
combatText.push_back(entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::updateCombatText(float deltaTime) {
|
|
|
|
|
|
for (auto& entry : combatText) {
|
|
|
|
|
|
entry.age += deltaTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
combatText.erase(
|
|
|
|
|
|
std::remove_if(combatText.begin(), combatText.end(),
|
|
|
|
|
|
[](const CombatTextEntry& e) { return e.isExpired(); }),
|
|
|
|
|
|
combatText.end());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 20:51:53 -08:00
|
|
|
|
void GameHandler::autoTargetAttacker(uint64_t attackerGuid) {
|
|
|
|
|
|
if (attackerGuid == 0 || attackerGuid == playerGuid) return;
|
|
|
|
|
|
if (targetGuid != 0) return;
|
|
|
|
|
|
if (!entityManager.hasEntity(attackerGuid)) return;
|
|
|
|
|
|
setTarget(attackerGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleAttackStart(network::Packet& packet) {
|
|
|
|
|
|
AttackStartData data;
|
|
|
|
|
|
if (!AttackStartParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.attackerGuid == playerGuid) {
|
|
|
|
|
|
autoAttacking = true;
|
|
|
|
|
|
autoAttackTarget = data.victimGuid;
|
2026-02-07 20:51:53 -08:00
|
|
|
|
} else if (data.victimGuid == playerGuid && data.attackerGuid != 0) {
|
|
|
|
|
|
hostileAttackers_.insert(data.attackerGuid);
|
|
|
|
|
|
autoTargetAttacker(data.attackerGuid);
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
|
|
|
|
|
|
|
// Play aggro sound when NPC attacks player
|
|
|
|
|
|
if (npcAggroCallback_) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.attackerGuid);
|
|
|
|
|
|
if (entity && entity->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
|
npcAggroCallback_(data.attackerGuid, pos);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAttackStop(network::Packet& packet) {
|
|
|
|
|
|
AttackStopData data;
|
|
|
|
|
|
if (!AttackStopParser::parse(packet, data)) return;
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
// Don't clear autoAttacking on SMSG_ATTACKSTOP - the server sends this
|
|
|
|
|
|
// when the attack loop pauses (out of range, etc). The player's intent
|
|
|
|
|
|
// to attack persists until target dies or player explicitly cancels.
|
|
|
|
|
|
// We'll re-send CMSG_ATTACKSWING periodically in the update loop.
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (data.attackerGuid == playerGuid) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)");
|
2026-02-06 18:34:45 -08:00
|
|
|
|
} else if (data.victimGuid == playerGuid) {
|
|
|
|
|
|
hostileAttackers_.erase(data.attackerGuid);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 17:59:40 -08:00
|
|
|
|
void GameHandler::dismount() {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (!socket) return;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
// Clear local mount state immediately (optimistic dismount).
|
|
|
|
|
|
// Server will confirm via SMSG_UPDATE_OBJECT with mountDisplayId=0.
|
2026-02-14 16:42:47 -08:00
|
|
|
|
uint32_t savedMountAura = mountAuraSpellId_;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
if (currentMountDisplayId_ != 0 || taxiMountActive_) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
2026-02-14 16:42:47 -08:00
|
|
|
|
mountAuraSpellId_ = 0;
|
2026-02-13 22:51:49 -08:00
|
|
|
|
LOG_INFO("Dismount: cleared local mount state");
|
2026-02-11 21:14:35 -08:00
|
|
|
|
}
|
2026-02-14 16:42:47 -08:00
|
|
|
|
// CMSG_CANCEL_MOUNT_AURA exists in TBC+ (0x0375). Classic/Vanilla doesn't have it.
|
|
|
|
|
|
uint16_t cancelMountWire = wireOpcode(Opcode::CMSG_CANCEL_MOUNT_AURA);
|
|
|
|
|
|
if (cancelMountWire != 0xFFFF) {
|
|
|
|
|
|
network::Packet pkt(cancelMountWire);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_CANCEL_MOUNT_AURA");
|
|
|
|
|
|
} else if (savedMountAura != 0) {
|
|
|
|
|
|
// Fallback for Classic/Vanilla: cancel the mount aura by spell ID
|
|
|
|
|
|
auto pkt = CancelAuraPacket::build(savedMountAura);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_CANCEL_AURA (mount spell ", savedMountAura, ") — Classic fallback");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// No tracked mount aura — try cancelling all indefinite self-cast auras
|
|
|
|
|
|
// (mount aura detection may have missed if aura arrived after mount field)
|
|
|
|
|
|
for (const auto& a : playerAuras) {
|
|
|
|
|
|
if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) {
|
|
|
|
|
|
auto pkt = CancelAuraPacket::build(a.spellId);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_CANCEL_AURA (spell ", a.spellId, ") — brute force dismount");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleForceRunSpeedChange(network::Packet& packet) {
|
|
|
|
|
|
// Packed GUID
|
|
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// uint32 counter
|
|
|
|
|
|
uint32_t counter = packet.readUInt32();
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
// Determine format from remaining bytes:
|
|
|
|
|
|
// 5 bytes remaining = uint8(1) + float(4) — standard 3.3.5a
|
|
|
|
|
|
// 8 bytes remaining = uint32(4) + float(4) — some forks
|
|
|
|
|
|
// 4 bytes remaining = float(4) — no unknown field
|
|
|
|
|
|
size_t remaining = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (remaining >= 8) {
|
|
|
|
|
|
packet.readUInt32(); // unknown (extended format)
|
|
|
|
|
|
} else if (remaining >= 5) {
|
|
|
|
|
|
packet.readUInt8(); // unknown (standard 3.3.5a)
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
// float newSpeed
|
|
|
|
|
|
float newSpeed = packet.readFloat();
|
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
LOG_INFO("SMSG_FORCE_RUN_SPEED_CHANGE: guid=0x", std::hex, guid, std::dec,
|
|
|
|
|
|
" counter=", counter, " speed=", newSpeed);
|
|
|
|
|
|
|
|
|
|
|
|
if (guid != playerGuid) return;
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Always ACK the speed change to prevent server stall.
|
|
|
|
|
|
// Packet format mirrors movement packets: packed guid + counter + movement info + new speed.
|
2026-02-07 18:33:14 -08:00
|
|
|
|
if (socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet ack(wireOpcode(Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK));
|
2026-02-11 21:14:35 -08:00
|
|
|
|
MovementPacket::writePackedGuid(ack, playerGuid);
|
2026-02-07 18:33:14 -08:00
|
|
|
|
ack.writeUInt32(counter);
|
2026-02-11 21:14:35 -08:00
|
|
|
|
|
|
|
|
|
|
MovementInfo wire = movementInfo;
|
|
|
|
|
|
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
|
|
|
|
|
|
wire.x = serverPos.x;
|
|
|
|
|
|
wire.y = serverPos.y;
|
|
|
|
|
|
wire.z = serverPos.z;
|
|
|
|
|
|
if (wire.hasFlag(MovementFlags::ONTRANSPORT)) {
|
|
|
|
|
|
glm::vec3 serverTransport =
|
|
|
|
|
|
core::coords::canonicalToServer(glm::vec3(wire.transportX, wire.transportY, wire.transportZ));
|
|
|
|
|
|
wire.transportX = serverTransport.x;
|
|
|
|
|
|
wire.transportY = serverTransport.y;
|
|
|
|
|
|
wire.transportZ = serverTransport.z;
|
|
|
|
|
|
}
|
|
|
|
|
|
MovementPacket::writeMovementPayload(ack, wire);
|
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
ack.writeFloat(newSpeed);
|
|
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate speed - reject garbage/NaN values but still ACK
|
|
|
|
|
|
if (std::isnan(newSpeed) || newSpeed < 0.1f || newSpeed > 100.0f) {
|
|
|
|
|
|
LOG_WARNING("Ignoring invalid run speed: ", newSpeed);
|
|
|
|
|
|
return;
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
2026-02-07 18:33:14 -08:00
|
|
|
|
|
|
|
|
|
|
serverRunSpeed_ = newSpeed;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
|
|
|
|
|
|
// Server can auto-dismount (e.g. entering no-mount areas) and only send a speed change.
|
|
|
|
|
|
// Keep client mount visuals in sync with server-authoritative movement speed.
|
|
|
|
|
|
if (!onTaxiFlight_ && !taxiMountActive_ && currentMountDisplayId_ != 0 && newSpeed <= 8.5f) {
|
|
|
|
|
|
LOG_INFO("Auto-clearing mount from speed change: speed=", newSpeed,
|
|
|
|
|
|
" displayId=", currentMountDisplayId_);
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 17:59:40 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 23:47:43 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Arena / Battleground Handlers
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t queueSlot = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
// Minimal packet = just queueSlot + arenaType(1) when status is NONE
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) {
|
|
|
|
|
|
LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint8_t arenaType = packet.readUInt8();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Unknown byte
|
|
|
|
|
|
packet.readUInt8();
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t bgTypeId = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 2) return;
|
|
|
|
|
|
uint16_t unk2 = packet.readUInt16();
|
|
|
|
|
|
(void)unk2;
|
|
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t clientInstanceId = packet.readUInt32();
|
2026-02-09 23:53:17 -08:00
|
|
|
|
(void)clientInstanceId;
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
uint8_t isRatedArena = packet.readUInt8();
|
2026-02-09 23:53:17 -08:00
|
|
|
|
(void)isRatedArena;
|
2026-02-07 23:47:43 -08:00
|
|
|
|
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t statusId = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
std::string bgName = "Battleground #" + std::to_string(bgTypeId);
|
|
|
|
|
|
if (arenaType > 0) {
|
|
|
|
|
|
bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (statusId) {
|
|
|
|
|
|
case 0: // STATUS_NONE
|
|
|
|
|
|
LOG_INFO("Battlefield status: NONE for ", bgName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 1: // STATUS_WAIT_QUEUE
|
|
|
|
|
|
addSystemChatMessage("Queued for " + bgName + ".");
|
|
|
|
|
|
LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2: // STATUS_WAIT_JOIN
|
|
|
|
|
|
addSystemChatMessage(bgName + " is ready! Type /join to enter.");
|
|
|
|
|
|
LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 3: // STATUS_IN_PROGRESS
|
|
|
|
|
|
addSystemChatMessage("Entered " + bgName + ".");
|
|
|
|
|
|
LOG_INFO("Battlefield status: IN_PROGRESS for ", bgName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 4: // STATUS_WAIT_LEAVE
|
|
|
|
|
|
LOG_INFO("Battlefield status: WAIT_LEAVE for ", bgName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
LOG_INFO("Battlefield status: unknown (", statusId, ") for ", bgName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleArenaTeamCommandResult(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) return;
|
|
|
|
|
|
uint32_t command = packet.readUInt32();
|
|
|
|
|
|
std::string name = packet.readString();
|
|
|
|
|
|
uint32_t error = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
static const char* commands[] = { "create", "invite", "leave", "remove", "disband", "leader" };
|
|
|
|
|
|
std::string cmdName = (command < 6) ? commands[command] : "unknown";
|
|
|
|
|
|
|
|
|
|
|
|
if (error == 0) {
|
|
|
|
|
|
addSystemChatMessage("Arena team " + cmdName + " successful" +
|
|
|
|
|
|
(name.empty() ? "." : ": " + name));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Arena team " + cmdName + " failed" +
|
|
|
|
|
|
(name.empty() ? "." : " for " + name + "."));
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Arena team command: ", cmdName, " name=", name, " error=", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t teamId = packet.readUInt32();
|
|
|
|
|
|
std::string teamName = packet.readString();
|
|
|
|
|
|
LOG_INFO("Arena team query response: id=", teamId, " name=", teamName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleArenaTeamInvite(network::Packet& packet) {
|
|
|
|
|
|
std::string playerName = packet.readString();
|
|
|
|
|
|
std::string teamName = packet.readString();
|
|
|
|
|
|
addSystemChatMessage(playerName + " has invited you to join " + teamName + ".");
|
|
|
|
|
|
LOG_INFO("Arena team invite from ", playerName, " to ", teamName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleArenaTeamEvent(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 1) return;
|
|
|
|
|
|
uint8_t event = packet.readUInt8();
|
|
|
|
|
|
|
|
|
|
|
|
static const char* events[] = {
|
|
|
|
|
|
"joined", "left", "removed", "leader changed",
|
|
|
|
|
|
"disbanded", "created"
|
|
|
|
|
|
};
|
|
|
|
|
|
std::string eventName = (event < 6) ? events[event] : "unknown event";
|
|
|
|
|
|
|
|
|
|
|
|
// Read string params (up to 3)
|
|
|
|
|
|
uint8_t strCount = 0;
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 1) {
|
|
|
|
|
|
strCount = packet.readUInt8();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string param1, param2;
|
|
|
|
|
|
if (strCount >= 1 && packet.getSize() > packet.getReadPos()) param1 = packet.readString();
|
|
|
|
|
|
if (strCount >= 2 && packet.getSize() > packet.getReadPos()) param2 = packet.readString();
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg = "Arena team " + eventName;
|
|
|
|
|
|
if (!param1.empty()) msg += ": " + param1;
|
|
|
|
|
|
if (!param2.empty()) msg += " (" + param2 + ")";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleArenaError(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t error = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
switch (error) {
|
|
|
|
|
|
case 1: msg = "The other team is not big enough."; break;
|
|
|
|
|
|
case 2: msg = "That team is full."; break;
|
|
|
|
|
|
case 3: msg = "Not enough members to start."; break;
|
|
|
|
|
|
case 4: msg = "Too many members."; break;
|
|
|
|
|
|
default: msg = "Arena error (code " + std::to_string(error) + ")"; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("Arena error: ", error, " - ", msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 18:59:09 -08:00
|
|
|
|
void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
|
|
|
|
|
|
// Server relays MSG_MOVE_* for other players: PackedGuid + MovementInfo
|
|
|
|
|
|
uint64_t moverGuid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (moverGuid == playerGuid || moverGuid == 0) {
|
|
|
|
|
|
return; // Skip our own echoes
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Read movement info (expansion-specific format)
|
|
|
|
|
|
// For classic: moveFlags(u32) + time(u32) + pos(4xf32) + [transport] + [pitch] + fallTime(u32) + [jump] + [splineElev]
|
|
|
|
|
|
MovementInfo info = {};
|
|
|
|
|
|
info.flags = packet.readUInt32();
|
2026-02-13 19:40:54 -08:00
|
|
|
|
// WotLK has u16 flags2, TBC has u8, Classic has none.
|
|
|
|
|
|
// Do NOT use build-number thresholds here (Turtle uses classic formats with a high build).
|
|
|
|
|
|
uint8_t flags2Size = packetParsers_ ? packetParsers_->movementFlags2Size() : 2;
|
|
|
|
|
|
if (flags2Size == 2) info.flags2 = packet.readUInt16();
|
|
|
|
|
|
else if (flags2Size == 1) info.flags2 = packet.readUInt8();
|
2026-02-13 18:59:09 -08:00
|
|
|
|
info.time = packet.readUInt32();
|
|
|
|
|
|
info.x = packet.readFloat();
|
|
|
|
|
|
info.y = packet.readFloat();
|
|
|
|
|
|
info.z = packet.readFloat();
|
|
|
|
|
|
info.orientation = packet.readFloat();
|
|
|
|
|
|
|
|
|
|
|
|
// Update entity position in entity manager
|
|
|
|
|
|
auto entity = entityManager.getEntity(moverGuid);
|
|
|
|
|
|
if (!entity) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Convert server coords to canonical
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(info.x, info.y, info.z));
|
|
|
|
|
|
float canYaw = core::coords::serverToCanonicalYaw(info.orientation);
|
2026-02-13 20:19:33 -08:00
|
|
|
|
// Smooth movement between client-relayed snapshots so animations can play.
|
|
|
|
|
|
uint32_t durationMs = 100;
|
|
|
|
|
|
auto itPrev = otherPlayerMoveTimeMs_.find(moverGuid);
|
|
|
|
|
|
if (itPrev != otherPlayerMoveTimeMs_.end()) {
|
|
|
|
|
|
uint32_t dt = info.time - itPrev->second; // handles wrap
|
|
|
|
|
|
if (dt >= 30 && dt <= 1000) {
|
|
|
|
|
|
if (dt < 50) dt = 50;
|
|
|
|
|
|
if (dt > 350) dt = 350;
|
|
|
|
|
|
durationMs = dt;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
otherPlayerMoveTimeMs_[moverGuid] = info.time;
|
|
|
|
|
|
|
|
|
|
|
|
entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, durationMs / 1000.0f);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
|
|
|
|
|
|
// Notify renderer
|
|
|
|
|
|
if (creatureMoveCallback_) {
|
2026-02-13 20:19:33 -08:00
|
|
|
|
creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, durationMs);
|
2026-02-13 18:59:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void GameHandler::handleMonsterMove(network::Packet& packet) {
|
|
|
|
|
|
MonsterMoveData data;
|
|
|
|
|
|
if (!MonsterMoveParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_MONSTER_MOVE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update entity position in entity manager
|
|
|
|
|
|
auto entity = entityManager.getEntity(data.guid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
if (data.hasDest) {
|
|
|
|
|
|
// Convert destination from server to canonical coords
|
|
|
|
|
|
glm::vec3 destCanonical = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(data.destX, data.destY, data.destZ));
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate facing angle
|
|
|
|
|
|
float orientation = entity->getOrientation();
|
|
|
|
|
|
if (data.moveType == 4) {
|
|
|
|
|
|
// FacingAngle - server specifies exact angle
|
2026-02-12 15:08:21 -08:00
|
|
|
|
orientation = core::coords::serverToCanonicalYaw(data.facingAngle);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
} else if (data.moveType == 3) {
|
|
|
|
|
|
// FacingTarget - face toward the target entity
|
|
|
|
|
|
auto target = entityManager.getEntity(data.facingTarget);
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
float dx = target->getX() - entity->getX();
|
|
|
|
|
|
float dy = target->getY() - entity->getY();
|
|
|
|
|
|
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
|
|
|
|
|
|
orientation = std::atan2(dy, dx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Normal move - face toward destination
|
|
|
|
|
|
float dx = destCanonical.x - entity->getX();
|
|
|
|
|
|
float dy = destCanonical.y - entity->getY();
|
|
|
|
|
|
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
|
|
|
|
|
|
orientation = std::atan2(dy, dx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 14:24:38 -08:00
|
|
|
|
// Interpolate entity position alongside renderer (so targeting matches visual)
|
|
|
|
|
|
entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z,
|
|
|
|
|
|
orientation, data.duration / 1000.0f);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
|
|
|
|
|
|
// Notify renderer to smoothly move the creature
|
|
|
|
|
|
if (creatureMoveCallback_) {
|
|
|
|
|
|
creatureMoveCallback_(data.guid,
|
|
|
|
|
|
destCanonical.x, destCanonical.y, destCanonical.z,
|
|
|
|
|
|
data.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (data.moveType == 1) {
|
|
|
|
|
|
// Stop at current position
|
|
|
|
|
|
glm::vec3 posCanonical = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(data.x, data.y, data.z));
|
|
|
|
|
|
entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z,
|
|
|
|
|
|
entity->getOrientation());
|
|
|
|
|
|
|
|
|
|
|
|
if (creatureMoveCallback_) {
|
|
|
|
|
|
creatureMoveCallback_(data.guid,
|
|
|
|
|
|
posCanonical.x, posCanonical.y, posCanonical.z, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 00:54:38 -08:00
|
|
|
|
void GameHandler::handleMonsterMoveTransport(network::Packet& packet) {
|
|
|
|
|
|
// Parse transport-relative creature movement (NPCs on boats/zeppelins)
|
|
|
|
|
|
// Packet structure: mover GUID + transport GUID + spline data (local coords)
|
|
|
|
|
|
|
|
|
|
|
|
uint64_t moverGuid = packet.readUInt64();
|
|
|
|
|
|
uint8_t unk = packet.readUInt8(); // Unknown byte (usually 0)
|
|
|
|
|
|
uint64_t transportGuid = packet.readUInt64();
|
|
|
|
|
|
|
2026-02-12 15:08:21 -08:00
|
|
|
|
// Transport-local coordinates (server space)
|
2026-02-11 00:54:38 -08:00
|
|
|
|
float localX = packet.readFloat();
|
|
|
|
|
|
float localY = packet.readFloat();
|
|
|
|
|
|
float localZ = packet.readFloat();
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_MONSTER_MOVE_TRANSPORT: mover=0x", std::hex, moverGuid,
|
|
|
|
|
|
" transport=0x", transportGuid, std::dec,
|
|
|
|
|
|
" localPos=(", localX, ", ", localY, ", ", localZ, ")");
|
|
|
|
|
|
|
|
|
|
|
|
// Compose world position: worldPos = transportTransform * localPos
|
|
|
|
|
|
auto entity = entityManager.getEntity(moverGuid);
|
|
|
|
|
|
if (!entity) {
|
|
|
|
|
|
LOG_WARNING(" NPC 0x", std::hex, moverGuid, std::dec, " not found in entity manager");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (transportManager_) {
|
|
|
|
|
|
// Use TransportManager to compose world position from local offset
|
2026-02-12 15:08:21 -08:00
|
|
|
|
glm::vec3 localPosCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ));
|
|
|
|
|
|
setTransportAttachment(moverGuid, entity->getType(), transportGuid, localPosCanonical, false, 0.0f);
|
|
|
|
|
|
glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localPosCanonical);
|
2026-02-11 00:54:38 -08:00
|
|
|
|
|
|
|
|
|
|
entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation());
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO(" Composed NPC world position: (", worldPos.x, ", ", worldPos.y, ", ", worldPos.z, ")");
|
2026-02-12 00:04:53 -08:00
|
|
|
|
|
|
|
|
|
|
if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) {
|
|
|
|
|
|
creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0);
|
|
|
|
|
|
}
|
2026-02-11 00:54:38 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING(" TransportManager not available for NPC position composition");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TODO: Parse full spline data for smooth NPC movement on transport
|
|
|
|
|
|
// Then update entity position and call creatureMoveCallback_
|
|
|
|
|
|
|
|
|
|
|
|
// Suppress unused variable warning for now
|
|
|
|
|
|
(void)unk;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
|
|
|
|
|
|
AttackerStateUpdateData data;
|
|
|
|
|
|
if (!AttackerStateUpdateParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
bool isPlayerAttacker = (data.attackerGuid == playerGuid);
|
|
|
|
|
|
bool isPlayerTarget = (data.targetGuid == playerGuid);
|
2026-02-05 14:01:26 -08:00
|
|
|
|
if (isPlayerAttacker && meleeSwingCallback_) {
|
|
|
|
|
|
meleeSwingCallback_();
|
|
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
if (!isPlayerAttacker && npcSwingCallback_) {
|
|
|
|
|
|
npcSwingCallback_(data.attackerGuid);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (isPlayerTarget && data.attackerGuid != 0) {
|
|
|
|
|
|
hostileAttackers_.insert(data.attackerGuid);
|
2026-02-07 20:51:53 -08:00
|
|
|
|
autoTargetAttacker(data.attackerGuid);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (data.isMiss()) {
|
|
|
|
|
|
addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker);
|
|
|
|
|
|
} else if (data.victimState == 1) {
|
|
|
|
|
|
addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker);
|
|
|
|
|
|
} else if (data.victimState == 2) {
|
|
|
|
|
|
addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE;
|
|
|
|
|
|
addCombatText(type, data.totalDamage, 0, isPlayerAttacker);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
(void)isPlayerTarget; // Used for future incoming damage display
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellDamageLog(network::Packet& packet) {
|
|
|
|
|
|
SpellDamageLogData data;
|
|
|
|
|
|
if (!SpellDamageLogParser::parse(packet, data)) return;
|
|
|
|
|
|
|
2026-02-07 20:51:53 -08:00
|
|
|
|
if (data.targetGuid == playerGuid && data.attackerGuid != 0) {
|
|
|
|
|
|
hostileAttackers_.insert(data.attackerGuid);
|
|
|
|
|
|
autoTargetAttacker(data.attackerGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
bool isPlayerSource = (data.attackerGuid == playerGuid);
|
|
|
|
|
|
auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE;
|
|
|
|
|
|
addCombatText(type, static_cast<int32_t>(data.damage), data.spellId, isPlayerSource);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellHealLog(network::Packet& packet) {
|
|
|
|
|
|
SpellHealLogData data;
|
|
|
|
|
|
if (!SpellHealLogParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
bool isPlayerSource = (data.casterGuid == playerGuid);
|
|
|
|
|
|
auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL;
|
|
|
|
|
|
addCombatText(type, static_cast<int32_t>(data.heal), data.spellId, isPlayerSource);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 3: Spells
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
|
2026-02-07 00:00:06 -08:00
|
|
|
|
// Attack (6603) routes to auto-attack instead of cast
|
2026-02-04 11:31:08 -08:00
|
|
|
|
if (spellId == 6603) {
|
|
|
|
|
|
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
|
|
|
|
|
|
if (target != 0) {
|
|
|
|
|
|
if (autoAttacking) {
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
startAutoAttack(target);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 13:29:27 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
2026-02-07 18:33:14 -08:00
|
|
|
|
// Casting any spell while mounted → dismount instead
|
|
|
|
|
|
if (isMounted()) {
|
|
|
|
|
|
dismount();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (casting) return; // Already casting
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Hearthstone is item-bound; use the item rather than direct spell cast.
|
|
|
|
|
|
if (spellId == 8690) {
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("Hearthstone spell intercepted, routing to useItemById(6948)");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
useItemById(6948);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 15:41:29 -08:00
|
|
|
|
uint64_t target = targetGuid != 0 ? targetGuid : this->targetGuid;
|
2026-02-13 22:14:34 -08:00
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildCastSpell(spellId, target, ++castCount)
|
|
|
|
|
|
: CastSpellPacket::build(spellId, target, ++castCount);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::cancelCast() {
|
|
|
|
|
|
if (!casting) return;
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = CancelCastPacket::build(currentCastSpellId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::cancelAura(uint32_t spellId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = CancelAuraPacket::build(spellId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) {
|
|
|
|
|
|
if (slot < 0 || slot >= ACTION_BAR_SLOTS) return;
|
|
|
|
|
|
actionBar[slot].type = type;
|
|
|
|
|
|
actionBar[slot].id = id;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
saveCharacterConfig();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
float GameHandler::getSpellCooldown(uint32_t spellId) const {
|
|
|
|
|
|
auto it = spellCooldowns.find(spellId);
|
|
|
|
|
|
return (it != spellCooldowns.end()) ? it->second : 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleInitialSpells(network::Packet& packet) {
|
|
|
|
|
|
InitialSpellsData data;
|
|
|
|
|
|
if (!InitialSpellsParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
knownSpells = data.spellIds;
|
|
|
|
|
|
|
2026-02-09 22:13:31 -08:00
|
|
|
|
// Debug: check if specific spells are in initial spells
|
|
|
|
|
|
bool has527 = std::find(knownSpells.begin(), knownSpells.end(), 527u) != knownSpells.end();
|
|
|
|
|
|
bool has988 = std::find(knownSpells.begin(), knownSpells.end(), 988u) != knownSpells.end();
|
2026-02-09 22:19:13 -08:00
|
|
|
|
bool has1180 = std::find(knownSpells.begin(), knownSpells.end(), 1180u) != knownSpells.end();
|
|
|
|
|
|
LOG_INFO("Initial spells include: 527=", has527, " 988=", has988, " 1180=", has1180);
|
2026-02-09 22:13:31 -08:00
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Ensure Attack (6603) and Hearthstone (8690) are always present
|
2026-02-04 11:31:08 -08:00
|
|
|
|
if (std::find(knownSpells.begin(), knownSpells.end(), 6603u) == knownSpells.end()) {
|
|
|
|
|
|
knownSpells.insert(knownSpells.begin(), 6603u);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (std::find(knownSpells.begin(), knownSpells.end(), 8690u) == knownSpells.end()) {
|
|
|
|
|
|
knownSpells.push_back(8690u);
|
|
|
|
|
|
}
|
2026-02-04 11:31:08 -08:00
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// Set initial cooldowns
|
|
|
|
|
|
for (const auto& cd : data.cooldowns) {
|
|
|
|
|
|
if (cd.cooldownMs > 0) {
|
|
|
|
|
|
spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Load saved action bar or use defaults (Attack slot 1, Hearthstone slot 12)
|
2026-02-04 11:31:08 -08:00
|
|
|
|
actionBar[0].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[0].id = 6603; // Attack
|
2026-02-08 03:05:38 -08:00
|
|
|
|
actionBar[11].type = ActionBarSlot::SPELL;
|
|
|
|
|
|
actionBar[11].id = 8690; // Hearthstone
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
loadCharacterConfig();
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Learned ", knownSpells.size(), " spells");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCastFailed(network::Packet& packet) {
|
|
|
|
|
|
CastFailedData data;
|
2026-02-14 16:54:43 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseCastFailed(packet, data)
|
|
|
|
|
|
: CastFailedParser::parse(packet, data);
|
|
|
|
|
|
if (!ok) return;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
|
2026-02-06 15:41:29 -08:00
|
|
|
|
// Add system message about failed cast with readable reason
|
|
|
|
|
|
const char* reason = getSpellCastResultString(data.result);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
2026-02-06 15:41:29 -08:00
|
|
|
|
if (reason) {
|
|
|
|
|
|
msg.message = reason;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")";
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellStart(network::Packet& packet) {
|
|
|
|
|
|
SpellStartData data;
|
|
|
|
|
|
if (!SpellStartParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// If this is the player's own cast, start cast bar
|
|
|
|
|
|
if (data.casterUnit == playerGuid && data.castTime > 0) {
|
|
|
|
|
|
casting = true;
|
|
|
|
|
|
currentCastSpellId = data.spellId;
|
|
|
|
|
|
castTimeTotal = data.castTime / 1000.0f;
|
|
|
|
|
|
castTimeRemaining = castTimeTotal;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellGo(network::Packet& packet) {
|
|
|
|
|
|
SpellGoData data;
|
|
|
|
|
|
if (!SpellGoParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Cast completed
|
|
|
|
|
|
if (data.casterUnit == playerGuid) {
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSpellCooldown(network::Packet& packet) {
|
|
|
|
|
|
SpellCooldownData data;
|
|
|
|
|
|
if (!SpellCooldownParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [spellId, cooldownMs] : data.cooldowns) {
|
|
|
|
|
|
float seconds = cooldownMs / 1000.0f;
|
|
|
|
|
|
spellCooldowns[spellId] = seconds;
|
|
|
|
|
|
// Update action bar cooldowns
|
|
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
|
|
|
|
|
slot.cooldownTotal = seconds;
|
|
|
|
|
|
slot.cooldownRemaining = seconds;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleCooldownEvent(network::Packet& packet) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
// Cooldown finished
|
|
|
|
|
|
spellCooldowns.erase(spellId);
|
|
|
|
|
|
for (auto& slot : actionBar) {
|
|
|
|
|
|
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
|
|
|
|
|
|
slot.cooldownRemaining = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
|
|
|
|
|
|
AuraUpdateData data;
|
|
|
|
|
|
if (!AuraUpdateParser::parse(packet, data, isAll)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Determine which aura list to update
|
|
|
|
|
|
std::vector<AuraSlot>* auraList = nullptr;
|
|
|
|
|
|
if (data.guid == playerGuid) {
|
|
|
|
|
|
auraList = &playerAuras;
|
|
|
|
|
|
} else if (data.guid == targetGuid) {
|
|
|
|
|
|
auraList = &targetAuras;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (auraList) {
|
2026-02-08 00:00:12 -08:00
|
|
|
|
if (isAll) {
|
|
|
|
|
|
auraList->clear();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
for (const auto& [slot, aura] : data.updates) {
|
|
|
|
|
|
// Ensure vector is large enough
|
|
|
|
|
|
while (auraList->size() <= slot) {
|
|
|
|
|
|
auraList->push_back(AuraSlot{});
|
|
|
|
|
|
}
|
|
|
|
|
|
(*auraList)[slot] = aura;
|
|
|
|
|
|
}
|
2026-02-14 16:42:47 -08:00
|
|
|
|
|
|
|
|
|
|
// If player is mounted but we haven't identified the mount aura yet,
|
|
|
|
|
|
// check newly added auras (aura update may arrive after mountDisplayId)
|
|
|
|
|
|
if (data.guid == playerGuid && currentMountDisplayId_ != 0 && mountAuraSpellId_ == 0) {
|
|
|
|
|
|
for (const auto& [slot, aura] : data.updates) {
|
|
|
|
|
|
if (!aura.isEmpty() && aura.maxDurationMs < 0 && aura.casterGuid == playerGuid) {
|
|
|
|
|
|
mountAuraSpellId_ = aura.spellId;
|
|
|
|
|
|
LOG_INFO("Mount aura detected from aura update: spellId=", aura.spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLearnedSpell(network::Packet& packet) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
knownSpells.push_back(spellId);
|
|
|
|
|
|
LOG_INFO("Learned spell: ", spellId);
|
2026-02-10 13:16:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Check if this spell corresponds to a talent rank
|
|
|
|
|
|
for (const auto& [talentId, talent] : talentCache_) {
|
|
|
|
|
|
for (int rank = 0; rank < 5; ++rank) {
|
|
|
|
|
|
if (talent.rankSpells[rank] == spellId) {
|
|
|
|
|
|
// Found the talent! Update the rank for the active spec
|
|
|
|
|
|
uint8_t newRank = rank + 1; // rank is 0-indexed in array, but stored as 1-indexed
|
|
|
|
|
|
learnedTalents_[activeTalentSpec_][talentId] = newRank;
|
|
|
|
|
|
LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank,
|
|
|
|
|
|
" (spell ", spellId, ") in spec ", (int)activeTalentSpec_);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleRemovedSpell(network::Packet& packet) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
knownSpells.erase(
|
|
|
|
|
|
std::remove(knownSpells.begin(), knownSpells.end(), spellId),
|
|
|
|
|
|
knownSpells.end());
|
|
|
|
|
|
LOG_INFO("Removed spell: ", spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
void GameHandler::handleSupercededSpell(network::Packet& packet) {
|
|
|
|
|
|
// Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2)
|
|
|
|
|
|
uint32_t oldSpellId = packet.readUInt32();
|
|
|
|
|
|
uint32_t newSpellId = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
// Remove old spell
|
|
|
|
|
|
knownSpells.erase(
|
|
|
|
|
|
std::remove(knownSpells.begin(), knownSpells.end(), oldSpellId),
|
|
|
|
|
|
knownSpells.end());
|
|
|
|
|
|
|
|
|
|
|
|
// Add new spell
|
|
|
|
|
|
knownSpells.push_back(newSpellId);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId);
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& newName = getSpellName(newSpellId);
|
|
|
|
|
|
if (!newName.empty()) {
|
|
|
|
|
|
addSystemChatMessage("Upgraded to " + newName);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleUnlearnSpells(network::Packet& packet) {
|
|
|
|
|
|
// Sent when unlearning multiple spells (e.g., spec change, respec)
|
|
|
|
|
|
uint32_t spellCount = packet.readUInt32();
|
|
|
|
|
|
LOG_INFO("Unlearning ", spellCount, " spells");
|
|
|
|
|
|
|
|
|
|
|
|
for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) {
|
|
|
|
|
|
uint32_t spellId = packet.readUInt32();
|
|
|
|
|
|
knownSpells.erase(
|
|
|
|
|
|
std::remove(knownSpells.begin(), knownSpells.end(), spellId),
|
|
|
|
|
|
knownSpells.end());
|
|
|
|
|
|
LOG_INFO(" Unlearned spell: ", spellId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (spellCount > 0) {
|
|
|
|
|
|
addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Talents
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTalentsInfo(network::Packet& packet) {
|
|
|
|
|
|
TalentsInfoData data;
|
|
|
|
|
|
if (!TalentsInfoParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure talent DBCs are loaded
|
|
|
|
|
|
loadTalentDbc();
|
|
|
|
|
|
|
|
|
|
|
|
// Validate spec number
|
|
|
|
|
|
if (data.talentSpec > 1) {
|
|
|
|
|
|
LOG_WARNING("Invalid talent spec: ", (int)data.talentSpec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store talents for this spec
|
|
|
|
|
|
unspentTalentPoints_[data.talentSpec] = data.unspentPoints;
|
|
|
|
|
|
|
|
|
|
|
|
// Clear and rebuild learned talents map for this spec
|
2026-02-10 13:16:38 -08:00
|
|
|
|
// Note: If a talent appears in the packet, it's learned (ranks are 0-indexed)
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
learnedTalents_[data.talentSpec].clear();
|
|
|
|
|
|
for (const auto& talent : data.talents) {
|
2026-02-10 13:16:38 -08:00
|
|
|
|
learnedTalents_[data.talentSpec][talent.talentId] = talent.currentRank;
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Talents loaded: spec=", (int)data.talentSpec,
|
|
|
|
|
|
" unspent=", (int)unspentTalentPoints_[data.talentSpec],
|
|
|
|
|
|
" learned=", learnedTalents_[data.talentSpec].size());
|
|
|
|
|
|
|
|
|
|
|
|
// If this is the first spec received, set it as active
|
|
|
|
|
|
static bool firstSpecReceived = false;
|
|
|
|
|
|
if (!firstSpecReceived) {
|
|
|
|
|
|
firstSpecReceived = true;
|
|
|
|
|
|
activeTalentSpec_ = data.talentSpec;
|
|
|
|
|
|
|
|
|
|
|
|
// Show message to player about active spec
|
|
|
|
|
|
if (unspentTalentPoints_[data.talentSpec] > 0) {
|
|
|
|
|
|
std::string msg = "You have " + std::to_string(unspentTalentPoints_[data.talentSpec]) +
|
|
|
|
|
|
" unspent talent point";
|
|
|
|
|
|
if (unspentTalentPoints_[data.talentSpec] > 1) msg += "s";
|
|
|
|
|
|
msg += " in spec " + std::to_string(data.talentSpec + 1);
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("learnTalent: Not in world or no socket connection");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Requesting to learn talent: id=", talentId, " rank=", requestedRank);
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = LearnTalentPacket::build(talentId, requestedRank);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::switchTalentSpec(uint8_t newSpec) {
|
|
|
|
|
|
if (newSpec > 1) {
|
|
|
|
|
|
LOG_WARNING("Invalid talent spec: ", (int)newSpec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (newSpec == activeTalentSpec_) {
|
|
|
|
|
|
LOG_INFO("Already on spec ", (int)newSpec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For now, just switch locally. In a real implementation, we'd send
|
|
|
|
|
|
// MSG_TALENT_WIPE_CONFIRM to the server to trigger a spec switch.
|
|
|
|
|
|
// The server would respond with new SMSG_TALENTS_INFO for the new spec.
|
|
|
|
|
|
activeTalentSpec_ = newSpec;
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Switched to talent spec ", (int)newSpec,
|
|
|
|
|
|
" (unspent=", (int)unspentTalentPoints_[newSpec],
|
|
|
|
|
|
", learned=", learnedTalents_[newSpec].size(), ")");
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg = "Switched to spec " + std::to_string(newSpec + 1);
|
|
|
|
|
|
if (unspentTalentPoints_[newSpec] > 0) {
|
|
|
|
|
|
msg += " (" + std::to_string(unspentTalentPoints_[newSpec]) + " unspent point";
|
|
|
|
|
|
if (unspentTalentPoints_[newSpec] > 1) msg += "s";
|
|
|
|
|
|
msg += ")";
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 4: Group/Party
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::inviteToGroup(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GroupInvitePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Inviting ", playerName, " to group");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::acceptGroupInvite() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
pendingGroupInvite = false;
|
|
|
|
|
|
auto packet = GroupAcceptPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Accepted group invite");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::declineGroupInvite() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
pendingGroupInvite = false;
|
|
|
|
|
|
auto packet = GroupDeclinePacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Declined group invite");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::leaveGroup() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GroupDisbandPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
partyData = GroupListData{};
|
|
|
|
|
|
LOG_INFO("Left group");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGroupInvite(network::Packet& packet) {
|
|
|
|
|
|
GroupInviteResponseData data;
|
|
|
|
|
|
if (!GroupInviteResponseParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingGroupInvite = true;
|
|
|
|
|
|
pendingInviterName = data.inviterName;
|
|
|
|
|
|
LOG_INFO("Group invite from: ", data.inviterName);
|
2026-02-05 13:22:15 -08:00
|
|
|
|
if (!data.inviterName.empty()) {
|
|
|
|
|
|
addSystemChatMessage(data.inviterName + " has invited you to a group.");
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGroupDecline(network::Packet& packet) {
|
|
|
|
|
|
GroupDeclineData data;
|
|
|
|
|
|
if (!GroupDeclineResponseParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
msg.message = data.playerName + " has declined your group invitation.";
|
|
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGroupList(network::Packet& packet) {
|
|
|
|
|
|
if (!GroupListParser::parse(packet, partyData)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (partyData.isEmpty()) {
|
|
|
|
|
|
LOG_INFO("No longer in a group");
|
2026-02-05 13:22:15 -08:00
|
|
|
|
addSystemChatMessage("You are no longer in a group.");
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("In group with ", partyData.memberCount, " members");
|
2026-02-05 13:22:15 -08:00
|
|
|
|
addSystemChatMessage("You are now in a group with " + std::to_string(partyData.memberCount) + " members.");
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGroupUninvite(network::Packet& packet) {
|
|
|
|
|
|
(void)packet;
|
|
|
|
|
|
partyData = GroupListData{};
|
|
|
|
|
|
LOG_INFO("Removed from group");
|
|
|
|
|
|
|
|
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
msg.message = "You have been removed from the group.";
|
|
|
|
|
|
addLocalChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handlePartyCommandResult(network::Packet& packet) {
|
|
|
|
|
|
PartyCommandResultData data;
|
|
|
|
|
|
if (!PartyCommandResultParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.result != PartyResult::OK) {
|
|
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
msg.message = "Party command failed (error " + std::to_string(static_cast<uint32_t>(data.result)) + ")";
|
|
|
|
|
|
if (!data.name.empty()) msg.message += " for " + data.name;
|
|
|
|
|
|
addLocalChatMessage(msg);
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Guild Handlers
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::kickGuildMember(const std::string& playerName) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GuildRemovePacket::build(playerName);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Kicking guild member: ", playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 20:16:14 -08:00
|
|
|
|
void GameHandler::disbandGuild() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GuildDisbandPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Disbanding guild");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setGuildLeader(const std::string& name) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GuildLeaderPacket::build(name);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Setting guild leader: ", name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setGuildPublicNote(const std::string& name, const std::string& note) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GuildSetPublicNotePacket::build(name, note);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Setting public note for ", name, ": ", note);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setGuildOfficerNote(const std::string& name, const std::string& note) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GuildSetOfficerNotePacket::build(name, note);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Setting officer note for ", name, ": ", note);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 21:39:48 -08:00
|
|
|
|
void GameHandler::acceptGuildInvite() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
pendingGuildInvite_ = false;
|
|
|
|
|
|
auto packet = GuildAcceptPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Accepted guild invite");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::declineGuildInvite() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
pendingGuildInvite_ = false;
|
|
|
|
|
|
auto packet = GuildDeclineInvitationPacket::build();
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Declined guild invite");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryGuildInfo(uint32_t guildId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GuildQueryPacket::build(guildId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
LOG_INFO("Querying guild info: guildId=", guildId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildInfo(network::Packet& packet) {
|
|
|
|
|
|
GuildInfoData data;
|
|
|
|
|
|
if (!GuildInfoParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage("Guild: " + data.guildName + " (" +
|
|
|
|
|
|
std::to_string(data.numMembers) + " members, " +
|
|
|
|
|
|
std::to_string(data.numAccounts) + " accounts)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildRoster(network::Packet& packet) {
|
|
|
|
|
|
GuildRosterData data;
|
|
|
|
|
|
if (!packetParsers_->parseGuildRoster(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
guildRoster_ = std::move(data);
|
|
|
|
|
|
hasGuildRoster_ = true;
|
|
|
|
|
|
LOG_INFO("Guild roster received: ", guildRoster_.members.size(), " members");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
|
|
|
|
|
|
GuildQueryResponseData data;
|
|
|
|
|
|
if (!packetParsers_->parseGuildQueryResponse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
guildName_ = data.guildName;
|
|
|
|
|
|
guildRankNames_.clear();
|
|
|
|
|
|
for (uint32_t i = 0; i < 10; ++i) {
|
|
|
|
|
|
guildRankNames_.push_back(data.rankNames[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Guild name set to: ", guildName_);
|
|
|
|
|
|
addSystemChatMessage("Guild: <" + guildName_ + ">");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildEvent(network::Packet& packet) {
|
|
|
|
|
|
GuildEventData data;
|
|
|
|
|
|
if (!GuildEventParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg;
|
|
|
|
|
|
switch (data.eventType) {
|
|
|
|
|
|
case GuildEvent::PROMOTION:
|
|
|
|
|
|
if (data.numStrings >= 3)
|
|
|
|
|
|
msg = data.strings[0] + " has promoted " + data.strings[1] + " to " + data.strings[2] + ".";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::DEMOTION:
|
|
|
|
|
|
if (data.numStrings >= 3)
|
|
|
|
|
|
msg = data.strings[0] + " has demoted " + data.strings[1] + " to " + data.strings[2] + ".";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::MOTD:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = "Guild MOTD: " + data.strings[0];
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::JOINED:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = data.strings[0] + " has joined the guild.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::LEFT:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = data.strings[0] + " has left the guild.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::REMOVED:
|
|
|
|
|
|
if (data.numStrings >= 2)
|
|
|
|
|
|
msg = data.strings[1] + " has been kicked from the guild by " + data.strings[0] + ".";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::LEADER_IS:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = data.strings[0] + " is the guild leader.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::LEADER_CHANGED:
|
|
|
|
|
|
if (data.numStrings >= 2)
|
|
|
|
|
|
msg = data.strings[0] + " has made " + data.strings[1] + " the new guild leader.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::DISBANDED:
|
|
|
|
|
|
msg = "Guild has been disbanded.";
|
|
|
|
|
|
guildName_.clear();
|
|
|
|
|
|
guildRankNames_.clear();
|
|
|
|
|
|
guildRoster_ = GuildRosterData{};
|
|
|
|
|
|
hasGuildRoster_ = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::SIGNED_ON:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = "[Guild] " + data.strings[0] + " has come online.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case GuildEvent::SIGNED_OFF:
|
|
|
|
|
|
if (data.numStrings >= 1)
|
|
|
|
|
|
msg = "[Guild] " + data.strings[0] + " has gone offline.";
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
msg = "Guild event " + std::to_string(data.eventType);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!msg.empty()) {
|
|
|
|
|
|
MessageChatData chatMsg;
|
|
|
|
|
|
chatMsg.type = ChatType::GUILD;
|
|
|
|
|
|
chatMsg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
chatMsg.message = msg;
|
|
|
|
|
|
addLocalChatMessage(chatMsg);
|
|
|
|
|
|
}
|
2026-02-16 20:16:14 -08:00
|
|
|
|
|
|
|
|
|
|
// Auto-refresh roster after membership/rank changes
|
|
|
|
|
|
switch (data.eventType) {
|
|
|
|
|
|
case GuildEvent::PROMOTION:
|
|
|
|
|
|
case GuildEvent::DEMOTION:
|
|
|
|
|
|
case GuildEvent::JOINED:
|
|
|
|
|
|
case GuildEvent::LEFT:
|
|
|
|
|
|
case GuildEvent::REMOVED:
|
|
|
|
|
|
case GuildEvent::LEADER_CHANGED:
|
|
|
|
|
|
if (hasGuildRoster_) requestGuildRoster();
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-13 21:39:48 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildInvite(network::Packet& packet) {
|
|
|
|
|
|
GuildInviteResponseData data;
|
|
|
|
|
|
if (!GuildInviteResponseParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
pendingGuildInvite_ = true;
|
|
|
|
|
|
pendingGuildInviterName_ = data.inviterName;
|
|
|
|
|
|
pendingGuildInviteGuildName_ = data.guildName;
|
|
|
|
|
|
LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName);
|
|
|
|
|
|
addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + ".");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildCommandResult(network::Packet& packet) {
|
|
|
|
|
|
GuildCommandResultData data;
|
|
|
|
|
|
if (!GuildCommandResultParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (data.errorCode != 0) {
|
|
|
|
|
|
std::string msg = "Guild command failed";
|
|
|
|
|
|
if (!data.name.empty()) msg += " for " + data.name;
|
|
|
|
|
|
msg += " (error " + std::to_string(data.errorCode) + ")";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Phase 5: Loot, Gossip, Vendor
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::lootTarget(uint64_t guid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = LootPacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::lootItem(uint8_t slotIndex) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = AutostoreLootItemPacket::build(slotIndex);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeLoot() {
|
|
|
|
|
|
if (!lootWindowOpen) return;
|
|
|
|
|
|
lootWindowOpen = false;
|
2026-02-06 18:52:28 -08:00
|
|
|
|
if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) {
|
|
|
|
|
|
clearTarget();
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
auto packet = LootReleasePacket::build(currentLoot.lootGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
currentLoot = LootResponseData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::interactWithNpc(uint64_t guid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = GossipHelloPacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
void GameHandler::interactWithGameObject(uint64_t guid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
|
|
|
|
|
|
// Rate-limit to prevent spamming the server
|
|
|
|
|
|
static uint64_t lastInteractGuid = 0;
|
|
|
|
|
|
static std::chrono::steady_clock::time_point lastInteractTime{};
|
|
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
|
if (guid == lastInteractGuid &&
|
|
|
|
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastInteractTime).count() < 1000) {
|
|
|
|
|
|
return; // Ignore repeated clicks within 1 second
|
|
|
|
|
|
}
|
|
|
|
|
|
lastInteractGuid = guid;
|
|
|
|
|
|
lastInteractTime = now;
|
|
|
|
|
|
|
|
|
|
|
|
auto entity = entityManager.getEntity(guid);
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
auto packet = GameObjectUsePacket::build(guid);
|
|
|
|
|
|
socket->send(packet);
|
2026-02-16 18:46:44 -08:00
|
|
|
|
|
|
|
|
|
|
// For mailbox GameObjects (type 19), open mail UI and request mail list.
|
|
|
|
|
|
// In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends
|
|
|
|
|
|
// animation/sound and expects the client to request the mail list.
|
|
|
|
|
|
if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
auto go = std::static_pointer_cast<GameObject>(entity);
|
|
|
|
|
|
auto* info = getCachedGameObjectInfo(go->getEntry());
|
|
|
|
|
|
if (info && info->type == 19) {
|
|
|
|
|
|
LOG_INFO("Mailbox interaction: opening mail UI and requesting mail list");
|
|
|
|
|
|
mailboxGuid_ = guid;
|
|
|
|
|
|
mailboxOpen_ = true;
|
|
|
|
|
|
hasNewMail_ = false;
|
|
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 19:44:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::selectGossipOption(uint32_t optionId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
LOG_INFO("selectGossipOption: optionId=", optionId,
|
|
|
|
|
|
" npcGuid=0x", std::hex, currentGossip.npcGuid, std::dec,
|
|
|
|
|
|
" menuId=", currentGossip.menuId,
|
|
|
|
|
|
" numOptions=", currentGossip.options.size());
|
2026-02-06 11:45:35 -08:00
|
|
|
|
auto packet = GossipSelectOptionPacket::build(currentGossip.npcGuid, currentGossip.menuId, optionId);
|
|
|
|
|
|
socket->send(packet);
|
2026-02-08 03:32:00 -08:00
|
|
|
|
|
|
|
|
|
|
for (const auto& opt : currentGossip.options) {
|
|
|
|
|
|
if (opt.id != optionId) continue;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
LOG_INFO(" matched option: id=", opt.id, " icon=", (int)opt.icon, " text='", opt.text, "'");
|
|
|
|
|
|
|
|
|
|
|
|
// Icon-based NPC interaction fallbacks
|
|
|
|
|
|
// Some servers need the specific activate packet in addition to gossip select
|
|
|
|
|
|
if (opt.icon == 6) {
|
|
|
|
|
|
// GOSSIP_ICON_MONEY_BAG = banker
|
|
|
|
|
|
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Text-based NPC type detection for servers using placeholder strings
|
2026-02-08 03:32:00 -08:00
|
|
|
|
std::string text = opt.text;
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
std::string textLower = text;
|
|
|
|
|
|
std::transform(textLower.begin(), textLower.end(), textLower.begin(),
|
2026-02-08 03:32:00 -08:00
|
|
|
|
[](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
|
|
|
|
|
|
if (text == "GOSSIP_OPTION_AUCTIONEER" || textLower.find("auction") != std::string::npos) {
|
|
|
|
|
|
auto pkt = AuctionHelloPacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent MSG_AUCTION_HELLO for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (text == "GOSSIP_OPTION_BANKER" || textLower.find("deposit box") != std::string::npos) {
|
|
|
|
|
|
auto pkt = BankerActivatePacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (textLower.find("make this inn your home") != std::string::npos ||
|
|
|
|
|
|
textLower.find("set your home") != std::string::npos) {
|
2026-02-08 03:32:00 -08:00
|
|
|
|
auto bindPkt = BinderActivatePacket::build(currentGossip.npcGuid);
|
|
|
|
|
|
socket->send(bindPkt);
|
|
|
|
|
|
LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-02-06 11:45:35 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::selectGossipQuest(uint32_t questId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || !gossipWindowOpen) return;
|
2026-02-09 23:24:04 -08:00
|
|
|
|
|
|
|
|
|
|
// Check if quest is in our quest log and completable
|
|
|
|
|
|
bool isInLog = false;
|
|
|
|
|
|
bool isCompletable = false;
|
|
|
|
|
|
for (const auto& quest : questLog_) {
|
|
|
|
|
|
if (quest.questId == questId) {
|
|
|
|
|
|
isInLog = true;
|
|
|
|
|
|
isCompletable = quest.complete;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 23:53:17 -08:00
|
|
|
|
LOG_INFO("selectGossipQuest: questId=", questId, " isInLog=", isInLog, " isCompletable=", isCompletable);
|
|
|
|
|
|
LOG_INFO(" Current quest log size: ", questLog_.size());
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
LOG_INFO(" Quest ", q.questId, ": complete=", q.complete);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 23:24:04 -08:00
|
|
|
|
if (isInLog && isCompletable) {
|
|
|
|
|
|
// Quest is ready to turn in - request reward
|
|
|
|
|
|
LOG_INFO("Turning in quest: questId=", questId, " npcGuid=", currentGossip.npcGuid);
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD));
|
2026-02-09 23:24:04 -08:00
|
|
|
|
packet.writeUInt64(currentGossip.npcGuid);
|
|
|
|
|
|
packet.writeUInt32(questId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// New quest or not completable - query details
|
|
|
|
|
|
LOG_INFO("Querying quest details: questId=", questId, " npcGuid=", currentGossip.npcGuid);
|
|
|
|
|
|
auto packet = QuestgiverQueryQuestPacket::build(currentGossip.npcGuid, questId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:45:35 -08:00
|
|
|
|
gossipWindowOpen = false;
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
void GameHandler::handleQuestDetails(network::Packet& packet) {
|
|
|
|
|
|
QuestDetailsData data;
|
|
|
|
|
|
if (!QuestDetailsParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_QUEST_DETAILS");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
currentQuestDetails = data;
|
|
|
|
|
|
questDetailsOpen = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::acceptQuest() {
|
|
|
|
|
|
if (!questDetailsOpen || state != WorldState::IN_WORLD || !socket) return;
|
2026-02-06 20:16:38 -08:00
|
|
|
|
uint64_t npcGuid = currentQuestDetails.npcGuid;
|
2026-02-06 11:59:51 -08:00
|
|
|
|
auto packet = QuestgiverAcceptQuestPacket::build(
|
2026-02-06 20:16:38 -08:00
|
|
|
|
npcGuid, currentQuestDetails.questId);
|
2026-02-06 11:59:51 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
|
|
|
|
|
|
// Add to quest log
|
|
|
|
|
|
bool alreadyInLog = false;
|
|
|
|
|
|
for (const auto& q : questLog_) {
|
|
|
|
|
|
if (q.questId == currentQuestDetails.questId) { alreadyInLog = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!alreadyInLog) {
|
|
|
|
|
|
QuestLogEntry entry;
|
|
|
|
|
|
entry.questId = currentQuestDetails.questId;
|
|
|
|
|
|
entry.title = currentQuestDetails.title;
|
|
|
|
|
|
entry.objectives = currentQuestDetails.objectives;
|
|
|
|
|
|
questLog_.push_back(entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
questDetailsOpen = false;
|
|
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
2026-02-06 20:16:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Re-query quest giver status so marker updates (! → ?)
|
|
|
|
|
|
if (npcGuid) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 20:16:38 -08:00
|
|
|
|
qsPkt.writeUInt64(npcGuid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
2026-02-06 11:59:51 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::declineQuest() {
|
|
|
|
|
|
questDetailsOpen = false;
|
|
|
|
|
|
currentQuestDetails = QuestDetailsData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void GameHandler::abandonQuest(uint32_t questId) {
|
|
|
|
|
|
// Find the quest's index in our local log
|
|
|
|
|
|
for (size_t i = 0; i < questLog_.size(); i++) {
|
|
|
|
|
|
if (questLog_[i].questId == questId) {
|
|
|
|
|
|
// Tell server to remove it (slot index in server quest log)
|
|
|
|
|
|
// We send the local index; server maps it via PLAYER_QUEST_LOG fields
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST));
|
2026-02-06 13:47:03 -08:00
|
|
|
|
pkt.writeUInt8(static_cast<uint8_t>(i));
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
questLog_.erase(questLog_.begin() + static_cast<ptrdiff_t>(i));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 21:50:15 -08:00
|
|
|
|
void GameHandler::handleQuestRequestItems(network::Packet& packet) {
|
|
|
|
|
|
QuestRequestItemsData data;
|
|
|
|
|
|
if (!QuestRequestItemsParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_REQUEST_ITEMS");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
currentQuestRequestItems_ = data;
|
|
|
|
|
|
questRequestItemsOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
questDetailsOpen = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Query item names for required items
|
|
|
|
|
|
for (const auto& item : data.requiredItems) {
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleQuestOfferReward(network::Packet& packet) {
|
|
|
|
|
|
QuestOfferRewardData data;
|
|
|
|
|
|
if (!QuestOfferRewardParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUESTGIVER_OFFER_REWARD");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-09 22:56:38 -08:00
|
|
|
|
LOG_INFO("Quest offer reward: questId=", data.questId, " title=\"", data.title, "\"");
|
2026-02-06 21:50:15 -08:00
|
|
|
|
currentQuestOfferReward_ = data;
|
|
|
|
|
|
questOfferRewardOpen_ = true;
|
|
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
questDetailsOpen = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Query item names for reward items
|
|
|
|
|
|
for (const auto& item : data.choiceRewards)
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
for (const auto& item : data.fixedRewards)
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::completeQuest() {
|
|
|
|
|
|
if (!questRequestItemsOpen_ || state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = QuestgiverCompleteQuestPacket::build(
|
|
|
|
|
|
currentQuestRequestItems_.npcGuid, currentQuestRequestItems_.questId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeQuestRequestItems() {
|
|
|
|
|
|
questRequestItemsOpen_ = false;
|
|
|
|
|
|
currentQuestRequestItems_ = QuestRequestItemsData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::chooseQuestReward(uint32_t rewardIndex) {
|
|
|
|
|
|
if (!questOfferRewardOpen_ || state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
uint64_t npcGuid = currentQuestOfferReward_.npcGuid;
|
2026-02-09 22:56:38 -08:00
|
|
|
|
LOG_INFO("Completing quest: questId=", currentQuestOfferReward_.questId,
|
|
|
|
|
|
" npcGuid=", npcGuid, " rewardIndex=", rewardIndex);
|
2026-02-06 21:50:15 -08:00
|
|
|
|
auto packet = QuestgiverChooseRewardPacket::build(
|
|
|
|
|
|
npcGuid, currentQuestOfferReward_.questId, rewardIndex);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
questOfferRewardOpen_ = false;
|
|
|
|
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
|
|
|
|
|
|
|
|
|
|
// Re-query quest giver status so markers update
|
|
|
|
|
|
if (npcGuid) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
2026-02-06 21:50:15 -08:00
|
|
|
|
qsPkt.writeUInt64(npcGuid);
|
|
|
|
|
|
socket->send(qsPkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeQuestOfferReward() {
|
|
|
|
|
|
questOfferRewardOpen_ = false;
|
|
|
|
|
|
currentQuestOfferReward_ = QuestOfferRewardData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::closeGossip() {
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
currentGossip = GossipMessageData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openVendor(uint64_t npcGuid) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = ListInventoryPacket::build(npcGuid);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
void GameHandler::closeVendor() {
|
|
|
|
|
|
vendorWindowOpen = false;
|
|
|
|
|
|
currentVendorItems = ListInventoryData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = BuyItemPacket::build(vendorGuid, itemId, slot, count);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 19:50:22 -08:00
|
|
|
|
void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) {
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
auto packet = SellItemPacket::build(vendorGuid, itemGuid, count);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void GameHandler::sellItemBySlot(int backpackIndex) {
|
|
|
|
|
|
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
|
|
|
|
|
if (slot.empty()) return;
|
|
|
|
|
|
|
2026-02-06 23:52:16 -08:00
|
|
|
|
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
|
|
|
|
|
|
if (itemGuid == 0) {
|
|
|
|
|
|
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_DEBUG("sellItemBySlot: slot=", backpackIndex,
|
|
|
|
|
|
" item=", slot.item.name,
|
|
|
|
|
|
" itemGuid=0x", std::hex, itemGuid, std::dec,
|
|
|
|
|
|
" vendorGuid=0x", std::hex, currentVendorItems.vendorGuid, std::dec);
|
|
|
|
|
|
if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) {
|
|
|
|
|
|
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
|
|
|
|
|
|
} else if (itemGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("Cannot sell: item not found in inventory.");
|
|
|
|
|
|
LOG_WARNING("Sell failed: missing item GUID for slot ", backpackIndex);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
} else {
|
2026-02-06 23:52:16 -08:00
|
|
|
|
addSystemChatMessage("Cannot sell: no vendor.");
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::autoEquipItemBySlot(int backpackIndex) {
|
|
|
|
|
|
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
|
|
|
|
|
if (slot.empty()) return;
|
|
|
|
|
|
|
2026-02-06 19:13:38 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
// WoW inventory: equipment 0-18, bags 19-22, backpack 23-38
|
|
|
|
|
|
auto packet = AutoEquipItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex));
|
2026-02-06 18:34:45 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
void GameHandler::autoEquipItemInBag(int bagIndex, int slotIndex) {
|
|
|
|
|
|
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
|
|
|
|
|
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
// Bag items: bag = equip slot 19+bagIndex, slot = index within bag
|
|
|
|
|
|
auto packet = AutoEquipItemPacket::build(
|
|
|
|
|
|
static_cast<uint8_t>(19 + bagIndex), static_cast<uint8_t>(slotIndex));
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sellItemInBag(int bagIndex, int slotIndex) {
|
|
|
|
|
|
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
|
|
|
|
|
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
|
|
|
|
|
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
|
|
|
|
|
|
if (slot.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve item GUID from container contents
|
|
|
|
|
|
uint64_t itemGuid = 0;
|
|
|
|
|
|
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
|
|
|
|
|
|
if (bagGuid != 0) {
|
|
|
|
|
|
auto it = containerContents_.find(bagGuid);
|
|
|
|
|
|
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
|
|
|
|
|
|
itemGuid = it->second.slotGuids[slotIndex];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (itemGuid == 0) {
|
|
|
|
|
|
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (itemGuid != 0 && currentVendorItems.vendorGuid != 0) {
|
|
|
|
|
|
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
|
|
|
|
|
|
} else if (itemGuid == 0) {
|
|
|
|
|
|
addSystemChatMessage("Cannot sell: item not found.");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Cannot sell: no vendor.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
void GameHandler::unequipToBackpack(EquipSlot equipSlot) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) return;
|
|
|
|
|
|
|
|
|
|
|
|
int freeSlot = inventory.findFreeBackpackSlot();
|
|
|
|
|
|
if (freeSlot < 0) {
|
|
|
|
|
|
addSystemChatMessage("Cannot unequip: no free backpack slots.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF.
|
|
|
|
|
|
uint8_t srcBag = 0xFF;
|
|
|
|
|
|
uint8_t srcSlot = static_cast<uint8_t>(equipSlot);
|
|
|
|
|
|
uint8_t dstBag = 0xFF;
|
|
|
|
|
|
uint8_t dstSlot = static_cast<uint8_t>(23 + freeSlot);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot,
|
|
|
|
|
|
" -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")");
|
|
|
|
|
|
|
|
|
|
|
|
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
void GameHandler::swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot) {
|
|
|
|
|
|
if (!socket || !socket->isConnected()) return;
|
|
|
|
|
|
LOG_INFO("swapContainerItems: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
|
|
|
|
|
|
") -> dst(bag=", (int)dstBag, " slot=", (int)dstSlot, ")");
|
|
|
|
|
|
auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
void GameHandler::useItemBySlot(int backpackIndex) {
|
|
|
|
|
|
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(backpackIndex);
|
2026-02-17 01:00:04 -08:00
|
|
|
|
if (slot.empty()) {
|
|
|
|
|
|
LOG_WARNING("useItemBySlot: slot ", backpackIndex, " is empty");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("useItemBySlot: backpackIndex=", backpackIndex, " itemId=", slot.item.itemId,
|
|
|
|
|
|
" wowSlot=", 23 + backpackIndex);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
|
|
|
|
|
|
uint64_t itemGuid = backpackSlotGuids_[backpackIndex];
|
2026-02-06 18:52:28 -08:00
|
|
|
|
if (itemGuid == 0) {
|
|
|
|
|
|
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
|
|
|
|
|
}
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("useItemBySlot: itemGuid=0x", std::hex, itemGuid, std::dec);
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
|
2026-02-06 19:13:38 -08:00
|
|
|
|
// WoW inventory: equipment 0-18, bags 19-22, backpack 23-38
|
2026-02-14 21:29:44 -08:00
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildUseItem(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid)
|
|
|
|
|
|
: UseItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex), itemGuid);
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("useItemBySlot: sending CMSG_USE_ITEM, packetSize=", packet.getSize());
|
2026-02-06 18:34:45 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-06 18:52:28 -08:00
|
|
|
|
} else if (itemGuid == 0) {
|
|
|
|
|
|
LOG_WARNING("Use item failed: missing item GUID for slot ", backpackIndex);
|
2026-02-06 18:34:45 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 01:00:04 -08:00
|
|
|
|
void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
|
|
|
|
|
|
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
|
|
|
|
|
|
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
|
|
|
|
|
|
const auto& slot = inventory.getBagSlot(bagIndex, slotIndex);
|
|
|
|
|
|
if (slot.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve item GUID from container contents
|
|
|
|
|
|
uint64_t itemGuid = 0;
|
|
|
|
|
|
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
|
|
|
|
|
|
if (bagGuid != 0) {
|
|
|
|
|
|
auto it = containerContents_.find(bagGuid);
|
|
|
|
|
|
if (it != containerContents_.end() && slotIndex < static_cast<int>(it->second.numSlots)) {
|
|
|
|
|
|
itemGuid = it->second.slotGuids[slotIndex];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (itemGuid == 0) {
|
|
|
|
|
|
itemGuid = resolveOnlineItemGuid(slot.item.itemId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("useItemInBag: bag=", bagIndex, " slot=", slotIndex, " itemId=", slot.item.itemId,
|
|
|
|
|
|
" itemGuid=0x", std::hex, itemGuid, std::dec);
|
|
|
|
|
|
|
|
|
|
|
|
if (itemGuid != 0 && state == WorldState::IN_WORLD && socket) {
|
|
|
|
|
|
// WoW bag addressing: bagIndex = equip slot of bag container (19-22)
|
|
|
|
|
|
// For CMSG_USE_ITEM: bag = 19+bagIndex, slot = slot within bag
|
|
|
|
|
|
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
|
|
|
|
|
|
auto packet = packetParsers_
|
|
|
|
|
|
? packetParsers_->buildUseItem(wowBag, static_cast<uint8_t>(slotIndex), itemGuid)
|
|
|
|
|
|
: UseItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex), itemGuid);
|
|
|
|
|
|
LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex,
|
|
|
|
|
|
" packetSize=", packet.getSize());
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
} else if (itemGuid == 0) {
|
|
|
|
|
|
LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 19:17:35 -08:00
|
|
|
|
void GameHandler::useItemById(uint32_t itemId) {
|
|
|
|
|
|
if (itemId == 0) return;
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("useItemById: searching for itemId=", itemId, " in backpack (", inventory.getBackpackSize(), " slots)");
|
|
|
|
|
|
// Search backpack first
|
2026-02-06 19:17:35 -08:00
|
|
|
|
for (int i = 0; i < inventory.getBackpackSize(); i++) {
|
|
|
|
|
|
const auto& slot = inventory.getBackpackSlot(i);
|
|
|
|
|
|
if (!slot.empty() && slot.item.itemId == itemId) {
|
2026-02-17 01:00:04 -08:00
|
|
|
|
LOG_INFO("useItemById: found itemId=", itemId, " at backpack slot ", i);
|
2026-02-06 19:17:35 -08:00
|
|
|
|
useItemBySlot(i);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-17 01:00:04 -08:00
|
|
|
|
// Search bags
|
|
|
|
|
|
for (int bag = 0; bag < inventory.NUM_BAG_SLOTS; bag++) {
|
|
|
|
|
|
int bagSize = inventory.getBagSize(bag);
|
|
|
|
|
|
for (int slot = 0; slot < bagSize; slot++) {
|
|
|
|
|
|
const auto& bagSlot = inventory.getBagSlot(bag, slot);
|
|
|
|
|
|
if (!bagSlot.empty() && bagSlot.item.itemId == itemId) {
|
|
|
|
|
|
LOG_INFO("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
|
|
|
|
|
|
useItemInBag(bag, slot);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_WARNING("useItemById: itemId=", itemId, " not found in inventory");
|
2026-02-06 19:17:35 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
void GameHandler::unstuck() {
|
|
|
|
|
|
if (unstuckCallback_) {
|
|
|
|
|
|
unstuckCallback_();
|
2026-02-08 15:32:04 -08:00
|
|
|
|
addSystemChatMessage("Unstuck: snapped upward. Use /unstuckgy for full teleport.");
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:24:12 -08:00
|
|
|
|
void GameHandler::unstuckGy() {
|
|
|
|
|
|
if (unstuckGyCallback_) {
|
|
|
|
|
|
unstuckGyCallback_();
|
2026-02-08 15:32:04 -08:00
|
|
|
|
addSystemChatMessage("Unstuck: teleported to safe location.");
|
2026-02-08 03:24:12 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
void GameHandler::handleLootResponse(network::Packet& packet) {
|
|
|
|
|
|
if (!LootResponseParser::parse(packet, currentLoot)) return;
|
|
|
|
|
|
lootWindowOpen = true;
|
2026-02-07 14:00:56 -08:00
|
|
|
|
|
|
|
|
|
|
// Query item info so loot window can show names instead of IDs
|
|
|
|
|
|
for (const auto& item : currentLoot.items) {
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 13:22:15 -08:00
|
|
|
|
if (currentLoot.gold > 0) {
|
2026-02-06 23:52:16 -08:00
|
|
|
|
if (state == WorldState::IN_WORLD && socket) {
|
2026-02-06 19:24:44 -08:00
|
|
|
|
// Auto-loot gold by sending CMSG_LOOT_MONEY (server handles the rest)
|
|
|
|
|
|
auto pkt = LootMoneyPacket::build();
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
currentLoot.gold = 0;
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
|
}
|
2026-02-05 13:22:15 -08:00
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLootReleaseResponse(network::Packet& packet) {
|
|
|
|
|
|
(void)packet;
|
|
|
|
|
|
lootWindowOpen = false;
|
|
|
|
|
|
currentLoot = LootResponseData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLootRemoved(network::Packet& packet) {
|
|
|
|
|
|
uint8_t slotIndex = packet.readUInt8();
|
|
|
|
|
|
for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) {
|
|
|
|
|
|
if (it->slotIndex == slotIndex) {
|
|
|
|
|
|
currentLoot.items.erase(it);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGossipMessage(network::Packet& packet) {
|
2026-02-14 19:38:11 -08:00
|
|
|
|
bool ok = packetParsers_ ? packetParsers_->parseGossipMessage(packet, currentGossip)
|
|
|
|
|
|
: GossipMessageParser::parse(packet, currentGossip);
|
|
|
|
|
|
if (!ok) return;
|
2026-02-06 12:08:47 -08:00
|
|
|
|
if (questDetailsOpen) return; // Don't reopen gossip while viewing quest
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
gossipWindowOpen = true;
|
|
|
|
|
|
vendorWindowOpen = false; // Close vendor if gossip opens
|
2026-02-09 01:29:44 -08:00
|
|
|
|
|
2026-02-09 23:53:17 -08:00
|
|
|
|
// Query quest data and update quest log based on gossip quests
|
|
|
|
|
|
for (const auto& questItem : currentGossip.quests) {
|
|
|
|
|
|
// Update quest log based on questIcon:
|
|
|
|
|
|
// questIcon & 0x04 = blue ? (turn-in/reward)
|
|
|
|
|
|
// questIcon & 0x02 = yellow ! (available)
|
|
|
|
|
|
// questIcon & 0x01 = gray ? (incomplete)
|
|
|
|
|
|
bool isCompletable = (questItem.questIcon & 0x04) != 0; // Can turn in
|
|
|
|
|
|
bool isIncomplete = (questItem.questIcon & 0x01) != 0; // Have but incomplete
|
|
|
|
|
|
// Note: questIcon & 0x02 = available (new quest), not added to log yet
|
|
|
|
|
|
|
|
|
|
|
|
// Add or update quest in log
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
for (auto& quest : questLog_) {
|
|
|
|
|
|
if (quest.questId == questItem.questId) {
|
|
|
|
|
|
quest.complete = isCompletable;
|
|
|
|
|
|
quest.title = questItem.title;
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
LOG_INFO("Updated quest ", questItem.questId, " in log: complete=", isCompletable);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!found && (isCompletable || isIncomplete)) {
|
|
|
|
|
|
// Quest is active (either completable or incomplete) - add to log
|
|
|
|
|
|
QuestLogEntry entry;
|
|
|
|
|
|
entry.questId = questItem.questId;
|
|
|
|
|
|
entry.complete = isCompletable;
|
|
|
|
|
|
entry.title = questItem.title;
|
|
|
|
|
|
questLog_.push_back(entry);
|
|
|
|
|
|
LOG_INFO("Added quest ", questItem.questId, " to log: complete=", isCompletable);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:29:44 -08:00
|
|
|
|
// Play NPC greeting voice
|
|
|
|
|
|
if (npcGreetingCallback_ && currentGossip.npcGuid != 0) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(currentGossip.npcGuid);
|
|
|
|
|
|
if (entity) {
|
|
|
|
|
|
glm::vec3 npcPos(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
|
npcGreetingCallback_(currentGossip.npcGuid, npcPos);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGossipComplete(network::Packet& packet) {
|
|
|
|
|
|
(void)packet;
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
|
|
|
|
|
|
|
// Play farewell sound before closing
|
|
|
|
|
|
if (npcFarewellCallback_ && currentGossip.npcGuid != 0) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(currentGossip.npcGuid);
|
|
|
|
|
|
if (entity && entity->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
|
npcFarewellCallback_(currentGossip.npcGuid, pos);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
currentGossip = GossipMessageData{};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleListInventory(network::Packet& packet) {
|
|
|
|
|
|
if (!ListInventoryParser::parse(packet, currentVendorItems)) return;
|
|
|
|
|
|
vendorWindowOpen = true;
|
|
|
|
|
|
gossipWindowOpen = false; // Close gossip if vendor opens
|
2026-02-06 11:59:51 -08:00
|
|
|
|
|
Add comprehensive NPC voice system with interaction and combat sounds
Implements full NPC voice interaction system supporting 6 different sound categories
for all playable races/genders. System loads ~450+ voice clips from MPQ archives.
Voice Categories:
- Greeting: Play on NPC right-click interaction
- Farewell: Play when closing gossip/dialog windows
- Vendor: Play when opening merchant/vendor windows
- Pissed: Play after clicking NPC 5+ times (spam protection)
- Aggro: Play when NPC enters combat with player
- Flee: Play when NPC is fleeing (ready for low-health triggers)
Features:
- Race/gender detection from NPC display IDs via CreatureDisplayInfoExtra.dbc
- Intelligent click tracking for pissed sounds
- Combat sounds use player character vocal files for humanoid NPCs
- Cooldown system prevents voice spam (2s default, combat sounds bypass)
- Generic fallback voices for unsupported NPC types
- 3D positional audio support
Voice Support:
- All playable races: Human, Dwarf, Gnome, Night Elf, Orc, Tauren, Troll, Undead
- Male and female variants for each race
- StandardNPC sounds for social interactions
- Character vocal sounds for combat
Technical Changes:
- Refactored NpcVoiceManager to support multiple sound categories
- Added callbacks: NpcFarewell, NpcVendor, NpcAggro
- Extended voice loading to parse both StandardNPC and Character vocal paths
- Integrated with GameHandler for gossip, vendor, and combat events
- Added detailed voice detection logging for debugging
Also includes:
- Sound manifest files added to docs/ for reference
- Blacksmith hammer pitch increased to 1.6x (was 1.4x)
- Blacksmith volume reduced 30% to 0.25 (was 0.35)
2026-02-09 16:03:51 -08:00
|
|
|
|
// Play vendor sound
|
|
|
|
|
|
if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(currentVendorItems.vendorGuid);
|
|
|
|
|
|
if (entity && entity->getType() == ObjectType::UNIT) {
|
|
|
|
|
|
glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
|
npcVendorCallback_(currentVendorItems.vendorGuid, pos);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 11:59:51 -08:00
|
|
|
|
// Query item info for all vendor items so we can show names
|
|
|
|
|
|
for (const auto& item : currentVendorItems.items) {
|
|
|
|
|
|
queryItemInfo(item.itemId, 0);
|
|
|
|
|
|
}
|
Add gameplay systems: combat, spells, groups, loot, vendors, and UI
Implement ~70 new protocol opcodes across 5 phases while maintaining
full 3.3.5a private server compatibility:
- Phase 1: Server-aware targeting (CMSG_SET_SELECTION), player/creature
name queries, CMSG_SET_ACTIVE_MOVER after login
- Phase 2: Auto-attack, melee/spell damage parsing, health/mana/power
tracking from UPDATE_OBJECT fields, floating combat text
- Phase 3: Spell casting, action bar (12 slots, keys 1-=), cast bar,
cooldown tracking, aura/buff system with cancellation
- Phase 4: Group invite/accept/decline/leave, party frames UI,
/invite chat command
- Phase 5: Loot window, NPC gossip dialog, vendor buy/sell interface
Also: disable debug HUD/panels by default, gate 3D rendering to
IN_GAME state only, fix window resize not updating UI positions.
2026-02-04 10:30:52 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:33:39 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Trainer
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTrainerList(network::Packet& packet) {
|
|
|
|
|
|
if (!TrainerListParser::parse(packet, currentTrainerList_)) return;
|
|
|
|
|
|
trainerWindowOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
|
|
|
|
|
|
2026-02-09 21:59:00 -08:00
|
|
|
|
// Debug: log known spells
|
|
|
|
|
|
LOG_INFO("Known spells count: ", knownSpells.size());
|
2026-02-10 01:24:37 -08:00
|
|
|
|
if (knownSpells.size() <= 50) {
|
2026-02-09 21:59:00 -08:00
|
|
|
|
std::string spellList;
|
|
|
|
|
|
for (uint32_t id : knownSpells) {
|
|
|
|
|
|
if (!spellList.empty()) spellList += ", ";
|
|
|
|
|
|
spellList += std::to_string(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Known spells: ", spellList);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// Check if specific prerequisite spells are known
|
|
|
|
|
|
bool has527 = std::find(knownSpells.begin(), knownSpells.end(), 527u) != knownSpells.end();
|
|
|
|
|
|
bool has25312 = std::find(knownSpells.begin(), knownSpells.end(), 25312u) != knownSpells.end();
|
|
|
|
|
|
LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312);
|
|
|
|
|
|
|
|
|
|
|
|
// Debug: log first few trainer spells to see their state
|
|
|
|
|
|
LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells");
|
|
|
|
|
|
for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) {
|
|
|
|
|
|
const auto& s = currentTrainerList_.spells[i];
|
|
|
|
|
|
LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
|
|
|
|
|
|
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
|
|
|
|
|
|
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
// Ensure caches are populated
|
2026-02-08 14:33:39 -08:00
|
|
|
|
loadSpellNameCache();
|
2026-02-08 14:46:01 -08:00
|
|
|
|
loadSkillLineDbc();
|
|
|
|
|
|
loadSkillLineAbilityDbc();
|
|
|
|
|
|
categorizeTrainerSpells();
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::trainSpell(uint32_t spellId) {
|
2026-02-09 21:59:00 -08:00
|
|
|
|
LOG_INFO("trainSpell called: spellId=", spellId, " state=", (int)state, " socket=", (socket ? "yes" : "no"));
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket) {
|
|
|
|
|
|
LOG_WARNING("trainSpell: Not in world or no socket connection");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Find spell cost in trainer list
|
|
|
|
|
|
uint32_t spellCost = 0;
|
|
|
|
|
|
for (const auto& spell : currentTrainerList_.spells) {
|
|
|
|
|
|
if (spell.spellId == spellId) {
|
|
|
|
|
|
spellCost = spell.spellCost;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Player money: ", playerMoneyCopper_, " copper, spell cost: ", spellCost, " copper");
|
|
|
|
|
|
|
2026-02-09 21:59:00 -08:00
|
|
|
|
LOG_INFO("Sending CMSG_TRAINER_BUY_SPELL: guid=", currentTrainerList_.trainerGuid,
|
2026-02-10 01:24:37 -08:00
|
|
|
|
" spellId=", spellId);
|
2026-02-08 15:03:43 -08:00
|
|
|
|
auto packet = TrainerBuySpellPacket::build(
|
|
|
|
|
|
currentTrainerList_.trainerGuid,
|
|
|
|
|
|
spellId);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
socket->send(packet);
|
2026-02-09 21:59:00 -08:00
|
|
|
|
LOG_INFO("CMSG_TRAINER_BUY_SPELL sent");
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeTrainer() {
|
|
|
|
|
|
trainerWindowOpen_ = false;
|
|
|
|
|
|
currentTrainerList_ = TrainerListData{};
|
2026-02-08 14:46:01 -08:00
|
|
|
|
trainerTabs_.clear();
|
2026-02-08 14:33:39 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::loadSpellNameCache() {
|
|
|
|
|
|
if (spellNameCacheLoaded_) return;
|
|
|
|
|
|
spellNameCacheLoaded_ = true;
|
|
|
|
|
|
|
|
|
|
|
|
auto* am = core::Application::getInstance().getAssetManager();
|
|
|
|
|
|
if (!am || !am->isInitialized()) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto dbc = am->loadDBC("Spell.dbc");
|
|
|
|
|
|
if (!dbc || !dbc->isLoaded()) {
|
|
|
|
|
|
LOG_WARNING("Trainer: Could not load Spell.dbc for spell names");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (dbc->getFieldCount() < 154) {
|
|
|
|
|
|
LOG_WARNING("Trainer: Spell.dbc has too few fields");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
|
2026-02-08 14:33:39 -08:00
|
|
|
|
uint32_t count = dbc->getRecordCount();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
if (id == 0) continue;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
|
|
|
|
|
|
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
|
2026-02-08 14:33:39 -08:00
|
|
|
|
if (!name.empty()) {
|
|
|
|
|
|
spellNameCache_[id] = {std::move(name), std::move(rank)};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
void GameHandler::loadSkillLineAbilityDbc() {
|
|
|
|
|
|
if (skillLineAbilityLoaded_) return;
|
|
|
|
|
|
skillLineAbilityLoaded_ = true;
|
|
|
|
|
|
|
|
|
|
|
|
auto* am = core::Application::getInstance().getAssetManager();
|
|
|
|
|
|
if (!am || !am->isInitialized()) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto slaDbc = am->loadDBC("SkillLineAbility.dbc");
|
|
|
|
|
|
if (slaDbc && slaDbc->isLoaded()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* slaL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLineAbility") : nullptr;
|
2026-02-08 14:46:01 -08:00
|
|
|
|
for (uint32_t i = 0; i < slaDbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
uint32_t skillLineId = slaDbc->getUInt32(i, slaL ? (*slaL)["SkillLineID"] : 1);
|
|
|
|
|
|
uint32_t spellId = slaDbc->getUInt32(i, slaL ? (*slaL)["SpellID"] : 2);
|
2026-02-08 14:46:01 -08:00
|
|
|
|
if (spellId > 0 && skillLineId > 0) {
|
|
|
|
|
|
spellToSkillLine_[spellId] = skillLineId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Trainer: Loaded ", spellToSkillLine_.size(), " skill line abilities");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::categorizeTrainerSpells() {
|
|
|
|
|
|
trainerTabs_.clear();
|
|
|
|
|
|
|
|
|
|
|
|
static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7;
|
|
|
|
|
|
|
|
|
|
|
|
// Group spells by skill line (category 7 = class spec tabs)
|
|
|
|
|
|
std::map<uint32_t, std::vector<const TrainerSpell*>> specialtySpells;
|
|
|
|
|
|
std::vector<const TrainerSpell*> generalSpells;
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& spell : currentTrainerList_.spells) {
|
|
|
|
|
|
auto slIt = spellToSkillLine_.find(spell.spellId);
|
|
|
|
|
|
if (slIt != spellToSkillLine_.end()) {
|
|
|
|
|
|
uint32_t skillLineId = slIt->second;
|
|
|
|
|
|
auto catIt = skillLineCategories_.find(skillLineId);
|
|
|
|
|
|
if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) {
|
|
|
|
|
|
specialtySpells[skillLineId].push_back(&spell);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
generalSpells.push_back(&spell);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort by spell name within each group
|
|
|
|
|
|
auto byName = [this](const TrainerSpell* a, const TrainerSpell* b) {
|
|
|
|
|
|
return getSpellName(a->spellId) < getSpellName(b->spellId);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Build named tabs sorted alphabetically
|
|
|
|
|
|
std::vector<std::pair<std::string, std::vector<const TrainerSpell*>>> named;
|
|
|
|
|
|
for (auto& [skillLineId, spells] : specialtySpells) {
|
|
|
|
|
|
auto nameIt = skillLineNames_.find(skillLineId);
|
|
|
|
|
|
std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Specialty";
|
|
|
|
|
|
std::sort(spells.begin(), spells.end(), byName);
|
|
|
|
|
|
named.push_back({std::move(tabName), std::move(spells)});
|
|
|
|
|
|
}
|
|
|
|
|
|
std::sort(named.begin(), named.end(),
|
|
|
|
|
|
[](const auto& a, const auto& b) { return a.first < b.first; });
|
|
|
|
|
|
|
|
|
|
|
|
for (auto& [name, spells] : named) {
|
|
|
|
|
|
trainerTabs_.push_back({std::move(name), std::move(spells)});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// General tab last
|
|
|
|
|
|
if (!generalSpells.empty()) {
|
|
|
|
|
|
std::sort(generalSpells.begin(), generalSpells.end(), byName);
|
|
|
|
|
|
trainerTabs_.push_back({"General", std::move(generalSpells)});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Trainer: Categorized into ", trainerTabs_.size(), " tabs");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
void GameHandler::loadTalentDbc() {
|
|
|
|
|
|
if (talentDbcLoaded_) return;
|
|
|
|
|
|
talentDbcLoaded_ = true;
|
|
|
|
|
|
|
|
|
|
|
|
auto* am = core::Application::getInstance().getAssetManager();
|
|
|
|
|
|
if (!am || !am->isInitialized()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Load Talent.dbc
|
|
|
|
|
|
auto talentDbc = am->loadDBC("Talent.dbc");
|
|
|
|
|
|
if (talentDbc && talentDbc->isLoaded()) {
|
|
|
|
|
|
// Talent.dbc structure (WoW 3.3.5a):
|
|
|
|
|
|
// 0: TalentID
|
|
|
|
|
|
// 1: TalentTabID
|
|
|
|
|
|
// 2: Row (tier)
|
|
|
|
|
|
// 3: Column
|
|
|
|
|
|
// 4-8: RankID[0-4] (spell IDs for ranks 1-5)
|
|
|
|
|
|
// 9-11: PrereqTalent[0-2]
|
|
|
|
|
|
// 12-14: PrereqRank[0-2]
|
|
|
|
|
|
// (other fields less relevant for basic functionality)
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* talL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Talent") : nullptr;
|
|
|
|
|
|
const uint32_t tID = talL ? (*talL)["ID"] : 0;
|
|
|
|
|
|
const uint32_t tTabID = talL ? (*talL)["TabID"] : 1;
|
|
|
|
|
|
const uint32_t tRow = talL ? (*talL)["Row"] : 2;
|
|
|
|
|
|
const uint32_t tCol = talL ? (*talL)["Column"] : 3;
|
|
|
|
|
|
const uint32_t tRank0 = talL ? (*talL)["RankSpell0"] : 4;
|
|
|
|
|
|
const uint32_t tPrereq0 = talL ? (*talL)["PrereqTalent0"] : 9;
|
|
|
|
|
|
const uint32_t tPrereqR0 = talL ? (*talL)["PrereqRank0"] : 12;
|
|
|
|
|
|
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
uint32_t count = talentDbc->getRecordCount();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
TalentEntry entry;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.talentId = talentDbc->getUInt32(i, tID);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
if (entry.talentId == 0) continue;
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.tabId = talentDbc->getUInt32(i, tTabID);
|
|
|
|
|
|
entry.row = static_cast<uint8_t>(talentDbc->getUInt32(i, tRow));
|
|
|
|
|
|
entry.column = static_cast<uint8_t>(talentDbc->getUInt32(i, tCol));
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
|
|
|
|
|
// Rank spells (1-5 ranks)
|
|
|
|
|
|
for (int r = 0; r < 5; ++r) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.rankSpells[r] = talentDbc->getUInt32(i, tRank0 + r);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prerequisites
|
|
|
|
|
|
for (int p = 0; p < 3; ++p) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.prereqTalent[p] = talentDbc->getUInt32(i, tPrereq0 + p);
|
|
|
|
|
|
entry.prereqRank[p] = static_cast<uint8_t>(talentDbc->getUInt32(i, tPrereqR0 + p));
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate max rank
|
|
|
|
|
|
entry.maxRank = 0;
|
|
|
|
|
|
for (int r = 0; r < 5; ++r) {
|
|
|
|
|
|
if (entry.rankSpells[r] != 0) {
|
|
|
|
|
|
entry.maxRank = r + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
talentCache_[entry.talentId] = entry;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Loaded ", talentCache_.size(), " talents from Talent.dbc");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Could not load Talent.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load TalentTab.dbc
|
|
|
|
|
|
auto tabDbc = am->loadDBC("TalentTab.dbc");
|
|
|
|
|
|
if (tabDbc && tabDbc->isLoaded()) {
|
|
|
|
|
|
// TalentTab.dbc structure (WoW 3.3.5a):
|
|
|
|
|
|
// 0: TalentTabID
|
|
|
|
|
|
// 1-17: Name (16 localized strings + flags = 17 fields)
|
|
|
|
|
|
// 18: SpellIconID
|
|
|
|
|
|
// 19: RaceMask
|
|
|
|
|
|
// 20: ClassMask
|
|
|
|
|
|
// 21: PetTalentMask
|
|
|
|
|
|
// 22: OrderIndex
|
|
|
|
|
|
// 23-39: BackgroundFile (16 localized strings + flags = 17 fields)
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* ttL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TalentTab") : nullptr;
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
uint32_t count = tabDbc->getRecordCount();
|
|
|
|
|
|
for (uint32_t i = 0; i < count; ++i) {
|
|
|
|
|
|
TalentTabEntry entry;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.tabId = tabDbc->getUInt32(i, ttL ? (*ttL)["ID"] : 0);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
if (entry.tabId == 0) continue;
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
entry.name = tabDbc->getString(i, ttL ? (*ttL)["Name"] : 1);
|
|
|
|
|
|
entry.classMask = tabDbc->getUInt32(i, ttL ? (*ttL)["ClassMask"] : 20);
|
|
|
|
|
|
entry.orderIndex = static_cast<uint8_t>(tabDbc->getUInt32(i, ttL ? (*ttL)["OrderIndex"] : 22));
|
|
|
|
|
|
entry.backgroundFile = tabDbc->getString(i, ttL ? (*ttL)["BackgroundFile"] : 23);
|
Implement complete talent system with dual spec support
Network Protocol:
- Add SMSG_TALENTS_INFO (0x4C0) packet parsing for talent data
- Add CMSG_LEARN_TALENT (0x251) to request learning talents
- Add MSG_TALENT_WIPE_CONFIRM (0x2AB) opcode for spec switching
- Parse talent spec, unspent points, and learned talent ranks
DBC Parsing:
- Load Talent.dbc: talent grid positions, ranks, prerequisites, spell IDs
- Load TalentTab.dbc: talent tree definitions with correct field indices
- Fix localized string field handling (17 fields per string)
- Load Spell.dbc and SpellIcon.dbc for talent icons and tooltips
- Class mask filtering using bitwise operations (1 << (class - 1))
UI Implementation:
- Complete talent tree UI with tabbed interface for specs
- Display talent icons from spell data with proper tinting/borders
- Enhanced tooltips: spell name, rank, current/next descriptions, prereqs
- Visual states: green (maxed), yellow (partial), white (available), gray (locked)
- Tier unlock system (5 points per tier requirement)
- Rank overlay on icons with shadow text
- Click to learn talents with validation
Dual Spec Support:
- Store unspent points and learned talents per spec (0 and 1)
- Track active spec and display its talents
- Spec switching UI with buttons for Spec 1/Spec 2
- Handle both SMSG_TALENTS_INFO packets from server at login
- Display unspent points for both specs in header
- Independent talent trees for each specialization
2026-02-10 02:00:13 -08:00
|
|
|
|
|
|
|
|
|
|
talentTabCache_[entry.tabId] = entry;
|
|
|
|
|
|
|
|
|
|
|
|
// Log first few tabs to debug class mask issue
|
|
|
|
|
|
if (talentTabCache_.size() <= 10) {
|
|
|
|
|
|
LOG_INFO(" Tab ", entry.tabId, ": ", entry.name, " (classMask=0x", std::hex, entry.classMask, std::dec, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Loaded ", talentTabCache_.size(), " talent tabs from TalentTab.dbc");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Could not load TalentTab.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:33:39 -08:00
|
|
|
|
static const std::string EMPTY_STRING;
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& GameHandler::getSpellName(uint32_t spellId) const {
|
|
|
|
|
|
auto it = spellNameCache_.find(spellId);
|
|
|
|
|
|
return (it != spellNameCache_.end()) ? it->second.name : EMPTY_STRING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& GameHandler::getSpellRank(uint32_t spellId) const {
|
|
|
|
|
|
auto it = spellNameCache_.find(spellId);
|
|
|
|
|
|
return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:46:01 -08:00
|
|
|
|
const std::string& GameHandler::getSkillLineName(uint32_t spellId) const {
|
|
|
|
|
|
auto slIt = spellToSkillLine_.find(spellId);
|
|
|
|
|
|
if (slIt == spellToSkillLine_.end()) return EMPTY_STRING;
|
|
|
|
|
|
auto nameIt = skillLineNames_.find(slIt->second);
|
|
|
|
|
|
return (nameIt != skillLineNames_.end()) ? nameIt->second : EMPTY_STRING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 12:01:03 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Single-player local combat
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 12:07:58 -08:00
|
|
|
|
|
2026-02-05 12:01:03 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 12:07:58 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// XP tracking
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
// WotLK 3.3.5a XP-to-next-level table (from player_xp_for_level)
|
|
|
|
|
|
static const uint32_t XP_TABLE[] = {
|
|
|
|
|
|
0, // level 0 (unused)
|
|
|
|
|
|
400, 900, 1400, 2100, 2800, 3600, 4500, 5400, 6500, 7600, // 1-10
|
|
|
|
|
|
8700, 9800, 11000, 12300, 13600, 15000, 16400, 17800, 19300, 20800, // 11-20
|
|
|
|
|
|
22400, 24000, 25500, 27200, 28900, 30500, 32200, 33900, 36300, 38800, // 21-30
|
|
|
|
|
|
41600, 44600, 48000, 51400, 55000, 58700, 62400, 66200, 70200, 74300, // 31-40
|
|
|
|
|
|
78500, 82800, 87100, 91600, 96300, 101000, 105800, 110700, 115700, 120900, // 41-50
|
|
|
|
|
|
126100, 131500, 137000, 142500, 148200, 154000, 159900, 165800, 172000, 290000, // 51-60
|
|
|
|
|
|
317000, 349000, 386000, 428000, 475000, 527000, 585000, 648000, 717000, 1523800, // 61-70
|
|
|
|
|
|
1539600, 1555700, 1571800, 1587900, 1604200, 1620700, 1637400, 1653900, 1670800 // 71-79
|
|
|
|
|
|
};
|
|
|
|
|
|
static constexpr uint32_t XP_TABLE_SIZE = sizeof(XP_TABLE) / sizeof(XP_TABLE[0]);
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t GameHandler::xpForLevel(uint32_t level) {
|
|
|
|
|
|
if (level == 0 || level >= XP_TABLE_SIZE) return 0;
|
|
|
|
|
|
return XP_TABLE[level];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t GameHandler::killXp(uint32_t playerLevel, uint32_t victimLevel) {
|
|
|
|
|
|
if (playerLevel == 0 || victimLevel == 0) return 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Gray level check (too low = 0 XP)
|
|
|
|
|
|
int32_t grayLevel;
|
|
|
|
|
|
if (playerLevel <= 5) grayLevel = 0;
|
|
|
|
|
|
else if (playerLevel <= 39) grayLevel = static_cast<int32_t>(playerLevel) - 5 - static_cast<int32_t>(playerLevel) / 10;
|
|
|
|
|
|
else if (playerLevel <= 59) grayLevel = static_cast<int32_t>(playerLevel) - 1 - static_cast<int32_t>(playerLevel) / 5;
|
|
|
|
|
|
else grayLevel = static_cast<int32_t>(playerLevel) - 9;
|
|
|
|
|
|
|
|
|
|
|
|
if (static_cast<int32_t>(victimLevel) <= grayLevel) return 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Base XP = 45 + 5 * victimLevel (WoW-like ZeroDifference formula)
|
|
|
|
|
|
uint32_t baseXp = 45 + 5 * victimLevel;
|
|
|
|
|
|
|
|
|
|
|
|
// Level difference multiplier
|
|
|
|
|
|
int32_t diff = static_cast<int32_t>(victimLevel) - static_cast<int32_t>(playerLevel);
|
|
|
|
|
|
float multiplier = 1.0f + diff * 0.05f;
|
|
|
|
|
|
if (multiplier < 0.1f) multiplier = 0.1f;
|
|
|
|
|
|
if (multiplier > 2.0f) multiplier = 2.0f;
|
|
|
|
|
|
|
|
|
|
|
|
return static_cast<uint32_t>(baseXp * multiplier);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleXpGain(network::Packet& packet) {
|
|
|
|
|
|
XpGainData data;
|
|
|
|
|
|
if (!XpGainParser::parse(packet, data)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Server already updates PLAYER_XP via update fields,
|
|
|
|
|
|
// but we can show combat text for XP gains
|
|
|
|
|
|
addCombatText(CombatTextEntry::HEAL, static_cast<int32_t>(data.totalXp), 0, true);
|
2026-02-05 13:22:15 -08:00
|
|
|
|
|
|
|
|
|
|
std::string msg = "You gain " + std::to_string(data.totalXp) + " experience.";
|
|
|
|
|
|
if (data.groupBonus > 0) {
|
|
|
|
|
|
msg += " (+" + std::to_string(data.groupBonus) + " group bonus)";
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:13:48 -08:00
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
|
void GameHandler::addMoneyCopper(uint32_t amount) {
|
|
|
|
|
|
if (amount == 0) return;
|
|
|
|
|
|
playerMoneyCopper_ += amount;
|
|
|
|
|
|
uint32_t gold = amount / 10000;
|
|
|
|
|
|
uint32_t silver = (amount / 100) % 100;
|
|
|
|
|
|
uint32_t copper = amount % 100;
|
|
|
|
|
|
std::string msg = "You receive ";
|
|
|
|
|
|
msg += std::to_string(gold) + "g ";
|
|
|
|
|
|
msg += std::to_string(silver) + "s ";
|
|
|
|
|
|
msg += std::to_string(copper) + "c.";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 13:22:15 -08:00
|
|
|
|
void GameHandler::addSystemChatMessage(const std::string& message) {
|
|
|
|
|
|
if (message.empty()) return;
|
|
|
|
|
|
MessageChatData msg;
|
|
|
|
|
|
msg.type = ChatType::SYSTEM;
|
|
|
|
|
|
msg.language = ChatLanguage::UNIVERSAL;
|
|
|
|
|
|
msg.message = message;
|
|
|
|
|
|
addLocalChatMessage(msg);
|
2026-02-05 12:07:58 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:43:32 -08:00
|
|
|
|
// ============================================================
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Teleport Handler
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleTeleportAck(network::Packet& packet) {
|
|
|
|
|
|
// MSG_MOVE_TELEPORT_ACK (server→client): packedGuid + u32 counter + u32 time
|
|
|
|
|
|
// followed by movement info with the new position
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) {
|
|
|
|
|
|
LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t counter = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
// Read the movement info embedded in the teleport
|
|
|
|
|
|
// Format: u32 flags, u16 flags2, u32 time, float x, float y, float z, float o
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4 + 2 + 4 + 4 * 4) {
|
|
|
|
|
|
LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
packet.readUInt32(); // moveFlags
|
|
|
|
|
|
packet.readUInt16(); // moveFlags2
|
|
|
|
|
|
uint32_t moveTime = packet.readUInt32();
|
|
|
|
|
|
float serverX = packet.readFloat();
|
|
|
|
|
|
float serverY = packet.readFloat();
|
|
|
|
|
|
float serverZ = packet.readFloat();
|
|
|
|
|
|
float orientation = packet.readFloat();
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("MSG_MOVE_TELEPORT_ACK: guid=0x", std::hex, guid, std::dec,
|
|
|
|
|
|
" counter=", counter,
|
|
|
|
|
|
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")");
|
|
|
|
|
|
|
|
|
|
|
|
// Update our position
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ));
|
|
|
|
|
|
movementInfo.x = canonical.x;
|
|
|
|
|
|
movementInfo.y = canonical.y;
|
|
|
|
|
|
movementInfo.z = canonical.z;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Send the ack back to the server
|
|
|
|
|
|
// Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time
|
|
|
|
|
|
if (socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK));
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// Write packed guid
|
|
|
|
|
|
uint8_t mask = 0;
|
|
|
|
|
|
uint8_t bytes[8];
|
|
|
|
|
|
int byteCount = 0;
|
|
|
|
|
|
uint64_t g = playerGuid;
|
|
|
|
|
|
for (int i = 0; i < 8; i++) {
|
|
|
|
|
|
uint8_t b = static_cast<uint8_t>(g & 0xFF);
|
|
|
|
|
|
g >>= 8;
|
|
|
|
|
|
if (b != 0) {
|
|
|
|
|
|
mask |= (1 << i);
|
|
|
|
|
|
bytes[byteCount++] = b;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ack.writeUInt8(mask);
|
|
|
|
|
|
for (int i = 0; i < byteCount; i++) {
|
|
|
|
|
|
ack.writeUInt8(bytes[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
ack.writeUInt32(counter);
|
|
|
|
|
|
ack.writeUInt32(moveTime);
|
|
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
LOG_INFO("Sent MSG_MOVE_TELEPORT_ACK response");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 02:23:41 -08:00
|
|
|
|
// Notify application of teleport — the callback decides whether to do
|
|
|
|
|
|
// a full world reload (map change) or just update position (same map).
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (worldEntryCallback_) {
|
|
|
|
|
|
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
void GameHandler::handleNewWorld(network::Packet& packet) {
|
|
|
|
|
|
// SMSG_NEW_WORLD: uint32 mapId, float x, y, z, orientation
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 20) {
|
|
|
|
|
|
LOG_WARNING("SMSG_NEW_WORLD too short");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t mapId = packet.readUInt32();
|
|
|
|
|
|
float serverX = packet.readFloat();
|
|
|
|
|
|
float serverY = packet.readFloat();
|
|
|
|
|
|
float serverZ = packet.readFloat();
|
|
|
|
|
|
float orientation = packet.readFloat();
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId,
|
|
|
|
|
|
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")",
|
|
|
|
|
|
" orient=", orientation);
|
|
|
|
|
|
|
|
|
|
|
|
currentMapId_ = mapId;
|
|
|
|
|
|
|
|
|
|
|
|
// Update player position
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ));
|
|
|
|
|
|
movementInfo.x = canonical.x;
|
|
|
|
|
|
movementInfo.y = canonical.y;
|
|
|
|
|
|
movementInfo.z = canonical.z;
|
2026-02-12 15:08:21 -08:00
|
|
|
|
movementInfo.orientation = core::coords::serverToCanonicalYaw(orientation);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
movementInfo.flags = 0;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
resurrectPending_ = false;
|
|
|
|
|
|
resurrectRequestPending_ = false;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
taxiStartGrace_ = 0.0f;
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
if (mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Clear world state for the new map
|
|
|
|
|
|
entityManager.clear();
|
|
|
|
|
|
hostileAttackers_.clear();
|
|
|
|
|
|
stopAutoAttack();
|
|
|
|
|
|
casting = false;
|
|
|
|
|
|
currentCastSpellId = 0;
|
|
|
|
|
|
castTimeRemaining = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready
|
|
|
|
|
|
if (socket) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
|
2026-02-08 03:05:38 -08:00
|
|
|
|
socket->send(ack);
|
|
|
|
|
|
LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reload terrain at new position
|
|
|
|
|
|
if (worldEntryCallback_) {
|
|
|
|
|
|
worldEntryCallback_(mapId, serverX, serverY, serverZ);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Taxi / Flight Path Handlers
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::loadTaxiDbc() {
|
|
|
|
|
|
if (taxiDbcLoaded_) return;
|
|
|
|
|
|
taxiDbcLoaded_ = true;
|
|
|
|
|
|
|
|
|
|
|
|
auto* am = core::Application::getInstance().getAssetManager();
|
|
|
|
|
|
if (!am || !am->isInitialized()) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto nodesDbc = am->loadDBC("TaxiNodes.dbc");
|
|
|
|
|
|
if (nodesDbc && nodesDbc->isLoaded()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* tnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiNodes") : nullptr;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
uint32_t fieldCount = nodesDbc->getFieldCount();
|
2026-02-07 16:59:20 -08:00
|
|
|
|
for (uint32_t i = 0; i < nodesDbc->getRecordCount(); i++) {
|
|
|
|
|
|
TaxiNode node;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
node.id = nodesDbc->getUInt32(i, tnL ? (*tnL)["ID"] : 0);
|
|
|
|
|
|
node.mapId = nodesDbc->getUInt32(i, tnL ? (*tnL)["MapID"] : 1);
|
|
|
|
|
|
node.x = nodesDbc->getFloat(i, tnL ? (*tnL)["X"] : 2);
|
|
|
|
|
|
node.y = nodesDbc->getFloat(i, tnL ? (*tnL)["Y"] : 3);
|
|
|
|
|
|
node.z = nodesDbc->getFloat(i, tnL ? (*tnL)["Z"] : 4);
|
|
|
|
|
|
node.name = nodesDbc->getString(i, tnL ? (*tnL)["Name"] : 5);
|
|
|
|
|
|
const uint32_t mountAllianceField = tnL ? (*tnL)["MountDisplayIdAlliance"] : 22;
|
|
|
|
|
|
const uint32_t mountHordeField = tnL ? (*tnL)["MountDisplayIdHorde"] : 23;
|
|
|
|
|
|
const uint32_t mountAllianceFB = tnL ? (*tnL)["MountDisplayIdAllianceFallback"] : 20;
|
|
|
|
|
|
const uint32_t mountHordeFB = tnL ? (*tnL)["MountDisplayIdHordeFallback"] : 21;
|
|
|
|
|
|
if (fieldCount > mountHordeField) {
|
|
|
|
|
|
node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, mountAllianceField);
|
|
|
|
|
|
node.mountDisplayIdHorde = nodesDbc->getUInt32(i, mountHordeField);
|
|
|
|
|
|
if (node.mountDisplayIdAlliance == 0 && node.mountDisplayIdHorde == 0 && fieldCount > mountHordeFB) {
|
|
|
|
|
|
node.mountDisplayIdAlliance = nodesDbc->getUInt32(i, mountAllianceFB);
|
|
|
|
|
|
node.mountDisplayIdHorde = nodesDbc->getUInt32(i, mountHordeFB);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (node.id > 0) {
|
|
|
|
|
|
taxiNodes_[node.id] = std::move(node);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (node.id == 195) {
|
|
|
|
|
|
std::string fields;
|
|
|
|
|
|
for (uint32_t f = 0; f < fieldCount; f++) {
|
|
|
|
|
|
fields += std::to_string(f) + ":" + std::to_string(nodesDbc->getUInt32(i, f)) + " ";
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("TaxiNodes[195] fields: ", fields);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Loaded ", taxiNodes_.size(), " taxi nodes from TaxiNodes.dbc");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Could not load TaxiNodes.dbc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto pathDbc = am->loadDBC("TaxiPath.dbc");
|
|
|
|
|
|
if (pathDbc && pathDbc->isLoaded()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* tpL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPath") : nullptr;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
for (uint32_t i = 0; i < pathDbc->getRecordCount(); i++) {
|
|
|
|
|
|
TaxiPathEdge edge;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
edge.pathId = pathDbc->getUInt32(i, tpL ? (*tpL)["ID"] : 0);
|
|
|
|
|
|
edge.fromNode = pathDbc->getUInt32(i, tpL ? (*tpL)["FromNode"] : 1);
|
|
|
|
|
|
edge.toNode = pathDbc->getUInt32(i, tpL ? (*tpL)["ToNode"] : 2);
|
|
|
|
|
|
edge.cost = pathDbc->getUInt32(i, tpL ? (*tpL)["Cost"] : 3);
|
2026-02-07 16:59:20 -08:00
|
|
|
|
taxiPathEdges_.push_back(edge);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Loaded ", taxiPathEdges_.size(), " taxi path edges from TaxiPath.dbc");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Could not load TaxiPath.dbc");
|
|
|
|
|
|
}
|
2026-02-08 21:32:38 -08:00
|
|
|
|
|
|
|
|
|
|
auto pathNodeDbc = am->loadDBC("TaxiPathNode.dbc");
|
|
|
|
|
|
if (pathNodeDbc && pathNodeDbc->isLoaded()) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* tpnL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("TaxiPathNode") : nullptr;
|
2026-02-08 21:32:38 -08:00
|
|
|
|
for (uint32_t i = 0; i < pathNodeDbc->getRecordCount(); i++) {
|
|
|
|
|
|
TaxiPathNode node;
|
2026-02-12 22:56:36 -08:00
|
|
|
|
node.id = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["ID"] : 0);
|
|
|
|
|
|
node.pathId = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["PathID"] : 1);
|
|
|
|
|
|
node.nodeIndex = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["NodeIndex"] : 2);
|
|
|
|
|
|
node.mapId = pathNodeDbc->getUInt32(i, tpnL ? (*tpnL)["MapID"] : 3);
|
|
|
|
|
|
node.x = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["X"] : 4);
|
|
|
|
|
|
node.y = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["Y"] : 5);
|
|
|
|
|
|
node.z = pathNodeDbc->getFloat(i, tpnL ? (*tpnL)["Z"] : 6);
|
2026-02-08 21:32:38 -08:00
|
|
|
|
taxiPathNodes_[node.pathId].push_back(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Sort waypoints by nodeIndex for each path
|
|
|
|
|
|
for (auto& [pathId, nodes] : taxiPathNodes_) {
|
|
|
|
|
|
std::sort(nodes.begin(), nodes.end(),
|
|
|
|
|
|
[](const TaxiPathNode& a, const TaxiPathNode& b) {
|
|
|
|
|
|
return a.nodeIndex < b.nodeIndex;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Loaded ", pathNodeDbc->getRecordCount(), " taxi path waypoints from TaxiPathNode.dbc");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("Could not load TaxiPathNode.dbc");
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleShowTaxiNodes(network::Packet& packet) {
|
|
|
|
|
|
ShowTaxiNodesData data;
|
|
|
|
|
|
if (!ShowTaxiNodesParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_SHOWTAXINODES");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadTaxiDbc();
|
|
|
|
|
|
|
2026-02-07 17:59:40 -08:00
|
|
|
|
// Detect newly discovered flight paths by comparing with stored mask
|
|
|
|
|
|
if (taxiMaskInitialized_) {
|
|
|
|
|
|
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
|
|
|
|
|
|
uint32_t newBits = data.nodeMask[i] & ~knownTaxiMask_[i];
|
|
|
|
|
|
if (newBits == 0) continue;
|
|
|
|
|
|
for (uint32_t bit = 0; bit < 32; ++bit) {
|
|
|
|
|
|
if (newBits & (1u << bit)) {
|
2026-02-07 19:44:03 -08:00
|
|
|
|
uint32_t nodeId = i * 32 + bit + 1;
|
2026-02-07 17:59:40 -08:00
|
|
|
|
auto it = taxiNodes_.find(nodeId);
|
|
|
|
|
|
if (it != taxiNodes_.end()) {
|
|
|
|
|
|
addSystemChatMessage("Discovered flight path: " + it->second.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update stored mask
|
|
|
|
|
|
for (uint32_t i = 0; i < TLK_TAXI_MASK_SIZE; ++i) {
|
|
|
|
|
|
knownTaxiMask_[i] = data.nodeMask[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMaskInitialized_ = true;
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
currentTaxiData_ = data;
|
|
|
|
|
|
taxiNpcGuid_ = data.npcGuid;
|
|
|
|
|
|
taxiWindowOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false;
|
2026-02-07 19:04:15 -08:00
|
|
|
|
buildTaxiCostMap();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
auto it = taxiNodes_.find(data.nearestNode);
|
|
|
|
|
|
if (it != taxiNodes_.end()) {
|
|
|
|
|
|
LOG_INFO("Taxi node ", data.nearestNode, " mounts: A=", it->second.mountDisplayIdAlliance,
|
|
|
|
|
|
" H=", it->second.mountDisplayIdHorde);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_INFO("Taxi window opened, nearest node=", data.nearestNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
void GameHandler::applyTaxiMountForCurrentNode() {
|
|
|
|
|
|
if (taxiMountActive_ || !mountCallback_) return;
|
|
|
|
|
|
auto it = taxiNodes_.find(currentTaxiData_.nearestNode);
|
2026-02-14 21:18:36 -08:00
|
|
|
|
if (it == taxiNodes_.end()) {
|
|
|
|
|
|
// Node not in DBC (custom server nodes, missing data) — use hardcoded fallback.
|
|
|
|
|
|
bool isAlliance = true;
|
|
|
|
|
|
switch (playerRace_) {
|
|
|
|
|
|
case Race::ORC: case Race::UNDEAD: case Race::TAUREN: case Race::TROLL:
|
|
|
|
|
|
case Race::GOBLIN: case Race::BLOOD_ELF:
|
|
|
|
|
|
isAlliance = false; break;
|
|
|
|
|
|
default: break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t mountId = isAlliance ? 1210u : 1310u;
|
|
|
|
|
|
taxiMountDisplayId_ = mountId;
|
|
|
|
|
|
taxiMountActive_ = true;
|
|
|
|
|
|
LOG_INFO("Taxi mount fallback (node ", currentTaxiData_.nearestNode, " not in DBC): displayId=", mountId);
|
|
|
|
|
|
mountCallback_(mountId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
bool isAlliance = true;
|
|
|
|
|
|
switch (playerRace_) {
|
|
|
|
|
|
case Race::ORC:
|
|
|
|
|
|
case Race::UNDEAD:
|
|
|
|
|
|
case Race::TAUREN:
|
|
|
|
|
|
case Race::TROLL:
|
|
|
|
|
|
case Race::GOBLIN:
|
|
|
|
|
|
case Race::BLOOD_ELF:
|
|
|
|
|
|
isAlliance = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
isAlliance = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint32_t mountId = isAlliance ? it->second.mountDisplayIdAlliance
|
|
|
|
|
|
: it->second.mountDisplayIdHorde;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (mountId == 541) mountId = 0; // Placeholder/invalid in some DBC sets
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (mountId == 0) {
|
|
|
|
|
|
mountId = isAlliance ? it->second.mountDisplayIdHorde
|
|
|
|
|
|
: it->second.mountDisplayIdAlliance;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (mountId == 541) mountId = 0;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
if (mountId == 0) {
|
|
|
|
|
|
auto& app = core::Application::getInstance();
|
|
|
|
|
|
uint32_t gryphonId = app.getGryphonDisplayId();
|
|
|
|
|
|
uint32_t wyvernId = app.getWyvernDisplayId();
|
|
|
|
|
|
if (isAlliance && gryphonId != 0) mountId = gryphonId;
|
|
|
|
|
|
if (!isAlliance && wyvernId != 0) mountId = wyvernId;
|
|
|
|
|
|
if (mountId == 0) {
|
|
|
|
|
|
mountId = (isAlliance ? wyvernId : gryphonId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mountId == 0) {
|
|
|
|
|
|
// Fallback: any non-zero mount display from the node.
|
|
|
|
|
|
if (it->second.mountDisplayIdAlliance != 0) mountId = it->second.mountDisplayIdAlliance;
|
|
|
|
|
|
else if (it->second.mountDisplayIdHorde != 0) mountId = it->second.mountDisplayIdHorde;
|
|
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (mountId == 0) {
|
|
|
|
|
|
// 3.3.5a fallback display IDs (real CreatureDisplayInfo entries).
|
|
|
|
|
|
// Alliance taxi gryphons commonly use 1210-1213.
|
|
|
|
|
|
// Horde taxi wyverns commonly use 1310-1312.
|
|
|
|
|
|
static const uint32_t kAllianceTaxiDisplays[] = {1210u, 1211u, 1212u, 1213u};
|
|
|
|
|
|
static const uint32_t kHordeTaxiDisplays[] = {1310u, 1311u, 1312u};
|
|
|
|
|
|
mountId = isAlliance ? kAllianceTaxiDisplays[0] : kHordeTaxiDisplays[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Last resort legacy fallback.
|
|
|
|
|
|
if (mountId == 0) {
|
2026-02-08 03:05:38 -08:00
|
|
|
|
mountId = isAlliance ? 30412u : 30413u;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mountId != 0) {
|
|
|
|
|
|
taxiMountDisplayId_ = mountId;
|
|
|
|
|
|
taxiMountActive_ = true;
|
|
|
|
|
|
LOG_INFO("Taxi mount apply: displayId=", mountId);
|
|
|
|
|
|
mountCallback_(mountId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::startClientTaxiPath(const std::vector<uint32_t>& pathNodes) {
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiClientIndex_ = 0;
|
|
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
taxiClientSegmentProgress_ = 0.0f;
|
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
|
// Build full spline path using TaxiPathNode waypoints (not just node positions)
|
|
|
|
|
|
for (size_t i = 0; i + 1 < pathNodes.size(); i++) {
|
|
|
|
|
|
uint32_t fromNode = pathNodes[i];
|
|
|
|
|
|
uint32_t toNode = pathNodes[i + 1];
|
|
|
|
|
|
// Find the pathId connecting these nodes
|
|
|
|
|
|
uint32_t pathId = 0;
|
|
|
|
|
|
for (const auto& edge : taxiPathEdges_) {
|
|
|
|
|
|
if (edge.fromNode == fromNode && edge.toNode == toNode) {
|
|
|
|
|
|
pathId = edge.pathId;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pathId == 0) {
|
|
|
|
|
|
LOG_WARNING("No taxi path found from node ", fromNode, " to ", toNode);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Get spline waypoints for this path segment
|
|
|
|
|
|
auto pathIt = taxiPathNodes_.find(pathId);
|
|
|
|
|
|
if (pathIt != taxiPathNodes_.end()) {
|
|
|
|
|
|
for (const auto& wpNode : pathIt->second) {
|
|
|
|
|
|
glm::vec3 serverPos(wpNode.x, wpNode.y, wpNode.z);
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
|
|
|
|
|
|
taxiClientPath_.push_back(canonical);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_WARNING("No spline waypoints found for taxi pathId ", pathId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (taxiClientPath_.size() < 2) {
|
|
|
|
|
|
// Fallback: use TaxiNodes directly when TaxiPathNode spline data is missing.
|
|
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
for (uint32_t nodeId : pathNodes) {
|
|
|
|
|
|
auto nodeIt = taxiNodes_.find(nodeId);
|
|
|
|
|
|
if (nodeIt == taxiNodes_.end()) continue;
|
|
|
|
|
|
glm::vec3 serverPos(nodeIt->second.x, nodeIt->second.y, nodeIt->second.z);
|
|
|
|
|
|
taxiClientPath_.push_back(core::coords::serverToCanonical(serverPos));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
|
if (taxiClientPath_.size() < 2) {
|
|
|
|
|
|
LOG_WARNING("Taxi path too short: ", taxiClientPath_.size(), " waypoints");
|
|
|
|
|
|
return;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Set initial orientation to face the first non-degenerate flight segment.
|
|
|
|
|
|
glm::vec3 start = taxiClientPath_[0];
|
|
|
|
|
|
glm::vec3 dir(0.0f);
|
|
|
|
|
|
float dirLen = 0.0f;
|
|
|
|
|
|
for (size_t i = 1; i < taxiClientPath_.size(); i++) {
|
|
|
|
|
|
dir = taxiClientPath_[i] - start;
|
|
|
|
|
|
dirLen = glm::length(dir);
|
|
|
|
|
|
if (dirLen >= 0.001f) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
float initialOrientation = movementInfo.orientation;
|
|
|
|
|
|
float initialRenderYaw = movementInfo.orientation;
|
|
|
|
|
|
float initialPitch = 0.0f;
|
|
|
|
|
|
float initialRoll = 0.0f;
|
|
|
|
|
|
if (dirLen >= 0.001f) {
|
|
|
|
|
|
initialOrientation = std::atan2(dir.y, dir.x);
|
|
|
|
|
|
glm::vec3 renderDir = core::coords::canonicalToRender(dir);
|
|
|
|
|
|
initialRenderYaw = std::atan2(renderDir.y, renderDir.x);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
glm::vec3 dirNorm = dir / dirLen;
|
2026-02-11 19:28:15 -08:00
|
|
|
|
initialPitch = std::asin(std::clamp(dirNorm.z, -1.0f, 1.0f));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
movementInfo.x = start.x;
|
|
|
|
|
|
movementInfo.y = start.y;
|
|
|
|
|
|
movementInfo.z = start.z;
|
|
|
|
|
|
movementInfo.orientation = initialOrientation;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-08 22:05:38 -08:00
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
if (playerEntity) {
|
2026-02-08 22:00:33 -08:00
|
|
|
|
playerEntity->setPosition(start.x, start.y, start.z, initialOrientation);
|
2026-02-11 19:28:15 -08:00
|
|
|
|
}
|
2026-02-08 22:00:33 -08:00
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (taxiOrientationCallback_) {
|
|
|
|
|
|
taxiOrientationCallback_(initialRenderYaw, initialPitch, initialRoll);
|
2026-02-08 22:00:33 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 21:32:38 -08:00
|
|
|
|
LOG_INFO("Taxi flight started with ", taxiClientPath_.size(), " spline waypoints");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiClientActive_ = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::updateClientTaxi(float deltaTime) {
|
|
|
|
|
|
if (!taxiClientActive_ || taxiClientPath_.size() < 2) return;
|
|
|
|
|
|
auto playerEntity = entityManager.getEntity(playerGuid);
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
auto finishTaxiFlight = [&]() {
|
Fix mount sounds, grey WMO meshes, taxi landing, tree animations, and classic dismount
- Per-family mount sounds (kodo, tallstrider, mechanostrider, etc.) detected from M2 model path
- Skip WMO groups with SHOW_SKYBOX flag or all-untextured batches (grey mesh in Orgrimmar)
- Freeze physics during taxi landing until terrain loads to prevent falling through void
- Disable bone animations on tropical vegetation (palm, bamboo, banana, etc.) to fix wiggling
- Snap player to final taxi waypoint on flight completion
- Extract mount aura spell ID from classic UNIT_FIELD_AURAS for CMSG_CANCEL_AURA dismount
- Increase /unstuck forward nudge to 5 units
2026-02-14 21:04:20 -08:00
|
|
|
|
// Snap player to the last waypoint (landing position) before clearing state.
|
|
|
|
|
|
// Without this, the player would be left at whatever mid-flight position
|
|
|
|
|
|
// they were at when the path completion was detected.
|
|
|
|
|
|
if (!taxiClientPath_.empty()) {
|
|
|
|
|
|
const auto& landingPos = taxiClientPath_.back();
|
|
|
|
|
|
if (playerEntity) {
|
|
|
|
|
|
playerEntity->setPosition(landingPos.x, landingPos.y, landingPos.z,
|
|
|
|
|
|
movementInfo.orientation);
|
|
|
|
|
|
}
|
|
|
|
|
|
movementInfo.x = landingPos.x;
|
|
|
|
|
|
movementInfo.y = landingPos.y;
|
|
|
|
|
|
movementInfo.z = landingPos.z;
|
|
|
|
|
|
LOG_INFO("Taxi landing: snapped to final waypoint (",
|
|
|
|
|
|
landingPos.x, ", ", landingPos.y, ", ", landingPos.z, ")");
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiClientActive_ = false;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
2026-02-08 23:15:26 -08:00
|
|
|
|
taxiLandingCooldown_ = 2.0f; // 2 second cooldown to prevent re-entering
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
2026-02-08 20:01:23 -08:00
|
|
|
|
currentMountDisplayId_ = 0;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiClientPath_.clear();
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
movementInfo.flags = 0;
|
|
|
|
|
|
movementInfo.flags2 = 0;
|
|
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_STOP);
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Taxi flight landed (client path)");
|
2026-02-11 19:28:15 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
|
|
|
|
|
|
finishTaxiFlight();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
float remainingDistance = taxiClientSegmentProgress_ + (taxiClientSpeed_ * deltaTime);
|
|
|
|
|
|
glm::vec3 start(0.0f);
|
|
|
|
|
|
glm::vec3 end(0.0f);
|
|
|
|
|
|
glm::vec3 dir(0.0f);
|
|
|
|
|
|
float segmentLen = 0.0f;
|
|
|
|
|
|
float t = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// Consume as many tiny/finished segments as needed this frame so taxi doesn't stall
|
|
|
|
|
|
// on dense/degenerate node clusters near takeoff/landing.
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
if (taxiClientIndex_ + 1 >= taxiClientPath_.size()) {
|
|
|
|
|
|
finishTaxiFlight();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
start = taxiClientPath_[taxiClientIndex_];
|
|
|
|
|
|
end = taxiClientPath_[taxiClientIndex_ + 1];
|
|
|
|
|
|
dir = end - start;
|
|
|
|
|
|
segmentLen = glm::length(dir);
|
|
|
|
|
|
|
|
|
|
|
|
if (segmentLen < 0.01f) {
|
|
|
|
|
|
taxiClientIndex_++;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (remainingDistance >= segmentLen) {
|
|
|
|
|
|
remainingDistance -= segmentLen;
|
|
|
|
|
|
taxiClientIndex_++;
|
|
|
|
|
|
taxiClientSegmentProgress_ = 0.0f;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
taxiClientSegmentProgress_ = remainingDistance;
|
|
|
|
|
|
t = taxiClientSegmentProgress_ / segmentLen;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 22:00:33 -08:00
|
|
|
|
// Use Catmull-Rom spline for smooth interpolation between waypoints
|
|
|
|
|
|
// Get surrounding points for spline curve
|
|
|
|
|
|
glm::vec3 p0 = (taxiClientIndex_ > 0) ? taxiClientPath_[taxiClientIndex_ - 1] : start;
|
|
|
|
|
|
glm::vec3 p1 = start;
|
|
|
|
|
|
glm::vec3 p2 = end;
|
|
|
|
|
|
glm::vec3 p3 = (taxiClientIndex_ + 2 < taxiClientPath_.size()) ?
|
|
|
|
|
|
taxiClientPath_[taxiClientIndex_ + 2] : end;
|
|
|
|
|
|
|
|
|
|
|
|
// Catmull-Rom spline formula for smooth curves
|
|
|
|
|
|
float t2 = t * t;
|
|
|
|
|
|
float t3 = t2 * t;
|
|
|
|
|
|
glm::vec3 nextPos = 0.5f * (
|
|
|
|
|
|
(2.0f * p1) +
|
|
|
|
|
|
(-p0 + p2) * t +
|
|
|
|
|
|
(2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 +
|
|
|
|
|
|
(-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3
|
|
|
|
|
|
);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-08 22:00:33 -08:00
|
|
|
|
// Calculate smooth direction for orientation (tangent to spline)
|
|
|
|
|
|
glm::vec3 tangent = 0.5f * (
|
|
|
|
|
|
(-p0 + p2) +
|
|
|
|
|
|
2.0f * (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t +
|
|
|
|
|
|
3.0f * (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t2
|
|
|
|
|
|
);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
float tangentLen = glm::length(tangent);
|
|
|
|
|
|
if (tangentLen < 0.0001f) {
|
|
|
|
|
|
tangent = dir;
|
|
|
|
|
|
tangentLen = glm::length(tangent);
|
|
|
|
|
|
if (tangentLen < 0.0001f) {
|
|
|
|
|
|
tangent = glm::vec3(std::cos(movementInfo.orientation), std::sin(movementInfo.orientation), 0.0f);
|
|
|
|
|
|
tangentLen = glm::length(tangent);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 22:00:33 -08:00
|
|
|
|
|
2026-02-08 22:05:38 -08:00
|
|
|
|
// Calculate yaw from horizontal direction
|
2026-02-11 19:28:15 -08:00
|
|
|
|
float targetOrientation = std::atan2(tangent.y, tangent.x);
|
2026-02-08 22:00:33 -08:00
|
|
|
|
|
2026-02-08 22:05:38 -08:00
|
|
|
|
// Calculate pitch from vertical component (altitude change)
|
2026-02-11 18:25:04 -08:00
|
|
|
|
glm::vec3 tangentNorm = tangent / std::max(tangentLen, 0.0001f);
|
2026-02-08 22:05:38 -08:00
|
|
|
|
float pitch = std::asin(std::clamp(tangentNorm.z, -1.0f, 1.0f));
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate roll (banking) from rate of yaw change
|
2026-02-08 22:00:33 -08:00
|
|
|
|
float currentOrientation = movementInfo.orientation;
|
|
|
|
|
|
float orientDiff = targetOrientation - currentOrientation;
|
|
|
|
|
|
// Normalize angle difference to [-PI, PI]
|
|
|
|
|
|
while (orientDiff > 3.14159265f) orientDiff -= 6.28318530f;
|
|
|
|
|
|
while (orientDiff < -3.14159265f) orientDiff += 6.28318530f;
|
2026-02-08 22:05:38 -08:00
|
|
|
|
// Bank proportional to turn rate (scaled for visual effect)
|
|
|
|
|
|
float roll = -orientDiff * 2.5f;
|
|
|
|
|
|
roll = std::clamp(roll, -0.7f, 0.7f); // Limit to ~40 degrees
|
|
|
|
|
|
|
|
|
|
|
|
// Smooth rotation transition (lerp towards target)
|
2026-02-08 22:00:33 -08:00
|
|
|
|
float smoothOrientation = currentOrientation + orientDiff * std::min(1.0f, deltaTime * 3.0f);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (playerEntity) {
|
|
|
|
|
|
playerEntity->setPosition(nextPos.x, nextPos.y, nextPos.z, smoothOrientation);
|
|
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
movementInfo.x = nextPos.x;
|
|
|
|
|
|
movementInfo.y = nextPos.y;
|
|
|
|
|
|
movementInfo.z = nextPos.z;
|
2026-02-08 22:00:33 -08:00
|
|
|
|
movementInfo.orientation = smoothOrientation;
|
|
|
|
|
|
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Update mount rotation with yaw/pitch/roll. Use render-space tangent yaw to
|
|
|
|
|
|
// avoid canonical<->render convention mismatches.
|
2026-02-08 22:00:33 -08:00
|
|
|
|
if (taxiOrientationCallback_) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
glm::vec3 renderTangent = core::coords::canonicalToRender(tangent);
|
|
|
|
|
|
float renderYaw = std::atan2(renderTangent.y, renderTangent.x);
|
|
|
|
|
|
taxiOrientationCallback_(renderYaw, pitch, roll);
|
2026-02-08 22:00:33 -08:00
|
|
|
|
}
|
2026-02-08 03:05:38 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
void GameHandler::handleActivateTaxiReply(network::Packet& packet) {
|
|
|
|
|
|
ActivateTaxiReplyData data;
|
|
|
|
|
|
if (!ActivateTaxiReplyParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_ACTIVATETAXIREPLY");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Guard against stray/mis-mapped packets being treated as taxi replies.
|
2026-02-11 22:27:02 -08:00
|
|
|
|
// We only consume a reply while an activation request is pending.
|
|
|
|
|
|
if (!taxiActivatePending_) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
LOG_DEBUG("Ignoring stray taxi reply: result=", data.result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
if (data.result == 0) {
|
2026-02-11 19:28:15 -08:00
|
|
|
|
// Some cores can emit duplicate success replies (e.g. basic + express activate).
|
|
|
|
|
|
// Ignore repeats once taxi is already active and no activation is pending.
|
|
|
|
|
|
if (onTaxiFlight_ && !taxiActivatePending_) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
onTaxiFlight_ = true;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f);
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-07 16:59:20 -08:00
|
|
|
|
taxiWindowOpen_ = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
applyTaxiMountForCurrentNode();
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_INFO("Taxi flight started!");
|
|
|
|
|
|
} else {
|
2026-02-11 22:27:02 -08:00
|
|
|
|
// If local taxi motion already started, treat late failure as stale and ignore.
|
|
|
|
|
|
if (onTaxiFlight_ || taxiClientActive_) {
|
|
|
|
|
|
LOG_WARNING("Ignoring stale taxi failure reply while flight is active: result=", data.result);
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
LOG_WARNING("Taxi activation failed, result=", data.result);
|
|
|
|
|
|
addSystemChatMessage("Cannot take that flight path.");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeTaxi() {
|
|
|
|
|
|
taxiWindowOpen_ = false;
|
2026-02-09 01:08:39 -08:00
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// Closing the taxi UI must not cancel an active/pending flight.
|
|
|
|
|
|
// The window can auto-close due distance checks while takeoff begins.
|
|
|
|
|
|
if (taxiActivatePending_ || onTaxiFlight_ || taxiClientActive_) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 01:08:39 -08:00
|
|
|
|
// If we optimistically mounted during node selection, dismount now
|
|
|
|
|
|
if (taxiMountActive_ && mountCallback_) {
|
|
|
|
|
|
mountCallback_(0); // Dismount
|
|
|
|
|
|
}
|
|
|
|
|
|
taxiMountActive_ = false;
|
|
|
|
|
|
taxiMountDisplayId_ = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Clear any pending activation
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
onTaxiFlight_ = false;
|
2026-02-09 01:35:29 -08:00
|
|
|
|
|
|
|
|
|
|
// Set cooldown to prevent auto-mount trigger from re-applying taxi mount
|
|
|
|
|
|
// (The UNIT_FLAG_TAXI_FLIGHT check in handleUpdateObject won't re-trigger during cooldown)
|
|
|
|
|
|
taxiLandingCooldown_ = 2.0f;
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:04:15 -08:00
|
|
|
|
void GameHandler::buildTaxiCostMap() {
|
|
|
|
|
|
taxiCostMap_.clear();
|
|
|
|
|
|
uint32_t startNode = currentTaxiData_.nearestNode;
|
|
|
|
|
|
if (startNode == 0) return;
|
|
|
|
|
|
|
2026-02-07 19:44:03 -08:00
|
|
|
|
// Build adjacency list with costs from all edges (path may traverse unknown nodes)
|
2026-02-07 19:04:15 -08:00
|
|
|
|
struct AdjEntry { uint32_t node; uint32_t cost; };
|
|
|
|
|
|
std::unordered_map<uint32_t, std::vector<AdjEntry>> adj;
|
|
|
|
|
|
for (const auto& edge : taxiPathEdges_) {
|
2026-02-07 19:44:03 -08:00
|
|
|
|
adj[edge.fromNode].push_back({edge.toNode, edge.cost});
|
2026-02-07 19:04:15 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BFS from startNode, accumulating costs along the path
|
|
|
|
|
|
std::deque<uint32_t> queue;
|
|
|
|
|
|
queue.push_back(startNode);
|
|
|
|
|
|
taxiCostMap_[startNode] = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (!queue.empty()) {
|
|
|
|
|
|
uint32_t cur = queue.front();
|
|
|
|
|
|
queue.pop_front();
|
|
|
|
|
|
for (const auto& next : adj[cur]) {
|
|
|
|
|
|
if (taxiCostMap_.find(next.node) == taxiCostMap_.end()) {
|
|
|
|
|
|
taxiCostMap_[next.node] = taxiCostMap_[cur] + next.cost;
|
|
|
|
|
|
queue.push_back(next.node);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t GameHandler::getTaxiCostTo(uint32_t destNodeId) const {
|
|
|
|
|
|
auto it = taxiCostMap_.find(destNodeId);
|
|
|
|
|
|
return (it != taxiCostMap_.end()) ? it->second : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
void GameHandler::activateTaxi(uint32_t destNodeId) {
|
|
|
|
|
|
if (!socket || state != WorldState::IN_WORLD) return;
|
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// One-shot taxi activation until server replies or timeout.
|
|
|
|
|
|
if (taxiActivatePending_ || onTaxiFlight_) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 16:59:20 -08:00
|
|
|
|
uint32_t startNode = currentTaxiData_.nearestNode;
|
|
|
|
|
|
if (startNode == 0 || destNodeId == 0 || startNode == destNodeId) return;
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// If already mounted, dismount before starting a taxi flight.
|
|
|
|
|
|
if (isMounted()) {
|
|
|
|
|
|
LOG_INFO("Taxi activate: dismounting current mount");
|
|
|
|
|
|
if (mountCallback_) mountCallback_(0);
|
|
|
|
|
|
currentMountDisplayId_ = 0;
|
|
|
|
|
|
dismount();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage("Taxi: requesting flight...");
|
|
|
|
|
|
|
2026-02-07 20:05:14 -08:00
|
|
|
|
// BFS to find path from startNode to destNodeId
|
|
|
|
|
|
std::unordered_map<uint32_t, std::vector<uint32_t>> adj;
|
|
|
|
|
|
for (const auto& edge : taxiPathEdges_) {
|
|
|
|
|
|
adj[edge.fromNode].push_back(edge.toNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::unordered_map<uint32_t, uint32_t> parent;
|
|
|
|
|
|
std::deque<uint32_t> queue;
|
|
|
|
|
|
queue.push_back(startNode);
|
|
|
|
|
|
parent[startNode] = startNode;
|
|
|
|
|
|
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
while (!queue.empty()) {
|
|
|
|
|
|
uint32_t cur = queue.front();
|
|
|
|
|
|
queue.pop_front();
|
|
|
|
|
|
if (cur == destNodeId) { found = true; break; }
|
|
|
|
|
|
for (uint32_t next : adj[cur]) {
|
|
|
|
|
|
if (parent.find(next) == parent.end()) {
|
|
|
|
|
|
parent[next] = cur;
|
|
|
|
|
|
queue.push_back(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
LOG_WARNING("No taxi path found from node ", startNode, " to ", destNodeId);
|
|
|
|
|
|
addSystemChatMessage("No flight path available to that destination.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<uint32_t> path;
|
|
|
|
|
|
for (uint32_t n = destNodeId; n != startNode; n = parent[n]) {
|
|
|
|
|
|
path.push_back(n);
|
|
|
|
|
|
}
|
|
|
|
|
|
path.push_back(startNode);
|
|
|
|
|
|
std::reverse(path.begin(), path.end());
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Taxi path: ", path.size(), " nodes, from ", startNode, " to ", destNodeId);
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Taxi activate: npc=0x", std::hex, taxiNpcGuid_, std::dec,
|
|
|
|
|
|
" start=", startNode, " dest=", destNodeId, " pathLen=", path.size());
|
|
|
|
|
|
if (!path.empty()) {
|
|
|
|
|
|
std::string pathStr;
|
|
|
|
|
|
for (size_t i = 0; i < path.size(); i++) {
|
|
|
|
|
|
pathStr += std::to_string(path[i]);
|
|
|
|
|
|
if (i + 1 < path.size()) pathStr += "->";
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Taxi path nodes: ", pathStr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
uint32_t totalCost = getTaxiCostTo(destNodeId);
|
|
|
|
|
|
LOG_INFO("Taxi activate: start=", startNode, " dest=", destNodeId, " cost=", totalCost);
|
2026-02-07 20:05:14 -08:00
|
|
|
|
|
2026-02-08 03:05:38 -08:00
|
|
|
|
// Some servers only accept basic CMSG_ACTIVATETAXI.
|
2026-02-07 20:05:14 -08:00
|
|
|
|
auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId);
|
|
|
|
|
|
socket->send(basicPkt);
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
|
// AzerothCore in this setup rejects/misparses CMSG_ACTIVATETAXIEXPRESS (0x312),
|
|
|
|
|
|
// so keep taxi activation on the basic packet only.
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Optimistically start taxi visuals; server will correct if it denies.
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiWindowOpen_ = false;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
taxiActivatePending_ = true;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
2026-02-11 21:14:35 -08:00
|
|
|
|
taxiStartGrace_ = 2.0f;
|
2026-02-08 03:05:38 -08:00
|
|
|
|
if (!onTaxiFlight_) {
|
|
|
|
|
|
onTaxiFlight_ = true;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
sanitizeMovementForTaxi();
|
2026-02-08 03:05:38 -08:00
|
|
|
|
applyTaxiMountForCurrentNode();
|
|
|
|
|
|
}
|
2026-02-11 19:28:15 -08:00
|
|
|
|
if (socket) {
|
|
|
|
|
|
sendMovement(Opcode::CMSG_MOVE_HEARTBEAT);
|
|
|
|
|
|
}
|
2026-02-08 21:32:38 -08:00
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
// Trigger terrain precache immediately (non-blocking).
|
2026-02-08 21:32:38 -08:00
|
|
|
|
if (taxiPrecacheCallback_) {
|
|
|
|
|
|
std::vector<glm::vec3> previewPath;
|
|
|
|
|
|
// Build full spline path using TaxiPathNode waypoints
|
|
|
|
|
|
for (size_t i = 0; i + 1 < path.size(); i++) {
|
|
|
|
|
|
uint32_t fromNode = path[i];
|
|
|
|
|
|
uint32_t toNode = path[i + 1];
|
|
|
|
|
|
// Find the pathId connecting these nodes
|
|
|
|
|
|
uint32_t pathId = 0;
|
|
|
|
|
|
for (const auto& edge : taxiPathEdges_) {
|
|
|
|
|
|
if (edge.fromNode == fromNode && edge.toNode == toNode) {
|
|
|
|
|
|
pathId = edge.pathId;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pathId == 0) continue;
|
|
|
|
|
|
// Get spline waypoints for this path segment
|
|
|
|
|
|
auto pathIt = taxiPathNodes_.find(pathId);
|
|
|
|
|
|
if (pathIt != taxiPathNodes_.end()) {
|
|
|
|
|
|
for (const auto& wpNode : pathIt->second) {
|
|
|
|
|
|
glm::vec3 serverPos(wpNode.x, wpNode.y, wpNode.z);
|
|
|
|
|
|
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
|
|
|
|
|
|
previewPath.push_back(canonical);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (previewPath.size() >= 2) {
|
|
|
|
|
|
taxiPrecacheCallback_(previewPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
// Flight starts immediately; upload callback stays opportunistic/non-blocking.
|
|
|
|
|
|
if (taxiFlightStartCallback_) {
|
|
|
|
|
|
taxiFlightStartCallback_();
|
|
|
|
|
|
}
|
|
|
|
|
|
startClientTaxiPath(path);
|
2026-02-11 22:27:02 -08:00
|
|
|
|
// We run taxi movement locally immediately; don't keep a long-lived pending state.
|
|
|
|
|
|
if (taxiClientActive_) {
|
|
|
|
|
|
taxiActivatePending_ = false;
|
|
|
|
|
|
taxiActivateTimer_ = 0.0f;
|
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage("Flight started.");
|
2026-02-08 03:05:38 -08:00
|
|
|
|
|
|
|
|
|
|
// Save recovery target in case of disconnect during taxi.
|
|
|
|
|
|
auto destIt = taxiNodes_.find(destNodeId);
|
|
|
|
|
|
if (destIt != taxiNodes_.end()) {
|
|
|
|
|
|
taxiRecoverMapId_ = destIt->second.mapId;
|
|
|
|
|
|
taxiRecoverPos_ = core::coords::serverToCanonical(
|
|
|
|
|
|
glm::vec3(destIt->second.x, destIt->second.y, destIt->second.z));
|
|
|
|
|
|
taxiRecoverPending_ = false;
|
|
|
|
|
|
}
|
2026-02-07 16:59:20 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-02-07 12:43:32 -08:00
|
|
|
|
// Server Info Command Handlers
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleQueryTimeResponse(network::Packet& packet) {
|
|
|
|
|
|
QueryTimeResponseData data;
|
|
|
|
|
|
if (!QueryTimeResponseParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_QUERY_TIME_RESPONSE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Convert Unix timestamp to readable format
|
|
|
|
|
|
time_t serverTime = static_cast<time_t>(data.serverTime);
|
|
|
|
|
|
struct tm* timeInfo = localtime(&serverTime);
|
|
|
|
|
|
char timeStr[64];
|
|
|
|
|
|
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", timeInfo);
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg = "Server time: " + std::string(timeStr);
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("Server time: ", data.serverTime, " (", timeStr, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handlePlayedTime(network::Packet& packet) {
|
|
|
|
|
|
PlayedTimeData data;
|
|
|
|
|
|
if (!PlayedTimeParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_PLAYED_TIME");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.triggerMessage) {
|
|
|
|
|
|
// Format total time played
|
|
|
|
|
|
uint32_t totalDays = data.totalTimePlayed / 86400;
|
|
|
|
|
|
uint32_t totalHours = (data.totalTimePlayed % 86400) / 3600;
|
|
|
|
|
|
uint32_t totalMinutes = (data.totalTimePlayed % 3600) / 60;
|
|
|
|
|
|
|
|
|
|
|
|
// Format level time played
|
|
|
|
|
|
uint32_t levelDays = data.levelTimePlayed / 86400;
|
|
|
|
|
|
uint32_t levelHours = (data.levelTimePlayed % 86400) / 3600;
|
|
|
|
|
|
uint32_t levelMinutes = (data.levelTimePlayed % 3600) / 60;
|
|
|
|
|
|
|
|
|
|
|
|
std::string totalMsg = "Total time played: ";
|
|
|
|
|
|
if (totalDays > 0) totalMsg += std::to_string(totalDays) + " days, ";
|
|
|
|
|
|
if (totalHours > 0 || totalDays > 0) totalMsg += std::to_string(totalHours) + " hours, ";
|
|
|
|
|
|
totalMsg += std::to_string(totalMinutes) + " minutes";
|
|
|
|
|
|
|
|
|
|
|
|
std::string levelMsg = "Time played this level: ";
|
|
|
|
|
|
if (levelDays > 0) levelMsg += std::to_string(levelDays) + " days, ";
|
|
|
|
|
|
if (levelHours > 0 || levelDays > 0) levelMsg += std::to_string(levelHours) + " hours, ";
|
|
|
|
|
|
levelMsg += std::to_string(levelMinutes) + " minutes";
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage(totalMsg);
|
|
|
|
|
|
addSystemChatMessage(levelMsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Played time: total=", data.totalTimePlayed, "s, level=", data.levelTimePlayed, "s");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleWho(network::Packet& packet) {
|
|
|
|
|
|
// Parse WHO response
|
|
|
|
|
|
uint32_t displayCount = packet.readUInt32();
|
|
|
|
|
|
uint32_t onlineCount = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online");
|
|
|
|
|
|
|
|
|
|
|
|
if (displayCount == 0) {
|
|
|
|
|
|
addSystemChatMessage("No players found.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:");
|
|
|
|
|
|
|
|
|
|
|
|
for (uint32_t i = 0; i < displayCount; ++i) {
|
|
|
|
|
|
std::string playerName = packet.readString();
|
|
|
|
|
|
std::string guildName = packet.readString();
|
|
|
|
|
|
uint32_t level = packet.readUInt32();
|
|
|
|
|
|
uint32_t classId = packet.readUInt32();
|
|
|
|
|
|
uint32_t raceId = packet.readUInt32();
|
|
|
|
|
|
packet.readUInt8(); // gender (unused)
|
|
|
|
|
|
packet.readUInt32(); // zoneId (unused)
|
|
|
|
|
|
|
|
|
|
|
|
std::string msg = " " + playerName;
|
|
|
|
|
|
if (!guildName.empty()) {
|
|
|
|
|
|
msg += " <" + guildName + ">";
|
|
|
|
|
|
}
|
|
|
|
|
|
msg += " - Level " + std::to_string(level);
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add /roll and friend management commands
Roll Command:
- Add /roll, /random, /rnd commands for random number generation
- Support multiple formats: /roll, /roll 100, /roll 1-100, /roll 10 50
- Broadcasts rolls to party/raid with "[Name] rolls X (min-max)" format
- Cap max roll at 10,000 to prevent abuse
- Use MSG_RANDOM_ROLL (0x1FB) bidirectional opcode
Friend Commands:
- Add /friend add <name>, /addfriend <name> to add friends
- Add /friend remove <name>, /removefriend <name> to remove friends
- Support aliases: /delfriend, /remfriend
- Maintain local friends cache mapping names to GUIDs for lookups
- Display status messages for all friend actions:
- Friend added/removed confirmations
- Friend online/offline notifications
- Error messages (not found, already friends, list full, ignoring)
Social Opcodes:
- Add CMSG_ADD_FRIEND (0x69) and SMSG_FRIEND_STATUS (0x68)
- Add CMSG_DEL_FRIEND (0x6A) for friend removal
- Add CMSG_SET_CONTACT_NOTES (0x6B) for friend notes (future use)
- Add CMSG_ADD_IGNORE (0x6C) and CMSG_DEL_IGNORE (0x6D) (future use)
Implementation:
- Add RandomRollPacket builder and RandomRollParser for roll data
- Add AddFriendPacket and DelFriendPacket builders
- Add FriendStatusParser to handle server friend status updates
- Add friendsCache map to store friend name-to-GUID mappings
- Add handleRandomRoll() and handleFriendStatus() packet handlers
- Comprehensive slash command parsing with multiple formats and aliases
2026-02-07 12:51:30 -08:00
|
|
|
|
void GameHandler::handleFriendStatus(network::Packet& packet) {
|
|
|
|
|
|
FriendStatusData data;
|
|
|
|
|
|
if (!FriendStatusParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_FRIEND_STATUS");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Look up player name from GUID
|
|
|
|
|
|
std::string playerName;
|
|
|
|
|
|
auto it = playerNameCache.find(data.guid);
|
|
|
|
|
|
if (it != playerNameCache.end()) {
|
|
|
|
|
|
playerName = it->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
playerName = "Unknown";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update friends cache
|
|
|
|
|
|
if (data.status == 1 || data.status == 2) { // Added or online
|
|
|
|
|
|
friendsCache[playerName] = data.guid;
|
|
|
|
|
|
} else if (data.status == 0) { // Removed
|
|
|
|
|
|
friendsCache.erase(playerName);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Status messages
|
|
|
|
|
|
switch (data.status) {
|
|
|
|
|
|
case 0:
|
|
|
|
|
|
addSystemChatMessage(playerName + " has been removed from your friends list.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
addSystemChatMessage(playerName + " has been added to your friends list.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is now online.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is now offline.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 4:
|
|
|
|
|
|
addSystemChatMessage("Player not found.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 5:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is already in your friends list.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 6:
|
|
|
|
|
|
addSystemChatMessage("Your friends list is full.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 7:
|
|
|
|
|
|
addSystemChatMessage(playerName + " is ignoring you.");
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
LOG_INFO("Friend status: ", (int)data.status, " for ", playerName);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("Friend status update: ", playerName, " status=", (int)data.status);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleRandomRoll(network::Packet& packet) {
|
|
|
|
|
|
RandomRollData data;
|
|
|
|
|
|
if (!RandomRollParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_RANDOM_ROLL");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get roller name
|
|
|
|
|
|
std::string rollerName;
|
|
|
|
|
|
if (data.rollerGuid == playerGuid) {
|
|
|
|
|
|
rollerName = "You";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
auto it = playerNameCache.find(data.rollerGuid);
|
|
|
|
|
|
if (it != playerNameCache.end()) {
|
|
|
|
|
|
rollerName = it->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rollerName = "Someone";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build message
|
|
|
|
|
|
std::string msg = rollerName;
|
|
|
|
|
|
if (data.rollerGuid == playerGuid) {
|
|
|
|
|
|
msg += " roll ";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg += " rolls ";
|
|
|
|
|
|
}
|
|
|
|
|
|
msg += std::to_string(data.result);
|
|
|
|
|
|
msg += " (" + std::to_string(data.minRoll) + "-" + std::to_string(data.maxRoll) + ")";
|
|
|
|
|
|
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
LOG_INFO("Random roll: ", rollerName, " rolled ", data.result, " (", data.minRoll, "-", data.maxRoll, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:58:11 -08:00
|
|
|
|
void GameHandler::handleLogoutResponse(network::Packet& packet) {
|
|
|
|
|
|
LogoutResponseData data;
|
|
|
|
|
|
if (!LogoutResponseParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_LOGOUT_RESPONSE");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.result == 0) {
|
|
|
|
|
|
// Success - logout initiated
|
|
|
|
|
|
if (data.instant) {
|
|
|
|
|
|
addSystemChatMessage("Logging out...");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Logging out in 20 seconds...");
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Logout response: success, instant=", (int)data.instant);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Failure
|
|
|
|
|
|
addSystemChatMessage("Cannot logout right now.");
|
|
|
|
|
|
loggingOut_ = false;
|
|
|
|
|
|
LOG_WARNING("Logout failed, result=", data.result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) {
|
|
|
|
|
|
addSystemChatMessage("Logout complete.");
|
|
|
|
|
|
loggingOut_ = false;
|
|
|
|
|
|
LOG_INFO("Logout complete");
|
|
|
|
|
|
// Server will disconnect us
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
uint32_t GameHandler::generateClientSeed() {
|
|
|
|
|
|
// Generate cryptographically random seed
|
|
|
|
|
|
std::random_device rd;
|
|
|
|
|
|
std::mt19937 gen(rd());
|
|
|
|
|
|
std::uniform_int_distribution<uint32_t> dis(1, 0xFFFFFFFF);
|
|
|
|
|
|
return dis(gen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::setState(WorldState newState) {
|
|
|
|
|
|
if (state != newState) {
|
|
|
|
|
|
LOG_DEBUG("World state: ", (int)state, " -> ", (int)newState);
|
|
|
|
|
|
state = newState;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::fail(const std::string& reason) {
|
|
|
|
|
|
LOG_ERROR("World connection failed: ", reason);
|
|
|
|
|
|
setState(WorldState::FAILED);
|
|
|
|
|
|
|
|
|
|
|
|
if (onFailure) {
|
|
|
|
|
|
onFailure(reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix camera orbit, deselect, chat formatting, loot/vendor bugs, critter hostility, and character screen
Smooth idle camera orbit without jump at loop boundary, click empty space to
deselect target, auto-target when attacked, fix critter hostility so neutral
factions aren't flagged red, add armor/stats to item templates, fix loot
iterator invalidation, show item template names as fallback, position drop
confirmation at cursor, remove [SYSTEM] chat prefix, show NPC names in monster
say/yell, and prevent auto-login on character select screen.
2026-02-06 16:40:44 -08:00
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Player Skills
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
static const std::string kEmptySkillName;
|
|
|
|
|
|
|
|
|
|
|
|
const std::string& GameHandler::getSkillName(uint32_t skillId) const {
|
|
|
|
|
|
auto it = skillLineNames_.find(skillId);
|
|
|
|
|
|
return (it != skillLineNames_.end()) ? it->second : kEmptySkillName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t GameHandler::getSkillCategory(uint32_t skillId) const {
|
|
|
|
|
|
auto it = skillLineCategories_.find(skillId);
|
|
|
|
|
|
return (it != skillLineCategories_.end()) ? it->second : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::loadSkillLineDbc() {
|
|
|
|
|
|
if (skillLineDbcLoaded_) return;
|
|
|
|
|
|
skillLineDbcLoaded_ = true;
|
|
|
|
|
|
|
|
|
|
|
|
auto* am = core::Application::getInstance().getAssetManager();
|
|
|
|
|
|
if (!am || !am->isInitialized()) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto dbc = am->loadDBC("SkillLine.dbc");
|
|
|
|
|
|
if (!dbc || !dbc->isLoaded()) {
|
|
|
|
|
|
LOG_WARNING("GameHandler: Could not load SkillLine.dbc");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const auto* slL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr;
|
2026-02-07 14:21:50 -08:00
|
|
|
|
for (uint32_t i = 0; i < dbc->getRecordCount(); i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
uint32_t id = dbc->getUInt32(i, slL ? (*slL)["ID"] : 0);
|
|
|
|
|
|
uint32_t category = dbc->getUInt32(i, slL ? (*slL)["Category"] : 1);
|
|
|
|
|
|
std::string name = dbc->getString(i, slL ? (*slL)["Name"] : 3);
|
2026-02-07 14:21:50 -08:00
|
|
|
|
if (id > 0 && !name.empty()) {
|
|
|
|
|
|
skillLineNames_[id] = name;
|
|
|
|
|
|
skillLineCategories_[id] = category;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("GameHandler: Loaded ", skillLineNames_.size(), " skill line names");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::extractSkillFields(const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
loadSkillLineDbc();
|
|
|
|
|
|
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t PLAYER_SKILL_INFO_START = fieldIndex(UF::PLAYER_SKILL_INFO_START);
|
2026-02-07 14:21:50 -08:00
|
|
|
|
static constexpr int MAX_SKILL_SLOTS = 128;
|
|
|
|
|
|
|
|
|
|
|
|
std::map<uint32_t, PlayerSkill> newSkills;
|
|
|
|
|
|
|
|
|
|
|
|
for (int slot = 0; slot < MAX_SKILL_SLOTS; slot++) {
|
|
|
|
|
|
uint16_t baseField = PLAYER_SKILL_INFO_START + slot * 3;
|
|
|
|
|
|
|
|
|
|
|
|
auto idIt = fields.find(baseField);
|
|
|
|
|
|
if (idIt == fields.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t raw0 = idIt->second;
|
|
|
|
|
|
uint16_t skillId = raw0 & 0xFFFF;
|
|
|
|
|
|
if (skillId == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto valIt = fields.find(baseField + 1);
|
|
|
|
|
|
if (valIt == fields.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t raw1 = valIt->second;
|
|
|
|
|
|
uint16_t value = raw1 & 0xFFFF;
|
|
|
|
|
|
uint16_t maxValue = (raw1 >> 16) & 0xFFFF;
|
|
|
|
|
|
|
|
|
|
|
|
PlayerSkill skill;
|
|
|
|
|
|
skill.skillId = skillId;
|
|
|
|
|
|
skill.value = value;
|
|
|
|
|
|
skill.maxValue = maxValue;
|
|
|
|
|
|
newSkills[skillId] = skill;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Detect increases and emit chat messages
|
|
|
|
|
|
for (const auto& [skillId, skill] : newSkills) {
|
|
|
|
|
|
if (skill.value == 0) continue;
|
|
|
|
|
|
auto oldIt = playerSkills_.find(skillId);
|
|
|
|
|
|
if (oldIt != playerSkills_.end() && skill.value > oldIt->second.value) {
|
2026-02-10 01:24:37 -08:00
|
|
|
|
// Filter out racial, generic, and hidden skills from announcements
|
|
|
|
|
|
// Category 5 = Attributes (Defense, etc.)
|
|
|
|
|
|
// Category 10 = Languages (Orcish, Common, etc.)
|
|
|
|
|
|
// Category 12 = Not Displayed (generic/hidden)
|
|
|
|
|
|
auto catIt = skillLineCategories_.find(skillId);
|
|
|
|
|
|
if (catIt != skillLineCategories_.end()) {
|
|
|
|
|
|
uint32_t category = catIt->second;
|
|
|
|
|
|
if (category == 5 || category == 10 || category == 12) {
|
|
|
|
|
|
continue; // Skip announcement for racial/generic skills
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 14:21:50 -08:00
|
|
|
|
const std::string& name = getSkillName(skillId);
|
|
|
|
|
|
std::string skillName = name.empty() ? ("Skill #" + std::to_string(skillId)) : name;
|
|
|
|
|
|
addSystemChatMessage("Your skill in " + skillName + " has increased to " + std::to_string(skill.value) + ".");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
playerSkills_ = std::move(newSkills);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 18:25:04 -08:00
|
|
|
|
void GameHandler::extractExploredZoneFields(const std::map<uint16_t, uint32_t>& fields) {
|
|
|
|
|
|
if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) {
|
|
|
|
|
|
playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool foundAny = false;
|
|
|
|
|
|
for (size_t i = 0; i < PLAYER_EXPLORED_ZONES_COUNT; i++) {
|
2026-02-12 22:56:36 -08:00
|
|
|
|
const uint16_t fieldIdx = static_cast<uint16_t>(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i);
|
2026-02-11 18:25:04 -08:00
|
|
|
|
auto it = fields.find(fieldIdx);
|
|
|
|
|
|
if (it == fields.end()) continue;
|
|
|
|
|
|
playerExploredZones_[i] = it->second;
|
|
|
|
|
|
foundAny = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (foundAny) {
|
|
|
|
|
|
hasPlayerExploredZones_ = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
std::string GameHandler::getCharacterConfigDir() {
|
|
|
|
|
|
std::string dir;
|
|
|
|
|
|
#ifdef _WIN32
|
|
|
|
|
|
const char* appdata = std::getenv("APPDATA");
|
|
|
|
|
|
dir = appdata ? std::string(appdata) + "\\wowee\\characters" : "characters";
|
|
|
|
|
|
#else
|
|
|
|
|
|
const char* home = std::getenv("HOME");
|
|
|
|
|
|
dir = home ? std::string(home) + "/.wowee/characters" : "characters";
|
|
|
|
|
|
#endif
|
|
|
|
|
|
return dir;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::saveCharacterConfig() {
|
|
|
|
|
|
const Character* ch = getActiveCharacter();
|
|
|
|
|
|
if (!ch || ch->name.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::string dir = getCharacterConfigDir();
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
std::filesystem::create_directories(dir, ec);
|
|
|
|
|
|
|
|
|
|
|
|
std::string path = dir + "/" + ch->name + ".cfg";
|
|
|
|
|
|
std::ofstream out(path);
|
|
|
|
|
|
if (!out.is_open()) {
|
|
|
|
|
|
LOG_WARNING("Could not save character config to ", path);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out << "character_guid=" << playerGuid << "\n";
|
2026-02-09 17:39:21 -08:00
|
|
|
|
out << "gender=" << static_cast<int>(ch->gender) << "\n";
|
2026-02-09 17:56:04 -08:00
|
|
|
|
out << "use_female_model=" << (ch->useFemaleModel ? 1 : 0) << "\n";
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
|
|
|
|
|
|
out << "action_bar_" << i << "_type=" << static_cast<int>(actionBar[i].type) << "\n";
|
|
|
|
|
|
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
|
|
|
|
|
|
}
|
2026-02-10 01:24:37 -08:00
|
|
|
|
|
|
|
|
|
|
// Save quest log
|
|
|
|
|
|
out << "quest_log_count=" << questLog_.size() << "\n";
|
|
|
|
|
|
for (size_t i = 0; i < questLog_.size(); i++) {
|
|
|
|
|
|
const auto& quest = questLog_[i];
|
|
|
|
|
|
out << "quest_" << i << "_id=" << quest.questId << "\n";
|
|
|
|
|
|
out << "quest_" << i << "_title=" << quest.title << "\n";
|
|
|
|
|
|
out << "quest_" << i << "_complete=" << (quest.complete ? 1 : 0) << "\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
LOG_INFO("Character config saved to ", path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::loadCharacterConfig() {
|
|
|
|
|
|
const Character* ch = getActiveCharacter();
|
|
|
|
|
|
if (!ch || ch->name.empty()) return;
|
|
|
|
|
|
|
|
|
|
|
|
std::string path = getCharacterConfigDir() + "/" + ch->name + ".cfg";
|
|
|
|
|
|
std::ifstream in(path);
|
|
|
|
|
|
if (!in.is_open()) return;
|
|
|
|
|
|
|
|
|
|
|
|
uint64_t savedGuid = 0;
|
|
|
|
|
|
std::array<int, ACTION_BAR_SLOTS> types{};
|
|
|
|
|
|
std::array<uint32_t, ACTION_BAR_SLOTS> ids{};
|
|
|
|
|
|
bool hasSlots = false;
|
2026-02-09 17:39:21 -08:00
|
|
|
|
int savedGender = -1;
|
2026-02-09 17:56:04 -08:00
|
|
|
|
int savedUseFemaleModel = -1;
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
|
|
|
|
|
|
std::string line;
|
|
|
|
|
|
while (std::getline(in, line)) {
|
|
|
|
|
|
size_t eq = line.find('=');
|
|
|
|
|
|
if (eq == std::string::npos) continue;
|
|
|
|
|
|
std::string key = line.substr(0, eq);
|
|
|
|
|
|
std::string val = line.substr(eq + 1);
|
|
|
|
|
|
|
|
|
|
|
|
if (key == "character_guid") {
|
|
|
|
|
|
try { savedGuid = std::stoull(val); } catch (...) {}
|
2026-02-09 17:39:21 -08:00
|
|
|
|
} else if (key == "gender") {
|
|
|
|
|
|
try { savedGender = std::stoi(val); } catch (...) {}
|
2026-02-09 17:56:04 -08:00
|
|
|
|
} else if (key == "use_female_model") {
|
|
|
|
|
|
try { savedUseFemaleModel = std::stoi(val); } catch (...) {}
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
} else if (key.rfind("action_bar_", 0) == 0) {
|
|
|
|
|
|
// Parse action_bar_N_type or action_bar_N_id
|
|
|
|
|
|
size_t firstUnderscore = 11; // length of "action_bar_"
|
|
|
|
|
|
size_t secondUnderscore = key.find('_', firstUnderscore);
|
|
|
|
|
|
if (secondUnderscore == std::string::npos) continue;
|
|
|
|
|
|
int slot = -1;
|
|
|
|
|
|
try { slot = std::stoi(key.substr(firstUnderscore, secondUnderscore - firstUnderscore)); } catch (...) { continue; }
|
|
|
|
|
|
if (slot < 0 || slot >= ACTION_BAR_SLOTS) continue;
|
|
|
|
|
|
std::string suffix = key.substr(secondUnderscore + 1);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (suffix == "type") {
|
|
|
|
|
|
types[slot] = std::stoi(val);
|
|
|
|
|
|
hasSlots = true;
|
|
|
|
|
|
} else if (suffix == "id") {
|
|
|
|
|
|
ids[slot] = static_cast<uint32_t>(std::stoul(val));
|
|
|
|
|
|
hasSlots = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (...) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate guid matches current character
|
|
|
|
|
|
if (savedGuid != 0 && savedGuid != playerGuid) {
|
|
|
|
|
|
LOG_WARNING("Character config guid mismatch for ", ch->name, ", using defaults");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 17:56:04 -08:00
|
|
|
|
// Apply saved gender and body type (allows nonbinary to persist even though server only stores male/female)
|
2026-02-09 17:39:21 -08:00
|
|
|
|
if (savedGender >= 0 && savedGender <= 2) {
|
|
|
|
|
|
for (auto& character : characters) {
|
|
|
|
|
|
if (character.guid == playerGuid) {
|
|
|
|
|
|
character.gender = static_cast<Gender>(savedGender);
|
2026-02-09 17:56:04 -08:00
|
|
|
|
if (savedUseFemaleModel >= 0) {
|
|
|
|
|
|
character.useFemaleModel = (savedUseFemaleModel != 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Applied saved gender: ", getGenderName(character.gender),
|
|
|
|
|
|
", body type: ", (character.useFemaleModel ? "feminine" : "masculine"));
|
2026-02-09 17:39:21 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix vendor buying, improve character select, parallelize WMO culling, and optimize collision
- Fix CMSG_BUY_ITEM count field from uint8 to uint32 (server silently dropped undersized packets)
- Character select screen: remember last selected character, two-column layout with details panel, double-click to enter world, responsive window sizing
- Fix stale character data between logins by replacing static init flag with per-character GUID tracking
- Parallelize WMO visibility culling across worker threads (same pattern as M2 renderer)
- Optimize WMO collision queries with world-space group bounds early rejection in getFloorHeight, checkWallCollision, isInsideWMO, and raycastBoundingBoxes
- Reduce camera ground samples from 5 to 3 movement-aligned probes
- Add WMO interior lighting, unlit materials, vertex color multiply, and alpha blending support
2026-02-07 15:29:19 -08:00
|
|
|
|
if (hasSlots) {
|
|
|
|
|
|
for (int i = 0; i < ACTION_BAR_SLOTS; i++) {
|
|
|
|
|
|
actionBar[i].type = static_cast<ActionBarSlot::Type>(types[i]);
|
|
|
|
|
|
actionBar[i].id = ids[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("Character config loaded from ", path);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 00:04:53 -08:00
|
|
|
|
void GameHandler::setTransportAttachment(uint64_t childGuid, ObjectType type, uint64_t transportGuid,
|
|
|
|
|
|
const glm::vec3& localOffset, bool hasLocalOrientation,
|
|
|
|
|
|
float localOrientation) {
|
|
|
|
|
|
if (childGuid == 0 || transportGuid == 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TransportAttachment& attachment = transportAttachments_[childGuid];
|
|
|
|
|
|
attachment.type = type;
|
|
|
|
|
|
attachment.transportGuid = transportGuid;
|
|
|
|
|
|
attachment.localOffset = localOffset;
|
|
|
|
|
|
attachment.hasLocalOrientation = hasLocalOrientation;
|
|
|
|
|
|
attachment.localOrientation = localOrientation;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::clearTransportAttachment(uint64_t childGuid) {
|
|
|
|
|
|
if (childGuid == 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
transportAttachments_.erase(childGuid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::updateAttachedTransportChildren(float /*deltaTime*/) {
|
|
|
|
|
|
if (!transportManager_ || transportAttachments_.empty()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
constexpr float kPosEpsilonSq = 0.0001f;
|
|
|
|
|
|
constexpr float kOriEpsilon = 0.001f;
|
|
|
|
|
|
std::vector<uint64_t> stale;
|
|
|
|
|
|
stale.reserve(8);
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [childGuid, attachment] : transportAttachments_) {
|
|
|
|
|
|
auto entity = entityManager.getEntity(childGuid);
|
|
|
|
|
|
if (!entity) {
|
|
|
|
|
|
stale.push_back(childGuid);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ActiveTransport* transport = transportManager_->getTransport(attachment.transportGuid);
|
|
|
|
|
|
if (!transport) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glm::vec3 composed = transportManager_->getPlayerWorldPosition(
|
|
|
|
|
|
attachment.transportGuid, attachment.localOffset);
|
|
|
|
|
|
|
|
|
|
|
|
float composedOrientation = entity->getOrientation();
|
|
|
|
|
|
if (attachment.hasLocalOrientation) {
|
|
|
|
|
|
float baseYaw = transport->hasServerYaw ? transport->serverYaw : 0.0f;
|
|
|
|
|
|
composedOrientation = baseYaw + attachment.localOrientation;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glm::vec3 oldPos(entity->getX(), entity->getY(), entity->getZ());
|
|
|
|
|
|
float oldOrientation = entity->getOrientation();
|
|
|
|
|
|
glm::vec3 delta = composed - oldPos;
|
|
|
|
|
|
const bool positionChanged = glm::dot(delta, delta) > kPosEpsilonSq;
|
|
|
|
|
|
const bool orientationChanged = std::abs(composedOrientation - oldOrientation) > kOriEpsilon;
|
|
|
|
|
|
if (!positionChanged && !orientationChanged) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entity->setPosition(composed.x, composed.y, composed.z, composedOrientation);
|
|
|
|
|
|
|
|
|
|
|
|
if (attachment.type == ObjectType::UNIT) {
|
|
|
|
|
|
if (creatureMoveCallback_) {
|
|
|
|
|
|
creatureMoveCallback_(childGuid, composed.x, composed.y, composed.z, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (attachment.type == ObjectType::GAMEOBJECT) {
|
|
|
|
|
|
if (gameObjectMoveCallback_) {
|
|
|
|
|
|
gameObjectMoveCallback_(childGuid, composed.x, composed.y, composed.z, composedOrientation);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (uint64_t guid : stale) {
|
|
|
|
|
|
transportAttachments_.erase(guid);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Mail System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeMailbox() {
|
|
|
|
|
|
mailboxOpen_ = false;
|
|
|
|
|
|
mailboxGuid_ = 0;
|
|
|
|
|
|
mailInbox_.clear();
|
|
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::refreshMailList() {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
|
|
|
|
|
auto packet = GetMailListPacket::build(mailboxGuid_);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::sendMail(const std::string& recipient, const std::string& subject,
|
|
|
|
|
|
const std::string& body, uint32_t money, uint32_t cod) {
|
2026-02-16 18:46:44 -08:00
|
|
|
|
if (state != WorldState::IN_WORLD) {
|
|
|
|
|
|
LOG_WARNING("sendMail: not in world");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!socket) {
|
|
|
|
|
|
LOG_WARNING("sendMail: no socket");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mailboxGuid_ == 0) {
|
|
|
|
|
|
LOG_WARNING("sendMail: mailboxGuid_ is 0 (mailbox closed?)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
auto packet = packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod);
|
|
|
|
|
|
LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money,
|
|
|
|
|
|
" mailboxGuid=", mailboxGuid_);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::mailTakeMoney(uint32_t mailId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
|
|
|
|
|
auto packet = MailTakeMoneyPacket::build(mailboxGuid_, mailId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemIndex) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemIndex);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::mailDelete(uint32_t mailId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
|
|
|
|
|
// Find mail template ID for this mail
|
|
|
|
|
|
uint32_t templateId = 0;
|
|
|
|
|
|
for (const auto& m : mailInbox_) {
|
|
|
|
|
|
if (m.messageId == mailId) {
|
|
|
|
|
|
templateId = m.mailTemplateId;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-16 18:46:44 -08:00
|
|
|
|
auto packet = packetParsers_->buildMailDelete(mailboxGuid_, mailId, templateId);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::mailMarkAsRead(uint32_t mailId) {
|
|
|
|
|
|
if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return;
|
|
|
|
|
|
auto packet = MailMarkAsReadPacket::build(mailboxGuid_, mailId);
|
|
|
|
|
|
socket->send(packet);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleShowMailbox(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) {
|
|
|
|
|
|
LOG_WARNING("SMSG_SHOW_MAILBOX too short");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
uint64_t guid = packet.readUInt64();
|
|
|
|
|
|
LOG_INFO("SMSG_SHOW_MAILBOX: guid=0x", std::hex, guid, std::dec);
|
|
|
|
|
|
mailboxGuid_ = guid;
|
|
|
|
|
|
mailboxOpen_ = true;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
hasNewMail_ = false;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
|
|
|
|
|
// Request inbox contents
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleMailListResult(network::Packet& packet) {
|
|
|
|
|
|
size_t remaining = packet.getSize() - packet.getReadPos();
|
2026-02-16 18:46:44 -08:00
|
|
|
|
if (remaining < 1) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
LOG_WARNING("SMSG_MAIL_LIST_RESULT too short (", remaining, " bytes)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Delegate parsing to expansion-aware packet parser
|
|
|
|
|
|
packetParsers_->parseMailList(packet, mailInbox_);
|
2026-02-15 14:00:41 -08:00
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Resolve sender names (needs GameHandler context, so done here)
|
|
|
|
|
|
for (auto& msg : mailInbox_) {
|
2026-02-15 14:00:41 -08:00
|
|
|
|
if (msg.messageType == 0 && msg.senderGuid != 0) {
|
|
|
|
|
|
msg.senderName = getCachedPlayerName(msg.senderGuid);
|
|
|
|
|
|
if (msg.senderName.empty()) {
|
|
|
|
|
|
queryPlayerName(msg.senderGuid);
|
|
|
|
|
|
msg.senderName = "Unknown";
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (msg.messageType == 2) {
|
|
|
|
|
|
msg.senderName = "Auction House";
|
|
|
|
|
|
} else if (msg.messageType == 3) {
|
|
|
|
|
|
msg.senderName = getCachedCreatureName(msg.senderEntry);
|
|
|
|
|
|
if (msg.senderName.empty()) msg.senderName = "NPC";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
msg.senderName = "System";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Open the mailbox UI if it isn't already open (Vanilla has no SMSG_SHOW_MAILBOX).
|
|
|
|
|
|
if (!mailboxOpen_) {
|
|
|
|
|
|
LOG_INFO("Opening mailbox UI (triggered by SMSG_MAIL_LIST_RESULT)");
|
|
|
|
|
|
mailboxOpen_ = true;
|
|
|
|
|
|
hasNewMail_ = false;
|
|
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
showMailCompose_ = false;
|
|
|
|
|
|
}
|
2026-02-15 14:00:41 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleSendMailResult(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 12) {
|
|
|
|
|
|
LOG_WARNING("SMSG_SEND_MAIL_RESULT too short");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t mailId = packet.readUInt32();
|
|
|
|
|
|
uint32_t command = packet.readUInt32();
|
|
|
|
|
|
uint32_t error = packet.readUInt32();
|
|
|
|
|
|
|
|
|
|
|
|
// Commands: 0=send, 1=moneyTaken, 2=itemTaken, 3=returnedToSender, 4=deleted, 5=madePermanent
|
2026-02-16 18:46:44 -08:00
|
|
|
|
// Vanilla errors: 0=OK, 1=equipError, 2=cannotSendToSelf, 3=notEnoughMoney, 4=recipientNotFound, 5=notYourTeam, 6=internalError
|
2026-02-15 14:00:41 -08:00
|
|
|
|
static const char* cmdNames[] = {"Send", "TakeMoney", "TakeItem", "Return", "Delete", "MadePermanent"};
|
|
|
|
|
|
const char* cmdName = (command < 6) ? cmdNames[command] : "Unknown";
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_SEND_MAIL_RESULT: mailId=", mailId, " cmd=", cmdName, " error=", error);
|
|
|
|
|
|
|
|
|
|
|
|
if (error == 0) {
|
|
|
|
|
|
// Success
|
|
|
|
|
|
switch (command) {
|
|
|
|
|
|
case 0: // Send
|
|
|
|
|
|
addSystemChatMessage("Mail sent successfully.");
|
|
|
|
|
|
showMailCompose_ = false;
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 1: // Money taken
|
|
|
|
|
|
addSystemChatMessage("Money received from mail.");
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2: // Item taken
|
|
|
|
|
|
addSystemChatMessage("Item received from mail.");
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 4: // Deleted
|
|
|
|
|
|
selectedMailIndex_ = -1;
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Error
|
|
|
|
|
|
std::string errMsg = "Mail error: ";
|
|
|
|
|
|
switch (error) {
|
|
|
|
|
|
case 1: errMsg += "Equipment error."; break;
|
2026-02-16 18:46:44 -08:00
|
|
|
|
case 2: errMsg += "You cannot send mail to yourself."; break;
|
|
|
|
|
|
case 3: errMsg += "Not enough money."; break;
|
|
|
|
|
|
case 4: errMsg += "Recipient not found."; break;
|
|
|
|
|
|
case 5: errMsg += "Cannot send to the opposing faction."; break;
|
|
|
|
|
|
case 6: errMsg += "Internal mail error."; break;
|
|
|
|
|
|
case 14: errMsg += "Disabled for trial accounts."; break;
|
|
|
|
|
|
case 15: errMsg += "Recipient's mailbox is full."; break;
|
|
|
|
|
|
case 16: errMsg += "Cannot send wrapped items COD."; break;
|
|
|
|
|
|
case 17: errMsg += "Mail and chat suspended."; break;
|
|
|
|
|
|
case 18: errMsg += "Too many attachments."; break;
|
|
|
|
|
|
case 19: errMsg += "Invalid attachment."; break;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
default: errMsg += "Unknown error (" + std::to_string(error) + ")."; break;
|
|
|
|
|
|
}
|
|
|
|
|
|
addSystemChatMessage(errMsg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleReceivedMail(network::Packet& packet) {
|
|
|
|
|
|
// Server notifies us that new mail arrived
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() >= 4) {
|
|
|
|
|
|
float nextMailTime = packet.readFloat();
|
|
|
|
|
|
(void)nextMailTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_RECEIVED_MAIL: New mail arrived!");
|
2026-02-16 18:46:44 -08:00
|
|
|
|
hasNewMail_ = true;
|
2026-02-15 14:00:41 -08:00
|
|
|
|
addSystemChatMessage("New mail has arrived.");
|
|
|
|
|
|
// If mailbox is open, refresh
|
|
|
|
|
|
if (mailboxOpen_) {
|
|
|
|
|
|
refreshMailList();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 18:46:44 -08:00
|
|
|
|
void GameHandler::handleQueryNextMailTime(network::Packet& packet) {
|
|
|
|
|
|
// Server response to MSG_QUERY_NEXT_MAIL_TIME
|
|
|
|
|
|
// If there's pending mail, the packet contains a float with time until next mail delivery
|
|
|
|
|
|
// A value of 0.0 or the presence of mail entries means there IS mail waiting
|
|
|
|
|
|
size_t remaining = packet.getSize() - packet.getReadPos();
|
|
|
|
|
|
if (remaining >= 4) {
|
|
|
|
|
|
float nextMailTime = packet.readFloat();
|
|
|
|
|
|
// In Vanilla: 0x00000000 = has mail, 0xC7A8C000 (big negative) = no mail
|
|
|
|
|
|
uint32_t rawValue;
|
|
|
|
|
|
std::memcpy(&rawValue, &nextMailTime, sizeof(uint32_t));
|
|
|
|
|
|
if (rawValue == 0 || nextMailTime >= 0.0f) {
|
|
|
|
|
|
hasNewMail_ = true;
|
|
|
|
|
|
LOG_INFO("MSG_QUERY_NEXT_MAIL_TIME: Player has pending mail");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
LOG_INFO("MSG_QUERY_NEXT_MAIL_TIME: No pending mail (value=", nextMailTime, ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 21:29:10 -08:00
|
|
|
|
glm::vec3 GameHandler::getComposedWorldPosition() {
|
|
|
|
|
|
if (playerTransportGuid_ != 0 && transportManager_) {
|
|
|
|
|
|
return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Not on transport, return normal movement position
|
|
|
|
|
|
return glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Implement bank, guild bank, and auction house systems
Add 27 new opcodes, packet builders/parsers, handler methods, inventory
extension with 28 bank slots + 7 bank bags, and UI windows for personal
bank, guild bank (6 tabs x 98 slots), and auction house (browse/sell/bid).
Fix Classic gossip parser to omit boxMoney/boxText fields not present in
Vanilla protocol, fix gossip icon labels with text-based NPC type detection,
and add Turtle WoW opcode mappings for bank and auction interactions.
2026-02-16 21:11:18 -08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Bank System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openBank(uint64_t guid) {
|
|
|
|
|
|
if (!isConnected()) return;
|
|
|
|
|
|
auto pkt = BankerActivatePacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeBank() {
|
|
|
|
|
|
bankOpen_ = false;
|
|
|
|
|
|
bankerGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::buyBankSlot() {
|
|
|
|
|
|
if (!isConnected() || !bankOpen_) return;
|
|
|
|
|
|
auto pkt = BuyBankSlotPacket::build(bankerGuid_);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::depositItem(uint8_t srcBag, uint8_t srcSlot) {
|
|
|
|
|
|
if (!isConnected() || !bankOpen_) return;
|
|
|
|
|
|
auto pkt = AutoBankItemPacket::build(srcBag, srcSlot);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::withdrawItem(uint8_t srcBag, uint8_t srcSlot) {
|
|
|
|
|
|
if (!isConnected() || !bankOpen_) return;
|
|
|
|
|
|
auto pkt = AutoStoreBankItemPacket::build(srcBag, srcSlot);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleShowBank(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 8) return;
|
|
|
|
|
|
bankerGuid_ = packet.readUInt64();
|
|
|
|
|
|
bankOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false; // Close gossip when bank opens
|
|
|
|
|
|
// Bank items are already tracked via update fields (bank slot GUIDs)
|
|
|
|
|
|
// Trigger rebuild to populate bank slots in inventory
|
|
|
|
|
|
rebuildOnlineInventory();
|
|
|
|
|
|
LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleBuyBankSlotResult(network::Packet& packet) {
|
|
|
|
|
|
if (packet.getSize() - packet.getReadPos() < 4) return;
|
|
|
|
|
|
uint32_t result = packet.readUInt32();
|
|
|
|
|
|
if (result == 0) {
|
|
|
|
|
|
addSystemChatMessage("Bank slot purchased.");
|
|
|
|
|
|
inventory.setPurchasedBankBagSlots(inventory.getPurchasedBankBagSlots() + 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
addSystemChatMessage("Cannot purchase bank slot.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Guild Bank System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openGuildBank(uint64_t guid) {
|
|
|
|
|
|
if (!isConnected()) return;
|
|
|
|
|
|
auto pkt = GuildBankerActivatePacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeGuildBank() {
|
|
|
|
|
|
guildBankOpen_ = false;
|
|
|
|
|
|
guildBankerGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::queryGuildBankTab(uint8_t tabId) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
guildBankActiveTab_ = tabId;
|
|
|
|
|
|
auto pkt = GuildBankQueryTabPacket::build(guildBankerGuid_, tabId, true);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::buyGuildBankTab() {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
uint8_t nextTab = static_cast<uint8_t>(guildBankData_.tabs.size());
|
|
|
|
|
|
auto pkt = GuildBankBuyTabPacket::build(guildBankerGuid_, nextTab);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::depositGuildBankMoney(uint32_t amount) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankDepositMoneyPacket::build(guildBankerGuid_, amount);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::withdrawGuildBankMoney(uint32_t amount) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankWithdrawMoneyPacket::build(guildBankerGuid_, amount);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::guildBankWithdrawItem(uint8_t tabId, uint8_t bankSlot, uint8_t destBag, uint8_t destSlot) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankSwapItemsPacket::buildBankToInventory(guildBankerGuid_, tabId, bankSlot, destBag, destSlot);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::guildBankDepositItem(uint8_t tabId, uint8_t bankSlot, uint8_t srcBag, uint8_t srcSlot) {
|
|
|
|
|
|
if (!isConnected() || !guildBankOpen_) return;
|
|
|
|
|
|
auto pkt = GuildBankSwapItemsPacket::buildInventoryToBank(guildBankerGuid_, tabId, bankSlot, srcBag, srcSlot);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleGuildBankList(network::Packet& packet) {
|
|
|
|
|
|
GuildBankData data;
|
|
|
|
|
|
if (!GuildBankListParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_GUILD_BANK_LIST");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
guildBankData_ = data;
|
|
|
|
|
|
guildBankOpen_ = true;
|
|
|
|
|
|
guildBankActiveTab_ = data.tabId;
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure item info for all guild bank items
|
|
|
|
|
|
for (const auto& item : data.tabItems) {
|
|
|
|
|
|
if (item.itemEntry != 0) ensureItemInfo(item.itemEntry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_GUILD_BANK_LIST: tab=", (int)data.tabId,
|
|
|
|
|
|
" items=", data.tabItems.size(),
|
|
|
|
|
|
" tabs=", data.tabs.size(),
|
|
|
|
|
|
" money=", data.money);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// Auction House System
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::openAuctionHouse(uint64_t guid) {
|
|
|
|
|
|
if (!isConnected()) return;
|
|
|
|
|
|
auto pkt = AuctionHelloPacket::build(guid);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::closeAuctionHouse() {
|
|
|
|
|
|
auctionOpen_ = false;
|
|
|
|
|
|
auctioneerGuid_ = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8_t levelMax,
|
|
|
|
|
|
uint32_t quality, uint32_t itemClass, uint32_t itemSubClass,
|
|
|
|
|
|
uint32_t invTypeMask, uint8_t usableOnly, uint32_t offset)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!isConnected() || !auctionOpen_) return;
|
|
|
|
|
|
if (auctionSearchDelayTimer_ > 0.0f) {
|
|
|
|
|
|
addSystemChatMessage("Please wait before searching again.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingAuctionTarget_ = AuctionResultTarget::BROWSE;
|
|
|
|
|
|
auto pkt = AuctionListItemsPacket::build(auctioneerGuid_, offset, name,
|
|
|
|
|
|
levelMin, levelMax, invTypeMask,
|
|
|
|
|
|
itemClass, itemSubClass, quality, usableOnly, 0);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionSellItem(uint64_t itemGuid, uint32_t stackCount,
|
|
|
|
|
|
uint32_t bid, uint32_t buyout, uint32_t duration)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!isConnected() || !auctionOpen_) return;
|
|
|
|
|
|
auto pkt = AuctionSellItemPacket::build(auctioneerGuid_, itemGuid, stackCount, bid, buyout, duration);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionPlaceBid(uint32_t auctionId, uint32_t amount) {
|
|
|
|
|
|
if (!isConnected() || !auctionOpen_) return;
|
|
|
|
|
|
auto pkt = AuctionPlaceBidPacket::build(auctioneerGuid_, auctionId, amount);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionBuyout(uint32_t auctionId, uint32_t buyoutPrice) {
|
|
|
|
|
|
auctionPlaceBid(auctionId, buyoutPrice);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionCancelItem(uint32_t auctionId) {
|
|
|
|
|
|
if (!isConnected() || !auctionOpen_) return;
|
|
|
|
|
|
auto pkt = AuctionRemoveItemPacket::build(auctioneerGuid_, auctionId);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionListOwnerItems(uint32_t offset) {
|
|
|
|
|
|
if (!isConnected() || !auctionOpen_) return;
|
|
|
|
|
|
pendingAuctionTarget_ = AuctionResultTarget::OWNER;
|
|
|
|
|
|
auto pkt = AuctionListOwnerItemsPacket::build(auctioneerGuid_, offset);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::auctionListBidderItems(uint32_t offset) {
|
|
|
|
|
|
if (!isConnected() || !auctionOpen_) return;
|
|
|
|
|
|
pendingAuctionTarget_ = AuctionResultTarget::BIDDER;
|
|
|
|
|
|
auto pkt = AuctionListBidderItemsPacket::build(auctioneerGuid_, offset);
|
|
|
|
|
|
socket->send(pkt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuctionHello(network::Packet& packet) {
|
|
|
|
|
|
size_t pktSize = packet.getSize();
|
|
|
|
|
|
size_t readPos = packet.getReadPos();
|
|
|
|
|
|
LOG_INFO("handleAuctionHello: packetSize=", pktSize, " readPos=", readPos);
|
|
|
|
|
|
// Hex dump first 20 bytes for debugging
|
|
|
|
|
|
const auto& rawData = packet.getData();
|
|
|
|
|
|
std::string hex;
|
|
|
|
|
|
size_t dumpLen = std::min<size_t>(rawData.size(), 20);
|
|
|
|
|
|
for (size_t i = 0; i < dumpLen; ++i) {
|
|
|
|
|
|
char b[4]; snprintf(b, sizeof(b), "%02x ", rawData[i]);
|
|
|
|
|
|
hex += b;
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO(" hex dump: ", hex);
|
|
|
|
|
|
AuctionHelloData data;
|
|
|
|
|
|
if (!AuctionHelloParser::parse(packet, data)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse MSG_AUCTION_HELLO response, size=", pktSize, " readPos=", readPos);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
auctioneerGuid_ = data.auctioneerGuid;
|
|
|
|
|
|
auctionHouseId_ = data.auctionHouseId;
|
|
|
|
|
|
auctionOpen_ = true;
|
|
|
|
|
|
gossipWindowOpen = false; // Close gossip when auction house opens
|
|
|
|
|
|
auctionActiveTab_ = 0;
|
|
|
|
|
|
auctionBrowseResults_ = AuctionListResult{};
|
|
|
|
|
|
auctionOwnerResults_ = AuctionListResult{};
|
|
|
|
|
|
auctionBidderResults_ = AuctionListResult{};
|
|
|
|
|
|
LOG_INFO("MSG_AUCTION_HELLO: auctioneer=0x", std::hex, data.auctioneerGuid, std::dec,
|
|
|
|
|
|
" house=", data.auctionHouseId, " enabled=", (int)data.enabled);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuctionListResult(network::Packet& packet) {
|
|
|
|
|
|
AuctionListResult result;
|
|
|
|
|
|
if (!AuctionListResultParser::parse(packet, result)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_AUCTION_LIST_RESULT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auctionBrowseResults_ = result;
|
|
|
|
|
|
auctionSearchDelayTimer_ = result.searchDelay / 1000.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure item info for all auction items
|
|
|
|
|
|
for (const auto& entry : result.auctions) {
|
|
|
|
|
|
if (entry.itemEntry != 0) ensureItemInfo(entry.itemEntry);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LOG_INFO("SMSG_AUCTION_LIST_RESULT: ", result.auctions.size(), " items, total=", result.totalCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuctionOwnerListResult(network::Packet& packet) {
|
|
|
|
|
|
AuctionListResult result;
|
|
|
|
|
|
if (!AuctionListResultParser::parse(packet, result)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_AUCTION_OWNER_LIST_RESULT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
auctionOwnerResults_ = result;
|
|
|
|
|
|
for (const auto& entry : result.auctions) {
|
|
|
|
|
|
if (entry.itemEntry != 0) ensureItemInfo(entry.itemEntry);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_AUCTION_OWNER_LIST_RESULT: ", result.auctions.size(), " items");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuctionBidderListResult(network::Packet& packet) {
|
|
|
|
|
|
AuctionListResult result;
|
|
|
|
|
|
if (!AuctionListResultParser::parse(packet, result)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_AUCTION_BIDDER_LIST_RESULT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
auctionBidderResults_ = result;
|
|
|
|
|
|
for (const auto& entry : result.auctions) {
|
|
|
|
|
|
if (entry.itemEntry != 0) ensureItemInfo(entry.itemEntry);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_AUCTION_BIDDER_LIST_RESULT: ", result.auctions.size(), " items");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void GameHandler::handleAuctionCommandResult(network::Packet& packet) {
|
|
|
|
|
|
AuctionCommandResult result;
|
|
|
|
|
|
if (!AuctionCommandResultParser::parse(packet, result)) {
|
|
|
|
|
|
LOG_WARNING("Failed to parse SMSG_AUCTION_COMMAND_RESULT");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const char* actions[] = {"Create", "Cancel", "Bid", "Buyout"};
|
|
|
|
|
|
const char* actionName = (result.action < 4) ? actions[result.action] : "Unknown";
|
|
|
|
|
|
|
|
|
|
|
|
if (result.errorCode == 0) {
|
|
|
|
|
|
std::string msg = std::string("Auction ") + actionName + " successful.";
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
// Refresh appropriate list
|
|
|
|
|
|
if (result.action == 0) auctionListOwnerItems();
|
|
|
|
|
|
else if (result.action == 1) auctionListOwnerItems();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const char* errors[] = {"OK", "Inventory", "Not enough money", "Item not found",
|
|
|
|
|
|
"Higher bid", "Increment", "Not enough items",
|
|
|
|
|
|
"DB error", "Restricted account"};
|
|
|
|
|
|
const char* errName = (result.errorCode < 9) ? errors[result.errorCode] : "Unknown";
|
|
|
|
|
|
std::string msg = std::string("Auction ") + actionName + " failed: " + errName;
|
|
|
|
|
|
addSystemChatMessage(msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName,
|
|
|
|
|
|
" error=", result.errorCode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} // namespace game
|
|
|
|
|
|
} // namespace wowee
|