Compare commits

..

No commits in common. "0b8e1834f628ccbecb60954d4c53f86f4a0a2210" and "e0346c85df4f532cd7c1bdb9ad48c7824cbcb6e3" have entirely different histories.

24 changed files with 551 additions and 2293 deletions

View file

@ -25,9 +25,6 @@ Implemented (working in normal use):
- Talent tree UI with proper visuals and functionality
- Pet tracking (SMSG_PET_SPELLS), dismiss pet button
- Party: group invites, party list, out-of-range member health (SMSG_PARTY_MEMBER_STATS)
- Nameplates: NPC subtitles, guild names, elite/boss/rare borders, quest/raid indicators, cast bars, debuff dots
- Floating combat text: world-space damage/heal numbers above entities with 3D projection
- Target/focus frames: guild name, creature type, rank badges, combo points, cast bars
- Map exploration: subzone-level fog-of-war reveal
- Warden anti-cheat: full module execution via Unicorn Engine x86 emulation; module caching
- Audio: ambient, movement, combat, spell, and UI sound systems

View file

@ -135,13 +135,6 @@ public:
bool isEntityMoving() const { return isMoving_; }
/// True only during the active interpolation phase (before reaching destination).
/// Unlike isEntityMoving(), this does NOT include the dead-reckoning overrun window,
/// so animations (Run/Walk) should use this to avoid "running in place" after arrival.
bool isActivelyMoving() const {
return isMoving_ && moveElapsed_ < moveDuration_;
}
// Returns the latest server-authoritative position: destination if moving, current if not.
// Unlike getX/Y/Z (which only update via updateMovement), this always reflects the
// last known server position regardless of distance culling.
@ -284,14 +277,18 @@ protected:
/**
* Player entity
* Name is inherited from Unit do NOT redeclare it here or the
* shadowed field will diverge from Unit::name, causing nameplates
* and other Unit*-based lookups to read an empty string.
*/
class Player : public Unit {
public:
Player() { type = ObjectType::PLAYER; }
explicit Player(uint64_t guid) : Unit(guid) { type = ObjectType::PLAYER; }
// Name
const std::string& getName() const { return name; }
void setName(const std::string& n) { name = n; }
protected:
std::string name;
};
/**

View file

@ -453,7 +453,6 @@ public:
uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE)
uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE)
std::chrono::steady_clock::time_point inviteReceivedTime{};
std::string bgName; // human-readable BG/arena name
};
// Available BG list (populated by SMSG_BATTLEFIELD_LIST)
@ -609,33 +608,6 @@ public:
uint32_t getPetitionCost() const { return petitionCost_; }
uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; }
// Petition signatures (guild charter signing flow)
struct PetitionSignature {
uint64_t playerGuid = 0;
std::string playerName; // resolved later or empty
};
struct PetitionInfo {
uint64_t petitionGuid = 0;
uint64_t ownerGuid = 0;
std::string guildName;
uint32_t signatureCount = 0;
uint32_t signaturesRequired = 9; // guild default; arena teams differ
std::vector<PetitionSignature> signatures;
bool showUI = false;
};
const PetitionInfo& getPetitionInfo() const { return petitionInfo_; }
bool hasPetitionSignaturesUI() const { return petitionInfo_.showUI; }
void clearPetitionSignaturesUI() { petitionInfo_.showUI = false; }
void signPetition(uint64_t petitionGuid);
void turnInPetition(uint64_t petitionGuid);
// Guild name lookup for other players' nameplates
// Returns the guild name for a given guildId, or empty if unknown.
// Automatically queries the server for unknown guild IDs.
const std::string& lookupGuildName(uint32_t guildId);
// Returns the guildId for a player entity (from PLAYER_GUILDID update field).
uint32_t getEntityGuildId(uint64_t guid) const;
// Ready check
struct ReadyCheckResult {
std::string name;
@ -695,16 +667,6 @@ public:
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? static_cast<int>(it->second.rank) : -1;
}
// Returns creature type (1=Beast,2=Dragonkin,...,7=Humanoid,...) or 0 if not cached
uint32_t getCreatureType(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.creatureType : 0;
}
// Returns creature family (e.g. pet family for beasts) or 0
uint32_t getCreatureFamily(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.family : 0;
}
// ---- Phase 2: Combat ----
void startAutoAttack(uint64_t targetGuid);
@ -969,10 +931,6 @@ public:
using StandStateCallback = std::function<void(uint8_t standState)>;
void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); }
// Appearance changed callback — fired when PLAYER_BYTES or facial features update (barber shop, etc.)
using AppearanceChangedCallback = std::function<void()>;
void setAppearanceChangedCallback(AppearanceChangedCallback cb) { appearanceChangedCallback_ = std::move(cb); }
// Ghost state callback — fired when player enters or leaves ghost (spirit) form
using GhostStateCallback = std::function<void(bool isGhost)>;
void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); }
@ -1244,17 +1202,6 @@ public:
uint32_t getPetUnlearnCost() const { return petUnlearnCost_; }
void confirmPetUnlearn();
void cancelPetUnlearn() { petUnlearnPending_ = false; }
// Barber shop
bool isBarberShopOpen() const { return barberShopOpen_; }
void closeBarberShop() { barberShopOpen_ = false; }
void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair);
// Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK)
uint32_t getInstanceDifficulty() const { return instanceDifficulty_; }
bool isInstanceHeroic() const { return instanceIsHeroic_; }
bool isInInstance() const { return inInstance_; }
/** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const;
/** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */
@ -1451,11 +1398,8 @@ public:
uint32_t seasonGames = 0;
uint32_t seasonWins = 0;
uint32_t rank = 0;
std::string teamName;
uint32_t teamType = 0; // 2, 3, or 5
};
const std::vector<ArenaTeamStats>& getArenaTeamStats() const { return arenaTeamStats_; }
void requestArenaTeamRoster(uint32_t teamId);
// ---- Arena Team Roster ----
struct ArenaTeamMember {
@ -1507,7 +1451,6 @@ public:
std::string itemName;
uint8_t itemQuality = 0;
uint32_t rollCountdownMs = 60000; // Duration of roll window in ms
uint8_t voteMask = 0xFF; // Bitmask: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant
std::chrono::steady_clock::time_point rollStartedAt{};
struct PlayerRollResult {
@ -2068,7 +2011,6 @@ public:
void openItemBySlot(int backpackIndex);
void openItemInBag(int bagIndex, int slotIndex);
void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1);
void splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count);
void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot);
void swapBagSlots(int srcBagIndex, int dstBagIndex);
void useItemById(uint32_t itemId);
@ -2395,9 +2337,6 @@ private:
void handleGuildInvite(network::Packet& packet);
void handleGuildCommandResult(network::Packet& packet);
void handlePetitionShowlist(network::Packet& packet);
void handlePetitionQueryResponse(network::Packet& packet);
void handlePetitionShowSignatures(network::Packet& packet);
void handlePetitionSignResults(network::Packet& packet);
void handlePetSpells(network::Packet& packet);
void handleTurnInPetitionResults(network::Packet& packet);
@ -2898,7 +2837,6 @@ private:
// Instance difficulty
uint32_t instanceDifficulty_ = 0;
bool instanceIsHeroic_ = false;
bool inInstance_ = false;
// Raid target markers (icon 0-7 -> guid; 0 = empty slot)
std::array<uint64_t, kRaidMarkCount> raidTargetGuids_ = {};
@ -3014,22 +2952,16 @@ private:
GuildInfoData guildInfoData_;
GuildQueryResponseData guildQueryData_;
bool hasGuildRoster_ = false;
std::unordered_map<uint32_t, std::string> guildNameCache_; // guildId → guild name
std::unordered_set<uint32_t> pendingGuildNameQueries_; // in-flight guild queries
bool pendingGuildInvite_ = false;
std::string pendingGuildInviterName_;
std::string pendingGuildInviteGuildName_;
bool showPetitionDialog_ = false;
uint32_t petitionCost_ = 0;
uint64_t petitionNpcGuid_ = 0;
PetitionInfo petitionInfo_;
uint64_t activeCharacterGuid_ = 0;
Race playerRace_ = Race::HUMAN;
// Barber shop
bool barberShopOpen_ = false;
// ---- Phase 5: Loot ----
bool lootWindowOpen = false;
bool autoLoot_ = false;
@ -3385,7 +3317,6 @@ private:
NpcAggroCallback npcAggroCallback_;
NpcRespawnCallback npcRespawnCallback_;
StandStateCallback standStateCallback_;
AppearanceChangedCallback appearanceChangedCallback_;
GhostStateCallback ghostStateCallback_;
MeleeSwingCallback meleeSwingCallback_;
uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing

View file

@ -61,9 +61,6 @@ struct CombatTextEntry {
float age = 0.0f; // Seconds since creation (for fadeout)
bool isPlayerSource = false; // True if player dealt this
uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower
uint64_t srcGuid = 0; // Source entity (attacker/caster)
uint64_t dstGuid = 0; // Destination entity (victim/target) — used for world-space positioning
float xSeed = 0.0f; // Random horizontal offset seed (-1..1) to stagger overlapping text
static constexpr float LIFETIME = 2.5f;
bool isExpired() const { return age >= LIFETIME; }

View file

@ -2046,13 +2046,6 @@ public:
static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot);
};
/** CMSG_SPLIT_ITEM packet builder */
class SplitItemPacket {
public:
static network::Packet build(uint8_t srcBag, uint8_t srcSlot,
uint8_t dstBag, uint8_t dstSlot, uint8_t count);
};
/** CMSG_SWAP_INV_ITEM packet builder */
class SwapInvItemPacket {
public:
@ -2796,12 +2789,5 @@ public:
static network::Packet build(int32_t titleBit);
};
/** CMSG_ALTER_APPEARANCE barber shop: change hair style, color, facial hair.
* Payload: uint32 hairStyle, uint32 hairColor, uint32 facialHair. */
class AlterAppearancePacket {
public:
static network::Packet build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair);
};
} // namespace game
} // namespace wowee

View file

