diff --git a/docs/status.md b/docs/status.md index 991d813d..fca68f19 100644 --- a/docs/status.md +++ b/docs/status.md @@ -25,6 +25,9 @@ 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 diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 57147902..a608f6f5 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -135,6 +135,13 @@ 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. @@ -277,18 +284,14 @@ 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; }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 47997040..569261b2 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -453,6 +453,7 @@ 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) @@ -608,6 +609,33 @@ 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 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; @@ -667,6 +695,16 @@ public: auto it = creatureInfoCache.find(entry); return (it != creatureInfoCache.end()) ? static_cast(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); @@ -931,6 +969,10 @@ public: using StandStateCallback = std::function; 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 setAppearanceChangedCallback(AppearanceChangedCallback cb) { appearanceChangedCallback_ = std::move(cb); } + // Ghost state callback — fired when player enters or leaves ghost (spirit) form using GhostStateCallback = std::function; void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); } @@ -1202,6 +1244,17 @@ 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. */ @@ -1398,8 +1451,11 @@ 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& getArenaTeamStats() const { return arenaTeamStats_; } + void requestArenaTeamRoster(uint32_t teamId); // ---- Arena Team Roster ---- struct ArenaTeamMember { @@ -1451,6 +1507,7 @@ 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 { @@ -2011,6 +2068,7 @@ 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); @@ -2337,6 +2395,9 @@ 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); @@ -2837,6 +2898,7 @@ 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 raidTargetGuids_ = {}; @@ -2952,16 +3014,22 @@ private: GuildInfoData guildInfoData_; GuildQueryResponseData guildQueryData_; bool hasGuildRoster_ = false; + std::unordered_map guildNameCache_; // guildId → guild name + std::unordered_set 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; @@ -3317,6 +3385,7 @@ 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 diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 67d94c2d..c6fe3663 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -61,6 +61,9 @@ 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; } diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index c2aa581f..c0408743 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2046,6 +2046,13 @@ 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: @@ -2789,5 +2796,12 @@ 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 diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 22578309..1f19b46e 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace wowee { @@ -434,6 +435,9 @@ private: void* glowVBMapped_ = nullptr; std::unordered_map 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 modelUnusedSince_; std::vector instances; // O(1) dedup: key = (modelId, quantized x, quantized y, quantized z) → instanceId diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 1f33d2f4..588fa3af 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -154,6 +154,9 @@ 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, diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 290c45eb..9fa540b3 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -279,6 +279,9 @@ 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 @@ -317,10 +320,6 @@ private: */ void workerLoop(); - /** - * Main thread: poll for completed tiles and upload to GPU - */ - void processReadyTiles(); void ensureGroundEffectTablesLoaded(); void generateGroundClutterPlacements(std::shared_ptr& pending, std::unordered_set& preparedModelIds); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0054bf05..4bc10707 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -86,7 +86,8 @@ private: bool showEntityWindow = false; bool showChatWindow = true; bool showMinimap_ = true; // M key toggles minimap - bool showNameplates_ = true; // V key toggles nameplates + bool showNameplates_ = true; // V key toggles enemy/NPC nameplates + bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player 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 @@ -365,6 +366,7 @@ 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); @@ -398,6 +400,7 @@ private: void renderBattlegroundScore(game::GameHandler& gameHandler); void renderDPSMeter(game::GameHandler& gameHandler); void renderDurabilityWarning(game::GameHandler& gameHandler); + void takeScreenshot(game::GameHandler& gameHandler); /** * Inventory screen @@ -532,6 +535,24 @@ 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] = ""; @@ -644,6 +665,7 @@ 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; diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 21ccdc00..dca0e5a5 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -187,6 +187,14 @@ 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_; diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 72eafc2a..82a674e4 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -45,6 +45,12 @@ private: std::unordered_map spellTooltips; // spellId -> description std::unordered_map 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 glyphProperties_; // glyphId -> info diff --git a/src/core/application.cpp b/src/core/application.cpp index 4ff3aae1..28f2fad1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1647,7 +1647,11 @@ 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. - const bool entityIsMoving = entity->isEntityMoving(); + // 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 isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); @@ -1716,6 +1720,110 @@ 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(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( + 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. @@ -1990,7 +2098,7 @@ void Application::setupUICallbacks() { worldEntryMovementGraceTimer_ = 2.0f; taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; - renderer->getTerrainManager()->processAllReadyTiles(); + renderer->getTerrainManager()->processReadyTiles(); { auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); std::vector> nearbyTiles; @@ -2023,10 +2131,12 @@ void Application::setupUICallbacks() { renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(0.5f); } - // 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(); + // 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(); // Queue all remaining tiles within the load radius (8 tiles = 17x17) // at the new position. precacheTiles skips already-loaded/pending tiles, @@ -2889,29 +2999,50 @@ void Application::setupUICallbacks() { } }); - // NPC death callback (online mode) - play death animation + // NPC/player 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() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death + 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 } }); - // NPC respawn callback (online mode) - reset to idle animation + // NPC/player 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() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 0, true); // Idle + 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 } }); - // NPC swing callback (online mode) - play attack animation + // NPC/player 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() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack + 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 } }); @@ -4723,24 +4854,42 @@ 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() && cr) { - cr->playAnimation(it->second, 1, false); // animation ID 1 = Death + 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 } }); 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() && cr) { - cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle + 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 } }); gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) { + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1 + 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 } }); } @@ -7044,6 +7193,7 @@ 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); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3591d97a..16666085 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -759,6 +759,8 @@ void GameHandler::disconnect() { activeCharacterGuid_ = 0; playerNameCache.clear(); pendingNameQueries.clear(); + guildNameCache_.clear(); + pendingGuildNameQueries_.clear(); friendGuids_.clear(); contacts_.clear(); transportAttachments_.clear(); @@ -2342,7 +2344,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; @@ -2356,9 +2358,10 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(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); + ") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec); break; } @@ -2678,8 +2681,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."); @@ -2949,6 +2952,7 @@ 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 parsedMisses; parsedMisses.reserve(storedLimit); @@ -2967,17 +2971,18 @@ 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) { - /*uint32_t reflectSpellId =*/ packet.readUInt32(); - /*uint8_t reflectResult =*/ packet.readUInt8(); + reflectSpellId = packet.readUInt32(); + /*uint8_t reflectResult =*/ packet.readUInt8(); } else { truncated = true; break; } } if (i < storedLimit) { - parsedMisses.push_back({victimGuid, missInfo}); + parsedMisses.push_back({victimGuid, missInfo, reflectSpellId}); } } @@ -2990,12 +2995,15 @@ 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, spellId, true, 0, casterGuid, victimGuid); + addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) - addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid); + addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid); } } break; @@ -4250,17 +4258,20 @@ 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) = 20 bytes + // WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes const bool periodicWotlk = isActiveExpansion("wotlk"); - const size_t dotSz = periodicWotlk ? 20u : 16u; + const size_t dotSz = periodicWotlk ? 21u : 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(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), + addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE, + static_cast(dmg), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (abs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(abs), @@ -4278,11 +4289,13 @@ 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(); - /*uint8_t isCrit=*/ packet.readUInt8(); + hotCrit = (packet.readUInt8() != 0); } - addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), + addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL, + static_cast(heal), spellId, isPlayerCaster, 0, casterGuid, victimGuid); if (hotAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), @@ -4889,6 +4902,7 @@ 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." @@ -5792,7 +5806,31 @@ 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: + 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(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_UPDATE_SEARCH: case Opcode::SMSG_UPDATE_LFG_LIST: case Opcode::SMSG_LFG_PLAYER_INFO: @@ -7616,9 +7654,13 @@ 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: - packet.setReadPos(packet.getSize()); + handlePetitionSignResults(packet); break; // ---- Pet system ---- @@ -7672,13 +7714,17 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PET_CAST_FAILED: { - if (packet.getSize() - packet.getReadPos() >= 5) { - uint8_t castCount = packet.readUInt8(); + // 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(); 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, " castCount=", (int)castCount); + " reason=", (int)reason); if (reason != 0) { const char* reasonStr = getSpellCastResultString(reason); const std::string& sName = getSpellName(spellId); @@ -11293,10 +11339,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); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + setPlayerOnTransport(block.transportGuid, canonicalOffset); if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); entity->setPosition(composed.x, composed.y, composed.z, oCanonical); @@ -11562,6 +11608,9 @@ 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 (", @@ -11908,7 +11957,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem corpseX_, ",", corpseY_, ",", corpseZ_, ") map=", corpseMapId_); } - if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) { npcDeathCallback_(block.guid); npcDeathNotified = true; } @@ -11921,7 +11970,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem LOG_INFO("Player entered ghost form"); } } - if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) { npcRespawnCallback_(block.guid); npcRespawnNotified = true; } @@ -11952,7 +12001,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem selfResAvailable_ = false; LOG_INFO("Player resurrected (dynamic flags)"); } - } else if (entity->getType() == ObjectType::UNIT) { + } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; if (!wasDead && nowDead) { @@ -12088,6 +12137,12 @@ 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; { @@ -12154,6 +12209,7 @@ 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] = { @@ -12204,15 +12260,38 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { playerResistances_[key - ufArmor - 1] = static_cast(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(val & 0xFF); + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.facialFeatures = facialHair; + break; + } + } uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); + LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots), + " facial=", static_cast(facialHair)); 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((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); + if (appearanceChangedCallback_) + appearanceChangedCallback_(); } else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { chosenTitleBit_ = static_cast(val); @@ -12430,10 +12509,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); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + setPlayerOnTransport(block.transportGuid, canonicalOffset); if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); entity->setPosition(composed.x, composed.y, composed.z, oCanonical); @@ -12737,6 +12816,15 @@ 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, " " + reply, data.senderName); + } else if (dndStatus_ && !data.senderName.empty()) { + std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_; + sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); + } } // Trigger chat bubble for SAY/YELL messages from others @@ -15544,6 +15632,12 @@ 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 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 @@ -16077,18 +16171,21 @@ 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 kBgNames[] = { {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, - {6, "Eye of the Storm"}, + {4, "Nagrand Arena"}, + {5, "Blade's Edge Arena"}, + {6, "All Arenas"}, + {7, "Eye of the Storm"}, + {8, "Ruins of Lordaeron"}, {9, "Strand of the Ancients"}, - {11, "Isle of Conquest"}, - {30, "Nagrand Arena"}, - {31, "Blade's Edge Arena"}, - {32, "Dalaran Sewers"}, - {33, "Ring of Valor"}, - {34, "Ruins of Lordaeron"}, + {10, "Dalaran Sewers"}, + {11, "Ring of Valor"}, + {30, "Isle of Conquest"}, + {32, "Random Battleground"}, }; std::string bgName = "Battleground"; for (const auto& kv : kBgNames) { @@ -16139,6 +16236,7 @@ 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; @@ -16392,6 +16490,7 @@ 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) @@ -16933,7 +17032,25 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t teamId = packet.readUInt32(); std::string teamName = packet.readString(); - LOG_INFO("Arena team query response: id=", teamId, " name=", teamName); + 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)); } void GameHandler::handleArenaTeamRoster(network::Packet& packet) { @@ -17077,18 +17194,29 @@ void GameHandler::handleArenaTeamStats(network::Packet& packet) { stats.seasonWins = packet.readUInt32(); stats.rank = packet.readUInt32(); - // Update or insert for this team + // Update or insert for this team (preserve name/type from query response) for (auto& s : arenaTeamStats_) { if (s.teamId == stats.teamId) { - s = stats; - LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, - " rating=", stats.rating, " rank=", stats.rank); + 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); return; } } - arenaTeamStats_.push_back(stats); - LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId, - " rating=", stats.rating, " rank=", stats.rank); + 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); } void GameHandler::handleArenaError(network::Packet& packet) { @@ -18505,6 +18633,12 @@ 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) { @@ -19137,6 +19271,13 @@ 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 // ============================================================ @@ -19563,6 +19704,28 @@ 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); @@ -19611,6 +19774,118 @@ 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; @@ -19647,18 +19922,30 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; - 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]); + // 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); } - 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) { @@ -19932,6 +20219,17 @@ 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); @@ -19939,6 +20237,12 @@ 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; @@ -21083,6 +21387,40 @@ 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(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(19 + b); + uint8_t dstSlot = static_cast(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); @@ -21236,8 +21574,8 @@ void GameHandler::unstuckHearth() { } void GameHandler::handleLootResponse(network::Packet& packet) { - // Classic 1.12 and TBC 2.4.3 use 14 bytes/item (no randomSuffix/randomProp fields); - // WotLK 3.3.5a uses 22 bytes/item. + // All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType). + // WotLK adds a quest item list after the regular items. const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0; @@ -22222,6 +22560,7 @@ 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"); } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index e0dd01f8..0d4d09e2 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -189,11 +189,8 @@ 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) { - // 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; - } + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; // Classic: UpdateFlags is uint8 (same as TBC) uint8_t updateFlags = packet.readUInt8(); @@ -209,6 +206,9 @@ 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,26 +225,29 @@ 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(); @@ -253,12 +256,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) - // TBC added flying_speed + backwards_flying_speed (8 total) - // WotLK added pitchRate (9 total) + if (rem() < 24) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -271,34 +274,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, nodes, finalNode (same as TBC) + // Classic spline: timePassed, duration, id, pointCount + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); - 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; - } + if (pointCount > 256) return false; + + // points + endPoint (no splineMode in Classic) + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { /*float px =*/ packet.readFloat(); /*float py =*/ packet.readFloat(); @@ -312,6 +315,7 @@ 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(); @@ -323,21 +327,25 @@ 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(); } @@ -1918,6 +1926,9 @@ 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(updateFlags); @@ -1931,6 +1942,8 @@ 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(); @@ -1949,8 +1962,10 @@ 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(); @@ -1960,14 +1975,17 @@ 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(); @@ -1976,10 +1994,12 @@ 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(); @@ -1997,17 +2017,23 @@ 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(); @@ -2018,10 +2044,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc ++badTurtleSplineCount; if (badTurtleSplineCount <= 5 || (badTurtleSplineCount % 100) == 0) { LOG_WARNING(" [Turtle] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badTurtleSplineCount, ")"); + " exceeds max (occurrence=", badTurtleSplineCount, ")"); } - pointCount = 0; + return false; } + // points + endPoint + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { packet.readFloat(); packet.readFloat(); packet.readFloat(); } @@ -2034,6 +2062,7 @@ 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(); @@ -2045,18 +2074,22 @@ 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(); } @@ -2185,12 +2218,10 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec return this->TbcPacketParsers::parseMovementBlock(p, b); }, "tbc"); } - if (!ok) { - ok = parseMovementVariant( - [](network::Packet& p, UpdateBlock& b) { - return UpdateObjectParser::parseMovementBlock(p, b); - }, "wotlk"); - } + // 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. break; case UpdateType::OUT_OF_RANGE_OBJECTS: case UpdateType::NEAR_OBJECTS: diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index c1397460..9d68879f 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -30,11 +30,8 @@ namespace TbcMoveFlags { // - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32) // ============================================================================ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { - // 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; - } + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; // TBC 2.4.3: UpdateFlags is uint8 (1 byte) uint8_t updateFlags = packet.readUInt8(); @@ -58,6 +55,9 @@ 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,29 +76,33 @@ 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(); @@ -107,11 +111,12 @@ 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) - // WotLK adds pitchRate (9 total) + if (rem() < 32) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -126,49 +131,47 @@ 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, nodes, finalNode - // (no durationMod, durationModNext, verticalAccel, effectStartTime, splineMode) + // TBC spline: timePassed, duration, id, pointCount + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); - 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; - } + if (pointCount > 256) return false; + + // points + endPoint (no splineMode in TBC) + if (rem() < static_cast(pointCount) * 12 + 12) return false; 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) { - // TBC: Simple stationary position (same as WotLK STATIONARY) + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -177,29 +180,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(); } - // TBC: No VEHICLE flag (WotLK 0x0080) - // TBC: No ROTATION flag (WotLK 0x0200) - - // HIGH_GUID (0x08) — TBC has 2 u32s, Classic has 1 u32 + // LOWGUID (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(); } - // ALL (0x10) + // HIGHGUID (0x10) if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t unknown2 =*/ packet.readUInt32(); } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index e6f6d872..e20c2d09 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -913,6 +913,9 @@ 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; @@ -957,6 +960,9 @@ 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(); @@ -974,8 +980,10 @@ 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(); @@ -987,6 +995,7 @@ 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(); } } @@ -1005,14 +1014,17 @@ 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(); @@ -1021,10 +1033,12 @@ 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 (7 speed values) + // Speeds (9 values in WotLK: walk/run/runBack/swim/swimBack/flight/flightBack/turn/pitch) + if (rem() < 36) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -1058,46 +1072,60 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*float finalAngle =*/ packet.readFloat(); } - // 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. + // 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. const size_t legacyStart = packet.getReadPos(); - if (!bytesAvailable(12 + 8 + 8 + 4)) return false; + if (!bytesAvailable(16)) return false; // minimum: 12 common + 4 pointCount /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); - /*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(); + const size_t afterSplineId = packet.getReadPos(); - const size_t remainingAfterCount = packet.getSize() - packet.getReadPos(); - const bool legacyCountLooksValid = (pointCount <= 256); - const size_t legacyPointsBytes = static_cast(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(); + // 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(pc) * 12ull + 13ull; + if (!bytesAvailable(needed)) return false; + for (uint32_t i = 0; i < pc; i++) { + packet.readFloat(); packet.readFloat(); 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.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"); + } + } + + // --- Try 3: Compact layout (compressed points) as final recovery --- + if (!splineParsed) { packet.setReadPos(legacyStart); const size_t afterFinalFacingPos = packet.getReadPos(); if (splineFlags & 0x00400000) { // Animation @@ -1118,8 +1146,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock static uint32_t badSplineCount = 0; ++badSplineCount; if (badSplineCount <= 5 || (badSplineCount % 100) == 0) { - LOG_WARNING(" Spline pointCount=", pointCount, - " invalid (legacy+compact) at readPos=", + LOG_WARNING(" Spline invalid (classic+wotlk+compact) at readPos=", afterFinalFacingPos, "/", packet.getSize(), ", occurrence=", badSplineCount); } @@ -1139,12 +1166,14 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock if (!bytesAvailable(compactPayloadBytes)) return false; packet.setReadPos(packet.getReadPos() + compactPayloadBytes); } - } // end else (compact fallback) + } // end 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(); @@ -1173,7 +1202,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } } else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) { - // Simple stationary position (4 floats) + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -1185,32 +1214,38 @@ 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(); } @@ -1220,6 +1255,8 @@ 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(); @@ -1307,6 +1344,8 @@ 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(updateTypeVal); @@ -1316,6 +1355,7 @@ 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); @@ -1324,6 +1364,7 @@ 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); @@ -1333,10 +1374,12 @@ 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(objectTypeVal); LOG_DEBUG(" Object type: ", (int)objectTypeVal); @@ -3209,12 +3252,11 @@ 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, "), capping"); - pointCount = kMaxSplinePoints; + " (guid=0x", std::hex, data.guid, std::dec, ")"); + return false; } // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). @@ -3865,13 +3907,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.hitTargets.reserve(storedHitLimit); for (uint16_t i = 0; i < rawHitCount; ++i) { - // WotLK hit targets are packed GUIDs, like the caster and miss targets. - if (!hasFullPackedGuid(packet)) { + // WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid). + if (packet.getSize() - packet.getReadPos() < 8) { LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount); truncatedTargets = true; break; } - const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + const uint64_t targetGuid = packet.readUInt64(); if (i < storedHitLimit) { data.hitTargets.push_back(targetGuid); } @@ -3889,7 +3931,27 @@ 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, @@ -3899,22 +3961,16 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { data.missTargets.reserve(storedMissLimit); for (uint16_t i = 0; i < rawMissCount; ++i) { - // Each miss entry: packed GUID(1-8 bytes) + missType(1 byte). + // WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType. // REFLECT additionally appends uint8 reflectResult. - if (!hasFullPackedGuid(packet)) { + if (packet.getSize() - packet.getReadPos() < 9) { // 8 GUID + 1 missType 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 = 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.targetGuid = packet.readUInt64(); m.missType = packet.readUInt8(); if (m.missType == 11) { // SPELL_MISS_REFLECT if (packet.getSize() - packet.getReadPos() < 1) { @@ -4302,6 +4358,17 @@ 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); @@ -5811,5 +5878,14 @@ 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 diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index d16fa26c..53be1a25 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -273,8 +273,9 @@ void CameraController::update(float deltaTime) { keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false; } - // Tilde toggles auto-run; any forward/backward key cancels it - bool tildeDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_GRAVE); + // 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)); if (tildeDown && !tildeWasDown) { autoRunning = !autoRunning; } diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 390ee2c5..40ffd4b1 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1753,6 +1753,7 @@ 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. @@ -1864,6 +1865,7 @@ 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 { @@ -4276,11 +4278,28 @@ void M2Renderer::cleanupUnusedModels() { usedModelIds.insert(instance.modelId); } - // Find and remove models with no instances + 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. std::vector toRemove; for (const auto& [id, model] : models) { - if (usedModelIds.find(id) == usedModelIds.end()) { + 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 toRemove.push_back(id); + modelUnusedSince_.erase(unusedIt); } } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 4da8bad7..2d942c23 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -67,6 +67,10 @@ #include #include #include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb_image_write.h" #include #include #include @@ -2574,6 +2578,101 @@ 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(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, ®ion); + + // 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(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(w), static_cast(h), + 4, pixels, static_cast(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; diff --git a/src/ui/auth_screen.cpp b/src/ui/auth_screen.cpp index 2f4b83cc..777285cf 100644 --- a/src/ui/auth_screen.cpp +++ b/src/ui/auth_screen.cpp @@ -206,8 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) { } } } - // Login screen music disabled - if (false && renderer) { + // Login screen music + if (renderer) { auto* music = renderer->getMusicManager(); if (music) { if (!loginMusicVolumeAdjusted_) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f4f8cd11..811ef73e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -401,6 +401,14 @@ void GameScreen::render(game::GameHandler& gameHandler) { ghostStateCallbackSet_ = true; } + // Set up appearance-changed callback to refresh inventory preview (barber shop, etc.) + if (!appearanceCallbackSet_) { + gameHandler.setAppearanceChangedCallback([this]() { + inventoryScreenCharGuid_ = 0; // force preview re-sync on next frame + }); + appearanceCallbackSet_ = true; + } + // Set up UI error frame callback (once) if (!uiErrorCallbackSet_) { gameHandler.setUIErrorCallback([this](const std::string& msg) { @@ -733,6 +741,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); renderTrainerWindow(gameHandler); + renderBarberShopWindow(gameHandler); renderStableWindow(gameHandler); renderTaxiWindow(gameHandler); renderMailWindow(gameHandler); @@ -2751,7 +2760,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) { if (showSettingsWindow) { - // Close settings window if open showSettingsWindow = false; } else if (showEscapeMenu) { showEscapeMenu = false; @@ -2762,6 +2770,32 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.closeLoot(); } else if (gameHandler.isGossipWindowOpen()) { gameHandler.closeGossip(); + } else if (gameHandler.isVendorWindowOpen()) { + gameHandler.closeVendor(); + } else if (gameHandler.isBarberShopOpen()) { + gameHandler.closeBarberShop(); + } else if (gameHandler.isBankOpen()) { + gameHandler.closeBank(); + } else if (gameHandler.isTrainerWindowOpen()) { + gameHandler.closeTrainer(); + } else if (showWhoWindow_) { + showWhoWindow_ = false; + } else if (showCombatLog_) { + showCombatLog_ = false; + } else if (showSocialFrame_) { + showSocialFrame_ = false; + } else if (talentScreen.isOpen()) { + talentScreen.setOpen(false); + } else if (spellbookScreen.isOpen()) { + spellbookScreen.setOpen(false); + } else if (questLogScreen.isOpen()) { + questLogScreen.setOpen(false); + } else if (inventoryScreen.isCharacterOpen()) { + inventoryScreen.toggleCharacter(); + } else if (inventoryScreen.isOpen()) { + inventoryScreen.setOpen(false); + } else if (showWorldMap_) { + showWorldMap_ = false; } else { showEscapeMenu = true; } @@ -2782,7 +2816,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { - showNameplates_ = !showNameplates_; + if (ImGui::GetIO().KeyShift) + showFriendlyNameplates_ = !showFriendlyNameplates_; + else + showNameplates_ = !showNameplates_; } if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { @@ -2809,6 +2846,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showTitlesWindow_ = !showTitlesWindow_; } + // Screenshot (PrintScreen key) + if (input.isKeyJustPressed(SDL_SCANCODE_PRINTSCREEN)) { + takeScreenshot(gameHandler); + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -2868,7 +2910,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } - // Cursor affordance: show hand cursor over interactable game objects. + // Cursor affordance: show hand cursor over interactable entities. if (!io.WantCaptureMouse) { auto* renderer = core::Application::getInstance().getRenderer(); auto* camera = renderer ? renderer->getCamera() : nullptr; @@ -2879,17 +2921,21 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { float screenH = static_cast(window->getHeight()); rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH); float closestT = 1e30f; - bool hoverInteractableGo = false; + bool hoverInteractable = false; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (entity->getType() != game::ObjectType::GAMEOBJECT) continue; + bool isGo = (entity->getType() == game::ObjectType::GAMEOBJECT); + bool isUnit = (entity->getType() == game::ObjectType::UNIT); + bool isPlayer = (entity->getType() == game::ObjectType::PLAYER); + if (!isGo && !isUnit && !isPlayer) continue; + if (guid == gameHandler.getPlayerGuid()) continue; // skip self glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); if (!hasBounds) { - hitRadius = 2.5f; + hitRadius = isGo ? 2.5f : 1.8f; hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - hitCenter.z += 1.2f; + hitCenter.z += isGo ? 1.2f : 1.0f; } else { hitRadius = std::max(hitRadius * 1.1f, 0.8f); } @@ -2897,10 +2943,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT) && hitT < closestT) { closestT = hitT; - hoverInteractableGo = true; + hoverInteractable = true; } } - if (hoverInteractableGo) { + if (hoverInteractable) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } } @@ -3295,6 +3341,15 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); } + if (auto* ren = core::Application::getInstance().getRenderer()) { + if (auto* cam = ren->getCameraController()) { + if (cam->isAutoRunning()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[Auto-Run]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Auto-running — press ` or NumLock to stop"); + } + } + } if (inCombatConfirmed && !isDead) { float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); @@ -4141,6 +4196,39 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Right-click context menu on target frame + if (ImGui::BeginPopupContextItem("##TargetFrameCtx")) { + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(target->getGuid()); + if (target->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(name); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(target->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(target->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(name); + } + ImGui::EndPopup(); + } + // Group leader crown — golden ♛ when the targeted player is the party/raid leader if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { @@ -4182,6 +4270,17 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // Player guild name (e.g. "") — mirrors NPC subtitle styling + if (target->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(target->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + // Right-click context menu on the target name if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); @@ -4280,6 +4379,30 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); } } + // Creature type label (Beast, Humanoid, Demon, etc.) + if (target->getType() == game::ObjectType::UNIT) { + uint32_t ctype = gameHandler.getCreatureType(unit->getEntry()); + const char* ctypeName = nullptr; + switch (ctype) { + case 1: ctypeName = "Beast"; break; + case 2: ctypeName = "Dragonkin"; break; + case 3: ctypeName = "Demon"; break; + case 4: ctypeName = "Elemental"; break; + case 5: ctypeName = "Giant"; break; + case 6: ctypeName = "Undead"; break; + case 7: ctypeName = "Humanoid"; break; + case 8: ctypeName = "Critter"; break; + case 9: ctypeName = "Mechanical"; break; + case 11: ctypeName = "Totem"; break; + case 12: ctypeName = "Non-combat Pet"; break; + case 13: ctypeName = "Gas Cloud"; break; + default: break; + } + if (ctypeName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", ctypeName); + } + } if (confirmedCombatWithTarget) { float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); ImGui::SameLine(); @@ -4330,6 +4453,38 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // Combo points — shown when the player has combo points on this target + { + uint8_t cp = gameHandler.getComboPoints(); + if (cp > 0 && gameHandler.getComboTarget() == target->getGuid()) { + const float dotSize = 12.0f; + const float dotSpacing = 4.0f; + const int maxCP = 5; + float totalW = maxCP * dotSize + (maxCP - 1) * dotSpacing; + float startX = (frameW - totalW) * 0.5f; + ImGui::SetCursorPosX(startX); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (int ci = 0; ci < maxCP; ++ci) { + float cx = cursor.x + ci * (dotSize + dotSpacing) + dotSize * 0.5f; + float cy = cursor.y + dotSize * 0.5f; + if (ci < static_cast(cp)) { + // Lit: yellow for 1-4, red glow for 5 + ImU32 col = (cp >= 5) + ? IM_COL32(255, 50, 30, 255) + : IM_COL32(255, 210, 30, 255); + dl->AddCircleFilled(ImVec2(cx, cy), dotSize * 0.45f, col); + // Subtle glow + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.5f, IM_COL32(255, 255, 200, 80), 0, 1.5f); + } else { + // Unlit: dark outline + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.4f, IM_COL32(80, 80, 80, 180), 0, 1.5f); + } + } + ImGui::Dummy(ImVec2(totalW, dotSize + 2.0f)); + } + } + // Target cast bar — shown when the target is casting if (gameHandler.isTargetCasting()) { float castPct = gameHandler.getTargetCastProgress(); @@ -4964,6 +5119,42 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); ImGui::PopStyleColor(4); + // Right-click context menu on focus frame + if (ImGui::BeginPopupContextItem("##FocusFrameCtx")) { + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(focus->getGuid()); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focus->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(focus->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(focus->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(focus->getGuid()); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } + // Group leader crown — golden ♛ when the focused player is the party/raid leader if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { @@ -5003,12 +5194,42 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "[Boss]"); } else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } + // Creature type + { + uint32_t fctype = gameHandler.getCreatureType(focusUnit->getEntry()); + const char* fctName = nullptr; + switch (fctype) { + case 1: fctName="Beast"; break; case 2: fctName="Dragonkin"; break; + case 3: fctName="Demon"; break; case 4: fctName="Elemental"; break; + case 5: fctName="Giant"; break; case 6: fctName="Undead"; break; + case 7: fctName="Humanoid"; break; case 8: fctName="Critter"; break; + case 9: fctName="Mechanical"; break; case 11: fctName="Totem"; break; + case 12: fctName="Non-combat Pet"; break; case 13: fctName="Gas Cloud"; break; + default: break; + } + if (fctName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", fctName); + } + } + // Creature subtitle const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); if (!fSub.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); } + // Player guild name on focus frame + if (focus->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(focus->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); const uint64_t fGuid = focus->getGuid(); @@ -5782,6 +6003,33 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /loc command — print player coordinates and zone name + if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { + const auto& pmi = gameHandler.getMovementInfo(); + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + char buf[256]; + snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", + pmi.x, pmi.y, pmi.z, + zoneName.empty() ? "" : " — ", + zoneName.c_str()); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = buf; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + + // /screenshot command — capture current frame to PNG + if (cmdLower == "screenshot" || cmdLower == "ss") { + takeScreenshot(gameHandler); + chatInputBuffer[0] = '\0'; + return; + } + // /zone command — print current zone name if (cmdLower == "zone") { std::string zoneName; @@ -5859,9 +6107,9 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { "Items: /use /equip /equipset [name]", "Target: /target /cleartarget /focus /clearfocus", "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /zone /afk [msg] /dnd [msg] /inspect", + "Misc: /played /time /zone /loc /afk [msg] /dnd [msg] /inspect", " /helm /cloak /trade /join /leave ", - " /score /unstuck /logout /ticket /help", + " /score /unstuck /logout /ticket /screenshot /help", }; for (const char* line : kHelpLines) { game::MessageChatData helpMsg; @@ -10365,262 +10613,331 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { if (entries.empty()) return; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); - // Render combat text entries overlaid on screen - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + // Camera for world-space projection + auto* appRenderer = core::Application::getInstance().getRenderer(); + rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr; + glm::mat4 viewProj; + if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + const float baseFontSize = ImGui::GetFontSize(); - if (ImGui::Begin("##CombatText", nullptr, flags)) { - // Incoming events (enemy attacks player) float near screen center (over the player). - // Outgoing events (player attacks enemy) float on the right side (near the target). - const float incomingX = screenW * 0.40f; - const float outgoingX = screenW * 0.68f; + // HUD fallback: entries without world-space anchor use classic screen-position layout. + // We still need an ImGui window for those. + const float hudIncomingX = screenW * 0.40f; + const float hudOutgoingX = screenW * 0.68f; + int hudInIdx = 0, hudOutIdx = 0; + bool needsHudWindow = false; - int inIdx = 0, outIdx = 0; - for (const auto& entry : entries) { - float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); - float yOffset = 200.0f - entry.age * 60.0f; - const bool outgoing = entry.isPlayerSource; + for (const auto& entry : entries) { + const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); + const bool outgoing = entry.isPlayerSource; - ImVec4 color; - char text[64]; - switch (entry.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 1.0f, 0.3f, alpha) : // Outgoing = yellow - ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red - break; - case game::CombatTextEntry::CRIT_DAMAGE: - snprintf(text, sizeof(text), "-%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.8f, 0.0f, alpha) : // Outgoing crit = bright yellow - ImVec4(1.0f, 0.5f, 0.0f, alpha); // Incoming crit = orange - break; - case game::CombatTextEntry::HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::CRIT_HEAL: - snprintf(text, sizeof(text), "+%d!", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::MISS: - snprintf(text, sizeof(text), "Miss"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); - break; - case game::CombatTextEntry::DODGE: - // outgoing=true: enemy dodged player's attack - // outgoing=false: player dodged incoming attack - snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PARRY: - snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::BLOCK: - if (entry.amount > 0) - snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); - else - snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::EVADE: - snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PERIODIC_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.9f, 0.3f, alpha) : // Outgoing DoT = pale yellow - ImVec4(1.0f, 0.4f, 0.4f, alpha); // Incoming DoT = pale red - break; - case game::CombatTextEntry::PERIODIC_HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.4f, 1.0f, 0.5f, alpha); - break; - case game::CombatTextEntry::ENVIRONMENTAL: { - const char* envLabel = ""; - switch (entry.powerType) { - case 0: envLabel = "Fatigue "; break; - case 1: envLabel = "Drowning "; break; - case 2: envLabel = ""; break; // Fall: just show the number (WoW convention) - case 3: envLabel = "Lava "; break; - case 4: envLabel = "Slime "; break; - case 5: envLabel = "Fire "; break; - default: envLabel = ""; break; - } - snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); - color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental - break; + // --- Format text and color (identical logic for both world and HUD paths) --- + ImVec4 color; + char text[128]; + switch (entry.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 1.0f, 0.3f, alpha) : + ImVec4(1.0f, 0.3f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_DAMAGE: + snprintf(text, sizeof(text), "-%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.8f, 0.0f, alpha) : + ImVec4(1.0f, 0.5f, 0.0f, alpha); + break; + case game::CombatTextEntry::HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_HEAL: + snprintf(text, sizeof(text), "+%d!", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::MISS: + snprintf(text, sizeof(text), "Miss"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DODGE: + snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PARRY: + snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::BLOCK: + if (entry.amount > 0) + snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); + else + snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PERIODIC_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.9f, 0.3f, alpha) : + ImVec4(1.0f, 0.4f, 0.4f, alpha); + break; + case game::CombatTextEntry::PERIODIC_HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.4f, 1.0f, 0.5f, alpha); + break; + case game::CombatTextEntry::ENVIRONMENTAL: { + const char* envLabel = ""; + switch (entry.powerType) { + case 0: envLabel = "Fatigue "; break; + case 1: envLabel = "Drowning "; break; + case 2: envLabel = ""; break; + case 3: envLabel = "Lava "; break; + case 4: envLabel = "Slime "; break; + case 5: envLabel = "Fire "; break; + default: envLabel = ""; break; } - case game::CombatTextEntry::ENERGIZE: - snprintf(text, sizeof(text), "+%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; // Rage: red - case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; // Focus: orange - case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; // Energy: yellow - case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; // Runic Power: teal - default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; // Mana (0): blue - } - break; - case game::CombatTextEntry::POWER_DRAIN: - snprintf(text, sizeof(text), "-%d", entry.amount); - switch (entry.powerType) { - case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; - case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; - case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; - case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; - default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; - } - break; - case game::CombatTextEntry::XP_GAIN: - snprintf(text, sizeof(text), "+%d XP", entry.amount); - color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP - break; - case game::CombatTextEntry::IMMUNE: - snprintf(text, sizeof(text), "Immune!"); - color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune - break; - case game::CombatTextEntry::ABSORB: - if (entry.amount > 0) - snprintf(text, sizeof(text), "Absorbed %d", entry.amount); - else - snprintf(text, sizeof(text), "Absorbed"); - color = ImVec4(0.5f, 0.8f, 1.0f, alpha); // Light blue for absorb - break; - case game::CombatTextEntry::RESIST: - if (entry.amount > 0) - snprintf(text, sizeof(text), "Resisted %d", entry.amount); - else - snprintf(text, sizeof(text), "Resisted"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist - break; - case game::CombatTextEntry::DEFLECT: - snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); - color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) - : ImVec4(0.5f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::REFLECT: { - const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!reflectName.empty()) - snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); - else - snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); - color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) - : ImVec4(0.75f, 0.85f, 1.0f, alpha); - break; + snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); + color = ImVec4(0.9f, 0.5f, 0.2f, alpha); + break; + } + case game::CombatTextEntry::ENERGIZE: + snprintf(text, sizeof(text), "+%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; } - case game::CombatTextEntry::PROC_TRIGGER: { - const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!procName.empty()) - snprintf(text, sizeof(text), "%s!", procName.c_str()); - else - snprintf(text, sizeof(text), "PROC!"); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for proc - break; + break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; } - case game::CombatTextEntry::DISPEL: - if (entry.spellId != 0) { - const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); - if (!dispelledName.empty()) - snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); - else - snprintf(text, sizeof(text), "Dispel"); - } else { + break; + case game::CombatTextEntry::XP_GAIN: + snprintf(text, sizeof(text), "+%d XP", entry.amount); + color = ImVec4(0.7f, 0.3f, 1.0f, alpha); + break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); + break; + case game::CombatTextEntry::ABSORB: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Absorbed %d", entry.amount); + else + snprintf(text, sizeof(text), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, alpha); + break; + case game::CombatTextEntry::RESIST: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Resisted %d", entry.amount); + else + snprintf(text, sizeof(text), "Resisted"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; + } + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else snprintf(text, sizeof(text), "Dispel"); - } - color = ImVec4(0.6f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::STEAL: - if (entry.spellId != 0) { - const std::string& stolenName = gameHandler.getSpellName(entry.spellId); - if (!stolenName.empty()) - snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); - else - snprintf(text, sizeof(text), "Spellsteal"); - } else { - snprintf(text, sizeof(text), "Spellsteal"); - } - color = ImVec4(0.8f, 0.7f, 1.0f, alpha); - break; - case game::CombatTextEntry::INTERRUPT: { - const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; - if (!interruptedName.empty()) - snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); - else - snprintf(text, sizeof(text), "Interrupt"); - color = ImVec4(1.0f, 0.6f, 0.9f, alpha); - break; + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : + ImVec4(0.75f, 0.35f, 0.35f, alpha); + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : + ImVec4(1.0f, 0.15f, 0.15f, alpha); + break; + default: + snprintf(text, sizeof(text), "%d", entry.amount); + color = ImVec4(1.0f, 1.0f, 1.0f, alpha); + break; + } + + // --- Rendering style --- + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + + // --- Try world-space anchor if we have a destination entity --- + // Types that should always stay as HUD elements (no world anchor) + bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN || + entry.type == game::CombatTextEntry::HONOR_GAIN || + entry.type == game::CombatTextEntry::PROC_TRIGGER); + + bool rendered = false; + if (!isHudOnly && camera && entry.dstGuid != 0) { + // Look up the destination entity's render position + glm::vec3 renderPos; + bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos); + if (!havePos) { + // Fallback to entity canonical position + auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + havePos = true; + } } - case game::CombatTextEntry::INSTAKILL: - snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); - color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) - : ImVec4(1.0f, 0.1f, 0.1f, alpha); - break; - case game::CombatTextEntry::HONOR_GAIN: - snprintf(text, sizeof(text), "+%d Honor", entry.amount); - color = ImVec4(1.0f, 0.85f, 0.0f, alpha); // Gold for honor - break; - case game::CombatTextEntry::GLANCING: - snprintf(text, sizeof(text), "~%d", entry.amount); - color = outgoing ? - ImVec4(0.75f, 0.75f, 0.5f, alpha) : // Outgoing glancing = muted yellow - ImVec4(0.75f, 0.35f, 0.35f, alpha); // Incoming glancing = muted red - break; - case game::CombatTextEntry::CRUSHING: - snprintf(text, sizeof(text), "%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.55f, 0.1f, alpha) : // Outgoing crushing = orange - ImVec4(1.0f, 0.15f, 0.15f, alpha); // Incoming crushing = bright red - break; - default: - snprintf(text, sizeof(text), "%d", entry.amount); - color = ImVec4(1.0f, 1.0f, 1.0f, alpha); - break; } - // Outgoing → right side (near target), incoming → center-left (near player) - int& idx = outgoing ? outIdx : inIdx; - float baseX = outgoing ? outgoingX : incomingX; + if (havePos) { + // Float upward from above the entity's head + renderPos.z += 2.5f + entry.age * 1.2f; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w > 0.01f) { + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) { + float sx = (ndc.x * 0.5f + 0.5f) * screenW; + float sy = (ndc.y * 0.5f + 0.5f) * screenH; + + // Horizontal stagger using the random seed + sx += entry.xSeed * 40.0f; + + // Center the text horizontally on the projected point + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + sx -= ts.x * 0.5f; + + // Clamp to screen bounds + sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f)); + + drawList->AddText(font, renderFontSize, + ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text); + drawList->AddText(font, renderFontSize, + ImVec2(sx, sy), textCol, text); + rendered = true; + } + } + } + } + + // --- HUD fallback for entries without world anchor or HUD-only types --- + if (!rendered) { + if (!needsHudWindow) { + needsHudWindow = true; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImGui::Begin("##CombatText", nullptr, flags); + } + + float yOffset = 200.0f - entry.age * 60.0f; + int& idx = outgoing ? hudOutIdx : hudInIdx; + float baseX = outgoing ? hudOutgoingX : hudIncomingX; float xOffset = baseX + (idx % 3 - 1) * 60.0f; ++idx; - // Crits render at 1.35× normal font size for visual impact - bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || - entry.type == game::CombatTextEntry::CRIT_HEAL); - ImFont* font = ImGui::GetFont(); - float baseFontSize = ImGui::GetFontSize(); - float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; - - // Advance cursor so layout accounting is correct, then read screen pos ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); ImVec2 screenPos = ImGui::GetCursorScreenPos(); - // Drop shadow for readability over complex backgrounds - ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); - ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); - ImDrawList* dl = ImGui::GetWindowDrawList(); + ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), shadowCol, text); dl->AddText(font, renderFontSize, screenPos, textCol, text); - // Reserve space so ImGui doesn't clip the window prematurely ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); ImGui::Dummy(ts); } } - ImGui::End(); + + if (needsHudWindow) { + ImGui::End(); + } } // ============================================================ @@ -10838,7 +11155,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); bool isTarget = (guid == targetGuid); - // Player nameplates are always shown; NPC nameplates respect the V-key toggle + // Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle + if (isPlayer && !showFriendlyNameplates_) continue; if (!isPlayer && !showNameplates_) continue; // For corpses (dead units), only show a minimal grey nameplate if selected @@ -10920,6 +11238,10 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { isTargetingPlayer = (unitTarget == playerGuid); } } + // Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver) + int creatureRank = -1; + if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry()); + // Border: gold = currently selected, orange = targeting player, dark = default ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) @@ -10943,6 +11265,24 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { } drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + // Elite/Boss/Rare decoration: extra outer border with rank-specific color + if (creatureRank == 1 || creatureRank == 2) { + // Elite / Rare Elite: gold double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 200, 50, A(200)), 3.0f); + } else if (creatureRank == 3) { + // Boss: red double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 40, 40, A(200)), 3.0f); + } else if (creatureRank == 4) { + // Rare: silver double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(170, 200, 230, A(200)), 3.0f); + } + // HP % text centered on health bar (non-corpse, non-full-health for readability) if (!isCorpse && unit->getMaxHealth() > 0) { int hpPct = static_cast(healthPct * 100.0f + 0.5f); @@ -10964,15 +11304,32 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); const float cbH = 6.0f * nameplateScale_; - // Spell name above the cast bar + // Spell icon + name above the cast bar const std::string& spellName = gameHandler.getSpellName(cs->spellId); - if (!spellName.empty()) { - ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); - float snX = sx - snSz.x * 0.5f; - float snY = castBarBaseY; - drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); - drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); - castBarBaseY += snSz.y + 2.0f; + { + auto* castAm = core::Application::getInstance().getAssetManager(); + VkDescriptorSet castIcon = (cs->spellId && castAm) + ? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE; + float iconSz = cbH + 8.0f; + if (castIcon) { + // Draw icon to the left of the cast bar + float iconX = barX - iconSz - 2.0f; + float iconY = castBarBaseY; + drawList->AddImage((ImTextureID)(uintptr_t)castIcon, + ImVec2(iconX, iconY), + ImVec2(iconX + iconSz, iconY + iconSz)); + drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f), + ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f), + IM_COL32(0, 0, 0, A(180)), 1.0f); + } + if (!spellName.empty()) { + ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); + float snX = sx - snSz.x * 0.5f; + float snY = castBarBaseY; + drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); + drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); + castBarBaseY += snSz.y + 2.0f; + } } // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete @@ -11034,14 +11391,79 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), IM_COL32(0, 0, 0, A(150)), 1.0f); - // Spell name tooltip on hover + // Duration clock-sweep overlay (like target frame auras) + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remainMs = aura.getRemainingMs(nowMs); + if (aura.maxDurationMs > 0 && remainMs > 0) { + float pct = 1.0f - static_cast(remainMs) / static_cast(aura.maxDurationMs); + pct = std::clamp(pct, 0.0f, 1.0f); + float cx = dotX + dotSize * 0.5f; + float cy = nameplateBottom + dotSize * 0.5f; + float r = dotSize * 0.5f; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + pct * IM_PI * 2.0f; + ImVec2 center(cx, cy); + const int segments = 12; + for (int seg = 0; seg < segments; seg++) { + float a0 = startAngle + (endAngle - startAngle) * seg / segments; + float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments; + drawList->AddTriangleFilled( + center, + ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)), + ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)), + IM_COL32(0, 0, 0, A(100))); + } + } + + // Stack count on dot (upper-left corner) + if (aura.charges > 1) { + char stackBuf[8]; + snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges); + drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf); + drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf); + } + + // Duration text below dot + if (remainMs > 0) { + char durBuf[8]; + if (remainMs >= 60000) + snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000); + else + snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000); + ImVec2 durSz = ImGui::CalcTextSize(durBuf); + float durX = dotX + (dotSize - durSz.x) * 0.5f; + float durY = nameplateBottom + dotSize + 1.0f; + drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf); + // Color: red if < 5s, yellow if < 15s, white otherwise + ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240)) + : remainMs < 15000 ? IM_COL32(255, 200, 60, A(240)) + : IM_COL32(230, 230, 230, A(220)); + drawList->AddText(ImVec2(durX, durY), durCol, durBuf); + } + + // Spell name + duration tooltip on hover { ImVec2 mouse = ImGui::GetMousePos(); if (mouse.x >= dotX && mouse.x < dotX + dotSize && mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); - if (!dotSpellName.empty()) - ImGui::SetTooltip("%s", dotSpellName.c_str()); + if (!dotSpellName.empty()) { + if (remainMs > 0) { + int secs = remainMs / 1000; + int mins = secs / 60; + secs %= 60; + char tipBuf[128]; + if (mins > 0) + snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs); + else + snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs); + ImGui::SetTooltip("%s", tipBuf); + } else { + ImGui::SetTooltip("%s", dotSpellName.c_str()); + } + } } } @@ -11093,9 +11515,33 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC } + // Sub-label below the name: guild tag for players, subtitle for NPCs + std::string subLabel; + if (isPlayer) { + uint32_t guildId = gameHandler.getEntityGuildId(guid); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) subLabel = "<" + gn + ">"; + } + } else { + // NPC subtitle (e.g. "", "") + std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) subLabel = "<" + sub + ">"; + } + if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line + drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + // Sub-label below the name (WoW-style or in lighter color) + if (!subLabel.empty()) { + ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str()); + float subX = sx - subSz.x * 0.5f; + float subY = nameY + textSize.y + 1.0f; + drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str()); + drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str()); + } + // Group leader crown to the right of the name on player nameplates if (isPlayer && gameHandler.isInGroup() && gameHandler.getPartyData().leaderGuid == guid) { @@ -11928,6 +12374,42 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { // Durability Warning (equipment damage indicator) // ============================================================ +void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) return; + + // Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png + const char* home = std::getenv("HOME"); + if (!home) home = std::getenv("USERPROFILE"); + if (!home) home = "/tmp"; + std::string dir = std::string(home) + "/.wowee/screenshots"; + + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &tt); +#else + localtime_r(&tt, &tm); +#endif + + char filename[128]; + std::snprintf(filename, sizeof(filename), + "WoWee_%04d%02d%02d_%02d%02d%02d.png", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + + std::string path = dir + "/" + filename; + + if (renderer->captureScreenshot(path)) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Screenshot saved: " + path; + core::Application::getInstance().getGameHandler()->addLocalChatMessage(sysMsg); + } +} + void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { if (gameHandler.getPlayerGuid() == 0) return; @@ -13066,20 +13548,30 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { } ImGui::Spacing(); - if (ImGui::Button("Need", ImVec2(80, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + // voteMask bits: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant + const uint8_t vm = roll.voteMask; + bool first = true; + if (vm & 0x02) { + if (ImGui::Button("Need", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Greed", ImVec2(80, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + if (vm & 0x04) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Greed", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Disenchant", ImVec2(95, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + if (vm & 0x08) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Disenchant", ImVec2(95, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Pass", ImVec2(70, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); + if (vm & 0x01) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Pass", ImVec2(70, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); } // Live roll results from group members @@ -13246,22 +13738,8 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) { - // BG name - std::string bgName; - if (slot->arenaType > 0) { - bgName = std::to_string(slot->arenaType) + "v" + std::to_string(slot->arenaType) + " Arena"; - } else { - switch (slot->bgTypeId) { - case 1: bgName = "Alterac Valley"; break; - case 2: bgName = "Warsong Gulch"; break; - case 3: bgName = "Arathi Basin"; break; - case 7: bgName = "Eye of the Storm"; break; - case 9: bgName = "Strand of the Ancients"; break; - case 11: bgName = "Isle of Conquest"; break; - default: bgName = "Battleground"; break; - } - } - + // BG name from stored queue data + std::string bgName = slot->bgName.empty() ? "Battleground" : slot->bgName; ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str()); ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast(remaining)); ImGui::Spacing(); @@ -13464,6 +13942,62 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndPopup(); } + // Petition signatures window (shown when a petition item is used or offered) + if (gameHandler.hasPetitionSignaturesUI()) { + ImGui::OpenPopup("PetitionSignatures"); + gameHandler.clearPetitionSignaturesUI(); + } + if (ImGui::BeginPopupModal("PetitionSignatures", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& pInfo = gameHandler.getPetitionInfo(); + if (!pInfo.guildName.empty()) + ImGui::Text("Guild Charter: %s", pInfo.guildName.c_str()); + else + ImGui::Text("Guild Charter"); + ImGui::Separator(); + + ImGui::Text("Signatures: %u / %u", pInfo.signatureCount, pInfo.signaturesRequired); + ImGui::Spacing(); + + if (!pInfo.signatures.empty()) { + for (size_t i = 0; i < pInfo.signatures.size(); ++i) { + const auto& sig = pInfo.signatures[i]; + // Try to resolve name from entity manager + std::string sigName; + if (sig.playerGuid != 0) { + auto entity = gameHandler.getEntityManager().getEntity(sig.playerGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) sigName = unit->getName(); + } + } + if (sigName.empty()) + sigName = "Player " + std::to_string(i + 1); + ImGui::BulletText("%s", sigName.c_str()); + } + ImGui::Spacing(); + } + + // If we're not the owner, show Sign button + bool isOwner = (pInfo.ownerGuid == gameHandler.getPlayerGuid()); + if (!isOwner) { + if (ImGui::Button("Sign", ImVec2(120, 0))) { + gameHandler.signPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } else if (pInfo.signatureCount >= pInfo.signaturesRequired) { + // Owner with enough sigs — turn in + if (ImGui::Button("Turn In", ImVec2(120, 0))) { + gameHandler.turnInPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } + if (ImGui::Button("Close", ImVec2(120, 0))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + if (!showGuildRoster_) return; // Get zone manager for name lookup @@ -14277,10 +14811,15 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { const auto& ts = arenaStats[ai]; ImGui::PushID(static_cast(ai)); - // Team header with rating - char teamLabel[48]; - snprintf(teamLabel, sizeof(teamLabel), "Team #%u", ts.teamId); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel); + // Team header: "2v2: Team Name" or fallback "Team #id" + std::string teamLabel; + if (ts.teamType > 0) + teamLabel = std::to_string(ts.teamType) + "v" + std::to_string(ts.teamType) + ": "; + if (!ts.teamName.empty()) + teamLabel += ts.teamName; + else + teamLabel += "Team #" + std::to_string(ts.teamId); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel.c_str()); ImGui::Indent(8.0f); // Rating and rank @@ -14306,6 +14845,10 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::Spacing(); ImGui::TextDisabled("-- Roster (%zu members) --", roster->members.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh")) + gameHandler.requestArenaTeamRoster(ts.teamId); + // Column headers ImGui::Columns(4, "##arenaRosterCols", false); ImGui::SetColumnWidth(0, 110.0f); @@ -14341,6 +14884,10 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::NextColumn(); } ImGui::Columns(1); + } else { + ImGui::Spacing(); + if (ImGui::SmallButton("Load Roster")) + gameHandler.requestArenaTeamRoster(ts.teamId); } ImGui::Unindent(8.0f); @@ -15505,7 +16052,22 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { gameHandler.repairAll(vendor.vendorGuid, false); } if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Repair all equipped items using your gold"); + // Show durability summary of all equipment + const auto& inv = gameHandler.getInventory(); + int damagedCount = 0; + int brokenCount = 0; + for (int s = 0; s < static_cast(game::EquipSlot::BAG1); s++) { + const auto& slot = inv.getEquipSlot(static_cast(s)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) brokenCount++; + else if (slot.item.curDurability < slot.item.maxDurability) damagedCount++; + } + if (brokenCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d damaged, %d broken", damagedCount, brokenCount); + else if (damagedCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d item%s need repair", damagedCount, damagedCount > 1 ? "s" : ""); + else + ImGui::SetTooltip("All equipment is in good condition"); } if (gameHandler.isInGuild()) { ImGui::SameLine(); @@ -15742,8 +16304,19 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (ImGui::SmallButton(buyBtnId.c_str())) { int qty = vendorBuyQty; if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; - gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, - static_cast(qty)); + uint32_t totalCost = item.buyPrice * static_cast(qty); + if (totalCost >= 10000) { // >= 1 gold: confirm + vendorConfirmOpen_ = true; + vendorConfirmGuid_ = vendor.vendorGuid; + vendorConfirmItemId_ = item.itemId; + vendorConfirmSlot_ = item.slot; + vendorConfirmQty_ = static_cast(qty); + vendorConfirmPrice_ = totalCost; + vendorConfirmItemName_ = (info && info->valid) ? info->name : "Item"; + } else { + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, + static_cast(qty)); + } } if (outOfStock) ImGui::EndDisabled(); @@ -15759,6 +16332,33 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (!open) { gameHandler.closeVendor(); } + + // Vendor purchase confirmation popup for expensive items + if (vendorConfirmOpen_) { + ImGui::OpenPopup("Confirm Purchase##vendor"); + vendorConfirmOpen_ = false; + } + if (ImGui::BeginPopupModal("Confirm Purchase##vendor", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + ImGui::Text("Buy %s", vendorConfirmItemName_.c_str()); + if (vendorConfirmQty_ > 1) + ImGui::Text("Quantity: %u", vendorConfirmQty_); + uint32_t g = vendorConfirmPrice_ / 10000; + uint32_t s = (vendorConfirmPrice_ / 100) % 100; + uint32_t c = vendorConfirmPrice_ % 100; + ImGui::Text("Cost: %ug %us %uc", g, s, c); + ImGui::Spacing(); + if (ImGui::Button("Buy", ImVec2(80, 0))) { + gameHandler.buyItem(vendorConfirmGuid_, vendorConfirmItemId_, + vendorConfirmSlot_, vendorConfirmQty_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } // ============================================================ @@ -16272,6 +16872,119 @@ void GameScreen::renderEscapeMenu() { ImGui::End(); } +// ============================================================ +// Barber Shop Window +// ============================================================ + +void GameScreen::renderBarberShopWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isBarberShopOpen()) { + barberInitialized_ = false; + return; + } + + const auto* ch = gameHandler.getActiveCharacter(); + if (!ch) return; + + uint8_t race = static_cast(ch->race); + game::Gender gender = ch->gender; + game::Race raceEnum = ch->race; + + // Initialize sliders from current appearance + if (!barberInitialized_) { + barberOrigHairStyle_ = static_cast((ch->appearanceBytes >> 16) & 0xFF); + barberOrigHairColor_ = static_cast((ch->appearanceBytes >> 24) & 0xFF); + barberOrigFacialHair_ = static_cast(ch->facialFeatures); + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + barberInitialized_ = true; + } + + int maxHairStyle = static_cast(game::getMaxHairStyle(raceEnum, gender)); + int maxHairColor = static_cast(game::getMaxHairColor(raceEnum, gender)); + int maxFacialHair = static_cast(game::getMaxFacialFeature(raceEnum, gender)); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float winW = 300.0f; + float winH = 220.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - winW) / 2.0f, (screenH - winH) / 2.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Appearing); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + bool open = true; + if (ImGui::Begin("Barber Shop", &open, flags)) { + ImGui::Text("Choose your new look:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushItemWidth(-1); + + // Hair Style + ImGui::Text("Hair Style"); + ImGui::SliderInt("##HairStyle", &barberHairStyle_, 0, maxHairStyle, + "%d"); + + // Hair Color + ImGui::Text("Hair Color"); + ImGui::SliderInt("##HairColor", &barberHairColor_, 0, maxHairColor, + "%d"); + + // Facial Hair / Piercings / Markings + const char* facialLabel = (gender == game::Gender::FEMALE) ? "Piercings" : "Facial Hair"; + // Some races use "Markings" or "Tusks" etc. + if (race == 8 || race == 6) facialLabel = "Features"; // Trolls, Tauren + ImGui::Text("%s", facialLabel); + ImGui::SliderInt("##FacialHair", &barberFacialHair_, 0, maxFacialHair, + "%d"); + + ImGui::PopItemWidth(); + + ImGui::Spacing(); + ImGui::Separator(); + + // Show whether anything changed + bool changed = (barberHairStyle_ != barberOrigHairStyle_ || + barberHairColor_ != barberOrigHairColor_ || + barberFacialHair_ != barberOrigFacialHair_); + + // OK / Reset / Cancel buttons + float btnW = 80.0f; + float totalW = btnW * 3 + ImGui::GetStyle().ItemSpacing.x * 2; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) / 2.0f); + + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("OK", ImVec2(btnW, 0))) { + gameHandler.sendAlterAppearance( + static_cast(barberHairStyle_), + static_cast(barberHairColor_), + static_cast(barberFacialHair_)); + // Keep window open — server will respond with SMSG_BARBER_SHOP_RESULT + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("Reset", ImVec2(btnW, 0))) { + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.closeBarberShop(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeBarberShop(); + } +} + // ============================================================ // Pet Stable Window // ============================================================ @@ -19006,6 +19719,31 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // Instance difficulty indicator — just below zone name, inside minimap top edge + if (gameHandler.isInInstance()) { + static const char* kDiffLabels[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; + uint32_t diff = gameHandler.getInstanceDifficulty(); + const char* label = (diff < 4) ? kDiffLabels[diff] : "Unknown"; + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.85f; + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label); + float tx = centerX - ts.x * 0.5f; + // Position below zone name: top edge + zone font size + small gap + float ty = centerY - mapRadius + 4.0f + ImGui::GetFontSize() + 2.0f; + float pad = 2.0f; + + // Color-code: heroic=orange, normal=light gray + ImU32 bgCol = gameHandler.isInstanceHeroic() ? IM_COL32(120, 60, 0, 180) : IM_COL32(0, 0, 0, 160); + ImU32 textCol = gameHandler.isInstanceHeroic() ? IM_COL32(255, 180, 50, 255) : IM_COL32(200, 200, 200, 220); + + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + bgCol, 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), textCol, label); + } + // Hover tooltip and right-click context menu { ImVec2 mouse = ImGui::GetMousePos(); @@ -19268,6 +20006,37 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); + // Clock display at bottom-right of minimap (local time) + { + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tmBuf{}; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockText[16]; + std::snprintf(clockText, sizeof(clockText), "%d:%02d %s", + (tmBuf.tm_hour % 12 == 0) ? 12 : tmBuf.tm_hour % 12, + tmBuf.tm_min, + tmBuf.tm_hour >= 12 ? "PM" : "AM"); + ImVec2 clockSz = ImGui::CalcTextSize(clockText); + float clockW = clockSz.x + 10.0f; + float clockH = clockSz.y + 6.0f; + ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - clockW - 2.0f, + centerY + mapRadius - clockH - 2.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(clockW, clockH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + ImGuiWindowFlags clockFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##MinimapClock", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.8f, 0.85f), "%s", clockText); + } + ImGui::End(); + } + // Indicators below the minimap (stacked: new mail, then BG queue, then latency) float indicatorX = centerX - mapRadius; float nextIndicatorY = centerY + mapRadius + 4.0f; @@ -19411,18 +20180,28 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { nextIndicatorY += kIndicatorH; } - // Latency indicator — centered at top of screen + // Latency + FPS indicator — centered at top of screen uint32_t latMs = gameHandler.getLatencyMs(); - if (showLatencyMeter_ && latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { + if (showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { + float currentFps = ImGui::GetIO().Framerate; ImVec4 latColor; if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); - char latBuf[32]; - snprintf(latBuf, sizeof(latBuf), "%u ms", latMs); - ImVec2 textSize = ImGui::CalcTextSize(latBuf); + ImVec4 fpsColor; + if (currentFps >= 60.0f) fpsColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (currentFps >= 30.0f) fpsColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else fpsColor = ImVec4(1.0f, 0.3f, 0.3f, 0.9f); + + char infoText[64]; + if (latMs > 0) + snprintf(infoText, sizeof(infoText), "%.0f fps | %u ms", currentFps, latMs); + else + snprintf(infoText, sizeof(infoText), "%.0f fps", currentFps); + + ImVec2 textSize = ImGui::CalcTextSize(infoText); float latW = textSize.x + 16.0f; float latH = textSize.y + 8.0f; ImGuiIO& lio = ImGui::GetIO(); @@ -19431,7 +20210,14 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); ImGui::SetNextWindowBgAlpha(0.45f); if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { - ImGui::TextColored(latColor, "%s", latBuf); + // Color the FPS and latency portions differently + ImGui::TextColored(fpsColor, "%.0f fps", currentFps); + if (latMs > 0) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "|"); + ImGui::SameLine(0, 4); + ImGui::TextColored(latColor, "%u ms", latMs); + } } ImGui::End(); } @@ -19600,8 +20386,16 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: 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. - // $n = player name + // $n/$N = player name, $c/$C = class name, $r/$R = race name // $p = subject pronoun (he/she/they) // $o = object pronoun (him/her/them) // $s = possessive adjective (his/her/their) @@ -19615,6 +20409,8 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: std::string replacement; 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; @@ -19749,6 +20545,7 @@ void GameScreen::saveSettings() { out << "show_keyring=" << (pendingShowKeyring ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "nameplate_scale=" << nameplateScale_ << "\n"; + out << "show_friendly_nameplates=" << (showFriendlyNameplates_ ? 1 : 0) << "\n"; out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; @@ -19878,6 +20675,8 @@ void GameScreen::loadSettings() { pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); } else if (key == "nameplate_scale") { nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); + } else if (key == "show_friendly_nameplates") { + showFriendlyNameplates_ = (std::stoi(val) != 0); } else if (key == "show_action_bar2") { pendingShowActionBar2 = (std::stoi(val) != 0); } else if (key == "action_bar2_offset_x") { @@ -22423,36 +23222,36 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::Text("Dungeon:"); struct DungeonEntry { uint32_t id; const char* name; }; - static const DungeonEntry kDungeons[] = { - { 861, "Random Dungeon" }, - { 862, "Random Heroic" }, - // Vanilla classics - { 36, "Deadmines" }, - { 43, "Ragefire Chasm" }, - { 47, "Razorfen Kraul" }, - { 48, "Blackfathom Deeps" }, - { 52, "Uldaman" }, - { 57, "Dire Maul: East" }, - { 70, "Onyxia's Lair" }, - // TBC heroics - { 264, "The Blood Furnace" }, - { 269, "The Shattered Halls" }, - // WotLK normals/heroics - { 576, "The Nexus" }, - { 578, "The Oculus" }, - { 595, "The Culling of Stratholme" }, - { 599, "Halls of Stone" }, - { 600, "Drak'Tharon Keep" }, - { 601, "Azjol-Nerub" }, - { 604, "Gundrak" }, - { 608, "Violet Hold" }, - { 619, "Ahn'kahet: Old Kingdom" }, - { 623, "Halls of Lightning" }, - { 632, "The Forge of Souls" }, - { 650, "Trial of the Champion" }, - { 658, "Pit of Saron" }, - { 668, "Halls of Reflection" }, + // Category 0=Random, 1=Classic, 2=TBC, 3=WotLK + struct DungeonEntryEx { uint32_t id; const char* name; uint8_t cat; }; + static const DungeonEntryEx kDungeons[] = { + { 861, "Random Dungeon", 0 }, + { 862, "Random Heroic", 0 }, + { 36, "Deadmines", 1 }, + { 43, "Ragefire Chasm", 1 }, + { 47, "Razorfen Kraul", 1 }, + { 48, "Blackfathom Deeps", 1 }, + { 52, "Uldaman", 1 }, + { 57, "Dire Maul: East", 1 }, + { 70, "Onyxia's Lair", 1 }, + { 264, "The Blood Furnace", 2 }, + { 269, "The Shattered Halls", 2 }, + { 576, "The Nexus", 3 }, + { 578, "The Oculus", 3 }, + { 595, "The Culling of Stratholme", 3 }, + { 599, "Halls of Stone", 3 }, + { 600, "Drak'Tharon Keep", 3 }, + { 601, "Azjol-Nerub", 3 }, + { 604, "Gundrak", 3 }, + { 608, "Violet Hold", 3 }, + { 619, "Ahn'kahet: Old Kingdom", 3 }, + { 623, "Halls of Lightning", 3 }, + { 632, "The Forge of Souls", 3 }, + { 650, "Trial of the Champion", 3 }, + { 658, "Pit of Saron", 3 }, + { 668, "Halls of Reflection", 3 }, }; + static const char* kCatHeaders[] = { nullptr, "-- Classic --", "-- TBC --", "-- WotLK --" }; // Find current index int curIdx = 0; @@ -22462,7 +23261,15 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(-1); if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) { + uint8_t lastCat = 255; for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + if (kDungeons[i].cat != lastCat && kCatHeaders[kDungeons[i].cat]) { + if (lastCat != 255) ImGui::Separator(); + ImGui::TextDisabled("%s", kCatHeaders[kDungeons[i].cat]); + lastCat = kDungeons[i].cat; + } else if (kDungeons[i].cat != lastCat) { + lastCat = kDungeons[i].cat; + } bool selected = (kDungeons[i].id == lfgSelectedDungeon_); if (ImGui::Selectable(kDungeons[i].name, selected)) lfgSelectedDungeon_ = kDungeons[i].id; diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index b4e2ac89..366e9fa0 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -871,6 +871,35 @@ 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(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(); } @@ -2302,22 +2331,39 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } - // Shift+right-click: open destroy confirmation for non-quest items + // Shift+right-click: split stack (if stackable >1) or destroy confirmation if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && - !holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) { - destroyConfirmOpen_ = true; - destroyItemName_ = item.name; - destroyCount_ = static_cast(std::clamp( - std::max(1u, item.stackCount), 1u, 255u)); - if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { - destroyBag_ = 0xFF; - destroySlot_ = static_cast(23 + backpackIndex); - } else if (kind == SlotKind::BACKPACK && isBagSlot) { - destroyBag_ = static_cast(19 + bagIndex); - destroySlot_ = static_cast(bagSlotIndex); - } else if (kind == SlotKind::EQUIPMENT) { - destroyBag_ = 0xFF; - destroySlot_ = static_cast(equipSlot); + !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(item.stackCount); + splitCount_ = splitMax_ / 2; + if (splitCount_ < 1) splitCount_ = 1; + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + splitBag_ = 0xFF; + splitSlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + splitBag_ = static_cast(19 + bagIndex); + splitSlot_ = static_cast(bagSlotIndex); + } + } else if (item.bindType != 4) { + // Destroy confirmation for non-quest, non-stackable items + destroyConfirmOpen_ = true; + destroyItemName_ = item.name; + destroyCount_ = static_cast(std::clamp( + std::max(1u, item.stackCount), 1u, 255u)); + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + destroyBag_ = static_cast(19 + bagIndex); + destroySlot_ = static_cast(bagSlotIndex); + } else if (kind == SlotKind::EQUIPMENT) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(equipSlot); + } } } diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 92b52bd9..fe5cd2cb 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -82,6 +82,14 @@ 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) { @@ -92,11 +100,12 @@ 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; diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index c2b92eff..5f87712f 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -176,6 +176,29 @@ 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, @@ -574,10 +597,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, ImGui::EndTooltip(); } - // Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...) - // CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value) + // Handle click — open confirmation dialog instead of learning directly if (clicked && canLearn && prereqsMet) { - gameHandler.learnTalent(talent.talentId, currentRank); + 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"; } ImGui::PopID();