diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..5b970dd9 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"55a28c7e-8043-44c2-9829-702f303c84ba","pid":3880168,"acquiredAt":1773085726967} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 718d657f..54f39283 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -118,6 +118,7 @@ if(WOWEE_ENABLE_AMD_FSR3_FRAMEGEN AND WOWEE_AMD_FFX_SDK_KITS_READY) CXX_STANDARD_REQUIRED ON ) target_include_directories(wowee_fsr3_framegen_amd_vk_probe PUBLIC + ${WOWEE_AMD_FFX_SDK_KITS_DIR}/upscalers/include ${WOWEE_AMD_FFX_SDK_KITS_DIR}/upscalers/fsr3/include ${WOWEE_AMD_FFX_SDK_KITS_DIR}/framegeneration/fsr3/include ${WOWEE_AMD_FFX_SDK_KITS_DIR}/framegeneration/include @@ -155,21 +156,53 @@ if(WOWEE_ENABLE_AMD_FSR3_FRAMEGEN AND WOWEE_AMD_FFX_SDK_KITS_READY) set(WOWEE_AMD_FSR3_RUNTIME_BUILD_TYPE Release) endif() - add_custom_target(wowee_fsr3_official_runtime_build - COMMAND ${CMAKE_COMMAND} - -S ${WOWEE_AMD_FFX_SDK_KITS_DIR} - -B ${WOWEE_AMD_FSR3_RUNTIME_BUILD_DIR} - -DCMAKE_BUILD_TYPE=${WOWEE_AMD_FSR3_RUNTIME_BUILD_TYPE} - -DFFX_BUILD_VK=ON - -DFFX_BUILD_FRAMEGENERATION=ON - -DFFX_BUILD_UPSCALER=ON - COMMAND ${CMAKE_COMMAND} - --build ${WOWEE_AMD_FSR3_RUNTIME_BUILD_DIR} - --config $ - --parallel - COMMENT "Building native AMD FSR3 runtime (Path A) from FidelityFX-SDK Kits" - VERBATIM + # Locate bash at configure time so the build-time COMMAND works on Windows + # (cmake custom commands run via cmd.exe on Windows, so bare 'bash' is not found). + find_program(BASH_EXECUTABLE bash + HINTS + /usr/bin + /bin + "${MSYS2_PATH}/usr/bin" + "$ENV{MSYS2_PATH}/usr/bin" + "C:/msys64/usr/bin" + "D:/msys64/usr/bin" ) + if(BASH_EXECUTABLE) + add_custom_target(wowee_fsr3_official_runtime_build + COMMAND ${CMAKE_COMMAND} + -S ${WOWEE_AMD_FFX_SDK_KITS_DIR} + -B ${WOWEE_AMD_FSR3_RUNTIME_BUILD_DIR} + -DCMAKE_BUILD_TYPE=${WOWEE_AMD_FSR3_RUNTIME_BUILD_TYPE} + -DFFX_BUILD_VK=ON + -DFFX_BUILD_FRAMEGENERATION=ON + -DFFX_BUILD_UPSCALER=ON + COMMAND ${BASH_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/generate_ffx_sdk_vk_permutations.sh + ${CMAKE_SOURCE_DIR}/extern/FidelityFX-SDK + COMMAND ${CMAKE_COMMAND} + --build ${WOWEE_AMD_FSR3_RUNTIME_BUILD_DIR} + --config $ + --parallel + COMMENT "Building native AMD FSR3 runtime (Path A) from FidelityFX-SDK Kits" + VERBATIM + ) + else() + message(STATUS "bash not found; VK permutation headers will not be auto-generated") + add_custom_target(wowee_fsr3_official_runtime_build + COMMAND ${CMAKE_COMMAND} + -S ${WOWEE_AMD_FFX_SDK_KITS_DIR} + -B ${WOWEE_AMD_FSR3_RUNTIME_BUILD_DIR} + -DCMAKE_BUILD_TYPE=${WOWEE_AMD_FSR3_RUNTIME_BUILD_TYPE} + -DFFX_BUILD_VK=ON + -DFFX_BUILD_FRAMEGENERATION=ON + -DFFX_BUILD_UPSCALER=ON + COMMAND ${CMAKE_COMMAND} + --build ${WOWEE_AMD_FSR3_RUNTIME_BUILD_DIR} + --config $ + --parallel + COMMENT "Building native AMD FSR3 runtime (Path A) from FidelityFX-SDK Kits (no permutation bootstrap)" + VERBATIM + ) + endif() add_custom_target(wowee_fsr3_official_runtime_copy COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_RUNTIME_OUTPUT_DIRECTORY} @@ -473,7 +506,6 @@ set(WOWEE_SOURCES src/rendering/renderer.cpp src/rendering/amd_fsr3_runtime.cpp src/rendering/shader.cpp - src/rendering/texture.cpp src/rendering/mesh.cpp src/rendering/camera.cpp src/rendering/camera_controller.cpp @@ -587,7 +619,6 @@ set(WOWEE_HEADERS include/rendering/vk_render_target.hpp include/rendering/renderer.hpp include/rendering/shader.hpp - include/rendering/texture.hpp include/rendering/mesh.hpp include/rendering/camera.hpp include/rendering/camera_controller.hpp @@ -641,6 +672,14 @@ if(TARGET opcodes-generate) add_dependencies(wowee opcodes-generate) endif() +# FidelityFX-SDK headers can trigger compiler-specific pragma/unused-static noise +# when included through the runtime bridge; keep suppression scoped to that TU. +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + set_source_files_properties(src/rendering/amd_fsr3_runtime.cpp PROPERTIES + COMPILE_OPTIONS "-Wno-unknown-pragmas;-Wno-unused-variable" + ) +endif() + # Compile GLSL shaders to SPIR-V if(GLSLC) compile_shaders(wowee) @@ -715,6 +754,12 @@ endif() if(TARGET wowee_fsr3_framegen_amd_vk_probe) target_link_libraries(wowee PRIVATE wowee_fsr3_framegen_amd_vk_probe) endif() +if(TARGET wowee_fsr3_official_runtime_copy) + # FSR3 Path A runtime is an opt-in artifact; build explicitly with: + # cmake --build build --target wowee_fsr3_official_runtime_copy + # Do NOT add as a hard dependency of wowee — it would break arm64 and Windows CI + # (no DXC available on arm64; bash context issues on MSYS2 Windows). +endif() # Link Unicorn if available if(HAVE_UNICORN) @@ -735,6 +780,13 @@ if(MSVC) target_compile_options(wowee PRIVATE /W4) else() target_compile_options(wowee PRIVATE -Wall -Wextra -Wpedantic -Wno-missing-field-initializers) + # GCC LTO emits -Wodr for FFX enum-name mismatches across SDK generations. + # We intentionally keep FSR2+FSR3 integrations in separate TUs and suppress + # this linker-time diagnostic to avoid CI noise. + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_compile_options(wowee PRIVATE -Wno-odr) + target_link_options(wowee PRIVATE -Wno-odr) + endif() endif() # Debug build flags diff --git a/README.md b/README.md index b1b1b300..54ae7eaa 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ make -j$(nproc) - All build jobs are AMD-FSR2-only (`WOWEE_ENABLE_AMD_FSR2=ON`) and explicitly build `wowee_fsr2_amd_vk` - Each job clones AMD's FSR2 SDK and FidelityFX-SDK (`Kelsidavis/FidelityFX-SDK`, `main` by default) - Linux CI validates FidelityFX-SDK Kits framegen headers +- FSR3 Path A runtime build auto-bootstraps missing VK permutation headers via `tools/generate_ffx_sdk_vk_permutations.sh` - CI builds `wowee_fsr3_framegen_amd_vk_probe` when that target is generated for the detected SDK layout - If FSR2 generated Vulkan permutation headers are absent upstream, WoWee bootstraps them from `third_party/fsr2_vk_permutations` - Container build via `container/build-in-container.sh` (Podman) diff --git a/docs/AMD_FSR2_INTEGRATION.md b/docs/AMD_FSR2_INTEGRATION.md index bc7bffe8..03d829f4 100644 --- a/docs/AMD_FSR2_INTEGRATION.md +++ b/docs/AMD_FSR2_INTEGRATION.md @@ -42,6 +42,7 @@ Runtime note: - Renderer/UI expose a persisted experimental framegen toggle. - Runtime loader is Path A only (official AMD runtime library). +- Path A runtime build now auto-runs `tools/generate_ffx_sdk_vk_permutations.sh` to ensure required VK permutation headers exist for FSR2/FSR3 upscaler shader blobs. - You can point to an explicit runtime binary with: - `WOWEE_FFX_SDK_RUNTIME_LIB=/absolute/path/to/libffx_fsr3_vk.so` (or `.dll` / `.dylib`). - If no official runtime is found, frame generation is disabled cleanly (Path C). @@ -76,6 +77,10 @@ Runtime note: - `framegeneration/fsr3/include/ffx_frameinterpolation.h` - `framegeneration/fsr3/include/ffx_opticalflow.h` - `backend/vk/ffx_vk.h` +- Runtime build path auto-bootstrap: + - Linux downloads DXC automatically when missing. + - Windows (MSYS2) downloads DXC automatically when missing. + - macOS expects `dxc` to be available in `PATH` (CI installs it via Homebrew). - CI builds `wowee_fsr3_framegen_amd_vk_probe` when that target is generated by CMake for the detected SDK layout. - Some upstream SDK checkouts do not include generated Vulkan permutation headers. - WoWee bootstraps those headers from the vendored snapshot so AMD backend builds remain cross-platform and deterministic. diff --git a/include/audio/ambient_sound_manager.hpp b/include/audio/ambient_sound_manager.hpp index 88f326aa..b73bafa1 100644 --- a/include/audio/ambient_sound_manager.hpp +++ b/include/audio/ambient_sound_manager.hpp @@ -45,6 +45,9 @@ public: void setZoneType(ZoneType type); ZoneType getCurrentZone() const { return currentZone_; } + // Convenience: derive ZoneType and CityType from a WoW zone ID + void setZoneId(uint32_t zoneId); + // City ambience control enum class CityType { NONE, @@ -111,6 +114,8 @@ private: std::vector windSounds_; std::vector tavernSounds_; std::vector blacksmithSounds_; + std::vector birdSounds_; + std::vector cricketSounds_; // Weather sound libraries std::vector rainLightSounds_; diff --git a/include/audio/music_manager.hpp b/include/audio/music_manager.hpp index 9a5bceea..dece08ed 100644 --- a/include/audio/music_manager.hpp +++ b/include/audio/music_manager.hpp @@ -52,6 +52,11 @@ private: float fadeInTimer = 0.0f; float fadeInDuration = 0.0f; float fadeInTargetVolume = 0.0f; + // Fade-out state (for stopMusic with fadeMs > 0) + bool fadingOut = false; + float fadeOutTimer = 0.0f; + float fadeOutDuration = 0.0f; + float fadeOutStartVolume = 0.0f; std::unordered_map> musicDataCache_; }; diff --git a/include/audio/npc_voice_manager.hpp b/include/audio/npc_voice_manager.hpp index 2a5d7cfb..92ab8f32 100644 --- a/include/audio/npc_voice_manager.hpp +++ b/include/audio/npc_voice_manager.hpp @@ -74,7 +74,6 @@ private: void loadVoiceSounds(); bool loadSound(const std::string& path, VoiceSample& sample); - VoiceType detectVoiceType(uint32_t creatureEntry) const; void playSound(uint64_t npcGuid, VoiceType voiceType, SoundCategory category, const glm::vec3& position); pipeline::AssetManager* assetManager_ = nullptr; diff --git a/include/audio/ui_sound_manager.hpp b/include/audio/ui_sound_manager.hpp index 1ab91ebd..241014ae 100644 --- a/include/audio/ui_sound_manager.hpp +++ b/include/audio/ui_sound_manager.hpp @@ -67,6 +67,9 @@ public: // Level up void playLevelUp(); + // Achievement + void playAchievementAlert(); + // Error/feedback void playError(); void playTargetSelect(); @@ -114,6 +117,7 @@ private: std::vector drinkingSounds_; std::vector levelUpSounds_; + std::vector achievementSounds_; std::vector errorSounds_; std::vector selectTargetSounds_; diff --git a/include/core/coordinates.hpp b/include/core/coordinates.hpp index 85a31549..af38b453 100644 --- a/include/core/coordinates.hpp +++ b/include/core/coordinates.hpp @@ -53,17 +53,20 @@ inline float normalizeAngleRad(float a) { // Convert server/wire yaw (radians) → canonical yaw (radians). // -// Under server<->canonical X/Y swap: -// dir_s = (cos(s), sin(s)) -// dir_c = swap(dir_s) = (sin(s), cos(s)) => c = PI/2 - s +// Codebase canonical convention: atan2(-dy, dx) in (canonical_X=north, canonical_Y=west). +// North=0, East=+π/2, South=±π, West=-π/2. +// +// Server direction at angle s: (cos s, sin s) in (server_X=canonical_Y, server_Y=canonical_X). +// After swap: dir_c = (sin s, cos s) in (canonical_X, canonical_Y). +// atan2(-dy, dx) = atan2(-cos s, sin s) = s - π/2. inline float serverToCanonicalYaw(float serverYaw) { - return normalizeAngleRad((PI * 0.5f) - serverYaw); + return normalizeAngleRad(serverYaw - (PI * 0.5f)); } // Convert canonical yaw (radians) → server/wire yaw (radians). -// This mapping is its own inverse. +// Inverse of serverToCanonicalYaw: s = c + π/2. inline float canonicalToServerYaw(float canonicalYaw) { - return normalizeAngleRad((PI * 0.5f) - canonicalYaw); + return normalizeAngleRad(canonicalYaw + (PI * 0.5f)); } // Convert between canonical WoW and engine rendering coordinates (just swap X/Y). diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 3af2f59a..aba5a344 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -332,6 +332,10 @@ public: // Stand state void setStandState(uint8_t state); // 0=stand, 1=sit, 2=sit_chair, 3=sleep, 4=sit_low_chair, 5=sit_medium_chair, 6=sit_high_chair, 7=dead, 8=kneel, 9=submerged + uint8_t getStandState() const { return standState_; } + bool isSitting() const { return standState_ >= 1 && standState_ <= 6; } + bool isDead() const { return standState_ == 7; } + bool isKneeling() const { return standState_ == 8; } // Display toggles void toggleHelm(); @@ -390,6 +394,9 @@ public: // Ready check void initiateReadyCheck(); void respondToReadyCheck(bool ready); + bool hasPendingReadyCheck() const { return pendingReadyCheck_; } + void dismissReadyCheck() { pendingReadyCheck_ = false; } + const std::string& getReadyCheckInitiator() const { return readyCheckInitiator_; } // Duel void forfeitDuel(); @@ -501,6 +508,10 @@ public: const std::vector& getPlayerAuras() const { return playerAuras; } const std::vector& getTargetAuras() const { return targetAuras; } + // Completed quests (populated from SMSG_QUERY_QUESTS_COMPLETED_RESPONSE) + bool isQuestCompleted(uint32_t questId) const { return completedQuests_.count(questId) > 0; } + const std::unordered_set& getCompletedQuests() const { return completedQuests_; } + // NPC death callback (for animations) using NpcDeathCallback = std::function; void setNpcDeathCallback(NpcDeathCallback cb) { npcDeathCallback_ = std::move(cb); } @@ -548,6 +559,8 @@ public: float getWeatherIntensity() const { return weatherIntensity_; } bool isRaining() const { return weatherType_ == 1 && weatherIntensity_ > 0.05f; } bool isSnowing() const { return weatherType_ == 2 && weatherIntensity_ > 0.05f; } + uint32_t getOverrideLightId() const { return overrideLightId_; } + uint32_t getOverrideLightTransMs() const { return overrideLightTransMs_; } // Player skills const std::map& getPlayerSkills() const { return playerSkills_; } @@ -703,6 +716,88 @@ public: bool hasPendingGroupInvite() const { return pendingGroupInvite; } const std::string& getPendingInviterName() const { return pendingInviterName; } + // ---- Item text (books / readable items) ---- + bool isItemTextOpen() const { return itemTextOpen_; } + const std::string& getItemText() const { return itemText_; } + void closeItemText() { itemTextOpen_ = false; } + void queryItemText(uint64_t itemGuid); + + // ---- Shared Quest ---- + bool hasPendingSharedQuest() const { return pendingSharedQuest_; } + uint32_t getSharedQuestId() const { return sharedQuestId_; } + const std::string& getSharedQuestTitle() const { return sharedQuestTitle_; } + const std::string& getSharedQuestSharerName() const { return sharedQuestSharerName_; } + void acceptSharedQuest(); + void declineSharedQuest(); + + // ---- Summon ---- + bool hasPendingSummonRequest() const { return pendingSummonRequest_; } + const std::string& getSummonerName() const { return summonerName_; } + float getSummonTimeoutSec() const { return summonTimeoutSec_; } + void acceptSummon(); + void declineSummon(); + void tickSummonTimeout(float dt) { + if (!pendingSummonRequest_) return; + summonTimeoutSec_ -= dt; + if (summonTimeoutSec_ <= 0.0f) { + pendingSummonRequest_ = false; + summonTimeoutSec_ = 0.0f; + } + } + + // ---- Trade ---- + enum class TradeStatus : uint8_t { + None = 0, PendingIncoming, Open, Accepted, Complete + }; + TradeStatus getTradeStatus() const { return tradeStatus_; } + bool hasPendingTradeRequest() const { return tradeStatus_ == TradeStatus::PendingIncoming; } + const std::string& getTradePeerName() const { return tradePeerName_; } + void acceptTradeRequest(); // respond to incoming SMSG_TRADE_STATUS(1) with CMSG_BEGIN_TRADE + void declineTradeRequest(); // respond with CMSG_CANCEL_TRADE + void acceptTrade(); // lock in offer: CMSG_ACCEPT_TRADE + void cancelTrade(); // CMSG_CANCEL_TRADE + + // ---- Duel ---- + bool hasPendingDuelRequest() const { return pendingDuelRequest_; } + const std::string& getDuelChallengerName() const { return duelChallengerName_; } + void acceptDuel(); + // forfeitDuel() already declared at line ~399 + + // ---- Instance lockouts ---- + struct InstanceLockout { + uint32_t mapId = 0; + uint32_t difficulty = 0; // 0=normal,1=heroic/10man,2=25man,3=25man heroic + uint64_t resetTime = 0; // Unix timestamp of instance reset + bool locked = false; + bool extended = false; + }; + const std::vector& getInstanceLockouts() const { return instanceLockouts_; } + + // ---- LFG / Dungeon Finder ---- + enum class LfgState : uint8_t { + None = 0, + RoleCheck = 1, + Queued = 2, + Proposal = 3, + Boot = 4, + InDungeon = 5, + FinishedDungeon= 6, + RaidBrowser = 7, + }; + + // roles bitmask: 0x02=tank, 0x04=healer, 0x08=dps; pass LFGDungeonEntry ID + void lfgJoin(uint32_t dungeonId, uint8_t roles); + void lfgLeave(); + void lfgAcceptProposal(uint32_t proposalId, bool accept); + void lfgTeleport(bool toLfgDungeon = true); + LfgState getLfgState() const { return lfgState_; } + bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } + bool isLfgInDungeon() const { return lfgState_ == LfgState::InDungeon; } + uint32_t getLfgDungeonId() const { return lfgDungeonId_; } + uint32_t getLfgProposalId() const { return lfgProposalId_; } + int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } + uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } + // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); void lootItem(uint8_t slotIndex); @@ -713,6 +808,19 @@ public: void setAutoLoot(bool enabled) { autoLoot_ = enabled; } bool isAutoLoot() const { return autoLoot_; } + // Group loot roll + struct LootRollEntry { + uint64_t objectGuid = 0; + uint32_t slot = 0; + uint32_t itemId = 0; + std::string itemName; + uint8_t itemQuality = 0; + }; + bool hasPendingLootRoll() const { return pendingLootRollActive_; } + const LootRollEntry& getPendingLootRoll() const { return pendingLootRoll_; } + void sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType); + // rollType: 0=need, 1=greed, 2=disenchant, 96=pass + // NPC Gossip void interactWithNpc(uint64_t guid); void interactWithGameObject(uint64_t guid); @@ -726,6 +834,17 @@ public: bool isQuestDetailsOpen() const { return questDetailsOpen; } const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; } + // Gossip / quest map POI markers (SMSG_GOSSIP_POI) + struct GossipPoi { + float x = 0.0f; // WoW canonical X (north) + float y = 0.0f; // WoW canonical Y (west) + uint32_t icon = 0; // POI icon type + uint32_t data = 0; + std::string name; + }; + const std::vector& getGossipPois() const { return gossipPois_; } + void clearGossipPois() { gossipPois_.clear(); } + // Quest turn-in bool isQuestRequestItemsOpen() const { return questRequestItemsOpen_; } const QuestRequestItemsData& getQuestRequestItems() const { return currentQuestRequestItems_; } @@ -766,11 +885,39 @@ public: uint32_t getWorldStateMapId() const { return worldStateMapId_; } uint32_t getWorldStateZoneId() const { return worldStateZoneId_; } + // Mirror timers (0=fatigue, 1=breath, 2=feigndeath) + struct MirrorTimer { + int32_t value = 0; + int32_t maxValue = 0; + int32_t scale = 0; // +1 = counting up, -1 = counting down + bool paused = false; + bool active = false; + }; + const MirrorTimer& getMirrorTimer(int type) const { + static MirrorTimer empty; + return (type >= 0 && type < 3) ? mirrorTimers_[type] : empty; + } + + // Combo points + uint8_t getComboPoints() const { return comboPoints_; } + uint64_t getComboTarget() const { return comboTarget_; } + + // Death Knight rune state (6 runes: 0-1=Blood, 2-3=Unholy, 4-5=Frost; may become Death=3) + enum class RuneType : uint8_t { Blood = 0, Unholy = 1, Frost = 2, Death = 3 }; + struct RuneSlot { + RuneType type = RuneType::Blood; + bool ready = true; // Server-confirmed ready state + float readyFraction = 1.0f; // 0.0=depleted → 1.0=full (from server sync) + }; + const std::array& getPlayerRunes() const { return playerRunes_; } + struct FactionStandingInit { uint8_t flags = 0; int32_t standing = 0; }; const std::vector& getInitialFactions() const { return initialFactions_; } + const std::unordered_map& getFactionStandings() const { return factionStandings_; } + const std::string& getFactionNamePublic(uint32_t factionId) const; uint32_t getLastContactListMask() const { return lastContactListMask_; } uint32_t getLastContactListCount() const { return lastContactListCount_; } bool isServerMovementAllowed() const { return serverMovementAllowed_; } @@ -795,6 +942,26 @@ public: using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } + // Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received + using AchievementEarnedCallback = std::function; + void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } + + // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. + // The soundId corresponds to a SoundEntries.dbc record. The receiver is + // responsible for looking up the file path and forwarding to MusicManager. + using PlayMusicCallback = std::function; + void setPlayMusicCallback(PlayMusicCallback cb) { playMusicCallback_ = std::move(cb); } + + // Server-triggered 2-D sound effect callback — fires when SMSG_PLAY_SOUND is received. + // The soundId corresponds to a SoundEntries.dbc record. + using PlaySoundCallback = std::function; + void setPlaySoundCallback(PlaySoundCallback cb) { playSoundCallback_ = std::move(cb); } + + // Server-triggered 3-D positional sound callback — fires for SMSG_PLAY_OBJECT_SOUND and + // SMSG_PLAY_SPELL_IMPACT. Includes sourceGuid so the receiver can look up world position. + using PlayPositionalSoundCallback = std::function; + void setPlayPositionalSoundCallback(PlayPositionalSoundCallback cb) { playPositionalSoundCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -1120,6 +1287,11 @@ private: void handleSpellDamageLog(network::Packet& packet); void handleSpellHealLog(network::Packet& packet); + // ---- Equipment set handler ---- + void handleEquipmentSetList(network::Packet& packet); + void handleUpdateAuraDuration(uint8_t slot, uint32_t durationMs); + void handleSetForcedReactions(network::Packet& packet); + // ---- Phase 3 handlers ---- void handleInitialSpells(network::Packet& packet); void handleCastFailed(network::Packet& packet); @@ -1127,6 +1299,7 @@ private: void handleSpellGo(network::Packet& packet); void handleSpellCooldown(network::Packet& packet); void handleCooldownEvent(network::Packet& packet); + void handleAchievementEarned(network::Packet& packet); void handleAuraUpdate(network::Packet& packet, bool isAll); void handleLearnedSpell(network::Packet& packet); void handleSupercededSpell(network::Packet& packet); @@ -1203,6 +1376,28 @@ private: void loadAreaTriggerDbc(); void checkAreaTriggers(); + // ---- Instance lockout handler ---- + void handleRaidInstanceInfo(network::Packet& packet); + void handleItemTextQueryResponse(network::Packet& packet); + void handleQuestConfirmAccept(network::Packet& packet); + void handleSummonRequest(network::Packet& packet); + void handleTradeStatus(network::Packet& packet); + void handleDuelRequested(network::Packet& packet); + void handleDuelComplete(network::Packet& packet); + void handleDuelWinner(network::Packet& packet); + void handleLootRoll(network::Packet& packet); + void handleLootRollWon(network::Packet& packet); + + // ---- LFG / Dungeon Finder handlers ---- + void handleLfgJoinResult(network::Packet& packet); + void handleLfgQueueStatus(network::Packet& packet); + void handleLfgProposalUpdate(network::Packet& packet); + void handleLfgRoleCheckUpdate(network::Packet& packet); + void handleLfgUpdatePlayer(network::Packet& packet); + void handleLfgPlayerReward(network::Packet& packet); + void handleLfgBootProposalUpdate(network::Packet& packet); + void handleLfgTeleportDenied(network::Packet& packet); + // ---- Arena / Battleground handlers ---- void handleBattlefieldStatus(network::Packet& packet); void handleInstanceDifficulty(network::Packet& packet); @@ -1381,6 +1576,7 @@ private: // ---- Display state ---- bool helmVisible_ = true; bool cloakVisible_ = true; + uint8_t standState_ = 0; // 0=stand, 1=sit, ..., 7=dead, 8=kneel (server-confirmed) // ---- Follow state ---- uint64_t followTargetGuid_ = 0; @@ -1528,11 +1724,68 @@ private: uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; + // Mirror timers (0=fatigue, 1=breath, 2=feigndeath) + MirrorTimer mirrorTimers_[3]; + + // Combo points (rogues/druids) + uint8_t comboPoints_ = 0; + uint64_t comboTarget_ = 0; + + // Instance / raid lockouts + std::vector instanceLockouts_; + + // LFG / Dungeon Finder state + LfgState lfgState_ = LfgState::None; + uint32_t lfgDungeonId_ = 0; // current dungeon entry + uint32_t lfgProposalId_ = 0; // pending proposal id (0 = none) + int32_t lfgAvgWaitSec_ = -1; // estimated wait, -1=unknown + uint32_t lfgTimeInQueueMs_= 0; // ms already in queue + + // Ready check state + bool pendingReadyCheck_ = false; + std::string readyCheckInitiator_; + + // Faction standings (factionId → absolute standing value) + std::unordered_map factionStandings_; + // Faction name cache (factionId → name), populated lazily from Faction.dbc + std::unordered_map factionNameCache_; + bool factionNameCacheLoaded_ = false; + void loadFactionNameCache(); + std::string getFactionName(uint32_t factionId) const; + // ---- Phase 4: Group ---- GroupListData partyData; bool pendingGroupInvite = false; std::string pendingInviterName; + // Item text state + bool itemTextOpen_ = false; + std::string itemText_; + + // Shared quest state + bool pendingSharedQuest_ = false; + uint32_t sharedQuestId_ = 0; + std::string sharedQuestTitle_; + std::string sharedQuestSharerName_; + uint64_t sharedQuestSharerGuid_ = 0; + + // Summon state + bool pendingSummonRequest_ = false; + uint64_t summonerGuid_ = 0; + std::string summonerName_; + float summonTimeoutSec_ = 0.0f; + + // Trade state + TradeStatus tradeStatus_ = TradeStatus::None; + uint64_t tradePeerGuid_= 0; + std::string tradePeerName_; + + // Duel state + bool pendingDuelRequest_ = false; + uint64_t duelChallengerGuid_= 0; + uint64_t duelFlagGuid_ = 0; + std::string duelChallengerName_; + // ---- Guild state ---- std::string guildName_; std::vector guildRankNames_; @@ -1554,6 +1807,10 @@ private: bool lootWindowOpen = false; bool autoLoot_ = false; LootResponseData currentLoot; + + // Group loot roll state + bool pendingLootRollActive_ = false; + LootRollEntry pendingLootRoll_; struct LocalLootState { LootResponseData data; bool moneyTaken = false; @@ -1585,6 +1842,7 @@ private: // Gossip bool gossipWindowOpen = false; GossipMessageData currentGossip; + std::vector gossipPois_; void performGameObjectInteractionNow(uint64_t guid); @@ -1783,6 +2041,10 @@ private: uint32_t weatherType_ = 0; // 0=clear, 1=rain, 2=snow, 3=storm float weatherIntensity_ = 0.0f; // 0.0 to 1.0 + // ---- Light override (SMSG_OVERRIDE_LIGHT) ---- + uint32_t overrideLightId_ = 0; // 0 = no override + uint32_t overrideLightTransMs_ = 0; + // ---- Player skills ---- std::map playerSkills_; std::unordered_map skillLineNames_; @@ -1810,6 +2072,7 @@ private: ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; + AchievementEarnedCallback achievementEarnedCallback_; MountCallback mountCallback_; TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; @@ -1827,12 +2090,42 @@ private: float serverPitchRate_ = 3.14159f; bool playerDead_ = false; bool releasedSpirit_ = false; + // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially + std::array playerRunes_ = [] { + std::array r{}; + r[0].type = r[1].type = RuneType::Blood; + r[2].type = r[3].type = RuneType::Unholy; + r[4].type = r[5].type = RuneType::Frost; + return r; + }(); uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; uint64_t resurrectCasterGuid_ = 0; bool repopPending_ = false; uint64_t lastRepopRequestMs_ = 0; + + // ---- Completed quest IDs (SMSG_QUERY_QUESTS_COMPLETED_RESPONSE) ---- + std::unordered_set completedQuests_; + + // ---- Equipment sets (SMSG_EQUIPMENT_SET_LIST) ---- + struct EquipmentSet { + uint64_t setGuid = 0; + uint32_t setId = 0; + std::string name; + std::string iconName; + uint32_t ignoreSlotMask = 0; + std::array itemGuids{}; + }; + std::vector equipmentSets_; + + // ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ---- + std::unordered_map forcedReactions_; // factionId -> reaction tier + + // ---- Server-triggered audio ---- + PlayMusicCallback playMusicCallback_; + PlaySoundCallback playSoundCallback_; + PlayPositionalSoundCallback playPositionalSoundCallback_; }; } // namespace game diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index cf0e7ea9..3d1e871f 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -50,7 +50,8 @@ struct ActionBarSlot { struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, - CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL + CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, + ENERGIZE, XP_GAIN }; Type type; int32_t amount = 0; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 4067746e..42f64bc9 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1265,6 +1265,12 @@ public: // Duel // ============================================================ +/** CMSG_DUEL_ACCEPTED packet builder (no payload) */ +class DuelAcceptPacket { +public: + static network::Packet build(); +}; + /** CMSG_DUEL_CANCELLED packet builder */ class DuelCancelPacket { public: @@ -1320,6 +1326,24 @@ public: static network::Packet build(uint64_t targetGuid); }; +/** CMSG_BEGIN_TRADE packet builder (no payload — accepts incoming trade request) */ +class BeginTradePacket { +public: + static network::Packet build(); +}; + +/** CMSG_CANCEL_TRADE packet builder (no payload) */ +class CancelTradePacket { +public: + static network::Packet build(); +}; + +/** CMSG_ACCEPT_TRADE packet builder (no payload — lock in current offer) */ +class AcceptTradePacket { +public: + static network::Packet build(); +}; + /** CMSG_ATTACKSWING packet builder */ class AttackSwingPacket { public: diff --git a/include/game/zone_manager.hpp b/include/game/zone_manager.hpp index 281aad16..0df3a842 100644 --- a/include/game/zone_manager.hpp +++ b/include/game/zone_manager.hpp @@ -6,6 +6,7 @@ #include namespace wowee { +namespace pipeline { class AssetManager; } namespace game { struct ZoneInfo { @@ -18,6 +19,10 @@ class ZoneManager { public: void initialize(); + // Supplement zone music paths using AreaTable → ZoneMusic → SoundEntries DBC chain. + // Safe to call after initialize(); idempotent and additive (does not remove existing paths). + void enrichFromDBC(pipeline::AssetManager* assets); + uint32_t getZoneId(int tileX, int tileY) const; const ZoneInfo* getZoneInfo(uint32_t zoneId) const; std::string getRandomMusic(uint32_t zoneId); diff --git a/include/rendering/amd_fsr3_runtime.hpp b/include/rendering/amd_fsr3_runtime.hpp index c2b379d7..2ad7a94c 100644 --- a/include/rendering/amd_fsr3_runtime.hpp +++ b/include/rendering/amd_fsr3_runtime.hpp @@ -68,6 +68,11 @@ public: const std::string& lastError() const { return lastError_; } private: + enum class ApiMode { + LegacyFsr3, + GenericApi + }; + void* libHandle_ = nullptr; std::string loadedLibraryPath_; void* scratchBuffer_ = nullptr; @@ -80,6 +85,10 @@ private: struct RuntimeFns; RuntimeFns* fns_ = nullptr; void* contextStorage_ = nullptr; + ApiMode apiMode_ = ApiMode::LegacyFsr3; + void* genericUpscaleContext_ = nullptr; + void* genericFramegenContext_ = nullptr; + uint64_t genericFrameId_ = 1; }; } // namespace wowee::rendering diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index ee7d6ebf..a37c4a2d 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -381,7 +381,7 @@ private: VkDescriptorPool materialDescPool_ = VK_NULL_HANDLE; VkDescriptorPool boneDescPool_ = VK_NULL_HANDLE; static constexpr uint32_t MAX_MATERIAL_SETS = 8192; - static constexpr uint32_t MAX_BONE_SETS = 2048; + static constexpr uint32_t MAX_BONE_SETS = 8192; // Dynamic particle buffers ::VkBuffer smokeVB_ = VK_NULL_HANDLE; diff --git a/include/rendering/terrain_renderer.hpp b/include/rendering/terrain_renderer.hpp index 77af9a64..f4994792 100644 --- a/include/rendering/terrain_renderer.hpp +++ b/include/rendering/terrain_renderer.hpp @@ -106,9 +106,22 @@ public: void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); /** - * Render terrain into shadow depth map (Phase 6 stub) + * Initialize terrain shadow pipeline (must be called after initialize()). + * @param shadowRenderPass Depth-only render pass used for the shadow map. */ - void renderShadow(VkCommandBuffer cmd, const glm::vec3& shadowCenter, float halfExtent); + bool initializeShadow(VkRenderPass shadowRenderPass); + + /** + * Render terrain into the shadow depth map. + * @param cmd Command buffer (inside shadow render pass). + * @param lightSpaceMatrix Orthographic light-space transform. + * @param shadowCenter World-space centre of shadow coverage. + * @param shadowRadius Cull radius around shadowCenter. + */ + void renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, + const glm::vec3& shadowCenter, float shadowRadius); + + bool hasShadowPipeline() const { return shadowPipeline_ != VK_NULL_HANDLE; } void clear(); @@ -119,7 +132,6 @@ public: void setFogEnabled(bool enabled) { fogEnabled = enabled; } bool isFogEnabled() const { return fogEnabled; } - // Shadow mapping stubs (Phase 6) void setShadowMap(VkDescriptorImageInfo /*depthInfo*/, const glm::mat4& /*lightSpaceMat*/) {} void clearShadowMap() {} @@ -142,12 +154,21 @@ private: VkContext* vkCtx = nullptr; pipeline::AssetManager* assetManager = nullptr; - // Pipeline + // Main pipelines VkPipeline pipeline = VK_NULL_HANDLE; VkPipeline wireframePipeline = VK_NULL_HANDLE; VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; VkDescriptorSetLayout materialSetLayout = VK_NULL_HANDLE; + // Shadow pipeline + VkPipeline shadowPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout shadowPipelineLayout_ = VK_NULL_HANDLE; + VkDescriptorSetLayout shadowParamsLayout_ = VK_NULL_HANDLE; + VkDescriptorPool shadowParamsPool_ = VK_NULL_HANDLE; + VkDescriptorSet shadowParamsSet_ = VK_NULL_HANDLE; + VkBuffer shadowParamsUBO_ = VK_NULL_HANDLE; + VmaAllocation shadowParamsAlloc_ = VK_NULL_HANDLE; + // Descriptor pool for material sets VkDescriptorPool materialDescPool = VK_NULL_HANDLE; static constexpr uint32_t MAX_MATERIAL_SETS = 16384; diff --git a/include/rendering/texture.hpp b/include/rendering/texture.hpp deleted file mode 100644 index 5baf32a4..00000000 --- a/include/rendering/texture.hpp +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include -#include - -namespace wowee { -namespace rendering { - -class Texture { -public: - Texture() = default; - ~Texture(); - - bool loadFromFile(const std::string& path); - bool loadFromMemory(const unsigned char* data, int width, int height, int channels); - - void bind(GLuint unit = 0) const; - void unbind() const; - - GLuint getID() const { return textureID; } - int getWidth() const { return width; } - int getHeight() const { return height; } - -private: - GLuint textureID = 0; - int width = 0; - int height = 0; -}; - -/** - * Apply anisotropic filtering to the currently bound GL_TEXTURE_2D. - * Queries the driver maximum once and caches it. No-op if the extension - * is not available. - */ -void applyAnisotropicFiltering(); - -} // namespace rendering -} // namespace wowee diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 76d70b84..cd944b47 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -62,6 +62,7 @@ private: // UI state bool showEntityWindow = false; bool showChatWindow = true; + bool showNameplates_ = true; // V key toggles nameplates bool showPlayerInfo = false; bool showGuildRoster_ = false; std::string selectedGuildMember_; @@ -117,7 +118,7 @@ private: bool pendingPOM = true; // on by default int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) bool pendingFSR = false; - int pendingUpscalingMode = 0; // 0=Off, 1=FSR1, 2=FSR2 + int pendingUpscalingMode = 0; // 0=Off, 1=FSR1, 2=FSR3 int pendingFSRQuality = 3; // 0=UltraQuality, 1=Quality, 2=Balanced, 3=Native(100%) float pendingFSRSharpness = 1.6f; float pendingFSR2JitterSign = 0.38f; @@ -181,6 +182,11 @@ private: */ void renderTargetFrame(game::GameHandler& gameHandler); + /** + * Render pet frame (below player frame when player has an active pet) + */ + void renderPetFrame(game::GameHandler& gameHandler); + /** * Process targeting input (Tab, Escape, click) */ @@ -201,9 +207,16 @@ private: void renderBagBar(game::GameHandler& gameHandler); void renderXpBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); + void renderMirrorTimers(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); void renderGroupInvitePopup(game::GameHandler& gameHandler); + void renderDuelRequestPopup(game::GameHandler& gameHandler); + void renderLootRollPopup(game::GameHandler& gameHandler); + void renderTradeRequestPopup(game::GameHandler& gameHandler); + void renderSummonRequestPopup(game::GameHandler& gameHandler); + void renderSharedQuestPopup(game::GameHandler& gameHandler); + void renderItemTextWindow(game::GameHandler& gameHandler); void renderBuffBar(game::GameHandler& gameHandler); void renderLootWindow(game::GameHandler& gameHandler); void renderGossipWindow(game::GameHandler& gameHandler); @@ -219,14 +232,19 @@ private: void renderSettingsWindow(); void renderQuestMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler); + void renderQuestObjectiveTracker(game::GameHandler& gameHandler); void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler); + void renderReadyCheckPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); void renderBankWindow(game::GameHandler& gameHandler); void renderGuildBankWindow(game::GameHandler& gameHandler); void renderAuctionHouseWindow(game::GameHandler& gameHandler); + void renderDungeonFinderWindow(game::GameHandler& gameHandler); + void renderInstanceLockouts(game::GameHandler& gameHandler); + void renderNameplates(game::GameHandler& gameHandler); /** * Inventory screen @@ -249,6 +267,9 @@ private: bool spellIconDbLoaded_ = false; VkDescriptorSet getSpellIcon(uint32_t spellId, pipeline::AssetManager* am); + // Death Knight rune bar: client-predicted fill (0.0=depleted, 1.0=ready) for smooth animation + float runeClientFill_[6] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f}; + // Action bar drag state (-1 = not dragging) int actionBarDragSlot_ = -1; VkDescriptorSet actionBarDragIcon_ = VK_NULL_HANDLE; @@ -259,6 +280,14 @@ private: int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none) int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none) + // Instance Lockouts window + bool showInstanceLockouts_ = false; + + // Dungeon Finder state + bool showDungeonFinder_ = false; + uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) + uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) + // Chat settings bool chatShowTimestamps_ = false; int chatFontSize_ = 1; // 0=small, 1=medium, 2=large @@ -320,8 +349,22 @@ private: uint32_t dingLevel_ = 0; void renderDingEffect(); + // Achievement toast banner + static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; + float achievementToastTimer_ = 0.0f; + uint32_t achievementToastId_ = 0; + void renderAchievementToast(); + + // Zone discovery text ("Entering: ") + static constexpr float ZONE_TEXT_DURATION = 5.0f; + float zoneTextTimer_ = 0.0f; + std::string zoneTextName_; + std::string lastKnownZoneName_; + void renderZoneText(); + public: void triggerDing(uint32_t newLevel); + void triggerAchievementToast(uint32_t achievementId); }; } // namespace ui diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index bc580bde..a0a19386 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -148,6 +148,7 @@ private: void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0); + void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, float size, const char* label, diff --git a/src/audio/ambient_sound_manager.cpp b/src/audio/ambient_sound_manager.cpp index 5e820ef7..22791fe8 100644 --- a/src/audio/ambient_sound_manager.cpp +++ b/src/audio/ambient_sound_manager.cpp @@ -83,6 +83,34 @@ bool AmbientSoundManager::initialize(pipeline::AssetManager* assets) { blacksmithSounds_.resize(1); bool blacksmithLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\BlackSmith.wav", blacksmithSounds_[0], assets); + // Load bird chirp sounds (daytime periodic) — up to 6 variants + { + static const char* birdPaths[] = { + "Sound\\Ambience\\BirdAmbience\\BirdChirp01.wav", + "Sound\\Ambience\\BirdAmbience\\BirdChirp02.wav", + "Sound\\Ambience\\BirdAmbience\\BirdChirp03.wav", + "Sound\\Ambience\\BirdAmbience\\BirdChirp04.wav", + "Sound\\Ambience\\BirdAmbience\\BirdChirp05.wav", + "Sound\\Ambience\\BirdAmbience\\BirdChirp06.wav", + }; + for (const char* p : birdPaths) { + birdSounds_.emplace_back(); + if (!loadSound(p, birdSounds_.back(), assets)) birdSounds_.pop_back(); + } + } + + // Load cricket/insect sounds (nighttime periodic) + { + static const char* cricketPaths[] = { + "Sound\\Ambience\\Insect\\InsectMorning.wav", + "Sound\\Ambience\\Insect\\InsectNight.wav", + }; + for (const char* p : cricketPaths) { + cricketSounds_.emplace_back(); + if (!loadSound(p, cricketSounds_.back(), assets)) cricketSounds_.pop_back(); + } + } + // Load weather sounds rainLightSounds_.resize(1); bool rainLightLoaded = loadSound("Sound\\Ambience\\Weather\\RainLight.wav", rainLightSounds_[0], assets); @@ -413,9 +441,13 @@ void AmbientSoundManager::updatePeriodicSounds(float deltaTime, bool isIndoor, b if (isDaytime()) { birdTimer_ += deltaTime; if (birdTimer_ >= randomFloat(BIRD_MIN_INTERVAL, BIRD_MAX_INTERVAL)) { - // Play a random bird chirp (we'll use wind sound as placeholder for now) - // TODO: Add actual bird sound files when available birdTimer_ = 0.0f; + if (!birdSounds_.empty()) { + std::uniform_int_distribution pick(0, birdSounds_.size() - 1); + const auto& snd = birdSounds_[pick(gen)]; + if (snd.loaded) + AudioEngine::instance().playSound2D(snd.data, BIRD_VOLUME, 1.0f); + } } } @@ -423,9 +455,13 @@ void AmbientSoundManager::updatePeriodicSounds(float deltaTime, bool isIndoor, b if (isNighttime()) { cricketTimer_ += deltaTime; if (cricketTimer_ >= randomFloat(CRICKET_MIN_INTERVAL, CRICKET_MAX_INTERVAL)) { - // Play cricket sounds - // TODO: Add actual cricket sound files when available cricketTimer_ = 0.0f; + if (!cricketSounds_.empty()) { + std::uniform_int_distribution pick(0, cricketSounds_.size() - 1); + const auto& snd = cricketSounds_[pick(gen)]; + if (snd.loaded) + AudioEngine::instance().playSound2D(snd.data, CRICKET_VOLUME, 1.0f); + } } } } @@ -554,6 +590,94 @@ void AmbientSoundManager::setZoneType(ZoneType type) { } } +void AmbientSoundManager::setZoneId(uint32_t zoneId) { + // Map WoW zone ID to ZoneType + CityType. + // City zones: set CityType and clear ZoneType. + // Outdoor zones: set ZoneType and clear CityType. + CityType city = CityType::NONE; + ZoneType zone = ZoneType::NONE; + + switch (zoneId) { + // ---- Major cities ---- + case 1519: city = CityType::STORMWIND; break; + case 1537: city = CityType::IRONFORGE; break; + case 1657: city = CityType::DARNASSUS; break; + case 1637: city = CityType::ORGRIMMAR; break; + case 1497: city = CityType::UNDERCITY; break; + case 1638: city = CityType::THUNDERBLUFF; break; + + // ---- Forest / snowy forest ---- + case 12: // Elwynn Forest + case 141: // Teldrassil + case 148: // Darkshore + case 493: // Moonglade + case 361: // Felwood + case 331: // Ashenvale + case 357: // Feralas + case 15: // Dustwallow Marsh (lush) + case 267: // Hillsbrad Foothills + case 36: // Alterac Mountains + case 45: // Arathi Highlands + zone = ZoneType::FOREST_NORMAL; break; + + case 1: // Dun Morogh + case 196: // Winterspring + case 3: // Badlands (actually dry but close enough) + case 2817: // Crystalsong Forest + case 66: // Storm Peaks + case 67: // Icecrown + case 394: // Dragonblight + case 65: // Howling Fjord + zone = ZoneType::FOREST_SNOW; break; + + // ---- Grasslands / plains ---- + case 40: // Westfall + case 215: // Mulgore + case 44: // Redridge Mountains + case 10: // Duskwood (counts as grassland night) + case 38: // Loch Modan + zone = ZoneType::GRASSLANDS; break; + + // ---- Desert ---- + case 17: // The Barrens + case 14: // Durotar + case 440: // Tanaris + case 400: // Thousand Needles + zone = ZoneType::DESERT_PLAINS; break; + + case 46: // Burning Steppes + case 51: // Searing Gorge + case 241: // Eastern Plaguelands (barren) + case 28: // Western Plaguelands + zone = ZoneType::DESERT_CANYON; break; + + // ---- Jungle ---- + case 33: // Stranglethorn Vale + case 78: // Un'Goro Crater + case 210: // Uldaman + case 1377: // Silithus (arid but closest) + zone = ZoneType::JUNGLE; break; + + // ---- Marsh / swamp ---- + case 8: // Swamp of Sorrows + case 11: // Wetlands + case 139: // Eastern Plaguelands + case 763: // Zangarmarsh + zone = ZoneType::MARSH; break; + + // ---- Beach / coast ---- + case 4: // Barrens coast (Merchant Coast) + case 3537: // Azuremyst Isle + case 3524: // Bloodmyst Isle + zone = ZoneType::BEACH; break; + + default: break; + } + + setCityType(city); + setZoneType(zone); +} + void AmbientSoundManager::setCityType(CityType type) { if (currentCity_ != type) { LOG_INFO("AmbientSoundManager: City changed from ", static_cast(currentCity_), diff --git a/src/audio/music_manager.cpp b/src/audio/music_manager.cpp index 8ccff4f2..c22e6d68 100644 --- a/src/audio/music_manager.cpp +++ b/src/audio/music_manager.cpp @@ -144,15 +144,24 @@ void MusicManager::playFilePath(const std::string& filePath, bool loop, float fa } void MusicManager::stopMusic(float fadeMs) { - (void)fadeMs; // Fade not implemented yet - AudioEngine::instance().stopMusic(); - playing = false; + if (!playing) return; + fadingIn = false; - fadeInTimer = 0.0f; - fadeInDuration = 0.0f; - fadeInTargetVolume = 0.0f; - currentTrack.clear(); - currentTrackIsFile = false; + crossfading = false; + + if (fadeMs > 0.0f) { + // Begin fade-out; actual stop happens once volume reaches zero in update() + fadingOut = true; + fadeOutTimer = 0.0f; + fadeOutDuration = fadeMs / 1000.0f; + fadeOutStartVolume = effectiveMusicVolume(); + } else { + AudioEngine::instance().stopMusic(); + playing = false; + fadingOut = false; + currentTrack.clear(); + currentTrackIsFile = false; + } } void MusicManager::setVolume(int volume) { @@ -224,6 +233,22 @@ void MusicManager::update(float deltaTime) { playing = false; } + if (fadingOut) { + fadeOutTimer += deltaTime; + float t = std::clamp(1.0f - fadeOutTimer / std::max(fadeOutDuration, 0.001f), 0.0f, 1.0f); + AudioEngine::instance().setMusicVolume(fadeOutStartVolume * t); + if (t <= 0.0f) { + // Fade complete — stop playback and restore volume for next track + fadingOut = false; + AudioEngine::instance().stopMusic(); + AudioEngine::instance().setMusicVolume(effectiveMusicVolume()); + playing = false; + currentTrack.clear(); + currentTrackIsFile = false; + } + return; // Don't process other fade logic while fading out + } + if (fadingIn) { fadeInTimer += deltaTime; float t = std::clamp(fadeInTimer / std::max(fadeInDuration, 0.001f), 0.0f, 1.0f); diff --git a/src/audio/npc_voice_manager.cpp b/src/audio/npc_voice_manager.cpp index 316fb9b2..1027d165 100644 --- a/src/audio/npc_voice_manager.cpp +++ b/src/audio/npc_voice_manager.cpp @@ -373,12 +373,5 @@ void NpcVoiceManager::playFlee(uint64_t npcGuid, VoiceType voiceType, const glm: playSound(npcGuid, voiceType, SoundCategory::FLEE, position); } -VoiceType NpcVoiceManager::detectVoiceType(uint32_t creatureEntry) const { - // TODO: Use CreatureTemplate.dbc or other data to map creature entry to voice type - // For now, return generic - (void)creatureEntry; - return VoiceType::GENERIC; -} - } // namespace audio } // namespace wowee diff --git a/src/audio/spell_sound_manager.cpp b/src/audio/spell_sound_manager.cpp index 255e83bf..4c024b88 100644 --- a/src/audio/spell_sound_manager.cpp +++ b/src/audio/spell_sound_manager.cpp @@ -2,6 +2,7 @@ #include "audio/audio_engine.hpp" #include "pipeline/asset_manager.hpp" #include "core/logger.hpp" +#include #include namespace wowee { @@ -180,7 +181,7 @@ void SpellSoundManager::playRandomSound(const std::vector& library, } void SpellSoundManager::setVolumeScale(float scale) { - volumeScale_ = std::max(0.0f, std::min(1.0f, scale)); + volumeScale_ = std::clamp(scale, .0f, 1.f); } void SpellSoundManager::playPrecast(MagicSchool school, SpellPower power) { diff --git a/src/audio/ui_sound_manager.cpp b/src/audio/ui_sound_manager.cpp index f50f1d6f..f32f0d9b 100644 --- a/src/audio/ui_sound_manager.cpp +++ b/src/audio/ui_sound_manager.cpp @@ -105,6 +105,13 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { levelUpSounds_.resize(1); bool levelUpLoaded = loadSound("Sound\\Interface\\LevelUp.wav", levelUpSounds_[0], assets); + // Load achievement sound (WotLK: Sound\Interface\AchievementSound.wav) + achievementSounds_.resize(1); + if (!loadSound("Sound\\Interface\\AchievementSound.wav", achievementSounds_[0], assets)) { + // Fallback to level-up sound if achievement sound is missing + achievementSounds_ = levelUpSounds_; + } + // Load error/feedback sounds errorSounds_.resize(1); loadSound("Sound\\Interface\\Error.wav", errorSounds_[0], assets); @@ -210,6 +217,9 @@ void UiSoundManager::playDrinking() { playSound(drinkingSounds_); } // Level up void UiSoundManager::playLevelUp() { playSound(levelUpSounds_); } +// Achievement +void UiSoundManager::playAchievementAlert() { playSound(achievementSounds_); } + // Error/feedback void UiSoundManager::playError() { playSound(errorSounds_); } void UiSoundManager::playTargetSelect() { playSound(selectTargetSounds_); } diff --git a/src/core/application.cpp b/src/core/application.cpp index 21c6f533..be239cfc 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2089,6 +2089,88 @@ void Application::setupUICallbacks() { } }); + // Achievement earned callback — show toast banner + gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) { + if (uiManager) { + uiManager->getGameScreen().triggerAchievementToast(achievementId); + } + }); + + // Server-triggered music callback (SMSG_PLAY_MUSIC) + // Resolves soundId → SoundEntries.dbc → MPQ path → MusicManager. + gameHandler->setPlayMusicCallback([this](uint32_t soundId) { + if (!assetManager || !renderer) return; + auto* music = renderer->getMusicManager(); + if (!music) return; + + auto dbc = assetManager->loadDBC("SoundEntries.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + int32_t idx = dbc->findRecordById(soundId); + if (idx < 0) return; + + // SoundEntries.dbc (WotLK): field 2 = Name (label), fields 3-12 = File[0..9], field 23 = DirectoryBase + const uint32_t row = static_cast(idx); + std::string dir = dbc->getString(row, 23); + for (uint32_t f = 3; f <= 12; ++f) { + std::string name = dbc->getString(row, f); + if (name.empty()) continue; + std::string path = dir.empty() ? name : dir + "\\" + name; + music->playMusic(path, /*loop=*/false); + return; + } + }); + + // SMSG_PLAY_SOUND: look up SoundEntries.dbc and play 2-D sound effect + gameHandler->setPlaySoundCallback([this](uint32_t soundId) { + if (!assetManager) return; + + auto dbc = assetManager->loadDBC("SoundEntries.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + int32_t idx = dbc->findRecordById(soundId); + if (idx < 0) return; + + const uint32_t row = static_cast(idx); + std::string dir = dbc->getString(row, 23); + for (uint32_t f = 3; f <= 12; ++f) { + std::string name = dbc->getString(row, f); + if (name.empty()) continue; + std::string path = dir.empty() ? name : dir + "\\" + name; + audio::AudioEngine::instance().playSound2D(path); + return; + } + }); + + // SMSG_PLAY_OBJECT_SOUND / SMSG_PLAY_SPELL_IMPACT: play as 3D positional sound at source entity + gameHandler->setPlayPositionalSoundCallback([this](uint32_t soundId, uint64_t sourceGuid) { + if (!assetManager || !gameHandler) return; + + auto dbc = assetManager->loadDBC("SoundEntries.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + int32_t idx = dbc->findRecordById(soundId); + if (idx < 0) return; + + const uint32_t row = static_cast(idx); + std::string dir = dbc->getString(row, 23); + for (uint32_t f = 3; f <= 12; ++f) { + std::string name = dbc->getString(row, f); + if (name.empty()) continue; + std::string path = dir.empty() ? name : dir + "\\" + name; + + // Play as 3D sound if source entity position is available + auto entity = gameHandler->getEntityManager().getEntity(sourceGuid); + if (entity) { + glm::vec3 pos{entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()}; + audio::AudioEngine::instance().playSound3D(path, pos); + } else { + audio::AudioEngine::instance().playSound2D(path); + } + return; + } + }); + // Other player level-up callback — trigger 3D effect + chat notification gameHandler->setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { if (!gameHandler || !renderer) return; @@ -4643,7 +4725,7 @@ audio::VoiceType Application::detectVoiceTypeFromDisplayId(uint32_t displayId) c switch (raceId) { case 1: raceName = "Human"; result = (sexId == 0) ? audio::VoiceType::HUMAN_MALE : audio::VoiceType::HUMAN_FEMALE; break; case 2: raceName = "Orc"; result = (sexId == 0) ? audio::VoiceType::ORC_MALE : audio::VoiceType::ORC_FEMALE; break; - case 3: raceName = "Dwarf"; result = (sexId == 0) ? audio::VoiceType::DWARF_MALE : audio::VoiceType::GENERIC; break; + case 3: raceName = "Dwarf"; result = (sexId == 0) ? audio::VoiceType::DWARF_MALE : audio::VoiceType::DWARF_FEMALE; break; case 4: raceName = "NightElf"; result = (sexId == 0) ? audio::VoiceType::NIGHTELF_MALE : audio::VoiceType::NIGHTELF_FEMALE; break; case 5: raceName = "Undead"; result = (sexId == 0) ? audio::VoiceType::UNDEAD_MALE : audio::VoiceType::UNDEAD_FEMALE; break; case 6: raceName = "Tauren"; result = (sexId == 0) ? audio::VoiceType::TAUREN_MALE : audio::VoiceType::TAUREN_FEMALE; break; @@ -4809,11 +4891,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Check model cache - reuse if same displayId was already loaded uint32_t modelId = 0; - bool modelCached = false; auto cacheIt = displayIdModelCache_.find(displayId); if (cacheIt != displayIdModelCache_.end()) { modelId = cacheIt->second; - modelCached = true; } else { // Load model from disk (only once per displayId) modelId = nextCreatureModelId_++; @@ -6618,7 +6698,9 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); const float renderYawWmo = orientation; - const float renderYawM2go = orientation + glm::radians(180.0f); + // M2 game objects: model default faces +renderX. renderYaw = canonical + 90° = server_yaw + // (same offset as creature/character renderer so all M2 models face consistently) + const float renderYawM2go = orientation + glm::radians(90.0f); bool loadedAsWmo = false; if (isWmo) { @@ -8145,15 +8227,17 @@ void Application::despawnOnlineGameObject(uint64_t guid) { void Application::loadQuestMarkerModels() { if (!assetManager || !renderer) return; - // Quest markers in WoW 3.3.5a are billboard sprites (BLP textures), not M2 models - // Load the BLP textures for quest markers - LOG_INFO("Quest markers will be rendered as billboard sprites using BLP textures:"); - LOG_INFO(" - Available: Interface\\GossipFrame\\AvailableQuestIcon.blp"); - LOG_INFO(" - Turn-in: Interface\\GossipFrame\\ActiveQuestIcon.blp"); - LOG_INFO(" - Incomplete: Interface\\GossipFrame\\IncompleteQuestIcon.blp"); - - // TODO: Implement billboard sprite rendering for quest markers - // For now, the 2D ImGui markers will continue to work + // Quest markers are billboard sprites; the renderer's QuestMarkerRenderer handles + // texture loading and pipeline setup during world initialization. + // Calling initialize() here is a no-op if already done; harmless if called early. + if (auto* qmr = renderer->getQuestMarkerRenderer()) { + if (auto* vkCtx = renderer->getVkContext()) { + VkDescriptorSetLayout pfl = renderer->getPerFrameSetLayout(); + if (pfl != VK_NULL_HANDLE) { + qmr->initialize(vkCtx, pfl, assetManager.get()); + } + } + } } void Application::updateQuestMarkers() { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 3cd05d3c..f1402b57 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1174,6 +1174,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() == 4) { uint32_t soundId = packet.readUInt32(); LOG_INFO("SMSG_PLAY_MUSIC (0x0103 alias): soundId=", soundId); + if (playMusicCallback_) playMusicCallback_(soundId); return; } } else if (opcode == 0x0480) { @@ -1390,6 +1391,12 @@ void GameHandler::handlePacket(network::Packet& packet) { handleMessageChat(packet); } break; + case Opcode::SMSG_GM_MESSAGECHAT: + // GM → player message: same wire format as SMSG_MESSAGECHAT + if (state == WorldState::IN_WORLD) { + handleMessageChat(packet); + } + break; case Opcode::SMSG_TEXT_EMOTE: if (state == WorldState::IN_WORLD) { @@ -1414,6 +1421,31 @@ void GameHandler::handlePacket(network::Packet& packet) { handleChannelNotify(packet); } break; + case Opcode::SMSG_CHAT_PLAYER_NOT_FOUND: { + // string: name of the player not found (for failed whispers) + std::string name = packet.readString(); + if (!name.empty()) { + addSystemChatMessage("No player named '" + name + "' is currently playing."); + } + break; + } + case Opcode::SMSG_CHAT_PLAYER_AMBIGUOUS: { + // string: ambiguous player name (multiple matches) + std::string name = packet.readString(); + if (!name.empty()) { + addSystemChatMessage("Player name '" + name + "' is ambiguous."); + } + break; + } + case Opcode::SMSG_CHAT_WRONG_FACTION: + addSystemChatMessage("You cannot send messages to members of that faction."); + break; + case Opcode::SMSG_CHAT_NOT_IN_PARTY: + addSystemChatMessage("You are not in a party."); + break; + case Opcode::SMSG_CHAT_RESTRICTED: + addSystemChatMessage("You cannot send chat messages in this area."); + break; case Opcode::SMSG_QUERY_TIME_RESPONSE: if (state == WorldState::IN_WORLD) { @@ -1467,11 +1499,39 @@ void GameHandler::handlePacket(network::Packet& packet) { handleRandomRoll(packet); } break; - case Opcode::SMSG_ITEM_PUSH_RESULT: - // Item received notification (new item in bags, loot, quest reward, etc.) - // TODO: parse and show "item received" UI notification - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_ITEM_PUSH_RESULT: { + // Item received notification (loot, quest reward, trade, etc.) + // guid(8) + received(1) + created(1) + showInChat(1) + bagSlot(1) + itemSlot(4) + // + itemId(4) + itemSuffixFactor(4) + randomPropertyId(4) + count(4) + totalCount(4) + constexpr size_t kMinSize = 8 + 1 + 1 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + 4; + if (packet.getSize() - packet.getReadPos() >= kMinSize) { + /*uint64_t recipientGuid =*/ packet.readUInt64(); + /*uint8_t received =*/ packet.readUInt8(); // 0=looted/generated, 1=received from trade + /*uint8_t created =*/ packet.readUInt8(); // 0=stack added, 1=new item slot + uint8_t showInChat = packet.readUInt8(); + /*uint8_t bagSlot =*/ packet.readUInt8(); + /*uint32_t itemSlot =*/ packet.readUInt32(); + uint32_t itemId = packet.readUInt32(); + /*uint32_t suffixFactor =*/ packet.readUInt32(); + /*int32_t randomProp =*/ static_cast(packet.readUInt32()); + uint32_t count = packet.readUInt32(); + /*uint32_t totalCount =*/ packet.readUInt32(); + + queryItemInfo(itemId, 0); + if (showInChat) { + std::string itemName = "item #" + std::to_string(itemId); + if (const ItemQueryResponseData* info = getItemInfo(itemId)) { + if (!info->name.empty()) itemName = info->name; + } + std::string msg = "Received: " + itemName; + if (count > 1) msg += " x" + std::to_string(count); + addSystemChatMessage(msg); + } + LOG_INFO("Item push: itemId=", itemId, " count=", count, + " showInChat=", static_cast(showInChat)); + } break; + } case Opcode::SMSG_LOGOUT_RESPONSE: handleLogoutResponse(packet); @@ -1507,6 +1567,663 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LOG_XPGAIN: handleXpGain(packet); break; + case Opcode::SMSG_EXPLORATION_EXPERIENCE: { + // uint32 areaId + uint32 xpGained + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t areaId =*/ packet.readUInt32(); + uint32_t xpGained = packet.readUInt32(); + if (xpGained > 0) { + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Discovered new area! Gained %u experience.", xpGained); + addSystemChatMessage(buf); + // XP is updated via PLAYER_XP update fields from the server. + } + } + break; + } + case Opcode::SMSG_PET_TAME_FAILURE: { + // uint8 reason: 0=invalid_creature, 1=too_many_pets, 2=already_tamed, etc. + const char* reasons[] = { + "Invalid creature", "Too many pets", "Already tamed", + "Wrong faction", "Level too low", "Creature not tameable", + "Can't control", "Can't command" + }; + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t reason = packet.readUInt8(); + const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason"; + std::string s = std::string("Failed to tame: ") + msg; + addSystemChatMessage(s); + } + break; + } + case Opcode::SMSG_PET_ACTION_FEEDBACK: { + // uint8 action + uint8 flags + packet.setReadPos(packet.getSize()); // Consume; no UI for pet feedback yet. + break; + } + case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: { + // uint32 petNumber + string name + uint32 timestamp + bool declined + packet.setReadPos(packet.getSize()); // Consume; pet names shown via unit objects. + break; + } + case Opcode::SMSG_QUESTUPDATE_FAILED: { + // uint32 questId + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t questId = packet.readUInt32(); + char buf[128]; + std::snprintf(buf, sizeof(buf), "Quest %u failed!", questId); + addSystemChatMessage(buf); + } + break; + } + case Opcode::SMSG_QUESTUPDATE_FAILEDTIMER: { + // uint32 questId + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t questId = packet.readUInt32(); + char buf[128]; + std::snprintf(buf, sizeof(buf), "Quest %u timed out!", questId); + addSystemChatMessage(buf); + } + break; + } + + // ---- Entity health/power delta updates ---- + case Opcode::SMSG_HEALTH_UPDATE: { + // packed_guid + uint32 health + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t hp = packet.readUInt32(); + auto entity = entityManager.getEntity(guid); + if (auto* unit = dynamic_cast(entity.get())) { + unit->setHealth(hp); + } + break; + } + case Opcode::SMSG_POWER_UPDATE: { + // packed_guid + uint8 powerType + uint32 value + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 5) break; + uint8_t powerType = packet.readUInt8(); + uint32_t value = packet.readUInt32(); + auto entity = entityManager.getEntity(guid); + if (auto* unit = dynamic_cast(entity.get())) { + unit->setPowerByType(powerType, value); + } + break; + } + + // ---- World state single update ---- + case Opcode::SMSG_UPDATE_WORLD_STATE: { + // uint32 field + uint32 value + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t field = packet.readUInt32(); + uint32_t value = packet.readUInt32(); + worldStates_[field] = value; + LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); + break; + } + case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: { + // uint32 time (server unix timestamp) — used to sync UI timers (arena, BG) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t serverTime = packet.readUInt32(); + LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime); + } + break; + } + case Opcode::SMSG_PVP_CREDIT: { + // uint32 honorPoints + uint64 victimGuid + uint32 victimRank + if (packet.getSize() - packet.getReadPos() >= 16) { + uint32_t honor = packet.readUInt32(); + uint64_t victimGuid = packet.readUInt64(); + uint32_t rank = packet.readUInt32(); + LOG_INFO("SMSG_PVP_CREDIT: honor=", honor, " victim=0x", std::hex, victimGuid, + std::dec, " rank=", rank); + std::string msg = "You gain " + std::to_string(honor) + " honor points."; + addSystemChatMessage(msg); + } + break; + } + + // ---- Combo points ---- + case Opcode::SMSG_UPDATE_COMBO_POINTS: { + // packed_guid (target) + uint8 points + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t target = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + comboPoints_ = packet.readUInt8(); + comboTarget_ = target; + LOG_DEBUG("SMSG_UPDATE_COMBO_POINTS: target=0x", std::hex, target, + std::dec, " points=", static_cast(comboPoints_)); + break; + } + + // ---- Mirror timers (breath/fatigue/feign death) ---- + case Opcode::SMSG_START_MIRROR_TIMER: { + // uint32 type + int32 value + int32 maxValue + int32 scale + uint32 tracker + uint8 paused + if (packet.getSize() - packet.getReadPos() < 21) break; + uint32_t type = packet.readUInt32(); + int32_t value = static_cast(packet.readUInt32()); + int32_t maxV = static_cast(packet.readUInt32()); + int32_t scale = static_cast(packet.readUInt32()); + /*uint32_t tracker =*/ packet.readUInt32(); + uint8_t paused = packet.readUInt8(); + if (type < 3) { + mirrorTimers_[type].value = value; + mirrorTimers_[type].maxValue = maxV; + mirrorTimers_[type].scale = scale; + mirrorTimers_[type].paused = (paused != 0); + mirrorTimers_[type].active = true; + } + break; + } + case Opcode::SMSG_STOP_MIRROR_TIMER: { + // uint32 type + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t type = packet.readUInt32(); + if (type < 3) { + mirrorTimers_[type].active = false; + mirrorTimers_[type].value = 0; + } + break; + } + case Opcode::SMSG_PAUSE_MIRROR_TIMER: { + // uint32 type + uint8 paused + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t type = packet.readUInt32(); + uint8_t paused = packet.readUInt8(); + if (type < 3) { + mirrorTimers_[type].paused = (paused != 0); + } + break; + } + + // ---- Cast result (WotLK extended cast failed) ---- + case Opcode::SMSG_CAST_RESULT: + // WotLK: uint8 castCount + uint32 spellId + uint8 result [+ optional extra] + // If result == 0, the spell successfully began; otherwise treat like SMSG_CAST_FAILED. + if (packet.getSize() - packet.getReadPos() >= 6) { + /*uint8_t castCount =*/ packet.readUInt8(); + /*uint32_t spellId =*/ packet.readUInt32(); + uint8_t result = packet.readUInt8(); + if (result != 0) { + // Failure — clear cast bar and show message + casting = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + const char* reason = getSpellCastResultString(result, -1); + MessageChatData msg; + msg.type = ChatType::SYSTEM; + msg.language = ChatLanguage::UNIVERSAL; + msg.message = reason ? reason + : ("Spell cast failed (error " + std::to_string(result) + ")"); + addLocalChatMessage(msg); + } + } + break; + + // ---- Spell failed on another unit ---- + case Opcode::SMSG_SPELL_FAILED_OTHER: + // packed_guid + uint8 castCount + uint32 spellId + uint8 reason — just consume + packet.setReadPos(packet.getSize()); + break; + + // ---- Spell proc resist log ---- + case Opcode::SMSG_PROCRESIST: + // guid(8) + guid(8) + uint32 spellId + uint8 logSchoolMask — just consume + packet.setReadPos(packet.getSize()); + break; + + // ---- Loot start roll (Need/Greed popup trigger) ---- + case Opcode::SMSG_LOOT_START_ROLL: { + // uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId + // + uint32 randomSuffix + uint32 randomPropId + uint32 countdown + uint8 voteMask + if (packet.getSize() - packet.getReadPos() < 33) break; + uint64_t objectGuid = packet.readUInt64(); + /*uint32_t mapId =*/ packet.readUInt32(); + uint32_t slot = packet.readUInt32(); + uint32_t itemId = packet.readUInt32(); + /*uint32_t randSuffix =*/ packet.readUInt32(); + /*uint32_t randProp =*/ packet.readUInt32(); + /*uint32_t countdown =*/ packet.readUInt32(); + /*uint8_t voteMask =*/ packet.readUInt8(); + // Trigger the roll popup for local player + pendingLootRollActive_ = true; + pendingLootRoll_.objectGuid = objectGuid; + pendingLootRoll_.slot = slot; + pendingLootRoll_.itemId = itemId; + auto* info = getItemInfo(itemId); + pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); + pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, + ") slot=", slot); + break; + } + + // ---- Pet stable result ---- + case Opcode::SMSG_STABLE_RESULT: { + // uint8 result + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t result = packet.readUInt8(); + const char* msg = nullptr; + switch (result) { + case 0x01: msg = "Pet stored in stable."; break; + case 0x06: msg = "Pet retrieved from stable."; break; + case 0x07: msg = "Stable slot purchased."; break; + case 0x08: msg = "Stable list updated."; break; + case 0x09: msg = "Stable failed: not enough money or other error."; break; + default: break; + } + if (msg) addSystemChatMessage(msg); + LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); + break; + } + + // ---- Title earned ---- + case Opcode::SMSG_TITLE_EARNED: { + // uint32 titleBitIndex + uint32 isLost + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t titleBit = packet.readUInt32(); + uint32_t isLost = packet.readUInt32(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!", + titleBit); + addSystemChatMessage(buf); + LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost); + break; + } + + // ---- Hearthstone binding ---- + case Opcode::SMSG_PLAYERBOUND: { + // uint64 binderGuid + uint32 mapId + uint32 zoneId + if (packet.getSize() - packet.getReadPos() < 16) break; + /*uint64_t binderGuid =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t zoneId = packet.readUInt32(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Your home location has been set (map %u, zone %u).", mapId, zoneId); + addSystemChatMessage(buf); + break; + } + case Opcode::SMSG_BINDER_CONFIRM: { + // uint64 npcGuid — server confirming bind point has been set + addSystemChatMessage("This innkeeper is now your home location."); + packet.setReadPos(packet.getSize()); + break; + } + + // ---- Phase shift (WotLK phasing) ---- + case Opcode::SMSG_SET_PHASE_SHIFT: { + // uint32 phaseFlags [+ packed guid + uint16 count + repeated uint16 phaseIds] + // Just consume; phasing doesn't require action from client in WotLK + packet.setReadPos(packet.getSize()); + break; + } + + // ---- XP gain toggle ---- + case Opcode::SMSG_TOGGLE_XP_GAIN: { + // uint8 enabled + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t enabled = packet.readUInt8(); + addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled."); + break; + } + + // ---- Gossip POI (quest map markers) ---- + case Opcode::SMSG_GOSSIP_POI: { + // uint32 flags + float x + float y + uint32 icon + uint32 data + string name + if (packet.getSize() - packet.getReadPos() < 20) break; + /*uint32_t flags =*/ packet.readUInt32(); + float poiX = packet.readFloat(); // WoW canonical coords + float poiY = packet.readFloat(); + uint32_t icon = packet.readUInt32(); + uint32_t data = packet.readUInt32(); + std::string name = packet.readString(); + GossipPoi poi; + poi.x = poiX; + poi.y = poiY; + poi.icon = icon; + poi.data = data; + poi.name = std::move(name); + gossipPois_.push_back(std::move(poi)); + LOG_DEBUG("SMSG_GOSSIP_POI: x=", poiX, " y=", poiY, " icon=", icon); + break; + } + + // ---- Character service results ---- + case Opcode::SMSG_CHAR_RENAME: { + // uint32 result (0=success) + uint64 guid + string newName + if (packet.getSize() - packet.getReadPos() >= 13) { + uint32_t result = packet.readUInt32(); + /*uint64_t guid =*/ packet.readUInt64(); + std::string newName = packet.readString(); + if (result == 0) { + addSystemChatMessage("Character name changed to: " + newName); + } else { + addSystemChatMessage("Character rename failed (error " + std::to_string(result) + ")."); + } + LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); + } + break; + } + case Opcode::SMSG_BINDZONEREPLY: { + // uint32 result (0=success, 1=too far) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Your home is now set to this location."); + } else { + addSystemChatMessage("You are too far from the innkeeper."); + } + } + break; + } + case Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT: { + // uint32 result + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Difficulty changed."); + } else { + static const char* reasons[] = { + "", "Error", "Too many members", "Already in dungeon", + "You are in a battleground", "Raid not allowed in heroic", + "You must be in a raid group", "Player not in group" + }; + const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; + addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); + } + } + break; + } + case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE: + addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); + break; + case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: { + // uint64 playerGuid + uint32 threshold + if (packet.getSize() - packet.getReadPos() >= 12) { + uint64_t guid = packet.readUInt64(); + uint32_t threshold = packet.readUInt32(); + if (guid == playerGuid && threshold > 0) { + addSystemChatMessage("You feel rather drunk."); + } + LOG_DEBUG("SMSG_CROSSED_INEBRIATION_THRESHOLD: guid=0x", std::hex, guid, + std::dec, " threshold=", threshold); + } + break; + } + case Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE: + // Far sight cancelled; viewport returns to player camera + LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE"); + break; + case Opcode::SMSG_COMBAT_EVENT_FAILED: + // Combat event could not be executed (e.g. invalid target for special ability) + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_FORCE_ANIM: { + // packed_guid + uint32 animId — force entity to play animation + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t animId =*/ packet.readUInt32(); + } + } + break; + } + case Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM: + case Opcode::SMSG_GAMEOBJECT_RESET_STATE: + case Opcode::SMSG_FLIGHT_SPLINE_SYNC: + case Opcode::SMSG_FORCE_DISPLAY_UPDATE: + case Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS: + case Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID: + case Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE: + case Opcode::SMSG_DAMAGE_CALC_LOG: + case Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT: + case Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE: + case Opcode::SMSG_FORCED_DEATH_UPDATE: + // Consume — handled by broader object update or not yet implemented + packet.setReadPos(packet.getSize()); + break; + + // ---- Zone defense messages ---- + case Opcode::SMSG_DEFENSE_MESSAGE: { + // uint32 zoneId + string message — used for PvP zone attack alerts + if (packet.getSize() - packet.getReadPos() >= 5) { + /*uint32_t zoneId =*/ packet.readUInt32(); + std::string defMsg = packet.readString(); + if (!defMsg.empty()) { + addSystemChatMessage("[Defense] " + defMsg); + } + } + break; + } + case Opcode::SMSG_CORPSE_RECLAIM_DELAY: { + // uint32 delayMs before player can reclaim corpse + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t delayMs = packet.readUInt32(); + uint32_t delaySec = (delayMs + 999) / 1000; + addSystemChatMessage("You can reclaim your corpse in " + + std::to_string(delaySec) + " seconds."); + LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); + } + break; + } + case Opcode::SMSG_DEATH_RELEASE_LOC: { + // uint32 mapId + float x + float y + float z — spirit healer position + if (packet.getSize() - packet.getReadPos() >= 16) { + uint32_t mapId = packet.readUInt32(); + float x = packet.readFloat(); + float y = packet.readFloat(); + float z = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", mapId, " x=", x, " y=", y, " z=", z); + } + break; + } + 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"); + break; + case Opcode::SMSG_FEIGN_DEATH_RESISTED: + addSystemChatMessage("Your Feign Death attempt was resisted."); + LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED"); + break; + case Opcode::SMSG_CHANNEL_MEMBER_COUNT: { + // string channelName + uint8 flags + uint32 memberCount + std::string chanName = packet.readString(); + if (packet.getSize() - packet.getReadPos() >= 5) { + /*uint8_t flags =*/ packet.readUInt8(); + uint32_t count = packet.readUInt32(); + LOG_DEBUG("SMSG_CHANNEL_MEMBER_COUNT: channel=", chanName, " members=", count); + } + break; + } + case Opcode::SMSG_GAMETIME_SET: + case Opcode::SMSG_GAMETIME_UPDATE: + // Server time correction: uint32 gameTimePacked (seconds since epoch) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t gameTimePacked = packet.readUInt32(); + gameTime_ = static_cast(gameTimePacked); + LOG_DEBUG("Server game time update: ", gameTime_, "s"); + } + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_GAMESPEED_SET: + // Server speed correction: uint32 gameTimePacked + float timeSpeed + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t gameTimePacked = packet.readUInt32(); + float timeSpeed = packet.readFloat(); + gameTime_ = static_cast(gameTimePacked); + timeSpeed_ = timeSpeed; + LOG_DEBUG("Server game speed update: time=", gameTime_, " speed=", timeSpeed_); + } + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_GAMETIMEBIAS_SET: + // Time bias — consume without processing + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_ACHIEVEMENT_DELETED: + case Opcode::SMSG_CRITERIA_DELETED: + // Consume achievement/criteria removal notifications + packet.setReadPos(packet.getSize()); + break; + + // ---- Combat clearing ---- + case Opcode::SMSG_ATTACKSWING_DEADTARGET: + // Target died mid-swing: clear auto-attack + autoAttacking = false; + autoAttackTarget = 0; + break; + case Opcode::SMSG_THREAT_CLEAR: + // All threat dropped on the local player (e.g. Vanish, Feign Death) + // No local state to clear — informational + LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); + break; + case Opcode::SMSG_THREAT_REMOVE: { + // packed_guid (unit) + packed_guid (victim whose threat was removed) + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); + } + } + break; + } + case Opcode::SMSG_HIGHEST_THREAT_UPDATE: { + // packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count + // + count × (packed_guid victim + uint32 threat) + // Informational — no threat UI yet; consume to suppress warnings + packet.setReadPos(packet.getSize()); + break; + } + + case Opcode::SMSG_CANCEL_COMBAT: + // Server-side combat state reset + autoAttacking = false; + autoAttackTarget = 0; + autoAttackRequested_ = false; + break; + + case Opcode::SMSG_BREAK_TARGET: + // Server breaking our targeting (PvP flag, etc.) + // uint64 guid — consume; target cleared if it matches + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t bGuid = packet.readUInt64(); + if (bGuid == targetGuid) targetGuid = 0; + } + break; + + case Opcode::SMSG_CLEAR_TARGET: + // uint64 guid — server cleared targeting on a unit (or 0 = clear all) + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t cGuid = packet.readUInt64(); + if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0; + } + break; + + // ---- Server-forced dismount ---- + case Opcode::SMSG_DISMOUNT: + // No payload — server forcing dismount + currentMountDisplayId_ = 0; + if (mountCallback_) mountCallback_(0); + break; + + case Opcode::SMSG_MOUNTRESULT: { + // uint32 result: 0=error, 1=invalid, 2=not in range, 3=already mounted, 4=ok + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t result = packet.readUInt32(); + if (result != 4) { + const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." }; + addSystemChatMessage(result < 4 ? msgs[result] : "Cannot mount."); + } + break; + } + case Opcode::SMSG_DISMOUNTRESULT: { + // uint32 result: 0=ok, others=error + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t result = packet.readUInt32(); + if (result != 0) addSystemChatMessage("Cannot dismount here."); + break; + } + + // ---- Loot notifications ---- + case Opcode::SMSG_LOOT_ALL_PASSED: { + // uint64 objectGuid + uint32 slot + uint32 itemId + uint32 randSuffix + uint32 randPropId + if (packet.getSize() - packet.getReadPos() < 24) break; + /*uint64_t objGuid =*/ packet.readUInt64(); + /*uint32_t slot =*/ packet.readUInt32(); + uint32_t itemId = packet.readUInt32(); + auto* info = getItemInfo(itemId); + char buf[256]; + std::snprintf(buf, sizeof(buf), "Everyone passed on [%s].", + info ? info->name.c_str() : std::to_string(itemId).c_str()); + addSystemChatMessage(buf); + pendingLootRollActive_ = false; + break; + } + case Opcode::SMSG_LOOT_ITEM_NOTIFY: + // uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count — consume + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LOOT_SLOT_CHANGED: + // uint64 objectGuid + uint32 slot + ... — consume + packet.setReadPos(packet.getSize()); + break; + + // ---- Spell log miss ---- + case Opcode::SMSG_SPELLLOGMISS: { + // packed_guid caster + packed_guid target + uint8 isCrit + uint32 count + // + count × (uint64 victimGuid + uint8 missInfo) + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 2) break; + /*uint64_t targetGuidLog =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint8_t isCrit =*/ packet.readUInt8(); + uint32_t count = packet.readUInt32(); + count = std::min(count, 32u); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 9; ++i) { + /*uint64_t victimGuid =*/ packet.readUInt64(); + uint8_t missInfo = packet.readUInt8(); + // Show combat text only for local player's spell misses + if (casterGuid == playerGuid) { + static const CombatTextEntry::Type missTypes[] = { + CombatTextEntry::MISS, // 0=MISS + CombatTextEntry::DODGE, // 1=DODGE + CombatTextEntry::PARRY, // 2=PARRY + CombatTextEntry::BLOCK, // 3=BLOCK + CombatTextEntry::MISS, // 4=EVADE → show as MISS + CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::MISS, // 7=ABSORB + CombatTextEntry::MISS, // 8=RESIST + }; + CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; + addCombatText(ct, 0, 0, true); + } + } + break; + } + + // ---- Environmental damage log ---- + case Opcode::SMSG_ENVIRONMENTALDAMAGELOG: { + // uint64 victimGuid + uint8 envDamageType + uint32 damage + uint32 absorb + uint32 resist + if (packet.getSize() - packet.getReadPos() < 21) break; + uint64_t victimGuid = packet.readUInt64(); + /*uint8_t envType =*/ packet.readUInt8(); + uint32_t damage = packet.readUInt32(); + /*uint32_t absorb =*/ packet.readUInt32(); + /*uint32_t resist =*/ packet.readUInt32(); + if (victimGuid == playerGuid && damage > 0) { + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + } + break; + } // ---- Creature Movement ---- case Opcode::SMSG_MONSTER_MOVE: @@ -1521,8 +2238,18 @@ void GameHandler::handlePacket(network::Packet& packet) { handleMonsterMoveTransport(packet); break; case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE: - case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: { - // Minimal parse: PackedGuid + case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: + case Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL: + case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE: + case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE: + case Opcode::SMSG_SPLINE_MOVE_LAND_WALK: + case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL: + case Opcode::SMSG_SPLINE_MOVE_ROOT: + case Opcode::SMSG_SPLINE_MOVE_SET_FLYING: + case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: + case Opcode::SMSG_SPLINE_MOVE_START_SWIM: + case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: { + // Minimal parse: PackedGuid only — entity state flag change. if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); } @@ -1763,6 +2490,87 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_COOLDOWN_EVENT: handleCooldownEvent(packet); break; + case Opcode::SMSG_CLEAR_COOLDOWN: { + // spellId(u32) + guid(u64): clear cooldown for the given spell/guid + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t spellId = packet.readUInt32(); + // guid is present but we only track per-spell for the local player + spellCooldowns.erase(spellId); + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot.cooldownRemaining = 0.0f; + } + } + LOG_DEBUG("SMSG_CLEAR_COOLDOWN: spellId=", spellId); + } + break; + } + case Opcode::SMSG_MODIFY_COOLDOWN: { + // spellId(u32) + diffMs(i32): adjust cooldown remaining by diffMs + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t spellId = packet.readUInt32(); + int32_t diffMs = static_cast(packet.readUInt32()); + float diffSec = diffMs / 1000.0f; + auto it = spellCooldowns.find(spellId); + if (it != spellCooldowns.end()) { + it->second = std::max(0.0f, it->second + diffSec); + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot.cooldownRemaining = std::max(0.0f, slot.cooldownRemaining + diffSec); + } + } + } + LOG_DEBUG("SMSG_MODIFY_COOLDOWN: spellId=", spellId, " diff=", diffMs, "ms"); + } + break; + } + case Opcode::SMSG_ACHIEVEMENT_EARNED: + handleAchievementEarned(packet); + break; + case Opcode::SMSG_ALL_ACHIEVEMENT_DATA: + // Initial data burst on login — ignored for now (no achievement tracker UI). + break; + case Opcode::SMSG_ITEM_COOLDOWN: { + // uint64 itemGuid + uint32 spellId + uint32 cooldownMs + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem >= 16) { + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + uint32_t cdMs = packet.readUInt32(); + float cdSec = cdMs / 1000.0f; + if (spellId != 0 && cdSec > 0.0f) { + spellCooldowns[spellId] = cdSec; + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot.cooldownRemaining = cdSec; + } + } + LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s"); + } + } + break; + } + case Opcode::SMSG_FISH_NOT_HOOKED: + addSystemChatMessage("Your fish got away."); + break; + case Opcode::SMSG_FISH_ESCAPED: + addSystemChatMessage("Your fish escaped!"); + break; + case Opcode::MSG_MINIMAP_PING: + // Minimap ping from a party member — consume; no visual support yet. + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_ZONE_UNDER_ATTACK: { + // uint32 areaId + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t areaId = packet.readUInt32(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + "A zone is under attack! (area %u)", areaId); + addSystemChatMessage(buf); + } + break; + } case Opcode::SMSG_CANCEL_AUTO_REPEAT: break; // Server signals to stop a repeating spell (wand/shoot); no client action needed case Opcode::SMSG_AURA_UPDATE: @@ -1771,6 +2579,51 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_AURA_UPDATE_ALL: handleAuraUpdate(packet, true); break; + case Opcode::SMSG_DISPEL_FAILED: { + // casterGuid(8) + victimGuid(8) + spellId(4) [+ failing spellId(4)...] + if (packet.getSize() - packet.getReadPos() >= 20) { + /*uint64_t casterGuid =*/ packet.readUInt64(); + /*uint64_t victimGuid =*/ packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + char buf[128]; + std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", spellId); + addSystemChatMessage(buf); + } + break; + } + case Opcode::SMSG_TOTEM_CREATED: { + // uint8 slot + uint64 guid + uint32 duration + uint32 spellId + if (packet.getSize() - packet.getReadPos() >= 17) { + uint8_t slot = packet.readUInt8(); + /*uint64_t guid =*/ packet.readUInt64(); + uint32_t duration = packet.readUInt32(); + uint32_t spellId = packet.readUInt32(); + LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, + " spellId=", spellId, " duration=", duration, "ms"); + } + break; + } + case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: { + // uint64 guid + uint32 timeLeftMs + if (packet.getSize() - packet.getReadPos() >= 12) { + /*uint64_t guid =*/ packet.readUInt64(); + uint32_t timeMs = packet.readUInt32(); + uint32_t secs = timeMs / 1000; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "You will be able to resurrect in %u seconds.", secs); + addSystemChatMessage(buf); + } + break; + } + case Opcode::SMSG_DURABILITY_DAMAGE_DEATH: { + // uint32 percent (how much durability was lost due to death) + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t pct =*/ packet.readUInt32(); + addSystemChatMessage("You have lost 10% of your gear's durability due to death."); + } + break; + } case Opcode::SMSG_LEARNED_SPELL: handleLearnedSpell(packet); break; @@ -1799,6 +2652,19 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_GROUP_LIST: handleGroupList(packet); break; + case Opcode::SMSG_GROUP_DESTROYED: + // The group was disbanded; clear all party state. + partyData.members.clear(); + partyData.memberCount = 0; + partyData.leaderGuid = 0; + addSystemChatMessage("Your party has been disbanded."); + LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); + break; + case Opcode::SMSG_GROUP_CANCEL: + // Group invite was cancelled before being accepted. + addSystemChatMessage("Group invite cancelled."); + LOG_DEBUG("SMSG_GROUP_CANCEL"); + break; case Opcode::SMSG_GROUP_UNINVITE: handleGroupUninvite(packet); break; @@ -1811,21 +2677,54 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PARTY_MEMBER_STATS_FULL: handlePartyMemberStats(packet, true); break; - case Opcode::MSG_RAID_READY_CHECK: - // Server ready-check prompt (minimal handling for now). - packet.setReadPos(packet.getSize()); + case Opcode::MSG_RAID_READY_CHECK: { + // Server is broadcasting a ready check (someone in the raid initiated it). + // Payload: empty body, or optional uint64 initiator GUID in some builds. + pendingReadyCheck_ = true; + readyCheckInitiator_.clear(); + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t initiatorGuid = packet.readUInt64(); + auto entity = entityManager.getEntity(initiatorGuid); + if (auto* unit = dynamic_cast(entity.get())) { + readyCheckInitiator_ = unit->getName(); + } + } + if (readyCheckInitiator_.empty() && partyData.leaderGuid != 0) { + // Identify initiator from party leader + for (const auto& member : partyData.members) { + if (member.guid == partyData.leaderGuid) { readyCheckInitiator_ = member.name; break; } + } + } + addSystemChatMessage(readyCheckInitiator_.empty() + ? "Ready check initiated!" + : readyCheckInitiator_ + " initiated a ready check!"); + LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_); break; + } case Opcode::MSG_RAID_READY_CHECK_CONFIRM: - // Ready-check responses from members. + // Another member responded to the ready check — consume. packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_RAID_INSTANCE_INFO: - // Raid lockout list (not yet surfaced in UI). - packet.setReadPos(packet.getSize()); + handleRaidInstanceInfo(packet); break; case Opcode::SMSG_DUEL_REQUESTED: - // Duel request UI flow not implemented yet. - packet.setReadPos(packet.getSize()); + handleDuelRequested(packet); + break; + case Opcode::SMSG_DUEL_COMPLETE: + handleDuelComplete(packet); + break; + case Opcode::SMSG_DUEL_WINNER: + handleDuelWinner(packet); + break; + case Opcode::SMSG_DUEL_OUTOFBOUNDS: + addSystemChatMessage("You are out of the duel area!"); + break; + case Opcode::SMSG_DUEL_INBOUNDS: + // Re-entered the duel area; no special action needed. + break; + case Opcode::SMSG_DUEL_COUNTDOWN: + // Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update. break; case Opcode::SMSG_PARTYKILLLOG: // Classic-era packet: killer GUID + victim GUID. @@ -1872,6 +2771,33 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LOOT_REMOVED: handleLootRemoved(packet); break; + case Opcode::SMSG_QUEST_CONFIRM_ACCEPT: + handleQuestConfirmAccept(packet); + break; + case Opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE: + handleItemTextQueryResponse(packet); + break; + case Opcode::SMSG_SUMMON_REQUEST: + handleSummonRequest(packet); + break; + case Opcode::SMSG_SUMMON_CANCEL: + pendingSummonRequest_ = false; + addSystemChatMessage("Summon cancelled."); + break; + case Opcode::SMSG_TRADE_STATUS: + case Opcode::SMSG_TRADE_STATUS_EXTENDED: + handleTradeStatus(packet); + break; + case Opcode::SMSG_LOOT_ROLL: + handleLootRoll(packet); + break; + case Opcode::SMSG_LOOT_ROLL_WON: + handleLootRollWon(packet); + break; + case Opcode::SMSG_LOOT_MASTER_LIST: + // Master looter list — no UI yet; consume to avoid unhandled warning. + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_GOSSIP_MESSAGE: handleGossipMessage(packet); break; @@ -2058,17 +2984,158 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_SET_FACTION_STANDING: { + // uint8 showVisualEffect + uint32 count + count × (uint32 factionId + int32 standing) + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint8_t showVisual =*/ packet.readUInt8(); + uint32_t count = packet.readUInt32(); + count = std::min(count, 128u); + loadFactionNameCache(); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { + uint32_t factionId = packet.readUInt32(); + int32_t standing = static_cast(packet.readUInt32()); + int32_t oldStanding = 0; + auto it = factionStandings_.find(factionId); + if (it != factionStandings_.end()) oldStanding = it->second; + factionStandings_[factionId] = standing; + int32_t delta = standing - oldStanding; + if (delta != 0) { + std::string name = getFactionName(factionId); + char buf[256]; + std::snprintf(buf, sizeof(buf), "Reputation with %s %s by %d.", + name.c_str(), + delta > 0 ? "increased" : "decreased", + std::abs(delta)); + addSystemChatMessage(buf); + } + LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); + } + break; + } + case Opcode::SMSG_SET_FACTION_ATWAR: + case Opcode::SMSG_SET_FACTION_VISIBLE: + // uint32 factionId [+ uint8 flags for ATWAR] — consume; hostility is tracked via update fields + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: case Opcode::SMSG_SPELL_DELAYED: case Opcode::SMSG_EQUIPMENT_SET_SAVED: - case Opcode::SMSG_PERIODICAURALOG: - case Opcode::SMSG_SPELLENERGIZELOG: - case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG: - case Opcode::SMSG_SET_PROFICIENCY: - case Opcode::SMSG_ACTION_BUTTONS: + case Opcode::SMSG_PERIODICAURALOG: { + // packed_guid victim, packed_guid caster, uint32 spellId, uint32 count, then per-effect + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 2) break; + uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t spellId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + bool isPlayerVictim = (victimGuid == playerGuid); + bool isPlayerCaster = (casterGuid == playerGuid); + if (!isPlayerVictim && !isPlayerCaster) { + packet.setReadPos(packet.getSize()); + break; + } + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 1; ++i) { + uint8_t auraType = packet.readUInt8(); + if (auraType == 3 || auraType == 89) { + // PERIODIC_DAMAGE / PERIODIC_DAMAGE_PERCENT: damage+school+absorbed+resisted + if (packet.getSize() - packet.getReadPos() < 16) break; + uint32_t dmg = packet.readUInt32(); + /*uint32_t school=*/ packet.readUInt32(); + /*uint32_t abs=*/ packet.readUInt32(); + /*uint32_t res=*/ packet.readUInt32(); + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), + spellId, isPlayerCaster); + } else if (auraType == 8 || auraType == 124 || auraType == 45) { + // PERIODIC_HEAL / PERIODIC_HEAL_PCT / OBS_MOD_HEALTH: heal+maxHeal+overHeal + if (packet.getSize() - packet.getReadPos() < 12) break; + uint32_t heal = packet.readUInt32(); + /*uint32_t max=*/ packet.readUInt32(); + /*uint32_t over=*/ packet.readUInt32(); + addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), + spellId, isPlayerCaster); + } else { + // Unknown/untracked aura type — stop parsing this event safely + packet.setReadPos(packet.getSize()); + break; + } + } + packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_SPELLENERGIZELOG: { + // packed victim GUID, packed caster GUID, uint32 spellId, uint8 powerType, int32 amount + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 4) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + rem = packet.getSize() - packet.getReadPos(); + if (rem < 6) { packet.setReadPos(packet.getSize()); break; } + uint32_t spellId = packet.readUInt32(); + /*uint8_t powerType =*/ packet.readUInt8(); + int32_t amount = static_cast(packet.readUInt32()); + bool isPlayerVictim = (victimGuid == playerGuid); + bool isPlayerCaster = (casterGuid == playerGuid); + if ((isPlayerVictim || isPlayerCaster) && amount > 0) + addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG: { + // uint64 victimGuid + uint8 envDmgType + uint32 damage + uint32 absorbed + uint32 resisted + // envDmgType: 1=Exhausted(fatigue), 2=Drowning, 3=Fall, 4=Lava, 5=Slime, 6=Fire + if (packet.getSize() - packet.getReadPos() < 21) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = packet.readUInt64(); + /*uint8_t envType =*/ packet.readUInt8(); + uint32_t dmg = packet.readUInt32(); + /*uint32_t abs =*/ packet.readUInt32(); + if (victimGuid == playerGuid && dmg > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SET_PROFICIENCY: + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_ACTION_BUTTONS: { + // uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons + // packed: bits 0-23 = actionId, bits 24-31 = type + // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 1) break; + /*uint8_t mode =*/ packet.readUInt8(); + rem--; + constexpr int SERVER_BAR_SLOTS = 144; + constexpr int OUR_BAR_SLOTS = 12; // our actionBar array size + for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { + if (rem < 4) break; + uint32_t packed = packet.readUInt32(); + rem -= 4; + if (i >= OUR_BAR_SLOTS) continue; // only load first bar + if (packed == 0) { + // Empty slot — only clear if not already set to Attack/Hearthstone defaults + // so we don't wipe hardcoded fallbacks when the server sends zeros. + continue; + } + uint8_t type = static_cast((packed >> 24) & 0xFF); + uint32_t id = packed & 0x00FFFFFFu; + if (id == 0) continue; + ActionBarSlot slot; + switch (type) { + case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; + case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; + default: continue; // macro or unknown — leave as-is + } + actionBar[i] = slot; + } + LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_LEVELUP_INFO: case Opcode::SMSG_LEVELUP_INFO_ALT: { @@ -2099,9 +3166,43 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t soundId = packet.readUInt32(); LOG_DEBUG("SMSG_PLAY_SOUND id=", soundId); + if (playSoundCallback_) playSoundCallback_(soundId); } break; + case Opcode::SMSG_SERVER_MESSAGE: { + // uint32 type, string message + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t msgType =*/ packet.readUInt32(); + std::string msg = packet.readString(); + if (!msg.empty()) addSystemChatMessage("[Server] " + msg); + } + break; + } + case Opcode::SMSG_CHAT_SERVER_MESSAGE: { + // uint32 type + string text + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t msgType =*/ packet.readUInt32(); + std::string msg = packet.readString(); + if (!msg.empty()) addSystemChatMessage("[Announcement] " + msg); + } + break; + } + case Opcode::SMSG_AREA_TRIGGER_MESSAGE: { + // uint32 size, then string + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t len =*/ packet.readUInt32(); + std::string msg = packet.readString(); + if (!msg.empty()) addSystemChatMessage(msg); + } + break; + } + case Opcode::SMSG_TRIGGER_CINEMATIC: + // uint32 cinematicId — we don't play cinematics; consume and skip. + packet.setReadPos(packet.getSize()); + LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped"); + break; + case Opcode::SMSG_LOOT_MONEY_NOTIFY: { // Format: uint32 money + uint8 soleLooter if (packet.getSize() - packet.getReadPos() >= 4) { @@ -2331,6 +3432,63 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::MSG_RAID_TARGET_UPDATE: break; + case Opcode::SMSG_BUY_ITEM: { + // uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount + // Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT. + if (packet.getSize() - packet.getReadPos() >= 20) { + uint64_t vendorGuid = packet.readUInt64(); + uint32_t vendorSlot = packet.readUInt32(); + int32_t newCount = static_cast(packet.readUInt32()); + uint32_t itemCount = packet.readUInt32(); + LOG_DEBUG("SMSG_BUY_ITEM: vendorGuid=0x", std::hex, vendorGuid, std::dec, + " slot=", vendorSlot, " newCount=", newCount, " bought=", itemCount); + pendingBuyItemId_ = 0; + pendingBuyItemSlot_ = 0; + } + break; + } + case Opcode::SMSG_CRITERIA_UPDATE: { + // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime + // Achievement criteria progress (informational — no criteria UI yet). + if (packet.getSize() - packet.getReadPos() >= 20) { + uint32_t criteriaId = packet.readUInt32(); + uint64_t progress = packet.readUInt64(); + /*uint32_t elapsedTime =*/ packet.readUInt32(); + /*uint32_t createTime =*/ packet.readUInt32(); + LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); + } + break; + } + case Opcode::SMSG_BARBER_SHOP_RESULT: { + // uint32 result (0 = success, 1 = no money, 2 = not barber, 3 = sitting) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Hairstyle changed."); + } else { + const char* msg = (result == 1) ? "Not enough money for new hairstyle." + : (result == 2) ? "You are not at a barber shop." + : (result == 3) ? "You must stand up to use the barber shop." + : "Barber shop unavailable."; + addSystemChatMessage(msg); + } + LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); + } + break; + } + case Opcode::SMSG_OVERRIDE_LIGHT: { + // uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs + if (packet.getSize() - packet.getReadPos() >= 12) { + uint32_t zoneLightId = packet.readUInt32(); + uint32_t overrideLightId = packet.readUInt32(); + uint32_t transitionMs = packet.readUInt32(); + overrideLightId_ = overrideLightId; + overrideLightTransMs_ = transitionMs; + LOG_DEBUG("SMSG_OVERRIDE_LIGHT: zone=", zoneLightId, + " override=", overrideLightId, " transition=", transitionMs, "ms"); + } + break; + } case Opcode::SMSG_WEATHER: { // Format: uint32 weatherType, float intensity, uint8 isAbrupt if (packet.getSize() - packet.getReadPos() >= 9) { @@ -2344,6 +3502,76 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + case Opcode::SMSG_SCRIPT_MESSAGE: { + // Server-script text message — display in system chat + std::string msg = packet.readString(); + if (!msg.empty()) { + addSystemChatMessage(msg); + LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg); + } + break; + } + case Opcode::SMSG_ENCHANTMENTLOG: { + // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType + if (packet.getSize() - packet.getReadPos() >= 28) { + /*uint64_t targetGuid =*/ packet.readUInt64(); + /*uint64_t casterGuid =*/ packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + /*uint32_t displayId =*/ packet.readUInt32(); + /*uint32_t animType =*/ packet.readUInt32(); + LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", spellId); + } + break; + } + case Opcode::SMSG_SOCKET_GEMS_RESULT: { + // uint64 itemGuid + uint32 result (0 = success) + if (packet.getSize() - packet.getReadPos() >= 12) { + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Gems socketed successfully."); + } else { + addSystemChatMessage("Failed to socket gems."); + } + LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result); + } + break; + } + case Opcode::SMSG_ITEM_REFUND_RESULT: { + // uint64 itemGuid + uint32 result (0=success) + if (packet.getSize() - packet.getReadPos() >= 12) { + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Item returned. Refund processed."); + } else { + addSystemChatMessage("Could not return item for refund."); + } + LOG_DEBUG("SMSG_ITEM_REFUND_RESULT: result=", result); + } + break; + } + case Opcode::SMSG_ITEM_TIME_UPDATE: { + // uint64 itemGuid + uint32 durationMs — item duration ticking down + if (packet.getSize() - packet.getReadPos() >= 12) { + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t durationMs = packet.readUInt32(); + LOG_DEBUG("SMSG_ITEM_TIME_UPDATE: remainingMs=", durationMs); + } + break; + } + case Opcode::SMSG_RESURRECT_FAILED: { + // uint32 reason — various resurrection failures + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t reason = packet.readUInt32(); + const char* msg = (reason == 1) ? "The target cannot be resurrected right now." + : (reason == 2) ? "Cannot resurrect in this area." + : "Resurrection failed."; + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason); + } + break; + } case Opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE: handleGameObjectQueryResponse(packet); break; @@ -2675,13 +3903,11 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingQuestQueryIds_.erase(questId); break; } - case Opcode::SMSG_QUESTLOG_FULL: { - LOG_INFO("***** RECEIVED SMSG_QUESTLOG_FULL *****"); - LOG_INFO(" Packet size: ", packet.getSize()); - LOG_INFO(" Server uses SMSG_QUESTLOG_FULL for quest log sync!"); - // TODO: Parse quest log entries from this packet + case Opcode::SMSG_QUESTLOG_FULL: + // Zero-payload notification: the player's quest log is full (25 quests). + addSystemChatMessage("Your quest log is full."); + LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity"); break; - } case Opcode::SMSG_QUESTGIVER_REQUEST_ITEMS: handleQuestRequestItems(packet); break; @@ -2728,8 +3954,12 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_STANDSTATE_UPDATE: // Server confirms stand state change (sit/stand/sleep/kneel) - // TODO: parse uint8 standState and update player entity - packet.setReadPos(packet.getSize()); + if (packet.getSize() - packet.getReadPos() >= 1) { + standState_ = packet.readUInt8(); + LOG_INFO("Stand state updated: ", static_cast(standState_), + " (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit" + : standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")"); + } break; case Opcode::SMSG_NEW_TAXI_PATH: // Empty packet - server signals a new flight path was learned @@ -2769,6 +3999,119 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_INSTANCE_DIFFICULTY: handleInstanceDifficulty(packet); break; + case Opcode::SMSG_INSTANCE_SAVE_CREATED: + // Zero-payload: your instance save was just created on the server. + addSystemChatMessage("You are now saved to this instance."); + LOG_INFO("SMSG_INSTANCE_SAVE_CREATED"); + break; + case Opcode::SMSG_RAID_INSTANCE_MESSAGE: { + if (packet.getSize() - packet.getReadPos() >= 12) { + uint32_t msgType = packet.readUInt32(); + uint32_t mapId = packet.readUInt32(); + /*uint32_t diff =*/ packet.readUInt32(); + // type: 1=warning(time left), 2=saved, 3=welcome + if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) { + uint32_t timeLeft = packet.readUInt32(); + uint32_t minutes = timeLeft / 60; + std::string msg = "Instance " + std::to_string(mapId) + + " will reset in " + std::to_string(minutes) + " minute(s)."; + addSystemChatMessage(msg); + } else if (msgType == 2) { + addSystemChatMessage("You have been saved to instance " + std::to_string(mapId) + "."); + } else if (msgType == 3) { + addSystemChatMessage("Welcome to instance " + std::to_string(mapId) + "."); + } + LOG_INFO("SMSG_RAID_INSTANCE_MESSAGE: type=", msgType, " map=", mapId); + } + break; + } + case Opcode::SMSG_INSTANCE_RESET: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t mapId = packet.readUInt32(); + // Remove matching lockout from local cache + auto it = std::remove_if(instanceLockouts_.begin(), instanceLockouts_.end(), + [mapId](const InstanceLockout& lo){ return lo.mapId == mapId; }); + instanceLockouts_.erase(it, instanceLockouts_.end()); + addSystemChatMessage("Instance " + std::to_string(mapId) + " has been reset."); + LOG_INFO("SMSG_INSTANCE_RESET: mapId=", mapId); + } + break; + } + case Opcode::SMSG_INSTANCE_RESET_FAILED: { + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t mapId = packet.readUInt32(); + uint32_t reason = packet.readUInt32(); + static const char* resetFailReasons[] = { + "Not max level.", "Offline party members.", "Party members inside.", + "Party members changing zone.", "Heroic difficulty only." + }; + const char* msg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; + addSystemChatMessage("Cannot reset instance " + std::to_string(mapId) + ": " + msg); + LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason); + } + break; + } + case Opcode::SMSG_INSTANCE_LOCK_WARNING_QUERY: { + // Server asks player to confirm entering a saved instance. + // We auto-confirm with CMSG_INSTANCE_LOCK_RESPONSE. + if (socket && packet.getSize() - packet.getReadPos() >= 17) { + /*uint32_t mapId =*/ packet.readUInt32(); + /*uint32_t diff =*/ packet.readUInt32(); + /*uint32_t timeLeft =*/ packet.readUInt32(); + packet.readUInt32(); // unk + /*uint8_t locked =*/ packet.readUInt8(); + // Send acceptance + network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE)); + resp.writeUInt8(1); // 1=accept + socket->send(resp); + LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted"); + } + break; + } + + // ---- LFG / Dungeon Finder ---- + case Opcode::SMSG_LFG_JOIN_RESULT: + handleLfgJoinResult(packet); + break; + case Opcode::SMSG_LFG_QUEUE_STATUS: + handleLfgQueueStatus(packet); + break; + case Opcode::SMSG_LFG_PROPOSAL_UPDATE: + handleLfgProposalUpdate(packet); + break; + case Opcode::SMSG_LFG_ROLE_CHECK_UPDATE: + handleLfgRoleCheckUpdate(packet); + break; + case Opcode::SMSG_LFG_UPDATE_PLAYER: + case Opcode::SMSG_LFG_UPDATE_PARTY: + handleLfgUpdatePlayer(packet); + break; + case Opcode::SMSG_LFG_PLAYER_REWARD: + handleLfgPlayerReward(packet); + break; + case Opcode::SMSG_LFG_BOOT_PROPOSAL_UPDATE: + handleLfgBootProposalUpdate(packet); + break; + case Opcode::SMSG_LFG_TELEPORT_DENIED: + handleLfgTeleportDenied(packet); + break; + case Opcode::SMSG_LFG_DISABLED: + addSystemChatMessage("The Dungeon Finder is currently disabled."); + LOG_INFO("SMSG_LFG_DISABLED received"); + break; + 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_UPDATE_SEARCH: + case Opcode::SMSG_UPDATE_LFG_LIST: + case Opcode::SMSG_LFG_PLAYER_INFO: + case Opcode::SMSG_LFG_PARTY_INFO: + case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: + // Informational LFG packets not yet surfaced in UI — consume silently. + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT: handleArenaTeamCommandResult(packet); break; @@ -2905,6 +4248,40 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } + case Opcode::SMSG_AUCTION_REMOVED_NOTIFICATION: { + // uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled + if (packet.getSize() - packet.getReadPos() >= 12) { + /*uint32_t auctionId =*/ packet.readUInt32(); + uint32_t itemEntry = packet.readUInt32(); + /*uint32_t itemRandom =*/ packet.readUInt32(); + ensureItemInfo(itemEntry); + auto* info = getItemInfo(itemEntry); + std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); + addSystemChatMessage("Your auction of " + itemName + " has expired."); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_OPEN_CONTAINER: { + // uint64 containerGuid — tells client to open this container + // The actual items come via update packets; we just log this. + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t containerGuid = packet.readUInt64(); + LOG_DEBUG("SMSG_OPEN_CONTAINER: guid=0x", std::hex, containerGuid, std::dec); + } + break; + } + case Opcode::SMSG_GM_TICKET_STATUS_UPDATE: + // GM ticket status (new/updated); no ticket UI yet + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_PLAYER_VEHICLE_DATA: + // Vehicle data update for player in vehicle; no vehicle UI yet + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_TAXINODE_STATUS: // Node status cache not implemented yet. packet.setReadPos(packet.getSize()); @@ -2923,6 +4300,575 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- Logout cancel ACK ---- + case Opcode::SMSG_LOGOUT_CANCEL_ACK: + // loggingOut_ already cleared by cancelLogout(); this is server's confirmation + packet.setReadPos(packet.getSize()); + break; + + // ---- Guild decline ---- + case Opcode::SMSG_GUILD_DECLINE: { + if (packet.getReadPos() < packet.getSize()) { + std::string name = packet.readString(); + addSystemChatMessage(name + " declined your guild invitation."); + } + break; + } + + // ---- Talents involuntarily reset ---- + case Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET: + addSystemChatMessage("Your talents have been reset by the server."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Account data sync ---- + case Opcode::SMSG_UPDATE_ACCOUNT_DATA: + case Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Rest state ---- + case Opcode::SMSG_SET_REST_START: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t restTrigger = packet.readUInt32(); + addSystemChatMessage(restTrigger > 0 ? "You are now resting." + : "You are no longer resting."); + } + break; + } + + // ---- Aura duration update ---- + case Opcode::SMSG_UPDATE_AURA_DURATION: { + if (packet.getSize() - packet.getReadPos() >= 5) { + uint8_t slot = packet.readUInt8(); + uint32_t durationMs = packet.readUInt32(); + handleUpdateAuraDuration(slot, durationMs); + } + break; + } + + // ---- Item name query response ---- + case Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t itemId = packet.readUInt32(); + std::string name = packet.readString(); + if (!itemInfoCache_.count(itemId) && !name.empty()) { + ItemQueryResponseData stub; + stub.entry = itemId; + stub.name = std::move(name); + stub.valid = true; + itemInfoCache_[itemId] = std::move(stub); + } + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- Mount special animation ---- + case Opcode::SMSG_MOUNTSPECIAL_ANIM: + (void)UpdateObjectParser::readPackedGuid(packet); + break; + + // ---- Character customisation / faction change results ---- + case Opcode::SMSG_CHAR_CUSTOMIZE: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + addSystemChatMessage(result == 0 ? "Character customization complete." + : "Character customization failed."); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CHAR_FACTION_CHANGE: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + addSystemChatMessage(result == 0 ? "Faction change complete." + : "Faction change failed."); + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- Invalidate cached player data ---- + case Opcode::SMSG_INVALIDATE_PLAYER: { + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + playerNameCache.erase(guid); + } + break; + } + + // ---- Movie trigger ---- + case Opcode::SMSG_TRIGGER_MOVIE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Equipment sets ---- + case Opcode::SMSG_EQUIPMENT_SET_LIST: + handleEquipmentSetList(packet); + break; + case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result != 0) addSystemChatMessage("Failed to equip item set."); + } + break; + } + + // ---- LFG informational (not yet surfaced in UI) ---- + case Opcode::SMSG_LFG_UPDATE: + case Opcode::SMSG_LFG_UPDATE_LFG: + case Opcode::SMSG_LFG_UPDATE_LFM: + case Opcode::SMSG_LFG_UPDATE_QUEUED: + case Opcode::SMSG_LFG_PENDING_INVITE: + case Opcode::SMSG_LFG_PENDING_MATCH: + case Opcode::SMSG_LFG_PENDING_MATCH_DONE: + packet.setReadPos(packet.getSize()); + break; + + // ---- GM Ticket responses ---- + case Opcode::SMSG_GMTICKET_CREATE: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t res = packet.readUInt8(); + addSystemChatMessage(res == 1 ? "GM ticket submitted." + : "Failed to submit GM ticket."); + } + break; + } + case Opcode::SMSG_GMTICKET_UPDATETEXT: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t res = packet.readUInt8(); + addSystemChatMessage(res == 1 ? "GM ticket updated." + : "Failed to update GM ticket."); + } + break; + } + case Opcode::SMSG_GMTICKET_DELETETICKET: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t res = packet.readUInt8(); + addSystemChatMessage(res == 9 ? "GM ticket deleted." + : "No ticket to delete."); + } + break; + } + case Opcode::SMSG_GMTICKET_GETTICKET: + case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: + packet.setReadPos(packet.getSize()); + break; + + // ---- DK rune tracking ---- + case Opcode::SMSG_CONVERT_RUNE: { + // uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death) + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); + break; + } + uint8_t idx = packet.readUInt8(); + uint8_t type = packet.readUInt8(); + if (idx < 6) playerRunes_[idx].type = static_cast(type & 0x3); + break; + } + case Opcode::SMSG_RESYNC_RUNES: { + // uint8 runeReadyMask (bit i=1 → rune i is ready) + // uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255) + if (packet.getSize() - packet.getReadPos() < 7) { + packet.setReadPos(packet.getSize()); + break; + } + uint8_t readyMask = packet.readUInt8(); + for (int i = 0; i < 6; i++) { + uint8_t cd = packet.readUInt8(); + playerRunes_[i].ready = (readyMask & (1u << i)) != 0; + playerRunes_[i].readyFraction = 1.0f - cd / 255.0f; + if (playerRunes_[i].ready) playerRunes_[i].readyFraction = 1.0f; + } + break; + } + case Opcode::SMSG_ADD_RUNE_POWER: { + // uint32 runeMask (bit i=1 → rune i just became ready) + if (packet.getSize() - packet.getReadPos() < 4) { + packet.setReadPos(packet.getSize()); + break; + } + uint32_t runeMask = packet.readUInt32(); + for (int i = 0; i < 6; i++) { + if (runeMask & (1u << i)) { + playerRunes_[i].ready = true; + playerRunes_[i].readyFraction = 1.0f; + } + } + break; + } + + // ---- Spell combat logs (consume) ---- + case Opcode::SMSG_AURACASTLOG: + case Opcode::SMSG_SPELLBREAKLOG: + case Opcode::SMSG_SPELLDAMAGESHIELD: + case Opcode::SMSG_SPELLDISPELLOG: + case Opcode::SMSG_SPELLINSTAKILLLOG: + case Opcode::SMSG_SPELLLOGEXECUTE: + case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: + case Opcode::SMSG_SPELLSTEALLOG: + case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: + case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: + case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: + packet.setReadPos(packet.getSize()); + break; + + // ---- Misc consume ---- + case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: + case Opcode::SMSG_COMPLAIN_RESULT: + case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: + case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: + case Opcode::SMSG_LOOT_LIST: + case Opcode::SMSG_RESUME_CAST_BAR: + case Opcode::SMSG_THREAT_UPDATE: + case Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP: + case Opcode::SMSG_UPDATE_LAST_INSTANCE: + case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: + case Opcode::SMSG_SEND_ALL_COMBAT_LOG: + case Opcode::SMSG_SET_PROJECTILE_POSITION: + case Opcode::SMSG_AUCTION_LIST_PENDING_SALES: + packet.setReadPos(packet.getSize()); + break; + + // ---- Server-first achievement broadcast ---- + case Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT: { + // charName (cstring) + guid (uint64) + achievementId (uint32) + ... + if (packet.getReadPos() < packet.getSize()) { + std::string charName = packet.readString(); + if (packet.getSize() - packet.getReadPos() >= 12) { + /*uint64_t guid =*/ packet.readUInt64(); + uint32_t achievementId = packet.readUInt32(); + char buf[192]; + std::snprintf(buf, sizeof(buf), + "%s is the first on the realm to earn achievement #%u!", + charName.c_str(), achievementId); + addSystemChatMessage(buf); + } + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- Forced faction reactions ---- + case Opcode::SMSG_SET_FORCED_REACTIONS: + handleSetForcedReactions(packet); + break; + + // ---- Spline speed changes for other units ---- + case Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED: + case Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED: + case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED: + case Opcode::SMSG_SPLINE_SET_WALK_SPEED: + case Opcode::SMSG_SPLINE_SET_TURN_RATE: + case Opcode::SMSG_SPLINE_SET_PITCH_RATE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Spline move flag changes for other units ---- + case Opcode::SMSG_SPLINE_MOVE_UNROOT: + case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: + case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER: + case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: + packet.setReadPos(packet.getSize()); + break; + + // ---- Quest failure notification ---- + case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { + // uint32 questId + uint32 reason + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t questId =*/ packet.readUInt32(); + uint32_t reason = packet.readUInt32(); + const char* reasonStr = "Unknown reason"; + switch (reason) { + case 1: reasonStr = "Quest failed: failed conditions"; break; + case 2: reasonStr = "Quest failed: inventory full"; break; + case 3: reasonStr = "Quest failed: too far away"; break; + case 4: reasonStr = "Quest failed: another quest is blocking"; break; + case 5: reasonStr = "Quest failed: wrong time of day"; break; + case 6: reasonStr = "Quest failed: wrong race"; break; + case 7: reasonStr = "Quest failed: wrong class"; break; + } + addSystemChatMessage(reasonStr); + } + break; + } + + // ---- Suspend comms (requires ACK) ---- + case Opcode::SMSG_SUSPEND_COMMS: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t seqIdx = packet.readUInt32(); + if (socket) { + network::Packet ack(wireOpcode(Opcode::CMSG_SUSPEND_COMMS_ACK)); + ack.writeUInt32(seqIdx); + socket->send(ack); + } + } + break; + } + + // ---- Pre-resurrect state ---- + case Opcode::SMSG_PRE_RESURRECT: { + // packed GUID of the player to enter pre-resurrect + (void)UpdateObjectParser::readPackedGuid(packet); + break; + } + + // ---- Hearthstone bind error ---- + case Opcode::SMSG_PLAYERBINDERROR: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t error = packet.readUInt32(); + if (error == 0) + addSystemChatMessage("Your hearthstone is not bound."); + else + addSystemChatMessage("Hearthstone bind failed."); + } + break; + } + + // ---- Instance/raid errors ---- + case Opcode::SMSG_RAID_GROUP_ONLY: { + addSystemChatMessage("You must be in a raid group to enter this instance."); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_RAID_READY_CHECK_ERROR: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t err = packet.readUInt8(); + if (err == 0) addSystemChatMessage("Ready check failed: not in a group."); + else if (err == 1) addSystemChatMessage("Ready check failed: in instance."); + else addSystemChatMessage("Ready check failed."); + } + break; + } + case Opcode::SMSG_RESET_FAILED_NOTIFY: { + addSystemChatMessage("Cannot reset instance: another player is still inside."); + packet.setReadPos(packet.getSize()); + break; + } + + // ---- Realm split ---- + case Opcode::SMSG_REALM_SPLIT: { + // uint32 splitType + uint32 deferTime + string realmName + // Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers. + uint32_t splitType = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + splitType = packet.readUInt32(); + packet.setReadPos(packet.getSize()); + if (socket) { + network::Packet resp(wireOpcode(Opcode::CMSG_REALM_SPLIT)); + resp.writeUInt32(splitType); + resp.writeString("3.3.5"); + socket->send(resp); + LOG_DEBUG("SMSG_REALM_SPLIT splitType=", splitType, " — sent CMSG_REALM_SPLIT ack"); + } + break; + } + + // ---- Real group update (status flags) ---- + case Opcode::SMSG_REAL_GROUP_UPDATE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Play music (WotLK standard opcode) ---- + case Opcode::SMSG_PLAY_MUSIC: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t soundId = packet.readUInt32(); + if (playMusicCallback_) playMusicCallback_(soundId); + } + break; + } + + // ---- Play object/spell sounds ---- + case Opcode::SMSG_PLAY_OBJECT_SOUND: + case Opcode::SMSG_PLAY_SPELL_IMPACT: + if (packet.getSize() - packet.getReadPos() >= 12) { + // uint32 soundId + uint64 sourceGuid + uint32_t soundId = packet.readUInt32(); + uint64_t srcGuid = packet.readUInt64(); + LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND/SPELL_IMPACT id=", soundId, " src=0x", std::hex, srcGuid, std::dec); + if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); + else if (playSoundCallback_) playSoundCallback_(soundId); + } else if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t soundId = packet.readUInt32(); + if (playSoundCallback_) playSoundCallback_(soundId); + } + packet.setReadPos(packet.getSize()); + break; + + // ---- Resistance/combat log ---- + case Opcode::SMSG_RESISTLOG: + packet.setReadPos(packet.getSize()); + break; + + // ---- Read item results ---- + case Opcode::SMSG_READ_ITEM_OK: + addSystemChatMessage("You read the item."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_READ_ITEM_FAILED: + addSystemChatMessage("You cannot read this item."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Completed quests query ---- + case Opcode::SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t count = packet.readUInt32(); + if (count <= 4096) { + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t questId = packet.readUInt32(); + completedQuests_.insert(questId); + } + LOG_DEBUG("SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: ", count, " completed quests"); + } + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- PVP quest kill update ---- + case Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL: { + // uint64 guid + uint32 questId + uint32 killCount + if (packet.getSize() - packet.getReadPos() >= 16) { + /*uint64_t guid =*/ packet.readUInt64(); + uint32_t questId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + char buf[64]; + std::snprintf(buf, sizeof(buf), "PVP kill counted for quest #%u (%u).", + questId, count); + addSystemChatMessage(buf); + } + break; + } + + // ---- NPC not responding ---- + case Opcode::SMSG_NPC_WONT_TALK: + addSystemChatMessage("That creature can't talk to you right now."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Petition ---- + case Opcode::SMSG_OFFER_PETITION_ERROR: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t err = packet.readUInt32(); + if (err == 1) addSystemChatMessage("Player is already in a guild."); + else if (err == 2) addSystemChatMessage("Player already has a petition."); + else addSystemChatMessage("Cannot offer petition to that player."); + } + break; + } + case Opcode::SMSG_PETITION_QUERY_RESPONSE: + case Opcode::SMSG_PETITION_SHOW_SIGNATURES: + case Opcode::SMSG_PETITION_SIGN_RESULTS: + packet.setReadPos(packet.getSize()); + break; + + // ---- Pet system (not yet implemented) ---- + case Opcode::SMSG_PET_GUIDS: + case Opcode::SMSG_PET_MODE: + case Opcode::SMSG_PET_BROKEN: + case Opcode::SMSG_PET_CAST_FAILED: + case Opcode::SMSG_PET_DISMISS_SOUND: + case Opcode::SMSG_PET_ACTION_SOUND: + case Opcode::SMSG_PET_LEARNED_SPELL: + case Opcode::SMSG_PET_UNLEARNED_SPELL: + case Opcode::SMSG_PET_UNLEARN_CONFIRM: + case Opcode::SMSG_PET_NAME_INVALID: + case Opcode::SMSG_PET_RENAMEABLE: + case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: + packet.setReadPos(packet.getSize()); + break; + + // ---- Inspect (full character inspection) ---- + case Opcode::SMSG_INSPECT: + packet.setReadPos(packet.getSize()); + break; + + // ---- Multiple aggregated packets/moves ---- + case Opcode::SMSG_MULTIPLE_MOVES: + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_MULTIPLE_PACKETS: { + // Each sub-packet uses the standard WotLK server wire format: + // uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2) + // uint16_le subOpcode + // payload (subSize - 2 bytes) + const auto& pdata = packet.getData(); + size_t dataLen = pdata.size(); + size_t pos = packet.getReadPos(); + static uint32_t multiPktWarnCount = 0; + while (pos + 4 <= dataLen) { + uint16_t subSize = static_cast( + (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); + if (subSize < 2) break; + size_t payloadLen = subSize - 2; + if (pos + 4 + payloadLen > dataLen) { + if (++multiPktWarnCount <= 10) { + LOG_WARNING("SMSG_MULTIPLE_PACKETS: sub-packet overruns buffer at pos=", + pos, " subSize=", subSize, " dataLen=", dataLen); + } + break; + } + uint16_t subOpcode = static_cast(pdata[pos + 2]) | + (static_cast(pdata[pos + 3]) << 8); + std::vector subPayload(pdata.begin() + pos + 4, + pdata.begin() + pos + 4 + payloadLen); + network::Packet subPacket(subOpcode, std::move(subPayload)); + handlePacket(subPacket); + pos += 4 + payloadLen; + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- Misc consume ---- + case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: + case Opcode::SMSG_PROPOSE_LEVEL_GRANT: + case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: + case Opcode::SMSG_REFER_A_FRIEND_FAILURE: + case Opcode::SMSG_REPORT_PVP_AFK_RESULT: + case Opcode::SMSG_REDIRECT_CLIENT: + case Opcode::SMSG_PVP_QUEUE_STATS: + case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: + case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: + case Opcode::SMSG_PLAYER_SKINNED: + case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: + case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: + case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: + case Opcode::SMSG_PROFILEDATA_RESPONSE: + case Opcode::SMSG_PLAY_TIME_WARNING: + packet.setReadPos(packet.getSize()); + break; + + // ---- Item query multiple (same format as single, re-use handler) ---- + case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: + handleItemQueryResponse(packet); + break; + + // ---- Object position/rotation queries ---- + case Opcode::SMSG_QUERY_OBJECT_POSITION: + case Opcode::SMSG_QUERY_OBJECT_ROTATION: + case Opcode::SMSG_VOICESESSION_FULL: + packet.setReadPos(packet.getSize()); + break; + + // ---- Player movement flag changes (server-pushed) ---- + case Opcode::SMSG_MOVE_GRAVITY_DISABLE: + case Opcode::SMSG_MOVE_GRAVITY_ENABLE: + case Opcode::SMSG_MOVE_LAND_WALK: + case Opcode::SMSG_MOVE_NORMAL_FALL: + case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + case Opcode::SMSG_MOVE_SET_COLLISION_HGT: + case Opcode::SMSG_MOVE_SET_FLIGHT: + case Opcode::SMSG_MOVE_UNSET_FLIGHT: + packet.setReadPos(packet.getSize()); + break; + default: // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. @@ -3513,7 +5459,15 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { pendingQuestQueryIds_.clear(); pendingLoginQuestResync_ = true; pendingLoginQuestResyncTimeout_ = 10.0f; + completedQuests_.clear(); LOG_INFO("Queued quest log resync for login (from server quest slots)"); + + // Request completed quest IDs from server (populates completedQuests_ when response arrives) + if (socket) { + network::Packet cqcPkt(wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED)); + socket->send(cqcPkt); + LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + } } } @@ -6841,18 +8795,76 @@ void GameHandler::respondToReadyCheck(bool ready) { LOG_INFO("Responded to ready check: ", ready ? "ready" : "not ready"); } +void GameHandler::acceptDuel() { + if (!pendingDuelRequest_ || state != WorldState::IN_WORLD || !socket) return; + pendingDuelRequest_ = false; + auto pkt = DuelAcceptPacket::build(); + socket->send(pkt); + addSystemChatMessage("You accept the duel."); + LOG_INFO("Accepted duel from guid=0x", std::hex, duelChallengerGuid_, std::dec); +} + void GameHandler::forfeitDuel() { if (state != WorldState::IN_WORLD || !socket) { LOG_WARNING("Cannot forfeit duel: not in world or not connected"); return; } - + pendingDuelRequest_ = false; // cancel request if still pending auto packet = DuelCancelPacket::build(); socket->send(packet); addSystemChatMessage("You have forfeited the duel."); LOG_INFO("Forfeited duel"); } +void GameHandler::handleDuelRequested(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 16) { + packet.setReadPos(packet.getSize()); + return; + } + duelChallengerGuid_ = packet.readUInt64(); + duelFlagGuid_ = packet.readUInt64(); + + // Resolve challenger name from entity list + duelChallengerName_.clear(); + auto entity = entityManager.getEntity(duelChallengerGuid_); + if (auto* unit = dynamic_cast(entity.get())) { + duelChallengerName_ = unit->getName(); + } + if (duelChallengerName_.empty()) { + char tmp[32]; + std::snprintf(tmp, sizeof(tmp), "0x%llX", + static_cast(duelChallengerGuid_)); + duelChallengerName_ = tmp; + } + pendingDuelRequest_ = true; + + addSystemChatMessage(duelChallengerName_ + " challenges you to a duel!"); + LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_, + " flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_); +} + +void GameHandler::handleDuelComplete(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 1) return; + uint8_t started = packet.readUInt8(); + // started=1: duel began, started=0: duel was cancelled before starting + pendingDuelRequest_ = false; + if (!started) { + addSystemChatMessage("The duel was cancelled."); + } + LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast(started)); +} + +void GameHandler::handleDuelWinner(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 3) return; + /*uint8_t type =*/ packet.readUInt8(); // 0=normal, 1=flee + std::string winner = packet.readString(); + std::string loser = packet.readString(); + + std::string msg = winner + " has defeated " + loser + " in a duel!"; + addSystemChatMessage(msg); + LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser); +} + void GameHandler::toggleAfk(const std::string& message) { afkStatus_ = !afkStatus_; afkMessage_ = message; @@ -8773,6 +10785,31 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); } +void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { + // SMSG_RAID_INSTANCE_INFO: uint32 count, then for each: + // mapId(u32) + difficulty(u32) + resetTime(u64) + locked(u8) + extended(u8) + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t count = packet.readUInt32(); + + instanceLockouts_.clear(); + instanceLockouts_.reserve(count); + + constexpr size_t kEntrySize = 4 + 4 + 8 + 1 + 1; + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < kEntrySize) break; + InstanceLockout lo; + lo.mapId = packet.readUInt32(); + lo.difficulty = packet.readUInt32(); + lo.resetTime = packet.readUInt64(); + lo.locked = packet.readUInt8() != 0; + lo.extended = packet.readUInt8() != 0; + instanceLockouts_.push_back(lo); + LOG_INFO("Instance lockout: mapId=", lo.mapId, " diff=", lo.difficulty, + " reset=", lo.resetTime, " locked=", lo.locked, " extended=", lo.extended); + } + LOG_INFO("SMSG_RAID_INSTANCE_INFO: ", instanceLockouts_.size(), " lockout(s)"); +} + void GameHandler::handleInstanceDifficulty(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) return; instanceDifficulty_ = packet.readUInt32(); @@ -8781,6 +10818,325 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) { LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); } +// --------------------------------------------------------------------------- +// LFG / Dungeon Finder handlers (WotLK 3.3.5a) +// --------------------------------------------------------------------------- + +static const char* lfgJoinResultString(uint8_t result) { + switch (result) { + case 0: return nullptr; // success + case 1: return "Role check failed."; + case 2: return "No LFG slots available for your group."; + case 3: return "No LFG object found."; + case 4: return "No slots available (player)."; + case 5: return "No slots available (party)."; + case 6: return "Dungeon requirements not met by all members."; + case 7: return "Party members are from different realms."; + case 8: return "Not all members are present."; + case 9: return "Get info timeout."; + case 10: return "Invalid dungeon slot."; + case 11: return "You are marked as a deserter."; + case 12: return "A party member is marked as a deserter."; + case 13: return "You are on a random dungeon cooldown."; + case 14: return "A party member is on a random dungeon cooldown."; + case 16: return "No spec/role available."; + default: return "Cannot join dungeon finder."; + } +} + +static const char* lfgTeleportDeniedString(uint8_t reason) { + switch (reason) { + case 0: return "You are not in a LFG group."; + case 1: return "You are not in the dungeon."; + case 2: return "You have a summon pending."; + case 3: return "You are dead."; + case 4: return "You have Deserter."; + case 5: return "You do not meet the requirements."; + default: return "Teleport to dungeon denied."; + } +} + +void GameHandler::handleLfgJoinResult(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 2) return; + + uint8_t result = packet.readUInt8(); + uint8_t state = packet.readUInt8(); + + if (result == 0) { + // Success — state tells us what phase we're entering + lfgState_ = static_cast(state); + LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast(state)); + addSystemChatMessage("Dungeon Finder: Joined the queue."); + } else { + const char* msg = lfgJoinResultString(result); + std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); + addSystemChatMessage(errMsg); + LOG_INFO("SMSG_LFG_JOIN_RESULT: result=", static_cast(result), + " state=", static_cast(state)); + } +} + +void GameHandler::handleLfgQueueStatus(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4 + 6 * 4 + 1 + 4) return; // dungeonId + 6 int32 + uint8 + uint32 + + lfgDungeonId_ = packet.readUInt32(); + int32_t avgWait = static_cast(packet.readUInt32()); + int32_t waitTime = static_cast(packet.readUInt32()); + /*int32_t waitTimeTank =*/ static_cast(packet.readUInt32()); + /*int32_t waitTimeHealer =*/ static_cast(packet.readUInt32()); + /*int32_t waitTimeDps =*/ static_cast(packet.readUInt32()); + /*uint8_t queuedByNeeded=*/ packet.readUInt8(); + lfgTimeInQueueMs_ = packet.readUInt32(); + + lfgAvgWaitSec_ = (waitTime >= 0) ? (waitTime / 1000) : (avgWait / 1000); + lfgState_ = LfgState::Queued; + + LOG_INFO("SMSG_LFG_QUEUE_STATUS: dungeonId=", lfgDungeonId_, + " avgWait=", avgWait, "ms waitTime=", waitTime, "ms"); +} + +void GameHandler::handleLfgProposalUpdate(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 16) return; + + uint32_t dungeonId = packet.readUInt32(); + uint32_t proposalId = packet.readUInt32(); + uint32_t proposalState = packet.readUInt32(); + /*uint32_t encounterMask =*/ packet.readUInt32(); + + if (remaining < 17) return; + /*bool canOverride =*/ packet.readUInt8(); + + lfgDungeonId_ = dungeonId; + lfgProposalId_ = proposalId; + + switch (proposalState) { + case 0: + lfgState_ = LfgState::Queued; + lfgProposalId_ = 0; + addSystemChatMessage("Dungeon Finder: Group proposal failed."); + break; + case 1: + lfgState_ = LfgState::InDungeon; + lfgProposalId_ = 0; + addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); + break; + case 2: + lfgState_ = LfgState::Proposal; + addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); + break; + default: + break; + } + + LOG_INFO("SMSG_LFG_PROPOSAL_UPDATE: dungeonId=", dungeonId, + " proposalId=", proposalId, " state=", proposalState); +} + +void GameHandler::handleLfgRoleCheckUpdate(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 6) return; + + /*uint32_t dungeonId =*/ packet.readUInt32(); + uint8_t roleCheckState = packet.readUInt8(); + /*bool isBeginning =*/ packet.readUInt8(); + + // roleCheckState: 0=default, 1=finished, 2=initializing, 3=missing_role, 4=wrong_dungeons + if (roleCheckState == 1) { + lfgState_ = LfgState::Queued; + LOG_INFO("LFG role check finished"); + } else if (roleCheckState == 3) { + lfgState_ = LfgState::None; + addSystemChatMessage("Dungeon Finder: Role check failed — missing required role."); + } else if (roleCheckState == 2) { + lfgState_ = LfgState::RoleCheck; + addSystemChatMessage("Dungeon Finder: Performing role check..."); + } + + LOG_INFO("SMSG_LFG_ROLE_CHECK_UPDATE: roleCheckState=", static_cast(roleCheckState)); +} + +void GameHandler::handleLfgUpdatePlayer(network::Packet& packet) { + // SMSG_LFG_UPDATE_PLAYER and SMSG_LFG_UPDATE_PARTY share the same layout. + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 1) return; + + uint8_t updateType = packet.readUInt8(); + + // LFGUpdateType values that carry no extra payload + // 0=default, 1=leader_unk1, 4=rolecheck_aborted, 8=removed_from_queue, + // 9=proposal_failed, 10=proposal_declined, 15=leave_queue, 17=member_offline, 18=group_disband + bool hasExtra = (updateType != 0 && updateType != 1 && updateType != 15 && + updateType != 17 && updateType != 18); + if (!hasExtra || packet.getSize() - packet.getReadPos() < 3) { + switch (updateType) { + case 8: lfgState_ = LfgState::None; + addSystemChatMessage("Dungeon Finder: Removed from queue."); break; + case 9: lfgState_ = LfgState::Queued; + addSystemChatMessage("Dungeon Finder: Proposal failed — re-queuing."); break; + case 10: lfgState_ = LfgState::Queued; + addSystemChatMessage("Dungeon Finder: A member declined the proposal."); break; + case 15: lfgState_ = LfgState::None; + addSystemChatMessage("Dungeon Finder: Left the queue."); break; + case 18: lfgState_ = LfgState::None; + addSystemChatMessage("Dungeon Finder: Your group disbanded."); break; + default: break; + } + LOG_INFO("SMSG_LFG_UPDATE_PLAYER/PARTY: updateType=", static_cast(updateType)); + return; + } + + /*bool queued =*/ packet.readUInt8(); + packet.readUInt8(); // unk1 + packet.readUInt8(); // unk2 + + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t count = packet.readUInt8(); + for (uint8_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 4; ++i) { + uint32_t dungeonEntry = packet.readUInt32(); + if (i == 0) lfgDungeonId_ = dungeonEntry; + } + } + + switch (updateType) { + case 6: lfgState_ = LfgState::Queued; + addSystemChatMessage("Dungeon Finder: You have joined the queue."); break; + case 11: lfgState_ = LfgState::Proposal; + addSystemChatMessage("Dungeon Finder: A group has been found!"); break; + case 12: lfgState_ = LfgState::Queued; + addSystemChatMessage("Dungeon Finder: Added to queue."); break; + case 13: lfgState_ = LfgState::Proposal; + addSystemChatMessage("Dungeon Finder: Proposal started."); break; + case 14: lfgState_ = LfgState::InDungeon; break; + case 16: addSystemChatMessage("Dungeon Finder: Two members are ready."); break; + default: break; + } + LOG_INFO("SMSG_LFG_UPDATE_PLAYER/PARTY: updateType=", static_cast(updateType)); +} + +void GameHandler::handleLfgPlayerReward(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4 + 4 + 1 + 4 + 4 + 4) return; + + /*uint32_t randomDungeonEntry =*/ packet.readUInt32(); + /*uint32_t dungeonEntry =*/ packet.readUInt32(); + packet.readUInt8(); // unk + uint32_t money = packet.readUInt32(); + uint32_t xp = packet.readUInt32(); + + std::string rewardMsg = "Dungeon Finder reward: " + std::to_string(money) + "g " + + std::to_string(xp) + " XP"; + + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t rewardCount = packet.readUInt32(); + for (uint32_t i = 0; i < rewardCount && packet.getSize() - packet.getReadPos() >= 9; ++i) { + uint32_t itemId = packet.readUInt32(); + uint32_t itemCount = packet.readUInt32(); + packet.readUInt8(); // unk + if (i == 0) { + rewardMsg += ", item #" + std::to_string(itemId); + if (itemCount > 1) rewardMsg += " x" + std::to_string(itemCount); + } + } + } + + addSystemChatMessage(rewardMsg); + lfgState_ = LfgState::FinishedDungeon; + LOG_INFO("SMSG_LFG_PLAYER_REWARD: money=", money, " xp=", xp); +} + +void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 7 + 4 + 4 + 4 + 4) return; + + bool inProgress = packet.readUInt8() != 0; + bool myVote = packet.readUInt8() != 0; + bool myAnswer = packet.readUInt8() != 0; + uint32_t totalVotes = packet.readUInt32(); + uint32_t bootVotes = packet.readUInt32(); + uint32_t timeLeft = packet.readUInt32(); + uint32_t votesNeeded = packet.readUInt32(); + + (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; + + if (inProgress) { + addSystemChatMessage( + std::string("Dungeon Finder: Vote to kick in progress (") + + std::to_string(timeLeft) + "s remaining)."); + } else if (myAnswer) { + addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); + } else { + addSystemChatMessage("Dungeon Finder: Vote kick failed."); + } + + LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, + " bootVotes=", bootVotes, "/", totalVotes); +} + +void GameHandler::handleLfgTeleportDenied(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 1) return; + uint8_t reason = packet.readUInt8(); + const char* msg = lfgTeleportDeniedString(reason); + addSystemChatMessage(std::string("Dungeon Finder: ") + msg); + LOG_INFO("SMSG_LFG_TELEPORT_DENIED: reason=", static_cast(reason)); +} + +// --------------------------------------------------------------------------- +// LFG outgoing packets +// --------------------------------------------------------------------------- + +void GameHandler::lfgJoin(uint32_t dungeonId, uint8_t roles) { + if (state != WorldState::IN_WORLD || !socket) return; + + network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_JOIN)); + pkt.writeUInt8(roles); + pkt.writeUInt8(0); // needed + pkt.writeUInt8(0); // unk + pkt.writeUInt8(1); // 1 dungeon in list + pkt.writeUInt32(dungeonId); + pkt.writeString(""); // comment + + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_JOIN: dungeonId=", dungeonId, " roles=", static_cast(roles)); +} + +void GameHandler::lfgLeave() { + if (!socket) return; + + network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_LEAVE)); + // CMSG_LFG_LEAVE has an LFG identifier block; send zeroes to leave any active queue. + pkt.writeUInt32(0); // slot + pkt.writeUInt32(0); // unk + pkt.writeUInt32(0); // dungeonId + + socket->send(pkt); + lfgState_ = LfgState::None; + LOG_INFO("Sent CMSG_LFG_LEAVE"); +} + +void GameHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) { + if (!socket) return; + + network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_PROPOSAL_RESULT)); + pkt.writeUInt32(proposalId); + pkt.writeUInt8(accept ? 1 : 0); + + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_PROPOSAL_RESULT: proposalId=", proposalId, " accept=", accept); +} + +void GameHandler::lfgTeleport(bool toLfgDungeon) { + if (!socket) return; + + network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_TELEPORT)); + pkt.writeUInt8(toLfgDungeon ? 0 : 1); // 0=teleport in, 1=teleport out + + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon); +} + void GameHandler::loadAreaTriggerDbc() { if (areaTriggerDbcLoaded_) return; areaTriggerDbcLoaded_ = true; @@ -9268,50 +11624,153 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { // Parse transport-relative creature movement (NPCs on boats/zeppelins) - // Packet structure: mover GUID + transport GUID + spline data (local coords) + // Packet: moverGuid(8) + unk(1) + transportGuid(8) + localX/Y/Z(12) + spline data + if (packet.getSize() - packet.getReadPos() < 8 + 1 + 8 + 12) return; uint64_t moverGuid = packet.readUInt64(); - uint8_t unk = packet.readUInt8(); // Unknown byte (usually 0) + /*uint8_t unk =*/ packet.readUInt8(); uint64_t transportGuid = packet.readUInt64(); - // Transport-local coordinates (server space) + // Transport-local start position (server coords: x=east/west, y=north/south, z=up) float localX = packet.readFloat(); float localY = packet.readFloat(); float localZ = packet.readFloat(); - LOG_INFO("SMSG_MONSTER_MOVE_TRANSPORT: mover=0x", std::hex, moverGuid, - " transport=0x", transportGuid, std::dec, - " localPos=(", localX, ", ", localY, ", ", localZ, ")"); - - // Compose world position: worldPos = transportTransform * localPos auto entity = entityManager.getEntity(moverGuid); - if (!entity) { - LOG_WARNING(" NPC 0x", std::hex, moverGuid, std::dec, " not found in entity manager"); + if (!entity) return; + + // ---- Spline data (same format as SMSG_MONSTER_MOVE, transport-local coords) ---- + if (packet.getReadPos() + 5 > packet.getSize()) { + // No spline data — snap to start position + if (transportManager_) { + glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ)); + setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, false, 0.0f); + glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation()); + if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) + creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); + } return; } - if (transportManager_) { - // Use TransportManager to compose world position from local offset - glm::vec3 localPosCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ)); - setTransportAttachment(moverGuid, entity->getType(), transportGuid, localPosCanonical, false, 0.0f); - glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localPosCanonical); + /*uint32_t splineId =*/ packet.readUInt32(); + uint8_t moveType = packet.readUInt8(); - entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation()); - - LOG_INFO(" Composed NPC world position: (", worldPos.x, ", ", worldPos.y, ", ", worldPos.z, ")"); - - if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) { - creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); + if (moveType == 1) { + // Stop — snap to start position + if (transportManager_) { + glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ)); + setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, false, 0.0f); + glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation()); + if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) + creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); } - } else { - LOG_WARNING(" TransportManager not available for NPC position composition"); + return; } - // TODO: Parse full spline data for smooth NPC movement on transport - // Then update entity position and call creatureMoveCallback_ + // Facing data based on moveType + float facingAngle = entity->getOrientation(); + if (moveType == 2) { // FacingSpot + if (packet.getReadPos() + 12 > packet.getSize()) return; + float sx = packet.readFloat(), sy = packet.readFloat(), sz = packet.readFloat(); + facingAngle = std::atan2(-(sy - localY), sx - localX); + (void)sz; + } else if (moveType == 3) { // FacingTarget + if (packet.getReadPos() + 8 > packet.getSize()) return; + uint64_t tgtGuid = packet.readUInt64(); + if (auto tgt = entityManager.getEntity(tgtGuid)) { + float dx = tgt->getX() - entity->getX(); + float dy = tgt->getY() - entity->getY(); + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) + facingAngle = std::atan2(-dy, dx); + } + } else if (moveType == 4) { // FacingAngle + if (packet.getReadPos() + 4 > packet.getSize()) return; + facingAngle = core::coords::serverToCanonicalYaw(packet.readFloat()); + } - // Suppress unused variable warning for now - (void)unk; + if (packet.getReadPos() + 4 > packet.getSize()) return; + uint32_t splineFlags = packet.readUInt32(); + + if (splineFlags & 0x00400000) { // Animation + if (packet.getReadPos() + 5 > packet.getSize()) return; + packet.readUInt8(); packet.readUInt32(); + } + + if (packet.getReadPos() + 4 > packet.getSize()) return; + uint32_t duration = packet.readUInt32(); + + if (splineFlags & 0x00000800) { // Parabolic + if (packet.getReadPos() + 8 > packet.getSize()) return; + packet.readFloat(); packet.readUInt32(); + } + + if (packet.getReadPos() + 4 > packet.getSize()) return; + uint32_t pointCount = packet.readUInt32(); + + // Read destination point (transport-local server coords) + float destLocalX = localX, destLocalY = localY, destLocalZ = localZ; + bool hasDest = false; + if (pointCount > 0) { + const bool uncompressed = (splineFlags & (0x00080000 | 0x00002000)) != 0; + if (uncompressed) { + for (uint32_t i = 0; i < pointCount - 1; ++i) { + if (packet.getReadPos() + 12 > packet.getSize()) break; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } + if (packet.getReadPos() + 12 <= packet.getSize()) { + destLocalX = packet.readFloat(); + destLocalY = packet.readFloat(); + destLocalZ = packet.readFloat(); + hasDest = true; + } + } else { + if (packet.getReadPos() + 12 <= packet.getSize()) { + destLocalX = packet.readFloat(); + destLocalY = packet.readFloat(); + destLocalZ = packet.readFloat(); + hasDest = true; + } + } + } + + if (!transportManager_) { + LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x", + std::hex, moverGuid, std::dec); + return; + } + + glm::vec3 startLocalCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ)); + + if (hasDest && duration > 0) { + glm::vec3 destLocalCanonical = core::coords::serverToCanonical(glm::vec3(destLocalX, destLocalY, destLocalZ)); + glm::vec3 destWorld = transportManager_->getPlayerWorldPosition(transportGuid, destLocalCanonical); + + // Face toward destination unless an explicit facing was given + if (moveType == 0) { + float dx = destLocalCanonical.x - startLocalCanonical.x; + float dy = destLocalCanonical.y - startLocalCanonical.y; + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) + facingAngle = std::atan2(-dy, dx); + } + + setTransportAttachment(moverGuid, entity->getType(), transportGuid, destLocalCanonical, false, 0.0f); + entity->startMoveTo(destWorld.x, destWorld.y, destWorld.z, facingAngle, duration / 1000.0f); + + if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) + creatureMoveCallback_(moverGuid, destWorld.x, destWorld.y, destWorld.z, duration); + + LOG_DEBUG("SMSG_MONSTER_MOVE_TRANSPORT: mover=0x", std::hex, moverGuid, + " transport=0x", transportGuid, std::dec, + " dur=", duration, "ms dest=(", destWorld.x, ",", destWorld.y, ",", destWorld.z, ")"); + } else { + glm::vec3 startWorld = transportManager_->getPlayerWorldPosition(transportGuid, startLocalCanonical); + setTransportAttachment(moverGuid, entity->getType(), transportGuid, startLocalCanonical, false, 0.0f); + entity->setPosition(startWorld.x, startWorld.y, startWorld.z, facingAngle); + if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) + creatureMoveCallback_(moverGuid, startWorld.x, startWorld.y, startWorld.z, 0); + } } void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { @@ -9366,12 +11825,14 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker); } else if (data.victimState == 2) { addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); + } else if (data.victimState == 4) { + addCombatText(CombatTextEntry::BLOCK, 0, 0, isPlayerAttacker); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker); } - (void)isPlayerTarget; // Used for future incoming damage display + (void)isPlayerTarget; } void GameHandler::handleSpellDamageLog(network::Packet& packet) { @@ -12081,7 +14542,7 @@ void GameHandler::handleXpGain(network::Packet& packet) { // Server already updates PLAYER_XP via update fields, // but we can show combat text for XP gains - addCombatText(CombatTextEntry::HEAL, static_cast(data.totalXp), 0, true); + addCombatText(CombatTextEntry::XP_GAIN, static_cast(data.totalXp), 0, true); std::string msg = "You gain " + std::to_string(data.totalXp) + " experience."; if (data.groupBonus > 0) { @@ -14246,5 +16707,513 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) { " error=", result.errorCode); } +// --------------------------------------------------------------------------- +// Item text (SMSG_ITEM_TEXT_QUERY_RESPONSE) +// uint64 itemGuid + uint8 isEmpty + string text (when !isEmpty) +// --------------------------------------------------------------------------- + +void GameHandler::handleItemTextQueryResponse(network::Packet& packet) { + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 9) return; // guid(8) + isEmpty(1) + + /*uint64_t guid =*/ packet.readUInt64(); + uint8_t isEmpty = packet.readUInt8(); + if (!isEmpty) { + itemText_ = packet.readString(); + itemTextOpen_= !itemText_.empty(); + } + LOG_DEBUG("SMSG_ITEM_TEXT_QUERY_RESPONSE: isEmpty=", (int)isEmpty, + " len=", itemText_.size()); +} + +void GameHandler::queryItemText(uint64_t itemGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_ITEM_TEXT_QUERY)); + pkt.writeUInt64(itemGuid); + socket->send(pkt); + LOG_DEBUG("CMSG_ITEM_TEXT_QUERY: guid=0x", std::hex, itemGuid, std::dec); +} + +// --------------------------------------------------------------------------- +// SMSG_QUEST_CONFIRM_ACCEPT (shared quest from group member) +// uint32 questId + string questTitle + uint64 sharerGuid +// --------------------------------------------------------------------------- + +void GameHandler::handleQuestConfirmAccept(network::Packet& packet) { + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 4) return; + + sharedQuestId_ = packet.readUInt32(); + sharedQuestTitle_ = packet.readString(); + if (packet.getSize() - packet.getReadPos() >= 8) { + sharedQuestSharerGuid_ = packet.readUInt64(); + } + + sharedQuestSharerName_.clear(); + auto entity = entityManager.getEntity(sharedQuestSharerGuid_); + if (auto* unit = dynamic_cast(entity.get())) { + sharedQuestSharerName_ = unit->getName(); + } + if (sharedQuestSharerName_.empty()) { + char tmp[32]; + std::snprintf(tmp, sizeof(tmp), "0x%llX", + static_cast(sharedQuestSharerGuid_)); + sharedQuestSharerName_ = tmp; + } + + pendingSharedQuest_ = true; + addSystemChatMessage(sharedQuestSharerName_ + " has shared the quest \"" + + sharedQuestTitle_ + "\" with you."); + LOG_INFO("SMSG_QUEST_CONFIRM_ACCEPT: questId=", sharedQuestId_, + " title=", sharedQuestTitle_, " sharer=", sharedQuestSharerName_); +} + +void GameHandler::acceptSharedQuest() { + if (!pendingSharedQuest_ || !socket) return; + pendingSharedQuest_ = false; + network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_CONFIRM_ACCEPT)); + pkt.writeUInt32(sharedQuestId_); + socket->send(pkt); + addSystemChatMessage("Accepted: " + sharedQuestTitle_); +} + +void GameHandler::declineSharedQuest() { + pendingSharedQuest_ = false; + // No response packet needed — just dismiss the UI +} + +// --------------------------------------------------------------------------- +// SMSG_SUMMON_REQUEST +// uint64 summonerGuid + uint32 zoneId + uint32 timeoutMs +// --------------------------------------------------------------------------- + +void GameHandler::handleSummonRequest(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 16) return; + + summonerGuid_ = packet.readUInt64(); + /*uint32_t zoneId =*/ packet.readUInt32(); + uint32_t timeoutMs = packet.readUInt32(); + summonTimeoutSec_ = timeoutMs / 1000.0f; + pendingSummonRequest_= true; + + summonerName_.clear(); + auto entity = entityManager.getEntity(summonerGuid_); + if (auto* unit = dynamic_cast(entity.get())) { + summonerName_ = unit->getName(); + } + if (summonerName_.empty()) { + char tmp[32]; + std::snprintf(tmp, sizeof(tmp), "0x%llX", + static_cast(summonerGuid_)); + summonerName_ = tmp; + } + + addSystemChatMessage(summonerName_ + " is summoning you."); + LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_, + " timeout=", summonTimeoutSec_, "s"); +} + +void GameHandler::acceptSummon() { + if (!pendingSummonRequest_ || !socket) return; + pendingSummonRequest_ = false; + network::Packet pkt(wireOpcode(Opcode::CMSG_SUMMON_RESPONSE)); + pkt.writeUInt8(1); // 1 = accept + socket->send(pkt); + addSystemChatMessage("Accepting summon..."); + LOG_INFO("Accepted summon from ", summonerName_); +} + +void GameHandler::declineSummon() { + if (!socket) return; + pendingSummonRequest_ = false; + network::Packet pkt(wireOpcode(Opcode::CMSG_SUMMON_RESPONSE)); + pkt.writeUInt8(0); // 0 = decline + socket->send(pkt); + addSystemChatMessage("Summon declined."); +} + +// --------------------------------------------------------------------------- +// Trade (SMSG_TRADE_STATUS / SMSG_TRADE_STATUS_EXTENDED) +// WotLK 3.3.5a status values: +// 0=busy, 1=begin_trade(+guid), 2=open_window, 3=cancelled, 4=accepted, +// 5=busy2, 6=no_target, 7=back_to_trade, 8=complete, 9=rejected, +// 10=too_far, 11=wrong_faction, 12=close_window, 13=ignore, +// 14-19=stun/dead/logout, 20=trial, 21=conjured_only +// --------------------------------------------------------------------------- + +void GameHandler::handleTradeStatus(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t status = packet.readUInt32(); + + switch (status) { + case 1: { // BEGIN_TRADE — incoming request; read initiator GUID + if (packet.getSize() - packet.getReadPos() >= 8) { + tradePeerGuid_ = packet.readUInt64(); + } + // Resolve name from entity list + tradePeerName_.clear(); + auto entity = entityManager.getEntity(tradePeerGuid_); + if (auto* unit = dynamic_cast(entity.get())) { + tradePeerName_ = unit->getName(); + } + if (tradePeerName_.empty()) { + char tmp[32]; + std::snprintf(tmp, sizeof(tmp), "0x%llX", + static_cast(tradePeerGuid_)); + tradePeerName_ = tmp; + } + tradeStatus_ = TradeStatus::PendingIncoming; + addSystemChatMessage(tradePeerName_ + " wants to trade with you."); + break; + } + case 2: // OPEN_WINDOW + tradeStatus_ = TradeStatus::Open; + addSystemChatMessage("Trade window opened."); + break; + case 3: // CANCELLED + case 9: // REJECTED + case 12: // CLOSE_WINDOW + tradeStatus_ = TradeStatus::None; + addSystemChatMessage("Trade cancelled."); + break; + case 4: // ACCEPTED (partner accepted) + tradeStatus_ = TradeStatus::Accepted; + addSystemChatMessage("Trade accepted. Awaiting other player..."); + break; + case 8: // COMPLETE + tradeStatus_ = TradeStatus::Complete; + addSystemChatMessage("Trade complete!"); + tradeStatus_ = TradeStatus::None; // reset after notification + break; + case 7: // BACK_TO_TRADE (unaccepted after a change) + tradeStatus_ = TradeStatus::Open; + addSystemChatMessage("Trade offer changed."); + break; + case 10: addSystemChatMessage("Trade target is too far away."); break; + case 11: addSystemChatMessage("Trade failed: wrong faction."); break; + case 13: addSystemChatMessage("Trade failed: player ignores you."); break; + case 14: addSystemChatMessage("Trade failed: you are stunned."); break; + case 15: addSystemChatMessage("Trade failed: target is stunned."); break; + case 16: addSystemChatMessage("Trade failed: you are dead."); break; + case 17: addSystemChatMessage("Trade failed: target is dead."); break; + case 20: addSystemChatMessage("Trial accounts cannot trade."); break; + default: break; + } + LOG_DEBUG("SMSG_TRADE_STATUS: status=", status); +} + +void GameHandler::acceptTradeRequest() { + if (tradeStatus_ != TradeStatus::PendingIncoming || !socket) return; + tradeStatus_ = TradeStatus::Open; + socket->send(BeginTradePacket::build()); +} + +void GameHandler::declineTradeRequest() { + if (!socket) return; + tradeStatus_ = TradeStatus::None; + socket->send(CancelTradePacket::build()); +} + +void GameHandler::acceptTrade() { + if (tradeStatus_ != TradeStatus::Open || !socket) return; + tradeStatus_ = TradeStatus::Accepted; + socket->send(AcceptTradePacket::build()); +} + +void GameHandler::cancelTrade() { + if (!socket) return; + tradeStatus_ = TradeStatus::None; + socket->send(CancelTradePacket::build()); +} + +// --------------------------------------------------------------------------- +// Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL) +// --------------------------------------------------------------------------- + +void GameHandler::handleLootRoll(network::Packet& packet) { + // uint64 objectGuid, uint32 slot, uint64 playerGuid, + // uint32 itemId, uint32 randomSuffix, uint32 randomPropId, + // uint8 rollNumber, uint8 rollType + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 26) return; // minimum: 8+4+8+4+4+4+1+1 = 34, be lenient + + uint64_t objectGuid = packet.readUInt64(); + uint32_t slot = packet.readUInt32(); + uint64_t rollerGuid = packet.readUInt64(); + uint32_t itemId = packet.readUInt32(); + /*uint32_t randSuffix =*/ packet.readUInt32(); + /*uint32_t randProp =*/ packet.readUInt32(); + uint8_t rollNum = packet.readUInt8(); + uint8_t rollType = packet.readUInt8(); + + // rollType 128 = "waiting for this player to roll" + if (rollType == 128 && rollerGuid == playerGuid) { + // Server is asking us to roll; present the roll UI. + pendingLootRollActive_ = true; + pendingLootRoll_.objectGuid = objectGuid; + pendingLootRoll_.slot = slot; + pendingLootRoll_.itemId = itemId; + // Look up item name from cache + auto* info = getItemInfo(itemId); + pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); + pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + LOG_INFO("SMSG_LOOT_ROLL: need/greed prompt for item=", itemId, + " (", pendingLootRoll_.itemName, ") slot=", slot); + return; + } + + // Otherwise it's reporting another player's roll result + const char* rollNames[] = {"Need", "Greed", "Disenchant", "Pass"}; + const char* rollName = (rollType < 4) ? rollNames[rollType] : "Pass"; + + std::string rollerName; + auto entity = entityManager.getEntity(rollerGuid); + if (auto* unit = dynamic_cast(entity.get())) { + rollerName = unit->getName(); + } + if (rollerName.empty()) rollerName = "Someone"; + + auto* info = getItemInfo(itemId); + std::string iName = info ? info->name : std::to_string(itemId); + + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s rolls %s (%d) on [%s]", + rollerName.c_str(), rollName, static_cast(rollNum), iName.c_str()); + addSystemChatMessage(buf); + + LOG_DEBUG("SMSG_LOOT_ROLL: ", rollerName, " rolled ", rollName, + " (", rollNum, ") on item ", itemId); + (void)objectGuid; (void)slot; +} + +void GameHandler::handleLootRollWon(network::Packet& packet) { + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 26) return; + + /*uint64_t objectGuid =*/ packet.readUInt64(); + /*uint32_t slot =*/ packet.readUInt32(); + uint64_t winnerGuid = packet.readUInt64(); + uint32_t itemId = packet.readUInt32(); + /*uint32_t randSuffix =*/ packet.readUInt32(); + /*uint32_t randProp =*/ packet.readUInt32(); + uint8_t rollNum = packet.readUInt8(); + uint8_t rollType = packet.readUInt8(); + + const char* rollNames[] = {"Need", "Greed", "Disenchant"}; + const char* rollName = (rollType < 3) ? rollNames[rollType] : "Roll"; + + std::string winnerName; + auto entity = entityManager.getEntity(winnerGuid); + if (auto* unit = dynamic_cast(entity.get())) { + winnerName = unit->getName(); + } + if (winnerName.empty()) { + winnerName = (winnerGuid == playerGuid) ? "You" : "Someone"; + } + + auto* info = getItemInfo(itemId); + std::string iName = info ? info->name : std::to_string(itemId); + + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s wins [%s] (%s %d)!", + winnerName.c_str(), iName.c_str(), rollName, static_cast(rollNum)); + addSystemChatMessage(buf); + + // Clear pending roll if it was ours + if (pendingLootRollActive_ && winnerGuid == playerGuid) { + pendingLootRollActive_ = false; + } + LOG_INFO("SMSG_LOOT_ROLL_WON: winner=", winnerName, " item=", itemId, + " roll=", rollName, "(", rollNum, ")"); +} + +void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType) { + if (state != WorldState::IN_WORLD || !socket) return; + pendingLootRollActive_ = false; + + network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_ROLL)); + pkt.writeUInt64(objectGuid); + pkt.writeUInt32(slot); + pkt.writeUInt8(rollType); + socket->send(pkt); + + const char* rollNames[] = {"Need", "Greed", "Disenchant", "Pass"}; + const char* rName = (rollType < 3) ? rollNames[rollType] : "Pass"; + LOG_INFO("CMSG_LOOT_ROLL: type=", rName, " item=", pendingLootRoll_.itemName); +} + +// --------------------------------------------------------------------------- +// SMSG_ACHIEVEMENT_EARNED (WotLK 3.3.5a wire 0x4AB) +// uint64 guid — player who earned it (may be another player) +// uint32 achievementId — Achievement.dbc ID +// PackedTime date — uint32 bitfield (seconds since epoch) +// uint32 realmFirst — how many on realm also got it (0 = realm first) +// --------------------------------------------------------------------------- +void GameHandler::handleAchievementEarned(network::Packet& packet) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 16) return; // guid(8) + id(4) + date(4) + + uint64_t guid = packet.readUInt64(); + uint32_t achievementId = packet.readUInt32(); + /*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed + + // Show chat notification + bool isSelf = (guid == playerGuid); + if (isSelf) { + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Achievement earned! (ID %u)", achievementId); + addSystemChatMessage(buf); + + if (achievementEarnedCallback_) { + achievementEarnedCallback_(achievementId); + } + } else { + // Another player in the zone earned an achievement + std::string senderName; + auto entity = entityManager.getEntity(guid); + if (auto* unit = dynamic_cast(entity.get())) { + senderName = unit->getName(); + } + if (senderName.empty()) { + char tmp[32]; + std::snprintf(tmp, sizeof(tmp), "0x%llX", + static_cast(guid)); + senderName = tmp; + } + char buf[256]; + std::snprintf(buf, sizeof(buf), + "%s has earned an achievement! (ID %u)", senderName.c_str(), achievementId); + addSystemChatMessage(buf); + } + + LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec, + " achievementId=", achievementId, " self=", isSelf); +} + +// --------------------------------------------------------------------------- +// Faction name cache (lazily loaded from Faction.dbc) +// --------------------------------------------------------------------------- + +void GameHandler::loadFactionNameCache() { + if (factionNameCacheLoaded_) return; + factionNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Faction.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + // Faction.dbc WotLK 3.3.5a field layout: + // 0: ID + // 1-4: ReputationRaceMask[4] + // 5-8: ReputationClassMask[4] + // 9-12: ReputationBase[4] + // 13-16: ReputationFlags[4] + // 17: ParentFactionID + // 18-19: Spillover rates (floats) + // 20-21: MaxRank + // 22: Name (English locale, string ref) + constexpr uint32_t ID_FIELD = 0; + constexpr uint32_t NAME_FIELD = 22; // enUS name string + + if (dbc->getFieldCount() <= NAME_FIELD) { + LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount()); + return; + } + + uint32_t count = dbc->getRecordCount(); + for (uint32_t i = 0; i < count; ++i) { + uint32_t factionId = dbc->getUInt32(i, ID_FIELD); + if (factionId == 0) continue; + std::string name = dbc->getString(i, NAME_FIELD); + if (!name.empty()) { + factionNameCache_[factionId] = std::move(name); + } + } + LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names"); +} + +std::string GameHandler::getFactionName(uint32_t factionId) const { + auto it = factionNameCache_.find(factionId); + if (it != factionNameCache_.end()) return it->second; + return "faction #" + std::to_string(factionId); +} + +const std::string& GameHandler::getFactionNamePublic(uint32_t factionId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionNameCache_.find(factionId); + if (it != factionNameCache_.end()) return it->second; + static const std::string empty; + return empty; +} + +// --------------------------------------------------------------------------- +// Aura duration update +// --------------------------------------------------------------------------- + +void GameHandler::handleUpdateAuraDuration(uint8_t slot, uint32_t durationMs) { + if (slot >= playerAuras.size()) return; + if (playerAuras[slot].isEmpty()) return; + playerAuras[slot].durationMs = static_cast(durationMs); + playerAuras[slot].receivedAtMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +// --------------------------------------------------------------------------- +// Equipment set list +// --------------------------------------------------------------------------- + +void GameHandler::handleEquipmentSetList(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t count = packet.readUInt32(); + if (count > 10) { + LOG_WARNING("SMSG_EQUIPMENT_SET_LIST: unexpected count ", count, ", ignoring"); + packet.setReadPos(packet.getSize()); + return; + } + equipmentSets_.clear(); + equipmentSets_.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 16) break; + EquipmentSet es; + es.setGuid = packet.readUInt64(); + es.setId = packet.readUInt32(); + es.name = packet.readString(); + es.iconName = packet.readString(); + es.ignoreSlotMask = packet.readUInt32(); + for (int slot = 0; slot < 19; ++slot) { + if (packet.getSize() - packet.getReadPos() < 8) break; + es.itemGuids[slot] = packet.readUInt64(); + } + equipmentSets_.push_back(std::move(es)); + } + LOG_INFO("SMSG_EQUIPMENT_SET_LIST: ", equipmentSets_.size(), " equipment sets received"); +} + +// --------------------------------------------------------------------------- +// Forced faction reactions +// --------------------------------------------------------------------------- + +void GameHandler::handleSetForcedReactions(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t count = packet.readUInt32(); + if (count > 64) { + LOG_WARNING("SMSG_SET_FORCED_REACTIONS: suspicious count ", count, ", ignoring"); + packet.setReadPos(packet.getSize()); + return; + } + forcedReactions_.clear(); + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t factionId = packet.readUInt32(); + uint32_t reaction = packet.readUInt32(); + forcedReactions_[factionId] = static_cast(reaction); + } + LOG_INFO("SMSG_SET_FORCED_REACTIONS: ", forcedReactions_.size(), " faction overrides"); +} + } // namespace game } // namespace wowee diff --git a/src/game/world.cpp b/src/game/world.cpp index d02f8dc8..eed3c43e 100644 --- a/src/game/world.cpp +++ b/src/game/world.cpp @@ -4,11 +4,14 @@ namespace wowee { namespace game { void World::update([[maybe_unused]] float deltaTime) { - // TODO: Update world state + // World state updates are handled by Application (terrain streaming, entity sync, + // camera, etc.) and GameHandler (server packet processing). World is a thin + // ownership token; per-frame logic lives in those subsystems. } void World::loadMap([[maybe_unused]] uint32_t mapId) { - // TODO: Load map data + // Terrain loading is driven by Application::loadOnlineWorld() via TerrainManager. + // This method exists as an extension point; no action needed here. } } // namespace game diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index bb66e416..c83563f0 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2083,6 +2083,12 @@ network::Packet ReadyCheckConfirmPacket::build(bool ready) { // Duel // ============================================================ +network::Packet DuelAcceptPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_DUEL_ACCEPTED)); + LOG_DEBUG("Built CMSG_DUEL_ACCEPTED"); + return packet; +} + network::Packet DuelCancelPacket::build() { network::Packet packet(wireOpcode(Opcode::CMSG_DUEL_CANCELLED)); LOG_DEBUG("Built CMSG_DUEL_CANCELLED"); @@ -2125,14 +2131,31 @@ network::Packet RequestRaidInfoPacket::build() { // ============================================================ network::Packet DuelProposedPacket::build(uint64_t targetGuid) { - // TODO: Duels are initiated via CMSG_CAST_SPELL with spell 7266, - // not a dedicated CMSG_DUEL_PROPOSED opcode (which doesn't exist in WoW). - // For now, build a cast spell packet targeting the opponent. + // Duels are initiated via CMSG_CAST_SPELL with spell 7266 (Duel) targeted at the opponent. + // There is no separate CMSG_DUEL_PROPOSED opcode in WoW. auto packet = CastSpellPacket::build(7266, targetGuid, 0); LOG_DEBUG("Built duel request (spell 7266) for target: 0x", std::hex, targetGuid, std::dec); return packet; } +network::Packet BeginTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_BEGIN_TRADE)); + LOG_DEBUG("Built CMSG_BEGIN_TRADE"); + return packet; +} + +network::Packet CancelTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_TRADE)); + LOG_DEBUG("Built CMSG_CANCEL_TRADE"); + return packet; +} + +network::Packet AcceptTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_ACCEPT_TRADE)); + LOG_DEBUG("Built CMSG_ACCEPT_TRADE"); + return packet; +} + network::Packet InitiateTradePacket::build(uint64_t targetGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE)); packet.writeUInt64(targetGuid); diff --git a/src/game/zone_manager.cpp b/src/game/zone_manager.cpp index de7d2bfa..10921baf 100644 --- a/src/game/zone_manager.cpp +++ b/src/game/zone_manager.cpp @@ -1,4 +1,5 @@ #include "game/zone_manager.hpp" +#include "pipeline/asset_manager.hpp" #include "core/logger.hpp" #include #include @@ -479,5 +480,88 @@ std::vector ZoneManager::getAllMusicPaths() const { return out; } +void ZoneManager::enrichFromDBC(pipeline::AssetManager* assets) { + if (!assets) return; + + auto areaDbc = assets->loadDBC("AreaTable.dbc"); + auto zoneMusicDbc = assets->loadDBC("ZoneMusic.dbc"); + auto soundDbc = assets->loadDBC("SoundEntries.dbc"); + + if (!areaDbc || !areaDbc->isLoaded()) { + LOG_WARNING("ZoneManager::enrichFromDBC: AreaTable.dbc not available"); + return; + } + if (!zoneMusicDbc || !zoneMusicDbc->isLoaded()) { + LOG_WARNING("ZoneManager::enrichFromDBC: ZoneMusic.dbc not available"); + return; + } + if (!soundDbc || !soundDbc->isLoaded()) { + LOG_WARNING("ZoneManager::enrichFromDBC: SoundEntries.dbc not available"); + return; + } + + // Build MPQ paths from a SoundEntries record. + // Fields 3-12 = File[0..9], field 23 = DirectoryBase. + auto getSoundPaths = [&](uint32_t soundId) -> std::vector { + if (soundId == 0) return {}; + int32_t idx = soundDbc->findRecordById(soundId); + if (idx < 0) return {}; + uint32_t row = static_cast(idx); + if (soundDbc->getFieldCount() < 24) return {}; + std::string dir = soundDbc->getString(row, 23); + std::vector paths; + for (uint32_t f = 3; f <= 12; ++f) { + std::string name = soundDbc->getString(row, f); + if (name.empty()) continue; + paths.push_back(dir.empty() ? name : dir + "\\" + name); + } + return paths; + }; + + const uint32_t numAreas = areaDbc->getRecordCount(); + const uint32_t areaFields = areaDbc->getFieldCount(); + if (areaFields < 9) { + LOG_WARNING("ZoneManager::enrichFromDBC: AreaTable.dbc has too few fields (", areaFields, ")"); + return; + } + + uint32_t zonesEnriched = 0; + for (uint32_t i = 0; i < numAreas; ++i) { + uint32_t zoneId = areaDbc->getUInt32(i, 0); + uint32_t zoneMusicId = areaDbc->getUInt32(i, 8); + if (zoneId == 0 || zoneMusicId == 0) continue; + + int32_t zmIdx = zoneMusicDbc->findRecordById(zoneMusicId); + if (zmIdx < 0) continue; + uint32_t zmRow = static_cast(zmIdx); + if (zoneMusicDbc->getFieldCount() < 8) continue; + + uint32_t daySoundId = zoneMusicDbc->getUInt32(zmRow, 6); + uint32_t nightSoundId = zoneMusicDbc->getUInt32(zmRow, 7); + + std::vector newPaths; + for (const auto& p : getSoundPaths(daySoundId)) newPaths.push_back(p); + for (const auto& p : getSoundPaths(nightSoundId)) newPaths.push_back(p); + if (newPaths.empty()) continue; + + auto& zone = zones[zoneId]; + if (zone.id == 0) zone.id = zoneId; + + // Append paths not already present (preserve hardcoded entries). + for (const auto& path : newPaths) { + bool found = false; + for (const auto& existing : zone.musicPaths) { + if (existing == path) { found = true; break; } + } + if (!found) { + zone.musicPaths.push_back(path); + ++zonesEnriched; + } + } + } + + LOG_INFO("Zone music enriched from DBC: ", zones.size(), " zones, ", zonesEnriched, " paths added"); +} + } // namespace game } // namespace wowee diff --git a/src/pipeline/mpq_manager.cpp b/src/pipeline/mpq_manager.cpp index 65f74239..98a984b1 100644 --- a/src/pipeline/mpq_manager.cpp +++ b/src/pipeline/mpq_manager.cpp @@ -110,8 +110,34 @@ bool MPQManager::initialize(const std::string& dataPath_) { // Load patch archives (highest priority) loadPatchArchives(); - // Load locale archives - loadLocaleArchives("enUS"); // TODO: Make configurable + // Load locale archives — auto-detect from available locale directories + { + // Prefer the locale override from environment, then scan for installed ones + const char* localeEnv = std::getenv("WOWEE_LOCALE"); + std::string detectedLocale; + if (localeEnv && localeEnv[0] != '\0') { + detectedLocale = localeEnv; + LOG_INFO("Using locale from WOWEE_LOCALE env: ", detectedLocale); + } else { + // Priority order: enUS first, then other common locales + static const std::array knownLocales = { + "enUS", "enGB", "deDE", "frFR", "esES", "esMX", + "zhCN", "zhTW", "koKR", "ruRU", "ptBR", "itIT" + }; + for (const char* loc : knownLocales) { + if (std::filesystem::exists(dataPath + "/" + loc)) { + detectedLocale = loc; + LOG_INFO("Auto-detected WoW locale: ", detectedLocale); + break; + } + } + if (detectedLocale.empty()) { + detectedLocale = "enUS"; + LOG_WARNING("No locale directory found in data path; defaulting to enUS"); + } + } + loadLocaleArchives(detectedLocale); + } if (archives.empty()) { LOG_WARNING("No MPQ archives loaded - will use loose file fallback"); diff --git a/src/rendering/amd_fsr3_runtime.cpp b/src/rendering/amd_fsr3_runtime.cpp index 773935b8..e7606fb6 100644 --- a/src/rendering/amd_fsr3_runtime.cpp +++ b/src/rendering/amd_fsr3_runtime.cpp @@ -17,7 +17,11 @@ #if WOWEE_HAS_AMD_FSR3_FRAMEGEN #include "third_party/ffx_fsr3_legacy_compat.h" +#include +#include +#include #include +#include #endif namespace wowee::rendering { @@ -34,6 +38,10 @@ struct AmdFsr3Runtime::RuntimeFns { decltype(&ffxFsr3ConfigureFrameGeneration) fsr3ConfigureFrameGeneration = nullptr; decltype(&ffxFsr3DispatchFrameGeneration) fsr3DispatchFrameGeneration = nullptr; decltype(&ffxFsr3ContextDestroy) fsr3ContextDestroy = nullptr; + PfnFfxCreateContext createContext = nullptr; + PfnFfxDestroyContext destroyContext = nullptr; + PfnFfxConfigure configure = nullptr; + PfnFfxDispatch dispatch = nullptr; }; #else struct AmdFsr3Runtime::RuntimeFns {}; @@ -51,6 +59,43 @@ FfxErrorCode vkSwapchainConfigureNoop(const FfxFrameGenerationConfig*) { return FFX_OK; } +std::string narrowWString(const wchar_t* msg) { + if (!msg) return {}; + std::string out; + for (const wchar_t* p = msg; *p; ++p) { + const wchar_t wc = *p; + if (wc >= 0 && wc <= 0x7f) { + out.push_back(static_cast(wc)); + } else { + out.push_back('?'); + } + } + return out; +} + +void ffxApiLogMessage(uint32_t type, const wchar_t* message) { + const std::string narrowed = narrowWString(message); + if (type == FFX_API_MESSAGE_TYPE_ERROR) { + LOG_ERROR("FSR3 runtime/API: ", narrowed); + } else { + LOG_WARNING("FSR3 runtime/API: ", narrowed); + } +} + +const char* ffxApiReturnCodeName(ffxReturnCode_t rc) { + switch (rc) { + case FFX_API_RETURN_OK: return "OK"; + case FFX_API_RETURN_ERROR: return "ERROR"; + case FFX_API_RETURN_ERROR_UNKNOWN_DESCTYPE: return "ERROR_UNKNOWN_DESCTYPE"; + case FFX_API_RETURN_ERROR_RUNTIME_ERROR: return "ERROR_RUNTIME_ERROR"; + case FFX_API_RETURN_NO_PROVIDER: return "NO_PROVIDER"; + case FFX_API_RETURN_ERROR_MEMORY: return "ERROR_MEMORY"; + case FFX_API_RETURN_ERROR_PARAMETER: return "ERROR_PARAMETER"; + case FFX_API_RETURN_PROVIDER_NO_SUPPORT_NEW_DESCTYPE: return "PROVIDER_NO_SUPPORT_NEW_DESCTYPE"; + default: return "UNKNOWN"; + } +} + template struct HasUpscaleOutputSize : std::false_type {}; @@ -141,6 +186,7 @@ FfxResourceDescription makeResourceDescription(VkFormat format, description.usage = usage; return description; } + } // namespace #endif @@ -184,23 +230,36 @@ bool AmdFsr3Runtime::initialize(const AmdFsr3RuntimeInitDesc& desc) { candidates.emplace_back("libffx_fsr3.so"); #endif + std::string lastDlopenError; for (const std::string& path : candidates) { #if defined(_WIN32) HMODULE h = LoadLibraryA(path.c_str()); if (!h) continue; libHandle_ = reinterpret_cast(h); #else + dlerror(); void* h = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); - if (!h) continue; + if (!h) { + const char* err = dlerror(); + if (err && *err) lastDlopenError = err; + continue; + } libHandle_ = h; #endif loadedLibraryPath_ = path; loadPathKind_ = LoadPathKind::Official; + LOG_INFO("FSR3 runtime: opened library candidate ", loadedLibraryPath_); break; } if (!libHandle_) { lastError_ = "no official runtime (Path A) found"; +#if !defined(_WIN32) + if (!lastDlopenError.empty()) { + lastError_ += " dlopen error: "; + lastError_ += lastDlopenError; + } +#endif return false; } @@ -223,16 +282,127 @@ bool AmdFsr3Runtime::initialize(const AmdFsr3RuntimeInitDesc& desc) { fns_->fsr3ConfigureFrameGeneration = reinterpret_castfsr3ConfigureFrameGeneration)>(resolveSym("ffxFsr3ConfigureFrameGeneration")); fns_->fsr3DispatchFrameGeneration = reinterpret_castfsr3DispatchFrameGeneration)>(resolveSym("ffxFsr3DispatchFrameGeneration")); fns_->fsr3ContextDestroy = reinterpret_castfsr3ContextDestroy)>(resolveSym("ffxFsr3ContextDestroy")); + fns_->createContext = reinterpret_castcreateContext)>(resolveSym("ffxCreateContext")); + fns_->destroyContext = reinterpret_castdestroyContext)>(resolveSym("ffxDestroyContext")); + fns_->configure = reinterpret_castconfigure)>(resolveSym("ffxConfigure")); + fns_->dispatch = reinterpret_castdispatch)>(resolveSym("ffxDispatch")); - if (!fns_->getScratchMemorySizeVK || !fns_->getDeviceVK || !fns_->getInterfaceVK || - !fns_->getCommandListVK || !fns_->getResourceVK || !fns_->fsr3ContextCreate || !fns_->fsr3ContextDispatchUpscale || - !fns_->fsr3ContextDestroy) { - LOG_WARNING("FSR3 runtime: required symbols not found in ", loadedLibraryPath_); - lastError_ = "missing required Vulkan FSR3 symbols in runtime library"; + const bool hasLegacyApi = (fns_->getScratchMemorySizeVK && fns_->getDeviceVK && fns_->getInterfaceVK && + fns_->getCommandListVK && fns_->getResourceVK && fns_->fsr3ContextCreate && + fns_->fsr3ContextDispatchUpscale && fns_->fsr3ContextDestroy); + const bool hasGenericApi = (fns_->createContext && fns_->destroyContext && fns_->configure && fns_->dispatch); + + if (!hasLegacyApi && !hasGenericApi) { + LOG_WARNING("FSR3 runtime: required symbols not found in ", loadedLibraryPath_, + " (need legacy ffxFsr3* or generic ffxCreateContext/ffxDispatch)"); + lastError_ = "missing required Vulkan FSR3 symbols in runtime library (legacy and generic APIs unavailable)"; shutdown(); return false; } + apiMode_ = hasLegacyApi ? ApiMode::LegacyFsr3 : ApiMode::GenericApi; + if (apiMode_ == ApiMode::GenericApi) { + ffxConfigureDescGlobalDebug1 globalDebug{}; + globalDebug.header.type = FFX_API_CONFIGURE_DESC_TYPE_GLOBALDEBUG1; + globalDebug.header.pNext = nullptr; + globalDebug.fpMessage = &ffxApiLogMessage; + globalDebug.debugLevel = FFX_API_CONFIGURE_GLOBALDEBUG_LEVEL_VERBOSE; + (void)fns_->configure(nullptr, reinterpret_cast(&globalDebug)); + + ffxCreateBackendVKDesc backendDesc{}; + backendDesc.header.type = FFX_API_CREATE_CONTEXT_DESC_TYPE_BACKEND_VK; + backendDesc.header.pNext = nullptr; + backendDesc.vkDevice = desc.device; + backendDesc.vkPhysicalDevice = desc.physicalDevice; + + ffxCreateContextDescUpscaleVersion upVerDesc{}; + upVerDesc.header.type = FFX_API_CREATE_CONTEXT_DESC_TYPE_UPSCALE_VERSION; + upVerDesc.header.pNext = nullptr; + upVerDesc.version = FFX_UPSCALER_VERSION; + + ffxCreateContextDescUpscale upDesc{}; + upDesc.header.type = FFX_API_CREATE_CONTEXT_DESC_TYPE_UPSCALE; + upDesc.header.pNext = reinterpret_cast(&backendDesc); + upDesc.flags = FFX_UPSCALE_ENABLE_AUTO_EXPOSURE | FFX_UPSCALE_ENABLE_MOTION_VECTORS_JITTER_CANCELLATION; + upDesc.flags |= FFX_UPSCALE_ENABLE_DEBUG_CHECKING; + if (desc.hdrInput) upDesc.flags |= FFX_UPSCALE_ENABLE_HIGH_DYNAMIC_RANGE; + if (desc.depthInverted) upDesc.flags |= FFX_UPSCALE_ENABLE_DEPTH_INVERTED; + upDesc.maxRenderSize.width = desc.maxRenderWidth; + upDesc.maxRenderSize.height = desc.maxRenderHeight; + upDesc.maxUpscaleSize.width = desc.displayWidth; + upDesc.maxUpscaleSize.height = desc.displayHeight; + upDesc.fpMessage = &ffxApiLogMessage; + backendDesc.header.pNext = reinterpret_cast(&upVerDesc); + + ffxContext upscaleCtx = nullptr; + const ffxReturnCode_t upCreateRc = + fns_->createContext(&upscaleCtx, reinterpret_cast(&upDesc), nullptr); + if (upCreateRc != FFX_API_RETURN_OK) { + const std::string loadedPath = loadedLibraryPath_; + lastError_ = "ffxCreateContext (upscale) failed rc=" + std::to_string(upCreateRc) + + " (" + ffxApiReturnCodeName(upCreateRc) + "), runtimeLib=" + loadedPath; + shutdown(); + return false; + } + genericUpscaleContext_ = upscaleCtx; + backendDesc.header.pNext = nullptr; + + if (desc.enableFrameGeneration) { + ffxCreateContextDescFrameGenerationVersion fgVerDesc{}; + fgVerDesc.header.type = FFX_API_CREATE_CONTEXT_DESC_TYPE_FRAMEGENERATION_VERSION; + fgVerDesc.header.pNext = nullptr; + fgVerDesc.version = FFX_FRAMEGENERATION_VERSION; + + ffxCreateContextDescFrameGeneration fgDesc{}; + fgDesc.header.type = FFX_API_CREATE_CONTEXT_DESC_TYPE_FRAMEGENERATION; + fgDesc.header.pNext = reinterpret_cast(&backendDesc); + fgDesc.flags = FFX_FRAMEGENERATION_ENABLE_MOTION_VECTORS_JITTER_CANCELLATION; + fgDesc.flags |= FFX_FRAMEGENERATION_ENABLE_DEBUG_CHECKING; + if (desc.hdrInput) fgDesc.flags |= FFX_FRAMEGENERATION_ENABLE_HIGH_DYNAMIC_RANGE; + if (desc.depthInverted) fgDesc.flags |= FFX_FRAMEGENERATION_ENABLE_DEPTH_INVERTED; + fgDesc.displaySize.width = desc.displayWidth; + fgDesc.displaySize.height = desc.displayHeight; + fgDesc.maxRenderSize.width = desc.maxRenderWidth; + fgDesc.maxRenderSize.height = desc.maxRenderHeight; + fgDesc.backBufferFormat = ffxApiGetSurfaceFormatVK(desc.colorFormat); + backendDesc.header.pNext = reinterpret_cast(&fgVerDesc); + + ffxContext fgCtx = nullptr; + const ffxReturnCode_t fgCreateRc = + fns_->createContext(&fgCtx, reinterpret_cast(&fgDesc), nullptr); + if (fgCreateRc != FFX_API_RETURN_OK) { + const std::string loadedPath = loadedLibraryPath_; + lastError_ = "ffxCreateContext (framegeneration) failed rc=" + std::to_string(fgCreateRc) + + " (" + ffxApiReturnCodeName(fgCreateRc) + "), runtimeLib=" + loadedPath; + shutdown(); + return false; + } + genericFramegenContext_ = fgCtx; + backendDesc.header.pNext = nullptr; + + ffxConfigureDescFrameGeneration fgCfg{}; + fgCfg.header.type = FFX_API_CONFIGURE_DESC_TYPE_FRAMEGENERATION; + fgCfg.header.pNext = nullptr; + fgCfg.frameGenerationEnabled = true; + fgCfg.allowAsyncWorkloads = false; + fgCfg.flags = FFX_FRAMEGENERATION_FLAG_NO_SWAPCHAIN_CONTEXT_NOTIFY; + fgCfg.onlyPresentGenerated = false; + fgCfg.frameID = genericFrameId_; + if (fns_->configure(reinterpret_cast(&genericFramegenContext_), + reinterpret_cast(&fgCfg)) != FFX_API_RETURN_OK) { + lastError_ = "ffxConfigure (framegeneration) failed"; + shutdown(); + return false; + } + frameGenerationReady_ = true; + } + + ready_ = true; + LOG_INFO("FSR3 runtime: loaded generic API from ", loadedLibraryPath_, + " framegenReady=", frameGenerationReady_ ? "yes" : "no"); + return true; + } + scratchBufferSize_ = fns_->getScratchMemorySizeVK(FFX_FSR3_CONTEXT_COUNT); if (scratchBufferSize_ == 0) { LOG_WARNING("FSR3 runtime: scratch buffer size query returned 0."); @@ -344,6 +514,49 @@ bool AmdFsr3Runtime::dispatchUpscale(const AmdFsr3RuntimeDispatchDesc& desc) { lastError_ = "invalid upscale dispatch resources"; return false; } + if (apiMode_ == ApiMode::GenericApi) { + if (!genericUpscaleContext_ || !fns_->dispatch) { + lastError_ = "generic API upscale context unavailable"; + return false; + } + ffxDispatchDescUpscale up{}; + up.header.type = FFX_API_DISPATCH_DESC_TYPE_UPSCALE; + up.header.pNext = nullptr; + up.commandList = reinterpret_cast(desc.commandBuffer); + up.color = ffxApiGetResourceVK(desc.colorImage, desc.colorFormat, desc.renderWidth, desc.renderHeight, FFX_API_RESOURCE_STATE_COMPUTE_READ); + up.depth = ffxApiGetResourceVK(desc.depthImage, desc.depthFormat, desc.renderWidth, desc.renderHeight, FFX_API_RESOURCE_STATE_COMPUTE_READ); + up.motionVectors = ffxApiGetResourceVK(desc.motionVectorImage, desc.motionVectorFormat, desc.renderWidth, desc.renderHeight, FFX_API_RESOURCE_STATE_COMPUTE_READ); + up.exposure = FfxApiResource{}; + up.reactive = FfxApiResource{}; + up.transparencyAndComposition = FfxApiResource{}; + up.output = ffxApiGetResourceVK(desc.outputImage, desc.outputFormat, desc.outputWidth, desc.outputHeight, FFX_API_RESOURCE_STATE_UNORDERED_ACCESS); + up.jitterOffset.x = desc.jitterX; + up.jitterOffset.y = desc.jitterY; + up.motionVectorScale.x = desc.motionScaleX; + up.motionVectorScale.y = desc.motionScaleY; + up.renderSize.width = desc.renderWidth; + up.renderSize.height = desc.renderHeight; + up.upscaleSize.width = desc.outputWidth; + up.upscaleSize.height = desc.outputHeight; + up.enableSharpening = false; + up.sharpness = 0.0f; + up.frameTimeDelta = std::max(0.001f, desc.frameTimeDeltaMs); + up.preExposure = 1.0f; + up.reset = desc.reset; + up.cameraNear = desc.cameraNear; + up.cameraFar = desc.cameraFar; + up.cameraFovAngleVertical = desc.cameraFovYRadians; + up.viewSpaceToMetersFactor = 1.0f; + up.flags = 0; + if (fns_->dispatch(reinterpret_cast(&genericUpscaleContext_), + reinterpret_cast(&up)) != FFX_API_RETURN_OK) { + lastError_ = "ffxDispatch (upscale) failed"; + return false; + } + lastError_.clear(); + return true; + } + if (!contextStorage_ || !fns_->fsr3ContextDispatchUpscale) { lastError_ = "official runtime upscale context unavailable"; return false; @@ -413,6 +626,67 @@ bool AmdFsr3Runtime::dispatchFrameGeneration(const AmdFsr3RuntimeDispatchDesc& d lastError_ = "invalid frame generation dispatch resources"; return false; } + if (apiMode_ == ApiMode::GenericApi) { + if (!genericFramegenContext_ || !fns_->dispatch) { + lastError_ = "generic API frame generation context unavailable"; + return false; + } + ffxDispatchDescFrameGenerationPrepareV2 prep{}; + prep.header.type = FFX_API_DISPATCH_DESC_TYPE_FRAMEGENERATION_PREPARE_V2; + prep.header.pNext = nullptr; + prep.frameID = genericFrameId_; + prep.flags = 0; + prep.commandList = reinterpret_cast(desc.commandBuffer); + prep.renderSize.width = desc.renderWidth; + prep.renderSize.height = desc.renderHeight; + prep.jitterOffset.x = desc.jitterX; + prep.jitterOffset.y = desc.jitterY; + prep.motionVectorScale.x = desc.motionScaleX; + prep.motionVectorScale.y = desc.motionScaleY; + prep.frameTimeDelta = std::max(0.001f, desc.frameTimeDeltaMs); + prep.reset = desc.reset; + prep.cameraNear = desc.cameraNear; + prep.cameraFar = desc.cameraFar; + prep.cameraFovAngleVertical = desc.cameraFovYRadians; + prep.viewSpaceToMetersFactor = 1.0f; + prep.depth = ffxApiGetResourceVK(desc.depthImage, desc.depthFormat, desc.renderWidth, desc.renderHeight, FFX_API_RESOURCE_STATE_COMPUTE_READ); + prep.motionVectors = ffxApiGetResourceVK(desc.motionVectorImage, desc.motionVectorFormat, desc.renderWidth, desc.renderHeight, FFX_API_RESOURCE_STATE_COMPUTE_READ); + prep.cameraPosition[0] = prep.cameraPosition[1] = prep.cameraPosition[2] = 0.0f; + prep.cameraUp[0] = 0.0f; prep.cameraUp[1] = 1.0f; prep.cameraUp[2] = 0.0f; + prep.cameraRight[0] = 1.0f; prep.cameraRight[1] = 0.0f; prep.cameraRight[2] = 0.0f; + prep.cameraForward[0] = 0.0f; prep.cameraForward[1] = 0.0f; prep.cameraForward[2] = -1.0f; + if (fns_->dispatch(reinterpret_cast(&genericFramegenContext_), + reinterpret_cast(&prep)) != FFX_API_RETURN_OK) { + lastError_ = "ffxDispatch (framegeneration prepare) failed"; + return false; + } + + ffxDispatchDescFrameGeneration fg{}; + fg.header.type = FFX_API_DISPATCH_DESC_TYPE_FRAMEGENERATION; + fg.header.pNext = nullptr; + fg.commandList = reinterpret_cast(desc.commandBuffer); + fg.presentColor = ffxApiGetResourceVK(desc.outputImage, desc.outputFormat, desc.outputWidth, desc.outputHeight, FFX_API_RESOURCE_STATE_COMPUTE_READ); + fg.outputs[0] = ffxApiGetResourceVK(desc.frameGenOutputImage, desc.outputFormat, desc.outputWidth, desc.outputHeight, FFX_API_RESOURCE_STATE_UNORDERED_ACCESS); + fg.numGeneratedFrames = 1; + fg.reset = desc.reset; + fg.backbufferTransferFunction = FFX_API_BACKBUFFER_TRANSFER_FUNCTION_SRGB; + fg.minMaxLuminance[0] = 0.0f; + fg.minMaxLuminance[1] = 1.0f; + fg.generationRect.left = 0; + fg.generationRect.top = 0; + fg.generationRect.width = desc.outputWidth; + fg.generationRect.height = desc.outputHeight; + fg.frameID = genericFrameId_; + if (fns_->dispatch(reinterpret_cast(&genericFramegenContext_), + reinterpret_cast(&fg)) != FFX_API_RETURN_OK) { + lastError_ = "ffxDispatch (framegeneration) failed"; + return false; + } + ++genericFrameId_; + lastError_.clear(); + return true; + } + if (!contextStorage_ || !fns_->fsr3DispatchFrameGeneration) { lastError_ = "official runtime frame generation context unavailable"; return false; @@ -449,7 +723,18 @@ bool AmdFsr3Runtime::dispatchFrameGeneration(const AmdFsr3RuntimeDispatchDesc& d void AmdFsr3Runtime::shutdown() { #if WOWEE_HAS_AMD_FSR3_FRAMEGEN - if (contextStorage_ && fns_ && fns_->fsr3ContextDestroy) { + if (apiMode_ == ApiMode::GenericApi && fns_ && fns_->destroyContext) { + if (genericFramegenContext_) { + auto ctx = reinterpret_cast(&genericFramegenContext_); + fns_->destroyContext(ctx, nullptr); + genericFramegenContext_ = nullptr; + } + if (genericUpscaleContext_) { + auto ctx = reinterpret_cast(&genericUpscaleContext_); + fns_->destroyContext(ctx, nullptr); + genericUpscaleContext_ = nullptr; + } + } else if (contextStorage_ && fns_ && fns_->fsr3ContextDestroy) { fns_->fsr3ContextDestroy(reinterpret_cast(contextStorage_)); } #endif @@ -476,6 +761,8 @@ void AmdFsr3Runtime::shutdown() { libHandle_ = nullptr; loadedLibraryPath_.clear(); loadPathKind_ = LoadPathKind::None; + apiMode_ = ApiMode::LegacyFsr3; + genericFrameId_ = 1; } } // namespace wowee::rendering diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index eed9a025..a28e49a6 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -751,14 +751,22 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { void M2Renderer::destroyInstanceBones(M2Instance& inst) { if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); VmaAllocator alloc = vkCtx_->getAllocator(); for (int i = 0; i < 2; i++) { + // Free bone descriptor set so the pool slot is immediately reusable. + // Without this, the pool fills up over a play session as tiles stream + // in/out, eventually causing vkAllocateDescriptorSets to fail and + // making animated instances invisible (perceived as flickering). + if (inst.boneSet[i] != VK_NULL_HANDLE) { + vkFreeDescriptorSets(device, boneDescPool_, 1, &inst.boneSet[i]); + inst.boneSet[i] = VK_NULL_HANDLE; + } if (inst.boneBuffer[i]) { vmaDestroyBuffer(alloc, inst.boneBuffer[i], inst.boneAlloc[i]); inst.boneBuffer[i] = VK_NULL_HANDLE; inst.boneMapped[i] = nullptr; } - // boneSet freed when pool is reset/destroyed } } diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index c78c61c3..09430dce 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -201,7 +201,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { } } if (renderer->isFSR2Enabled()) { - ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "FSR 2.2: ON"); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "FSR 3 Upscale: ON"); ImGui::Text(" JitterSign=%.2f", renderer->getFSR2JitterSign()); const bool fgEnabled = renderer->isAmdFsr3FramegenEnabled(); const bool fgReady = renderer->isAmdFsr3FramegenRuntimeReady(); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index e53b261b..0fd4beb9 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -706,9 +706,12 @@ bool Renderer::initialize(core::Window* win) { lightingManager = std::make_unique(); [[maybe_unused]] auto* assetManager = core::Application::getInstance().getAssetManager(); - // Create zone manager + // Create zone manager; enrich music paths from DBC if available zoneManager = std::make_unique(); zoneManager->initialize(); + if (assetManager) { + zoneManager->enrichFromDBC(assetManager); + } // Initialize AudioEngine (singleton) if (!audio::AudioEngine::instance().initialize()) { @@ -727,9 +730,6 @@ bool Renderer::initialize(core::Window* win) { spellSoundManager = std::make_unique(); movementSoundManager = std::make_unique(); - // TODO Phase 6: Vulkan underwater overlay, post-process, and shadow map - // GL versions stubbed during migration - // Create secondary command buffer resources for multithreaded rendering if (!createSecondaryCommandResources()) { LOG_WARNING("Failed to create secondary command buffers — falling back to single-threaded rendering"); @@ -3037,9 +3037,12 @@ void Renderer::update(float deltaTime) { // Update zone detection and music if (zoneManager && musicManager && terrainManager && camera) { - // First check tile-based zone + // Prefer server-authoritative zone ID (from SMSG_INIT_WORLD_STATES); + // fall back to tile-based lookup for single-player / offline mode. + const auto* gh = core::Application::getInstance().getGameHandler(); + uint32_t serverZoneId = gh ? gh->getWorldStateZoneId() : 0; auto tile = terrainManager->getCurrentTile(); - uint32_t zoneId = zoneManager->getZoneId(tile.x, tile.y); + uint32_t zoneId = (serverZoneId != 0) ? serverZoneId : zoneManager->getZoneId(tile.x, tile.y); bool insideTavern = false; bool insideBlacksmith = false; @@ -3149,6 +3152,10 @@ void Renderer::update(float deltaTime) { } } } + // Update ambient sound manager zone type + if (ambientSoundManager) { + ambientSoundManager->setZoneId(zoneId); + } } musicManager->update(deltaTime); @@ -3899,7 +3906,9 @@ bool Renderer::initFSR2Resources() { fsr2_.amdFsr3RuntimePath = "Path C"; fsr2_.amdFsr3RuntimeLastError = fsr2_.amdFsr3Runtime->lastError(); LOG_WARNING("FSR3 framegen toggle is enabled, but runtime initialization failed. ", - "Set WOWEE_FFX_SDK_RUNTIME_LIB to the SDK runtime binary path."); + "path=", fsr2_.amdFsr3RuntimePath, + " error=", fsr2_.amdFsr3RuntimeLastError.empty() ? "(none)" : fsr2_.amdFsr3RuntimeLastError, + " runtimeLib=", fsr2_.amdFsr3Runtime->loadedLibraryPath().empty() ? "(not loaded)" : fsr2_.amdFsr3Runtime->loadedLibraryPath()); } } #endif @@ -4986,6 +4995,11 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s terrainRenderer.reset(); return false; } + if (shadowRenderPass != VK_NULL_HANDLE) { + terrainRenderer->initializeShadow(shadowRenderPass); + } + } else if (!terrainRenderer->hasShadowPipeline() && shadowRenderPass != VK_NULL_HANDLE) { + terrainRenderer->initializeShadow(shadowRenderPass); } // Create water renderer if not already created @@ -5154,6 +5168,11 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s } cachedAssetManager = assetManager; + + // Enrich zone music from DBC if not already done (e.g. asset manager was null at init). + if (zoneManager && assetManager) { + zoneManager->enrichFromDBC(assetManager); + } } // Snap camera to ground @@ -5707,6 +5726,9 @@ void Renderer::renderShadowPass() { // Phase 7/8: render shadow casters const float shadowCullRadius = shadowDistance_ * 1.35f; + if (terrainRenderer) { + terrainRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius); + } if (wmoRenderer) { wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius); } diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index fb20ce42..4e8593f5 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -314,6 +314,13 @@ void TerrainRenderer::shutdown() { if (materialDescPool) { vkDestroyDescriptorPool(device, materialDescPool, nullptr); materialDescPool = VK_NULL_HANDLE; } if (materialSetLayout) { vkDestroyDescriptorSetLayout(device, materialSetLayout, nullptr); materialSetLayout = VK_NULL_HANDLE; } + // Shadow pipeline cleanup + if (shadowPipeline_) { vkDestroyPipeline(device, shadowPipeline_, nullptr); shadowPipeline_ = VK_NULL_HANDLE; } + if (shadowPipelineLayout_) { vkDestroyPipelineLayout(device, shadowPipelineLayout_, nullptr); shadowPipelineLayout_ = VK_NULL_HANDLE; } + if (shadowParamsPool_) { vkDestroyDescriptorPool(device, shadowParamsPool_, nullptr); shadowParamsPool_ = VK_NULL_HANDLE; shadowParamsSet_ = VK_NULL_HANDLE; } + if (shadowParamsLayout_) { vkDestroyDescriptorSetLayout(device, shadowParamsLayout_, nullptr); shadowParamsLayout_ = VK_NULL_HANDLE; } + if (shadowParamsUBO_) { vmaDestroyBuffer(allocator, shadowParamsUBO_, shadowParamsAlloc_); shadowParamsUBO_ = VK_NULL_HANDLE; shadowParamsAlloc_ = VK_NULL_HANDLE; } + vkCtx = nullptr; } @@ -784,8 +791,198 @@ void TerrainRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, c } -void TerrainRenderer::renderShadow(VkCommandBuffer /*cmd*/, const glm::vec3& /*shadowCenter*/, float /*halfExtent*/) { - // Phase 6 stub +bool TerrainRenderer::initializeShadow(VkRenderPass shadowRenderPass) { + if (!vkCtx || shadowRenderPass == VK_NULL_HANDLE) return false; + if (shadowPipeline_ != VK_NULL_HANDLE) return true; // already initialised + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + // ShadowParams UBO — terrain uses no bones, no texture, no alpha test + struct ShadowParamsUBO { + int32_t useBones = 0; + int32_t useTexture = 0; + int32_t alphaTest = 0; + int32_t foliageSway = 0; + float windTime = 0.0f; + float foliageMotionDamp = 1.0f; + }; + + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(ShadowParamsUBO); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + if (vmaCreateBuffer(allocator, &bufCI, &allocCI, + &shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) { + LOG_ERROR("TerrainRenderer: failed to create shadow params UBO"); + return false; + } + ShadowParamsUBO defaultParams{}; + std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams)); + + // Descriptor set layout: binding 0 = combined sampler (unused), binding 1 = ShadowParams UBO + VkDescriptorSetLayoutBinding layoutBindings[2]{}; + layoutBindings[0].binding = 0; + layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + layoutBindings[0].descriptorCount = 1; + layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + layoutBindings[1].binding = 1; + layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + layoutBindings[1].descriptorCount = 1; + layoutBindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutCreateInfo layoutCI{}; + layoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutCI.bindingCount = 2; + layoutCI.pBindings = layoutBindings; + if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) { + LOG_ERROR("TerrainRenderer: failed to create shadow params set layout"); + return false; + } + + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[0].descriptorCount = 1; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[1].descriptorCount = 1; + VkDescriptorPoolCreateInfo poolCI{}; + poolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolCI.maxSets = 1; + poolCI.poolSizeCount = 2; + poolCI.pPoolSizes = poolSizes; + if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) { + LOG_ERROR("TerrainRenderer: failed to create shadow params pool"); + return false; + } + + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = shadowParamsPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) { + LOG_ERROR("TerrainRenderer: failed to allocate shadow params set"); + return false; + } + + // Write descriptors — sampler uses whiteTexture as dummy (useTexture=0 so never sampled) + VkDescriptorBufferInfo bufInfo{ shadowParamsUBO_, 0, sizeof(ShadowParamsUBO) }; + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = whiteTexture->getImageView(); + imgInfo.sampler = whiteTexture->getSampler(); + + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = shadowParamsSet_; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = shadowParamsSet_; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + + // Pipeline layout: set 0 = shadowParamsLayout_, push 128 bytes (lightSpaceMatrix + model) + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pc.offset = 0; + pc.size = 128; + shadowPipelineLayout_ = createPipelineLayout(device, {shadowParamsLayout_}, {pc}); + if (!shadowPipelineLayout_) { + LOG_ERROR("TerrainRenderer: failed to create shadow pipeline layout"); + return false; + } + + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/shadow.vert.spv")) { + LOG_ERROR("TerrainRenderer: failed to load shadow vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/shadow.frag.spv")) { + LOG_ERROR("TerrainRenderer: failed to load shadow fragment shader"); + vertShader.destroy(); + return false; + } + + // Terrain vertex layout: pos(0,off0) normal(1,off12) texCoord(2,off24) layerUV(3,off32) + // stride = sizeof(TerrainVertex) = 44 bytes + // Shadow shader expects: aPos(loc0), aTexCoord(loc1), aBoneWeights(loc2), aBoneIndicesF(loc3) + // Alias unused bone attrs to position (offset 0); useBones=0 so they are never read. + const uint32_t stride = static_cast(sizeof(pipeline::TerrainVertex)); + VkVertexInputBindingDescription vertBind{}; + vertBind.binding = 0; + vertBind.stride = stride; + vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + std::vector vertAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // aPos -> position + {1, 0, VK_FORMAT_R32G32_SFLOAT, 24}, // aTexCoord -> texCoord (unused) + {2, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 0}, // aBoneWeights -> position (unused) + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 0}, // aBoneIndices -> position (unused) + }; + + shadowPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, vertAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setDepthBias(0.05f, 0.20f) + .setNoColorAttachment() + .setLayout(shadowPipelineLayout_) + .setRenderPass(shadowRenderPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!shadowPipeline_) { + LOG_ERROR("TerrainRenderer: failed to create shadow pipeline"); + return false; + } + LOG_INFO("TerrainRenderer shadow pipeline initialized"); + return true; +} + +void TerrainRenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, + const glm::vec3& shadowCenter, float shadowRadius) { + if (!shadowPipeline_ || !shadowParamsSet_) return; + if (chunks.empty()) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &shadowParamsSet_, 0, nullptr); + + // Identity model matrix — terrain vertices are already in world space + static const glm::mat4 identity(1.0f); + struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; + ShadowPush push{ lightSpaceMatrix, identity }; + vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, + 0, 128, &push); + + for (const auto& chunk : chunks) { + if (!chunk.isValid()) continue; + + // Sphere-cull chunk against shadow region + glm::vec3 diff = chunk.boundingSphereCenter - shadowCenter; + float distSq = glm::dot(diff, diff); + float combinedRadius = shadowRadius + chunk.boundingSphereRadius; + if (distSq > combinedRadius * combinedRadius) continue; + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &chunk.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, chunk.indexBuffer, 0, VK_INDEX_TYPE_UINT16); + vkCmdDrawIndexed(cmd, chunk.indexCount, 1, 0, 0, 0); + } } void TerrainRenderer::removeTile(int tileX, int tileY) { diff --git a/src/rendering/texture.cpp b/src/rendering/texture.cpp deleted file mode 100644 index 769ba36e..00000000 --- a/src/rendering/texture.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "rendering/texture.hpp" -#include "core/logger.hpp" - -// Stub implementation - would use stb_image or similar -namespace wowee { -namespace rendering { - -Texture::~Texture() { - if (textureID) { - glDeleteTextures(1, &textureID); - } -} - -bool Texture::loadFromFile(const std::string& path) { - // TODO: Implement with stb_image or BLP loader - LOG_WARNING("Texture loading not yet implemented: ", path); - return false; -} - -bool Texture::loadFromMemory(const unsigned char* data, int w, int h, int channels) { - width = w; - height = h; - - glGenTextures(1, &textureID); - glBindTexture(GL_TEXTURE_2D, textureID); - - GLenum format = (channels == 4) ? GL_RGBA : GL_RGB; - glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data); - - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); - glBindTexture(GL_TEXTURE_2D, 0); - - return true; -} - -void Texture::bind(GLuint unit) const { - glActiveTexture(GL_TEXTURE0 + unit); - glBindTexture(GL_TEXTURE_2D, textureID); -} - -void Texture::unbind() const { - glBindTexture(GL_TEXTURE_2D, 0); -} - -void applyAnisotropicFiltering() { - static float maxAniso = -1.0f; - if (maxAniso < 0.0f) { - if (GLEW_EXT_texture_filter_anisotropic) { - glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &maxAniso); - if (maxAniso < 1.0f) maxAniso = 1.0f; - } else { - maxAniso = 0.0f; // Extension not available - } - } - if (maxAniso > 0.0f) { - float desired = 16.0f; - float clamped = (desired < maxAniso) ? desired : maxAniso; - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, clamped); - } -} - -} // namespace rendering -} // namespace wowee diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index a01cfedb..d79e53f7 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -942,13 +942,10 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu if (surface.origin.z > 2000.0f || surface.origin.z < -500.0f) return; - // Build tile mask from MLIQ flags and per-vertex heights + // Build tile mask from MLIQ flags size_t tileCount = static_cast(surface.width) * static_cast(surface.height); size_t maskBytes = (tileCount + 7) / 8; surface.mask.assign(maskBytes, 0x00); - const float baseZ = liquid.basePosition.z; - const bool hasHeights = !liquid.heights.empty() && - liquid.heights.size() >= static_cast(vertexCount); for (size_t t = 0; t < tileCount; t++) { bool hasLiquid = true; int tx = static_cast(t) % surface.width; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 318a4e23..06e6b4b5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -370,6 +370,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Player unit frame (top-left) renderPlayerFrame(gameHandler); + // Pet frame (below player frame, only when player has an active pet) + if (gameHandler.hasPet()) { + renderPetFrame(gameHandler); + } + // Target frame (only when we have a target) if (gameHandler.hasTarget()) { renderTargetFrame(gameHandler); @@ -393,10 +398,20 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBagBar(gameHandler); renderXpBar(gameHandler); renderCastBar(gameHandler); + renderMirrorTimers(gameHandler); + renderQuestObjectiveTracker(gameHandler); + if (showNameplates_) renderNameplates(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); renderGroupInvitePopup(gameHandler); + renderDuelRequestPopup(gameHandler); + renderLootRollPopup(gameHandler); + renderTradeRequestPopup(gameHandler); + renderSummonRequestPopup(gameHandler); + renderSharedQuestPopup(gameHandler); + renderItemTextWindow(gameHandler); renderGuildInvitePopup(gameHandler); + renderReadyCheckPopup(gameHandler); renderGuildRoster(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); @@ -412,6 +427,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderBankWindow(gameHandler); renderGuildBankWindow(gameHandler); renderAuctionHouseWindow(gameHandler); + renderDungeonFinderWindow(gameHandler); + renderInstanceLockouts(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now renderMinimapMarkers(gameHandler); renderDeathScreen(gameHandler); @@ -420,6 +437,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderEscapeMenu(); renderSettingsWindow(); renderDingEffect(); + renderAchievementToast(); + renderZoneText(); // World map (M key toggle handled inside) renderWorldMap(gameHandler); @@ -1384,6 +1403,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } + // V — toggle nameplates (WoW default keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_V)) { + showNameplates_ = !showNameplates_; + } + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -1768,16 +1792,20 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { uint8_t powerType = unit->getPowerType(); uint32_t power = unit->getPower(); uint32_t maxPower = unit->getMaxPower(); - // Rage (1) and Energy (3) always cap at 100 — show bar even if server - // hasn't sent UNIT_FIELD_MAXPOWER1 yet (warriors start combat at 0 rage). - if (maxPower == 0 && (powerType == 1 || powerType == 3)) maxPower = 100; + // Rage (1), Focus (2), Energy (3), and Runic Power (6) always cap at 100. + // Show bar even if server hasn't sent UNIT_FIELD_MAXPOWER1 yet. + if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3 || powerType == 6)) maxPower = 100; if (maxPower > 0) { float mpPct = static_cast(power) / static_cast(maxPower); ImVec4 powerColor; switch (powerType) { case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) + case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange) case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) + case 4: powerColor = ImVec4(0.5f, 0.9f, 0.3f, 1.0f); break; // Happiness (green) + case 6: powerColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; // Runic Power (crimson) + case 7: powerColor = ImVec4(0.4f, 0.1f, 0.6f, 1.0f); break; // Soul Shards (purple) default: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); @@ -1787,6 +1815,148 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } } + + // Death Knight rune bar (class 6) — 6 colored squares with fill fraction + if (gameHandler.getPlayerClass() == 6) { + const auto& runes = gameHandler.getPlayerRunes(); + float dt = ImGui::GetIO().DeltaTime; + + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + float spacing = 3.0f; + float squareW = (totalW - spacing * 5.0f) / 6.0f; + float squareH = 14.0f; + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < 6; i++) { + // Client-side prediction: advance fill over ~10s cooldown + runeClientFill_[i] = runes[i].ready ? 1.0f + : std::min(runeClientFill_[i] + dt / 10.0f, runes[i].readyFraction + 0.02f); + runeClientFill_[i] = std::clamp(runeClientFill_[i], 0.0f, runes[i].ready ? 1.0f : 0.97f); + + float x0 = cursor.x + i * (squareW + spacing); + float y0 = cursor.y; + float x1 = x0 + squareW; + float y1 = y0 + squareH; + + // Background (dark) + dl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), + IM_COL32(30, 30, 30, 200), 2.0f); + + // Fill color by rune type + ImVec4 fc; + switch (runes[i].type) { + case game::GameHandler::RuneType::Blood: fc = ImVec4(0.85f, 0.12f, 0.12f, 1.0f); break; + case game::GameHandler::RuneType::Unholy: fc = ImVec4(0.20f, 0.72f, 0.20f, 1.0f); break; + case game::GameHandler::RuneType::Frost: fc = ImVec4(0.30f, 0.55f, 0.90f, 1.0f); break; + case game::GameHandler::RuneType::Death: fc = ImVec4(0.55f, 0.20f, 0.70f, 1.0f); break; + default: fc = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); break; + } + float fillX = x0 + (x1 - x0) * runeClientFill_[i]; + dl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), + ImGui::ColorConvertFloat4ToU32(fc), 2.0f); + + // Border + ImU32 borderCol = runes[i].ready + ? IM_COL32(220, 220, 220, 180) + : IM_COL32(100, 100, 100, 160); + dl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); + } + ImGui::Dummy(ImVec2(totalW, squareH)); + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { + uint64_t petGuid = gameHandler.getPetGuid(); + if (petGuid == 0) return; + + auto petEntity = gameHandler.getEntityManager().getEntity(petGuid); + if (!petEntity) return; + auto* petUnit = dynamic_cast(petEntity.get()); + if (!petUnit) return; + + // Position below player frame. If in a group, push below party frames + // (party frame at y=120, each member ~50px, up to 4 members → max ~320px + y=120 = ~440). + // When not grouped, the player frame ends at ~110px so y=125 is fine. + const int partyMemberCount = gameHandler.isInGroup() + ? static_cast(gameHandler.getPartyData().members.size()) : 0; + float petY = (partyMemberCount > 0) + ? 120.0f + partyMemberCount * 52.0f + 8.0f + : 125.0f; + ImGui::SetNextWindowPos(ImVec2(10.0f, petY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.1f, 0.08f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); + + if (ImGui::Begin("##PetFrame", nullptr, flags)) { + const std::string& petName = petUnit->getName(); + uint32_t petLevel = petUnit->getLevel(); + + // Name + level on one row — clicking the pet name targets it + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.9f, 0.4f, 1.0f)); + char petLabel[96]; + snprintf(petLabel, sizeof(petLabel), "%s", + petName.empty() ? "Pet" : petName.c_str()); + if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) { + gameHandler.setTarget(petGuid); + } + ImGui::PopStyleColor(); + if (petLevel > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv %u", petLevel); + } + + // Health bar + uint32_t hp = petUnit->getHealth(); + uint32_t maxHp = petUnit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); + char hpText[32]; + snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); + ImGui::PopStyleColor(); + } + + // Power/mana bar (hunters' pets use focus) + uint8_t powerType = petUnit->getPowerType(); + uint32_t power = petUnit->getPower(); + uint32_t maxPower = petUnit->getMaxPower(); + if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3)) maxPower = 100; + if (maxPower > 0) { + float mpPct = static_cast(power) / static_cast(maxPower); + ImVec4 powerColor; + switch (powerType) { + case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana + case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage + case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (hunter pets) + case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy + default: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); + char mpText[32]; + snprintf(mpText, sizeof(mpText), "%u/%u", power, maxPower); + ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpText); + ImGui::PopStyleColor(); + } + + // Dismiss button (compact, right-aligned) + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::SmallButton("Dismiss")) { + gameHandler.dismissPet(); + } } ImGui::End(); @@ -1902,7 +2072,11 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { switch (targetPowerType) { case 0: targetPowerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) case 1: targetPowerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) + case 2: targetPowerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange) case 3: targetPowerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) + case 4: targetPowerColor = ImVec4(0.5f, 0.9f, 0.3f, 1.0f); break; // Happiness (green) + case 6: targetPowerColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; // Runic Power (crimson) + case 7: targetPowerColor = ImVec4(0.4f, 0.1f, 0.6f, 1.0f); break; // Soul Shards (purple) default: targetPowerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, targetPowerColor); @@ -1970,16 +2144,50 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Compute remaining once for overlay + tooltip + uint64_t tNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t tRemainMs = aura.getRemainingMs(tNowMs); + + // Duration countdown overlay + if (tRemainMs > 0) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + ImVec2 iconMax = ImGui::GetItemRectMax(); + char timeStr[12]; + int secs = (tRemainMs + 999) / 1000; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%d", secs); + ImVec2 textSize = ImGui::CalcTextSize(timeStr); + float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; + float cy = iconMax.y - textSize.y - 1.0f; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), + IM_COL32(0, 0, 0, 200), timeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), + IM_COL32(255, 255, 255, 255), timeStr); + } + + // Stack / charge count — upper-left corner + if (aura.charges > 1) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + // Tooltip if (ImGui::IsItemHovered()) { std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - int32_t remaining = aura.getRemainingMs(nowMs); - if (remaining > 0) { - int seconds = remaining / 1000; + if (tRemainMs > 0) { + int seconds = tRemainMs / 1000; if (seconds < 60) { ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); } else { @@ -1999,12 +2207,68 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(2); ImGui::PopStyleVar(); + + // ---- Target-of-Target (ToT) mini frame ---- + // Read target's current target from UNIT_FIELD_TARGET_LO/HI update fields + if (target) { + const auto& fields = target->getFields(); + uint64_t totGuid = 0; + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end()) { + totGuid = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + totGuid |= (static_cast(hiIt->second) << 32); + } + + if (totGuid != 0) { + auto totEntity = gameHandler.getEntityManager().getEntity(totGuid); + if (totEntity) { + // Position ToT frame just below and right-aligned with the target frame + float totW = 160.0f; + float totX = (screenW - totW) / 2.0f + (frameW - totW); + ImGui::SetNextWindowPos(ImVec2(totX, 30.0f + 130.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(totW, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.80f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 0.7f)); + + if (ImGui::Begin("##ToTFrame", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + std::string totName = getEntityName(totEntity); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "%s", totName.c_str()); + + if (totEntity->getType() == game::ObjectType::UNIT || + totEntity->getType() == game::ObjectType::PLAYER) { + auto totUnit = std::static_pointer_cast(totEntity); + uint32_t hp = totUnit->getHealth(); + uint32_t maxHp = totUnit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : + ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + ImGui::ProgressBar(pct, ImVec2(-1, 10), ""); + ImGui::PopStyleColor(); + } + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); + } + } + } } void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); - game::ChatType type; + game::ChatType type = game::ChatType::SAY; std::string message = input; std::string target; @@ -4168,6 +4432,147 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Mirror Timers (breath / fatigue / feign death) +// ============================================================ + +void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + static const struct { const char* label; ImVec4 color; } kTimerInfo[3] = { + { "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) }, + { "Breath", ImVec4(0.2f, 0.5f, 1.0f, 1.0f) }, + { "Feign", ImVec4(0.6f, 0.6f, 0.6f, 1.0f) }, + }; + + float barW = 280.0f; + float barH = 36.0f; + float barX = (screenW - barW) / 2.0f; + float baseY = screenH - 160.0f; // Just above the cast bar slot + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoInputs; + + for (int i = 0; i < 3; ++i) { + const auto& t = gameHandler.getMirrorTimer(i); + if (!t.active || t.maxValue <= 0) continue; + + float frac = static_cast(t.value) / static_cast(t.maxValue); + frac = std::max(0.0f, std::min(1.0f, frac)); + + char winId[32]; + std::snprintf(winId, sizeof(winId), "##MirrorTimer%d", i); + ImGui::SetNextWindowPos(ImVec2(barX, baseY - i * (barH + 4.0f)), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.88f)); + if (ImGui::Begin(winId, nullptr, flags)) { + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kTimerInfo[i].color); + char overlay[48]; + float sec = static_cast(t.value) / 1000.0f; + std::snprintf(overlay, sizeof(overlay), "%s %.0fs", kTimerInfo[i].label, sec); + ImGui::ProgressBar(frac, ImVec2(-1, 20), overlay); + ImGui::PopStyleColor(); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + } +} + +// ============================================================ +// Quest Objective Tracker (right-side HUD) +// ============================================================ + +void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { + const auto& questLog = gameHandler.getQuestLog(); + if (questLog.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + constexpr float TRACKER_W = 220.0f; + constexpr float RIGHT_MARGIN = 10.0f; + constexpr int MAX_QUESTS = 5; + + float x = screenW - TRACKER_W - RIGHT_MARGIN; + float y = 200.0f; // below minimap area + + ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##QuestTracker", nullptr, flags)) { + int shown = 0; + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (shown >= MAX_QUESTS) break; + + // Quest title in yellow (gold) if complete, white if in progress + ImVec4 titleCol = q.complete ? ImVec4(1.0f, 0.84f, 0.0f, 1.0f) + : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); + ImGui::TextColored(titleCol, "%s", q.title.c_str()); + + // Objectives line (condensed) + if (q.complete) { + ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), " (Complete)"); + } else { + // Kill counts + for (const auto& [entry, progress] : q.killCounts) { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %u/%u", progress.first, progress.second); + } + // Item counts + for (const auto& [itemId, count] : q.itemCounts) { + uint32_t required = 1; + auto reqIt = q.requiredItemCounts.find(itemId); + if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; + const auto* info = gameHandler.getItemInfo(itemId); + const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; + if (itemName) { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %s: %u/%u", itemName, count, required); + } else { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " Item: %u/%u", count, required); + } + } + if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) { + // Show the raw objectives text, truncated if needed + const std::string& obj = q.objectives; + if (obj.size() > 40) { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %.37s...", obj.c_str()); + } else { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %s", obj.c_str()); + } + } + } + + if (shown < MAX_QUESTS - 1 && shown < static_cast(questLog.size()) - 1) { + ImGui::Spacing(); + } + ++shown; + } + } + ImGui::End(); + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); +} + // ============================================================ // Floating Combat Text (Phase 2) // ============================================================ @@ -4238,6 +4643,33 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; + case game::CombatTextEntry::BLOCK: + 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::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: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental + break; + case game::CombatTextEntry::ENERGIZE: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 0.6f, 1.0f, alpha); // Blue for mana/energy + 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; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -4256,6 +4688,117 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { ImGui::End(); } +// ============================================================ +// Nameplates — world-space health bars projected to screen +// ============================================================ + +void GameScreen::renderNameplates(game::GameHandler& gameHandler) { + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + auto* appRenderer = core::Application::getInstance().getRenderer(); + if (!appRenderer) return; + rendering::Camera* camera = appRenderer->getCamera(); + if (!camera) return; + + auto* window = core::Application::getInstance().getWindow(); + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); + + const glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + const glm::vec3 camPos = camera->getPosition(); + const uint64_t playerGuid = gameHandler.getPlayerGuid(); + const uint64_t targetGuid = gameHandler.getTargetGuid(); + + ImDrawList* drawList = ImGui::GetBackgroundDrawList(); + + for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { + if (!entityPtr || guid == playerGuid) continue; + + auto* unit = dynamic_cast(entityPtr.get()); + if (!unit || unit->getMaxHealth() == 0) continue; + + // Only show nameplate for the currently targeted unit + if (guid != targetGuid) continue; + + // Convert canonical WoW position → render space, raise to head height + glm::vec3 renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + renderPos.z += 2.3f; + + // Cull if too far (render units ≈ WoW yards) + float dist = glm::length(renderPos - camPos); + if (dist > 40.0f) continue; + + // Project to clip space + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.01f) continue; // Behind camera + + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x < -1.2f || ndc.x > 1.2f || ndc.y < -1.2f || ndc.y > 1.2f) continue; + + // NDC → screen pixels. + // The camera bakes the Vulkan Y-flip into the projection matrix, so + // NDC y = -1 is the top of the screen and y = 1 is the bottom. + // Map directly: sy = (ndc.y + 1) / 2 * screenH (no extra inversion). + float sx = (ndc.x * 0.5f + 0.5f) * screenW; + float sy = (ndc.y * 0.5f + 0.5f) * screenH; + + // Fade out in the last 5 units of range + float alpha = dist < 35.0f ? 1.0f : 1.0f - (dist - 35.0f) / 5.0f; + auto A = [&](int v) { return static_cast(v * alpha); }; + + // Bar colour by hostility + ImU32 barColor, bgColor; + if (unit->isHostile()) { + barColor = IM_COL32(220, 60, 60, A(200)); + bgColor = IM_COL32(100, 25, 25, A(160)); + } else { + barColor = IM_COL32(60, 200, 80, A(200)); + bgColor = IM_COL32(25, 100, 35, A(160)); + } + ImU32 borderColor = (guid == targetGuid) + ? IM_COL32(255, 215, 0, A(255)) + : IM_COL32(20, 20, 20, A(180)); + + // Bar geometry + constexpr float barW = 80.0f; + constexpr float barH = 8.0f; + const float barX = sx - barW * 0.5f; + + float healthPct = std::clamp( + static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()), + 0.0f, 1.0f); + + drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f); + drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f); + drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + + // Name + level label above health bar + uint32_t level = unit->getLevel(); + char labelBuf[96]; + if (level > 0) { + uint32_t playerLevel = gameHandler.getPlayerLevel(); + // Show skull for units more than 10 levels above the player + if (playerLevel > 0 && level > playerLevel + 10) + snprintf(labelBuf, sizeof(labelBuf), "?? %s", unit->getName().c_str()); + else + snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unit->getName().c_str()); + } else { + snprintf(labelBuf, sizeof(labelBuf), "%s", unit->getName().c_str()); + } + ImVec2 textSize = ImGui::CalcTextSize(labelBuf); + float nameX = sx - textSize.x * 0.5f; + float nameY = sy - barH - 12.0f; + // Name color: hostile=red, non-hostile=yellow (WoW convention) + ImU32 nameColor = unit->isHostile() + ? IM_COL32(220, 80, 80, A(230)) + : IM_COL32(240, 200, 100, A(230)); + drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); + drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + } +} + // ============================================================ // Party Frames (Phase 4) // ============================================================ @@ -4328,7 +4871,11 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { switch (member.powerType) { case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) + case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange) case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) + case 4: powerColor = ImVec4(0.5f, 0.9f, 0.3f, 1.0f); break; // Happiness (green) + case 6: powerColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; // Runic Power (crimson) + case 7: powerColor = ImVec4(0.4f, 0.1f, 0.6f, 1.0f); break; // Soul Shards (purple) default: powerColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); break; } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); @@ -4374,6 +4921,195 @@ void GameScreen::renderGroupInvitePopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderDuelRequestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingDuelRequest()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 250), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + if (ImGui::Begin("Duel Request", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::Text("%s challenges you to a duel!", gameHandler.getDuelChallengerName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptDuel(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.forfeitDuel(); + } + } + ImGui::End(); +} + +void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isItemTextOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW * 0.5f - 200, screenH * 0.15f), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(400, 300), ImGuiCond_FirstUseEver); + + bool open = true; + if (!ImGui::Begin("Book", &open, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + if (!open) gameHandler.closeItemText(); + return; + } + if (!open) { + ImGui::End(); + gameHandler.closeItemText(); + return; + } + + // Parchment-toned background text + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.2f, 0.1f, 0.0f, 1.0f)); + ImGui::TextWrapped("%s", gameHandler.getItemText().c_str()); + ImGui::PopStyleColor(); + + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(80, 0))) { + gameHandler.closeItemText(); + } + + ImGui::End(); +} + +void GameScreen::renderSharedQuestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingSharedQuest()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 490), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Shared Quest", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::Text("%s has shared a quest with you:", gameHandler.getSharedQuestSharerName().c_str()); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "\"%s\"", gameHandler.getSharedQuestTitle().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptSharedQuest(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineSharedQuest(); + } + } + ImGui::End(); +} + +void GameScreen::renderSummonRequestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingSummonRequest()) return; + + // Tick the timeout down + float dt = ImGui::GetIO().DeltaTime; + gameHandler.tickSummonTimeout(dt); + if (!gameHandler.hasPendingSummonRequest()) return; // expired + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 430), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Summon Request", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::Text("%s is summoning you.", gameHandler.getSummonerName().c_str()); + float t = gameHandler.getSummonTimeoutSec(); + if (t > 0.0f) { + ImGui::Text("Time remaining: %.0fs", t); + } + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptSummon(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineSummon(); + } + } + ImGui::End(); +} + +void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingTradeRequest()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 150, 370), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Always); + + if (ImGui::Begin("Trade Request", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::Text("%s wants to trade with you.", gameHandler.getTradePeerName().c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Accept", ImVec2(130, 30))) { + gameHandler.acceptTradeRequest(); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(130, 30))) { + gameHandler.declineTradeRequest(); + } + } + ImGui::End(); +} + +void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingLootRoll()) return; + + const auto& roll = gameHandler.getPendingLootRoll(); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, 310), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Loot Roll", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + // Quality color for item name + static const ImVec4 kQualityColors[] = { + ImVec4(0.6f, 0.6f, 0.6f, 1.0f), // 0=poor (grey) + ImVec4(1.0f, 1.0f, 1.0f, 1.0f), // 1=common (white) + ImVec4(0.1f, 1.0f, 0.1f, 1.0f), // 2=uncommon (green) + ImVec4(0.0f, 0.44f, 0.87f, 1.0f),// 3=rare (blue) + ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4=epic (purple) + ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5=legendary (orange) + }; + uint8_t q = roll.itemQuality; + ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; + + ImGui::Text("An item is up for rolls:"); + ImGui::TextColored(col, "[%s]", roll.itemName.c_str()); + ImGui::Spacing(); + + if (ImGui::Button("Need", ImVec2(80, 30))) { + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + } + ImGui::SameLine(); + if (ImGui::Button("Greed", ImVec2(80, 30))) { + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + } + ImGui::SameLine(); + if (ImGui::Button("Disenchant", ImVec2(95, 30))) { + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + } + ImGui::SameLine(); + if (ImGui::Button("Pass", ImVec2(70, 30))) { + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); + } + } + ImGui::End(); +} + void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) { if (!gameHandler.hasPendingGuildInvite()) return; @@ -4400,6 +5136,38 @@ void GameScreen::renderGuildInvitePopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingReadyCheck()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 175, screenH / 2 - 60), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + if (ImGui::Begin("Ready Check", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + const std::string& initiator = gameHandler.getReadyCheckInitiator(); + if (initiator.empty()) { + ImGui::Text("A ready check has been initiated!"); + } else { + ImGui::TextWrapped("%s has initiated a ready check!", initiator.c_str()); + } + ImGui::Spacing(); + + if (ImGui::Button("Ready", ImVec2(155, 30))) { + gameHandler.respondToReadyCheck(true); + gameHandler.dismissReadyCheck(); + } + ImGui::SameLine(); + if (ImGui::Button("Not Ready", ImVec2(155, 30))) { + gameHandler.respondToReadyCheck(false); + gameHandler.dismissReadyCheck(); + } + } + ImGui::End(); +} + void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // O key toggle (WoW default Social/Guild keybind) if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { @@ -4847,6 +5615,46 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Compute remaining duration once (shared by overlay and tooltip) + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remainMs = aura.getRemainingMs(nowMs); + + // Duration countdown overlay — always visible on the icon bottom + if (remainMs > 0) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + ImVec2 iconMax = ImGui::GetItemRectMax(); + char timeStr[12]; + int secs = (remainMs + 999) / 1000; // ceiling seconds + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%d", secs); + ImVec2 textSize = ImGui::CalcTextSize(timeStr); + float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; + float cy = iconMax.y - textSize.y - 2.0f; + // Drop shadow for readability over any icon colour + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), + IM_COL32(0, 0, 0, 200), timeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), + IM_COL32(255, 255, 255, 255), timeStr); + } + + // Stack / charge count overlay — upper-left corner of the icon + if (aura.charges > 1) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + // Drop shadow then bright yellow text + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + // Right-click to cancel buffs / dismount if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { if (gameHandler.isMounted()) { @@ -4856,16 +5664,12 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } } - // Tooltip with spell name and live countdown + // Tooltip with spell name and countdown if (ImGui::IsItemHovered()) { std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - int32_t remaining = aura.getRemainingMs(nowMs); - if (remaining > 0) { - int seconds = remaining / 1000; + if (remainMs > 0) { + int seconds = remainMs / 1000; if (seconds < 60) { ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); } else { @@ -5947,6 +6751,10 @@ void GameScreen::renderEscapeMenu() { settingsInit = false; showEscapeMenu = false; } + if (ImGui::Button("Instance Lockouts", ImVec2(-1, 0))) { + showInstanceLockouts_ = true; + showEscapeMenu = false; + } ImGui::Spacing(); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); @@ -6308,7 +7116,7 @@ void GameScreen::renderSettingsWindow() { if (fsr2Active) { ImGui::BeginDisabled(); int disabled = 0; - ImGui::Combo("Anti-Aliasing (FSR2)", &disabled, "Off (FSR2 active)\0", 1); + ImGui::Combo("Anti-Aliasing (FSR3)", &disabled, "Off (FSR3 active)\0", 1); ImGui::EndDisabled(); } else if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) { static const VkSampleCountFlagBits aaSamples[] = { @@ -6321,8 +7129,8 @@ void GameScreen::renderSettingsWindow() { } // FSR Upscaling { - // FSR mode selection: Off, FSR 1.0 (Spatial), FSR 2.2 (Temporal) - const char* fsrModeLabels[] = { "Off", "FSR 1.0 (Spatial)", "FSR 2.2 (Temporal)" }; + // FSR mode selection: Off, FSR 1.0 (Spatial), FSR 3.x (Temporal) + const char* fsrModeLabels[] = { "Off", "FSR 1.0 (Spatial)", "FSR 3.x (Temporal)" }; int fsrMode = pendingUpscalingMode; if (ImGui::Combo("Upscaling", &fsrMode, fsrModeLabels, 3)) { pendingUpscalingMode = fsrMode; @@ -6335,7 +7143,7 @@ void GameScreen::renderSettingsWindow() { } if (fsrMode > 0) { if (fsrMode == 2 && renderer) { - ImGui::TextDisabled("FSR2 backend: %s", + ImGui::TextDisabled("FSR3 backend: %s", renderer->isAmdFsr2SdkAvailable() ? "AMD FidelityFX SDK" : "Internal fallback"); if (renderer->isAmdFsr3FramegenSdkAvailable()) { if (ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &pendingAMDFramegen)) { @@ -6387,7 +7195,7 @@ void GameScreen::renderSettingsWindow() { saveSettings(); } if (fsrMode == 2) { - ImGui::SeparatorText("FSR2 Tuning"); + ImGui::SeparatorText("FSR3 Tuning"); if (ImGui::SliderFloat("Jitter Sign", &pendingFSR2JitterSign, -2.0f, 2.0f, "%.2f")) { if (renderer) { renderer->setFSR2DebugTuning( @@ -7128,6 +7936,32 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { IM_COL32(0, 0, 0, 255), marker); } + // Gossip POI markers (quest / NPC navigation targets) + for (const auto& poi : gameHandler.getGossipPois()) { + // Convert WoW canonical coords to render coords for minimap projection + glm::vec3 poiRender = core::coords::canonicalToRender(glm::vec3(poi.x, poi.y, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(poiRender, sx, sy)) continue; + + // Draw as a cyan diamond with tooltip on hover + const float d = 5.0f; + ImVec2 pts[4] = { + { sx, sy - d }, + { sx + d, sy }, + { sx, sy + d }, + { sx - d, sy }, + }; + drawList->AddConvexPolyFilled(pts, 4, IM_COL32(0, 210, 255, 220)); + drawList->AddPolyline(pts, 4, IM_COL32(255, 255, 255, 160), true, 1.0f); + + // Show name label if cursor is within ~8px + ImVec2 cursorPos = ImGui::GetMousePos(); + float dx = cursorPos.x - sx, dy = cursorPos.y - sy; + if (!poi.name.empty() && (dx * dx + dy * dy) < 64.0f) { + ImGui::SetTooltip("%s", poi.name.c_str()); + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; @@ -8877,4 +9711,450 @@ void GameScreen::renderDingEffect() { } } +void GameScreen::triggerAchievementToast(uint32_t achievementId) { + achievementToastId_ = achievementId; + achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; + + // Play a UI sound if available + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* sfx = renderer->getUiSoundManager()) { + sfx->playAchievementAlert(); + } + } +} + +void GameScreen::renderAchievementToast() { + if (achievementToastTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + achievementToastTimer_ -= dt; + if (achievementToastTimer_ < 0.0f) achievementToastTimer_ = 0.0f; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Slide in from the right — fully visible for most of the duration, slides out at end + constexpr float SLIDE_TIME = 0.4f; + float slideIn = std::min(achievementToastTimer_, ACHIEVEMENT_TOAST_DURATION - achievementToastTimer_); + float slideFrac = (ACHIEVEMENT_TOAST_DURATION > 0.0f && SLIDE_TIME > 0.0f) + ? std::min(slideIn / SLIDE_TIME, 1.0f) + : 1.0f; + + constexpr float TOAST_W = 280.0f; + constexpr float TOAST_H = 60.0f; + float xFull = screenW - TOAST_W - 20.0f; + float xHidden = screenW + 10.0f; + float toastX = xHidden + (xFull - xHidden) * slideFrac; + float toastY = screenH - TOAST_H - 80.0f; // above action bar area + + float alpha = std::min(1.0f, achievementToastTimer_ / 0.5f); // fade at very end + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + // Background panel (gold border, dark fill) + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + TOAST_W, toastY + TOAST_H); + draw->AddRectFilled(tl, br, IM_COL32(30, 20, 10, (int)(alpha * 230)), 6.0f); + draw->AddRect(tl, br, IM_COL32(200, 170, 50, (int)(alpha * 255)), 6.0f, 0, 2.0f); + + // Title + ImFont* font = ImGui::GetFont(); + float titleSize = 14.0f; + float bodySize = 12.0f; + const char* title = "Achievement Earned!"; + float titleW = font->CalcTextSizeA(titleSize, FLT_MAX, 0.0f, title).x; + float titleX = toastX + (TOAST_W - titleW) * 0.5f; + draw->AddText(font, titleSize, ImVec2(titleX + 1, toastY + 8 + 1), + IM_COL32(0, 0, 0, (int)(alpha * 180)), title); + draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), + IM_COL32(255, 215, 0, (int)(alpha * 255)), title); + + // Achievement ID line (until we have Achievement.dbc name lookup) + char idBuf[64]; + std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; + float idX = toastX + (TOAST_W - idW) * 0.5f; + draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), + IM_COL32(220, 200, 150, (int)(alpha * 255)), idBuf); +} + +// --------------------------------------------------------------------------- +// Zone discovery text — "Entering: " fades in/out at screen centre +// --------------------------------------------------------------------------- + +void GameScreen::renderZoneText() { + // Poll the renderer for zone name changes + auto* appRenderer = core::Application::getInstance().getRenderer(); + if (appRenderer) { + const std::string& zoneName = appRenderer->getCurrentZoneName(); + if (!zoneName.empty() && zoneName != lastKnownZoneName_) { + lastKnownZoneName_ = zoneName; + zoneTextName_ = zoneName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } + } + + if (zoneTextTimer_ <= 0.0f || zoneTextName_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + zoneTextTimer_ -= dt; + if (zoneTextTimer_ < 0.0f) zoneTextTimer_ = 0.0f; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Fade: ramp up in first 0.5 s, hold, fade out in last 1.0 s + float alpha; + if (zoneTextTimer_ > ZONE_TEXT_DURATION - 0.5f) + alpha = 1.0f - (zoneTextTimer_ - (ZONE_TEXT_DURATION - 0.5f)) / 0.5f; + else if (zoneTextTimer_ < 1.0f) + alpha = zoneTextTimer_; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + ImFont* font = ImGui::GetFont(); + + // "Entering:" header + const char* header = "Entering:"; + float headerSize = 16.0f; + float nameSize = 26.0f; + + ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); + ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, zoneTextName_.c_str()); + + float centreY = screenH * 0.30f; // upper third, like WoW + float headerX = (screenW - headerDim.x) * 0.5f; + float nameX = (screenW - nameDim.x) * 0.5f; + float headerY = centreY; + float nameY = centreY + headerDim.y + 4.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + // "Entering:" in gold + draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), header); + draw->AddText(font, headerSize, ImVec2(headerX, headerY), + IM_COL32(255, 215, 0, (int)(alpha * 255)), header); + + // Zone name in white + draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), zoneTextName_.c_str()); + draw->AddText(font, nameSize, ImVec2(nameX, nameY), + IM_COL32(255, 255, 255, (int)(alpha * 255)), zoneTextName_.c_str()); +} + +// --------------------------------------------------------------------------- +// Dungeon Finder window (toggle with hotkey or bag-bar button) +// --------------------------------------------------------------------------- +void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { + // Toggle on I key when not typing + if (!chatInputActive && ImGui::IsKeyPressed(ImGuiKey_I, false)) { + showDungeonFinder_ = !showDungeonFinder_; + } + + if (!showDungeonFinder_) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW * 0.5f - 175.0f, screenH * 0.2f), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(350, 0), ImGuiCond_Always); + + bool open = true; + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize; + if (!ImGui::Begin("Dungeon Finder", &open, flags)) { + ImGui::End(); + if (!open) showDungeonFinder_ = false; + return; + } + if (!open) { + ImGui::End(); + showDungeonFinder_ = false; + return; + } + + using LfgState = game::GameHandler::LfgState; + LfgState state = gameHandler.getLfgState(); + + // ---- Status banner ---- + switch (state) { + case LfgState::None: + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Status: Not queued"); + break; + case LfgState::RoleCheck: + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Status: Role check in progress..."); + break; + case LfgState::Queued: { + int32_t avgSec = gameHandler.getLfgAvgWaitSec(); + uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); + int qMin = static_cast(qMs / 60000); + int qSec = static_cast((qMs % 60000) / 1000); + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); + if (avgSec >= 0) { + int aMin = avgSec / 60; + int aSec = avgSec % 60; + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), + "Avg wait: %d:%02d", aMin, aSec); + } + break; + } + case LfgState::Proposal: + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); + break; + case LfgState::Boot: + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Status: Vote kick in progress"); + break; + case LfgState::InDungeon: + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); + break; + case LfgState::FinishedDungeon: + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); + break; + case LfgState::RaidBrowser: + ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser"); + break; + } + + ImGui::Separator(); + + // ---- Proposal accept/decline ---- + if (state == LfgState::Proposal) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for your dungeon!"); + ImGui::Spacing(); + if (ImGui::Button("Accept", ImVec2(120, 0))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); + } + ImGui::SameLine(); + if (ImGui::Button("Decline", ImVec2(120, 0))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); + } + ImGui::Separator(); + } + + // ---- Teleport button (in dungeon) ---- + if (state == LfgState::InDungeon) { + if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) { + gameHandler.lfgTeleport(true); + } + ImGui::Separator(); + } + + // ---- Role selection (only when not queued/in dungeon) ---- + bool canConfigure = (state == LfgState::None || state == LfgState::FinishedDungeon); + + if (canConfigure) { + ImGui::Text("Role:"); + ImGui::SameLine(); + bool isTank = (lfgRoles_ & 0x02) != 0; + bool isHealer = (lfgRoles_ & 0x04) != 0; + bool isDps = (lfgRoles_ & 0x08) != 0; + if (ImGui::Checkbox("Tank", &isTank)) lfgRoles_ = (lfgRoles_ & ~0x02) | (isTank ? 0x02 : 0); + ImGui::SameLine(); + if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); + ImGui::SameLine(); + if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); + + ImGui::Spacing(); + + // ---- Dungeon selection ---- + 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" }, + }; + + // Find current index + int curIdx = 0; + for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + if (kDungeons[i].id == lfgSelectedDungeon_) { curIdx = i; break; } + } + + ImGui::SetNextItemWidth(-1); + if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) { + for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + bool selected = (kDungeons[i].id == lfgSelectedDungeon_); + if (ImGui::Selectable(kDungeons[i].name, selected)) + lfgSelectedDungeon_ = kDungeons[i].id; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + ImGui::Spacing(); + + // ---- Join button ---- + bool rolesOk = (lfgRoles_ != 0); + if (!rolesOk) { + ImGui::BeginDisabled(); + } + if (ImGui::Button("Join Dungeon Finder", ImVec2(-1, 0))) { + gameHandler.lfgJoin(lfgSelectedDungeon_, lfgRoles_); + } + if (!rolesOk) { + ImGui::EndDisabled(); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Select at least one role."); + } + } + + // ---- Leave button (when queued or role check) ---- + if (state == LfgState::Queued || state == LfgState::RoleCheck) { + if (ImGui::Button("Leave Queue", ImVec2(-1, 0))) { + gameHandler.lfgLeave(); + } + } + + ImGui::End(); +} + +// ============================================================ +// Instance Lockouts +// ============================================================ + +void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { + if (!showInstanceLockouts_) return; + + ImGui::SetNextWindowSize(ImVec2(480, 0), ImGuiCond_Appearing); + ImGui::SetNextWindowPos( + ImVec2(ImGui::GetIO().DisplaySize.x / 2 - 240, 140), ImGuiCond_Appearing); + + if (!ImGui::Begin("Instance Lockouts", &showInstanceLockouts_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + const auto& lockouts = gameHandler.getInstanceLockouts(); + + if (lockouts.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active instance lockouts."); + } else { + // Build map name lookup from Map.dbc (cached after first call) + static std::unordered_map sMapNames; + static bool sMapNamesLoaded = false; + if (!sMapNamesLoaded) { + sMapNamesLoaded = true; + if (auto* am = core::Application::getInstance().getAssetManager()) { + if (auto dbc = am->loadDBC("Map.dbc"); dbc && dbc->isLoaded()) { + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + // Field 2 = MapName_enUS (first localized), field 1 = InternalName + std::string name = dbc->getString(i, 2); + if (name.empty()) name = dbc->getString(i, 1); + if (!name.empty()) sMapNames[id] = std::move(name); + } + } + } + } + + auto difficultyLabel = [](uint32_t diff) -> const char* { + switch (diff) { + case 0: return "Normal"; + case 1: return "Heroic"; + case 2: return "25-Man"; + case 3: return "25-Man Heroic"; + default: return "Unknown"; + } + }; + + // Current UTC time for reset countdown + auto nowSec = static_cast(std::time(nullptr)); + + if (ImGui::BeginTable("lockouts", 4, + ImGuiTableFlags_SizingStretchProp | + ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersOuter)) { + ImGui::TableSetupColumn("Instance", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Difficulty", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Resets In", ImGuiTableColumnFlags_WidthFixed, 100.0f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableHeadersRow(); + + for (const auto& lo : lockouts) { + ImGui::TableNextRow(); + + // Instance name + ImGui::TableSetColumnIndex(0); + auto it = sMapNames.find(lo.mapId); + if (it != sMapNames.end()) { + ImGui::TextUnformatted(it->second.c_str()); + } else { + ImGui::Text("Map %u", lo.mapId); + } + + // Difficulty + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(difficultyLabel(lo.difficulty)); + + // Reset countdown + ImGui::TableSetColumnIndex(2); + if (lo.resetTime > nowSec) { + uint64_t remaining = lo.resetTime - nowSec; + uint64_t days = remaining / 86400; + uint64_t hours = (remaining % 86400) / 3600; + if (days > 0) { + ImGui::Text("%llud %lluh", + static_cast(days), + static_cast(hours)); + } else { + uint64_t mins = (remaining % 3600) / 60; + ImGui::Text("%lluh %llum", + static_cast(hours), + static_cast(mins)); + } + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Expired"); + } + + // Locked / Extended status + ImGui::TableSetColumnIndex(3); + if (lo.extended) { + ImGui::TextColored(ImVec4(0.3f, 0.7f, 1.0f, 1.0f), "Ext"); + } else if (lo.locked) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Locked"); + } else { + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Open"); + } + } + + ImGui::EndTable(); + } + } + + ImGui::End(); +} + }} // namespace wowee::ui diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index ee4b54a3..320fc316 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1090,6 +1090,11 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Reputation")) { + renderReputationPanel(gameHandler); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Skills")) { const auto& skills = gameHandler.getPlayerSkills(); if (skills.empty()) { @@ -1171,6 +1176,89 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { } } +void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { + const auto& standings = gameHandler.getFactionStandings(); + if (standings.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("No reputation data received yet."); + ImGui::TextDisabled("Reputation updates as you kill enemies and complete quests."); + return; + } + + // WoW reputation tier breakpoints (cumulative from floor -42000) + // Tier name, threshold for next rank, bar color + struct RepTier { + const char* name; + int32_t floor; // raw value where this tier begins + int32_t ceiling; // raw value where the next tier begins + ImVec4 color; + }; + static const RepTier tiers[] = { + { "Hated", -42000, -6001, ImVec4(0.6f, 0.1f, 0.1f, 1.0f) }, + { "Hostile", -6000, -3001, ImVec4(0.8f, 0.2f, 0.1f, 1.0f) }, + { "Unfriendly", -3000, -1, ImVec4(0.9f, 0.5f, 0.1f, 1.0f) }, + { "Neutral", 0, 2999, ImVec4(0.8f, 0.8f, 0.2f, 1.0f) }, + { "Friendly", 3000, 8999, ImVec4(0.2f, 0.7f, 0.2f, 1.0f) }, + { "Honored", 9000, 20999, ImVec4(0.2f, 0.8f, 0.5f, 1.0f) }, + { "Revered", 21000, 41999, ImVec4(0.3f, 0.6f, 1.0f, 1.0f) }, + { "Exalted", 42000, 42000, ImVec4(1.0f, 0.84f, 0.0f, 1.0f) }, + }; + + auto getTier = [&](int32_t val) -> const RepTier& { + for (int i = 6; i >= 0; --i) { + if (val >= tiers[i].floor) return tiers[i]; + } + return tiers[0]; + }; + + ImGui::BeginChild("##ReputationList", ImVec2(0, 0), true); + + // Sort factions alphabetically by name + std::vector> sortedFactions(standings.begin(), standings.end()); + std::sort(sortedFactions.begin(), sortedFactions.end(), + [&](const auto& a, const auto& b) { + const std::string& na = gameHandler.getFactionNamePublic(a.first); + const std::string& nb = gameHandler.getFactionNamePublic(b.first); + return na < nb; + }); + + for (const auto& [factionId, standing] : sortedFactions) { + const RepTier& tier = getTier(standing); + + const std::string& factionName = gameHandler.getFactionNamePublic(factionId); + const char* displayName = factionName.empty() ? "Unknown Faction" : factionName.c_str(); + + // Faction name + tier label on same line + ImGui::TextColored(tier.color, "[%s]", tier.name); + ImGui::SameLine(90.0f); + ImGui::Text("%s", displayName); + + // Progress bar showing position within current tier + float ratio = 0.0f; + char overlay[64] = ""; + if (tier.floor == 42000) { + // Exalted — full bar + ratio = 1.0f; + snprintf(overlay, sizeof(overlay), "Exalted"); + } else { + int32_t tierRange = tier.ceiling - tier.floor + 1; + int32_t inTier = standing - tier.floor; + ratio = static_cast(inTier) / static_cast(tierRange); + ratio = std::max(0.0f, std::min(1.0f, ratio)); + snprintf(overlay, sizeof(overlay), "%d / %d", + inTier < 0 ? 0 : inTier, tierRange); + } + + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tier.color); + ImGui::SetNextItemWidth(-1.0f); + ImGui::ProgressBar(ratio, ImVec2(0, 12.0f), overlay); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } + + ImGui::EndChild(); +} + void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Equipment"); ImGui::Separator(); diff --git a/tools/generate_ffx_sdk_vk_permutations.sh b/tools/generate_ffx_sdk_vk_permutations.sh new file mode 100755 index 00000000..78010d39 --- /dev/null +++ b/tools/generate_ffx_sdk_vk_permutations.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SDK_ROOT="${1:-$ROOT_DIR/extern/FidelityFX-SDK}" +KITS_DIR="$SDK_ROOT/Kits/FidelityFX" +FFX_SC="$KITS_DIR/tools/ffx_sc/ffx_sc.py" +OUT_DIR="$KITS_DIR/framegeneration/fsr3/internal/permutations/vk" +SHADER_DIR="$KITS_DIR/upscalers/fsr3/internal/shaders" + +if [[ ! -f "$FFX_SC" ]]; then + echo "Missing ffx_sc.py at $FFX_SC" >&2 + exit 1 +fi + +required_headers=( + "$OUT_DIR/ffx_fsr2_accumulate_pass_wave64_16bit_permutations.h" + "$OUT_DIR/ffx_fsr3upscaler_accumulate_pass_wave64_16bit_permutations.h" + "$OUT_DIR/ffx_fsr3upscaler_autogen_reactive_pass_permutations.h" +) +if [[ "${WOWEE_FORCE_REGEN_PERMS:-0}" != "1" ]]; then + missing=0 + for h in "${required_headers[@]}"; do + [[ -f "$h" ]] || missing=1 + done + if [[ $missing -eq 0 ]]; then + echo "FidelityFX VK permutation headers already present." + exit 0 + fi +fi + +if [[ -z "${DXC:-}" ]]; then + if [[ -x /tmp/dxc/bin/dxc ]]; then + export DXC=/tmp/dxc/bin/dxc + elif command -v dxc >/dev/null 2>&1; then + export DXC="$(command -v dxc)" + elif [[ "$(uname -s)" == "Linux" ]]; then + _arch="$(uname -m)" + if [[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]]; then + echo "Linux aarch64: no official arm64 DXC release available." >&2 + echo "Install 'directx-shader-compiler' via apt or set DXC=/path/to/dxc to regenerate." >&2 + echo "Skipping VK permutation codegen (permutations may be pre-built in the SDK checkout)." + exit 0 + fi + echo "DXC not found; downloading Linux DXC release to /tmp/dxc ..." + tmp_json="$(mktemp)" + curl -sS https://api.github.com/repos/microsoft/DirectXShaderCompiler/releases/latest > "$tmp_json" + dxc_url="$(python3 - << 'PY' "$tmp_json" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +for a in data.get('assets', []): + name = a.get('name', '') + if name.startswith('linux_dxc_') and name.endswith('.x86_64.tar.gz'): + print(a.get('browser_download_url', '')) + break +PY +)" + rm -f "$tmp_json" + if [[ -z "$dxc_url" ]]; then + echo "Failed to locate Linux DXC release asset URL." >&2 + exit 1 + fi + rm -rf /tmp/dxc /tmp/linux_dxc.tar.gz + curl -L --fail "$dxc_url" -o /tmp/linux_dxc.tar.gz + mkdir -p /tmp/dxc + tar -xzf /tmp/linux_dxc.tar.gz -C /tmp/dxc --strip-components=1 + export DXC=/tmp/dxc/bin/dxc + elif [[ "$(uname -s)" =~ MINGW|MSYS|CYGWIN ]]; then + echo "DXC not found; downloading Windows DXC release to /tmp/dxc ..." + tmp_json="$(mktemp)" + curl -sS https://api.github.com/repos/microsoft/DirectXShaderCompiler/releases/latest > "$tmp_json" + dxc_url="$(python3 - << 'PY' "$tmp_json" +import json, sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +for a in data.get('assets', []): + name = a.get('name', '') + if name.startswith('dxc_') and name.endswith('.zip'): + print(a.get('browser_download_url', '')) + break +PY +)" + rm -f "$tmp_json" + if [[ -z "$dxc_url" ]]; then + echo "Failed to locate Windows DXC release asset URL." >&2 + exit 1 + fi + rm -rf /tmp/dxc /tmp/dxc_win.zip + curl -L --fail "$dxc_url" -o /tmp/dxc_win.zip + mkdir -p /tmp/dxc + unzip -q /tmp/dxc_win.zip -d /tmp/dxc + if [[ -x /tmp/dxc/bin/x64/dxc.exe ]]; then + export DXC=/tmp/dxc/bin/x64/dxc.exe + elif [[ -x /tmp/dxc/bin/x86/dxc.exe ]]; then + export DXC=/tmp/dxc/bin/x86/dxc.exe + else + echo "DXC download succeeded, but dxc.exe was not found." >&2 + exit 1 + fi + else + echo "DXC not found. Set DXC=/path/to/dxc or install to /tmp/dxc/bin/dxc" >&2 + exit 1 + fi +fi + +mkdir -p "$OUT_DIR" + +# First generate frame interpolation + optical flow permutations via SDK script. +( + cd "$SDK_ROOT" + ./generate_vk_permutations.sh +) + +BASE_ARGS=(-reflection -embed-arguments -E CS -Wno-for-redefinition -Wno-ambig-lit-shift -DFFX_GPU=1 -DFFX_HLSL=1 -DFFX_IMPLICIT_SHADER_REGISTER_BINDING_HLSL=0) +WAVE32=(-DFFX_HLSL_SM=62 -T cs_6_2) +WAVE64=("-DFFX_PREFER_WAVE64=[WaveSize(64)]" -DFFX_HLSL_SM=66 -T cs_6_6) +BIT16=(-DFFX_HALF=1 -enable-16bit-types) + +compile_shader() { + local file="$1"; shift + local name="$1"; shift + python3 "$FFX_SC" "${BASE_ARGS[@]}" "$@" -name="$name" -output="$OUT_DIR" "$file" +} + +# FSR2 (for upscalers/fsr3/internal/ffx_fsr2_shaderblobs.cpp) +FSR2_COMMON=( + -DFFX_FSR2_EMBED_ROOTSIG=0 + -DFFX_FSR2_OPTION_UPSAMPLE_SAMPLERS_USE_DATA_HALF=0 + -DFFX_FSR2_OPTION_ACCUMULATE_SAMPLERS_USE_DATA_HALF=0 + -DFFX_FSR2_OPTION_REPROJECT_SAMPLERS_USE_DATA_HALF=1 + -DFFX_FSR2_OPTION_POSTPROCESSLOCKSTATUS_SAMPLERS_USE_DATA_HALF=0 + -DFFX_FSR2_OPTION_UPSAMPLE_USE_LANCZOS_TYPE=2 + "-DFFX_FSR2_OPTION_REPROJECT_USE_LANCZOS_TYPE={0,1}" + "-DFFX_FSR2_OPTION_HDR_COLOR_INPUT={0,1}" + "-DFFX_FSR2_OPTION_LOW_RESOLUTION_MOTION_VECTORS={0,1}" + "-DFFX_FSR2_OPTION_JITTERED_MOTION_VECTORS={0,1}" + "-DFFX_FSR2_OPTION_INVERTED_DEPTH={0,1}" + "-DFFX_FSR2_OPTION_APPLY_SHARPENING={0,1}" + -I "$KITS_DIR/api/internal/include/gpu" + -I "$KITS_DIR/upscalers/fsr3/include/gpu" +) +FSR2_SHADERS=( + ffx_fsr2_autogen_reactive_pass + ffx_fsr2_accumulate_pass + ffx_fsr2_compute_luminance_pyramid_pass + ffx_fsr2_depth_clip_pass + ffx_fsr2_lock_pass + ffx_fsr2_reconstruct_previous_depth_pass + ffx_fsr2_rcas_pass + ffx_fsr2_tcr_autogen_pass +) + +for shader in "${FSR2_SHADERS[@]}"; do + file="$SHADER_DIR/$shader.hlsl" + [[ -f "$file" ]] || continue + compile_shader "$file" "$shader" -DFFX_HALF=0 "${WAVE32[@]}" "${FSR2_COMMON[@]}" + compile_shader "$file" "${shader}_wave64" -DFFX_HALF=0 "${WAVE64[@]}" "${FSR2_COMMON[@]}" + compile_shader "$file" "${shader}_16bit" "${BIT16[@]}" "${WAVE32[@]}" "${FSR2_COMMON[@]}" + compile_shader "$file" "${shader}_wave64_16bit" "${BIT16[@]}" "${WAVE64[@]}" "${FSR2_COMMON[@]}" +done + +# FSR3 upscaler (for upscalers/fsr3/internal/ffx_fsr3upscaler_shaderblobs.cpp) +FSR3_COMMON=( + -DFFX_FSR3UPSCALER_EMBED_ROOTSIG=0 + -DFFX_FSR3UPSCALER_OPTION_UPSAMPLE_SAMPLERS_USE_DATA_HALF=0 + -DFFX_FSR3UPSCALER_OPTION_ACCUMULATE_SAMPLERS_USE_DATA_HALF=0 + -DFFX_FSR3UPSCALER_OPTION_REPROJECT_SAMPLERS_USE_DATA_HALF=1 + -DFFX_FSR3UPSCALER_OPTION_POSTPROCESSLOCKSTATUS_SAMPLERS_USE_DATA_HALF=0 + -DFFX_FSR3UPSCALER_OPTION_UPSAMPLE_USE_LANCZOS_TYPE=2 + "-DFFX_FSR3UPSCALER_OPTION_REPROJECT_USE_LANCZOS_TYPE={0,1}" + "-DFFX_FSR3UPSCALER_OPTION_HDR_COLOR_INPUT={0,1}" + "-DFFX_FSR3UPSCALER_OPTION_LOW_RESOLUTION_MOTION_VECTORS={0,1}" + "-DFFX_FSR3UPSCALER_OPTION_JITTERED_MOTION_VECTORS={0,1}" + "-DFFX_FSR3UPSCALER_OPTION_INVERTED_DEPTH={0,1}" + "-DFFX_FSR3UPSCALER_OPTION_APPLY_SHARPENING={0,1}" + -I "$KITS_DIR/api/internal/gpu" + -I "$KITS_DIR/upscalers/fsr3/include/gpu" +) +FSR3_SHADERS=( + ffx_fsr3upscaler_autogen_reactive_pass + ffx_fsr3upscaler_accumulate_pass + ffx_fsr3upscaler_luma_pyramid_pass + ffx_fsr3upscaler_prepare_reactivity_pass + ffx_fsr3upscaler_prepare_inputs_pass + ffx_fsr3upscaler_shading_change_pass + ffx_fsr3upscaler_rcas_pass + ffx_fsr3upscaler_shading_change_pyramid_pass + ffx_fsr3upscaler_luma_instability_pass + ffx_fsr3upscaler_debug_view_pass +) + +for shader in "${FSR3_SHADERS[@]}"; do + file="$SHADER_DIR/$shader.hlsl" + [[ -f "$file" ]] || continue + compile_shader "$file" "$shader" -DFFX_HALF=0 "${WAVE32[@]}" "${FSR3_COMMON[@]}" + compile_shader "$file" "${shader}_wave64" -DFFX_HALF=0 "${WAVE64[@]}" "${FSR3_COMMON[@]}" + compile_shader "$file" "${shader}_16bit" "${BIT16[@]}" "${WAVE32[@]}" "${FSR3_COMMON[@]}" + compile_shader "$file" "${shader}_wave64_16bit" "${BIT16[@]}" "${WAVE64[@]}" "${FSR3_COMMON[@]}" +done + +echo "Generated VK permutation headers in $OUT_DIR"