@ -13,7 +13,6 @@
#include <string>
#include <optional>
#include <random>
#include <chrono>
#include <future>
namespace wowee {
@ -435,9 +434,6 @@ private:
void* glowVBMapped_ = nullptr;
std::unordered_map<uint32_t, M2ModelGPU> models;
// Grace period for model cleanup: track when a model first became instanceless.
// Models are only evicted after 60 seconds with no instances.
std::unordered_map<uint32_t, std::chrono::steady_clock::time_point> modelUnusedSince_;
std::vector<M2Instance> instances;
// O(1) dedup: key = (modelId, quantized x, quantized y, quantized z) → instanceId

View file

@ -154,9 +154,6 @@ public:
void triggerLevelUpEffect(const glm::vec3& position);
void cancelEmote();
// Screenshot capture — copies swapchain image to PNG file
bool captureScreenshot(const std::string& outputPath);
// Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT)
// useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path
void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,

View file

@ -279,9 +279,6 @@ public:
/** Process one ready tile (for loading screens with per-tile progress updates) */
void processOneReadyTile();
/** Process a bounded batch of ready tiles with async GPU upload (no sync wait) */
void processReadyTiles();
private:
/**
* Get tile coordinates from GL world position
@ -320,6 +317,10 @@ private:
*/
void workerLoop();
/**
* Main thread: poll for completed tiles and upload to GPU
*/
void processReadyTiles();
void ensureGroundEffectTablesLoaded();
void generateGroundClutterPlacements(std::shared_ptr<PendingTile>& pending,
std::unordered_set<uint32_t>& preparedModelIds);

View file

@ -86,8 +86,7 @@ private:
bool showEntityWindow = false;
bool showChatWindow = true;
bool showMinimap_ = true; // M key toggles minimap
bool showNameplates_ = true; // V key toggles enemy/NPC nameplates
bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates
bool showNameplates_ = true; // V key toggles nameplates
float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions
uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none)
ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click
@ -366,7 +365,6 @@ private:
void renderQuestOfferRewardWindow(game::GameHandler& gameHandler);
void renderVendorWindow(game::GameHandler& gameHandler);
void renderTrainerWindow(game::GameHandler& gameHandler);
void renderBarberShopWindow(game::GameHandler& gameHandler);
void renderStableWindow(game::GameHandler& gameHandler);
void renderTaxiWindow(game::GameHandler& gameHandler);
void renderLogoutCountdown(game::GameHandler& gameHandler);
@ -400,7 +398,6 @@ private:
void renderBattlegroundScore(game::GameHandler& gameHandler);
void renderDPSMeter(game::GameHandler& gameHandler);
void renderDurabilityWarning(game::GameHandler& gameHandler);
void takeScreenshot(game::GameHandler& gameHandler);
/**
* Inventory screen
@ -535,24 +532,6 @@ private:
// Vendor search filter
char vendorSearchFilter_[128] = "";
// Vendor purchase confirmation for expensive items
bool vendorConfirmOpen_ = false;
uint64_t vendorConfirmGuid_ = 0;
uint32_t vendorConfirmItemId_ = 0;
uint32_t vendorConfirmSlot_ = 0;
uint32_t vendorConfirmQty_ = 1;
uint32_t vendorConfirmPrice_ = 0;
std::string vendorConfirmItemName_;
// Barber shop UI state
int barberHairStyle_ = 0;
int barberHairColor_ = 0;
int barberFacialHair_ = 0;
int barberOrigHairStyle_ = 0;
int barberOrigHairColor_ = 0;
int barberOrigFacialHair_ = 0;
bool barberInitialized_ = false;
// Trainer search filter
char trainerSearchFilter_[128] = "";
@ -665,7 +644,6 @@ private:
float resurrectFlashTimer_ = 0.0f;
static constexpr float kResurrectFlashDuration = 3.0f;
bool ghostStateCallbackSet_ = false;
bool appearanceCallbackSet_ = false;
bool ghostOpacityStateKnown_ = false;
bool ghostOpacityLastState_ = false;
uint32_t ghostOpacityLastInstanceId_ = 0;

View file

@ -187,14 +187,6 @@ private:
uint8_t destroyCount_ = 1;
std::string destroyItemName_;
// Stack split popup state
bool splitConfirmOpen_ = false;
uint8_t splitBag_ = 0xFF;
uint8_t splitSlot_ = 0;
int splitMax_ = 1;
int splitCount_ = 1;
std::string splitItemName_;
// Pending chat item link from shift-click
std::string pendingChatItemLink_;

View file

@ -45,12 +45,6 @@ private:
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, VkDescriptorSet> bgTextureCache_; // tabId -> bg texture
// Talent learn confirmation
bool talentConfirmOpen_ = false;
uint32_t pendingTalentId_ = 0;
uint32_t pendingTalentRank_ = 0;
std::string pendingTalentName_;
// GlyphProperties.dbc cache: glyphId -> { spellId, isMajor }
struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; };
std::unordered_map<uint32_t, GlyphInfo> glyphProperties_; // glyphId -> info

View file

@ -1647,11 +1647,7 @@ void Application::update(float deltaTime) {
// startMoveTo() in handleMonsterMove, regardless of distance-cull.
// This correctly detects movement for distant creatures (> 150u)
// where updateMovement() is not called and getX/Y/Z() stays stale.
// Use isActivelyMoving() (not isEntityMoving()) so the
// Run/Walk animation stops when the creature reaches its
// destination, rather than persisting through the dead-
// reckoning overrun window.
const bool entityIsMoving = entity->isActivelyMoving();
const bool entityIsMoving = entity->isEntityMoving();
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
@ -1720,110 +1716,6 @@ void Application::update(float deltaTime) {
}
}
// --- Online player render sync (position, orientation, animation) ---
// Mirrors the creature sync loop above but without collision guard or
// weapon-attach logic. Without this, online players never transition
// back to Stand after movement stops ("run in place" bug).
auto playerSyncStart = std::chrono::steady_clock::now();
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
auto* charRenderer = renderer->getCharacterRenderer();
glm::vec3 pPos(0.0f);
bool havePPos = false;
if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ());
havePPos = true;
}
const float pSyncRadiusSq = 320.0f * 320.0f;
for (const auto& [guid, instanceId] : playerInstances_) {
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity || entity->getType() != game::ObjectType::PLAYER) continue;
// Distance cull
if (havePPos) {
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
glm::vec3 d = latestCanonical - pPos;
if (glm::dot(d, d) > pSyncRadiusSq) continue;
}
// Position sync
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
auto posIt = creatureRenderPosCache_.find(guid);
if (posIt == creatureRenderPosCache_.end()) {
charRenderer->setInstancePosition(instanceId, renderPos);
creatureRenderPosCache_[guid] = renderPos;
} else {
const glm::vec3 prevPos = posIt->second;
const glm::vec2 delta2(renderPos.x - prevPos.x, renderPos.y - prevPos.y);
float planarDist = glm::length(delta2);
float dz = std::abs(renderPos.z - prevPos.z);
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
const bool deadOrCorpse = unitPtr->getHealth() == 0;
const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f);
const bool entityIsMoving = entity->isActivelyMoving();
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
} else if (planarDist > 0.03f || dz > 0.08f) {
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
}
posIt->second = renderPos;
// Drive movement animation (same logic as creatures)
const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0;
const bool isWalkingNow = creatureWalkingState_.count(guid) > 0;
const bool isFlyingNow = creatureFlyingState_.count(guid) > 0;
bool prevMoving = creatureWasMoving_[guid];
bool prevSwimming = creatureWasSwimming_[guid];
bool prevFlying = creatureWasFlying_[guid];
bool prevWalking = creatureWasWalking_[guid];
const bool stateChanged = (isMovingNow != prevMoving) ||
(isSwimmingNow != prevSwimming) ||
(isFlyingNow != prevFlying) ||
(isWalkingNow != prevWalking && isMovingNow);
if (stateChanged) {
creatureWasMoving_[guid] = isMovingNow;
creatureWasSwimming_[guid] = isSwimmingNow;
creatureWasFlying_[guid] = isFlyingNow;
creatureWasWalking_[guid] = isWalkingNow;
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) {
uint32_t targetAnim;
if (isMovingNow) {
if (isFlyingNow) targetAnim = 159u; // FlyForward
else if (isSwimmingNow) targetAnim = 42u; // Swim
else if (isWalkingNow) targetAnim = 4u; // Walk
else targetAnim = 5u; // Run
} else {
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
else targetAnim = 0u; // Stand
}
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
}
}
}
// Orientation sync
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
}
}
{
float psMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - playerSyncStart).count();
if (psMs > 5.0f) {
LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (",
playerInstances_.size(), " players)");
}
}
// Movement heartbeat is sent from GameHandler::update() to avoid
// duplicate packets from multiple update loops.
@ -2098,7 +1990,7 @@ void Application::setupUICallbacks() {
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
renderer->getTerrainManager()->processReadyTiles();
renderer->getTerrainManager()->processAllReadyTiles();
{
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> nearbyTiles;
@ -2131,12 +2023,10 @@ void Application::setupUICallbacks() {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(0.5f);
}
// Kick off async upload for any tiles that finished background
// parsing. Use the bounded processReadyTiles() instead of
// processAllReadyTiles() to avoid multi-second main-thread stalls
// when many tiles are ready (the rest will finalize over subsequent
// frames via the normal terrain update loop).
renderer->getTerrainManager()->processReadyTiles();
// Flush any tiles that finished background parsing during the cast
// (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before
// the first frame at the new position.
renderer->getTerrainManager()->processAllReadyTiles();
// Queue all remaining tiles within the load radius (8 tiles = 17x17)
// at the new position. precacheTiles skips already-loaded/pending tiles,
@ -2999,50 +2889,29 @@ void Application::setupUICallbacks() {
}
});
// NPC/player death callback (online mode) - play death animation
// NPC death callback (online mode) - play death animation
gameHandler->setNpcDeathCallback([this](uint64_t guid) {
deadCreatureGuids_.insert(guid);
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
else {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death
}
});
// NPC/player respawn callback (online mode) - reset to idle animation
// NPC respawn callback (online mode) - reset to idle animation
gameHandler->setNpcRespawnCallback([this](uint64_t guid) {
deadCreatureGuids_.erase(guid);
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
else {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->playAnimation(it->second, 0, true); // Idle
}
});
// NPC/player swing callback (online mode) - play attack animation
// NPC swing callback (online mode) - play attack animation
gameHandler->setNpcSwingCallback([this](uint64_t guid) {
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
else {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 16, false); // Attack
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack
}
});
@ -4854,42 +4723,24 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) {
app->deadCreatureGuids_.insert(guid);
uint32_t instanceId = 0;
auto it = app->creatureInstances_.find(guid);
if (it != app->creatureInstances_.end()) instanceId = it->second;
else {
auto pit = app->playerInstances_.find(guid);
if (pit != app->playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0 && cr) {
cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death
if (it != app->creatureInstances_.end() && cr) {
cr->playAnimation(it->second, 1, false); // animation ID 1 = Death
}
});
gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) {
app->deadCreatureGuids_.erase(guid);
uint32_t instanceId = 0;
auto it = app->creatureInstances_.find(guid);
if (it != app->creatureInstances_.end()) instanceId = it->second;
else {
auto pit = app->playerInstances_.find(guid);
if (pit != app->playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0 && cr) {
cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle
if (it != app->creatureInstances_.end() && cr) {
cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle
}
});
gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) {
uint32_t instanceId = 0;
auto it = app->creatureInstances_.find(guid);
if (it != app->creatureInstances_.end()) instanceId = it->second;
else {
auto pit = app->playerInstances_.find(guid);
if (pit != app->playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0 && cr) {
cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1
if (it != app->creatureInstances_.end() && cr) {
cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1
}
});
}
@ -7193,7 +7044,6 @@ void Application::despawnOnlinePlayer(uint64_t guid) {
playerInstances_.erase(it);
onlinePlayerAppearance_.erase(guid);
pendingOnlinePlayerEquipment_.erase(guid);
creatureRenderPosCache_.erase(guid);
creatureSwimmingState_.erase(guid);
creatureWalkingState_.erase(guid);
creatureFlyingState_.erase(guid);

View file

@ -759,8 +759,6 @@ void GameHandler::disconnect() {
activeCharacterGuid_ = 0;
playerNameCache.clear();
pendingNameQueries.clear();
guildNameCache_.clear();
pendingGuildNameQueries_.clear();
friendGuids_.clear();
contacts_.clear();
transportAttachments_.clear();
@ -2344,7 +2342,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint32_t randProp =*/ packet.readUInt32();
}
uint32_t countdown = packet.readUInt32();
uint8_t voteMask = packet.readUInt8();
/*uint8_t voteMask =*/ packet.readUInt8();
// Trigger the roll popup for local player
pendingLootRollActive_ = true;
pendingLootRoll_.objectGuid = objectGuid;
@ -2358,10 +2356,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId);
pendingLootRoll_.itemQuality = info ? static_cast<uint8_t>(info->quality) : 0;
pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000;
pendingLootRoll_.voteMask = voteMask;
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName,
") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec);
") slot=", slot);
break;
}
@ -2681,8 +2678,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
case Opcode::SMSG_ENABLE_BARBER_SHOP:
// Sent by server when player sits in barber chair — triggers barber shop UI
// No payload; we don't have barber shop UI yet, so just log
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
barberShopOpen_ = true;
break;
case Opcode::SMSG_FEIGN_DEATH_RESISTED:
addUIError("Your Feign Death was resisted.");
@ -2952,7 +2949,6 @@ void GameHandler::handlePacket(network::Packet& packet) {
struct SpellMissLogEntry {
uint64_t victimGuid = 0;
uint8_t missInfo = 0;
uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT)
};
std::vector<SpellMissLogEntry> parsedMisses;
parsedMisses.reserve(storedLimit);
@ -2971,18 +2967,17 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
const uint8_t missInfo = packet.readUInt8();
// REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult
uint32_t reflectSpellId = 0;
if (missInfo == 11) {
if (packet.getSize() - packet.getReadPos() >= 5) {
reflectSpellId = packet.readUInt32();
/*uint8_t reflectResult =*/ packet.readUInt8();
/*uint32_t reflectSpellId =*/ packet.readUInt32();
/*uint8_t reflectResult =*/ packet.readUInt8();
} else {
truncated = true;
break;
}
}
if (i < storedLimit) {
parsedMisses.push_back({victimGuid, missInfo, reflectSpellId});
parsedMisses.push_back({victimGuid, missInfo});
}
}
@ -2995,15 +2990,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
const uint64_t victimGuid = miss.victimGuid;
const uint8_t missInfo = miss.missInfo;
CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo);
// For REFLECT, use the reflected spell ID so combat text shows the spell name
uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0)
? miss.reflectSpellId : spellId;
if (casterGuid == playerGuid) {
// We cast a spell and it missed the target
addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid);
addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid);
} else if (victimGuid == playerGuid) {
// Enemy spell missed us (we dodged/parried/blocked/resisted/etc.)
addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid);
addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid);
}
}
break;
@ -4258,20 +4250,17 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint8_t auraType = packet.readUInt8();
if (auraType == 3 || auraType == 89) {
// Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes
// WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes
// WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4) = 20 bytes
const bool periodicWotlk = isActiveExpansion("wotlk");
const size_t dotSz = periodicWotlk ? 21u : 16u;
const size_t dotSz = periodicWotlk ? 20u : 16u;
if (packet.getSize() - packet.getReadPos() < dotSz) break;
uint32_t dmg = packet.readUInt32();
if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32();
/*uint32_t school=*/ packet.readUInt32();
uint32_t abs = packet.readUInt32();
uint32_t res = packet.readUInt32();
bool dotCrit = false;
if (periodicWotlk) dotCrit = (packet.readUInt8() != 0);
if (dmg > 0)
addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE,
static_cast<int32_t>(dmg),
addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast<int32_t>(dmg),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (abs > 0)
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(abs),
@ -4289,13 +4278,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint32_t max=*/ packet.readUInt32();
/*uint32_t over=*/ packet.readUInt32();
uint32_t hotAbs = 0;
bool hotCrit = false;
if (healWotlk) {
hotAbs = packet.readUInt32();
hotCrit = (packet.readUInt8() != 0);
/*uint8_t isCrit=*/ packet.readUInt8();
}
addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL,
static_cast<int32_t>(heal),
addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast<int32_t>(heal),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (hotAbs > 0)
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(hotAbs),
@ -4902,7 +4889,6 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t result = packet.readUInt32();
if (result == 0) {
addSystemChatMessage("Hairstyle changed.");
barberShopOpen_ = false;
} else {
const char* msg = (result == 1) ? "Not enough money for new hairstyle."
: (result == 2) ? "You are not at a barber shop."
@ -5806,31 +5792,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_LFG_OFFER_CONTINUE:
addSystemChatMessage("Dungeon Finder: You may continue your dungeon.");
break;
case Opcode::SMSG_LFG_ROLE_CHOSEN: {
// uint64 guid + uint8 ready + uint32 roles
if (packet.getSize() - packet.getReadPos() >= 13) {
uint64_t roleGuid = packet.readUInt64();
uint8_t ready = packet.readUInt8();
uint32_t roles = packet.readUInt32();
// Build a descriptive message for group chat
std::string roleName;
if (roles & 0x02) roleName += "Tank ";
if (roles & 0x04) roleName += "Healer ";
if (roles & 0x08) roleName += "DPS ";
if (roleName.empty()) roleName = "None";
// Find player name
std::string pName = "A player";
if (auto e = entityManager.getEntity(roleGuid))
if (auto u = std::dynamic_pointer_cast<Unit>(e))
pName = u->getName();
if (ready)
addSystemChatMessage(pName + " has chosen: " + roleName);
LOG_DEBUG("SMSG_LFG_ROLE_CHOSEN: guid=", roleGuid,
" ready=", (int)ready, " roles=", roles);
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_LFG_ROLE_CHOSEN:
case Opcode::SMSG_LFG_UPDATE_SEARCH:
case Opcode::SMSG_UPDATE_LFG_LIST:
case Opcode::SMSG_LFG_PLAYER_INFO:
@ -7654,13 +7616,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_PETITION_QUERY_RESPONSE:
handlePetitionQueryResponse(packet);
break;
case Opcode::SMSG_PETITION_SHOW_SIGNATURES:
handlePetitionShowSignatures(packet);
break;
case Opcode::SMSG_PETITION_SIGN_RESULTS:
handlePetitionSignResults(packet);
packet.setReadPos(packet.getSize());
break;
// ---- Pet system ----
@ -7714,17 +7672,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_PET_CAST_FAILED: {
// WotLK: castCount(1) + spellId(4) + reason(1)
// Classic/TBC: spellId(4) + reason(1) (no castCount)
const bool hasCount = isActiveExpansion("wotlk");
const size_t minSize = hasCount ? 6u : 5u;
if (packet.getSize() - packet.getReadPos() >= minSize) {
if (hasCount) /*uint8_t castCount =*/ packet.readUInt8();
if (packet.getSize() - packet.getReadPos() >= 5) {
uint8_t castCount = packet.readUInt8();
uint32_t spellId = packet.readUInt32();
uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1)
? packet.readUInt8() : 0;
LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId,
" reason=", (int)reason);
" reason=", (int)reason, " castCount=", (int)castCount);
if (reason != 0) {
const char* reasonStr = getSpellCastResultString(reason);
const std::string& sName = getSpellName(spellId);
@ -11339,10 +11293,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
// Track player-on-transport state
if (block.guid == playerGuid) {
if (block.onTransport) {
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
setPlayerOnTransport(block.transportGuid, canonicalOffset);
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
@ -11608,9 +11562,6 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
" displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render");
}
}
if (unitInitiallyDead && npcDeathCallback_) {
npcDeathCallback_(block.guid);
}
} else if (creatureSpawnCallback_) {
LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec,
" displayId=", unit->getDisplayId(), " at (",
@ -11957,7 +11908,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
corpseX_, ",", corpseY_, ",", corpseZ_,
") map=", corpseMapId_);
}
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) {
if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
@ -11970,7 +11921,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
LOG_INFO("Player entered ghost form");
}
}
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) {
if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) {
npcRespawnCallback_(block.guid);
npcRespawnNotified = true;
}
@ -12001,7 +11952,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
selfResAvailable_ = false;
LOG_INFO("Player resurrected (dynamic flags)");
}
} else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
} else if (entity->getType() == ObjectType::UNIT) {
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
if (!wasDead && nowDead) {
@ -12137,12 +12088,6 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
" displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render");
}
}
bool isDeadNow = (unit->getHealth() == 0) ||
((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0);
if (isDeadNow && !npcDeathNotified && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
} else if (creatureSpawnCallback_) {
float unitScale2 = 1.0f;
{
@ -12209,7 +12154,6 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES);
const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2);
const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE);
const uint16_t ufStatsV[5] = {
@ -12260,38 +12204,15 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(val);
}
else if (ufPBytesV != 0xFFFF && key == ufPBytesV) {
// PLAYER_BYTES changed (barber shop, polymorph, etc.)
// Update the Character struct so inventory preview refreshes
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.appearanceBytes = val;
break;
}
}
if (appearanceChangedCallback_)
appearanceChangedCallback_();
}
else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) {
// Byte 0 (bits 0-7): facial hair / piercings
uint8_t facialHair = static_cast<uint8_t>(val & 0xFF);
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.facialFeatures = facialHair;
break;
}
}
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots),
" facial=", static_cast<int>(facialHair));
LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots));
inventory.setPurchasedBankBagSlots(bankBagSlots);
// Byte 3 (bits 24-31): REST_STATE
// 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY
uint8_t restStateByte = static_cast<uint8_t>((val >> 24) & 0xFF);
isResting_ = (restStateByte != 0);
if (appearanceChangedCallback_)
appearanceChangedCallback_();
}
else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) {
chosenTitleBit_ = static_cast<int32_t>(val);
@ -12509,10 +12430,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
// Track player-on-transport state from MOVEMENT updates
if (block.onTransport) {
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
setPlayerOnTransport(block.transportGuid, canonicalOffset);
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
@ -12816,15 +12737,6 @@ void GameHandler::handleMessageChat(network::Packet& packet) {
// Track whisper sender for /r command
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
lastWhisperSender_ = data.senderName;
// Auto-reply if AFK or DND
if (afkStatus_ && !data.senderName.empty()) {
std::string reply = afkMessage_.empty() ? "Away from Keyboard" : afkMessage_;
sendChatMessage(ChatType::WHISPER, "<AFK> " + reply, data.senderName);
} else if (dndStatus_ && !data.senderName.empty()) {
std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_;
sendChatMessage(ChatType::WHISPER, "<DND> " + reply, data.senderName);
}
}
// Trigger chat bubble for SAY/YELL messages from others
@ -15632,12 +15544,6 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint
entry.age = 0.0f;
entry.isPlayerSource = isPlayerSource;
entry.powerType = powerType;
entry.srcGuid = srcGuid;
entry.dstGuid = dstGuid;
// Random horizontal stagger so simultaneous hits don't stack vertically
static std::mt19937 rng(std::random_device{}());
std::uniform_real_distribution<float> dist(-1.0f, 1.0f);
entry.xSeed = dist(rng);
combatText.push_back(entry);
// Persistent combat log — use explicit GUIDs if provided, else fall back to
@ -16171,21 +16077,18 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
uint32_t statusId = packet.readUInt32();
// Map BG type IDs to their names (stable across all three expansions)
// BattlemasterList.dbc IDs (3.3.5a)
static const std::pair<uint32_t, const char*> kBgNames[] = {
{1, "Alterac Valley"},
{2, "Warsong Gulch"},
{3, "Arathi Basin"},
{4, "Nagrand Arena"},
{5, "Blade's Edge Arena"},
{6, "All Arenas"},
{7, "Eye of the Storm"},
{8, "Ruins of Lordaeron"},
{6, "Eye of the Storm"},
{9, "Strand of the Ancients"},
{10, "Dalaran Sewers"},
{11, "Ring of Valor"},
{30, "Isle of Conquest"},
{32, "Random Battleground"},
{11, "Isle of Conquest"},
{30, "Nagrand Arena"},
{31, "Blade's Edge Arena"},
{32, "Dalaran Sewers"},
{33, "Ring of Valor"},
{34, "Ruins of Lordaeron"},
};
std::string bgName = "Battleground";
for (const auto& kv : kBgNames) {
@ -16236,7 +16139,6 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
bgQueues_[queueSlot].bgTypeId = bgTypeId;
bgQueues_[queueSlot].arenaType = arenaType;
bgQueues_[queueSlot].statusId = statusId;
bgQueues_[queueSlot].bgName = bgName;
if (statusId == 1) {
bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec;
bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec;
@ -16490,7 +16392,6 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) {
} else {
instanceIsHeroic_ = (instanceDifficulty_ == 1);
}
inInstance_ = true;
LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_);
// Announce difficulty change to the player (only when it actually changes)
@ -17032,25 +16933,7 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t teamId = packet.readUInt32();
std::string teamName = packet.readString();
uint32_t teamType = 0;
if (packet.getSize() - packet.getReadPos() >= 4)
teamType = packet.readUInt32();
LOG_INFO("Arena team query response: id=", teamId, " name=", teamName, " type=", teamType);
// Store name and type in matching ArenaTeamStats entry
for (auto& s : arenaTeamStats_) {
if (s.teamId == teamId) {
s.teamName = teamName;
s.teamType = teamType;
return;
}
}
// No stats entry yet — create a placeholder so we can show the name
ArenaTeamStats stub;
stub.teamId = teamId;
stub.teamName = teamName;
stub.teamType = teamType;
arenaTeamStats_.push_back(std::move(stub));
LOG_INFO("Arena team query response: id=", teamId, " name=", teamName);
}
void GameHandler::handleArenaTeamRoster(network::Packet& packet) {
@ -17194,29 +17077,18 @@ void GameHandler::handleArenaTeamStats(network::Packet& packet) {
stats.seasonWins = packet.readUInt32();
stats.rank = packet.readUInt32();
// Update or insert for this team (preserve name/type from query response)
// Update or insert for this team
for (auto& s : arenaTeamStats_) {
if (s.teamId == stats.teamId) {
stats.teamName = std::move(s.teamName);
stats.teamType = s.teamType;
s = std::move(stats);
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", s.teamId,
" rating=", s.rating, " rank=", s.rank);
s = stats;
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
" rating=", stats.rating, " rank=", stats.rank);
return;
}
}
arenaTeamStats_.push_back(std::move(stats));
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", arenaTeamStats_.back().teamId,
" rating=", arenaTeamStats_.back().rating,
" rank=", arenaTeamStats_.back().rank);
}
void GameHandler::requestArenaTeamRoster(uint32_t teamId) {
if (!socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_ARENA_TEAM_ROSTER));
pkt.writeUInt32(teamId);
socket->send(pkt);
LOG_INFO("Requesting arena team roster for teamId=", teamId);
arenaTeamStats_.push_back(stats);
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
" rating=", stats.rating, " rank=", stats.rank);
}
void GameHandler::handleArenaError(network::Packet& packet) {
@ -18633,12 +18505,6 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
msg.language = ChatLanguage::UNIVERSAL;
msg.message = errMsg;
addLocalChatMessage(msg);
// Play error sound for cast failure feedback
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playError();
}
}
static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) {
@ -19271,13 +19137,6 @@ void GameHandler::confirmTalentWipe() {
talentWipeCost_ = 0;
}
void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) {
if (state != WorldState::IN_WORLD || !socket) return;
auto pkt = AlterAppearancePacket::build(hairStyle, hairColor, facialHair);
socket->send(pkt);
LOG_INFO("sendAlterAppearance: hair=", hairStyle, " color=", hairColor, " facial=", facialHair);
}
// ============================================================
// Phase 4: Group/Party
// ============================================================
@ -19704,28 +19563,6 @@ void GameHandler::queryGuildInfo(uint32_t guildId) {
LOG_INFO("Querying guild info: guildId=", guildId);
}
static const std::string kEmptyString;
const std::string& GameHandler::lookupGuildName(uint32_t guildId) {
if (guildId == 0) return kEmptyString;
auto it = guildNameCache_.find(guildId);
if (it != guildNameCache_.end()) return it->second;
// Query the server if we haven't already
if (pendingGuildNameQueries_.insert(guildId).second) {
queryGuildInfo(guildId);
}
return kEmptyString;
}
uint32_t GameHandler::getEntityGuildId(uint64_t guid) const {
auto entity = entityManager.getEntity(guid);
if (!entity || entity->getType() != ObjectType::PLAYER) return 0;
// PLAYER_GUILDID = UNIT_END + 3 across all expansions
const uint16_t ufUnitEnd = fieldIndex(UF::UNIT_END);
if (ufUnitEnd == 0xFFFF) return 0;
return entity->getField(ufUnitEnd + 3);
}
void GameHandler::createGuild(const std::string& guildName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildCreatePacket::build(guildName);
@ -19774,118 +19611,6 @@ void GameHandler::handlePetitionShowlist(network::Packet& packet) {
LOG_INFO("Petition showlist: cost=", data.cost);
}
void GameHandler::handlePetitionQueryResponse(network::Packet& packet) {
// SMSG_PETITION_QUERY_RESPONSE (3.3.5a):
// uint32 petitionEntry, uint64 petitionGuid, string guildName,
// string bodyText (empty), uint32 flags, uint32 minSignatures,
// uint32 maxSignatures, ...plus more fields we can skip
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 12) return;
/*uint32_t entry =*/ packet.readUInt32();
uint64_t petGuid = packet.readUInt64();
std::string guildName = packet.readString();
/*std::string body =*/ packet.readString();
// Update petition info if it matches our current petition
if (petitionInfo_.petitionGuid == petGuid) {
petitionInfo_.guildName = guildName;
}
LOG_INFO("SMSG_PETITION_QUERY_RESPONSE: guid=", petGuid, " name=", guildName);
packet.setReadPos(packet.getSize()); // skip remaining fields
}
void GameHandler::handlePetitionShowSignatures(network::Packet& packet) {
// SMSG_PETITION_SHOW_SIGNATURES (3.3.5a):
// uint64 itemGuid (petition item in inventory)
// uint64 ownerGuid
// uint32 petitionGuid (low part / entry)
// uint8 signatureCount
// For each signature:
// uint64 playerGuid
// uint32 unk (always 0)
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 21) return;
petitionInfo_ = PetitionInfo{};
petitionInfo_.petitionGuid = packet.readUInt64();
petitionInfo_.ownerGuid = packet.readUInt64();
/*uint32_t petEntry =*/ packet.readUInt32();
uint8_t sigCount = packet.readUInt8();
petitionInfo_.signatureCount = sigCount;
petitionInfo_.signatures.reserve(sigCount);
for (uint8_t i = 0; i < sigCount; ++i) {
if (rem() < 12) break;
PetitionSignature sig;
sig.playerGuid = packet.readUInt64();
/*uint32_t unk =*/ packet.readUInt32();
petitionInfo_.signatures.push_back(sig);
}
petitionInfo_.showUI = true;
LOG_INFO("SMSG_PETITION_SHOW_SIGNATURES: petGuid=", petitionInfo_.petitionGuid,
" owner=", petitionInfo_.ownerGuid,
" sigs=", sigCount);
}
void GameHandler::handlePetitionSignResults(network::Packet& packet) {
// SMSG_PETITION_SIGN_RESULTS (3.3.5a):
// uint64 petitionGuid, uint64 playerGuid, uint32 result
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 20) return;
uint64_t petGuid = packet.readUInt64();
uint64_t playerGuid = packet.readUInt64();
uint32_t result = packet.readUInt32();
switch (result) {
case 0: // PETITION_SIGN_OK
addSystemChatMessage("Petition signed successfully.");
// Increment local count
if (petitionInfo_.petitionGuid == petGuid) {
petitionInfo_.signatureCount++;
PetitionSignature sig;
sig.playerGuid = playerGuid;
petitionInfo_.signatures.push_back(sig);
}
break;
case 1: // PETITION_SIGN_ALREADY_SIGNED
addSystemChatMessage("You have already signed that petition.");
break;
case 2: // PETITION_SIGN_ALREADY_IN_GUILD
addSystemChatMessage("You are already in a guild.");
break;
case 3: // PETITION_SIGN_CANT_SIGN_OWN
addSystemChatMessage("You cannot sign your own petition.");
break;
default:
addSystemChatMessage("Cannot sign petition (error " + std::to_string(result) + ").");
break;
}
LOG_INFO("SMSG_PETITION_SIGN_RESULTS: pet=", petGuid, " player=", playerGuid,
" result=", result);
}
void GameHandler::signPetition(uint64_t petitionGuid) {
if (!socket || state != WorldState::IN_WORLD) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_PETITION_SIGN));
pkt.writeUInt64(petitionGuid);
pkt.writeUInt8(0); // unk
socket->send(pkt);
LOG_INFO("Signing petition: ", petitionGuid);
}
void GameHandler::turnInPetition(uint64_t petitionGuid) {
if (!socket || state != WorldState::IN_WORLD) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_TURN_IN_PETITION));
pkt.writeUInt64(petitionGuid);
socket->send(pkt);
LOG_INFO("Turning in petition: ", petitionGuid);
}
void GameHandler::handleTurnInPetitionResults(network::Packet& packet) {
uint32_t result = 0;
if (!TurnInPetitionResultsParser::parse(packet, result)) return;
@ -19922,30 +19647,18 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
GuildQueryResponseData data;
if (!packetParsers_->parseGuildQueryResponse(packet, data)) return;
// Always cache the guild name for nameplate lookups
if (data.guildId != 0 && !data.guildName.empty()) {
guildNameCache_[data.guildId] = data.guildName;
pendingGuildNameQueries_.erase(data.guildId);
}
// Check if this is the local player's guild
const Character* ch = getActiveCharacter();
bool isLocalGuild = (ch && ch->hasGuild() && ch->guildId == data.guildId);
if (isLocalGuild) {
const bool wasUnknown = guildName_.empty();
guildName_ = data.guildName;
guildQueryData_ = data;
guildRankNames_.clear();
for (uint32_t i = 0; i < 10; ++i) {
guildRankNames_.push_back(data.rankNames[i]);
}
LOG_INFO("Guild name set to: ", guildName_);
if (wasUnknown && !guildName_.empty())
addSystemChatMessage("Guild: <" + guildName_ + ">");
} else {
LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName);
const bool wasUnknown = guildName_.empty();
guildName_ = data.guildName;
guildQueryData_ = data;
guildRankNames_.clear();
for (uint32_t i = 0; i < 10; ++i) {
guildRankNames_.push_back(data.rankNames[i]);
}
LOG_INFO("Guild name set to: ", guildName_);
// Only announce once — when we first learn our own guild name at login.
// Subsequent queries (e.g. querying other players' guilds) are silent.
if (wasUnknown && !guildName_.empty())
addSystemChatMessage("Guild: <" + guildName_ + ">");
}
void GameHandler::handleGuildEvent(network::Packet& packet) {
@ -20219,17 +19932,6 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
addSystemChatMessage("Too far away.");
return;
}
// Stop movement before interacting — servers may reject GO use or
// immediately cancel the resulting spell cast if the player is moving.
const uint32_t moveFlags = movementInfo.flags;
const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD
(moveFlags & 0x00000002u) || // BACKWARD
(moveFlags & 0x00000004u) || // STRAFE_LEFT
(moveFlags & 0x00000008u); // STRAFE_RIGHT
if (isMoving) {
movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags
sendMovement(Opcode::MSG_MOVE_STOP);
}
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
movementInfo.orientation = std::atan2(-dy, dx);
sendMovement(Opcode::MSG_MOVE_SET_FACING);
@ -20237,12 +19939,6 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec,
" entry=", goEntry, " type=", goType,
" name='", goName, "' dist=", entity ? std::sqrt(
(entity->getX() - movementInfo.x) * (entity->getX() - movementInfo.x) +
(entity->getY() - movementInfo.y) * (entity->getY() - movementInfo.y) +
(entity->getZ() - movementInfo.z) * (entity->getZ() - movementInfo.z)) : -1.0f);
auto packet = GameObjectUsePacket::build(guid);
socket->send(packet);
lastInteractedGoGuid_ = guid;
@ -21387,40 +21083,6 @@ void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
socket->send(packet);
}
void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
if (count == 0) return;
// Find a free slot for the split destination: try backpack first, then bags
int freeBp = inventory.findFreeBackpackSlot();
if (freeBp >= 0) {
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeBp);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
socket->send(packet);
return;
}
// Try equipped bags
for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) {
int bagSize = inventory.getBagSize(b);
for (int s = 0; s < bagSize; s++) {
if (inventory.getBagSlot(b, s).empty()) {
uint8_t dstBag = static_cast<uint8_t>(19 + b);
uint8_t dstSlot = static_cast<uint8_t>(s);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=", (int)dstBag,
" slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
socket->send(packet);
return;
}
}
}
addSystemChatMessage("Cannot split: no free inventory slots.");
}
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
@ -21574,8 +21236,8 @@ void GameHandler::unstuckHearth() {
}
void GameHandler::handleLootResponse(network::Packet& packet) {
// All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType).
// WotLK adds a quest item list after the regular items.
// Classic 1.12 and TBC 2.4.3 use 14 bytes/item (no randomSuffix/randomProp fields);
// WotLK 3.3.5a uses 22 bytes/item.
const bool wotlkLoot = isActiveExpansion("wotlk");
if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return;
const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0;
@ -22560,7 +22222,6 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
}
currentMapId_ = mapId;
inInstance_ = false; // cleared on map change; re-set if SMSG_INSTANCE_DIFFICULTY follows
if (socket) {
socket->tracePacketsFor(std::chrono::seconds(12), "new_world");
}

