From 8378eb9232459cfd1d3bdf5d7826464a1b9de714 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 07:19:37 -0700 Subject: [PATCH 01/42] fix: correct sync Warden MODULE check returning 0x01 instead of 0x00 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync path's MODULE handler was returning 0x01 (module found) for unwanted cheat DLLs (WPESPY, TAMIA, PRXDRVPE, etc.) instead of 0x00 (not found). Since VMaNGOS compares the result as a boolean, returning any non-zero value for a cheat module tells the server "this cheat DLL is loaded," triggering Warden penalties that accumulate into a kick after ~3-5 minutes. Also adds ±4KB hint window search to searchCodePattern for faster PAGE_A resolution without full brute-force, and restores the turtle PAGE_A fallback (confirmed patterns are runtime-patched offsets not present in the on-disk PE). --- include/game/warden_memory.hpp | 4 +- src/game/game_handler.cpp | 200 ++++++++++++++++++++------------- src/game/warden_memory.cpp | 94 +++++++++++++--- 3 files changed, 207 insertions(+), 91 deletions(-) diff --git a/include/game/warden_memory.hpp b/include/game/warden_memory.hpp index d01a27c3..39a2abf2 100644 --- a/include/game/warden_memory.hpp +++ b/include/game/warden_memory.hpp @@ -41,10 +41,12 @@ public: * @param expectedHash 20-byte expected HMAC-SHA1 digest * @param patternLen Length of the pattern to search for * @param imageOnly If true, search only executable sections (.text) + * @param hintOffset RVA hint from PAGE_A request — check this position first * @return true if a matching pattern was found in the PE image */ bool searchCodePattern(const uint8_t seed[4], const uint8_t expectedHash[20], - uint8_t patternLen, bool imageOnly) const; + uint8_t patternLen, bool imageOnly, + uint32_t hintOffset = 0, bool hintOnly = false) const; /** Write a little-endian uint32 at the given virtual address in the PE image. */ void writeLE32(uint32_t va, uint32_t value); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 222bfabe..9790d73e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -9135,7 +9135,7 @@ bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) { for (int i = 0; i < 9; i++) { char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s; } - LOG_DEBUG("Warden: Check opcodes: ", opcHex); + LOG_WARNING("Warden: Check opcodes: ", opcHex); } size_t entryCount = (static_cast(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE; @@ -9542,6 +9542,10 @@ void GameHandler::handleWardenData(network::Packet& packet) { switch (ct) { case CT_TIMING: { + // Result byte: 0x01 = timing check ran successfully, + // 0x00 = timing check failed (Wine/VM — server skips anti-AFK). + // We return 0x01 so the server validates normally; our + // LastHardwareAction (now-2000) ensures a clean 2s delta. resultData.push_back(0x01); uint32_t ticks = static_cast( std::chrono::duration_cast( @@ -9561,7 +9565,8 @@ void GameHandler::handleWardenData(network::Packet& packet) { pos += 4; uint8_t readLen = decrypted[pos++]; LOG_WARNING("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), - " len=", (int)readLen); + " len=", (int)readLen, + (strIdx ? " module=\"" + moduleName + "\"" : "")); if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) { uint32_t now = static_cast( std::chrono::duration_cast( @@ -9588,21 +9593,12 @@ void GameHandler::handleWardenData(network::Packet& packet) { LOG_WARNING("Warden: Applying 4-byte ULONG alignment padding for WinVersionGet"); resultData.push_back(0x00); resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); - } else if (wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { - // Try Warden module memory - uint32_t modBase = offset & ~0xFFFFu; - uint32_t modOfs = offset - modBase; - const auto& modData = wardenLoadedModule_->getDecompressedData(); - if (modOfs + readLen <= modData.size()) { - std::memcpy(memBuf.data(), modData.data() + modOfs, readLen); - LOG_WARNING("Warden: MEM_CHECK served from Warden module (offset=0x", - [&]{char s[12];snprintf(s,12,"%x",modOfs);return std::string(s);}(), ")"); - resultData.push_back(0x00); - resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); - } else { - resultData.push_back(0xE9); - } } else { + // Address not in PE/KUSER — return 0xE9 (not readable). + // Real 32-bit WoW can't read kernel space (>=0x80000000) + // or arbitrary unallocated user-space addresses. + LOG_WARNING("Warden: MEM_CHECK -> 0xE9 (unmapped 0x", + [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), ")"); resultData.push_back(0xE9); } break; @@ -9620,22 +9616,15 @@ void GameHandler::handleWardenData(network::Packet& packet) { uint8_t patLen = p[28]; bool found = false; bool turtleFallback = false; - // Turtle fallback: if offset is within PE image range, - // this is an integrity check — skip the expensive 25-second - // brute-force search and return "found" immediately to stay - // within the server's Warden response timeout. - bool canTurtleFallback = (ct == CT_PAGE_A && isActiveExpansion("turtle") && - wardenMemory_ && wardenMemory_->isLoaded() && off < 0x600000); if (isKnownWantedCodeScan(seed, sha1, off, patLen)) { found = true; - } else if (canTurtleFallback) { - // Skip the expensive 25-second brute-force search; - // the turtle fallback will return "found" instantly. - found = true; - turtleFallback = true; } else if (wardenMemory_ && wardenMemory_->isLoaded() && patLen > 0) { - found = wardenMemory_->searchCodePattern(seed, sha1, patLen, isImageOnly); - if (!found && wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { + // Hint + nearby window search (instant). + // Skip full brute-force for Turtle PAGE_A to avoid + // 25s delay that triggers response timeout. + bool hintOnly = (ct == CT_PAGE_A && isActiveExpansion("turtle")); + found = wardenMemory_->searchCodePattern(seed, sha1, patLen, isImageOnly, off, hintOnly); + if (!found && !hintOnly && wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { const uint8_t* modMem = static_cast(wardenLoadedModule_->getModuleMemory()); size_t modSize = wardenLoadedModule_->getModuleSize(); if (modMem && modSize >= patLen) { @@ -9647,6 +9636,13 @@ void GameHandler::handleWardenData(network::Packet& packet) { } } } + // Turtle PAGE_A fallback: patterns at runtime-patched + // offsets don't exist in the on-disk PE. The server + // expects "found" for these code integrity checks. + if (!found && ct == CT_PAGE_A && isActiveExpansion("turtle") && off < 0x600000) { + found = true; + turtleFallback = true; + } uint8_t pageResult = found ? 0x4A : 0x00; LOG_WARNING("Warden: ", pageName, " offset=0x", [&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}(), @@ -9703,9 +9699,23 @@ void GameHandler::handleWardenData(network::Packet& packet) { bool isWanted = hmacSha1Matches(sb, "KERNEL32.DLL", rh); std::string mn = isWanted ? "KERNEL32.DLL" : "?"; if (!isWanted) { + // Cheat modules (unwanted — report not found) if (hmacSha1Matches(sb,"WPESPY.DLL",rh)) mn = "WPESPY.DLL"; else if (hmacSha1Matches(sb,"TAMIA.DLL",rh)) mn = "TAMIA.DLL"; else if (hmacSha1Matches(sb,"PRXDRVPE.DLL",rh)) mn = "PRXDRVPE.DLL"; + else if (hmacSha1Matches(sb,"SPEEDHACK-I386.DLL",rh)) mn = "SPEEDHACK-I386.DLL"; + else if (hmacSha1Matches(sb,"D3DHOOK.DLL",rh)) mn = "D3DHOOK.DLL"; + else if (hmacSha1Matches(sb,"NJUMD.DLL",rh)) mn = "NJUMD.DLL"; + // System DLLs (wanted — report found) + else if (hmacSha1Matches(sb,"USER32.DLL",rh)) { mn = "USER32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"NTDLL.DLL",rh)) { mn = "NTDLL.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"WS2_32.DLL",rh)) { mn = "WS2_32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"WSOCK32.DLL",rh)) { mn = "WSOCK32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"ADVAPI32.DLL",rh)) { mn = "ADVAPI32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"SHELL32.DLL",rh)) { mn = "SHELL32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"GDI32.DLL",rh)) { mn = "GDI32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"OPENGL32.DLL",rh)) { mn = "OPENGL32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"WINMM.DLL",rh)) { mn = "WINMM.DLL"; isWanted = true; } } uint8_t mr = isWanted ? 0x4A : 0x00; LOG_WARNING("Warden: MODULE \"", mn, "\" -> 0x", @@ -9886,7 +9896,9 @@ void GameHandler::handleWardenData(network::Packet& packet) { switch (ct) { case CT_TIMING: { // No additional request data - // Response: [uint8 result=1][uint32 ticks] + // Response: [uint8 result][uint32 ticks] + // 0x01 = timing check ran successfully (server validates anti-AFK) + // 0x00 = timing failed (Wine/VM — server skips check but flags client) resultData.push_back(0x01); uint32_t ticks = static_cast( std::chrono::duration_cast( @@ -9895,6 +9907,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { resultData.push_back((ticks >> 8) & 0xFF); resultData.push_back((ticks >> 16) & 0xFF); resultData.push_back((ticks >> 24) & 0xFF); + LOG_WARNING("Warden: (sync) TIMING ticks=", ticks); break; } case CT_MEM: { @@ -9918,31 +9931,27 @@ void GameHandler::handleWardenData(network::Packet& packet) { } } + // Dynamically update LastHardwareAction before reading + // (anti-AFK scan compares this timestamp against TIMING ticks) + if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) { + uint32_t now = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + wardenMemory_->writeLE32(0xCF0BC8, now - 2000); + } + // Read bytes from PE image (includes patched runtime globals) std::vector memBuf(readLen, 0); if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) { LOG_DEBUG("Warden: MEM_CHECK served from PE image"); - } else if (wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { - // Try Warden module memory (addresses outside PE range) - uint32_t modBase = offset & ~0xFFFFu; // 64KB-aligned base guess - uint32_t modOfs = offset - modBase; - const auto& modData = wardenLoadedModule_->getDecompressedData(); - if (modOfs + readLen <= modData.size()) { - std::memcpy(memBuf.data(), modData.data() + modOfs, readLen); - LOG_WARNING("Warden: MEM_CHECK served from Warden module (base=0x", - [&]{char s[12];snprintf(s,12,"%08x",modBase);return std::string(s);}(), - " offset=0x", - [&]{char s[12];snprintf(s,12,"%x",modOfs);return std::string(s);}(), ")"); - } else { - LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x", - [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}()); - } + resultData.push_back(0x00); + resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); } else { - LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x", - [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}()); + // Address not in PE/KUSER — return 0xE9 (not readable). + LOG_WARNING("Warden: (sync) MEM_CHECK -> 0xE9 (unmapped 0x", + [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), ")"); + resultData.push_back(0xE9); } - resultData.push_back(0x00); - resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); break; } case CT_PAGE_A: { @@ -9988,18 +9997,29 @@ void GameHandler::handleWardenData(network::Packet& packet) { if (isKnownWantedCodeScan(seedBytes, reqHash, off, len)) { pageResult = 0x4A; } else if (wardenMemory_ && wardenMemory_->isLoaded() && len > 0) { - if (wardenMemory_->searchCodePattern(seedBytes, reqHash, len, true)) + if (wardenMemory_->searchCodePattern(seedBytes, reqHash, len, true, off)) pageResult = 0x4A; } - // Turtle fallback for integrity checks + // Turtle PAGE_A fallback: runtime-patched offsets aren't in the + // on-disk PE. Server expects "found" for code integrity checks. if (pageResult == 0x00 && isActiveExpansion("turtle") && off < 0x600000) { pageResult = 0x4A; LOG_WARNING("Warden: PAGE_A turtle-fallback for offset=0x", [&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}()); } } - LOG_DEBUG("Warden: PAGE_A request bytes=", consume, - " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); + if (consume >= 29) { + uint32_t off2 = uint32_t((decrypted.data()+pos)[24]) | (uint32_t((decrypted.data()+pos)[25])<<8) | + (uint32_t((decrypted.data()+pos)[26])<<16) | (uint32_t((decrypted.data()+pos)[27])<<24); + uint8_t len2 = (decrypted.data()+pos)[28]; + LOG_WARNING("Warden: (sync) PAGE_A offset=0x", + [&]{char s[12];snprintf(s,12,"%08x",off2);return std::string(s);}(), + " patLen=", (int)len2, + " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); + } else { + LOG_WARNING("Warden: (sync) PAGE_A (short ", consume, "b) result=0x", + [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); + } pos += consume; resultData.push_back(pageResult); break; @@ -10048,7 +10068,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { if (pos + 1 > checkEnd) { pos = checkEnd; break; } uint8_t strIdx = decrypted[pos++]; std::string filePath = resolveWardenString(strIdx); - LOG_DEBUG("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); + LOG_WARNING("Warden: (sync) MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); bool found = false; std::vector hash(20, 0); @@ -10083,10 +10103,15 @@ void GameHandler::handleWardenData(network::Packet& packet) { } } - // Response: [uint8 result][20 sha1] - // result=0 => found/success, result=1 => not found/failure - resultData.push_back(found ? 0x00 : 0x01); - resultData.insert(resultData.end(), hash.begin(), hash.end()); + // Response: result=0 + 20-byte SHA1 if found; result=1 (no hash) if not found. + // Server only reads 20 hash bytes when result==0; extra bytes corrupt parsing. + if (found) { + resultData.push_back(0x00); + resultData.insert(resultData.end(), hash.begin(), hash.end()); + } else { + resultData.push_back(0x01); + } + LOG_WARNING("Warden: (sync) MPQ result=", found ? "FOUND" : "NOT_FOUND"); break; } case CT_LUA: { @@ -10094,7 +10119,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { if (pos + 1 > checkEnd) { pos = checkEnd; break; } uint8_t strIdx = decrypted[pos++]; std::string luaVar = resolveWardenString(strIdx); - LOG_DEBUG("Warden: LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\""); + LOG_WARNING("Warden: (sync) LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\""); // Response: [uint8 result=0][uint16 len=0] // Lua string doesn't exist resultData.push_back(0x01); // not found @@ -10106,9 +10131,10 @@ void GameHandler::handleWardenData(network::Packet& packet) { pos += 24; // skip seed + sha1 uint8_t strIdx = decrypted[pos++]; std::string driverName = resolveWardenString(strIdx); - LOG_DEBUG("Warden: DRIVER=\"", (driverName.empty() ? "?" : driverName), "\""); - // Response: [uint8 result=1] (driver NOT found = clean) - resultData.push_back(0x01); + LOG_WARNING("Warden: (sync) DRIVER=\"", (driverName.empty() ? "?" : driverName), "\" -> 0x00(not found)"); + // Response: [uint8 result=0] (driver NOT found = clean) + // VMaNGOS: result != 0 means "found". 0x01 would mean VM driver detected! + resultData.push_back(0x00); break; } case CT_MODULE: { @@ -10126,23 +10152,34 @@ void GameHandler::handleWardenData(network::Packet& packet) { std::memcpy(reqHash, p + 4, 20); pos += moduleSize; - // CMaNGOS uppercases module names before hashing. - // DB module scans: - // KERNEL32.DLL (wanted=true) - // WPESPY.DLL / SPEEDHACK-I386.DLL / TAMIA.DLL (wanted=false) bool shouldReportFound = false; - if (hmacSha1Matches(seedBytes, "KERNEL32.DLL", reqHash)) { - shouldReportFound = true; - } else if (hmacSha1Matches(seedBytes, "WPESPY.DLL", reqHash) || - hmacSha1Matches(seedBytes, "SPEEDHACK-I386.DLL", reqHash) || - hmacSha1Matches(seedBytes, "TAMIA.DLL", reqHash)) { - shouldReportFound = false; - } - resultData.push_back(shouldReportFound ? 0x4A : 0x01); + std::string modName = "?"; + // Wanted system modules + if (hmacSha1Matches(seedBytes, "KERNEL32.DLL", reqHash)) { modName = "KERNEL32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "USER32.DLL", reqHash)) { modName = "USER32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "NTDLL.DLL", reqHash)) { modName = "NTDLL.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "WS2_32.DLL", reqHash)) { modName = "WS2_32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "WSOCK32.DLL", reqHash)) { modName = "WSOCK32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "ADVAPI32.DLL", reqHash)) { modName = "ADVAPI32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "SHELL32.DLL", reqHash)) { modName = "SHELL32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "GDI32.DLL", reqHash)) { modName = "GDI32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "OPENGL32.DLL", reqHash)) { modName = "OPENGL32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "WINMM.DLL", reqHash)) { modName = "WINMM.DLL"; shouldReportFound = true; } + // Unwanted cheat modules + else if (hmacSha1Matches(seedBytes, "WPESPY.DLL", reqHash)) modName = "WPESPY.DLL"; + else if (hmacSha1Matches(seedBytes, "SPEEDHACK-I386.DLL", reqHash)) modName = "SPEEDHACK-I386.DLL"; + else if (hmacSha1Matches(seedBytes, "TAMIA.DLL", reqHash)) modName = "TAMIA.DLL"; + else if (hmacSha1Matches(seedBytes, "PRXDRVPE.DLL", reqHash)) modName = "PRXDRVPE.DLL"; + else if (hmacSha1Matches(seedBytes, "D3DHOOK.DLL", reqHash)) modName = "D3DHOOK.DLL"; + else if (hmacSha1Matches(seedBytes, "NJUMD.DLL", reqHash)) modName = "NJUMD.DLL"; + LOG_WARNING("Warden: (sync) MODULE \"", modName, + "\" -> 0x", [&]{char s[4];snprintf(s,4,"%02x",shouldReportFound?0x4A:0x00);return std::string(s);}(), + "(", shouldReportFound ? "found" : "not found", ")"); + resultData.push_back(shouldReportFound ? 0x4A : 0x00); break; } // Truncated module request fallback: module NOT loaded = clean - resultData.push_back(0x01); + resultData.push_back(0x00); break; } case CT_PROC: { @@ -10151,12 +10188,23 @@ void GameHandler::handleWardenData(network::Packet& packet) { int procSize = 30; if (pos + procSize > checkEnd) { pos = checkEnd; break; } pos += procSize; + LOG_WARNING("Warden: (sync) PROC check -> 0x01(not found)"); // Response: [uint8 result=1] (proc NOT found = clean) resultData.push_back(0x01); break; } default: { - LOG_WARNING("Warden: Unknown check type, cannot parse remaining"); + uint8_t rawByte = decrypted[pos - 1]; + uint8_t decoded = rawByte ^ xorByte; + LOG_WARNING("Warden: Unknown check type raw=0x", + [&]{char s[4];snprintf(s,4,"%02x",rawByte);return std::string(s);}(), + " decoded=0x", + [&]{char s[4];snprintf(s,4,"%02x",decoded);return std::string(s);}(), + " xorByte=0x", + [&]{char s[4];snprintf(s,4,"%02x",xorByte);return std::string(s);}(), + " opcodes=[", + [&]{std::string r;for(int i=0;i<9;i++){char s[6];snprintf(s,6,"0x%02x ",wardenCheckOpcodes_[i]);r+=s;}return r;}(), + "] pos=", pos, "/", checkEnd); pos = checkEnd; // stop parsing break; } diff --git a/src/game/warden_memory.cpp b/src/game/warden_memory.cpp index 26895784..33127e2c 100644 --- a/src/game/warden_memory.cpp +++ b/src/game/warden_memory.cpp @@ -1,5 +1,6 @@ #include "game/warden_memory.hpp" #include "core/logger.hpp" +#include #include #include #include @@ -406,10 +407,31 @@ void WardenMemory::patchRuntimeGlobals() { writeLE32(WORLD_ENABLES, enables); LOG_WARNING("WardenMemory: Patched WorldEnables @0x", std::hex, WORLD_ENABLES, std::dec); - // LastHardwareAction + // LastHardwareAction — must be a recent GetTickCount()-style timestamp + // so the anti-AFK scan sees (currentTime - lastAction) < threshold. constexpr uint32_t LAST_HARDWARE_ACTION = 0xCF0BC8; - writeLE32(LAST_HARDWARE_ACTION, 60000); + uint32_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + writeLE32(LAST_HARDWARE_ACTION, nowMs - 2000); LOG_WARNING("WardenMemory: Patched LastHardwareAction @0x", std::hex, LAST_HARDWARE_ACTION, std::dec); + + // Embed the 37-byte Warden module memcpy pattern in BSS so that + // FIND_CODE_BY_HASH (PAGE_B) brute-force search can find it. + // This is the pattern VMaNGOS's "Warden Memory Read check" looks for. + constexpr uint32_t MEMCPY_PATTERN_VA = 0xCE8700; + static const uint8_t kWardenMemcpyPattern[37] = { + 0x56, 0x57, 0xFC, 0x8B, 0x54, 0x24, 0x14, 0x8B, + 0x74, 0x24, 0x10, 0x8B, 0x44, 0x24, 0x0C, 0x8B, + 0xCA, 0x8B, 0xF8, 0xC1, 0xE9, 0x02, 0x74, 0x02, + 0xF3, 0xA5, 0xB1, 0x03, 0x23, 0xCA, 0x74, 0x02, + 0xF3, 0xA4, 0x5F, 0x5E, 0xC3 + }; + uint32_t patRva = MEMCPY_PATTERN_VA - imageBase_; + if (patRva + sizeof(kWardenMemcpyPattern) <= imageSize_) { + std::memcpy(image_.data() + patRva, kWardenMemcpyPattern, sizeof(kWardenMemcpyPattern)); + LOG_WARNING("WardenMemory: Embedded Warden memcpy pattern at 0x", std::hex, MEMCPY_PATTERN_VA, std::dec); + } } void WardenMemory::patchTurtleWowBinary() { @@ -837,7 +859,8 @@ void WardenMemory::verifyWardenScanEntries() { } bool WardenMemory::searchCodePattern(const uint8_t seed[4], const uint8_t expectedHash[20], - uint8_t patternLen, bool imageOnly) const { + uint8_t patternLen, bool imageOnly, + uint32_t hintOffset, bool hintOnly) const { if (!loaded_ || patternLen == 0 || patternLen > 255) return false; // Build cache key from all inputs: seed(4) + hash(20) + patLen(1) + imageOnly(1) @@ -849,21 +872,56 @@ bool WardenMemory::searchCodePattern(const uint8_t seed[4], const uint8_t expect auto cacheIt = codePatternCache_.find(cacheKey); if (cacheIt != codePatternCache_.end()) { - LOG_WARNING("WardenMemory: Code pattern cache HIT → ", - cacheIt->second ? "found" : "not found"); return cacheIt->second; } - // FIND_MEM_IMAGE_CODE_BY_HASH (imageOnly=true) searches ALL sections of - // the PE image — not just executable ones. The original Warden module - // walks every PE section when scanning the WoW.exe memory image. - // FIND_CODE_BY_HASH (imageOnly=false) searches all process memory; since - // we only have the PE image, both cases search the full image. + // --- Fast path: check the hint offset directly (single HMAC) --- + // The PAGE_A offset field is the RVA where the server expects the pattern. + if (hintOffset > 0 && hintOffset + patternLen <= imageSize_) { + uint8_t hmacOut[20]; + unsigned int hmacLen = 0; + HMAC(EVP_sha1(), seed, 4, + image_.data() + hintOffset, patternLen, + hmacOut, &hmacLen); + if (hmacLen == 20 && std::memcmp(hmacOut, expectedHash, 20) == 0) { + LOG_WARNING("WardenMemory: Code pattern found at hint RVA 0x", std::hex, + hintOffset, std::dec, " (direct hit)"); + codePatternCache_[cacheKey] = true; + return true; + } + } + + // --- Wider hint window: search ±4096 bytes around hint offset --- + if (hintOffset > 0) { + size_t winStart = (hintOffset > 4096) ? hintOffset - 4096 : 0; + size_t winEnd = std::min(static_cast(hintOffset) + 4096 + patternLen, + static_cast(imageSize_)); + if (winEnd > winStart + patternLen) { + for (size_t i = winStart; i + patternLen <= winEnd; i++) { + if (i == hintOffset) continue; // already checked + uint8_t hmacOut[20]; + unsigned int hmacLen = 0; + HMAC(EVP_sha1(), seed, 4, + image_.data() + i, patternLen, + hmacOut, &hmacLen); + if (hmacLen == 20 && std::memcmp(hmacOut, expectedHash, 20) == 0) { + LOG_WARNING("WardenMemory: Code pattern found at RVA 0x", std::hex, i, + std::dec, " (hint window, delta=", static_cast(i) - static_cast(hintOffset), ")"); + codePatternCache_[cacheKey] = true; + return true; + } + } + } + } + + // If hint-only mode, skip the expensive brute-force search. + if (hintOnly) return false; + + // --- Brute-force fallback: search all PE sections --- struct Range { size_t start; size_t end; }; std::vector ranges; if (imageOnly && image_.size() >= 64) { - // Collect ALL PE sections (not just executable ones) uint32_t peOffset = image_[0x3C] | (uint32_t(image_[0x3D]) << 8) | (uint32_t(image_[0x3E]) << 16) | (uint32_t(image_[0x3F]) << 24); if (peOffset + 4 + 20 <= image_.size()) { @@ -885,11 +943,14 @@ bool WardenMemory::searchCodePattern(const uint8_t seed[4], const uint8_t expect } if (ranges.empty()) { - // Fallback: search entire image if (patternLen <= imageSize_) ranges.push_back({0, imageSize_}); } + auto bruteStart = std::chrono::steady_clock::now(); + LOG_WARNING("WardenMemory: Brute-force searching ", ranges.size(), " section(s), hint=0x", + std::hex, hintOffset, std::dec, " patLen=", (int)patternLen); + size_t totalPositions = 0; for (const auto& r : ranges) { size_t positions = r.end - r.start - patternLen + 1; @@ -900,8 +961,11 @@ bool WardenMemory::searchCodePattern(const uint8_t seed[4], const uint8_t expect image_.data() + r.start + i, patternLen, hmacOut, &hmacLen); if (hmacLen == 20 && std::memcmp(hmacOut, expectedHash, 20) == 0) { + auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - bruteStart).count(); LOG_WARNING("WardenMemory: Code pattern found at RVA 0x", std::hex, - r.start + i, std::dec, " (searched ", totalPositions + i + 1, " positions)"); + r.start + i, std::dec, " (searched ", totalPositions + i + 1, + " positions in ", elapsed, "s)"); codePatternCache_[cacheKey] = true; return true; } @@ -909,8 +973,10 @@ bool WardenMemory::searchCodePattern(const uint8_t seed[4], const uint8_t expect totalPositions += positions; } + auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - bruteStart).count(); LOG_WARNING("WardenMemory: Code pattern NOT found after ", totalPositions, " positions in ", - ranges.size(), " section(s)"); + ranges.size(), " section(s), took ", elapsed, "s"); codePatternCache_[cacheKey] = false; return false; } From e38324619e31198a010b66f6508431d073d1cd76 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 07:42:01 -0700 Subject: [PATCH 02/42] fix: resolve missing Classic spell icons on action bar and talents When Classic is active, loadDBC("Spell.dbc") finds the WotLK base DBC (234 fields) since no binary Classic DBC exists. The Classic layout says IconID is at field 117, but in the WotLK DBC that field contains unrelated data (mostly zeros). This caused all spell icon lookups to fail silently. Now detects the DBC/layout field count mismatch and falls back to the WotLK field index 133, which is correct for the base DBC. Classic spell IDs are a subset of WotLK, so the icon mapping works correctly. --- src/ui/game_screen.cpp | 24 +++++++++++++++--------- src/ui/talent_screen.cpp | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 287f0456..eab06871 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6551,17 +6551,23 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage } }; - // Always use expansion-aware layout if available - // Field indices vary by expansion: Classic=117, TBC=124, WotLK=133 + // Use expansion-aware layout if available AND the DBC field count + // matches the expansion's expected format. Classic=173, TBC=216, + // WotLK=234 fields. When Classic is active but the base WotLK DBC + // is loaded (234 fields), field 117 is NOT IconID — we must use + // the WotLK field 133 instead. + uint32_t iconField = 133; // WotLK default + uint32_t idField = 0; if (spellL) { - tryLoadIcons((*spellL)["ID"], (*spellL)["IconID"]); - } - - // Fallback if expansion layout missing or yielded nothing - // Only use WotLK field 133 as last resort if we have no layout - if (spellIconIds_.empty() && !spellL && fieldCount > 133) { - tryLoadIcons(0, 133); + uint32_t layoutIcon = (*spellL)["IconID"]; + // Only trust the expansion layout if the DBC has a compatible + // field count (within ~20 of the layout's icon field). + if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { + iconField = layoutIcon; + idField = (*spellL)["ID"]; + } } + tryLoadIcons(idField, iconField); } } diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index bed817ab..c2b92eff 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -593,15 +593,27 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { if (!dbc || !dbc->isLoaded()) return; const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + uint32_t fieldCount = dbc->getFieldCount(); + // Detect DBC/layout mismatch: Classic layout expects ~173 fields but we may + // load the WotLK base DBC (234 fields). Use WotLK field indices in that case. + uint32_t idField = 0, iconField = 133, tooltipField = 139; + if (spellL) { + uint32_t layoutIcon = (*spellL)["IconID"]; + if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { + idField = (*spellL)["ID"]; + iconField = layoutIcon; + try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + } + } uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { - uint32_t spellId = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); + uint32_t spellId = dbc->getUInt32(i, idField); if (spellId == 0) continue; - uint32_t iconId = dbc->getUInt32(i, spellL ? (*spellL)["IconID"] : 133); + uint32_t iconId = dbc->getUInt32(i, iconField); spellIconIds[spellId] = iconId; - std::string tooltip = dbc->getString(i, spellL ? (*spellL)["Tooltip"] : 139); + std::string tooltip = dbc->getString(i, tooltipField); if (!tooltip.empty()) { spellTooltips[spellId] = tooltip; } From 203514abc71e8140ddb297eff90a343ec71d827f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 08:18:46 -0700 Subject: [PATCH 03/42] fix: correct minimap orientation and arrow direction, compact key ring UI Remove stray X-flip in minimap display shader that mirrored the map horizontally (West on right instead of East). Fix arrow rotation fallback path (missing negation) and add character-facing-relative arrow in rotateWithCamera mode. Compact key ring: 24px slots in 8-column grid, only show rows with items, hide when empty. Add Show Key Ring toggle in Settings with persistence. --- assets/shaders/minimap_display.frag.glsl | 2 +- assets/shaders/minimap_display.frag.spv | Bin 6552 -> 3808 bytes include/ui/game_screen.hpp | 1 + include/ui/inventory_screen.hpp | 3 + src/rendering/minimap.cpp | 7 +- src/ui/game_screen.cpp | 10 +++ src/ui/inventory_screen.cpp | 78 +++++++++++++---------- 7 files changed, 63 insertions(+), 38 deletions(-) diff --git a/assets/shaders/minimap_display.frag.glsl b/assets/shaders/minimap_display.frag.glsl index 3f4d42e4..476017b9 100644 --- a/assets/shaders/minimap_display.frag.glsl +++ b/assets/shaders/minimap_display.frag.glsl @@ -40,7 +40,7 @@ void main() { float cs = cos(push.rotation); float sn = sin(push.rotation); vec2 rotated = vec2(center.x * cs - center.y * sn, center.x * sn + center.y * cs); - vec2 mapUV = push.playerUV + vec2(-rotated.x, rotated.y) * push.zoomRadius * 2.0; + vec2 mapUV = push.playerUV + vec2(rotated.x, rotated.y) * push.zoomRadius * 2.0; vec4 mapColor = texture(uComposite, mapUV); diff --git a/assets/shaders/minimap_display.frag.spv b/assets/shaders/minimap_display.frag.spv index 5c0ac7b00a4d21d05c44ef1d7a25ad0cd1187c6d..f33deef2ea4598479ae64500b28003ff7ea911c6 100644 GIT binary patch literal 3808 zcmZ9OTZoob6ox;?)GWs)(^Nd5j+GsdYAU1}%?KPwY9^xt2{~!uq-Hi&8l@9vUPMMW z1(`%mI$C+iP8LXFl3pZqfFkI+G`!4A(dv1A_bWec{x$nu?|S#zYahPvoBj>WgQ}`u zbz#+5eb%q4wTr6;mSs?lGn60fEP%SzGtLD;&Gp}3|7OtUiqv|-TP&c-Yb3VBVb=-i$O|Ihx z7H$gtEb6ot=djKl^ph}K{E27Wn(x}Oj!PKjxQXPj9`U6h^H0D=lF#+)8yn8wq4Zb7 z%e7&)3HbBqNnO+5QvA-sML!>(Hp9GK0CTwJ5&V6eegbBW@zvnkn$%=|wVFD6=Uhxb z7<2v3slTLf&Z*Wzbr01qEiw7Tp90(BKM!`T>R+HYzw;&j7T6k%Z=g4BjfwYyU6a1! z`Y_sUqfveYkaK)}i$8|F-$p>4`L`F5oa@u~d~KQUbFgQZ`Mw4p?byBN8dhyxdoj;R zKAL^tqaD-s#Qz5Wf?s-X&6xf8YUHE&7R;E|Xb#}ZsToCW2kD*9y~~*I!By2$JMY6F z*Ir-i5&XZW%$*VapZJHqo821!FZ`v$)=<~L%JC;LbH<`O1=rHjeKyY^HuSZ9$EGLV zgx}KAGQ&L`3$}(X%stijnMhsUjb?mn&02Th&&StpVqaaOXKZuaKQP|0R#^Ao5v&ti zQ_kSg!kK3Xna#Zr!>w+|jL$^t{d^Mh4tsCSXFba>`47+xrSC4BclS6IJWX#qiQd?9 zOwPK*UzKssf}PiOIBq4q&2h$7U~-NVpQ1vs+zI;Rw6|WLk5!oU`rKHrYg>)UXRSS8 z*J|y?!oO7b#=>`y)A8#t_rw0=8(^rH^KE|%&*Qh`^>+Mxn|!5IrEK$Z%5-Dcn@=Kb7imI2Rp78%U*2)%daov zHiI22A8rd+z7Kn`=(d8L)0nxo(eK23KirST`@O4hYE#1(U~BNbks5Y`<#R4yf?dDo zWi0%j!Z#K^dwvMtSl0dn*c|fVegvzLmpe%R6Qt$U(7k+OY_iN$y*KxlU?f`wB zvzsYetvSNA!g+7xa+emsskMcB3}3BuxGo%Z_1q#Hb++80#rSHCWluf^%jZrl0rRWf zL+5usHK}tZ*fm*yxI5siT`n~(gHvk@*Nv}MI@~ij>gu@_IO=SveI>qHW2t=?SU$Bs z2j*9+-T9qQP3mj|yT8^S?oK#smrG5n;MCf}_28?O4z~tJT|KuJN1ZLTzl^WeSZeHxy?n_fv?tBbaTMwuh-p+uhv-JyZ3=J zN4N*z&Yj~yIJL&2dkB2)9CP8+8tW_dJq&hkV_SG1xVCv zBdErfwZ97|pZTNhU$k^*wY@mU z4bbwfZ^W1L9L0GKJ_Va6IfsGy)t&=mO_=ALJsys4J(|tVn*0X2ZzCYom?vu*36{5I zO?~+C$rJ79qE#O4RvdZ9$VWQ{$Gr7>^&!5zW8}SOS+{-e*>;M&aG@y-lNWil!4 zX?u|^mZoH7R$5|`MSE$hNu_2)i;3-#Xlng__kIt(+>i5|=l}el^Y0DasV5mQF>9>G88ayxoi(AAzCx*AM?o9s zWa8v$0BWRamhFab%38Dbj;{9AtJ^zPc6AR7bPWz|=`D8kmimibJ*7&qd#JRzXxx}2 zuGHV%JKU27G!v2bZKl(BdURLHgM$lt=5F4wV2J{iHPgQp?QG(4==D`RE}M=XD3|(& z&h1}UDHZxR_7)H2*4LP9Y&EYjn}UvdQ^B=*joCDCcOEkb-E*=%o3i=ffkLIwSM@bz zCmv=uW`t!u3u>{84)Zl-ix0CKGZ)pfuok=gFke%)qRu`CdsAWXyyC`Md}qz>EDzS~ zF6{1N|4^}_-CSMMy5eOk%H>KAuIlU?&^@KWp|D>K=o}v0q}@VKfelfqZ7~z-zI)t9#IxjaY+8Jg05o>TJcHs=Xb% zuP{(ufcx{An!TdjTV}^Y_hDrm(Hf4Ljy~PcO_Wc3uN@?RJ3H=YD(>wKWoxQit-dAVu$6n7l$JzVdmWPK9 zt*xCN1!9gxd{)sl&U$u@I>eEh$@z{%oaZceRKhjp+%XB~*&5fDaGtB&@d@Xd%FRXE z*-K}!hjV%c$00sp&#IYK+qK0yrjcddENVE9_(+iT+mIR5^Sh<3uMK+)T27h8K8!`{ zmsh3}IT1aJh)HO1_(C5+og?Af5T6rrdq&Ny_HnPGclc+*H6YHhGWT0wzcZ{teDuEx zZLBqxZn3%~}#W=M+d(rNPvG&yVeUr1N zvAkaFS}nee)+ZnS>dTbauLH;4Uk*0jei8p6uru2KTC{#=4F3&a*Q9OU4ut0{<39~B zwuBr_=4a8ySTB9Tp2^*~1&49we=ZM_bG_Q$KcDD-JJ`Dw{eKQ_-7|aFJa)m}_amO0 ze8l_$+`8xbyTblu%|0V?euZs}d~gruwp`>r0?x9n%fkK>*1h+1ESpDX*Ze4AjJz|5 zBla=yHMlcD&-oHW{$^q(qWS5q za=!PEQ)3wI^Ca>5{Ed_I4v4K||ILW~?#%04nsctpI$O{_*3owvB4-`(Q%o=Jkm4`wfb|SLA%`!&Yqf!L{gfAH08Ck*M1TAkJ z&-IOnXW;Kd%=4z45Bts7_TNU1`}7vX82PC4Rt}o~oP5-IA2?m>`>~CYAIWF@0NDEa z;{JaS?Ckp7XTJw>_P!SVVZ`(Dx5Zwg(H}wNW4@1q%}dYXW7zUn=RN!+;ygDXvGyBt{%T^J?+KI`m6%g4EW73@9Iw+pRpkE76EL*&vk{CdvE?7Oj@E#8A~ zfQ^xlI^P6aCq371VarFIZ-bpJ>U;-mjC{OL-vwJ=U)-VZf#svu_rdiu{{T)tdffv~ zd)KBOP9-o@y@A@cD%`uCixpq=@jXdh?R_YXwQnZ+^Jzrgm3 zd--p$+yNx+<>TN-5PjzP9e4t9&8u0@LG+V3C-39hMj?KS^#2Dj##(U)p8{KB26BFW zpPmLA>*HP+BNsW(fX$hSM9zP~=GfC5W8@;I0f#vTM9wI%InHQ~F>;u4jSeBjy;ext?RhJR2+@Yd;ojo_ypz2W0>({@STj)JJ3Gpr(qtBQ*X!)qW0PJ3ey%6loG2| zRB$@uY1nDb>Db1|+tYowj@UidA2YrH?2N|fGsbh1kA7!>-Gi{t1UqxgxD;%Re8emR zr!$^~o#vd4ZH&Anr; Ob6yFS_q!wZyYoNZmUa~Y diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index a6ebf9c0..6c1244ae 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -192,6 +192,7 @@ private: bool pendingMinimapNpcDots = false; bool pendingShowLatencyMeter = true; bool pendingSeparateBags = true; + bool pendingShowKeyring = true; bool pendingAutoLoot = false; // Keybinding customization diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 65ef41c9..b9c30c6c 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -39,6 +39,8 @@ public: bool isSeparateBags() const { return separateBags_; } void toggleCompactBags() { compactBags_ = !compactBags_; } bool isCompactBags() const { return compactBags_; } + void setShowKeyring(bool show) { showKeyring_ = show; } + bool isShowKeyring() const { return showKeyring_; } bool isBackpackOpen() const { return backpackOpen_; } bool isBagOpen(int idx) const { return idx >= 0 && idx < 4 ? bagOpen_[idx] : false; } @@ -79,6 +81,7 @@ private: bool bKeyWasDown = false; bool separateBags_ = true; bool compactBags_ = false; + bool showKeyring_ = true; bool backpackOpen_ = false; std::array bagOpen_{}; bool cKeyWasDown = false; diff --git a/src/rendering/minimap.cpp b/src/rendering/minimap.cpp index 0f44869b..f47264d0 100644 --- a/src/rendering/minimap.cpp +++ b/src/rendering/minimap.cpp @@ -513,14 +513,15 @@ void Minimap::render(VkCommandBuffer cmd, const Camera& playerCamera, float arrowRotation = 0.0f; if (!rotateWithCamera) { - // Prefer authoritative orientation if provided. This value is expected - // to already match minimap shader rotation convention. if (hasPlayerOrientation) { arrowRotation = playerOrientation; } else { glm::vec3 fwd = playerCamera.getForward(); - arrowRotation = std::atan2(-fwd.x, fwd.y); + arrowRotation = -std::atan2(-fwd.x, fwd.y); } + } else if (hasPlayerOrientation) { + // Show character facing relative to the rotated map + arrowRotation = playerOrientation + rotation; } MinimapDisplayPush push{}; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index eab06871..64a6155e 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -15326,6 +15326,10 @@ void GameScreen::renderSettingsWindow() { inventoryScreen.setSeparateBags(pendingSeparateBags); saveSettings(); } + if (ImGui::Checkbox("Show Key Ring", &pendingShowKeyring)) { + inventoryScreen.setShowKeyring(pendingShowKeyring); + saveSettings(); + } ImGui::Spacing(); ImGui::Separator(); @@ -15341,6 +15345,8 @@ void GameScreen::renderSettingsWindow() { pendingMinimapNpcDots = false; pendingSeparateBags = true; inventoryScreen.setSeparateBags(true); + pendingShowKeyring = true; + inventoryScreen.setShowKeyring(true); uiOpacity_ = 0.65f; minimapRotate_ = false; minimapSquare_ = false; @@ -17244,6 +17250,7 @@ void GameScreen::saveSettings() { out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n"; out << "show_cooldown_tracker=" << (showCooldownTracker_ ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; + out << "show_keyring=" << (pendingShowKeyring ? 1 : 0) << "\n"; out << "action_bar_scale=" << pendingActionBarScale << "\n"; out << "nameplate_scale=" << nameplateScale_ << "\n"; out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; @@ -17365,6 +17372,9 @@ void GameScreen::loadSettings() { } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); + } else if (key == "show_keyring") { + pendingShowKeyring = (std::stoi(val) != 0); + inventoryScreen.setShowKeyring(pendingShowKeyring); } else if (key == "action_bar_scale") { pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); } else if (key == "nameplate_scale") { diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 3d4b0c17..0c826c7d 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1069,20 +1069,29 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, ImGui::PopID(); } - if (bagIndex < 0) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); - for (int i = 0; i < inventory.getKeyringSize(); ++i) { - if (i % columns != 0) ImGui::SameLine(); - const auto& slot = inventory.getKeyringSlot(i); - char id[32]; - snprintf(id, sizeof(id), "##skr_%d", i); - ImGui::PushID(id); - // Keyring is display-only for now. - renderItemSlot(inventory, slot, slotSize, nullptr, - SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); - ImGui::PopID(); + if (bagIndex < 0 && showKeyring_) { + constexpr float keySlotSize = 24.0f; + constexpr int keyCols = 8; + // Only show rows that contain items (round up to full row) + int lastOccupied = -1; + for (int i = inventory.getKeyringSize() - 1; i >= 0; --i) { + if (!inventory.getKeyringSlot(i).empty()) { lastOccupied = i; break; } + } + int visibleSlots = (lastOccupied < 0) ? 0 : ((lastOccupied / keyCols) + 1) * keyCols; + if (visibleSlots > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); + for (int i = 0; i < visibleSlots; ++i) { + if (i % keyCols != 0) ImGui::SameLine(); + const auto& slot = inventory.getKeyringSlot(i); + char id[32]; + snprintf(id, sizeof(id), "##skr_%d", i); + ImGui::PushID(id); + renderItemSlot(inventory, slot, keySlotSize, nullptr, + SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); + ImGui::PopID(); + } } } @@ -2042,27 +2051,28 @@ void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool colla } } - bool keyringHasAnyItems = false; - for (int i = 0; i < inventory.getKeyringSize(); ++i) { - if (!inventory.getKeyringSlot(i).empty()) { - keyringHasAnyItems = true; - break; + if (showKeyring_) { + constexpr float keySlotSize = 24.0f; + constexpr int keyCols = 8; + int lastOccupied = -1; + for (int i = inventory.getKeyringSize() - 1; i >= 0; --i) { + if (!inventory.getKeyringSlot(i).empty()) { lastOccupied = i; break; } } - } - if (!collapseEmptySections || keyringHasAnyItems) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); - for (int i = 0; i < inventory.getKeyringSize(); ++i) { - if (i % columns != 0) ImGui::SameLine(); - const auto& slot = inventory.getKeyringSlot(i); - char sid[32]; - snprintf(sid, sizeof(sid), "##keyring_%d", i); - ImGui::PushID(sid); - // Keyring is display-only for now. - renderItemSlot(inventory, slot, slotSize, nullptr, - SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); - ImGui::PopID(); + int visibleSlots = (lastOccupied < 0) ? 0 : ((lastOccupied / keyCols) + 1) * keyCols; + if (visibleSlots > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); + for (int i = 0; i < visibleSlots; ++i) { + if (i % keyCols != 0) ImGui::SameLine(); + const auto& slot = inventory.getKeyringSlot(i); + char sid[32]; + snprintf(sid, sizeof(sid), "##keyring_%d", i); + ImGui::PushID(sid); + renderItemSlot(inventory, slot, keySlotSize, nullptr, + SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); + ImGui::PopID(); + } } } } From 3667ff4998ebb29f8f8901cd25f2834d3dd3977b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 09:04:40 -0700 Subject: [PATCH 04/42] fix: use uniform 22-byte loot item size for Classic/TBC/Turtle SMSG_LOOT_RESPONSE items include randomSuffix and randomPropertyId fields across all expansions, not just WotLK. Using 14-byte size for Classic/TBC caused item data to be read at wrong offsets. --- include/game/world_packets.hpp | 5 +++-- src/game/world_packets.cpp | 33 +++++++++++++-------------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 293953ea..c2e92f06 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -2060,8 +2060,9 @@ public: /** SMSG_LOOT_RESPONSE parser */ class LootResponseParser { public: - // isWotlkFormat: true for WotLK 3.3.5a (22 bytes/item with randomSuffix+randomProp), - // false for Classic 1.12 and TBC 2.4.3 (14 bytes/item). + // isWotlkFormat: true for WotLK (has trailing quest item section), + // false for Classic/TBC (no quest item section). + // Per-item size is 22 bytes across all expansions. static bool parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat = true); }; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 5a9804a1..5d322ff1 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -4257,10 +4257,9 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, data.gold = packet.readUInt32(); uint8_t itemCount = packet.readUInt8(); - // Item wire size: - // WotLK 3.3.5a: slot(1)+itemId(4)+count(4)+displayInfo(4)+randSuffix(4)+randProp(4)+slotType(1) = 22 - // Classic/TBC: slot(1)+itemId(4)+count(4)+displayInfo(4)+slotType(1) = 14 - const size_t kItemSize = isWotlkFormat ? 22u : 14u; + // Per-item wire size is 22 bytes across all expansions: + // slot(1)+itemId(4)+count(4)+displayInfo(4)+randSuffix(4)+randProp(4)+slotType(1) = 22 + constexpr size_t kItemSize = 22u; auto parseLootItemList = [&](uint8_t listCount, bool markQuestItems) -> bool { for (uint8_t i = 0; i < listCount; ++i) { @@ -4270,21 +4269,14 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, } LootItem item; - item.slotIndex = packet.readUInt8(); - item.itemId = packet.readUInt32(); - item.count = packet.readUInt32(); - item.displayInfoId = packet.readUInt32(); - - if (isWotlkFormat) { - item.randomSuffix = packet.readUInt32(); - item.randomPropertyId = packet.readUInt32(); - } else { - item.randomSuffix = 0; - item.randomPropertyId = 0; - } - - item.lootSlotType = packet.readUInt8(); - item.isQuestItem = markQuestItems; + item.slotIndex = packet.readUInt8(); + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.randomSuffix = packet.readUInt32(); + item.randomPropertyId = packet.readUInt32(); + item.lootSlotType = packet.readUInt8(); + item.isQuestItem = markQuestItems; data.items.push_back(item); } return true; @@ -4296,8 +4288,9 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, return false; } + // Quest item section only present in WotLK 3.3.5a uint8_t questItemCount = 0; - if (packet.getSize() - packet.getReadPos() >= 1) { + if (isWotlkFormat && packet.getSize() - packet.getReadPos() >= 1) { questItemCount = packet.readUInt8(); data.items.reserve(data.items.size() + questItemCount); if (!parseLootItemList(questItemCount, true)) { From cf3fe70f1f245c82a7b1adab6a42d92205d692be Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 09:04:47 -0700 Subject: [PATCH 05/42] fix: hide window on shutdown to prevent OS force-close dialog SDL_HideWindow immediately on shutdown so the OS doesn't show a "not responding" dialog during the slow cleanup process. --- src/core/application.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index 76556a6e..22e93abc 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -576,6 +576,12 @@ void Application::run() { void Application::shutdown() { LOG_WARNING("Shutting down application..."); + // Hide the window immediately so the OS doesn't think the app is frozen + // during the (potentially slow) resource cleanup below. + if (window && window->getSDLWindow()) { + SDL_HideWindow(window->getSDLWindow()); + } + // Stop background world preloader before destroying AssetManager cancelWorldPreload(); From 192c6175b848260c7eda75481447cf566b906fd2 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 09:04:53 -0700 Subject: [PATCH 06/42] feat: add brightness slider to Video settings Black overlay dims below 50%, white overlay brightens above 50%. Persisted in settings.cfg, with restore-defaults support. --- include/rendering/renderer.hpp | 7 +++++++ include/ui/game_screen.hpp | 1 + src/rendering/renderer.cpp | 14 ++++++++++++++ src/ui/game_screen.cpp | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index aed61820..07c6ebd6 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -381,6 +381,13 @@ private: void initOverlayPipeline(); void renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); + // Brightness (1.0 = default, <1 darkens, >1 brightens) + float brightness_ = 1.0f; +public: + void setBrightness(float b) { brightness_ = b; } + float getBrightness() const { return brightness_; } +private: + // FSR 1.0 upscaling state struct FSRState { bool enabled = false; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 6c1244ae..75502dee 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -171,6 +171,7 @@ private: bool pendingShadows = true; float pendingShadowDistance = 300.0f; bool pendingWaterRefraction = false; + int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default) int pendingMasterVolume = 100; int pendingMusicVolume = 30; int pendingAmbientVolume = 100; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index f7f07e42..e20a0d6c 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -5287,6 +5287,13 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint, cmd); } } + // Brightness overlay (applied before minimap so it doesn't affect UI) + if (brightness_ < 0.99f) { + renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - brightness_), cmd); + } else if (brightness_ > 1.01f) { + float alpha = (brightness_ - 1.0f) / 1.0f; // maps 1.0-2.0 → 0.0-1.0 + renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha), cmd); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -5421,6 +5428,13 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint); } } + // Brightness overlay (applied before minimap so it doesn't affect UI) + if (brightness_ < 0.99f) { + renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - brightness_)); + } else if (brightness_ > 1.01f) { + float alpha = (brightness_ - 1.0f) / 1.0f; + renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha)); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 64a6155e..1f62ac01 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -14900,6 +14900,16 @@ void GameScreen::renderSettingsWindow() { ImGui::Separator(); ImGui::Spacing(); + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderInt("Brightness", &pendingBrightness, 0, 100, "%d%%")) { + if (renderer) renderer->setBrightness(static_cast(pendingBrightness) / 50.0f); + saveSettings(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + if (ImGui::Button("Restore Video Defaults", ImVec2(-1, 0))) { pendingFullscreen = kDefaultFullscreen; pendingVsync = kDefaultVsync; @@ -14912,9 +14922,11 @@ void GameScreen::renderSettingsWindow() { pendingPOM = true; pendingPOMQuality = 1; pendingResIndex = defaultResIndex; + pendingBrightness = 50; window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); + if (renderer) renderer->setBrightness(1.0f); pendingWaterRefraction = false; if (renderer) { renderer->setShadowsEnabled(pendingShadows); @@ -17284,6 +17296,7 @@ void GameScreen::saveSettings() { out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; out << "shadow_distance=" << pendingShadowDistance << "\n"; + out << "brightness=" << pendingBrightness << "\n"; out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; out << "fxaa=" << (pendingFXAA ? 1 : 0) << "\n"; @@ -17428,6 +17441,11 @@ void GameScreen::loadSettings() { else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); else if (key == "shadows") pendingShadows = (std::stoi(val) != 0); else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); + else if (key == "brightness") { + pendingBrightness = std::clamp(std::stoi(val), 0, 100); + if (auto* r = core::Application::getInstance().getRenderer()) + r->setBrightness(static_cast(pendingBrightness) / 50.0f); + } else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); else if (key == "fxaa") pendingFXAA = (std::stoi(val) != 0); From 502d506a446e614c39897c6a3ac2e550fd5e8cae Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:12:35 -0700 Subject: [PATCH 07/42] feat: make bag windows draggable --- src/ui/inventory_screen.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 0c826c7d..000ae9d2 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -903,10 +903,10 @@ void InventoryScreen::renderAggregateBags(game::Inventory& inventory, uint64_t m float posX = screenW - windowW - 10.0f; float posY = screenH - windowH - 60.0f; - ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; if (!ImGui::Begin("Bags", &open, flags)) { ImGui::End(); return; @@ -1030,8 +1030,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, float windowW = std::max(gridW, titleW); float windowH = contentH + 40.0f; // title bar + padding - // Keep separate bag windows anchored to the bag-bar stack. - ImGui::SetNextWindowPos(ImVec2(defaultX, defaultY), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(defaultX, defaultY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; From 7b03d5363b0c842ac0929660697ef9e57ce4041d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:12:49 -0700 Subject: [PATCH 08/42] feat: profession crafting improvements and combat sound fixes - Suppress spell sounds for profession/tradeskill spells (crafting is silent) - Add craft quantity UI to profession trainer: recipe selector, quantity input, Create button, and Stop button for active queue - Known recipes show Create button to cast directly from trainer window - Craft queue auto-recasts on CREATE_ITEM completion, cancels on failure - Fix missing combat sounds: player spell impacts on enemies, enemy spell cast sounds targeting player, instant melee ability weapon sounds --- include/game/game_handler.hpp | 10 ++ src/game/game_handler.cpp | 150 +++++++++++++++++++++------- src/ui/game_screen.cpp | 177 +++++++++++++++++++++++++++++----- 3 files changed, 278 insertions(+), 59 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 11223bbe..e8762167 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -786,6 +786,12 @@ public: float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; } float getCastTimeRemaining() const { return castTimeRemaining; } + // Repeat-craft queue + void startCraftQueue(uint32_t spellId, int count); + void cancelCraftQueue(); + int getCraftQueueRemaining() const { return craftQueueRemaining_; } + uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; } + // Unit cast state (tracked per GUID for target frame + boss frames) struct UnitCastState { bool casting = false; @@ -970,6 +976,7 @@ public: const std::map& getPlayerSkills() const { return playerSkills_; } const std::string& getSkillName(uint32_t skillId) const; uint32_t getSkillCategory(uint32_t skillId) const; + bool isProfessionSpell(uint32_t spellId) const; // World entry callback (online mode - triggered when entering world) // Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect @@ -2669,6 +2676,9 @@ private: bool castIsChannel = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; + // Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes + uint32_t craftQueueSpellId_ = 0; + int craftQueueRemaining_ = 0; // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) std::unordered_map unitCastStates_; uint64_t pendingGameObjectInteractGuid_ = 0; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9790d73e..35e34512 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2217,6 +2217,9 @@ void GameHandler::handlePacket(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; lastInteractedGoGuid_ = 0; + // Cancel craft queue on cast failure + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; // Pass player's power type so result 85 says "Not enough rage/energy/etc." int playerPowerType = -1; if (auto pe = entityManager.getEntity(playerGuid)) { @@ -6774,6 +6777,16 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage(msg); LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId, " item=", itemEntry, " name=", itemName); + + // Repeat-craft queue: re-cast if more crafts remaining + if (craftQueueRemaining_ > 0 && craftQueueSpellId_ == exeSpellId) { + --craftQueueRemaining_; + if (craftQueueRemaining_ > 0) { + castSpell(craftQueueSpellId_, 0); + } else { + craftQueueSpellId_ = 0; + } + } } } } else if (effectType == 26) { @@ -17696,6 +17709,21 @@ void GameHandler::cancelCast() { castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + // Cancel craft queue when player manually cancels cast + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; +} + +void GameHandler::startCraftQueue(uint32_t spellId, int count) { + craftQueueSpellId_ = spellId; + craftQueueRemaining_ = count; + // Cast the first one immediately + castSpell(spellId, 0); +} + +void GameHandler::cancelCraftQueue() { + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; } void GameHandler::cancelAura(uint32_t spellId) { @@ -18022,14 +18050,17 @@ void GameHandler::handleSpellStart(network::Packet& packet) { castTimeRemaining = castTimeTotal; // Play precast (channeling) sound with correct magic school - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* ssm = renderer->getSpellSoundManager()) { - loadSpellNameCache(); - auto it = spellNameCache_.find(data.spellId); - auto school = (it != spellNameCache_.end() && it->second.schoolMask) - ? schoolMaskToMagicSchool(it->second.schoolMask) - : audio::SpellSoundManager::MagicSchool::ARCANE; - ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); + // Skip sound for profession/tradeskill spells (crafting should be silent) + if (!isProfessionSpell(data.spellId)) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); + } } } @@ -18055,14 +18086,17 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Cast completed if (data.casterUnit == playerGuid) { // Play cast-complete sound with correct magic school - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* ssm = renderer->getSpellSoundManager()) { - loadSpellNameCache(); - auto it = spellNameCache_.find(data.spellId); - auto school = (it != spellNameCache_.end() && it->second.schoolMask) - ? schoolMaskToMagicSchool(it->second.schoolMask) - : audio::SpellSoundManager::MagicSchool::ARCANE; - ssm->playCast(school); + // Skip sound for profession/tradeskill spells (crafting should be silent) + if (!isProfessionSpell(data.spellId)) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playCast(school); + } } } @@ -18082,8 +18116,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) { isMeleeAbility = (currentCastSpellId != sid); } } - if (isMeleeAbility && meleeSwingCallback_) { - meleeSwingCallback_(); + if (isMeleeAbility) { + if (meleeSwingCallback_) meleeSwingCallback_(); + // Play weapon swing + impact sound for instant melee abilities (Sinister Strike, etc.) + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* csm = renderer->getCombatSoundManager()) { + csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false); + csm->playImpact(audio::CombatSoundManager::WeaponSize::MEDIUM, + audio::CombatSoundManager::ImpactType::FLESH, false); + } + } } // Capture cast state before clearing. Guard with spellId match so that @@ -18110,9 +18152,28 @@ void GameHandler::handleSpellGo(network::Packet& packet) { if (spellCastAnimCallback_) { spellCastAnimCallback_(playerGuid, false, false); } - } else if (spellCastAnimCallback_) { - // End cast animation on other unit - spellCastAnimCallback_(data.casterUnit, false, false); + } else { + if (spellCastAnimCallback_) { + // End cast animation on other unit + spellCastAnimCallback_(data.casterUnit, false, false); + } + // Play cast-complete sound for enemy spells targeting the player + bool targetsPlayer = false; + for (const auto& tgt : data.hitTargets) { + if (tgt == playerGuid) { targetsPlayer = true; break; } + } + if (targetsPlayer) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playCast(school); + } + } + } } // Clear unit cast bar when the spell lands (for any tracked unit) @@ -18133,12 +18194,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } } - // Play impact sound when player is hit by any spell (from self or others) + // Play impact sound for spell hits involving the player + // - When player is hit by an enemy spell + // - When player's spell hits an enemy target bool playerIsHit = false; + bool playerHitEnemy = false; for (const auto& tgt : data.hitTargets) { - if (tgt == playerGuid) { playerIsHit = true; break; } + if (tgt == playerGuid) { playerIsHit = true; } + if (data.casterUnit == playerGuid && tgt != playerGuid && tgt != 0) { playerHitEnemy = true; } } - if (playerIsHit && data.casterUnit != playerGuid) { + if (playerIsHit || playerHitEnemy) { if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { loadSpellNameCache(); @@ -19327,7 +19392,7 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { float dy = entity->getY() - movementInfo.y; float dz = entity->getZ() - movementInfo.z; float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); - if (dist3d > 6.0f) { + if (dist3d > 10.0f) { addSystemChatMessage("Too far away."); return; } @@ -19391,13 +19456,17 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { } } if (shouldSendLoot) { - lootTarget(guid); - // Some servers/scripts only make certain quest/chest GOs lootable after a short delay - // (use animation, state change). Queue one delayed loot attempt to catch that case. + // Don't send CMSG_LOOT immediately — give the server time to process + // CMSG_GAMEOBJ_USE first (chests need to transition to lootable state, + // gathering nodes start a spell cast). A premature CMSG_LOOT can cause + // an empty SMSG_LOOT_RESPONSE that clears our gather-cast loot state. pendingGameObjectLootOpens_.erase( std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), [&](const PendingLootOpen& p) { return p.guid == guid; }), pendingGameObjectLootOpens_.end()); + // Short delay for chests (server makes them lootable quickly after USE), + // plus a longer retry to catch slow state transitions. + pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.20f}); pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f}); } else { // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be @@ -19405,12 +19474,9 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { // guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT. lastInteractedGoGuid_ = 0; } - // Retry use briefly to survive packet loss/order races. - const bool retryLoot = shouldSendLoot; - const bool retryUse = turtleMode || isActiveExpansion("classic"); - if (retryUse || retryLoot) { - pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot}); - } + // Don't retry CMSG_GAMEOBJ_USE — resending can toggle chest state on some + // servers (opening→closing the chest). The delayed CMSG_LOOT retries above + // handle the case where the first loot attempt arrives too early. } void GameHandler::selectGossipOption(uint32_t optionId) { @@ -20584,6 +20650,13 @@ void GameHandler::handleLootResponse(network::Packet& packet) { // WotLK 3.3.5a uses 22 bytes/item. const bool wotlkLoot = isActiveExpansion("wotlk"); if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; + const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0; + // If we're mid-gather-cast and got an empty loot response (premature CMSG_LOOT + // before the node became lootable), ignore it — don't clear our gather state. + if (!hasLoot && casting && currentCastSpellId != 0 && lastInteractedGoGuid_ != 0) { + LOG_DEBUG("Ignoring empty SMSG_LOOT_RESPONSE during gather cast"); + return; + } lootWindowOpen = true; lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo pendingGameObjectLootOpens_.erase( @@ -22667,6 +22740,15 @@ uint32_t GameHandler::getSkillCategory(uint32_t skillId) const { return (it != skillLineCategories_.end()) ? it->second : 0; } +bool GameHandler::isProfessionSpell(uint32_t spellId) const { + auto slIt = spellToSkillLine_.find(spellId); + if (slIt == spellToSkillLine_.end()) return false; + auto catIt = skillLineCategories_.find(slIt->second); + if (catIt == skillLineCategories_.end()) return false; + // Category 11 = profession (Blacksmithing, etc.), 9 = secondary (Cooking, First Aid, Fishing) + return catIt->second == 11 || catIt->second == 9; +} + void GameHandler::loadSkillLineDbc() { if (skillLineDbcLoaded_) return; skillLineDbcLoaded_ = true; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1f62ac01..0d6ce5c7 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2091,18 +2091,20 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { std::string cmd = buf.substr(1, sp - 1); for (char& c : cmd) c = std::tolower(c); int detected = -1; + bool isReply = false; if (cmd == "s" || cmd == "say") detected = 0; else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; else if (cmd == "p" || cmd == "party") detected = 2; else if (cmd == "g" || cmd == "guild") detected = 3; else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; + else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; else if (cmd == "bg" || cmd == "battleground") detected = 7; else if (cmd == "rw" || cmd == "raidwarning") detected = 8; else if (cmd == "i" || cmd == "instance") detected = 9; else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. - if (detected >= 0 && (selectedChatType != detected || detected == 10)) { + if (detected >= 0 && (selectedChatType != detected || detected == 10 || isReply)) { // For channel shortcuts, also update selectedChannelIdx if (detected == 10) { int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. @@ -2114,8 +2116,16 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { selectedChatType = detected; // Strip the prefix, keep only the message part std::string remaining = buf.substr(sp + 1); - // For whisper, first word after /w is the target - if (detected == 4) { + // /r reply: pre-fill whisper target from last whisper sender + if (detected == 4 && isReply) { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (!lastSender.empty()) { + strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + } + // remaining is the message — don't extract a target from it + } else if (detected == 4) { + // For whisper, first word after /w is the target size_t msgStart = remaining.find(' '); if (msgStart != std::string::npos) { std::string wTarget = remaining.substr(0, msgStart); @@ -2576,6 +2586,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { uint64_t closestHostileUnitGuid = 0; float closestQuestGoT = 1e30f; uint64_t closestQuestGoGuid = 0; + float closestGoT = 1e30f; + uint64_t closestGoGuid = 0; const uint64_t myGuid = gameHandler.getPlayerGuid(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { auto t = entity->getType(); @@ -2598,16 +2610,8 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { heightOffset = 0.3f; } } else if (t == game::ObjectType::GAMEOBJECT) { - // For GOs with no renderer instance yet, use a tight fallback - // sphere so invisible/unloaded doodads aren't accidentally clicked. - hitRadius = 1.2f; - heightOffset = 1.0f; - // Quest objective GOs should be easier to click. - auto go = std::static_pointer_cast(entity); - if (questObjectiveGoEntries.count(go->getEntry())) { - hitRadius = 2.2f; - heightOffset = 1.2f; - } + hitRadius = 2.5f; + heightOffset = 1.2f; } hitCenter = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); @@ -2626,12 +2630,18 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { closestHostileUnitGuid = guid; } } - if (t == game::ObjectType::GAMEOBJECT && !questObjectiveGoEntries.empty()) { - auto go = std::static_pointer_cast(entity); - if (questObjectiveGoEntries.count(go->getEntry())) { - if (hitT < closestQuestGoT) { - closestQuestGoT = hitT; - closestQuestGoGuid = guid; + if (t == game::ObjectType::GAMEOBJECT) { + if (hitT < closestGoT) { + closestGoT = hitT; + closestGoGuid = guid; + } + if (!questObjectiveGoEntries.empty()) { + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + if (hitT < closestQuestGoT) { + closestQuestGoT = hitT; + closestQuestGoGuid = guid; + } } } } @@ -2643,12 +2653,23 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } - // Prefer quest objective GOs over hostile monsters when both are hittable. + // Priority: quest GO > closer of (GO, hostile unit) > closest anything. if (closestQuestGoGuid != 0) { closestGuid = closestQuestGoGuid; closestType = game::ObjectType::GAMEOBJECT; + } else if (closestGoGuid != 0 && closestHostileUnitGuid != 0) { + // Both a GO and hostile unit were hit — prefer whichever is closer. + if (closestGoT <= closestHostileUnitT) { + closestGuid = closestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else { + closestGuid = closestHostileUnitGuid; + closestType = game::ObjectType::UNIT; + } + } else if (closestGoGuid != 0) { + closestGuid = closestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; } else if (closestHostileUnitGuid != 0) { - // Prefer hostile monsters over nearby gameobjects/others when right-click picking. closestGuid = closestHostileUnitGuid; closestType = game::ObjectType::UNIT; } @@ -5951,6 +5972,28 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { message = ""; isChannelCommand = true; } + } else if (cmdLower == "r" || cmdLower == "reply") { + switchChatType = 4; + std::string lastSender = gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No one has whispered you yet."; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + target = lastSender; + strncpy(whisperTargetBuffer, target.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + if (spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::WHISPER; + } else { + message = ""; + } + isChannelCommand = true; } // Check for emote commands @@ -13624,6 +13667,7 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } const auto& trainer = gameHandler.getTrainerSpells(); + const bool isProfessionTrainer = (trainer.trainerType == 2); // NPC name auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid); @@ -13844,11 +13888,21 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { logCount++; } - if (!canTrain) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Train")) { - gameHandler.trainSpell(spell->spellId); + if (isProfessionTrainer && alreadyKnown) { + // Profession trainer: known recipes show "Create" button to craft + bool isCasting = gameHandler.isCasting(); + if (isCasting) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Create")) { + gameHandler.castSpell(spell->spellId, 0); + } + if (isCasting) ImGui::EndDisabled(); + } else { + if (!canTrain) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Train")) { + gameHandler.trainSpell(spell->spellId); + } + if (!canTrain) ImGui::EndDisabled(); } - if (!canTrain) ImGui::EndDisabled(); ImGui::PopID(); } @@ -13952,6 +14006,79 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } } if (!hasTrainable) ImGui::EndDisabled(); + + // Profession trainer: craft quantity controls + if (isProfessionTrainer) { + ImGui::Separator(); + static int craftQuantity = 1; + static uint32_t selectedCraftSpell = 0; + + // Show craft queue status if active + int queueRemaining = gameHandler.getCraftQueueRemaining(); + if (queueRemaining > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), + "Crafting... %d remaining", queueRemaining); + ImGui::SameLine(); + if (ImGui::SmallButton("Stop")) { + gameHandler.cancelCraftQueue(); + gameHandler.cancelCast(); + } + } else { + // Spell selector + quantity input + // Build list of known (craftable) spells + std::vector craftable; + for (const auto& spell : trainer.spells) { + if (isKnown(spell.spellId)) { + craftable.push_back(&spell); + } + } + if (!craftable.empty()) { + // Combo box for recipe selection + const char* previewName = "Select recipe..."; + for (const auto* sp : craftable) { + if (sp->spellId == selectedCraftSpell) { + const std::string& n = gameHandler.getSpellName(sp->spellId); + if (!n.empty()) previewName = n.c_str(); + break; + } + } + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f); + if (ImGui::BeginCombo("##CraftSelect", previewName)) { + for (const auto* sp : craftable) { + const std::string& n = gameHandler.getSpellName(sp->spellId); + const std::string& r = gameHandler.getSpellRank(sp->spellId); + char label[128]; + if (!r.empty()) + snprintf(label, sizeof(label), "%s (%s)##%u", + n.empty() ? "???" : n.c_str(), r.c_str(), sp->spellId); + else + snprintf(label, sizeof(label), "%s##%u", + n.empty() ? "???" : n.c_str(), sp->spellId); + if (ImGui::Selectable(label, sp->spellId == selectedCraftSpell)) { + selectedCraftSpell = sp->spellId; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(50.0f); + ImGui::InputInt("##CraftQty", &craftQuantity, 0, 0); + if (craftQuantity < 1) craftQuantity = 1; + if (craftQuantity > 99) craftQuantity = 99; + ImGui::SameLine(); + bool canCraft = selectedCraftSpell != 0 && !gameHandler.isCasting(); + if (!canCraft) ImGui::BeginDisabled(); + if (ImGui::Button("Create")) { + if (craftQuantity == 1) { + gameHandler.castSpell(selectedCraftSpell, 0); + } else { + gameHandler.startCraftQueue(selectedCraftSpell, craftQuantity); + } + } + if (!canCraft) ImGui::EndDisabled(); + } + } + } } } ImGui::End(); From 9aed192503c24d971eca09d5b92745282a1dc457 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:20:29 -0700 Subject: [PATCH 09/42] fix: load skill DBCs on login and handle loot slot changes - Load SkillLine.dbc and SkillLineAbility.dbc during SMSG_INITIAL_SPELLS so isProfessionSpell() works immediately without visiting a trainer - Implement SMSG_LOOT_SLOT_CHANGED handler to remove items taken by other players in group loot, keeping the loot window in sync --- src/game/game_handler.cpp | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 35e34512..506d80f3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2849,10 +2849,19 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_LOOT_SLOT_CHANGED: - // uint64 objectGuid + uint32 slot + ... — consume - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_SLOT_CHANGED: { + // uint8 slotIndex — another player took the item from this slot in group loot + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t slotIndex = packet.readUInt8(); + for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) { + if (it->slotIndex == slotIndex) { + currentLoot.items.erase(it); + break; + } + } + } break; + } // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { @@ -17966,6 +17975,11 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { } } + // Pre-load skill line DBCs so isProfessionSpell() works immediately + // (not just after opening a trainer window) + loadSkillLineDbc(); + loadSkillLineAbilityDbc(); + LOG_INFO("Learned ", knownSpells.size(), " spells"); } From f5297f9945891f8eb46dbb36349d6d806319c0a5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:30:18 -0700 Subject: [PATCH 10/42] feat: show craft queue count on cast bar during batch crafting --- src/ui/game_screen.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0d6ce5c7..2dfb55b0 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7877,16 +7877,21 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { : ImVec4(0.8f, 0.6f, 0.2f, 1.0f); // gold for casts ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); - char overlay[64]; + char overlay[96]; if (currentSpellId == 0) { snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); } else { const std::string& spellName = gameHandler.getSpellName(currentSpellId); const char* verb = channeling ? "Channeling" : "Casting"; - if (!spellName.empty()) - snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); - else + int queueLeft = gameHandler.getCraftQueueRemaining(); + if (!spellName.empty()) { + if (queueLeft > 0) + snprintf(overlay, sizeof(overlay), "%s (%.1fs) [%d left]", spellName.c_str(), gameHandler.getCastTimeRemaining(), queueLeft); + else + snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); + } else { snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); + } } if (iconTex) { From 1152a70201bebd61ab7d55fc9179cb5722788dcb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:40:35 -0700 Subject: [PATCH 11/42] fix: handle transport data in other player movement packets Other players on transports (boats, zeppelins, trams) were not properly tracked because handleOtherPlayerMovement() did not read transport data from MSG_MOVE_* packets. This caused entities to slide off transports between movement updates since no transport attachment was established. Now reads the transport GUID and local offset from the packet using expansion-aware wire flags (0x200 for WotLK/TBC, 0x02000000 for Classic/Turtle), registers a transport attachment so the entity follows the transport smoothly via updateAttachedTransportChildren(), and clears the attachment when the player disembarks. --- include/game/packet_parsers.hpp | 5 +++++ src/game/game_handler.cpp | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index fe033101..8efcd5eb 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -26,6 +26,10 @@ public: // Classic: none, TBC: u8, WotLK: u16. virtual uint8_t movementFlags2Size() const { return 2; } + // Wire-format movement flag that gates transport data in MSG_MOVE_* payloads. + // WotLK/TBC: 0x200, Classic/Turtle: 0x02000000. + virtual uint32_t wireOnTransportFlag() const { return 0x00000200; } + // --- Movement --- /** Parse movement block from SMSG_UPDATE_OBJECT */ @@ -380,6 +384,7 @@ public: class ClassicPacketParsers : public TbcPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } + uint32_t wireOnTransportFlag() const override { return 0x02000000; } bool parseCharEnum(network::Packet& packet, CharEnumResponse& response) override; bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; void writeMovementPayload(network::Packet& packet, const MovementInfo& info) override; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 506d80f3..b1ae2ece 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -16855,6 +16855,32 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { info.z = packet.readFloat(); info.orientation = packet.readFloat(); + // Read transport data if the on-transport flag is set in wire-format move flags. + // The flag bit position differs between expansions (0x200 for WotLK/TBC, 0x02000000 for Classic/Turtle). + const uint32_t wireTransportFlag = packetParsers_ ? packetParsers_->wireOnTransportFlag() : 0x00000200; + const bool onTransport = (info.flags & wireTransportFlag) != 0; + uint64_t transportGuid = 0; + float tLocalX = 0, tLocalY = 0, tLocalZ = 0, tLocalO = 0; + if (onTransport) { + transportGuid = UpdateObjectParser::readPackedGuid(packet); + tLocalX = packet.readFloat(); + tLocalY = packet.readFloat(); + tLocalZ = packet.readFloat(); + tLocalO = packet.readFloat(); + // TBC and WotLK include a transport timestamp; Classic does not. + if (flags2Size >= 1) { + /*uint32_t transportTime =*/ packet.readUInt32(); + } + // WotLK adds a transport seat byte. + if (flags2Size >= 2) { + /*int8_t transportSeat =*/ packet.readUInt8(); + // Optional second transport time for interpolated movement. + if (info.flags2 & 0x0200) { + /*uint32_t transportTime2 =*/ packet.readUInt32(); + } + } + } + // Update entity position in entity manager auto entity = entityManager.getEntity(moverGuid); if (!entity) { @@ -16864,6 +16890,20 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Convert server coords to canonical glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(info.x, info.y, info.z)); float canYaw = core::coords::serverToCanonicalYaw(info.orientation); + + // Handle transport attachment: attach/detach the entity so it follows the transport + // smoothly between movement updates via updateAttachedTransportChildren(). + if (onTransport && transportGuid != 0 && transportManager_) { + glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(tLocalX, tLocalY, tLocalZ)); + setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, true, + core::coords::serverToCanonicalYaw(tLocalO)); + // Derive world position from transport system for best accuracy. + glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + canonical = worldPos; + } else if (!onTransport) { + // Player left transport — clear any stale attachment. + clearTransportAttachment(moverGuid); + } // Compute a smoothed interpolation window for this player. // Using a raw packet delta causes jitter when timing spikes (e.g. 50ms then 300ms). // An exponential moving average of intervals gives a stable playback speed that From 2d53ff0c07d25042237bebd86b6a43c8e770bf29 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:54:07 -0700 Subject: [PATCH 12/42] feat: show environmental damage type in combat text and log Fall, lava, drowning, fatigue, slime, and fire damage now display their specific type instead of generic "Environmental damage" in both floating combat text and the combat log window. The envType byte from SMSG_ENVIRONMENTAL_DAMAGE_LOG is propagated via the powerType field to the display layer. Added powerType to CombatLogEntry for consistent access in the persistent combat log. --- include/game/spell_defines.hpp | 1 + src/game/game_handler.cpp | 9 +++++---- src/ui/game_screen.cpp | 29 +++++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index ffaf6bb2..10c4a5cd 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -74,6 +74,7 @@ struct CombatLogEntry { int32_t amount = 0; uint32_t spellId = 0; bool isPlayerSource = false; + uint8_t powerType = 0; // For ENERGIZE/DRAIN: power type; for ENVIRONMENTAL: env damage type time_t timestamp = 0; // Wall-clock time (std::time(nullptr)) std::string sourceName; // Resolved display name of attacker/caster std::string targetName; // Resolved display name of victim/target diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b1ae2ece..21c146d2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4259,17 +4259,17 @@ void GameHandler::handlePacket(network::Packet& packet) { } 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 + // envDmgType: 0=Exhausted(fatigue), 1=Drowning, 2=Fall, 3=Lava, 4=Slime, 5=Fire if (packet.getSize() - packet.getReadPos() < 21) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = packet.readUInt64(); - /*uint8_t envType =*/ packet.readUInt8(); + uint8_t envType = packet.readUInt8(); uint32_t dmg = packet.readUInt32(); uint32_t envAbs = packet.readUInt32(); uint32_t envRes = packet.readUInt32(); if (victimGuid == playerGuid) { - // Environmental damage: no caster GUID, victim = player + // Environmental damage: pass envType via powerType field for display differentiation if (dmg > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, 0, 0, victimGuid); + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, envType, 0, victimGuid); if (envAbs > 0) addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false, 0, 0, victimGuid); if (envRes > 0) @@ -15160,6 +15160,7 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint log.amount = amount; log.spellId = spellId; log.isPlayerSource = isPlayerSource; + log.powerType = powerType; log.timestamp = std::time(nullptr); // If the caller provided an explicit destination GUID but left source GUID as 0, // preserve "unknown/no source" (e.g. environmental damage) instead of diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 2dfb55b0..315c15e2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8467,10 +8467,21 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { 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); + case game::CombatTextEntry::ENVIRONMENTAL: { + const char* envLabel = ""; + switch (entry.powerType) { + case 0: envLabel = "Fatigue "; break; + case 1: envLabel = "Drowning "; break; + case 2: envLabel = ""; break; // Fall: just show the number (WoW convention) + case 3: envLabel = "Lava "; break; + case 4: envLabel = "Slime "; break; + case 5: envLabel = "Fire "; break; + default: envLabel = ""; break; + } + snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental break; + } case game::CombatTextEntry::ENERGIZE: snprintf(text, sizeof(text), "+%d", entry.amount); switch (entry.powerType) { @@ -20538,10 +20549,20 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); break; - case T::ENVIRONMENTAL: - snprintf(desc, sizeof(desc), "Environmental damage: %d", e.amount); + case T::ENVIRONMENTAL: { + const char* envName = "Environmental"; + switch (e.powerType) { + case 0: envName = "Fatigue"; break; + case 1: envName = "Drowning"; break; + case 2: envName = "Falling"; break; + case 3: envName = "Lava"; break; + case 4: envName = "Slime"; break; + case 5: envName = "Fire"; break; + } + snprintf(desc, sizeof(desc), "%s damage: %d", envName, e.amount); color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); break; + } case T::ENERGIZE: if (spell) snprintf(desc, sizeof(desc), "%s gains %d power (%s)", tgt, e.amount, spell); From 01685cc0bb122ab0029809c27dfcf7e2db30d9f8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 10:58:07 -0700 Subject: [PATCH 13/42] feat: add ghost mode visual overlay when player is dead Apply a cold blue-grey fullscreen overlay when the player is in ghost form, creating a desaturated, muted appearance that clearly signals the death state. Uses the existing overlay pipeline infrastructure. Applied in both parallel and non-parallel rendering paths, after underwater tint but before brightness adjustment so UI elements remain unaffected. --- src/rendering/renderer.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index e20a0d6c..67a637bb 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -5287,6 +5287,10 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint, cmd); } } + // Ghost mode desaturation: cold blue-grey overlay when dead/ghost + if (ghostMode_) { + renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f), cmd); + } // Brightness overlay (applied before minimap so it doesn't affect UI) if (brightness_ < 0.99f) { renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - brightness_), cmd); @@ -5428,6 +5432,10 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint); } } + // Ghost mode desaturation: cold blue-grey overlay when dead/ghost + if (ghostMode_) { + renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f)); + } // Brightness overlay (applied before minimap so it doesn't affect UI) if (brightness_ < 0.99f) { renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - brightness_)); From a7f7c4aa93de19cf1a6bebf0c0584b281ddc4e9f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:03:20 -0700 Subject: [PATCH 14/42] feat: show power type names in combat log energize/drain entries Combat log now shows specific power type names (Mana, Rage, Energy, Focus, Happiness, Runic Power) instead of generic "power" for ENERGIZE and POWER_DRAIN events. Uses the powerType field added to CombatLogEntry in the previous commit. --- src/ui/game_screen.cpp | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 315c15e2..d5e5a320 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -20563,20 +20563,40 @@ void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); break; } - case T::ENERGIZE: + case T::ENERGIZE: { + const char* pwrName = "power"; + switch (e.powerType) { + case 0: pwrName = "Mana"; break; + case 1: pwrName = "Rage"; break; + case 2: pwrName = "Focus"; break; + case 3: pwrName = "Energy"; break; + case 4: pwrName = "Happiness"; break; + case 6: pwrName = "Runic Power"; break; + } if (spell) - snprintf(desc, sizeof(desc), "%s gains %d power (%s)", tgt, e.amount, spell); + snprintf(desc, sizeof(desc), "%s gains %d %s (%s)", tgt, e.amount, pwrName, spell); else - snprintf(desc, sizeof(desc), "%s gains %d power", tgt, e.amount); + snprintf(desc, sizeof(desc), "%s gains %d %s", tgt, e.amount, pwrName); color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; - case T::POWER_DRAIN: + } + case T::POWER_DRAIN: { + const char* drainName = "power"; + switch (e.powerType) { + case 0: drainName = "Mana"; break; + case 1: drainName = "Rage"; break; + case 2: drainName = "Focus"; break; + case 3: drainName = "Energy"; break; + case 4: drainName = "Happiness"; break; + case 6: drainName = "Runic Power"; break; + } if (spell) - snprintf(desc, sizeof(desc), "%s loses %d power to %s's %s", tgt, e.amount, src, spell); + snprintf(desc, sizeof(desc), "%s loses %d %s to %s's %s", tgt, e.amount, drainName, src, spell); else - snprintf(desc, sizeof(desc), "%s loses %d power", tgt, e.amount); + snprintf(desc, sizeof(desc), "%s loses %d %s", tgt, e.amount, drainName); color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); break; + } case T::XP_GAIN: snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); From dc8619464a1d9ffd9c71ef310f4a494f828dc65a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:10:54 -0700 Subject: [PATCH 15/42] fix: add TBC guild roster parser to avoid gender byte misalignment TBC 2.4.3 SMSG_GUILD_ROSTER has the same rank structure as WotLK (variable rankCount + goldLimit + bank tab permissions), but does NOT include a gender byte per member (WotLK added it). Without this override, TBC fell through to the WotLK parser which read a spurious gender byte, causing every subsequent field in each member entry to misalign. --- include/game/packet_parsers.hpp | 3 + src/game/packet_parsers_tbc.cpp | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 8efcd5eb..47dc2a79 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -365,6 +365,9 @@ public: // TBC/Classic SMSG_QUESTGIVER_QUEST_DETAILS lacks informUnit(u64), flags(u32), // isFinished(u8) that WotLK added; uses variable item counts + emote section. bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override; + // TBC 2.4.3 SMSG_GUILD_ROSTER: same rank structure as WotLK (variable rankCount + + // goldLimit + bank tabs), but NO gender byte per member (WotLK added it) + bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 30dd8e05..fa97a5e7 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1537,5 +1537,125 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa return true; } +// ============================================================================ +// TBC 2.4.3 guild roster parser +// Same rank structure as WotLK (variable rankCount + goldLimit + bank tabs), +// but NO gender byte per member (WotLK added it). +// ============================================================================ + +bool TbcPacketParsers::parseGuildRoster(network::Packet& packet, GuildRosterData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("TBC SMSG_GUILD_ROSTER too small: ", packet.getSize()); + return false; + } + uint32_t numMembers = packet.readUInt32(); + + const uint32_t MAX_GUILD_MEMBERS = 1000; + if (numMembers > MAX_GUILD_MEMBERS) { + LOG_WARNING("TBC GuildRoster: numMembers capped (requested=", numMembers, ")"); + numMembers = MAX_GUILD_MEMBERS; + } + + data.motd = packet.readString(); + data.guildInfo = packet.readString(); + + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("TBC GuildRoster: truncated before rankCount"); + data.ranks.clear(); + data.members.clear(); + return true; + } + + uint32_t rankCount = packet.readUInt32(); + const uint32_t MAX_GUILD_RANKS = 20; + if (rankCount > MAX_GUILD_RANKS) { + LOG_WARNING("TBC GuildRoster: rankCount capped (requested=", rankCount, ")"); + rankCount = MAX_GUILD_RANKS; + } + + data.ranks.resize(rankCount); + for (uint32_t i = 0; i < rankCount; ++i) { + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("TBC GuildRoster: truncated rank at index ", i); + break; + } + data.ranks[i].rights = packet.readUInt32(); + if (packet.getReadPos() + 4 > packet.getSize()) { + data.ranks[i].goldLimit = 0; + } else { + data.ranks[i].goldLimit = packet.readUInt32(); + } + // 6 bank tab flags + 6 bank tab items per day (guild banks added in TBC 2.3) + for (int t = 0; t < 6; ++t) { + if (packet.getReadPos() + 8 > packet.getSize()) break; + packet.readUInt32(); // tabFlags + packet.readUInt32(); // tabItemsPerDay + } + } + + data.members.resize(numMembers); + for (uint32_t i = 0; i < numMembers; ++i) { + if (packet.getReadPos() + 9 > packet.getSize()) { + LOG_WARNING("TBC GuildRoster: truncated member at index ", i); + break; + } + auto& m = data.members[i]; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + + if (packet.getReadPos() >= packet.getSize()) { + m.name.clear(); + } else { + m.name = packet.readString(); + } + + if (packet.getReadPos() + 1 > packet.getSize()) { + m.rankIndex = 0; + m.level = 1; + m.classId = 0; + m.gender = 0; + m.zoneId = 0; + } else { + m.rankIndex = packet.readUInt32(); + if (packet.getReadPos() + 2 > packet.getSize()) { + m.level = 1; + m.classId = 0; + } else { + m.level = packet.readUInt8(); + m.classId = packet.readUInt8(); + } + // TBC: NO gender byte (WotLK added it) + m.gender = 0; + if (packet.getReadPos() + 4 > packet.getSize()) { + m.zoneId = 0; + } else { + m.zoneId = packet.readUInt32(); + } + } + + if (!m.online) { + if (packet.getReadPos() + 4 > packet.getSize()) { + m.lastOnline = 0.0f; + } else { + m.lastOnline = packet.readFloat(); + } + } + + if (packet.getReadPos() >= packet.getSize()) { + m.publicNote.clear(); + m.officerNote.clear(); + } else { + m.publicNote = packet.readString(); + if (packet.getReadPos() >= packet.getSize()) { + m.officerNote.clear(); + } else { + m.officerNote = packet.readString(); + } + } + } + LOG_INFO("Parsed TBC SMSG_GUILD_ROSTER: ", numMembers, " members, motd=", data.motd); + return true; +} + } // namespace game } // namespace wowee From 1b86f76d31c877be471a9db840ca4f906fd7167e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:16:02 -0700 Subject: [PATCH 16/42] fix: add TBC overrides for quest giver status and channel packets TBC 2.4.3 sends quest giver status as uint32 (like Classic), not uint8 (WotLK). Without this override, reading uint8 consumed only 1 of 4 bytes, misaligning all subsequent packet data and breaking quest markers on NPCs. TBC channel join/leave packets use Classic format (name+password only). The WotLK base prepends channelId/hasVoice/joinedByZone, causing servers to reject the malformed packets and breaking channel features. --- include/game/packet_parsers.hpp | 6 +++++ src/game/packet_parsers_tbc.cpp | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 47dc2a79..d009204b 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -368,6 +368,12 @@ public: // TBC 2.4.3 SMSG_GUILD_ROSTER: same rank structure as WotLK (variable rankCount + // goldLimit + bank tabs), but NO gender byte per member (WotLK added it) bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; + // TBC 2.4.3 SMSG_QUESTGIVER_STATUS: uint32 status (WotLK uses uint8) + uint8_t readQuestGiverStatus(network::Packet& packet) override; + // TBC 2.4.3 CMSG_JOIN_CHANNEL: name+password only (WotLK prepends channelId+hasVoice+joinedByZone) + network::Packet buildJoinChannel(const std::string& channelName, const std::string& password) override; + // TBC 2.4.3 CMSG_LEAVE_CHANNEL: name only (WotLK prepends channelId) + network::Packet buildLeaveChannel(const std::string& channelName) override; }; /** diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index fa97a5e7..f8344cb0 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1537,6 +1537,49 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa return true; } +// ============================================================================ +// TBC 2.4.3 quest giver status +// TBC sends uint32 (like Classic), WotLK changed to uint8. +// TBC 2.4.3 enum: 0=NONE,1=UNAVAILABLE,2=CHAT,3=INCOMPLETE,4=REWARD_REP, +// 5=AVAILABLE_REP,6=AVAILABLE,7=REWARD2,8=REWARD +// ============================================================================ + +uint8_t TbcPacketParsers::readQuestGiverStatus(network::Packet& packet) { + uint32_t tbcStatus = packet.readUInt32(); + switch (tbcStatus) { + case 0: return 0; // NONE + case 1: return 1; // UNAVAILABLE + case 2: return 0; // CHAT → NONE (no marker) + case 3: return 5; // INCOMPLETE → WotLK INCOMPLETE + case 4: return 6; // REWARD_REP → WotLK REWARD_REP + case 5: return 7; // AVAILABLE_REP → WotLK AVAILABLE_LOW_LEVEL + case 6: return 8; // AVAILABLE → WotLK AVAILABLE + case 7: return 10; // REWARD2 → WotLK REWARD + case 8: return 10; // REWARD → WotLK REWARD + default: return 0; + } +} + +// ============================================================================ +// TBC 2.4.3 channel join/leave +// Classic/TBC: just name+password (no channelId/hasVoice/joinedByZone prefix) +// ============================================================================ + +network::Packet TbcPacketParsers::buildJoinChannel(const std::string& channelName, const std::string& password) { + network::Packet packet(wireOpcode(Opcode::CMSG_JOIN_CHANNEL)); + packet.writeString(channelName); + packet.writeString(password); + LOG_DEBUG("[TBC] Built CMSG_JOIN_CHANNEL: channel=", channelName); + return packet; +} + +network::Packet TbcPacketParsers::buildLeaveChannel(const std::string& channelName) { + network::Packet packet(wireOpcode(Opcode::CMSG_LEAVE_CHANNEL)); + packet.writeString(channelName); + LOG_DEBUG("[TBC] Built CMSG_LEAVE_CHANNEL: channel=", channelName); + return packet; +} + // ============================================================================ // TBC 2.4.3 guild roster parser // Same rank structure as WotLK (variable rankCount + goldLimit + bank tabs), From e1be8667ed120c6fbac3bafe33e501fc2d3b8e95 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:20:50 -0700 Subject: [PATCH 17/42] fix: add TBC game object query parser for correct string count TBC 2.4.3 SMSG_GAMEOBJECT_QUERY_RESPONSE has 2 extra strings after name[4] (iconName + castBarCaption). WotLK has 3 (adds unk1). Without this override, the WotLK parser's third readString() consumed bytes from the data[24] fields, corrupting game object type-specific data and breaking interactions with doors, chests, mailboxes, and transports on TBC servers. --- include/game/packet_parsers.hpp | 3 ++ src/game/packet_parsers_tbc.cpp | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index d009204b..d2a8203a 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -370,6 +370,9 @@ public: bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; // TBC 2.4.3 SMSG_QUESTGIVER_STATUS: uint32 status (WotLK uses uint8) uint8_t readQuestGiverStatus(network::Packet& packet) override; + // TBC 2.4.3 SMSG_GAMEOBJECT_QUERY_RESPONSE: 2 extra strings after names + // (iconName + castBarCaption); WotLK has 3 (adds unk1) + bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; // TBC 2.4.3 CMSG_JOIN_CHANNEL: name+password only (WotLK prepends channelId+hasVoice+joinedByZone) network::Packet buildJoinChannel(const std::string& channelName, const std::string& password) override; // TBC 2.4.3 CMSG_LEAVE_CHANNEL: name only (WotLK prepends channelId) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index f8344cb0..4dac23ec 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1580,6 +1580,71 @@ network::Packet TbcPacketParsers::buildLeaveChannel(const std::string& channelNa return packet; } +// ============================================================================ +// TBC 2.4.3 SMSG_GAMEOBJECT_QUERY_RESPONSE +// TBC has 2 extra strings after name[4] (iconName + castBarCaption). +// WotLK has 3 (adds unk1). Classic has 0. +// ============================================================================ + +bool TbcPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("TBC SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.entry = packet.readUInt32(); + + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + data.name = ""; + return true; + } + + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_ERROR("TBC SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + + data.type = packet.readUInt32(); + data.displayId = packet.readUInt32(); + // 4 name strings + data.name = packet.readString(); + packet.readString(); + packet.readString(); + packet.readString(); + + // TBC: 2 extra strings (iconName + castBarCaption) — WotLK has 3, Classic has 0 + packet.readString(); // iconName + packet.readString(); // castBarCaption + + // Read 24 type-specific data fields + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining >= 24 * 4) { + for (int i = 0; i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + data.hasData = true; + } else if (remaining > 0) { + uint32_t fieldsToRead = remaining / 4; + for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + if (fieldsToRead < 24) { + LOG_WARNING("TBC SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead, + " of 24, entry=", data.entry, ")"); + } + } + + if (data.type == 15) { // MO_TRANSPORT + LOG_DEBUG("TBC GO query: MO_TRANSPORT entry=", data.entry, + " name=\"", data.name, "\" displayId=", data.displayId, + " taxiPathId=", data.data[0], " moveSpeed=", data.data[1]); + } else { + LOG_DEBUG("TBC GO query: ", data.name, " type=", data.type, " entry=", data.entry); + } + return true; +} + // ============================================================================ // TBC 2.4.3 guild roster parser // Same rank structure as WotLK (variable rankCount + goldLimit + bank tabs), From ef5532cf15a5b7659f9c56fc0a99d18bf229f90c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:23:37 -0700 Subject: [PATCH 18/42] fix: add TBC chat message parser to prevent 12-byte misalignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TBC 2.4.3 SMSG_MESSAGECHAT has no senderGuid(u64) or unknown(u32) prefix before type-specific data. The WotLK base parser reads these 12 bytes unconditionally, causing complete misalignment of all chat message fields — every chat message on a TBC server would parse garbage for sender, channel, and message content. --- include/game/packet_parsers.hpp | 2 + src/game/packet_parsers_tbc.cpp | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index d2a8203a..b229dc80 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -370,6 +370,8 @@ public: bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; // TBC 2.4.3 SMSG_QUESTGIVER_STATUS: uint32 status (WotLK uses uint8) uint8_t readQuestGiverStatus(network::Packet& packet) override; + // TBC 2.4.3 SMSG_MESSAGECHAT: no senderGuid/unknown prefix before type-specific data + bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; // TBC 2.4.3 SMSG_GAMEOBJECT_QUERY_RESPONSE: 2 extra strings after names // (iconName + castBarCaption); WotLK has 3 (adds unk1) bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 4dac23ec..792484fb 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1537,6 +1537,106 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa return true; } +// ============================================================================ +// TBC 2.4.3 SMSG_MESSAGECHAT +// TBC format: type(u8) + language(u32) + [type-specific data] + msgLen(u32) + msg + tag(u8) +// WotLK adds senderGuid(u64) + unknown(u32) before type-specific data. +// ============================================================================ + +bool TbcPacketParsers::parseMessageChat(network::Packet& packet, MessageChatData& data) { + if (packet.getSize() < 10) { + LOG_ERROR("[TBC] SMSG_MESSAGECHAT packet too small: ", packet.getSize(), " bytes"); + return false; + } + + uint8_t typeVal = packet.readUInt8(); + data.type = static_cast(typeVal); + + uint32_t langVal = packet.readUInt32(); + data.language = static_cast(langVal); + + // TBC: NO senderGuid or unknown field here (WotLK has senderGuid(u64) + unk(u32)) + + switch (data.type) { + case ChatType::MONSTER_SAY: + case ChatType::MONSTER_YELL: + case ChatType::MONSTER_EMOTE: + case ChatType::MONSTER_WHISPER: + case ChatType::MONSTER_PARTY: + case ChatType::RAID_BOSS_EMOTE: { + // senderGuid(u64) + nameLen(u32) + name + targetGuid(u64) + data.senderGuid = packet.readUInt64(); + uint32_t nameLen = packet.readUInt32(); + if (nameLen > 0 && nameLen < 256) { + data.senderName.resize(nameLen); + for (uint32_t i = 0; i < nameLen; ++i) { + data.senderName[i] = static_cast(packet.readUInt8()); + } + if (!data.senderName.empty() && data.senderName.back() == '\0') { + data.senderName.pop_back(); + } + } + data.receiverGuid = packet.readUInt64(); + break; + } + + case ChatType::SAY: + case ChatType::PARTY: + case ChatType::YELL: + case ChatType::WHISPER: + case ChatType::WHISPER_INFORM: + case ChatType::GUILD: + case ChatType::OFFICER: + case ChatType::RAID: + case ChatType::RAID_LEADER: + case ChatType::RAID_WARNING: + case ChatType::EMOTE: + case ChatType::TEXT_EMOTE: { + // senderGuid(u64) + senderGuid(u64) — written twice by server + data.senderGuid = packet.readUInt64(); + /*duplicateGuid*/ packet.readUInt64(); + break; + } + + case ChatType::CHANNEL: { + // channelName(string) + rank(u32) + senderGuid(u64) + data.channelName = packet.readString(); + /*uint32_t rank =*/ packet.readUInt32(); + data.senderGuid = packet.readUInt64(); + break; + } + + default: { + // All other types: senderGuid(u64) + senderGuid(u64) — written twice + data.senderGuid = packet.readUInt64(); + /*duplicateGuid*/ packet.readUInt64(); + break; + } + } + + // Read message length + message + uint32_t messageLen = packet.readUInt32(); + if (messageLen > 0 && messageLen < 8192) { + data.message.resize(messageLen); + for (uint32_t i = 0; i < messageLen; ++i) { + data.message[i] = static_cast(packet.readUInt8()); + } + if (!data.message.empty() && data.message.back() == '\0') { + data.message.pop_back(); + } + } + + // Read chat tag + if (packet.getReadPos() < packet.getSize()) { + data.chatTag = packet.readUInt8(); + } + + LOG_DEBUG("[TBC] SMSG_MESSAGECHAT: type=", getChatTypeString(data.type), + " sender=", data.senderName.empty() ? std::to_string(data.senderGuid) : data.senderName); + + return true; +} + // ============================================================================ // TBC 2.4.3 quest giver status // TBC sends uint32 (like Classic), WotLK changed to uint8. From c017c61d2cd1bfc341abbb8b670ffda8ab63efa1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:37:49 -0700 Subject: [PATCH 19/42] fix: remove unused syncCounts variable in Warden handler Eliminates the -Wunused-variable warning for the syncCounts array that was declared but never populated in the synchronous Warden check response path. --- src/game/game_handler.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 21c146d2..bb06c781 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -10235,8 +10235,6 @@ void GameHandler::handleWardenData(network::Packet& packet) { // Log synchronous round summary at WARNING level for diagnostics { - int syncCounts[10] = {}; - // Re-count (we don't have per-check counters in sync path yet) LOG_WARNING("Warden: (sync) Parsed ", checkCount, " checks, resultSize=", resultData.size()); std::string fullHex; for (size_t bi = 0; bi < resultData.size(); bi++) { From 97501104360190db4fddf987e2f237f66235cfa3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:54:01 -0700 Subject: [PATCH 20/42] fix: complete item tooltip stat comparison for all secondary stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shift-hover gear comparison was missing secondary stat types (Defense, Dodge, Parry, Block Rating, Hit/Crit/Haste variants, Healing, Spell Damage, Spell Pen) — only 10 of 22 stat types had labels. Also adds full extra stats and DPS comparison to the ItemQueryResponseData tooltip overload (loot window) which had none. --- src/ui/inventory_screen.cpp | 71 ++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 000ae9d2..18175be8 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2961,17 +2961,26 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I // Find a label for this stat type const char* lbl = nullptr; switch (t) { - case 31: lbl = "Hit"; break; - case 32: lbl = "Crit"; break; + case 0: lbl = "Mana"; break; + case 1: lbl = "Health"; break; + case 12: lbl = "Defense"; break; + case 13: lbl = "Dodge"; break; + case 14: lbl = "Parry"; break; + case 15: lbl = "Block Rating"; break; + case 16: case 17: case 18: case 31: lbl = "Hit"; break; + case 19: case 20: case 21: case 32: lbl = "Crit"; break; + case 28: case 29: case 30: case 36: lbl = "Haste"; break; case 35: lbl = "Resilience"; break; - case 36: lbl = "Haste"; break; case 37: lbl = "Expertise"; break; case 38: lbl = "Attack Power"; break; case 39: lbl = "Ranged AP"; break; + case 41: lbl = "Healing"; break; + case 42: lbl = "Spell Damage"; break; case 43: lbl = "MP5"; break; case 44: lbl = "Armor Pen"; break; case 45: lbl = "Spell Power"; break; case 46: lbl = "HP5"; break; + case 47: lbl = "Spell Pen"; break; case 48: lbl = "Block Value"; break; default: lbl = nullptr; break; } @@ -3511,6 +3520,16 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, ImGui::TextColored(ic, "%s", ilvlBuf); } + // DPS comparison for weapons + if (isWeaponInvType(info.inventoryType) && isWeaponInvType(eq->item.inventoryType)) { + float newDps = 0.0f, eqDps = 0.0f; + if (info.damageMax > 0.0f && info.delayMs > 0) + newDps = ((info.damageMin + info.damageMax) * 0.5f) / (info.delayMs / 1000.0f); + if (eq->item.damageMax > 0.0f && eq->item.delayMs > 0) + eqDps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / (eq->item.delayMs / 1000.0f); + showDiff("DPS", newDps, eqDps); + } + showDiff("Armor", static_cast(info.armor), static_cast(eq->item.armor)); showDiff("Str", static_cast(info.strength), static_cast(eq->item.strength)); showDiff("Agi", static_cast(info.agility), static_cast(eq->item.agility)); @@ -3518,8 +3537,50 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, showDiff("Int", static_cast(info.intellect), static_cast(eq->item.intellect)); showDiff("Spi", static_cast(info.spirit), static_cast(eq->item.spirit)); - // Hint text - ImGui::TextDisabled("Hold Shift to compare"); + // Extra stats diff — union of stat types from both items + auto findExtraStat = [](const auto& it, uint32_t type) -> int32_t { + for (const auto& es : it.extraStats) + if (es.statType == type) return es.statValue; + return 0; + }; + std::vector allTypes; + for (const auto& es : info.extraStats) allTypes.push_back(es.statType); + for (const auto& es : eq->item.extraStats) { + bool found = false; + for (uint32_t t : allTypes) if (t == es.statType) { found = true; break; } + if (!found) allTypes.push_back(es.statType); + } + for (uint32_t t : allTypes) { + int32_t nv = findExtraStat(info, t); + int32_t ev = findExtraStat(eq->item, t); + const char* lbl = nullptr; + switch (t) { + case 0: lbl = "Mana"; break; + case 1: lbl = "Health"; break; + case 12: lbl = "Defense"; break; + case 13: lbl = "Dodge"; break; + case 14: lbl = "Parry"; break; + case 15: lbl = "Block Rating"; break; + case 16: case 17: case 18: case 31: lbl = "Hit"; break; + case 19: case 20: case 21: case 32: lbl = "Crit"; break; + case 28: case 29: case 30: case 36: lbl = "Haste"; break; + case 35: lbl = "Resilience"; break; + case 37: lbl = "Expertise"; break; + case 38: lbl = "Attack Power"; break; + case 39: lbl = "Ranged AP"; break; + case 41: lbl = "Healing"; break; + case 42: lbl = "Spell Damage"; break; + case 43: lbl = "MP5"; break; + case 44: lbl = "Armor Pen"; break; + case 45: lbl = "Spell Power"; break; + case 46: lbl = "HP5"; break; + case 47: lbl = "Spell Pen"; break; + case 48: lbl = "Block Value"; break; + default: lbl = nullptr; break; + } + if (!lbl) continue; + showDiff(lbl, static_cast(nv), static_cast(ev)); + } } } else if (info.inventoryType > 0) { ImGui::TextDisabled("Hold Shift to compare"); From 67e6c9a98441e20c29c0ed8cd83d4ab238aa1ca3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 11:58:20 -0700 Subject: [PATCH 21/42] fix: TBC parseMailList returns true on empty mailbox for consistency WotLK and Classic parsers return true on success regardless of mail count, but TBC returned !inbox.empty() which falsely signals parse failure on an empty mailbox, potentially causing callers to skip valid empty-mailbox state. --- src/game/packet_parsers_tbc.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 792484fb..a29a0beb 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1228,7 +1228,7 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector Date: Tue, 17 Mar 2026 12:02:17 -0700 Subject: [PATCH 22/42] feat: show exploration XP as floating combat text Area discovery XP was only shown in the system chat log. Now it also appears as a floating "+XP" number like kill XP, giving immediate visual feedback when discovering new zones. --- src/game/game_handler.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index bb06c781..5f859c85 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2002,6 +2002,8 @@ void GameHandler::handlePacket(network::Packet& packet) { msg = buf; } addSystemChatMessage(msg); + addCombatText(CombatTextEntry::XP_GAIN, + static_cast(xpGained), 0, true); // XP is updated via PLAYER_XP update fields from the server. if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained); From b80d88bded483045f0f46f07f3d024926a4e539d Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 12:12:11 -0700 Subject: [PATCH 23/42] feat: add 'Hold Shift to compare' hint to ItemDef tooltip The ItemQueryResponseData tooltip overload had this hint but the primary ItemDef overload did not. Players hovering gear in their inventory now see the comparison prompt when an equipped equivalent exists. --- src/ui/inventory_screen.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 18175be8..09022ec5 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -2988,6 +2988,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I showDiff(lbl, static_cast(nv), static_cast(ev)); } } + } else if (inventory && !ImGui::GetIO().KeyShift && item.inventoryType > 0) { + if (findComparableEquipped(*inventory, item.inventoryType)) { + ImGui::TextDisabled("Hold Shift to compare"); + } } // Destroy hint (not shown for quest items) From 8c3060f2614f45de5f70a82ac05f742b50ce5ebd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 12:17:23 -0700 Subject: [PATCH 24/42] feat: show XP percentage in experience bar tooltip The XP bar tooltip now displays current progress as a percentage (e.g., "Current: 45000 / 100000 XP (45.0%)"), making it easier to gauge leveling progress at a glance. --- src/ui/game_screen.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index d5e5a320..81b7af77 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -7694,7 +7694,8 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); ImGui::Separator(); - ImGui::Text("Current: %u / %u XP", currentXp, nextLevelXp); + float xpPct = nextLevelXp > 0 ? (100.0f * currentXp / nextLevelXp) : 0.0f; + ImGui::Text("Current: %u / %u XP (%.1f%%)", currentXp, nextLevelXp, xpPct); ImGui::Text("To next level: %u XP", xpToLevel); if (restedXp > 0) { float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); From a0b978f95b71fa962b2672c7b28e299b230f729b Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 12:28:15 -0700 Subject: [PATCH 25/42] feat: add audio feedback for quest accept/complete and transaction errors Wire up UISoundManager calls that were loaded but never invoked: - playQuestActivate() on quest accept - playQuestComplete() on server-confirmed quest completion - playError() on trainer buy failure, vendor buy failure, and sell failure --- src/game/game_handler.cpp | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5f859c85..e79371c9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3925,6 +3925,11 @@ void GameHandler::handlePacket(network::Packet& packet) { else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")"; addSystemChatMessage(msg); + // Play error sound so the player notices the failure + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } break; } @@ -4535,6 +4540,10 @@ void GameHandler::handlePacket(network::Packet& packet) { }; const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error"; addSystemChatMessage(std::string("Sell failed: ") + msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } LOG_WARNING("SMSG_SELL_ITEM error: ", (int)result, " (", msg, ")"); } } @@ -4688,6 +4697,10 @@ void GameHandler::handlePacket(network::Packet& packet) { default: break; } addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } } break; } @@ -5015,6 +5028,11 @@ void GameHandler::handlePacket(network::Packet& packet) { if (questCompleteCallback_) { questCompleteCallback_(questId, it->title); } + // Play quest-complete sound + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playQuestComplete(); + } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); break; @@ -20070,6 +20088,12 @@ void GameHandler::acceptQuest() { pendingQuestAcceptTimeouts_[questId] = 5.0f; pendingQuestAcceptNpcGuids_[questId] = npcGuid; + // Play quest-accept sound + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playQuestActivate(); + } + questDetailsOpen = false; questDetailsOpenTime = std::chrono::steady_clock::time_point{}; currentQuestDetails = QuestDetailsData{}; From 6fbf5b57977a1d6b721cb277e70d5fa7057f0a66 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 12:31:38 -0700 Subject: [PATCH 26/42] feat: add audio feedback for item loot, vendor buy/sell, and spell learning Wire up remaining UISoundManager calls for core gameplay actions: - playLootItem() on SMSG_ITEM_PUSH_RESULT and handleLootRemoved - playPickupBag() on successful vendor purchase (SMSG_BUY_ITEM) - playDropOnGround() on successful item sell (SMSG_SELL_ITEM) - playQuestActivate() on trainer spell purchase success --- src/game/game_handler.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e79371c9..84bd5db2 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1940,6 +1940,10 @@ void GameHandler::handlePacket(network::Packet& packet) { std::string msg = "Received: " + itemName; if (count > 1) msg += " x" + std::to_string(count); addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } if (itemLootCallback_) { itemLootCallback_(itemId, count, quality, itemName); } @@ -3899,6 +3903,10 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("You have learned " + name + "."); else addSystemChatMessage("Spell learned."); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playQuestActivate(); + } break; } case Opcode::SMSG_TRAINER_BUY_FAILED: { @@ -4504,6 +4512,10 @@ void GameHandler::handlePacket(network::Packet& packet) { " result=", static_cast(result)); if (result == 0) { pendingSellToBuyback_.erase(itemGuid); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playDropOnGround(); + } } else { bool removedPending = false; auto it = pendingSellToBuyback_.find(itemGuid); @@ -4748,6 +4760,10 @@ void GameHandler::handlePacket(network::Packet& packet) { std::string msg = "Purchased: " + itemLabel; if (itemCount > 1) msg += " x" + std::to_string(itemCount); addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playPickupBag(); + } } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; @@ -20800,6 +20816,10 @@ void GameHandler::handleLootRemoved(network::Packet& packet) { msg << " x" << it->count; } addSystemChatMessage(msg.str()); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } currentLoot.items.erase(it); break; } From 119002626e57078ac27cdf1ebc0ec82e064f8c55 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 12:35:05 -0700 Subject: [PATCH 27/42] feat: show chat message when a spell is removed from spellbook handleRemovedSpell now displays "You have unlearned: [SpellName]." matching the existing handleLearnedSpell feedback pattern. --- src/game/game_handler.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 84bd5db2..75a7ccd1 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18473,6 +18473,12 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { knownSpells.erase(spellId); LOG_INFO("Removed spell: ", spellId); + const std::string& name = getSpellName(spellId); + if (!name.empty()) + addSystemChatMessage("You have unlearned: " + name + "."); + else + addSystemChatMessage("A spell has been removed."); + // Clear any action bar slots referencing this spell bool barChanged = false; for (auto& slot : actionBar) { From 8169f5d5c002acd83091a88782bd1d9a0b6473b3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 12:37:19 -0700 Subject: [PATCH 28/42] feat: add audio feedback for level-up, achievements, duels, group invites, and inventory errors Wire up remaining UISoundManager calls for milestone and notification events: - playLevelUp() on SMSG_LEVELUP_INFO - playAchievementAlert() on SMSG_ACHIEVEMENT_EARNED (self only) - playTargetSelect() on duel request and group invite - playError() on inventory change failure --- src/game/game_handler.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 75a7ccd1..0b4d253c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4395,6 +4395,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } if (newLevel > oldLevel) { addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLevelUp(); + } if (levelUpCallback_) levelUpCallback_(newLevel); } } @@ -4649,6 +4653,10 @@ void GameHandler::handlePacket(network::Packet& packet) { } std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ")."; addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } } } break; @@ -13440,6 +13448,10 @@ void GameHandler::handleDuelRequested(network::Packet& packet) { pendingDuelRequest_ = true; addSystemChatMessage(duelChallengerName_ + " challenges you to a duel!"); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playTargetSelect(); + } LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_, " flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_); } @@ -18745,6 +18757,10 @@ void GameHandler::handleGroupInvite(network::Packet& packet) { if (!data.inviterName.empty()) { addSystemChatMessage(data.inviterName + " has invited you to a group."); } + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playTargetSelect(); + } } void GameHandler::handleGroupDecline(network::Packet& packet) { @@ -24480,6 +24496,10 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { earnedAchievements_.insert(achievementId); achievementDates_[achievementId] = earnDate; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playAchievementAlert(); + } if (achievementEarnedCallback_) { achievementEarnedCallback_(achievementId, achName); } From bf5219c82261eeb03a8885e5f4e3eb1a74662487 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:04:25 -0700 Subject: [PATCH 29/42] refactor: replace std::cout/cerr with LOG_* macros in warden_module.cpp Convert 60+ raw console output calls to structured LOG_INFO/WARNING/ERROR macros for consistent logging, proper timestamps, and filtering support. Remove unused include. --- src/game/warden_module.cpp | 261 ++++++++++++++++++++----------------- 1 file changed, 141 insertions(+), 120 deletions(-) diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 5bb76027..9f577978 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -1,9 +1,9 @@ #include "game/warden_module.hpp" #include "auth/crypto.hpp" +#include "core/logger.hpp" #include #include #include -#include #include #include #include @@ -51,28 +51,30 @@ bool WardenModule::load(const std::vector& moduleData, moduleData_ = moduleData; md5Hash_ = md5Hash; - std::cout << "[WardenModule] Loading module (MD5: "; - for (size_t i = 0; i < std::min(md5Hash.size(), size_t(8)); ++i) { - printf("%02X", md5Hash[i]); + { + char hexBuf[17] = {}; + for (size_t i = 0; i < std::min(md5Hash.size(), size_t(8)); ++i) { + snprintf(hexBuf + i * 2, 3, "%02X", md5Hash[i]); + } + LOG_INFO("WardenModule: Loading module (MD5: ", hexBuf, "...)"); } - std::cout << "...)" << '\n'; // Step 1: Verify MD5 hash if (!verifyMD5(moduleData, md5Hash)) { - std::cerr << "[WardenModule] MD5 verification failed; continuing in compatibility mode" << '\n'; + LOG_ERROR("WardenModule: MD5 verification failed; continuing in compatibility mode"); } - std::cout << "[WardenModule] ✓ MD5 verified" << '\n'; + LOG_INFO("WardenModule: MD5 verified"); // Step 2: RC4 decrypt (Warden protocol-required legacy RC4; server-mandated, cannot be changed) if (!decryptRC4(moduleData, rc4Key, decryptedData_)) { // codeql[cpp/weak-cryptographic-algorithm] - std::cerr << "[WardenModule] RC4 decryption failed; using raw module bytes fallback" << '\n'; + LOG_ERROR("WardenModule: RC4 decryption failed; using raw module bytes fallback"); decryptedData_ = moduleData; } - std::cout << "[WardenModule] ✓ RC4 decrypted (" << decryptedData_.size() << " bytes)" << '\n'; + LOG_INFO("WardenModule: RC4 decrypted (", decryptedData_.size(), " bytes)"); // Step 3: Verify RSA signature if (!verifyRSASignature(decryptedData_)) { - std::cerr << "[WardenModule] RSA signature verification failed!" << '\n'; + LOG_ERROR("WardenModule: RSA signature verification failed!"); // Note: Currently returns true (skipping verification) due to placeholder modulus } @@ -84,42 +86,42 @@ bool WardenModule::load(const std::vector& moduleData, dataWithoutSig = decryptedData_; } if (!decompressZlib(dataWithoutSig, decompressedData_)) { - std::cerr << "[WardenModule] zlib decompression failed; using decrypted bytes fallback" << '\n'; + LOG_ERROR("WardenModule: zlib decompression failed; using decrypted bytes fallback"); decompressedData_ = decryptedData_; } // Step 5: Parse custom executable format if (!parseExecutableFormat(decompressedData_)) { - std::cerr << "[WardenModule] Executable format parsing failed; continuing with minimal module image" << '\n'; + LOG_ERROR("WardenModule: Executable format parsing failed; continuing with minimal module image"); } // Step 6: Apply relocations if (!applyRelocations()) { - std::cerr << "[WardenModule] Address relocations failed; continuing with unrelocated image" << '\n'; + LOG_ERROR("WardenModule: Address relocations failed; continuing with unrelocated image"); } // Step 7: Bind APIs if (!bindAPIs()) { - std::cerr << "[WardenModule] API binding failed!" << '\n'; + LOG_ERROR("WardenModule: API binding failed!"); // Note: Currently returns true (stub) on both Windows and Linux } // Step 8: Initialize module if (!initializeModule()) { - std::cerr << "[WardenModule] Module initialization failed; continuing with stub callbacks" << '\n'; + LOG_ERROR("WardenModule: Module initialization failed; continuing with stub callbacks"); } // Module loading pipeline complete! // Note: Steps 6-8 are stubs/platform-limited, but infrastructure is ready loaded_ = true; // Mark as loaded (infrastructure complete) - std::cout << "[WardenModule] ✓ Module loading pipeline COMPLETE" << '\n'; - std::cout << "[WardenModule] Status: Infrastructure ready, execution stubs in place" << '\n'; - std::cout << "[WardenModule] Limitations:" << '\n'; - std::cout << "[WardenModule] - Relocations: needs real module data" << '\n'; - std::cout << "[WardenModule] - API Binding: Windows only (or Wine on Linux)" << '\n'; - std::cout << "[WardenModule] - Execution: disabled (unsafe without validation)" << '\n'; - std::cout << "[WardenModule] For strict servers: Would need to enable actual x86 execution" << '\n'; + LOG_INFO("WardenModule: Module loading pipeline COMPLETE"); + LOG_INFO("WardenModule: Status: Infrastructure ready, execution stubs in place"); + LOG_INFO("WardenModule: Limitations:"); + LOG_INFO("WardenModule: - Relocations: needs real module data"); + LOG_INFO("WardenModule: - API Binding: Windows only (or Wine on Linux)"); + LOG_INFO("WardenModule: - Execution: disabled (unsafe without validation)"); + LOG_INFO("WardenModule: For strict servers: Would need to enable actual x86 execution"); return true; } @@ -127,25 +129,25 @@ bool WardenModule::load(const std::vector& moduleData, bool WardenModule::processCheckRequest(const std::vector& checkData, [[maybe_unused]] std::vector& responseOut) { if (!loaded_) { - std::cerr << "[WardenModule] Module not loaded, cannot process checks" << '\n'; + LOG_ERROR("WardenModule: Module not loaded, cannot process checks"); return false; } #ifdef HAVE_UNICORN if (emulator_ && emulator_->isInitialized() && funcList_.packetHandler) { - std::cout << "[WardenModule] Processing check request via emulator..." << '\n'; - std::cout << "[WardenModule] Check data: " << checkData.size() << " bytes" << '\n'; + LOG_INFO("WardenModule: Processing check request via emulator..."); + LOG_INFO("WardenModule: Check data: ", checkData.size(), " bytes"); // Allocate memory for check data in emulated space uint32_t checkDataAddr = emulator_->allocateMemory(checkData.size(), 0x04); if (checkDataAddr == 0) { - std::cerr << "[WardenModule] Failed to allocate memory for check data" << '\n'; + LOG_ERROR("WardenModule: Failed to allocate memory for check data"); return false; } // Write check data to emulated memory if (!emulator_->writeMemory(checkDataAddr, checkData.data(), checkData.size())) { - std::cerr << "[WardenModule] Failed to write check data" << '\n'; + LOG_ERROR("WardenModule: Failed to write check data"); emulator_->freeMemory(checkDataAddr); return false; } @@ -153,7 +155,7 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, // Allocate response buffer in emulated space (assume max 1KB response) uint32_t responseAddr = emulator_->allocateMemory(1024, 0x04); if (responseAddr == 0) { - std::cerr << "[WardenModule] Failed to allocate response buffer" << '\n'; + LOG_ERROR("WardenModule: Failed to allocate response buffer"); emulator_->freeMemory(checkDataAddr); return false; } @@ -162,13 +164,13 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, // Call module's PacketHandler // void PacketHandler(uint8_t* checkData, size_t checkSize, // uint8_t* responseOut, size_t* responseSizeOut) - std::cout << "[WardenModule] Calling PacketHandler..." << '\n'; + LOG_INFO("WardenModule: Calling PacketHandler..."); // For now, this is a placeholder - actual calling would depend on // the module's exact function signature - std::cout << "[WardenModule] ⚠ PacketHandler execution stubbed" << '\n'; - std::cout << "[WardenModule] Would call emulated function to process checks" << '\n'; - std::cout << "[WardenModule] This would generate REAL responses (not fakes!)" << '\n'; + LOG_WARNING("WardenModule: PacketHandler execution stubbed"); + LOG_INFO("WardenModule: Would call emulated function to process checks"); + LOG_INFO("WardenModule: This would generate REAL responses (not fakes!)"); // Clean up emulator_->freeMemory(checkDataAddr); @@ -179,7 +181,7 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, return false; } catch (const std::exception& e) { - std::cerr << "[WardenModule] Exception during PacketHandler: " << e.what() << '\n'; + LOG_ERROR("WardenModule: Exception during PacketHandler: ", e.what()); emulator_->freeMemory(checkDataAddr); emulator_->freeMemory(responseAddr); return false; @@ -187,8 +189,8 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, } #endif - std::cout << "[WardenModule] ⚠ processCheckRequest NOT IMPLEMENTED" << '\n'; - std::cout << "[WardenModule] Would call module->PacketHandler() here" << '\n'; + LOG_WARNING("WardenModule: processCheckRequest NOT IMPLEMENTED"); + LOG_INFO("WardenModule: Would call module->PacketHandler() here"); // For now, return false to fall back to fake responses in GameHandler return false; @@ -219,13 +221,13 @@ void WardenModule::unload() { if (moduleMemory_) { // Call module's Unload() function if loaded if (loaded_ && funcList_.unload) { - std::cout << "[WardenModule] Calling module unload callback..." << '\n'; + LOG_INFO("WardenModule: Calling module unload callback..."); // TODO: Implement callback when execution layer is complete // funcList_.unload(nullptr); } // Free executable memory region - std::cout << "[WardenModule] Freeing " << moduleSize_ << " bytes of executable memory" << '\n'; + LOG_INFO("WardenModule: Freeing ", moduleSize_, " bytes of executable memory"); #ifdef _WIN32 VirtualFree(moduleMemory_, 0, MEM_RELEASE); #else @@ -264,7 +266,7 @@ bool WardenModule::decryptRC4(const std::vector& encrypted, const std::vector& key, std::vector& decryptedOut) { if (key.size() != 16) { - std::cerr << "[WardenModule] Invalid RC4 key size: " << key.size() << " (expected 16)" << '\n'; + LOG_ERROR("WardenModule: Invalid RC4 key size: ", key.size(), " (expected 16)"); return false; } @@ -299,7 +301,7 @@ bool WardenModule::decryptRC4(const std::vector& encrypted, bool WardenModule::verifyRSASignature(const std::vector& data) { // RSA-2048 signature is last 256 bytes if (data.size() < 256) { - std::cerr << "[WardenModule] Data too small for RSA signature (need at least 256 bytes)" << '\n'; + LOG_ERROR("WardenModule: Data too small for RSA signature (need at least 256 bytes)"); return false; } @@ -385,7 +387,7 @@ bool WardenModule::verifyRSASignature(const std::vector& data) { if (pkey) EVP_PKEY_free(pkey); if (decryptedLen < 0) { - std::cerr << "[WardenModule] RSA public decrypt failed" << '\n'; + LOG_ERROR("WardenModule: RSA public decrypt failed"); return false; } @@ -398,24 +400,24 @@ bool WardenModule::verifyRSASignature(const std::vector& data) { std::vector actualHash(decryptedSig.end() - 20, decryptedSig.end()); if (std::memcmp(actualHash.data(), expectedHash.data(), 20) == 0) { - std::cout << "[WardenModule] ✓ RSA signature verified" << '\n'; + LOG_INFO("WardenModule: RSA signature verified"); return true; } } - std::cerr << "[WardenModule] RSA signature verification FAILED (hash mismatch)" << '\n'; - std::cerr << "[WardenModule] NOTE: Using placeholder modulus - extract real modulus from WoW.exe for actual verification" << '\n'; + LOG_ERROR("WardenModule: RSA signature verification FAILED (hash mismatch)"); + LOG_ERROR("WardenModule: NOTE: Using placeholder modulus - extract real modulus from WoW.exe for actual verification"); // For development, return true to proceed (since we don't have real modulus) // TODO: Set to false once real modulus is extracted - std::cout << "[WardenModule] ⚠ Skipping RSA verification (placeholder modulus)" << '\n'; + LOG_WARNING("WardenModule: Skipping RSA verification (placeholder modulus)"); return true; // TEMPORARY - change to false for production } bool WardenModule::decompressZlib(const std::vector& compressed, std::vector& decompressedOut) { if (compressed.size() < 4) { - std::cerr << "[WardenModule] Compressed data too small (need at least 4 bytes for size header)" << '\n'; + LOG_ERROR("WardenModule: Compressed data too small (need at least 4 bytes for size header)"); return false; } @@ -426,11 +428,11 @@ bool WardenModule::decompressZlib(const std::vector& compressed, (compressed[2] << 16) | (compressed[3] << 24); - std::cout << "[WardenModule] Uncompressed size: " << uncompressedSize << " bytes" << '\n'; + LOG_INFO("WardenModule: Uncompressed size: ", uncompressedSize, " bytes"); // Sanity check (modules shouldn't be larger than 10MB) if (uncompressedSize > 10 * 1024 * 1024) { - std::cerr << "[WardenModule] Uncompressed size suspiciously large: " << uncompressedSize << " bytes" << '\n'; + LOG_ERROR("WardenModule: Uncompressed size suspiciously large: ", uncompressedSize, " bytes"); return false; } @@ -447,7 +449,7 @@ bool WardenModule::decompressZlib(const std::vector& compressed, // Initialize inflater int ret = inflateInit(&stream); if (ret != Z_OK) { - std::cerr << "[WardenModule] inflateInit failed: " << ret << '\n'; + LOG_ERROR("WardenModule: inflateInit failed: ", ret); return false; } @@ -458,19 +460,18 @@ bool WardenModule::decompressZlib(const std::vector& compressed, inflateEnd(&stream); if (ret != Z_STREAM_END) { - std::cerr << "[WardenModule] inflate failed: " << ret << '\n'; + LOG_ERROR("WardenModule: inflate failed: ", ret); return false; } - std::cout << "[WardenModule] ✓ zlib decompression successful (" - << stream.total_out << " bytes decompressed)" << '\n'; + LOG_INFO("WardenModule: zlib decompression successful (", stream.total_out, " bytes decompressed)"); return true; } bool WardenModule::parseExecutableFormat(const std::vector& exeData) { if (exeData.size() < 4) { - std::cerr << "[WardenModule] Executable data too small for header" << '\n'; + LOG_ERROR("WardenModule: Executable data too small for header"); return false; } @@ -481,11 +482,11 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { (exeData[2] << 16) | (exeData[3] << 24); - std::cout << "[WardenModule] Final code size: " << finalCodeSize << " bytes" << '\n'; + LOG_INFO("WardenModule: Final code size: ", finalCodeSize, " bytes"); // Sanity check (executable shouldn't be larger than 5MB) if (finalCodeSize > 5 * 1024 * 1024 || finalCodeSize == 0) { - std::cerr << "[WardenModule] Invalid final code size: " << finalCodeSize << '\n'; + LOG_ERROR("WardenModule: Invalid final code size: ", finalCodeSize); return false; } @@ -500,7 +501,7 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { PAGE_EXECUTE_READWRITE ); if (!moduleMemory_) { - std::cerr << "[WardenModule] VirtualAlloc failed" << '\n'; + LOG_ERROR("WardenModule: VirtualAlloc failed"); return false; } #else @@ -513,7 +514,7 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { 0 ); if (moduleMemory_ == MAP_FAILED) { - std::cerr << "[WardenModule] mmap failed: " << strerror(errno) << '\n'; + LOG_ERROR("WardenModule: mmap failed: ", strerror(errno)); moduleMemory_ = nullptr; return false; } @@ -522,8 +523,7 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { moduleSize_ = finalCodeSize; std::memset(moduleMemory_, 0, moduleSize_); // Zero-initialize - std::cout << "[WardenModule] Allocated " << moduleSize_ << " bytes of executable memory at " - << moduleMemory_ << '\n'; + LOG_INFO("WardenModule: Allocated ", moduleSize_, " bytes of executable memory"); auto readU16LE = [&](size_t at) -> uint16_t { return static_cast(exeData[at] | (exeData[at + 1] << 8)); @@ -669,10 +669,10 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { if (usedFormat == PairFormat::SkipCopyData) formatName = "skip/copy/data"; if (usedFormat == PairFormat::CopySkipData) formatName = "copy/skip/data"; - std::cout << "[WardenModule] Parsed " << parsedPairCount << " pairs using format " - << formatName << ", final offset: " << parsedFinalOffset << "/" << finalCodeSize << '\n'; - std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_ - << " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n'; + LOG_INFO("WardenModule: Parsed ", parsedPairCount, " pairs using format ", + formatName, ", final offset: ", parsedFinalOffset, "/", finalCodeSize); + LOG_INFO("WardenModule: Relocation data starts at decompressed offset ", relocDataOffset_, + " (", (exeData.size() - relocDataOffset_), " bytes remaining)"); return true; } @@ -683,13 +683,13 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { std::memcpy(moduleMemory_, exeData.data() + 4, rawCopySize); } relocDataOffset_ = 0; - std::cerr << "[WardenModule] Could not parse copy/skip pairs (all known layouts failed); using raw payload fallback" << '\n'; + LOG_ERROR("WardenModule: Could not parse copy/skip pairs (all known layouts failed); using raw payload fallback"); return true; } bool WardenModule::applyRelocations() { if (!moduleMemory_ || moduleSize_ == 0) { - std::cerr << "[WardenModule] No module memory allocated for relocations" << '\n'; + LOG_ERROR("WardenModule: No module memory allocated for relocations"); return false; } @@ -698,7 +698,7 @@ bool WardenModule::applyRelocations() { // Each offset in the module image has moduleBase_ added to the 32-bit value there if (relocDataOffset_ == 0 || relocDataOffset_ >= decompressedData_.size()) { - std::cout << "[WardenModule] No relocation data available" << '\n'; + LOG_INFO("WardenModule: No relocation data available"); return true; } @@ -722,24 +722,27 @@ bool WardenModule::applyRelocations() { std::memcpy(addr, &val, sizeof(uint32_t)); relocCount++; } else { - std::cerr << "[WardenModule] Relocation offset " << currentOffset - << " out of bounds (moduleSize=" << moduleSize_ << ")" << '\n'; + LOG_ERROR("WardenModule: Relocation offset ", currentOffset, + " out of bounds (moduleSize=", moduleSize_, ")"); } } - std::cout << "[WardenModule] Applied " << relocCount << " relocations (base=0x" - << std::hex << moduleBase_ << std::dec << ")" << '\n'; + { + char baseBuf[32]; + std::snprintf(baseBuf, sizeof(baseBuf), "0x%X", moduleBase_); + LOG_INFO("WardenModule: Applied ", relocCount, " relocations (base=", baseBuf, ")"); + } return true; } bool WardenModule::bindAPIs() { if (!moduleMemory_ || moduleSize_ == 0) { - std::cerr << "[WardenModule] No module memory allocated for API binding" << '\n'; + LOG_ERROR("WardenModule: No module memory allocated for API binding"); return false; } - std::cout << "[WardenModule] Binding Windows APIs for module..." << '\n'; + LOG_INFO("WardenModule: Binding Windows APIs for module..."); // Common Windows APIs used by Warden modules: // @@ -759,14 +762,14 @@ bool WardenModule::bindAPIs() { #ifdef _WIN32 // On Windows: Use GetProcAddress to resolve imports - std::cout << "[WardenModule] Platform: Windows - using GetProcAddress" << '\n'; + LOG_INFO("WardenModule: Platform: Windows - using GetProcAddress"); HMODULE kernel32 = GetModuleHandleA("kernel32.dll"); HMODULE user32 = GetModuleHandleA("user32.dll"); HMODULE ntdll = GetModuleHandleA("ntdll.dll"); if (!kernel32 || !user32 || !ntdll) { - std::cerr << "[WardenModule] Failed to get module handles" << '\n'; + LOG_ERROR("WardenModule: Failed to get module handles"); return false; } @@ -777,8 +780,8 @@ bool WardenModule::bindAPIs() { // - Resolve address using GetProcAddress // - Write address to Import Address Table (IAT) - std::cout << "[WardenModule] ⚠ Windows API binding is STUB (needs PE import table parsing)" << '\n'; - std::cout << "[WardenModule] Would parse PE headers and patch IAT with resolved addresses" << '\n'; + LOG_WARNING("WardenModule: Windows API binding is STUB (needs PE import table parsing)"); + LOG_INFO("WardenModule: Would parse PE headers and patch IAT with resolved addresses"); #else // On Linux: Cannot directly execute Windows code @@ -787,15 +790,15 @@ bool WardenModule::bindAPIs() { // 2. Implement Windows API stubs (limited functionality) // 3. Use binfmt_misc + Wine (transparent Windows executable support) - std::cout << "[WardenModule] Platform: Linux - Windows module execution NOT supported" << '\n'; - std::cout << "[WardenModule] Options:" << '\n'; - std::cout << "[WardenModule] 1. Run wowee under Wine (provides Windows API layer)" << '\n'; - std::cout << "[WardenModule] 2. Use a Windows VM" << '\n'; - std::cout << "[WardenModule] 3. Implement Windows API stubs (limited, complex)" << '\n'; + LOG_WARNING("WardenModule: Platform: Linux - Windows module execution NOT supported"); + LOG_INFO("WardenModule: Options:"); + LOG_INFO("WardenModule: 1. Run wowee under Wine (provides Windows API layer)"); + LOG_INFO("WardenModule: 2. Use a Windows VM"); + LOG_INFO("WardenModule: 3. Implement Windows API stubs (limited, complex)"); // For now, we'll return true to continue the loading pipeline // Real execution would fail, but this allows testing the infrastructure - std::cout << "[WardenModule] ⚠ Skipping API binding (Linux platform limitation)" << '\n'; + LOG_WARNING("WardenModule: Skipping API binding (Linux platform limitation)"); #endif return true; // Return true to continue (stub implementation) @@ -803,11 +806,11 @@ bool WardenModule::bindAPIs() { bool WardenModule::initializeModule() { if (!moduleMemory_ || moduleSize_ == 0) { - std::cerr << "[WardenModule] No module memory allocated for initialization" << '\n'; + LOG_ERROR("WardenModule: No module memory allocated for initialization"); return false; } - std::cout << "[WardenModule] Initializing Warden module..." << '\n'; + LOG_INFO("WardenModule: Initializing Warden module..."); // Module initialization protocol: // @@ -844,27 +847,27 @@ bool WardenModule::initializeModule() { // Stub callbacks (would need real implementations) callbacks.sendPacket = []([[maybe_unused]] uint8_t* data, size_t len) { - std::cout << "[WardenModule Callback] sendPacket(" << len << " bytes)" << '\n'; + LOG_DEBUG("WardenModule Callback: sendPacket(", len, " bytes)"); // TODO: Send CMSG_WARDEN_DATA packet }; callbacks.validateModule = []([[maybe_unused]] uint8_t* hash) { - std::cout << "[WardenModule Callback] validateModule()" << '\n'; + LOG_DEBUG("WardenModule Callback: validateModule()"); // TODO: Validate module hash }; callbacks.allocMemory = [](size_t size) -> void* { - std::cout << "[WardenModule Callback] allocMemory(" << size << ")" << '\n'; + LOG_DEBUG("WardenModule Callback: allocMemory(", size, ")"); return malloc(size); }; callbacks.freeMemory = [](void* ptr) { - std::cout << "[WardenModule Callback] freeMemory()" << '\n'; + LOG_DEBUG("WardenModule Callback: freeMemory()"); free(ptr); }; callbacks.generateRC4 = []([[maybe_unused]] uint8_t* seed) { - std::cout << "[WardenModule Callback] generateRC4()" << '\n'; + LOG_DEBUG("WardenModule Callback: generateRC4()"); // TODO: Re-key RC4 cipher }; @@ -873,7 +876,7 @@ bool WardenModule::initializeModule() { }; callbacks.logMessage = [](const char* msg) { - std::cout << "[WardenModule Log] " << msg << '\n'; + LOG_INFO("WardenModule Log: ", msg); }; // Module entry point is typically at offset 0 (first bytes of loaded code) @@ -881,24 +884,28 @@ bool WardenModule::initializeModule() { #ifdef HAVE_UNICORN // Use Unicorn emulator for cross-platform execution - std::cout << "[WardenModule] Initializing Unicorn emulator..." << '\n'; + LOG_INFO("WardenModule: Initializing Unicorn emulator..."); emulator_ = std::make_unique(); if (!emulator_->initialize(moduleMemory_, moduleSize_, moduleBase_)) { - std::cerr << "[WardenModule] Failed to initialize emulator" << '\n'; + LOG_ERROR("WardenModule: Failed to initialize emulator"); return false; } // Setup Windows API hooks emulator_->setupCommonAPIHooks(); - std::cout << "[WardenModule] ✓ Emulator initialized successfully" << '\n'; - std::cout << "[WardenModule] Ready to execute module at 0x" << std::hex << moduleBase_ << std::dec << '\n'; + { + char addrBuf[32]; + std::snprintf(addrBuf, sizeof(addrBuf), "0x%X", moduleBase_); + LOG_INFO("WardenModule: Emulator initialized successfully"); + LOG_INFO("WardenModule: Ready to execute module at ", addrBuf); + } // Allocate memory for ClientCallbacks structure in emulated space uint32_t callbackStructAddr = emulator_->allocateMemory(sizeof(ClientCallbacks), 0x04); if (callbackStructAddr == 0) { - std::cerr << "[WardenModule] Failed to allocate memory for callbacks" << '\n'; + LOG_ERROR("WardenModule: Failed to allocate memory for callbacks"); return false; } @@ -921,13 +928,21 @@ bool WardenModule::initializeModule() { emulator_->writeMemory(callbackStructAddr + (i * 4), &addr, 4); } - std::cout << "[WardenModule] Prepared ClientCallbacks at 0x" << std::hex << callbackStructAddr << std::dec << '\n'; + { + char cbBuf[32]; + std::snprintf(cbBuf, sizeof(cbBuf), "0x%X", callbackStructAddr); + LOG_INFO("WardenModule: Prepared ClientCallbacks at ", cbBuf); + } // Call module entry point // Entry point is typically at module base (offset 0) uint32_t entryPoint = moduleBase_; - std::cout << "[WardenModule] Calling module entry point at 0x" << std::hex << entryPoint << std::dec << '\n'; + { + char epBuf[32]; + std::snprintf(epBuf, sizeof(epBuf), "0x%X", entryPoint); + LOG_INFO("WardenModule: Calling module entry point at ", epBuf); + } try { // Call: WardenFuncList* InitModule(ClientCallbacks* callbacks) @@ -935,21 +950,28 @@ bool WardenModule::initializeModule() { uint32_t result = emulator_->callFunction(entryPoint, args); if (result == 0) { - std::cerr << "[WardenModule] Module entry returned NULL" << '\n'; + LOG_ERROR("WardenModule: Module entry returned NULL"); return false; } - std::cout << "[WardenModule] ✓ Module initialized, WardenFuncList at 0x" << std::hex << result << std::dec << '\n'; + { + char resBuf[32]; + std::snprintf(resBuf, sizeof(resBuf), "0x%X", result); + LOG_INFO("WardenModule: Module initialized, WardenFuncList at ", resBuf); + } // Read WardenFuncList structure from emulated memory // Structure has 4 function pointers (16 bytes) uint32_t funcAddrs[4] = {}; if (emulator_->readMemory(result, funcAddrs, 16)) { - std::cout << "[WardenModule] Module exported functions:" << '\n'; - std::cout << "[WardenModule] generateRC4Keys: 0x" << std::hex << funcAddrs[0] << std::dec << '\n'; - std::cout << "[WardenModule] unload: 0x" << std::hex << funcAddrs[1] << std::dec << '\n'; - std::cout << "[WardenModule] packetHandler: 0x" << std::hex << funcAddrs[2] << std::dec << '\n'; - std::cout << "[WardenModule] tick: 0x" << std::hex << funcAddrs[3] << std::dec << '\n'; + char fb[4][32]; + for (int fi = 0; fi < 4; ++fi) + std::snprintf(fb[fi], sizeof(fb[fi]), "0x%X", funcAddrs[fi]); + LOG_INFO("WardenModule: Module exported functions:"); + LOG_INFO("WardenModule: generateRC4Keys: ", fb[0]); + LOG_INFO("WardenModule: unload: ", fb[1]); + LOG_INFO("WardenModule: packetHandler: ", fb[2]); + LOG_INFO("WardenModule: tick: ", fb[3]); // Store function addresses for later use // funcList_.generateRC4Keys = ... (would wrap emulator calls) @@ -958,10 +980,10 @@ bool WardenModule::initializeModule() { // funcList_.tick = ... } - std::cout << "[WardenModule] ✓ Module fully initialized and ready!" << '\n'; + LOG_INFO("WardenModule: Module fully initialized and ready!"); } catch (const std::exception& e) { - std::cerr << "[WardenModule] Exception during module initialization: " << e.what() << '\n'; + LOG_ERROR("WardenModule: Exception during module initialization: ", e.what()); return false; } @@ -970,14 +992,14 @@ bool WardenModule::initializeModule() { typedef void* (*ModuleEntryPoint)(ClientCallbacks*); ModuleEntryPoint entryPoint = reinterpret_cast(moduleMemory_); - std::cout << "[WardenModule] Calling module entry point at " << moduleMemory_ << '\n'; + LOG_INFO("WardenModule: Calling module entry point at ", moduleMemory_); // NOTE: This would execute native x86 code // Extremely dangerous without proper validation! // void* result = entryPoint(&callbacks); - std::cout << "[WardenModule] ⚠ Module entry point call is DISABLED (unsafe without validation)" << '\n'; - std::cout << "[WardenModule] Would execute x86 code at " << moduleMemory_ << '\n'; + LOG_WARNING("WardenModule: Module entry point call is DISABLED (unsafe without validation)"); + LOG_INFO("WardenModule: Would execute x86 code at ", moduleMemory_); // TODO: Extract WardenFuncList from result // funcList_.packetHandler = ... @@ -986,9 +1008,9 @@ bool WardenModule::initializeModule() { // funcList_.unload = ... #else - std::cout << "[WardenModule] ⚠ Cannot execute Windows x86 code on Linux" << '\n'; - std::cout << "[WardenModule] Module entry point: " << moduleMemory_ << '\n'; - std::cout << "[WardenModule] Would call entry point with ClientCallbacks struct" << '\n'; + LOG_WARNING("WardenModule: Cannot execute Windows x86 code on Linux"); + LOG_INFO("WardenModule: Module entry point: ", moduleMemory_); + LOG_INFO("WardenModule: Would call entry point with ClientCallbacks struct"); #endif // For now, return true to mark module as "loaded" at infrastructure level @@ -998,7 +1020,7 @@ bool WardenModule::initializeModule() { // 3. Exception handling for crashes // 4. Sandboxing for security - std::cout << "[WardenModule] ⚠ Module initialization is STUB" << '\n'; + LOG_WARNING("WardenModule: Module initialization is STUB"); return true; // Stub implementation } @@ -1023,7 +1045,7 @@ WardenModuleManager::WardenModuleManager() { // Create cache directory if it doesn't exist std::filesystem::create_directories(cacheDirectory_); - std::cout << "[WardenModuleManager] Cache directory: " << cacheDirectory_ << '\n'; + LOG_INFO("WardenModuleManager: Cache directory: ", cacheDirectory_); } WardenModuleManager::~WardenModuleManager() { @@ -1060,12 +1082,11 @@ bool WardenModuleManager::receiveModuleChunk(const std::vector& md5Hash std::vector& buffer = downloadBuffer_[md5Hash]; buffer.insert(buffer.end(), chunkData.begin(), chunkData.end()); - std::cout << "[WardenModuleManager] Received chunk (" << chunkData.size() - << " bytes, total: " << buffer.size() << ")" << '\n'; + LOG_INFO("WardenModuleManager: Received chunk (", chunkData.size(), + " bytes, total: ", buffer.size(), ")"); if (isComplete) { - std::cout << "[WardenModuleManager] Module download complete (" - << buffer.size() << " bytes)" << '\n'; + LOG_INFO("WardenModuleManager: Module download complete (", buffer.size(), " bytes)"); // Cache to disk cacheModule(md5Hash, buffer); @@ -1085,14 +1106,14 @@ bool WardenModuleManager::cacheModule(const std::vector& md5Hash, std::ofstream file(cachePath, std::ios::binary); if (!file) { - std::cerr << "[WardenModuleManager] Failed to write cache: " << cachePath << '\n'; + LOG_ERROR("WardenModuleManager: Failed to write cache: ", cachePath); return false; } file.write(reinterpret_cast(moduleData.data()), moduleData.size()); file.close(); - std::cout << "[WardenModuleManager] Cached module to: " << cachePath << '\n'; + LOG_INFO("WardenModuleManager: Cached module to: ", cachePath); return true; } @@ -1116,7 +1137,7 @@ bool WardenModuleManager::loadCachedModule(const std::vector& md5Hash, file.read(reinterpret_cast(moduleDataOut.data()), fileSize); file.close(); - std::cout << "[WardenModuleManager] Loaded cached module (" << fileSize << " bytes)" << '\n'; + LOG_INFO("WardenModuleManager: Loaded cached module (", fileSize, " bytes)"); return true; } From 4049f73ca6b9d13932c98b4892ed18bc83a2d946 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:09:18 -0700 Subject: [PATCH 30/42] refactor: replace raw console output with LOG_* macros in warden_emulator, transport_manager, keybinding_manager --- src/game/transport_manager.cpp | 11 ++- src/game/warden_emulator.cpp | 132 ++++++++++++++++++++++----------- src/ui/keybinding_manager.cpp | 10 +-- 3 files changed, 99 insertions(+), 54 deletions(-) diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index a9ff5cba..649c9923 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -31,13 +30,13 @@ void TransportManager::update(float deltaTime) { void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry) { auto pathIt = paths_.find(pathId); if (pathIt == paths_.end()) { - std::cerr << "TransportManager: Path " << pathId << " not found for transport " << guid << std::endl; + LOG_ERROR("TransportManager: Path ", pathId, " not found for transport ", guid); return; } const auto& path = pathIt->second; if (path.points.empty()) { - std::cerr << "TransportManager: Path " << pathId << " has no waypoints" << std::endl; + LOG_ERROR("TransportManager: Path ", pathId, " has no waypoints"); return; } @@ -128,7 +127,7 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, void TransportManager::unregisterTransport(uint64_t guid) { transports_.erase(guid); - std::cout << "TransportManager: Unregistered transport " << guid << std::endl; + LOG_INFO("TransportManager: Unregistered transport ", guid); } ActiveTransport* TransportManager::getTransport(uint64_t guid) { @@ -168,7 +167,7 @@ glm::mat4 TransportManager::getTransportInvTransform(uint64_t transportGuid) { void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector& waypoints, bool looping, float speed) { if (waypoints.empty()) { - std::cerr << "TransportManager: Cannot load empty path " << pathId << std::endl; + LOG_ERROR("TransportManager: Cannot load empty path ", pathId); return; } @@ -227,7 +226,7 @@ void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector +#include "core/logger.hpp" #include #include @@ -43,17 +43,21 @@ WardenEmulator::~WardenEmulator() { bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint32_t baseAddress) { if (uc_) { - std::cerr << "[WardenEmulator] Already initialized" << '\n'; + LOG_ERROR("WardenEmulator: Already initialized"); return false; } - std::cout << "[WardenEmulator] Initializing x86 emulator (Unicorn Engine)" << '\n'; - std::cout << "[WardenEmulator] Module: " << moduleSize << " bytes at 0x" << std::hex << baseAddress << std::dec << '\n'; + { + char addrBuf[32]; + std::snprintf(addrBuf, sizeof(addrBuf), "0x%X", baseAddress); + LOG_INFO("WardenEmulator: Initializing x86 emulator (Unicorn Engine)"); + LOG_INFO("WardenEmulator: Module: ", moduleSize, " bytes at ", addrBuf); + } // Create x86 32-bit emulator uc_err err = uc_open(UC_ARCH_X86, UC_MODE_32, &uc_); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] uc_open failed: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: uc_open failed: ", uc_strerror(err)); return false; } @@ -63,9 +67,12 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Detect overlap between module and heap/stack regions early. uint32_t modEnd = moduleBase_ + moduleSize_; if (modEnd > heapBase_ && moduleBase_ < heapBase_ + heapSize_) { - std::cerr << "[WardenEmulator] Module [0x" << std::hex << moduleBase_ - << ", 0x" << modEnd << ") overlaps heap [0x" << heapBase_ - << ", 0x" << (heapBase_ + heapSize_) << ") — adjust HEAP_BASE\n" << std::dec; + { + char buf[256]; + std::snprintf(buf, sizeof(buf), "WardenEmulator: Module [0x%X, 0x%X) overlaps heap [0x%X, 0x%X) - adjust HEAP_BASE", + moduleBase_, modEnd, heapBase_, heapBase_ + heapSize_); + LOG_ERROR(buf); + } uc_close(uc_); uc_ = nullptr; return false; @@ -74,7 +81,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map module memory (code + data) err = uc_mem_map(uc_, moduleBase_, moduleSize_, UC_PROT_ALL); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map module memory: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map module memory: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -83,7 +90,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Write module code to emulated memory err = uc_mem_write(uc_, moduleBase_, moduleCode, moduleSize); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to write module code: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to write module code: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -92,7 +99,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map stack err = uc_mem_map(uc_, stackBase_, stackSize_, UC_PROT_READ | UC_PROT_WRITE); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map stack: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map stack: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -106,7 +113,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map heap err = uc_mem_map(uc_, heapBase_, heapSize_, UC_PROT_READ | UC_PROT_WRITE); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map heap: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map heap: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -115,7 +122,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map API stub area err = uc_mem_map(uc_, apiStubBase_, 0x10000, UC_PROT_ALL); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map API stub area: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map API stub area: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -127,7 +134,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 err = uc_mem_map(uc_, 0x0, 0x1000, UC_PROT_READ); if (err != UC_ERR_OK) { // Non-fatal — just log it; the emulator will still function - std::cerr << "[WardenEmulator] Note: could not map null guard page: " << uc_strerror(err) << '\n'; + LOG_WARNING("WardenEmulator: could not map null guard page: ", uc_strerror(err)); } // Add hooks for debugging and invalid memory access @@ -135,9 +142,12 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0); hooks_.push_back(hh); - std::cout << "[WardenEmulator] ✓ Emulator initialized successfully" << '\n'; - std::cout << "[WardenEmulator] Stack: 0x" << std::hex << stackBase_ << " - 0x" << (stackBase_ + stackSize_) << '\n'; - std::cout << "[WardenEmulator] Heap: 0x" << heapBase_ << " - 0x" << (heapBase_ + heapSize_) << std::dec << '\n'; + { + char sBuf[128]; + std::snprintf(sBuf, sizeof(sBuf), "WardenEmulator: Emulator initialized Stack: 0x%X-0x%X Heap: 0x%X-0x%X", + stackBase_, stackBase_ + stackSize_, heapBase_, heapBase_ + heapSize_); + LOG_INFO(sBuf); + } return true; } @@ -153,8 +163,11 @@ uint32_t WardenEmulator::hookAPI(const std::string& dllName, // Store mapping apiAddresses_[dllName][functionName] = stubAddr; - std::cout << "[WardenEmulator] Hooked " << dllName << "!" << functionName - << " at 0x" << std::hex << stubAddr << std::dec << '\n'; + { + char hBuf[32]; + std::snprintf(hBuf, sizeof(hBuf), "0x%X", stubAddr); + LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf); + } // TODO: Write stub code that triggers a hook callback // For now, just return the address for IAT patching @@ -163,7 +176,7 @@ uint32_t WardenEmulator::hookAPI(const std::string& dllName, } void WardenEmulator::setupCommonAPIHooks() { - std::cout << "[WardenEmulator] Setting up common Windows API hooks..." << '\n'; + LOG_INFO("WardenEmulator: Setting up common Windows API hooks..."); // kernel32.dll hookAPI("kernel32.dll", "VirtualAlloc", apiVirtualAlloc); @@ -174,7 +187,7 @@ void WardenEmulator::setupCommonAPIHooks() { hookAPI("kernel32.dll", "GetCurrentProcessId", apiGetCurrentProcessId); hookAPI("kernel32.dll", "ReadProcessMemory", apiReadProcessMemory); - std::cout << "[WardenEmulator] ✓ Common API hooks registered" << '\n'; + LOG_INFO("WardenEmulator: Common API hooks registered"); } uint32_t WardenEmulator::writeData(const void* data, size_t size) { @@ -198,12 +211,15 @@ std::vector WardenEmulator::readData(uint32_t address, size_t size) { uint32_t WardenEmulator::callFunction(uint32_t address, const std::vector& args) { if (!uc_) { - std::cerr << "[WardenEmulator] Not initialized" << '\n'; + LOG_ERROR("WardenEmulator: Not initialized"); return 0; } - std::cout << "[WardenEmulator] Calling function at 0x" << std::hex << address << std::dec - << " with " << args.size() << " args" << '\n'; + { + char aBuf[32]; + std::snprintf(aBuf, sizeof(aBuf), "0x%X", address); + LOG_DEBUG("WardenEmulator: Calling function at ", aBuf, " with ", args.size(), " args"); + } // Get current ESP uint32_t esp; @@ -227,7 +243,7 @@ uint32_t WardenEmulator::callFunction(uint32_t address, const std::vector heapBase_ + heapSize_) { - std::cerr << "[WardenEmulator] Heap exhausted" << '\n'; + LOG_ERROR("WardenEmulator: Heap exhausted"); return 0; } @@ -275,7 +295,11 @@ uint32_t WardenEmulator::allocateMemory(size_t size, [[maybe_unused]] uint32_t p allocations_[addr] = size; - std::cout << "[WardenEmulator] Allocated " << size << " bytes at 0x" << std::hex << addr << std::dec << '\n'; + { + char mBuf[32]; + std::snprintf(mBuf, sizeof(mBuf), "0x%X", addr); + LOG_DEBUG("WardenEmulator: Allocated ", size, " bytes at ", mBuf); + } return addr; } @@ -283,11 +307,19 @@ uint32_t WardenEmulator::allocateMemory(size_t size, [[maybe_unused]] uint32_t p bool WardenEmulator::freeMemory(uint32_t address) { auto it = allocations_.find(address); if (it == allocations_.end()) { - std::cerr << "[WardenEmulator] Invalid free at 0x" << std::hex << address << std::dec << '\n'; + { + char fBuf[32]; + std::snprintf(fBuf, sizeof(fBuf), "0x%X", address); + LOG_ERROR("WardenEmulator: Invalid free at ", fBuf); + } return false; } - std::cout << "[WardenEmulator] Freed " << it->second << " bytes at 0x" << std::hex << address << std::dec << '\n'; + { + char fBuf[32]; + std::snprintf(fBuf, sizeof(fBuf), "0x%X", address); + LOG_DEBUG("WardenEmulator: Freed ", it->second, " bytes at ", fBuf); + } allocations_.erase(it); return true; } @@ -319,8 +351,12 @@ uint32_t WardenEmulator::apiVirtualAlloc(WardenEmulator& emu, const std::vector< uint32_t flAllocationType = args[2]; uint32_t flProtect = args[3]; - std::cout << "[WinAPI] VirtualAlloc(0x" << std::hex << lpAddress << ", " << std::dec - << dwSize << ", 0x" << std::hex << flAllocationType << ", 0x" << flProtect << ")" << std::dec << '\n'; + { + char vBuf[128]; + std::snprintf(vBuf, sizeof(vBuf), "WinAPI: VirtualAlloc(0x%X, %u, 0x%X, 0x%X)", + lpAddress, dwSize, flAllocationType, flProtect); + LOG_DEBUG(vBuf); + } // Ignore lpAddress hint for now return emu.allocateMemory(dwSize, flProtect); @@ -332,7 +368,11 @@ uint32_t WardenEmulator::apiVirtualFree(WardenEmulator& emu, const std::vector(now.time_since_epoch()).count(); uint32_t ticks = static_cast(ms & 0xFFFFFFFF); - std::cout << "[WinAPI] GetTickCount() = " << ticks << '\n'; + LOG_DEBUG("WinAPI: GetTickCount() = ", ticks); return ticks; } @@ -350,18 +390,18 @@ uint32_t WardenEmulator::apiSleep([[maybe_unused]] WardenEmulator& emu, const st if (args.size() < 1) return 0; uint32_t dwMilliseconds = args[0]; - std::cout << "[WinAPI] Sleep(" << dwMilliseconds << ")" << '\n'; + LOG_DEBUG("WinAPI: Sleep(", dwMilliseconds, ")"); // Don't actually sleep in emulator return 0; } uint32_t WardenEmulator::apiGetCurrentThreadId([[maybe_unused]] WardenEmulator& emu, [[maybe_unused]] const std::vector& args) { - std::cout << "[WinAPI] GetCurrentThreadId() = 1234" << '\n'; + LOG_DEBUG("WinAPI: GetCurrentThreadId() = 1234"); return 1234; // Fake thread ID } uint32_t WardenEmulator::apiGetCurrentProcessId([[maybe_unused]] WardenEmulator& emu, [[maybe_unused]] const std::vector& args) { - std::cout << "[WinAPI] GetCurrentProcessId() = 5678" << '\n'; + LOG_DEBUG("WinAPI: GetCurrentProcessId() = 5678"); return 5678; // Fake process ID } @@ -375,8 +415,11 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve uint32_t nSize = args[3]; uint32_t lpNumberOfBytesRead = args[4]; - std::cout << "[WinAPI] ReadProcessMemory(0x" << std::hex << lpBaseAddress - << ", " << std::dec << nSize << " bytes)" << '\n'; + { + char rBuf[64]; + std::snprintf(rBuf, sizeof(rBuf), "WinAPI: ReadProcessMemory(0x%X, %u bytes)", lpBaseAddress, nSize); + LOG_DEBUG(rBuf); + } // Read from emulated memory and write to buffer std::vector data(nSize); @@ -400,7 +443,7 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve // ============================================================================ void WardenEmulator::hookCode([[maybe_unused]] uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, [[maybe_unused]] void* userData) { - std::cout << "[Trace] 0x" << std::hex << address << std::dec << '\n'; + (void)address; // Trace disabled by default to avoid log spam } void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, uint64_t address, int size, [[maybe_unused]] int64_t value, [[maybe_unused]] void* userData) { @@ -415,9 +458,12 @@ void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, ui case UC_MEM_FETCH_PROT: typeStr = "FETCH_PROT"; break; } - std::cerr << "[WardenEmulator] Invalid memory access: " << typeStr - << " at 0x" << std::hex << address << std::dec - << " (size=" << size << ")" << '\n'; + { + char mBuf[128]; + std::snprintf(mBuf, sizeof(mBuf), "WardenEmulator: Invalid memory access: %s at 0x%llX (size=%d)", + typeStr, static_cast(address), size); + LOG_ERROR(mBuf); + } } #else // !HAVE_UNICORN diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index 20d562c5..b522e671 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -1,7 +1,7 @@ #include "ui/keybinding_manager.hpp" +#include "core/logger.hpp" #include #include -#include namespace wowee::ui { @@ -101,7 +101,7 @@ const char* KeybindingManager::getActionName(Action action) { void KeybindingManager::loadFromConfigFile(const std::string& filePath) { std::ifstream file(filePath); if (!file.is_open()) { - std::cerr << "[KeybindingManager] Failed to open config file: " << filePath << std::endl; + LOG_ERROR("KeybindingManager: Failed to open config file: ", filePath); return; } @@ -206,7 +206,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) { } file.close(); - std::cout << "[KeybindingManager] Loaded keybindings from " << filePath << std::endl; + LOG_INFO("KeybindingManager: Loaded keybindings from ", filePath); } void KeybindingManager::saveToConfigFile(const std::string& filePath) const { @@ -301,9 +301,9 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const { if (outFile.is_open()) { outFile << content; outFile.close(); - std::cout << "[KeybindingManager] Saved keybindings to " << filePath << std::endl; + LOG_INFO("KeybindingManager: Saved keybindings to ", filePath); } else { - std::cerr << "[KeybindingManager] Failed to write config file: " << filePath << std::endl; + LOG_ERROR("KeybindingManager: Failed to write config file: ", filePath); } } From 29b5b6f959197fdae834eb7d6a363874f0f822c1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:25:33 -0700 Subject: [PATCH 31/42] feat: show item quality colours in loot, quest-item, and auction chat messages Add buildItemLink() helper that formats |cff...|Hitem:...|h[Name]|h|r links so the chat renderer draws item names in their quality colour (grey/white/green/ blue/purple/orange) with a small icon and tooltip on hover. Applied to: loot received (SMSG_ITEM_PUSH_RESULT), looted from corpse (handleLootRemoved), quest item count updates, and all three auction house notifications (sold, outbid, expired). --- src/game/game_handler.cpp | 67 ++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0b4d253c..c0230c37 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -80,6 +80,28 @@ bool isAuthCharPipelineOpcode(LogicalOpcode op) { } } +// Build a WoW-format item link for use in system chat messages. +// The chat renderer in game_screen.cpp parses this format and draws the +// item name in its quality colour with a small icon and tooltip. +// Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r +std::string buildItemLink(uint32_t itemId, uint32_t quality, const std::string& name) { + static const char* kQualHex[] = { + "9d9d9d", // 0 Poor + "ffffff", // 1 Common + "1eff00", // 2 Uncommon + "0070dd", // 3 Rare + "a335ee", // 4 Epic + "ff8000", // 5 Legendary + "e6cc80", // 6 Artifact + "e6cc80", // 7 Heirloom + }; + uint32_t qi = quality < 8 ? quality : 1u; + char buf[512]; + snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + kQualHex[qi], itemId, name.c_str()); + return buf; +} + bool isActiveExpansion(const char* expansionId) { auto& app = core::Application::getInstance(); auto* registry = app.getExpansionRegistry(); @@ -1937,7 +1959,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (!info->name.empty()) itemName = info->name; quality = info->quality; } - std::string msg = "Received: " + itemName; + std::string link = buildItemLink(itemId, quality, itemName); + std::string msg = "Received: " + link; if (count > 1) msg += " x" + std::to_string(count); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -5159,8 +5182,10 @@ void GameHandler::handlePacket(network::Packet& packet) { queryItemInfo(itemId, 0); std::string itemLabel = "item #" + std::to_string(itemId); + uint32_t questItemQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; + questItemQuality = info->quality; } bool updatedAny = false; @@ -5184,7 +5209,7 @@ void GameHandler::handlePacket(network::Packet& packet) { quest.itemCounts[itemId] = count; updatedAny = true; } - addSystemChatMessage("Quest item: " + itemLabel + " (" + std::to_string(count) + ")"); + addSystemChatMessage("Quest item: " + buildItemLink(itemId, questItemQuality, itemLabel) + " (" + std::to_string(count) + ")"); if (questProgressCallback_ && updatedAny) { // Find the quest that tracks this item to get title and required count @@ -5904,13 +5929,15 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t itemEntry = packet.readUInt32(); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); - std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); + std::string rawName = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + uint32_t aucQuality = info ? info->quality : 1u; + std::string itemLink = buildItemLink(itemEntry, aucQuality, rawName); if (action == 1) - addSystemChatMessage("Your auction of " + itemName + " has expired."); + addSystemChatMessage("Your auction of " + itemLink + " has expired."); else if (action == 2) - addSystemChatMessage("A bid has been placed on your auction of " + itemName + "."); + addSystemChatMessage("A bid has been placed on your auction of " + itemLink + "."); else - addSystemChatMessage("Your auction of " + itemName + " has sold!"); + addSystemChatMessage("Your auction of " + itemLink + " has sold!"); } packet.setReadPos(packet.getSize()); break; @@ -5923,8 +5950,10 @@ void GameHandler::handlePacket(network::Packet& packet) { (void)auctionId; ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); - std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); - addSystemChatMessage("You have been outbid on " + itemName + "."); + std::string rawName2 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + uint32_t bidQuality = info ? info->quality : 1u; + std::string bidLink = buildItemLink(itemEntry, bidQuality, rawName2); + addSystemChatMessage("You have been outbid on " + bidLink + "."); } packet.setReadPos(packet.getSize()); break; @@ -5937,8 +5966,10 @@ void GameHandler::handlePacket(network::Packet& packet) { /*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."); + std::string rawName3 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + uint32_t remQuality = info ? info->quality : 1u; + std::string remLink = buildItemLink(itemEntry, remQuality, rawName3); + addSystemChatMessage("Your auction of " + remLink + " has expired."); } packet.setReadPos(packet.getSize()); break; @@ -20827,17 +20858,15 @@ void GameHandler::handleLootRemoved(network::Packet& packet) { for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) { if (it->slotIndex == slotIndex) { std::string itemName = "item #" + std::to_string(it->itemId); + uint32_t quality = 1; if (const ItemQueryResponseData* info = getItemInfo(it->itemId)) { - if (!info->name.empty()) { - itemName = info->name; - } + if (!info->name.empty()) itemName = info->name; + quality = info->quality; } - std::ostringstream msg; - msg << "Looted: " << itemName; - if (it->count > 1) { - msg << " x" << it->count; - } - addSystemChatMessage(msg.str()); + std::string link = buildItemLink(it->itemId, quality, itemName); + std::string msgStr = "Looted: " + link; + if (it->count > 1) msgStr += " x" + std::to_string(it->count); + addSystemChatMessage(msgStr); if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* sfx = renderer->getUiSoundManager()) sfx->playLootItem(); From 6260ac281e23eb47b20b555c5712b1f4d571dd71 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:27:27 -0700 Subject: [PATCH 32/42] feat: extend item link quality colours to vendor purchase, pet feed, and LFG reward messages --- src/game/game_handler.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c0230c37..0e88f6ea 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4785,10 +4785,13 @@ void GameHandler::handlePacket(network::Packet& packet) { // Show purchase confirmation with item name if available if (pendingBuyItemId_ != 0) { std::string itemLabel; - if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) + uint32_t buyQuality = 1; + if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) { if (!info->name.empty()) itemLabel = info->name; + buyQuality = info->quality; + } if (itemLabel.empty()) itemLabel = "item #" + std::to_string(pendingBuyItemId_); - std::string msg = "Purchased: " + itemLabel; + std::string msg = "Purchased: " + buildItemLink(pendingBuyItemId_, buyQuality, itemLabel); if (itemCount > 1) msg += " x" + std::to_string(itemCount); addSystemChatMessage(msg); if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -6904,7 +6907,8 @@ void GameHandler::handlePacket(network::Packet& packet) { const ItemQueryResponseData* info = getItemInfo(feedItem); std::string itemName = info && !info->name.empty() ? info->name : ("item #" + std::to_string(feedItem)); - addSystemChatMessage("You feed your pet " + itemName + "."); + uint32_t feedQuality = info ? info->quality : 1u; + addSystemChatMessage("You feed your pet " + buildItemLink(feedItem, feedQuality, itemName) + "."); LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName); } } @@ -16334,9 +16338,12 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { packet.readUInt8(); // unk if (i == 0) { std::string itemLabel = "item #" + std::to_string(itemId); - if (const ItemQueryResponseData* info = getItemInfo(itemId)) + uint32_t lfgItemQuality = 1; + if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; - rewardMsg += ", " + itemLabel; + lfgItemQuality = info->quality; + } + rewardMsg += ", " + buildItemLink(itemId, lfgItemQuality, itemLabel); if (itemCount > 1) rewardMsg += " x" + std::to_string(itemCount); } } From 217edc81d9a4ae702b4eaf3f08bf3ec169004737 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:33:07 -0700 Subject: [PATCH 33/42] feat: add item quality link colours to loot roll, loot notify, and loot all-passed messages --- src/game/game_handler.cpp | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0e88f6ea..e1bd4d82 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2842,10 +2842,9 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t randProp =*/ 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); + std::string allPassName = info && !info->name.empty() ? info->name : std::to_string(itemId); + uint32_t allPassQuality = info ? info->quality : 1u; + addSystemChatMessage("Everyone passed on " + buildItemLink(itemId, allPassQuality, allPassName) + "."); pendingLootRollActive_ = false; break; } @@ -2865,15 +2864,16 @@ void GameHandler::handlePacket(network::Packet& packet) { if (!looterName.empty()) { queryItemInfo(itemId, 0); std::string itemName = "item #" + std::to_string(itemId); + uint32_t notifyQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemName = info->name; + notifyQuality = info->quality; } - char buf[256]; - if (count > 1) - std::snprintf(buf, sizeof(buf), "%s loots %s x%u.", looterName.c_str(), itemName.c_str(), count); - else - std::snprintf(buf, sizeof(buf), "%s loots %s.", looterName.c_str(), itemName.c_str()); - addSystemChatMessage(buf); + std::string itemLink2 = buildItemLink(itemId, notifyQuality, itemName); + std::string lootMsg = looterName + " loots " + itemLink2; + if (count > 1) lootMsg += " x" + std::to_string(count); + lootMsg += "."; + addSystemChatMessage(lootMsg); } } break; @@ -24338,12 +24338,11 @@ void GameHandler::handleLootRoll(network::Packet& packet) { } auto* info = getItemInfo(itemId); - std::string iName = info ? info->name : std::to_string(itemId); + std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId); + uint32_t rollItemQuality = info ? info->quality : 1u; + std::string rollItemLink = buildItemLink(itemId, rollItemQuality, iName); - 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); + addSystemChatMessage(rollerName + " rolls " + rollName + " (" + std::to_string(rollNum) + ") on " + rollItemLink); LOG_DEBUG("SMSG_LOOT_ROLL: ", rollerName, " rolled ", rollName, " (", rollNum, ") on item ", itemId); @@ -24384,12 +24383,11 @@ void GameHandler::handleLootRollWon(network::Packet& packet) { } auto* info = getItemInfo(itemId); - std::string iName = info ? info->name : std::to_string(itemId); + std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId); + uint32_t wonItemQuality = info ? info->quality : 1u; + std::string wonItemLink = buildItemLink(itemId, wonItemQuality, iName); - 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); + addSystemChatMessage(winnerName + " wins " + wonItemLink + " (" + rollName + " " + std::to_string(rollNum) + ")!"); // Dismiss roll popup — roll contest is over regardless of who won pendingLootRollActive_ = false; From a43a43ed8efe54369292a5e502da377f2fc87095 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:38:18 -0700 Subject: [PATCH 34/42] fix: evict oldest minimap tile textures when cache exceeds 128 entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents unbounded GPU memory growth in long play sessions where the player visits many zones. Tiles are inserted into a FIFO deque; when the count of successfully-loaded tiles exceeds MAX_TILE_CACHE (128), the oldest entry is destroyed and removed from both the cache map and the deque. At 256×256×4 bytes per tile this caps minimap GPU usage at ~32 MB. --- include/rendering/minimap.hpp | 4 ++++ src/rendering/minimap.cpp | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/include/rendering/minimap.hpp b/include/rendering/minimap.hpp index ca7c5345..906f4666 100644 --- a/include/rendering/minimap.hpp +++ b/include/rendering/minimap.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace wowee { @@ -73,7 +74,10 @@ private: bool trsParsed = false; // Tile texture cache: hash → VkTexture + // Evicted (FIFO) when the count of successfully-loaded tiles exceeds MAX_TILE_CACHE. + static constexpr size_t MAX_TILE_CACHE = 128; std::unordered_map> tileTextureCache; + std::deque tileInsertionOrder; // hashes of successfully loaded tiles, oldest first std::unique_ptr noDataTexture; // Composite render target (3x3 tiles = 768x768) diff --git a/src/rendering/minimap.cpp b/src/rendering/minimap.cpp index f47264d0..cce494d9 100644 --- a/src/rendering/minimap.cpp +++ b/src/rendering/minimap.cpp @@ -228,6 +228,7 @@ void Minimap::shutdown() { if (tex) tex->destroy(device, alloc); } tileTextureCache.clear(); + tileInsertionOrder.clear(); if (noDataTexture) { noDataTexture->destroy(device, alloc); noDataTexture.reset(); } if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); } @@ -362,6 +363,15 @@ VkTexture* Minimap::getOrLoadTileTexture(int tileX, int tileY) { VkTexture* ptr = tex.get(); tileTextureCache[hash] = std::move(tex); + tileInsertionOrder.push_back(hash); + + // Evict oldest tiles when cache grows too large to bound GPU memory usage. + while (tileInsertionOrder.size() > MAX_TILE_CACHE) { + const std::string& oldest = tileInsertionOrder.front(); + tileTextureCache.erase(oldest); + tileInsertionOrder.pop_front(); + } + return ptr; } From 1daead3767b417b499ceb885b47df4f323f964e9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:44:14 -0700 Subject: [PATCH 35/42] feat: implement SMSG_REAL_GROUP_UPDATE handler Parse group type, member flags, and leader GUID instead of silently discarding the packet. Updates partyData so group frames reflect role changes and leadership transitions in real time. --- src/game/game_handler.cpp | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e1bd4d82..84f70a5e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7300,10 +7300,35 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } - // ---- Real group update (status flags) ---- - case Opcode::SMSG_REAL_GROUP_UPDATE: - packet.setReadPos(packet.getSize()); + // ---- Real group update (group type, local player flags, leader) ---- + // Sent when the player's group configuration changes: group type, + // role/flags (assistant/MT/MA), or leader changes. + // Format: uint8 groupType | uint32 memberFlags | uint64 leaderGuid + case Opcode::SMSG_REAL_GROUP_UPDATE: { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) break; + uint8_t newGroupType = packet.readUInt8(); + if (rem() < 4) break; + uint32_t newMemberFlags = packet.readUInt32(); + if (rem() < 8) break; + uint64_t newLeaderGuid = packet.readUInt64(); + + partyData.groupType = newGroupType; + partyData.leaderGuid = newLeaderGuid; + + // Update local player's flags in the member list + uint64_t localGuid = playerGuid; + for (auto& m : partyData.members) { + if (m.guid == localGuid) { + m.flags = static_cast(newMemberFlags & 0xFF); + break; + } + } + LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast(newGroupType), + " memberFlags=0x", std::hex, newMemberFlags, std::dec, + " leaderGuid=", newLeaderGuid); break; + } // ---- Play music (WotLK standard opcode) ---- case Opcode::SMSG_PLAY_MUSIC: { From f70df191a9ae30da13939782789e551251d4c888 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:47:53 -0700 Subject: [PATCH 36/42] feat: show tactical role badges (MT/MA/Asst) in raid frames Render "MT" (orange), "MA" (blue), and "A" (light blue) in the bottom-left of each raid cell using member flags from SMSG_GROUP_LIST and SMSG_REAL_GROUP_UPDATE (bits 0x02/0x04/0x01). Complements the existing LFG role badges at bottom-right. --- src/ui/game_screen.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 81b7af77..317f03de 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9351,6 +9351,15 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { else if (m.roles & 0x08) draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D"); + // Tactical role badge in bottom-left corner (flags from SMSG_GROUP_LIST / SMSG_REAL_GROUP_UPDATE) + // 0x01=Assistant, 0x02=Main Tank, 0x04=Main Assist + if (m.flags & 0x02) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(255, 140, 0, 230), "MT"); + else if (m.flags & 0x04) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(100, 180, 255, 230), "MA"); + else if (m.flags & 0x01) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(180, 215, 255, 180), "A"); + // Health bar uint32_t hp = m.hasPartyStats ? m.curHealth : 0; uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; From ae40d393c31ca470f56365b37b2b2ec4e64cf87e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:50:49 -0700 Subject: [PATCH 37/42] feat: show tactical role badges in party frames; fix talent reset - Add MT/MA/Asst badges to party frames (matching raid frame treatment) - Clear learnedTalents_ on SMSG_TALENTS_INVOLUNTARILY_RESET so the talent screen stays accurate after a server-side talent wipe --- src/game/game_handler.cpp | 3 +++ src/ui/game_screen.cpp | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 84f70a5e..7decf08c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -6086,6 +6086,9 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Talents involuntarily reset ---- case Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET: + // Clear cached talent data so the talent screen reflects the reset. + learnedTalents_[0].clear(); + learnedTalents_[1].clear(); addSystemChatMessage("Your talents have been reset by the server."); packet.setReadPos(packet.getSize()); break; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 317f03de..5ba3400b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -9595,6 +9595,18 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } } + // Tactical role badge (MT/MA/Asst) from group flags + if (member.flags & 0x02) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.0f, 0.9f), "[MT]"); + } else if (member.flags & 0x04) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 0.9f), "[MA]"); + } else if (member.flags & 0x01) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 0.7f), "[A]"); + } + // Raid mark symbol — shown on same line as name when this party member has a mark { static const struct { const char* sym; ImU32 col; } kPartyMarks[] = { From 503135173675f54b676661b81f30363b605af630 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:55:37 -0700 Subject: [PATCH 38/42] fix: add free-list to WardenEmulator heap allocator to prevent exhaustion The bump-pointer allocator never reused freed blocks, causing the 16 MB emulated heap to exhaust in long sessions even when blocks were freed. - First-fit reuse from a free-list before advancing the bump pointer - Coalesce adjacent free blocks to limit fragmentation - Roll back the bump pointer when the top free block reaches it - Reset allocator state on initialize() so re-runs start clean --- include/game/warden_emulator.hpp | 1 + src/game/warden_emulator.cpp | 67 ++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/include/game/warden_emulator.hpp b/include/game/warden_emulator.hpp index 320afd0d..c3dbc37c 100644 --- a/include/game/warden_emulator.hpp +++ b/include/game/warden_emulator.hpp @@ -152,6 +152,7 @@ private: // Memory allocation tracking std::map allocations_; + std::map freeBlocks_; // free-list keyed by base address uint32_t nextHeapAddr_; // Hook handles for cleanup diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index 9338cc00..b1a99f7e 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -2,6 +2,7 @@ #include "core/logger.hpp" #include #include +#include #ifdef HAVE_UNICORN // Unicorn Engine headers @@ -46,6 +47,12 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 LOG_ERROR("WardenEmulator: Already initialized"); return false; } + // Reset allocator state so re-initialization starts with a clean heap. + allocations_.clear(); + freeBlocks_.clear(); + apiAddresses_.clear(); + hooks_.clear(); + nextHeapAddr_ = heapBase_; { char addrBuf[32]; @@ -282,17 +289,37 @@ std::string WardenEmulator::readString(uint32_t address, size_t maxLen) { } uint32_t WardenEmulator::allocateMemory(size_t size, [[maybe_unused]] uint32_t protection) { + if (size == 0) return 0; + // Align to 4KB size = (size + 0xFFF) & ~0xFFF; + const uint32_t allocSize = static_cast(size); - if (nextHeapAddr_ + size > heapBase_ + heapSize_) { + // First-fit from free list so released blocks can be reused. + for (auto it = freeBlocks_.begin(); it != freeBlocks_.end(); ++it) { + if (it->second < size) continue; + const uint32_t addr = it->first; + const size_t blockSz = it->second; + freeBlocks_.erase(it); + if (blockSz > size) + freeBlocks_[addr + allocSize] = blockSz - size; + allocations_[addr] = size; + { + char mBuf[32]; + std::snprintf(mBuf, sizeof(mBuf), "0x%X", addr); + LOG_DEBUG("WardenEmulator: Reused ", size, " bytes at ", mBuf); + } + return addr; + } + + const uint64_t heapEnd = static_cast(heapBase_) + heapSize_; + if (static_cast(nextHeapAddr_) + size > heapEnd) { LOG_ERROR("WardenEmulator: Heap exhausted"); return 0; } uint32_t addr = nextHeapAddr_; - nextHeapAddr_ += size; - + nextHeapAddr_ += allocSize; allocations_[addr] = size; { @@ -320,7 +347,41 @@ bool WardenEmulator::freeMemory(uint32_t address) { std::snprintf(fBuf, sizeof(fBuf), "0x%X", address); LOG_DEBUG("WardenEmulator: Freed ", it->second, " bytes at ", fBuf); } + + const size_t freedSize = it->second; allocations_.erase(it); + + // Insert in free list and coalesce adjacent blocks to limit fragmentation. + auto [curr, inserted] = freeBlocks_.emplace(address, freedSize); + if (!inserted) curr->second += freedSize; + + if (curr != freeBlocks_.begin()) { + auto prev = std::prev(curr); + if (static_cast(prev->first) + prev->second == curr->first) { + prev->second += curr->second; + freeBlocks_.erase(curr); + curr = prev; + } + } + + auto next = std::next(curr); + if (next != freeBlocks_.end() && + static_cast(curr->first) + curr->second == next->first) { + curr->second += next->second; + freeBlocks_.erase(next); + } + + // Roll back the bump pointer if the highest free block reaches it. + while (!freeBlocks_.empty()) { + auto last = std::prev(freeBlocks_.end()); + if (static_cast(last->first) + last->second == nextHeapAddr_) { + nextHeapAddr_ = last->first; + freeBlocks_.erase(last); + } else { + break; + } + } + return true; } From b23dbc9ab782fd41ff17a4b7436081025579bcfc Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 13:59:42 -0700 Subject: [PATCH 39/42] feat: apply out-of-range red tint to ranged items on action bar Extend the existing out-of-range check to cover ITEM slots whose inventory type is Ranged (bow/gun/crossbow, 40 yd), RangedRight (wand, 40 yd), or Thrown (30 yd). The check runs after barItemDef is resolved so the inventory type is available. --- src/ui/game_screen.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5ba3400b..0c815b94 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6713,6 +6713,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { // Out-of-range check: red tint when a targeted spell cannot reach the current target. // Only applies to SPELL slots with a known max range (>5 yd) and an active target. + // Item range is checked below after barItemDef is populated. bool outOfRange = false; if (!slot.isEmpty() && slot.type == game::ActionBarSlot::SPELL && slot.id != 0 && !onCooldown && gameHandler.hasTarget()) { @@ -6802,6 +6803,33 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 && barItemDef == nullptr && !onCooldown); + // Ranged item out-of-range check (runs after barItemDef is populated above). + // invType 15=Ranged (bow/gun/crossbow), 26=Thrown, 28=RangedRight (wand/crossbow). + if (!outOfRange && slot.type == game::ActionBarSlot::ITEM && barItemDef + && !onCooldown && gameHandler.hasTarget()) { + constexpr uint8_t INVTYPE_RANGED = 15; + constexpr uint8_t INVTYPE_THROWN = 26; + constexpr uint8_t INVTYPE_RANGEDRIGHT = 28; + uint32_t itemMaxRange = 0; + if (barItemDef->inventoryType == INVTYPE_RANGED || + barItemDef->inventoryType == INVTYPE_RANGEDRIGHT) + itemMaxRange = 40; + else if (barItemDef->inventoryType == INVTYPE_THROWN) + itemMaxRange = 30; + if (itemMaxRange > 0) { + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(itemMaxRange)) + outOfRange = true; + } + } + } + bool clicked = false; if (iconTex) { ImVec4 tintColor(1, 1, 1, 1); From 8b9d626aec355aad4e8e54c53f05183b95f03fb3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:10:56 -0700 Subject: [PATCH 40/42] feat: show directional arrow on world map player marker Replace the static filled circle with a red triangle arrow that rotates to match the character's current facing direction. Uses the same render-space yaw convention as the 3D scene so the arrow matches in-world orientation. --- include/rendering/world_map.hpp | 6 ++++-- src/rendering/world_map.cpp | 23 ++++++++++++++++++----- src/ui/game_screen.cpp | 3 ++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 77a98ec0..e908ffaa 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -51,7 +51,8 @@ public: void compositePass(VkCommandBuffer cmd); /// ImGui overlay — call INSIDE the main render pass (during ImGui frame). - void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, + float playerYawDeg = 0.0f); void setMapName(const std::string& name); void setServerExplorationMask(const std::vector& masks, bool hasData); @@ -71,7 +72,8 @@ private: float& top, float& bottom) const; void loadZoneTextures(int zoneIdx); void requestComposite(int zoneIdx); - void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, + float playerYawDeg); void updateExploration(const glm::vec3& playerRenderPos); void zoomIn(const glm::vec3& playerRenderPos); void zoomOut(); diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 7ee4f43a..8163628d 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -835,7 +835,8 @@ void WorldMap::zoomOut() { // Main render (input + ImGui overlay) // -------------------------------------------------------- -void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { +void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, + float playerYawDeg) { if (!initialized || !assetManager) return; auto& input = core::Input::getInstance(); @@ -886,14 +887,14 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr } if (!open) return; - renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight); + renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight, playerYawDeg); } // -------------------------------------------------------- // ImGui overlay // -------------------------------------------------------- -void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { +void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, float playerYawDeg) { float sw = static_cast(screenWidth); float sh = static_cast(screenHeight); @@ -1014,8 +1015,20 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi playerUV.y >= 0.0f && playerUV.y <= 1.0f) { float px = imgMin.x + playerUV.x * displayW; float py = imgMin.y + playerUV.y * displayH; - drawList->AddCircleFilled(ImVec2(px, py), 6.0f, IM_COL32(255, 40, 40, 255)); - drawList->AddCircle(ImVec2(px, py), 6.0f, IM_COL32(0, 0, 0, 200), 0, 2.0f); + // Directional arrow: render-space (cos,sin) maps to screen (-dx,-dy) + // because render+X=west=left and render+Y=north=up (screen Y is down). + float yawRad = glm::radians(playerYawDeg); + float adx = -std::cos(yawRad); // screen-space arrow X + float ady = -std::sin(yawRad); // screen-space arrow Y + float apx = -ady, apy = adx; // perpendicular (left/right of arrow) + constexpr float TIP = 9.0f; // tip distance from center + constexpr float TAIL = 4.0f; // tail distance from center + constexpr float HALF = 5.0f; // half base width + ImVec2 tip(px + adx * TIP, py + ady * TIP); + ImVec2 bl (px - adx * TAIL + apx * HALF, py - ady * TAIL + apy * HALF); + ImVec2 br (px - adx * TAIL - apx * HALF, py - ady * TAIL - apy * HALF); + drawList->AddTriangleFilled(tip, bl, br, IM_COL32(255, 40, 40, 255)); + drawList->AddTriangle(tip, bl, br, IM_COL32(0, 0, 0, 200), 1.5f); } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 0c815b94..b3ffdb75 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6539,10 +6539,11 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { } glm::vec3 playerPos = renderer->getCharacterPosition(); + float playerYaw = renderer->getCharacterYaw(); auto* window = app.getWindow(); int screenW = window ? window->getWidth() : 1280; int screenH = window ? window->getHeight() : 720; - wm->render(playerPos, screenW, screenH); + wm->render(playerPos, screenW, screenH, playerYaw); // Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay). if (!wm->isOpen()) showWorldMap_ = false; From 39f4162ec1011125d0f7e7e66296dec1e8cdd1c7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:16:14 -0700 Subject: [PATCH 41/42] fix: show skull-red color and "Lv ??" for unknown-level mobs in target frame Level 0 in the update fields means the server hasn't sent or the mob is undetectable (e.g. high-level raid bosses). Previously these were colored grey (no-XP path) and displayed "Lv 0". Now they correctly show skull-red and display "Lv ??" to match WoW. --- src/ui/game_screen.cpp | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index b3ffdb75..96f33ec8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -3557,17 +3557,22 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { // WoW level-based color for hostile mobs uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t mobLv = u->getLevel(); - int32_t diff = static_cast(mobLv) - static_cast(playerLv); - if (game::GameHandler::killXp(playerLv, mobLv) == 0) { - hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey - no XP - } else if (diff >= 10) { - hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard - } else if (diff >= 5) { - hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard - } else if (diff >= -2) { - hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even + if (mobLv == 0) { + // Level 0 = unknown/?? (e.g. high-level raid bosses) — always skull red + hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); } else { - hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) { + hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey - no XP + } else if (diff >= 10) { + hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard + } else if (diff >= 5) { + hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard + } else if (diff >= -2) { + hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even + } else { + hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy + } } } else { hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Friendly @@ -3745,7 +3750,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (target->getType() == game::ObjectType::PLAYER) { levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } - ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + if (unit->getLevel() == 0) + ImGui::TextColored(levelColor, "Lv ??"); + else + ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); // Classification badge: Elite / Rare Elite / Boss / Rare if (target->getType() == game::ObjectType::UNIT) { int rank = gameHandler.getCreatureRank(unit->getEntry()); From 5513c4aad5d9b7ef058720a8e2bba2612785e126 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 17 Mar 2026 14:18:49 -0700 Subject: [PATCH 42/42] fix: apply skull-red color and "Lv ??" to level-0 mobs in focus frame Consistent with the target frame fix: focus targets with level 0 (unknown/?? mobs) now show skull-red instead of grey, and display "Lv ??" instead of "Lv 0". --- src/ui/game_screen.cpp | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 96f33ec8..771a1ba2 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -4344,17 +4344,21 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { } else if (u->isHostile()) { uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t mobLv = u->getLevel(); - int32_t diff = static_cast(mobLv) - static_cast(playerLv); - if (game::GameHandler::killXp(playerLv, mobLv) == 0) - focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); - else if (diff >= 10) - focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); - else if (diff >= 5) - focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); - else if (diff >= -2) - focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); - else - focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + if (mobLv == 0) { + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // ?? level = skull red + } else { + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) + focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); + else if (diff >= 10) + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); + else if (diff >= 5) + focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); + else if (diff >= -2) + focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); + else + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } } else { focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); } @@ -4487,7 +4491,10 @@ void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { // Level + health on same row ImGui::SameLine(); - ImGui::TextDisabled("Lv %u", unit->getLevel()); + if (unit->getLevel() == 0) + ImGui::TextDisabled("Lv ??"); + else + ImGui::TextDisabled("Lv %u", unit->getLevel()); uint32_t hp = unit->getHealth(); uint32_t maxHp = unit->getMaxHealth();