View file

@ -189,8 +189,11 @@ uint32_t classicWireMoveFlags(uint32_t internalFlags) {
// Same as TBC: u8 UpdateFlags, JUMPING=0x2000, 8 speeds, no pitchRate
// ============================================================================
bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
// Validate minimum packet size for updateFlags byte
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[Classic] Movement block packet too small (need at least 1 byte for updateFlags)");
return false;
}
// Classic: UpdateFlags is uint8 (same as TBC)
uint8_t updateFlags = packet.readUInt8();
@ -206,9 +209,6 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
const uint8_t UPDATEFLAG_HAS_POSITION = 0x40;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes
if (rem() < 52) return false;
// Movement flags (u32 only — NO extra flags byte in Classic)
uint32_t moveFlags = packet.readUInt32();
/*uint32_t time =*/ packet.readUInt32();
@ -225,29 +225,26 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// Transport data (Classic: ONTRANSPORT=0x02000000, no timestamp)
if (moveFlags & ClassicMoveFlags::ONTRANSPORT) {
if (rem() < 1) return false;
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 16) return false; // 4 floats
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
block.transportO = packet.readFloat();
// Classic: NO transport timestamp (TBC adds u32 timestamp)
// Classic: NO transport seat byte
}
// Pitch (Classic: only SWIMMING, no FLYING or ONTRANSPORT pitch)
if (moveFlags & ClassicMoveFlags::SWIMMING) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jumping (Classic: JUMPING=0x2000, same as TBC)
if (moveFlags & ClassicMoveFlags::JUMPING) {
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -256,12 +253,12 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// Spline elevation
if (moveFlags & ClassicMoveFlags::SPLINE_ELEVATION) {
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Speeds (Classic: 6 values — no flight speeds, no pitchRate)
if (rem() < 24) return false;
// TBC added flying_speed + backwards_flying_speed (8 total)
// WotLK added pitchRate (9 total)
/*float walkSpeed =*/ packet.readFloat();
float runSpeed = packet.readFloat();
/*float runBackSpeed =*/ packet.readFloat();
@ -274,34 +271,34 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// Spline data (Classic: SPLINE_ENABLED=0x00400000)
if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) {
if (rem() < 4) return false;
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [Classic] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) { // FINAL_POINT
if (rem() < 12) return false;
/*float finalX =*/ packet.readFloat();
/*float finalY =*/ packet.readFloat();
/*float finalZ =*/ packet.readFloat();
} else if (splineFlags & 0x00020000) { // FINAL_TARGET
if (rem() < 8) return false;
/*uint64_t finalTarget =*/ packet.readUInt64();
} else if (splineFlags & 0x00040000) { // FINAL_ANGLE
if (rem() < 4) return false;
/*float finalAngle =*/ packet.readFloat();
}
// Classic spline: timePassed, duration, id, pointCount
if (rem() < 16) return false;
// Classic spline: timePassed, duration, id, nodes, finalNode (same as TBC)
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
if (pointCount > 256) return false;
// points + endPoint (no splineMode in Classic)
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
if (pointCount > 256) {
static uint32_t badClassicSplineCount = 0;
++badClassicSplineCount;
if (badClassicSplineCount <= 5 || (badClassicSplineCount % 100) == 0) {
LOG_WARNING(" [Classic] Spline pointCount=", pointCount,
" exceeds max, capping (occurrence=", badClassicSplineCount, ")");
}
pointCount = 0;
}
for (uint32_t i = 0; i < pointCount; i++) {
/*float px =*/ packet.readFloat();
/*float py =*/ packet.readFloat();
@ -315,7 +312,6 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
}
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
if (rem() < 16) return false;
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -327,25 +323,21 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// High GUID
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t highGuid =*/ packet.readUInt32();
}
// ALL/SELF extra uint32
if (updateFlags & UPDATEFLAG_ALL) {
if (rem() < 4) return false;
/*uint32_t unkAll =*/ packet.readUInt32();
}
// Current melee target as packed guid
if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) {
if (rem() < 1) return false;
/*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
// Transport progress / world time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
@ -1926,9 +1918,6 @@ namespace TurtleMoveFlags {
}
bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
uint8_t updateFlags = packet.readUInt8();
block.updateFlags = static_cast<uint16_t>(updateFlags);
@ -1942,8 +1931,6 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
const uint8_t UPDATEFLAG_HAS_POSITION = 0x40;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes
if (rem() < 52) return false;
size_t livingStart = packet.getReadPos();
uint32_t moveFlags = packet.readUInt32();
@ -1962,10 +1949,8 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// Transport — Classic flag position 0x02000000
if (moveFlags & TurtleMoveFlags::ONTRANSPORT) {
if (rem() < 1) return false; // PackedGuid mask byte
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 20) return false; // 4 floats + u32 timestamp
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
@ -1975,17 +1960,14 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// Pitch (swimming only, Classic-style)
if (moveFlags & TurtleMoveFlags::SWIMMING) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jump data
if (moveFlags & TurtleMoveFlags::JUMPING) {
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -1994,12 +1976,10 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// Spline elevation
if (moveFlags & TurtleMoveFlags::SPLINE_ELEVATION) {
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Turtle: 6 speeds (same as Classic — no flight speeds)
if (rem() < 24) return false; // 6 × float
float walkSpeed = packet.readFloat();
float runSpeed = packet.readFloat();
float runBackSpeed = packet.readFloat();
@ -2017,23 +1997,17 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) ||
(moveFlags & TurtleMoveFlags::SPLINE_TBC);
if (hasSpline) {
if (rem() < 4) return false;
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [Turtle] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) {
if (rem() < 12) return false;
packet.readFloat(); packet.readFloat(); packet.readFloat();
} else if (splineFlags & 0x00020000) {
if (rem() < 8) return false;
packet.readUInt64();
} else if (splineFlags & 0x00040000) {
if (rem() < 4) return false;
packet.readFloat();
}
// timePassed + duration + splineId + pointCount = 16 bytes
if (rem() < 16) return false;
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
@ -2044,12 +2018,10 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
++badTurtleSplineCount;
if (badTurtleSplineCount <= 5 || (badTurtleSplineCount % 100) == 0) {
LOG_WARNING(" [Turtle] Spline pointCount=", pointCount,
" exceeds max (occurrence=", badTurtleSplineCount, ")");
" exceeds max, capping (occurrence=", badTurtleSplineCount, ")");
}
return false;
pointCount = 0;
}
// points + endPoint
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
for (uint32_t i = 0; i < pointCount; i++) {
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
@ -2062,7 +2034,6 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
" bytes, readPos now=", packet.getReadPos());
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
if (rem() < 16) return false;
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -2074,22 +2045,18 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// High GUID — 1×u32
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t highGuid =*/ packet.readUInt32();
}
if (updateFlags & UPDATEFLAG_ALL) {
if (rem() < 4) return false;
/*uint32_t unkAll =*/ packet.readUInt32();
}
if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) {
if (rem() < 1) return false;
/*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
@ -2218,10 +2185,12 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec
return this->TbcPacketParsers::parseMovementBlock(p, b);
}, "tbc");
}
// NOTE: Do NOT fall back to WotLK parseMovementBlock here.
// WotLK uses uint16 updateFlags and 9 speeds vs Classic's uint8
// and 6 speeds. A false-positive WotLK parse consumes wrong bytes,
// corrupting subsequent update fields and losing NPC data.
if (!ok) {
ok = parseMovementVariant(
[](network::Packet& p, UpdateBlock& b) {
return UpdateObjectParser::parseMovementBlock(p, b);
}, "wotlk");
}
break;
case UpdateType::OUT_OF_RANGE_OBJECTS:
case UpdateType::NEAR_OBJECTS:

View file

@ -30,8 +30,11 @@ namespace TbcMoveFlags {
// - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32)
// ============================================================================
bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
// Validate minimum packet size for updateFlags byte
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[TBC] Movement block packet too small (need at least 1 byte for updateFlags)");
return false;
}
// TBC 2.4.3: UpdateFlags is uint8 (1 byte)
uint8_t updateFlags = packet.readUInt8();
@ -55,9 +58,6 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
const uint8_t UPDATEFLAG_HIGHGUID = 0x10;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+moveFlags2(1)+time(4)+position(16)+fallTime(4)+speeds(32) = 61
if (rem() < 61) return false;
// Full movement block for living units
uint32_t moveFlags = packet.readUInt32();
uint8_t moveFlags2 = packet.readUInt8(); // TBC: uint8, not uint16
@ -76,33 +76,29 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
// Transport data
if (moveFlags & TbcMoveFlags::ON_TRANSPORT) {
if (rem() < 1) return false;
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 20) return false; // 4 floats + 1 uint32
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
block.transportO = packet.readFloat();
/*uint32_t tTime =*/ packet.readUInt32();
// TBC: NO transport seat byte
// TBC: NO interpolated movement check
}
// Pitch: SWIMMING, or else ONTRANSPORT (TBC-specific secondary pitch)
if (moveFlags & TbcMoveFlags::SWIMMING) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
} else if (moveFlags & TbcMoveFlags::ONTRANSPORT) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jumping (TBC: JUMPING=0x2000, WotLK: FALLING=0x1000)
if (moveFlags & TbcMoveFlags::JUMPING) {
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -111,12 +107,11 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
// Spline elevation (TBC: 0x02000000, WotLK: 0x04000000)
if (moveFlags & TbcMoveFlags::SPLINE_ELEVATION) {
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Speeds (TBC: 8 values — walk, run, runBack, swim, fly, flyBack, swimBack, turn)
if (rem() < 32) return false;
// WotLK adds pitchRate (9 total)
/*float walkSpeed =*/ packet.readFloat();
float runSpeed = packet.readFloat();
/*float runBackSpeed =*/ packet.readFloat();
@ -131,47 +126,49 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
// Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000)
if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) {
if (rem() < 4) return false;
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [TBC] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) { // FINAL_POINT
if (rem() < 12) return false;
/*float finalX =*/ packet.readFloat();
/*float finalY =*/ packet.readFloat();
/*float finalZ =*/ packet.readFloat();
} else if (splineFlags & 0x00020000) { // FINAL_TARGET
if (rem() < 8) return false;
/*uint64_t finalTarget =*/ packet.readUInt64();
} else if (splineFlags & 0x00040000) { // FINAL_ANGLE
if (rem() < 4) return false;
/*float finalAngle =*/ packet.readFloat();
}
// TBC spline: timePassed, duration, id, pointCount
if (rem() < 16) return false;
// TBC spline: timePassed, duration, id, nodes, finalNode
// (no durationMod, durationModNext, verticalAccel, effectStartTime, splineMode)
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
if (pointCount > 256) return false;
// points + endPoint (no splineMode in TBC)
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
if (pointCount > 256) {
static uint32_t badTbcSplineCount = 0;
++badTbcSplineCount;
if (badTbcSplineCount <= 5 || (badTbcSplineCount % 100) == 0) {
LOG_WARNING(" [TBC] Spline pointCount=", pointCount,
" exceeds max, capping (occurrence=", badTbcSplineCount, ")");
}
pointCount = 0;
}
for (uint32_t i = 0; i < pointCount; i++) {
/*float px =*/ packet.readFloat();
/*float py =*/ packet.readFloat();
/*float pz =*/ packet.readFloat();
}
// TBC: NO splineMode byte (WotLK adds it)
/*float endPointX =*/ packet.readFloat();
/*float endPointY =*/ packet.readFloat();
/*float endPointZ =*/ packet.readFloat();
}
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
if (rem() < 16) return false;
// TBC: Simple stationary position (same as WotLK STATIONARY)
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -180,29 +177,29 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
LOG_DEBUG(" [TBC] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")");
}
// TBC: No UPDATEFLAG_POSITION (0x0100) code path
// Target GUID
if (updateFlags & UPDATEFLAG_HAS_TARGET) {
if (rem() < 1) return false;
/*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
// Transport time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
// LOWGUID (0x08) — TBC has 2 u32s, Classic has 1 u32
// TBC: No VEHICLE flag (WotLK 0x0080)
// TBC: No ROTATION flag (WotLK 0x0200)
// HIGH_GUID (0x08) — TBC has 2 u32s, Classic has 1 u32
if (updateFlags & UPDATEFLAG_LOWGUID) {
if (rem() < 8) return false;
/*uint32_t unknown0 =*/ packet.readUInt32();
/*uint32_t unknown1 =*/ packet.readUInt32();
}
// HIGHGUID (0x10)
// ALL (0x10)
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t unknown2 =*/ packet.readUInt32();
}

View file

@ -913,9 +913,6 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// 1. UpdateFlags (1 byte, sometimes 2)
// 2. Movement data depends on update flags
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 2) return false;
// Update flags (3.3.5a uses 2 bytes for flags)
uint16_t updateFlags = packet.readUInt16();
block.updateFlags = updateFlags;
@ -960,9 +957,6 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
const uint16_t UPDATEFLAG_HIGHGUID = 0x0010;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+moveFlags2(2)+time(4)+position(16)+fallTime(4)+speeds(36) = 66
if (rem() < 66) return false;
// Full movement block for living units
uint32_t moveFlags = packet.readUInt32();
uint16_t moveFlags2 = packet.readUInt16();
@ -980,10 +974,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Transport data (if on transport)
if (moveFlags & 0x00000200) { // MOVEMENTFLAG_ONTRANSPORT
if (rem() < 1) return false;
block.onTransport = true;
block.transportGuid = readPackedGuid(packet);
if (rem() < 21) return false; // 4 floats + uint32 + uint8
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
@ -995,7 +987,6 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
if (moveFlags2 & 0x0200) { // MOVEMENTFLAG2_INTERPOLATED_MOVEMENT
if (rem() < 4) return false;
/*uint32_t tTime2 =*/ packet.readUInt32();
}
}
@ -1014,17 +1005,14 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
if ((moveFlags & 0x00200000) /* SWIMMING */ ||
(moveFlags & 0x01000000) /* FLYING */ ||
(moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jumping
if (moveFlags & 0x00001000) { // MOVEMENTFLAG_FALLING
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -1033,12 +1021,10 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Spline elevation
if (moveFlags & 0x04000000) { // MOVEMENTFLAG_SPLINE_ELEVATION
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Speeds (9 values in WotLK: walk/run/runBack/swim/swimBack/flight/flightBack/turn/pitch)
if (rem() < 36) return false;
// Speeds (7 speed values)
/*float walkSpeed =*/ packet.readFloat();
float runSpeed = packet.readFloat();
/*float runBackSpeed =*/ packet.readFloat();
@ -1072,60 +1058,46 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
/*float finalAngle =*/ packet.readFloat();
}
// Spline data layout varies by expansion:
// Classic/Vanilla: timePassed(4)+duration(4)+splineId(4)+pointCount(4)+points+mode(1)+endPoint(12)
// WotLK: timePassed(4)+duration(4)+splineId(4)+durationMod(4)+durationModNext(4)
// +[ANIMATION(5)]+[PARABOLIC(8)]+pointCount(4)+points+mode(1)+endPoint(12)
// Since the parser has no expansion context, auto-detect by trying Classic first.
// Legacy UPDATE_OBJECT spline layout used by many servers:
// timePassed, duration, splineId, durationMod, durationModNext,
// [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)],
// verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint.
const size_t legacyStart = packet.getReadPos();
if (!bytesAvailable(16)) return false; // minimum: 12 common + 4 pointCount
if (!bytesAvailable(12 + 8 + 8 + 4)) return false;
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
const size_t afterSplineId = packet.getReadPos();
// Helper: try to parse uncompressed spline points from current read position.
auto tryParseUncompressedSpline = [&](const char* tag) -> bool {
if (!bytesAvailable(4)) return false;
uint32_t pc = packet.readUInt32();
if (pc > 256) return false;
size_t needed = static_cast<size_t>(pc) * 12ull + 13ull;
if (!bytesAvailable(needed)) return false;
for (uint32_t i = 0; i < pc; i++) {
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
packet.readUInt8(); // splineMode
packet.readFloat(); packet.readFloat(); packet.readFloat(); // endPoint
LOG_DEBUG(" Spline pointCount=", pc, " (", tag, ")");
return true;
};
// --- Try 1: Classic format (pointCount immediately after splineId) ---
bool splineParsed = tryParseUncompressedSpline("classic");
// --- Try 2: WotLK format (durationMod+durationModNext+conditional+pointCount) ---
if (!splineParsed) {
packet.setReadPos(afterSplineId);
bool wotlkOk = bytesAvailable(8); // durationMod + durationModNext
if (wotlkOk) {
/*float durationMod =*/ packet.readFloat();
/*float durationModNext =*/ packet.readFloat();
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
if (!bytesAvailable(5)) { wotlkOk = false; }
else { packet.readUInt8(); packet.readUInt32(); }
}
}
if (wotlkOk && (splineFlags & 0x00000800)) { // SPLINEFLAG_PARABOLIC
if (!bytesAvailable(8)) { wotlkOk = false; }
else { packet.readFloat(); packet.readUInt32(); }
}
if (wotlkOk) {
splineParsed = tryParseUncompressedSpline("wotlk");
}
/*float durationMod =*/ packet.readFloat();
/*float durationModNext =*/ packet.readFloat();
// Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
if (!bytesAvailable(5)) return false;
packet.readUInt8(); // animationType
packet.readUInt32(); // animTime
}
/*float verticalAccel =*/ packet.readFloat();
/*uint32_t effectStartTime =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
// --- Try 3: Compact layout (compressed points) as final recovery ---
if (!splineParsed) {
const size_t remainingAfterCount = packet.getSize() - packet.getReadPos();
const bool legacyCountLooksValid = (pointCount <= 256);
const size_t legacyPointsBytes = static_cast<size_t>(pointCount) * 12ull;
const bool legacyPayloadFits = (legacyPointsBytes + 13ull) <= remainingAfterCount;
if (legacyCountLooksValid && legacyPayloadFits) {
for (uint32_t i = 0; i < pointCount; i++) {
/*float px =*/ packet.readFloat();
/*float py =*/ packet.readFloat();
/*float pz =*/ packet.readFloat();
}
/*uint8_t splineMode =*/ packet.readUInt8();
/*float endPointX =*/ packet.readFloat();
/*float endPointY =*/ packet.readFloat();
/*float endPointZ =*/ packet.readFloat();
LOG_DEBUG(" Spline pointCount=", pointCount, " (legacy)");
} else {
// Legacy pointCount looks invalid; try compact WotLK layout as recovery.
// This keeps malformed/variant packets from desyncing the whole update block.
packet.setReadPos(legacyStart);
const size_t afterFinalFacingPos = packet.getReadPos();
if (splineFlags & 0x00400000) { // Animation
@ -1146,7 +1118,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
static uint32_t badSplineCount = 0;
++badSplineCount;
if (badSplineCount <= 5 || (badSplineCount % 100) == 0) {
LOG_WARNING(" Spline invalid (classic+wotlk+compact) at readPos=",
LOG_WARNING(" Spline pointCount=", pointCount,
" invalid (legacy+compact) at readPos=",
afterFinalFacingPos, "/", packet.getSize(),
", occurrence=", badSplineCount);
}
@ -1166,14 +1139,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
if (!bytesAvailable(compactPayloadBytes)) return false;
packet.setReadPos(packet.getReadPos() + compactPayloadBytes);
}
} // end compact fallback
} // end else (compact fallback)
}
}
else if (updateFlags & UPDATEFLAG_POSITION) {
// Transport position update (UPDATEFLAG_POSITION = 0x0100)
if (rem() < 1) return false;
uint64_t transportGuid = readPackedGuid(packet);
if (rem() < 32) return false; // 8 floats
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -1202,7 +1173,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
}
}
else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) {
if (rem() < 16) return false;
// Simple stationary position (4 floats)
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -1214,38 +1185,32 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Target GUID (for units with target)
if (updateFlags & UPDATEFLAG_HAS_TARGET) {
if (rem() < 1) return false;
/*uint64_t targetGuid =*/ readPackedGuid(packet);
}
// Transport time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
// Vehicle
if (updateFlags & UPDATEFLAG_VEHICLE) {
if (rem() < 8) return false;
/*uint32_t vehicleId =*/ packet.readUInt32();
/*float vehicleOrientation =*/ packet.readFloat();
}
// Rotation (GameObjects)
if (updateFlags & UPDATEFLAG_ROTATION) {
if (rem() < 8) return false;
/*int64_t rotation =*/ packet.readUInt64();
}
// Low GUID
if (updateFlags & UPDATEFLAG_LOWGUID) {
if (rem() < 4) return false;
/*uint32_t lowGuid =*/ packet.readUInt32();
}
// High GUID
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t highGuid =*/ packet.readUInt32();
}
@ -1255,8 +1220,6 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& block) {
size_t startPos = packet.getReadPos();
if (packet.getReadPos() >= packet.getSize()) return false;
// Read number of blocks (each block is 32 fields = 32 bits)
uint8_t blockCount = packet.readUInt8();
@ -1344,8 +1307,6 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
}
bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& block) {
if (packet.getReadPos() >= packet.getSize()) return false;
// Read update type
uint8_t updateTypeVal = packet.readUInt8();
block.updateType = static_cast<UpdateType>(updateTypeVal);
@ -1355,7 +1316,6 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
switch (block.updateType) {
case UpdateType::VALUES: {
// Partial update - changed fields only
if (packet.getReadPos() >= packet.getSize()) return false;
block.guid = readPackedGuid(packet);
LOG_DEBUG(" VALUES update for GUID: 0x", std::hex, block.guid, std::dec);
@ -1364,7 +1324,6 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
case UpdateType::MOVEMENT: {
// Movement update
if (packet.getReadPos() + 8 > packet.getSize()) return false;
block.guid = packet.readUInt64();
LOG_DEBUG(" MOVEMENT update for GUID: 0x", std::hex, block.guid, std::dec);
@ -1374,12 +1333,10 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2: {
// Create new object with full data
if (packet.getReadPos() >= packet.getSize()) return false;
block.guid = readPackedGuid(packet);
LOG_DEBUG(" CREATE_OBJECT for GUID: 0x", std::hex, block.guid, std::dec);
// Read object type
if (packet.getReadPos() >= packet.getSize()) return false;
uint8_t objectTypeVal = packet.readUInt8();
block.objectType = static_cast<ObjectType>(objectTypeVal);
LOG_DEBUG(" Object type: ", (int)objectTypeVal);
@ -3252,11 +3209,12 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) {
if (pointCount == 0) return true;
// Cap pointCount to prevent excessive iteration from malformed packets.
constexpr uint32_t kMaxSplinePoints = 1000;
if (pointCount > kMaxSplinePoints) {
LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints,
" (guid=0x", std::hex, data.guid, std::dec, ")");
return false;
" (guid=0x", std::hex, data.guid, std::dec, "), capping");
pointCount = kMaxSplinePoints;
}
// Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed).
@ -3907,13 +3865,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
data.hitTargets.reserve(storedHitLimit);
for (uint16_t i = 0; i < rawHitCount; ++i) {
// WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid).
if (packet.getSize() - packet.getReadPos() < 8) {
// WotLK hit targets are packed GUIDs, like the caster and miss targets.
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount);
truncatedTargets = true;
break;
}
const uint64_t targetGuid = packet.readUInt64();
const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet);
if (i < storedHitLimit) {
data.hitTargets.push_back(targetGuid);
}
@ -3931,27 +3889,7 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
return false;
}
const size_t missCountPos = packet.getReadPos();
const uint8_t rawMissCount = packet.readUInt8();
if (rawMissCount > 20) {
// Likely offset error — dump context bytes for diagnostics.
const auto& raw = packet.getData();
std::string hexCtx;
size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos;
size_t dumpEnd = std::min(missCountPos + 16, raw.size());
for (size_t i = dumpStart; i < dumpEnd; ++i) {
char buf[4];
std::snprintf(buf, sizeof(buf), "%02x ", raw[i]);
hexCtx += buf;
if (i == missCountPos - 1) hexCtx += "[";
if (i == missCountPos) hexCtx += "] ";
}
LOG_WARNING("Spell go: suspect missCount=", (int)rawMissCount,
" spell=", data.spellId, " hits=", (int)data.hitCount,
" castFlags=0x", std::hex, data.castFlags, std::dec,
" missCountPos=", missCountPos, " pktSize=", packet.getSize(),
" ctx=", hexCtx);
}
if (rawMissCount > 128) {
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount,
") spell=", data.spellId, " hits=", (int)data.hitCount,
@ -3961,16 +3899,22 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
data.missTargets.reserve(storedMissLimit);
for (uint16_t i = 0; i < rawMissCount; ++i) {
// WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType.
// Each miss entry: packed GUID(1-8 bytes) + missType(1 byte).
// REFLECT additionally appends uint8 reflectResult.
if (packet.getSize() - packet.getReadPos() < 9) { // 8 GUID + 1 missType
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount,
" spell=", data.spellId, " hits=", (int)data.hitCount);
truncatedTargets = true;
break;
}
SpellGoMissEntry m;
m.targetGuid = packet.readUInt64();
m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount,
" spell=", data.spellId);
truncatedTargets = true;
break;
}
m.missType = packet.readUInt8();
if (m.missType == 11) { // SPELL_MISS_REFLECT
if (packet.getSize() - packet.getReadPos() < 1) {
@ -4358,17 +4302,6 @@ network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t s
return packet;
}
network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot,
uint8_t dstBag, uint8_t dstSlot, uint8_t count) {
network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM));
packet.writeUInt8(srcBag);
packet.writeUInt8(srcSlot);
packet.writeUInt8(dstBag);
packet.writeUInt8(dstSlot);
packet.writeUInt8(count);
return packet;
}
network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM));
packet.writeUInt8(srcSlot);
@ -5878,14 +5811,5 @@ network::Packet SetTitlePacket::build(int32_t titleBit) {
return p;
}
network::Packet AlterAppearancePacket::build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) {
// CMSG_ALTER_APPEARANCE: uint32 hairStyle + uint32 hairColor + uint32 facialHair
network::Packet p(wireOpcode(Opcode::CMSG_ALTER_APPEARANCE));
p.writeUInt32(hairStyle);
p.writeUInt32(hairColor);
p.writeUInt32(facialHair);
return p;
}
} // namespace game
} // namespace wowee

View file

@ -273,9 +273,8 @@ void CameraController::update(float deltaTime) {
keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false;
}
// Tilde or NumLock toggles auto-run; any forward/backward key cancels it
bool tildeDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_GRAVE) ||
input.isKeyPressed(SDL_SCANCODE_NUMLOCKCLEAR));
// Tilde toggles auto-run; any forward/backward key cancels it
bool tildeDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_GRAVE);
if (tildeDown && !tildeWasDown) {
autoRunning = !autoRunning;
}

View file

@ -1753,7 +1753,6 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
return 0;
}
const auto& mdlRef = modelIt->second;
modelUnusedSince_.erase(modelId);
// Deduplicate: skip if same model already at nearly the same position.
// Uses hash map for O(1) lookup instead of O(N) scan.
@ -1865,7 +1864,6 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4&
LOG_WARNING("Cannot create instance: model ", modelId, " not loaded");
return 0;
}
modelUnusedSince_.erase(modelId);
// Deduplicate: O(1) hash lookup
{
@ -4278,28 +4276,11 @@ void M2Renderer::cleanupUnusedModels() {
usedModelIds.insert(instance.modelId);
}
const auto now = std::chrono::steady_clock::now();
constexpr auto kGracePeriod = std::chrono::seconds(60);
// Find models with no instances that have exceeded the grace period.
// Models that just lost their last instance get tracked but not evicted
// immediately — this prevents thrashing when GO models are briefly
// instance-free between despawn and respawn cycles.
// Find and remove models with no instances
std::vector<uint32_t> toRemove;
for (const auto& [id, model] : models) {
if (usedModelIds.find(id) != usedModelIds.end()) {
// Model still in use — clear any pending unused timestamp
modelUnusedSince_.erase(id);
continue;
}
auto unusedIt = modelUnusedSince_.find(id);
if (unusedIt == modelUnusedSince_.end()) {
// First cycle with no instances — start the grace timer
modelUnusedSince_[id] = now;
} else if (now - unusedIt->second >= kGracePeriod) {
// Grace period expired — mark for removal
if (usedModelIds.find(id) == usedModelIds.end()) {
toRemove.push_back(id);
modelUnusedSince_.erase(unusedIt);
}
}

View file

@ -67,10 +67,6 @@
#include <cctype>
#include <cmath>
#include <chrono>
#include <filesystem>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#include <cstdlib>
#include <optional>
#include <unordered_map>
@ -2578,101 +2574,6 @@ void Renderer::cancelEmote() {
emoteLoop = false;
}
bool Renderer::captureScreenshot(const std::string& outputPath) {
if (!vkCtx) return false;
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
VkExtent2D extent = vkCtx->getSwapchainExtent();
const auto& images = vkCtx->getSwapchainImages();
if (images.empty() || currentImageIndex >= images.size()) return false;
VkImage srcImage = images[currentImageIndex];
uint32_t w = extent.width;
uint32_t h = extent.height;
VkDeviceSize bufSize = static_cast<VkDeviceSize>(w) * h * 4;
// Stall GPU so the swapchain image is idle
vkDeviceWaitIdle(device);
// Create staging buffer
VkBufferCreateInfo bufInfo{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bufInfo.size = bufSize;
bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VmaAllocationCreateInfo allocCI{};
allocCI.usage = VMA_MEMORY_USAGE_CPU_ONLY;
VkBuffer stagingBuf = VK_NULL_HANDLE;
VmaAllocation stagingAlloc = VK_NULL_HANDLE;
if (vmaCreateBuffer(alloc, &bufInfo, &allocCI, &stagingBuf, &stagingAlloc, nullptr) != VK_SUCCESS) {
LOG_WARNING("Screenshot: failed to create staging buffer");
return false;
}
// Record copy commands
VkCommandBuffer cmd = vkCtx->beginSingleTimeCommands();
// Transition swapchain image: PRESENT_SRC → TRANSFER_SRC
VkImageMemoryBarrier toTransfer{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
toTransfer.srcAccessMask = VK_ACCESS_MEMORY_READ_BIT;
toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
toTransfer.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
toTransfer.image = srcImage;
toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 1, &toTransfer);
// Copy image to buffer
VkBufferImageCopy region{};
region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.imageExtent = {w, h, 1};
vkCmdCopyImageToBuffer(cmd, srcImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
stagingBuf, 1, &region);
// Transition back: TRANSFER_SRC → PRESENT_SRC
VkImageMemoryBarrier toPresent = toTransfer;
toPresent.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
toPresent.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT;
toPresent.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
toPresent.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
0, 0, nullptr, 0, nullptr, 1, &toPresent);
vkCtx->endSingleTimeCommands(cmd);
// Map and convert BGRA → RGBA
void* mapped = nullptr;
vmaMapMemory(alloc, stagingAlloc, &mapped);
auto* pixels = static_cast<uint8_t*>(mapped);
for (uint32_t i = 0; i < w * h; ++i) {
std::swap(pixels[i * 4 + 0], pixels[i * 4 + 2]); // B ↔ R
}
// Ensure output directory exists
std::filesystem::path outPath(outputPath);
if (outPath.has_parent_path())
std::filesystem::create_directories(outPath.parent_path());
int ok = stbi_write_png(outputPath.c_str(),
static_cast<int>(w), static_cast<int>(h),
4, pixels, static_cast<int>(w * 4));
vmaUnmapMemory(alloc, stagingAlloc);
vmaDestroyBuffer(alloc, stagingBuf, stagingAlloc);
if (ok) {
LOG_INFO("Screenshot saved: ", outputPath);
} else {
LOG_WARNING("Screenshot: stbi_write_png failed for ", outputPath);
}
return ok != 0;
}
void Renderer::triggerLevelUpEffect(const glm::vec3& position) {
if (!levelUpEffect) return;

View file

@ -206,8 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
}
}
}
// Login screen music
if (renderer) {
// Login screen music disabled
if (false && renderer) {
auto* music = renderer->getMusicManager();
if (music) {
if (!loginMusicVolumeAdjusted_) {

File diff suppressed because it is too large Load diff

View file

@ -871,35 +871,6 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
ImGui::EndPopup();
}
// Stack split popup
if (splitConfirmOpen_) {
ImVec2 mousePos = ImGui::GetIO().MousePos;
ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always);
ImGui::OpenPopup("##SplitStack");
splitConfirmOpen_ = false;
}
if (ImGui::BeginPopup("##SplitStack", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Text("Split %s", splitItemName_.c_str());
ImGui::Spacing();
ImGui::SetNextItemWidth(120.0f);
ImGui::SliderInt("##splitcount", &splitCount_, 1, splitMax_ - 1);
ImGui::Spacing();
if (ImGui::Button("OK", ImVec2(55, 0))) {
if (gameHandler_ && splitCount_ > 0 && splitCount_ < splitMax_) {
gameHandler_->splitItem(splitBag_, splitSlot_, static_cast<uint8_t>(splitCount_));
}
splitItemName_.clear();
inventoryDirty = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(55, 0))) {
splitItemName_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Draw held item at cursor
renderHeldItem();
}
@ -2331,39 +2302,22 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
}
// Shift+right-click: split stack (if stackable >1) or destroy confirmation
// Shift+right-click: open destroy confirmation for non-quest items
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) &&
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0) {
if (item.stackCount > 1 && item.maxStack > 1) {
// Open split popup for stackable items
splitConfirmOpen_ = true;
splitItemName_ = item.name;
splitMax_ = static_cast<int>(item.stackCount);
splitCount_ = splitMax_ / 2;
if (splitCount_ < 1) splitCount_ = 1;
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
splitBag_ = 0xFF;
splitSlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
splitBag_ = static_cast<uint8_t>(19 + bagIndex);
splitSlot_ = static_cast<uint8_t>(bagSlotIndex);
}
} else if (item.bindType != 4) {
// Destroy confirmation for non-quest, non-stackable items
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
}
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) {
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
}
}

View file

@ -82,14 +82,6 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler
pos += replacement.length();
}
// Resolve class and race names for $C and $R placeholders
std::string className = "Adventurer";
std::string raceName = "Unknown";
if (character) {
className = game::getClassName(character->characterClass);
raceName = game::getRaceName(character->race);
}
// Replace simple placeholders
pos = 0;
while ((pos = result.find('$', pos)) != std::string::npos) {
@ -100,12 +92,11 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler
switch (code) {
case 'n': case 'N': replacement = playerName; break;
case 'c': case 'C': replacement = className; break;
case 'r': case 'R': replacement = raceName; break;
case 'p': replacement = pronouns.subject; break;
case 'o': replacement = pronouns.object; break;
case 's': replacement = pronouns.possessive; break;
case 'S': replacement = pronouns.possessiveP; break;
case 'r': replacement = pronouns.object; break;
case 'b': case 'B': replacement = "\n"; break;
case 'g': case 'G': pos++; continue;
default: pos++; continue;

View file

@ -176,29 +176,6 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
ImGui::EndTabBar();
}
// Talent learn confirmation popup
if (talentConfirmOpen_) {
ImGui::OpenPopup("Learn Talent?##talent_confirm");
talentConfirmOpen_ = false;
}
if (ImGui::BeginPopupModal("Learn Talent?##talent_confirm", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", pendingTalentName_.c_str());
ImGui::Text("Rank %u", pendingTalentRank_ + 1);
ImGui::Spacing();
ImGui::TextWrapped("Spend a talent point?");
ImGui::Spacing();
if (ImGui::Button("Learn", ImVec2(80, 0))) {
gameHandler.learnTalent(pendingTalentId_, pendingTalentRank_);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(80, 0))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
}
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId,
@ -597,15 +574,10 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
ImGui::EndTooltip();
}
// Handle click — open confirmation dialog instead of learning directly
// Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...)
// CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value)
if (clicked && canLearn && prereqsMet) {
talentConfirmOpen_ = true;
pendingTalentId_ = talent.talentId;
pendingTalentRank_ = currentRank;
uint32_t nextSpell = (currentRank < 5) ? talent.rankSpells[currentRank] : 0;
pendingTalentName_ = nextSpell ? gameHandler.getSpellName(nextSpell) : "";
if (pendingTalentName_.empty())
pendingTalentName_ = spellId ? gameHandler.getSpellName(spellId) : "Talent";
gameHandler.learnTalent(talent.talentId, currentRank);
}
ImGui::PopID();