diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0357408b..0698607a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,10 @@ jobs: - name: Configure run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + - name: Verify Release Config + run: | + cmake -LA -N build | grep -E '^CMAKE_BUILD_TYPE:STRING=Release$' + - name: Build run: cmake --build build --parallel $(nproc) @@ -123,6 +127,10 @@ jobs: -DCMAKE_PREFIX_PATH="$BREW" \ -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" + - name: Verify Release Config + run: | + cmake -LA -N build | grep -E '^CMAKE_BUILD_TYPE:STRING=Release$' + - name: Build run: cmake --build build --parallel $(sysctl -n hw.logicalcpu) @@ -271,6 +279,11 @@ jobs: shell: msys2 {0} run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + - name: Verify Release Config + shell: msys2 {0} + run: | + cmake -LA -N build | grep -E '^CMAKE_BUILD_TYPE:STRING=Release$' + - name: Build shell: msys2 {0} run: cmake --build build --parallel $(nproc) diff --git a/.gitignore b/.gitignore index f95c18ed..9a80c3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,7 @@ asset_pipeline/ # Local texture dumps / extracted art should never be committed assets/textures/ node_modules/ + +# Python cache artifacts +tools/__pycache__/ +*.pyc diff --git a/CMakeLists.txt b/CMakeLists.txt index fe58f6c2..bd4232b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,13 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Explicitly tag optimized configs so runtime defaults can enforce low-noise logging. +add_compile_definitions( + $<$:WOWEE_RELEASE_LOGGING> + $<$:WOWEE_RELEASE_LOGGING> + $<$:WOWEE_RELEASE_LOGGING> +) + # Output directories set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) diff --git a/assets/Original Music/TavernAlliance01.mp3 b/assets/Original Music/TavernAlliance01.mp3 new file mode 100644 index 00000000..12dab6d2 Binary files /dev/null and b/assets/Original Music/TavernAlliance01.mp3 differ diff --git a/assets/Original Music/TavernAllianceREMIX.mp3 b/assets/Original Music/TavernAllianceREMIX.mp3 new file mode 100644 index 00000000..e153f568 Binary files /dev/null and b/assets/Original Music/TavernAllianceREMIX.mp3 differ diff --git a/build.bat b/build.bat new file mode 100644 index 00000000..efeefb5a --- /dev/null +++ b/build.bat @@ -0,0 +1,3 @@ +@echo off +REM Convenience wrapper — launches the PowerShell build script. +powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 00000000..1f35f0f1 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + Builds the wowee project (Windows equivalent of build.sh). + +.DESCRIPTION + Creates a build directory, runs CMake configure + build, and creates a + directory junction for the Data folder so the binary can find assets. +#> + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +Write-Host "Building wowee..." + +# Create build directory if it doesn't exist +if (-not (Test-Path "build")) { + New-Item -ItemType Directory -Path "build" | Out-Null +} +Set-Location "build" + +# Configure with CMake +Write-Host "Configuring with CMake..." +& cmake .. -DCMAKE_BUILD_TYPE=Release +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Build with all cores +$numProcs = $env:NUMBER_OF_PROCESSORS +if (-not $numProcs) { $numProcs = 4 } +Write-Host "Building with $numProcs cores..." +& cmake --build . --parallel $numProcs +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Ensure Data junction exists in bin directory +$binData = Join-Path (Get-Location) "bin\Data" +if (-not (Test-Path $binData)) { + $target = (Resolve-Path (Join-Path (Get-Location) "..\Data")).Path + cmd /c mklink /J "$binData" "$target" +} + +Write-Host "" +Write-Host "Build complete! Binary: build\bin\wowee.exe" +Write-Host "Run with: cd build\bin && .\wowee.exe" diff --git a/debug_texture.bat b/debug_texture.bat new file mode 100644 index 00000000..7e80310c --- /dev/null +++ b/debug_texture.bat @@ -0,0 +1,3 @@ +@echo off +REM Convenience wrapper — launches the PowerShell texture debug script. +powershell -ExecutionPolicy Bypass -File "%~dp0debug_texture.ps1" %* diff --git a/debug_texture.ps1 b/debug_texture.ps1 new file mode 100644 index 00000000..18feda3c --- /dev/null +++ b/debug_texture.ps1 @@ -0,0 +1,64 @@ +<# +.SYNOPSIS + Converts raw RGBA texture dumps to PNG for visual inspection (Windows equivalent of debug_texture.sh). + +.PARAMETER Width + Texture width in pixels. Defaults to 1024. + +.PARAMETER Height + Texture height in pixels. Defaults to 1024. + +.EXAMPLE + .\debug_texture.ps1 + .\debug_texture.ps1 -Width 2048 -Height 2048 +#> + +param( + [int]$Width = 1024, + [int]$Height = 1024 +) + +$TempDir = $env:TEMP + +Write-Host "Converting debug textures (${Width}x${Height})..." + +# Find raw dumps — filenames include dimensions (e.g. wowee_composite_debug_1024x1024.raw) +$rawFiles = Get-ChildItem -Path $TempDir -Filter "wowee_*_debug*.raw" -ErrorAction SilentlyContinue + +if (-not $rawFiles) { + Write-Host "No debug dumps found in $TempDir" + Write-Host " (looked for $TempDir\wowee_*_debug*.raw)" + exit 0 +} + +foreach ($rawItem in $rawFiles) { + $raw = $rawItem.FullName + $png = $raw -replace '\.raw$', '.png' + + # Try ImageMagick first, fall back to ffmpeg + if (Get-Command magick -ErrorAction SilentlyContinue) { + & magick -size "${Width}x${Height}" -depth 8 "rgba:$raw" "$png" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Created $png (${Width}x${Height})" + } else { + Write-Host "Failed to convert $raw" + } + } elseif (Get-Command convert -ErrorAction SilentlyContinue) { + & convert -size "${Width}x${Height}" -depth 8 "rgba:$raw" "$png" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Created $png (${Width}x${Height})" + } else { + Write-Host "Failed to convert $raw" + } + } elseif (Get-Command ffmpeg -ErrorAction SilentlyContinue) { + & ffmpeg -y -f rawvideo -pix_fmt rgba -s "${Width}x${Height}" -i "$raw" "$png" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Created $png (${Width}x${Height})" + } else { + Write-Host "Failed to convert $raw" + } + } else { + Write-Host "Need 'magick' (ImageMagick) or 'ffmpeg' to convert $raw" + Write-Host " Install: winget install ImageMagick.ImageMagick" + } +} diff --git a/debug_texture.sh b/debug_texture.sh index feaa42d5..36725cd6 100755 --- a/debug_texture.sh +++ b/debug_texture.sh @@ -8,23 +8,32 @@ H=${2:-1024} echo "Converting debug textures (${W}x${H})..." -for raw in /tmp/wowee_composite_debug.raw /tmp/wowee_equip_composite_debug.raw; do - if [ -f "$raw" ]; then - png="${raw%.raw}.png" - # Try ImageMagick first, fall back to ffmpeg - if command -v convert &>/dev/null; then - convert -size ${W}x${H} -depth 8 rgba:"$raw" "$png" 2>/dev/null && \ - echo "Created $png (${W}x${H})" || \ - echo "Failed to convert $raw" - elif command -v ffmpeg &>/dev/null; then - ffmpeg -y -f rawvideo -pix_fmt rgba -s ${W}x${H} -i "$raw" "$png" 2>/dev/null && \ - echo "Created $png (${W}x${H})" || \ - echo "Failed to convert $raw" - else - echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw" - echo " Install: sudo apt install imagemagick" - fi +TMPD="${TMPDIR:-/tmp}" + +# Find raw dumps — filenames include dimensions (e.g. wowee_composite_debug_1024x1024.raw) +shopt -s nullglob +RAW_FILES=("$TMPD"/wowee_*_debug*.raw) +shopt -u nullglob + +if [ ${#RAW_FILES[@]} -eq 0 ]; then + echo "No debug dumps found in $TMPD" + echo " (looked for $TMPD/wowee_*_debug*.raw)" + exit 0 +fi + +for raw in "${RAW_FILES[@]}"; do + png="${raw%.raw}.png" + # Try ImageMagick first, fall back to ffmpeg + if command -v convert &>/dev/null; then + convert -size ${W}x${H} -depth 8 rgba:"$raw" "$png" 2>/dev/null && \ + echo "Created $png (${W}x${H})" || \ + echo "Failed to convert $raw" + elif command -v ffmpeg &>/dev/null; then + ffmpeg -y -f rawvideo -pix_fmt rgba -s ${W}x${H} -i "$raw" "$png" 2>/dev/null && \ + echo "Created $png (${W}x${H})" || \ + echo "Failed to convert $raw" else - echo "Not found: $raw" + echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw" + echo " Install: sudo apt install imagemagick" fi done diff --git a/include/core/logger.hpp b/include/core/logger.hpp index 4ba4e3ce..a927abee 100644 --- a/include/core/logger.hpp +++ b/include/core/logger.hpp @@ -65,6 +65,13 @@ public: } private: + static constexpr int kDefaultMinLevelValue = +#if defined(NDEBUG) || defined(WOWEE_RELEASE_LOGGING) + static_cast(LogLevel::WARNING); +#else + static_cast(LogLevel::INFO); +#endif + Logger() = default; ~Logger() = default; Logger(const Logger&) = delete; @@ -77,22 +84,61 @@ private: return oss.str(); } - std::atomic minLevel_{static_cast(LogLevel::INFO)}; + std::atomic minLevel_{kDefaultMinLevelValue}; std::mutex mutex; std::ofstream fileStream; bool fileReady = false; bool echoToStdout_ = true; std::chrono::steady_clock::time_point lastFlushTime_{}; uint32_t flushIntervalMs_ = 250; + bool dedupeEnabled_ = true; + uint32_t dedupeWindowMs_ = 250; + LogLevel lastLevel_ = LogLevel::DEBUG; + std::string lastMessage_; + std::chrono::steady_clock::time_point lastMessageTime_{}; + uint64_t suppressedCount_ = 0; + void emitLineLocked(LogLevel level, const std::string& message); + void flushSuppressedLocked(); void ensureFile(); }; -// Convenience macros -#define LOG_DEBUG(...) wowee::core::Logger::getInstance().debug(__VA_ARGS__) -#define LOG_INFO(...) wowee::core::Logger::getInstance().info(__VA_ARGS__) -#define LOG_WARNING(...) wowee::core::Logger::getInstance().warning(__VA_ARGS__) -#define LOG_ERROR(...) wowee::core::Logger::getInstance().error(__VA_ARGS__) -#define LOG_FATAL(...) wowee::core::Logger::getInstance().fatal(__VA_ARGS__) +// Convenience macros. +// Guard calls at the macro site so variadic arguments are not evaluated +// when the corresponding level is disabled. +#define LOG_DEBUG(...) do { \ + auto& _wowee_logger = wowee::core::Logger::getInstance(); \ + if (_wowee_logger.shouldLog(wowee::core::LogLevel::DEBUG)) { \ + _wowee_logger.debug(__VA_ARGS__); \ + } \ +} while (0) + +#define LOG_INFO(...) do { \ + auto& _wowee_logger = wowee::core::Logger::getInstance(); \ + if (_wowee_logger.shouldLog(wowee::core::LogLevel::INFO)) { \ + _wowee_logger.info(__VA_ARGS__); \ + } \ +} while (0) + +#define LOG_WARNING(...) do { \ + auto& _wowee_logger = wowee::core::Logger::getInstance(); \ + if (_wowee_logger.shouldLog(wowee::core::LogLevel::WARNING)) { \ + _wowee_logger.warning(__VA_ARGS__); \ + } \ +} while (0) + +#define LOG_ERROR(...) do { \ + auto& _wowee_logger = wowee::core::Logger::getInstance(); \ + if (_wowee_logger.shouldLog(wowee::core::LogLevel::ERROR)) { \ + _wowee_logger.error(__VA_ARGS__); \ + } \ +} while (0) + +#define LOG_FATAL(...) do { \ + auto& _wowee_logger = wowee::core::Logger::getInstance(); \ + if (_wowee_logger.shouldLog(wowee::core::LogLevel::FATAL)) { \ + _wowee_logger.fatal(__VA_ARGS__); \ + } \ +} while (0) } // namespace core } // namespace wowee diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 835627bc..3431ce55 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -171,6 +171,8 @@ private: // Cached isInsideWMO result (throttled to avoid per-frame cost) bool cachedInsideWMO = false; bool cachedInsideInteriorWMO = false; + int insideStateCheckCounter_ = 0; + glm::vec3 lastInsideStateCheckPos_ = glm::vec3(0.0f); int insideWMOCheckCounter = 0; glm::vec3 lastInsideWMOCheckPos = glm::vec3(0.0f); diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 4f163e89..3e3dba7f 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -238,6 +238,7 @@ private: glm::vec3 shadowCenter = glm::vec3(0.0f); bool shadowCenterInitialized = false; bool shadowsEnabled = true; + uint32_t shadowFrameCounter_ = 0; public: diff --git a/include/ui/auth_screen.hpp b/include/ui/auth_screen.hpp index 484797e3..e1dbdde7 100644 --- a/include/ui/auth_screen.hpp +++ b/include/ui/auth_screen.hpp @@ -120,6 +120,9 @@ private: bool musicInitAttempted = false; bool musicPlaying = false; + bool missingIntroTracksLogged_ = false; + bool introTracksScanned_ = false; + std::vector introTracks_; bool loginMusicVolumeAdjusted_ = false; int savedMusicVolume_ = 30; }; diff --git a/rebuild.bat b/rebuild.bat new file mode 100644 index 00000000..196b6eed --- /dev/null +++ b/rebuild.bat @@ -0,0 +1,3 @@ +@echo off +REM Convenience wrapper — launches the PowerShell clean rebuild script. +powershell -ExecutionPolicy Bypass -File "%~dp0rebuild.ps1" %* diff --git a/rebuild.ps1 b/rebuild.ps1 new file mode 100644 index 00000000..b365e3b4 --- /dev/null +++ b/rebuild.ps1 @@ -0,0 +1,50 @@ +<# +.SYNOPSIS + Clean rebuilds the wowee project (Windows equivalent of rebuild.sh). + +.DESCRIPTION + Removes the build directory, reconfigures from scratch, rebuilds, and + creates a directory junction for the Data folder. +#> + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +Write-Host "Clean rebuilding wowee..." + +# Remove build directory completely +if (Test-Path "build") { + Write-Host "Removing old build directory..." + Remove-Item -Recurse -Force "build" +} + +# Create fresh build directory +New-Item -ItemType Directory -Path "build" | Out-Null +Set-Location "build" + +# Configure with CMake +Write-Host "Configuring with CMake..." +& cmake .. -DCMAKE_BUILD_TYPE=Release +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Build with all cores +$numProcs = $env:NUMBER_OF_PROCESSORS +if (-not $numProcs) { $numProcs = 4 } +Write-Host "Building with $numProcs cores..." +& cmake --build . --parallel $numProcs +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Create Data junction in bin directory +Write-Host "Creating Data junction..." +$binData = Join-Path (Get-Location) "bin\Data" +if (-not (Test-Path $binData)) { + $target = (Resolve-Path (Join-Path (Get-Location) "..\Data")).Path + cmd /c mklink /J "$binData" "$target" + Write-Host " Created Data junction -> $target" +} + +Write-Host "" +Write-Host "Clean build complete! Binary: build\bin\wowee.exe" +Write-Host "Run with: cd build\bin && .\wowee.exe" diff --git a/src/audio/ambient_sound_manager.cpp b/src/audio/ambient_sound_manager.cpp index 7ab88689..5e820ef7 100644 --- a/src/audio/ambient_sound_manager.cpp +++ b/src/audio/ambient_sound_manager.cpp @@ -296,7 +296,8 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v const int MAX_ACTIVE_WATER = 3; // Max 3 water sounds at once for (auto& emitter : emitters_) { - float distance = glm::distance(emitter.position, cameraPos); + const glm::vec3 delta = emitter.position - cameraPos; + const float distSq = glm::dot(delta, delta); // Determine max distance based on type float maxDist = MAX_AMBIENT_DISTANCE; @@ -317,7 +318,8 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v } // Update active state based on distance AND limits - bool withinRange = (distance < maxDist); + const float maxDistSq = maxDist * maxDist; + const bool withinRange = (distSq < maxDistSq); if (isFire && withinRange && activeFireCount < MAX_ACTIVE_FIRE) { emitter.active = true; @@ -336,6 +338,9 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v // Update play timer emitter.lastPlayTime += deltaTime; + // We only need the true distance for volume attenuation once the emitter is active. + const float distance = std::sqrt(distSq); + // Handle different emitter types switch (emitter.type) { case AmbientType::FIREPLACE_SMALL: diff --git a/src/auth/auth_packets.cpp b/src/auth/auth_packets.cpp index b8390831..f95a5344 100644 --- a/src/auth/auth_packets.cpp +++ b/src/auth/auth_packets.cpp @@ -1,12 +1,59 @@ #include "auth/auth_packets.hpp" #include "core/logger.hpp" +#include "network/net_platform.hpp" #include #include #include +#include namespace wowee { namespace auth { +namespace { +bool detectOutboundIPv4(std::array& outIp) { + net::ensureInit(); + + socket_t s = ::socket(AF_INET, SOCK_DGRAM, 0); + if (s == INVALID_SOCK) { + return false; + } + + sockaddr_in remote{}; + remote.sin_family = AF_INET; + remote.sin_port = htons(53); + if (inet_pton(AF_INET, "1.1.1.1", &remote.sin_addr) != 1) { + net::closeSocket(s); + return false; + } + + if (::connect(s, reinterpret_cast(&remote), sizeof(remote)) != 0) { + net::closeSocket(s); + return false; + } + + sockaddr_in local{}; +#ifdef _WIN32 + int localLen = sizeof(local); +#else + socklen_t localLen = sizeof(local); +#endif + if (::getsockname(s, reinterpret_cast(&local), &localLen) != 0) { + net::closeSocket(s); + return false; + } + + net::closeSocket(s); + + const uint32_t ip = ntohl(local.sin_addr.s_addr); + outIp[0] = static_cast((ip >> 24) & 0xFF); + outIp[1] = static_cast((ip >> 16) & 0xFF); + outIp[2] = static_cast((ip >> 8) & 0xFF); + outIp[3] = static_cast(ip & 0xFF); + + return (ip != 0); +} +} // namespace + network::Packet LogonChallengePacket::build(const std::string& account, const ClientInfo& info) { // Convert account to uppercase std::string upperAccount = account; @@ -66,8 +113,20 @@ network::Packet LogonChallengePacket::build(const std::string& account, const Cl // Timezone packet.writeUInt32(info.timezone); - // IP address (always 0) - packet.writeUInt32(0); + // Client IP: use the real outbound local IPv4 when detectable. + // Fallback to 0.0.0.0 if detection fails. + { + std::array localIp{0, 0, 0, 0}; + if (detectOutboundIPv4(localIp)) { + packet.writeUInt8(localIp[0]); + packet.writeUInt8(localIp[1]); + packet.writeUInt8(localIp[2]); + packet.writeUInt8(localIp[3]); + } else { + packet.writeUInt32(0); + LOG_DEBUG("LOGON_CHALLENGE client IP detection failed; using 0.0.0.0 fallback"); + } + } // Account length and name packet.writeUInt8(static_cast(upperAccount.length())); diff --git a/src/auth/integrity.cpp b/src/auth/integrity.cpp index cdf7d146..13f71261 100644 --- a/src/auth/integrity.cpp +++ b/src/auth/integrity.cpp @@ -3,6 +3,7 @@ #include #include +#include namespace wowee { namespace auth { @@ -41,39 +42,46 @@ bool computeIntegrityHashWin32WithExe(const std::array& checksumSal // that distribution rather than a stock 1.12.1 client, so when using Turtle's executable we include // Turtle-specific DLLs as well. const bool isTurtleExe = (exeName == "TurtleWoW.exe"); - const char* kFilesBase[] = { - nullptr, // exeName - "fmod.dll", - "ijl15.dll", - "dbghelp.dll", - "unicows.dll", + // Some macOS client layouts use FMOD dylib naming instead of fmod.dll. + // We accept the first matching filename in each alias group. + std::vector> fileGroups = { + { exeName }, + { "fmod.dll", "fmod.dylib", "libfmod.dylib", "fmodex.dll", "fmodex.dylib", "libfmod.so" }, + { "ijl15.dll" }, + { "dbghelp.dll" }, + { "unicows.dll" }, }; - const char* kFilesTurtleExtra[] = { - "twloader.dll", - "twdiscord.dll", - }; - - std::vector files; - files.reserve(1 + 4 + (isTurtleExe ? (sizeof(kFilesTurtleExtra) / sizeof(kFilesTurtleExtra[0])) : 0)); - for (const char* f : kFilesBase) { - files.push_back(f ? std::string(f) : exeName); - } if (isTurtleExe) { - for (const char* f : kFilesTurtleExtra) files.push_back(std::string(f)); + fileGroups.push_back({ "twloader.dll" }); + fileGroups.push_back({ "twdiscord.dll" }); } std::vector allFiles; - std::string err; - for (const auto& nameStr : files) { - std::vector bytes; - std::string path = miscDir; - if (!path.empty() && path.back() != '/') path += '/'; - path += nameStr; - if (!readWholeFile(path, bytes, err)) { - outError = err; + for (const auto& group : fileGroups) { + bool foundInGroup = false; + std::string groupErr; + + for (const auto& nameStr : group) { + std::vector bytes; + std::string path = miscDir; + if (!path.empty() && path.back() != '/') path += '/'; + path += nameStr; + + std::string err; + if (!readWholeFile(path, bytes, err)) { + if (groupErr.empty()) groupErr = err; + continue; + } + + allFiles.insert(allFiles.end(), bytes.begin(), bytes.end()); + foundInGroup = true; + break; + } + + if (!foundInGroup) { + outError = groupErr.empty() ? "missing required integrity file group" : groupErr; return false; } - allFiles.insert(allFiles.end(), bytes.begin(), bytes.end()); } // HMAC_SHA1(checksumSalt, allFiles) diff --git a/src/core/logger.cpp b/src/core/logger.cpp index 498dd219..f513b836 100644 --- a/src/core/logger.cpp +++ b/src/core/logger.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include namespace wowee { namespace core { @@ -28,20 +30,35 @@ void Logger::ensureFile() { flushIntervalMs_ = static_cast(parsed); } } + if (const char* dedupe = std::getenv("WOWEE_LOG_DEDUPE")) { + dedupeEnabled_ = !(dedupe[0] == '0' || dedupe[0] == 'f' || dedupe[0] == 'F' || + dedupe[0] == 'n' || dedupe[0] == 'N'); + } + if (const char* dedupeMs = std::getenv("WOWEE_LOG_DEDUPE_MS")) { + char* end = nullptr; + unsigned long parsed = std::strtoul(dedupeMs, &end, 10); + if (end != dedupeMs && parsed <= 60000ul) { + dedupeWindowMs_ = static_cast(parsed); + } + } + if (const char* level = std::getenv("WOWEE_LOG_LEVEL")) { + std::string v(level); + std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (v == "debug") setLogLevel(LogLevel::DEBUG); + else if (v == "info") setLogLevel(LogLevel::INFO); + else if (v == "warn" || v == "warning") setLogLevel(LogLevel::WARNING); + else if (v == "error") setLogLevel(LogLevel::ERROR); + else if (v == "fatal") setLogLevel(LogLevel::FATAL); + } std::error_code ec; std::filesystem::create_directories("logs", ec); fileStream.open("logs/wowee.log", std::ios::out | std::ios::trunc); lastFlushTime_ = std::chrono::steady_clock::now(); } -void Logger::log(LogLevel level, const std::string& message) { - if (!shouldLog(level)) { - return; - } - - std::lock_guard lock(mutex); - ensureFile(); - +void Logger::emitLineLocked(LogLevel level, const std::string& message) { // Get current time auto now = std::chrono::system_clock::now(); auto time = std::chrono::system_clock::to_time_t(now); @@ -92,6 +109,38 @@ void Logger::log(LogLevel level, const std::string& message) { } } +void Logger::flushSuppressedLocked() { + if (suppressedCount_ == 0) return; + emitLineLocked(lastLevel_, "Previous message repeated " + std::to_string(suppressedCount_) + " times"); + suppressedCount_ = 0; +} + +void Logger::log(LogLevel level, const std::string& message) { + if (!shouldLog(level)) { + return; + } + + std::lock_guard lock(mutex); + ensureFile(); + + auto nowSteady = std::chrono::steady_clock::now(); + if (dedupeEnabled_ && !lastMessage_.empty() && + level == lastLevel_ && message == lastMessage_) { + auto elapsedMs = std::chrono::duration_cast(nowSteady - lastMessageTime_).count(); + if (elapsedMs >= 0 && elapsedMs <= static_cast(dedupeWindowMs_)) { + ++suppressedCount_; + lastMessageTime_ = nowSteady; + return; + } + } + + flushSuppressedLocked(); + emitLineLocked(level, message); + lastLevel_ = level; + lastMessage_ = message; + lastMessageTime_ = nowSteady; +} + void Logger::setLogLevel(LogLevel level) { minLevel_.store(static_cast(level), std::memory_order_relaxed); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 03ad0679..4f1c844a 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1235,7 +1235,7 @@ void GameHandler::handlePacket(network::Packet& packet) { ++wardenPacketsAfterGate_; } if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) { - LOG_INFO("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec, + LOG_DEBUG("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec, " state=", worldStateName(state), " size=", packet.getSize()); } @@ -3462,7 +3462,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_INFO("Warden: Check opcodes: ", opcHex); + LOG_DEBUG("Warden: Check opcodes: ", opcHex); } size_t entryCount = (static_cast(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE; @@ -3512,17 +3512,20 @@ void GameHandler::handleWardenData(network::Packet& packet) { // Decrypt the payload std::vector decrypted = wardenCrypto_->decrypt(data); - // Log decrypted data - { + // Avoid expensive hex formatting when DEBUG logs are disabled. + if (core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG)) { std::string hex; size_t logSize = std::min(decrypted.size(), size_t(256)); hex.reserve(logSize * 3); for (size_t i = 0; i < logSize; ++i) { - char b[4]; snprintf(b, sizeof(b), "%02x ", decrypted[i]); hex += b; + char b[4]; + snprintf(b, sizeof(b), "%02x ", decrypted[i]); + hex += b; } - if (decrypted.size() > 64) + if (decrypted.size() > 64) { hex += "... (" + std::to_string(decrypted.size() - 64) + " more)"; - LOG_INFO("Warden: Decrypted (", decrypted.size(), " bytes): ", hex); + } + LOG_DEBUG("Warden: Decrypted (", decrypted.size(), " bytes): ", hex); } if (decrypted.empty()) { @@ -3541,7 +3544,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { } if (socket && socket->isConnected()) { socket->send(response); - LOG_INFO("Warden: Sent response (", plaintext.size(), " bytes plaintext)"); + LOG_DEBUG("Warden: Sent response (", plaintext.size(), " bytes plaintext)"); } }; @@ -3564,7 +3567,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { { std::string hashHex; for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; } - LOG_INFO("Warden: MODULE_USE hash=", hashHex, " size=", wardenModuleSize_); + LOG_DEBUG("Warden: MODULE_USE hash=", hashHex, " size=", wardenModuleSize_); // Try to load pre-computed challenge/response entries loadWardenCRFile(hashHex); @@ -3574,7 +3577,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { std::vector resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING sendWardenResponse(resp); wardenState_ = WardenState::WAIT_MODULE_CACHE; - LOG_INFO("Warden: Sent MODULE_MISSING, waiting for module data chunks"); + LOG_DEBUG("Warden: Sent MODULE_MISSING, waiting for module data chunks"); break; } @@ -3598,7 +3601,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { decrypted.begin() + 3, decrypted.begin() + 3 + chunkSize); - LOG_INFO("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ", + LOG_DEBUG("Warden: MODULE_CACHE chunk ", chunkSize, " bytes, total ", wardenModuleData_.size(), "/", wardenModuleSize_); // Check if module download is complete @@ -3627,7 +3630,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { std::ofstream wf(cachePath, std::ios::binary); if (wf) { wf.write(reinterpret_cast(wardenModuleData_.data()), wardenModuleData_.size()); - LOG_INFO("Warden: Cached module to ", cachePath); + LOG_DEBUG("Warden: Cached module to ", cachePath); } } @@ -3644,7 +3647,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { // Send MODULE_OK (opcode 0x01) std::vector resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK sendWardenResponse(resp); - LOG_INFO("Warden: Sent MODULE_OK"); + LOG_DEBUG("Warden: Sent MODULE_OK"); } // No response for intermediate chunks break; @@ -3670,7 +3673,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { } if (match) { - LOG_INFO("Warden: Found matching CR entry for seed"); + LOG_DEBUG("Warden: Found matching CR entry for seed"); // Log the reply we're sending { @@ -3678,7 +3681,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { for (int i = 0; i < 20; i++) { char s[4]; snprintf(s, 4, "%02x", match->reply[i]); replyHex += s; } - LOG_INFO("Warden: Sending pre-computed reply=", replyHex); + LOG_DEBUG("Warden: Sending pre-computed reply=", replyHex); } // Send HASH_RESULT (opcode 0x04 + 20-byte reply) @@ -3693,7 +3696,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { std::vector newDecryptKey(match->serverKey, match->serverKey + 16); wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey); - LOG_INFO("Warden: Switched to CR key set"); + LOG_DEBUG("Warden: Switched to CR key set"); wardenState_ = WardenState::WAIT_CHECKS; break; @@ -3721,7 +3724,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { const auto& firstCR = wardenCREntries_[0]; std::string expectedHex; for (int i = 0; i < 20; i++) { char s[4]; snprintf(s, 4, "%02x", firstCR.reply[i]); expectedHex += s; } - LOG_INFO("Warden: Empirical test — expected reply from CR[0]=", expectedHex); + LOG_DEBUG("Warden: Empirical test — expected reply from CR[0]=", expectedHex); // Test 1: SHA1(moduleImage) { @@ -3729,7 +3732,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { auto h = auth::Crypto::sha1(data); bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_INFO("Warden: SHA1(moduleImage)=", hex, match ? " MATCH!" : ""); + LOG_DEBUG("Warden: SHA1(moduleImage)=", hex, match ? " MATCH!" : ""); } // Test 2: SHA1(seed || moduleImage) { @@ -3739,7 +3742,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { auto h = auth::Crypto::sha1(data); bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_INFO("Warden: SHA1(seed||image)=", hex, match ? " MATCH!" : ""); + LOG_DEBUG("Warden: SHA1(seed||image)=", hex, match ? " MATCH!" : ""); } // Test 3: SHA1(moduleImage || seed) { @@ -3748,21 +3751,21 @@ void GameHandler::handleWardenData(network::Packet& packet) { auto h = auth::Crypto::sha1(data); bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_INFO("Warden: SHA1(image||seed)=", hex, match ? " MATCH!" : ""); + LOG_DEBUG("Warden: SHA1(image||seed)=", hex, match ? " MATCH!" : ""); } // Test 4: SHA1(decompressedData) { auto h = auth::Crypto::sha1(decompressedData); bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_INFO("Warden: SHA1(decompressed)=", hex, match ? " MATCH!" : ""); + LOG_DEBUG("Warden: SHA1(decompressed)=", hex, match ? " MATCH!" : ""); } // Test 5: SHA1(rawModuleData) { auto h = auth::Crypto::sha1(wardenModuleData_); bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_INFO("Warden: SHA1(rawModule)=", hex, match ? " MATCH!" : ""); + LOG_DEBUG("Warden: SHA1(rawModule)=", hex, match ? " MATCH!" : ""); } // Test 6: Check if all CR replies are the same (constant hash) { @@ -3773,7 +3776,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { break; } } - LOG_INFO("Warden: All ", wardenCREntries_.size(), " CR replies identical? ", allSame ? "YES" : "NO"); + LOG_DEBUG("Warden: All ", wardenCREntries_.size(), " CR replies identical? ", allSame ? "YES" : "NO"); } } @@ -3786,7 +3789,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { { std::string hex; for (auto b : reply) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_INFO("Warden: Sending SHA1(moduleImage)=", hex); + LOG_DEBUG("Warden: Sending SHA1(moduleImage)=", hex); } // Send HASH_RESULT (opcode 0x04 + 20-byte hash) @@ -3807,7 +3810,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { wardenCrypto_->replaceKeys(ek, dk); for (auto& b : newEncryptKey) b = 0; for (auto& b : newDecryptKey) b = 0; - LOG_INFO("Warden: Derived and applied key update from seed"); + LOG_DEBUG("Warden: Derived and applied key update from seed"); } wardenState_ = WardenState::WAIT_CHECKS; @@ -3815,7 +3818,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { } case 0x02: { // WARDEN_SMSG_CHEAT_CHECKS_REQUEST - LOG_INFO("Warden: CHEAT_CHECKS_REQUEST (", decrypted.size(), " bytes)"); + LOG_DEBUG("Warden: CHEAT_CHECKS_REQUEST (", decrypted.size(), " bytes)"); if (decrypted.size() < 3) { LOG_ERROR("Warden: CHEAT_CHECKS_REQUEST too short"); @@ -3833,14 +3836,14 @@ void GameHandler::handleWardenData(network::Packet& packet) { strings.emplace_back(reinterpret_cast(decrypted.data() + pos), slen); pos += slen; } - LOG_INFO("Warden: String table: ", strings.size(), " entries"); + LOG_DEBUG("Warden: String table: ", strings.size(), " entries"); for (size_t i = 0; i < strings.size(); i++) { - LOG_INFO("Warden: [", i, "] = \"", strings[i], "\""); + LOG_DEBUG("Warden: [", i, "] = \"", strings[i], "\""); } // XOR byte is the last byte of the packet uint8_t xorByte = decrypted.back(); - LOG_INFO("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }()); + LOG_DEBUG("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }()); // Check type enum indices enum CheckType { CT_MEM=0, CT_PAGE_A=1, CT_PAGE_B=2, CT_MPQ=3, CT_LUA=4, @@ -3958,7 +3961,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { pos++; checkCount++; - LOG_INFO("Warden: Check #", checkCount, " type=", checkTypeNames[ct], + LOG_DEBUG("Warden: Check #", checkCount, " type=", checkTypeNames[ct], " at offset ", pos - 1); switch (ct) { @@ -3984,10 +3987,10 @@ void GameHandler::handleWardenData(network::Packet& packet) { | (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24); pos += 4; uint8_t readLen = decrypted[pos++]; - LOG_INFO("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), + LOG_DEBUG("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), " len=", (int)readLen); if (!moduleName.empty()) { - LOG_INFO("Warden: MEM module=\"", moduleName, "\""); + LOG_DEBUG("Warden: MEM module=\"", moduleName, "\""); } // Lazy-load WoW.exe PE image on first MEM_CHECK @@ -4001,7 +4004,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { // Read bytes from PE image (includes patched runtime globals) std::vector memBuf(readLen, 0); if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) { - LOG_INFO("Warden: MEM_CHECK served from PE image"); + LOG_DEBUG("Warden: MEM_CHECK served from PE image"); } else { LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}()); @@ -4054,7 +4057,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { pageResult = 0x4A; // PatternFound } } - LOG_INFO("Warden: PAGE_A request bytes=", consume, + LOG_DEBUG("Warden: PAGE_A request bytes=", consume, " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); pos += consume; resultData.push_back(pageResult); @@ -4093,7 +4096,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { pageResult = 0x4A; // PatternFound } } - LOG_INFO("Warden: PAGE_B request bytes=", consume, + LOG_DEBUG("Warden: PAGE_B request bytes=", consume, " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); pos += consume; resultData.push_back(pageResult); @@ -4104,7 +4107,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_INFO("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); + LOG_DEBUG("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); bool found = false; std::vector hash(20, 0); @@ -4150,7 +4153,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_INFO("Warden: LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\""); + LOG_DEBUG("Warden: LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\""); // Response: [uint8 result=0][uint16 len=0] // Lua string doesn't exist resultData.push_back(0x01); // not found @@ -4162,7 +4165,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { pos += 24; // skip seed + sha1 uint8_t strIdx = decrypted[pos++]; std::string driverName = resolveWardenString(strIdx); - LOG_INFO("Warden: DRIVER=\"", (driverName.empty() ? "?" : driverName), "\""); + LOG_DEBUG("Warden: DRIVER=\"", (driverName.empty() ? "?" : driverName), "\""); // Response: [uint8 result=1] (driver NOT found = clean) resultData.push_back(0x01); break; @@ -4219,7 +4222,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { } } - LOG_INFO("Warden: Parsed ", checkCount, " checks, result data size=", resultData.size()); + LOG_DEBUG("Warden: Parsed ", checkCount, " checks, result data size=", resultData.size()); // --- Compute checksum: XOR of 5 uint32s from SHA1(resultData) --- auto resultHash = auth::Crypto::sha1(resultData); @@ -4244,18 +4247,18 @@ void GameHandler::handleWardenData(network::Packet& packet) { resp.push_back((checksum >> 24) & 0xFF); resp.insert(resp.end(), resultData.begin(), resultData.end()); sendWardenResponse(resp); - LOG_INFO("Warden: Sent CHEAT_CHECKS_RESULT (", resp.size(), " bytes, ", + LOG_DEBUG("Warden: Sent CHEAT_CHECKS_RESULT (", resp.size(), " bytes, ", checkCount, " checks, checksum=0x", [&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")"); break; } case 0x03: // WARDEN_SMSG_MODULE_INITIALIZE - LOG_INFO("Warden: MODULE_INITIALIZE (", decrypted.size(), " bytes, no response needed)"); + LOG_DEBUG("Warden: MODULE_INITIALIZE (", decrypted.size(), " bytes, no response needed)"); break; default: - LOG_INFO("Warden: Unknown opcode 0x", std::hex, (int)wardenOpcode, std::dec, + LOG_DEBUG("Warden: Unknown opcode 0x", std::hex, (int)wardenOpcode, std::dec, " (state=", (int)wardenState_, ", size=", decrypted.size(), ")"); break; } diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 68a2fc9c..1c253459 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -529,78 +529,165 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { std::cout << "[WardenModule] Allocated " << moduleSize_ << " bytes of executable memory at " << moduleMemory_ << '\n'; - // Parse copy/skip pairs (MaNGOS/TrinityCore format) - // Format: repeated [2B copy_count][copy_count bytes data][2B skip_count] - // Copy = copy from source to dest, Skip = advance dest pointer (zeros) - // Terminates when copy_count == 0 - size_t pos = 4; // Skip 4-byte size header - size_t destOffset = 0; - int pairCount = 0; + auto readU16LE = [&](size_t at) -> uint16_t { + return static_cast(exeData[at] | (exeData[at + 1] << 8)); + }; - while (pos + 2 <= exeData.size()) { - // Read copy count (2 bytes LE) - uint16_t copyCount = exeData[pos] | (exeData[pos + 1] << 8); - pos += 2; + enum class PairFormat { + CopyDataSkip, // [copy][data][skip] + SkipCopyData, // [skip][copy][data] + CopySkipData // [copy][skip][data] + }; - if (copyCount == 0) { - break; // End of copy/skip pairs - } + auto tryParsePairs = [&](PairFormat format, + std::vector& imageOut, + size_t& relocPosOut, + size_t& finalOffsetOut, + int& pairCountOut) -> bool { + imageOut.assign(moduleSize_, 0); + size_t pos = 4; // Skip 4-byte final size header + size_t destOffset = 0; + int pairCount = 0; - if (copyCount > 0) { - if (pos + copyCount > exeData.size()) { - std::cerr << "[WardenModule] Copy section extends beyond data bounds" << '\n'; - #ifdef _WIN32 - VirtualFree(moduleMemory_, 0, MEM_RELEASE); - #else - munmap(moduleMemory_, moduleSize_); - #endif - moduleMemory_ = nullptr; - return false; + while (pos + 2 <= exeData.size()) { + uint16_t copyCount = 0; + uint16_t skipCount = 0; + + switch (format) { + case PairFormat::CopyDataSkip: { + copyCount = readU16LE(pos); + pos += 2; + if (copyCount == 0) { + relocPosOut = pos; + finalOffsetOut = destOffset; + pairCountOut = pairCount; + imageOut.resize(moduleSize_); + return true; + } + + if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) { + return false; + } + + std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount); + pos += copyCount; + destOffset += copyCount; + + if (pos + 2 > exeData.size()) { + return false; + } + skipCount = readU16LE(pos); + pos += 2; + break; + } + + case PairFormat::SkipCopyData: { + if (pos + 4 > exeData.size()) { + return false; + } + skipCount = readU16LE(pos); + pos += 2; + copyCount = readU16LE(pos); + pos += 2; + + if (skipCount == 0 && copyCount == 0) { + relocPosOut = pos; + finalOffsetOut = destOffset; + pairCountOut = pairCount; + imageOut.resize(moduleSize_); + return true; + } + + if (destOffset + skipCount > moduleSize_) { + return false; + } + destOffset += skipCount; + + if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) { + return false; + } + std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount); + pos += copyCount; + destOffset += copyCount; + break; + } + + case PairFormat::CopySkipData: { + if (pos + 4 > exeData.size()) { + return false; + } + copyCount = readU16LE(pos); + pos += 2; + skipCount = readU16LE(pos); + pos += 2; + + if (copyCount == 0 && skipCount == 0) { + relocPosOut = pos; + finalOffsetOut = destOffset; + pairCountOut = pairCount; + imageOut.resize(moduleSize_); + return true; + } + + if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) { + return false; + } + std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount); + pos += copyCount; + destOffset += copyCount; + break; + } } - if (destOffset + copyCount > moduleSize_) { - std::cerr << "[WardenModule] Copy section exceeds module size" << '\n'; - #ifdef _WIN32 - VirtualFree(moduleMemory_, 0, MEM_RELEASE); - #else - munmap(moduleMemory_, moduleSize_); - #endif - moduleMemory_ = nullptr; + if (destOffset + skipCount > moduleSize_) { return false; } - - std::memcpy( - static_cast(moduleMemory_) + destOffset, - exeData.data() + pos, - copyCount - ); - pos += copyCount; - destOffset += copyCount; + destOffset += skipCount; + pairCount++; } - // Read skip count (2 bytes LE) - uint16_t skipCount = 0; - if (pos + 2 <= exeData.size()) { - skipCount = exeData[pos] | (exeData[pos + 1] << 8); - pos += 2; - } + return false; + }; - // Advance dest pointer by skipCount (gaps are zero-filled from memset) - destOffset += skipCount; + std::vector parsedImage; + size_t parsedRelocPos = 0; + size_t parsedFinalOffset = 0; + int parsedPairCount = 0; - pairCount++; - std::cout << "[WardenModule] Pair " << pairCount << ": copy " << copyCount - << ", skip " << skipCount << " (dest offset=" << destOffset << ")" << '\n'; + PairFormat usedFormat = PairFormat::CopyDataSkip; + bool parsed = tryParsePairs(PairFormat::CopyDataSkip, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount); + if (!parsed) { + usedFormat = PairFormat::SkipCopyData; + parsed = tryParsePairs(PairFormat::SkipCopyData, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount); + } + if (!parsed) { + usedFormat = PairFormat::CopySkipData; + parsed = tryParsePairs(PairFormat::CopySkipData, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount); } - // Save position — remaining decompressed data contains relocation entries - relocDataOffset_ = pos; + if (parsed) { + std::memcpy(moduleMemory_, parsedImage.data(), parsedImage.size()); + relocDataOffset_ = parsedRelocPos; - std::cout << "[WardenModule] Parsed " << pairCount << " skip/copy pairs, final offset: " - << destOffset << "/" << finalCodeSize << '\n'; - std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_ - << " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n'; + const char* formatName = "copy/data/skip"; + 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'; + return true; + } + + // Fallback: copy raw payload (without the 4-byte size header) into module memory. + // This keeps loading alive for servers where packet flow can continue with hash/check fallbacks. + if (exeData.size() > 4) { + size_t rawCopySize = std::min(moduleSize_, exeData.size() - 4); + 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'; return true; } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 9dbc2b71..bcc06550 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -65,6 +65,23 @@ std::optional selectClosestFloor(const std::optional& a, return std::nullopt; } +std::optional selectReachableFloor3(const std::optional& a, + const std::optional& b, + const std::optional& c, + float refZ, + float maxStepUp) { + std::optional best; + auto consider = [&](const std::optional& h) { + if (!h) return; + if (*h > refZ + maxStepUp) return; + if (!best || *h > *best) best = *h; + }; + consider(a); + consider(b); + consider(c); + return best; +} + } // namespace CameraController::CameraController(Camera* cam) : camera(cam) { @@ -126,6 +143,8 @@ void CameraController::update(float deltaTime) { if (!enabled || !camera) { return; } + // Keep physics integration stable during render hitches to avoid floor tunneling. + const float physicsDeltaTime = std::min(deltaTime, 1.0f / 30.0f); // During taxi flights, skip movement logic but keep camera orbit/zoom controls. if (externalFollow_) { @@ -360,6 +379,7 @@ void CameraController::update(float deltaTime) { if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera glm::vec3 targetPos = *followTarget; + const glm::vec3 prevTargetPos = *followTarget; if (!externalFollow_) { if (wmoRenderer) { wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); @@ -403,6 +423,68 @@ void CameraController::update(float deltaTime) { float depthFromFeet = (*waterH - targetPos.z); inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) || (!floorH && (depthFromFeet >= MIN_SWIM_WATER_DEPTH)); + + // Ramp exit assist: when swimming forward near the surface toward a + // reachable floor (dock/shore ramp), switch to walking sooner. + if (swimming && inWater && floorH && nowForward) { + float floorDelta = *floorH - targetPos.z; + float waterOverFloor = *waterH - *floorH; + bool nearSurface = depthFromFeet <= 1.45f; + bool reachableRamp = (floorDelta >= -0.30f && floorDelta <= 1.10f); + bool shallowRampWater = waterOverFloor <= 1.55f; + bool notDiving = forward3D.z > -0.20f; + if (nearSurface && reachableRamp && shallowRampWater && notDiving) { + inWater = false; + } + } + + // Forward plank/ramp assist: sample structure floors ahead so water exit + // can happen when the ramp is in front of us (not only under our feet). + if (swimming && inWater && nowForward && forward3D.z > -0.20f) { + auto queryFloorAt = [&](float x, float y, float probeZ) -> std::optional { + std::optional best; + if (terrainManager) { + best = terrainManager->getHeightAt(x, y); + } + if (wmoRenderer) { + float nz = 1.0f; + auto wh = wmoRenderer->getFloorHeight(x, y, probeZ, &nz); + if (wh && nz >= 0.40f && (!best || *wh > *best)) best = wh; + } + if (m2Renderer && !externalFollow_) { + float nz = 1.0f; + auto mh = m2Renderer->getFloorHeight(x, y, probeZ, &nz); + if (mh && nz >= 0.35f && (!best || *mh > *best)) best = mh; + } + return best; + }; + + glm::vec2 fwd2(forward.x, forward.y); + float fwdLen = glm::length(fwd2); + if (fwdLen > 1e-4f) { + fwd2 /= fwdLen; + std::optional aheadFloor; + const float probeZ = targetPos.z + 2.0f; + const float dists[] = {0.45f, 0.90f, 1.25f}; + for (float d : dists) { + float sx = targetPos.x + fwd2.x * d; + float sy = targetPos.y + fwd2.y * d; + auto h = queryFloorAt(sx, sy, probeZ); + if (h && (!aheadFloor || *h > *aheadFloor)) aheadFloor = h; + } + + if (aheadFloor) { + float floorDelta = *aheadFloor - targetPos.z; + float waterOverFloor = *waterH - *aheadFloor; + bool nearSurface = depthFromFeet <= 1.65f; + bool reachableRamp = (floorDelta >= -0.35f && floorDelta <= 1.25f); + bool shallowRampWater = waterOverFloor <= 1.75f; + if (nearSurface && reachableRamp && shallowRampWater) { + inWater = false; + } + } + } + } } } // Keep swimming through water-data gaps at chunk boundaries. @@ -442,7 +524,7 @@ void CameraController::update(float deltaTime) { if (glm::length(swimMove) > 0.001f) { swimMove = glm::normalize(swimMove); - targetPos += swimMove * swimSpeed * deltaTime; + targetPos += swimMove * swimSpeed * physicsDeltaTime; } // Spacebar = swim up (continuous, not a jump) @@ -451,7 +533,7 @@ void CameraController::update(float deltaTime) { verticalVelocity = SWIM_BUOYANCY; } else { // Gentle sink when not pressing space - verticalVelocity += SWIM_GRAVITY * deltaTime; + verticalVelocity += SWIM_GRAVITY * physicsDeltaTime; if (verticalVelocity < SWIM_SINK_SPEED) { verticalVelocity = SWIM_SINK_SPEED; } @@ -459,15 +541,15 @@ void CameraController::update(float deltaTime) { // you afloat unless you're intentionally diving. if (!diveIntent) { float surfaceErr = (waterSurfaceZ - targetPos.z); - verticalVelocity += surfaceErr * 7.0f * deltaTime; - verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime); + verticalVelocity += surfaceErr * 7.0f * physicsDeltaTime; + verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * physicsDeltaTime); if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) { verticalVelocity = 0.0f; } } } - targetPos.z += verticalVelocity * deltaTime; + targetPos.z += verticalVelocity * physicsDeltaTime; // Don't rise above water surface if (waterH && targetPos.z > *waterH - WATER_SURFACE_OFFSET) { @@ -486,17 +568,42 @@ void CameraController::update(float deltaTime) { if (updateFloorCache) { floorQueryFrameCounter = 0; lastFloorQueryPos = targetPos; + constexpr float MAX_SWIM_FLOOR_ABOVE_FEET = 0.25f; + constexpr float MIN_SWIM_CEILING_ABOVE_FEET = 0.30f; + constexpr float MAX_SWIM_CEILING_ABOVE_FEET = 1.80f; + std::optional ceilingH; + auto considerFloor = [&](const std::optional& h) { + if (!h) return; + // Swim-floor guard: only accept surfaces at or very slightly above feet. + if (*h <= targetPos.z + MAX_SWIM_FLOOR_ABOVE_FEET) { + if (!floorH || *h > *floorH) floorH = h; + } + // Swim-ceiling guard: detect structures just above feet so upward swim + // can't clip through docks/platform undersides. + float dz = *h - targetPos.z; + if (dz >= MIN_SWIM_CEILING_ABOVE_FEET && dz <= MAX_SWIM_CEILING_ABOVE_FEET) { + if (!ceilingH || *h < *ceilingH) ceilingH = h; + } + }; if (terrainManager) { - floorH = terrainManager->getHeightAt(targetPos.x, targetPos.y); + considerFloor(terrainManager->getHeightAt(targetPos.x, targetPos.y)); } if (wmoRenderer) { auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f); - if (wh && (!floorH || *wh > *floorH)) floorH = wh; + considerFloor(wh); } if (m2Renderer && !externalFollow_) { - auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z); - if (mh && (!floorH || *mh > *floorH)) floorH = mh; + auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f); + considerFloor(mh); + } + + if (ceilingH && verticalVelocity > 0.0f) { + float ceilingLimit = *ceilingH - 0.35f; + if (targetPos.z > ceilingLimit) { + targetPos.z = ceilingLimit; + verticalVelocity = 0.0f; + } } cachedFloorHeight = floorH; @@ -557,7 +664,7 @@ void CameraController::update(float deltaTime) { if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); - targetPos += movement * speed * deltaTime; + targetPos += movement * speed * physicsDeltaTime; } // Jump with input buffering and coyote time @@ -572,12 +679,12 @@ void CameraController::update(float deltaTime) { coyoteTimer = 0.0f; } - jumpBufferTimer -= deltaTime; - coyoteTimer -= deltaTime; + jumpBufferTimer -= physicsDeltaTime; + coyoteTimer -= physicsDeltaTime; // Apply gravity - verticalVelocity += gravity * deltaTime; - targetPos.z += verticalVelocity * deltaTime; + verticalVelocity += gravity * physicsDeltaTime; + targetPos.z += verticalVelocity * physicsDeltaTime; } } else { // External follow (e.g., taxi): trust server position without grounding. @@ -589,14 +696,21 @@ void CameraController::update(float deltaTime) { // Refresh inside-WMO state before collision/grounding so we don't use stale // terrain-first caches while entering enclosed tunnel/building spaces. if (wmoRenderer && !externalFollow_) { - bool prevInside = cachedInsideWMO; - bool prevInsideInterior = cachedInsideInteriorWMO; - cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); - cachedInsideInteriorWMO = wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f); - if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) { - hasCachedFloor_ = false; - hasCachedCamFloor = false; - cachedPivotLift_ = 0.0f; + const float insideDist = glm::length(targetPos - lastInsideStateCheckPos_); + if (++insideStateCheckCounter_ >= 2 || insideDist > 0.35f) { + insideStateCheckCounter_ = 0; + lastInsideStateCheckPos_ = targetPos; + + bool prevInside = cachedInsideWMO; + bool prevInsideInterior = cachedInsideInteriorWMO; + cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); + cachedInsideInteriorWMO = cachedInsideWMO && + wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f); + if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) { + hasCachedFloor_ = false; + hasCachedCamFloor = false; + cachedPivotLift_ = 0.0f; + } } } @@ -654,15 +768,17 @@ void CameraController::update(float deltaTime) { // WMO tunnel/bridge ramps are often steeper than outdoor terrain ramps. constexpr float MIN_WALKABLE_NORMAL_TERRAIN = 0.7f; // ~45° constexpr float MIN_WALKABLE_NORMAL_WMO = 0.45f; // allow tunnel ramps + constexpr float MIN_WALKABLE_NORMAL_M2 = 0.45f; // allow bridge/deck ramps std::optional groundH; std::optional centerTerrainH; std::optional centerWmoH; + std::optional centerM2H; { // Collision cache: skip expensive checks if barely moved (15cm threshold) float distMoved = glm::length(glm::vec2(targetPos.x, targetPos.y) - glm::vec2(lastCollisionCheckPos_.x, lastCollisionCheckPos_.y)); - bool useCached = hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE; + bool useCached = grounded && hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE; if (useCached) { // Never trust cached ground while actively descending or when // vertical drift from cached floor is meaningful. @@ -678,6 +794,7 @@ void CameraController::update(float deltaTime) { // Full collision check std::optional terrainH; std::optional wmoH; + std::optional m2H; if (terrainManager) { terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y); } @@ -689,6 +806,13 @@ void CameraController::update(float deltaTime) { if (wmoRenderer) { wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &wmoNormalZ); } + if (m2Renderer && !externalFollow_) { + float m2NormalZ = 1.0f; + m2H = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &m2NormalZ); + if (m2H && m2NormalZ < MIN_WALKABLE_NORMAL_M2) { + m2H = std::nullopt; + } + } // Reject steep WMO slopes float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN; @@ -704,6 +828,7 @@ void CameraController::update(float deltaTime) { } centerTerrainH = terrainH; centerWmoH = wmoH; + centerM2H = m2H; // Guard against extremely bad WMO void ramps, but keep normal tunnel // transitions valid. Only reject when the WMO sample is implausibly far @@ -739,10 +864,10 @@ void CameraController::update(float deltaTime) { // to avoid oscillating between top terrain and deep WMO floors. groundH = selectClosestFloor(terrainH, wmoH, targetPos.z); } else { - groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); + groundH = selectReachableFloor3(terrainH, wmoH, m2H, targetPos.z, stepUpBudget); } } else { - groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); + groundH = selectReachableFloor3(terrainH, wmoH, m2H, targetPos.z, stepUpBudget); } // Update cache @@ -759,13 +884,29 @@ void CameraController::update(float deltaTime) { // Transition safety: if no reachable floor was selected, choose the higher // of terrain/WMO center surfaces when it is still near the player. // This avoids dropping into void gaps at terrain<->WMO seams. + const bool nearWmoSpace = cachedInsideWMO || centerWmoH.has_value(); + bool nearStructureSpace = nearWmoSpace || centerM2H.has_value(); + if (!nearStructureSpace && hasRealGround_) { + // Plank-gap hint: center probes can miss sparse bridge segments. + // Probe once around last known ground before allowing a full drop. + if (wmoRenderer) { + auto whHint = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, lastGroundZ + 1.5f); + if (whHint && std::abs(*whHint - lastGroundZ) <= 2.0f) nearStructureSpace = true; + } + if (!nearStructureSpace && m2Renderer && !externalFollow_) { + float nz = 1.0f; + auto mhHint = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, lastGroundZ + 1.5f, &nz); + if (mhHint && nz >= MIN_WALKABLE_NORMAL_M2 && + std::abs(*mhHint - lastGroundZ) <= 2.0f) nearStructureSpace = true; + } + } if (!groundH) { - auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, std::nullopt); + auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, centerM2H); if (highestCenter) { float dz = targetPos.z - *highestCenter; // Keep this fallback narrow: only for WMO seam cases, or very short // transient misses while still almost touching the last floor. - bool allowFallback = cachedInsideWMO || (noGroundTimer_ < 0.10f && dz < 0.6f); + bool allowFallback = nearStructureSpace || (noGroundTimer_ < 0.10f && dz < 0.6f); if (allowFallback && dz >= -0.5f && dz < 2.0f) { groundH = highestCenter; } @@ -774,7 +915,7 @@ void CameraController::update(float deltaTime) { // Continuity guard only for WMO seam overlap: avoid instantly switching to a // much lower floor sample at tunnel mouths (bad WMO ramp chains into void). - if (groundH && hasRealGround_ && cachedInsideWMO && !cachedInsideInteriorWMO) { + if (groundH && hasRealGround_ && nearWmoSpace && !cachedInsideInteriorWMO) { float dropFromLast = lastGroundZ - *groundH; if (dropFromLast > 1.5f) { if (centerTerrainH && *centerTerrainH > *groundH + 1.5f) { @@ -785,7 +926,7 @@ void CameraController::update(float deltaTime) { // Seam stability: while overlapping WMO shells, cap how fast floor height can // step downward in a single frame to avoid following bad ramp samples into void. - if (groundH && cachedInsideWMO && !cachedInsideInteriorWMO && lastGroundZ > 1.0f) { + if (groundH && nearWmoSpace && !cachedInsideInteriorWMO && lastGroundZ > 1.0f) { float maxDropPerFrame = (verticalVelocity < -8.0f) ? 2.0f : 0.60f; float minAllowed = lastGroundZ - maxDropPerFrame; // Extra seam guard: outside interior groups, avoid accepting floors that @@ -802,9 +943,19 @@ void CameraController::update(float deltaTime) { } } + // Structure continuity guard: if a floor query suddenly jumps far below + // recent support while near dock/bridge geometry, keep a conservative + // support height to avoid dropping through sparse collision seams. + if (groundH && hasRealGround_ && nearStructureSpace && !nowJump) { + float dropFromLast = lastGroundZ - *groundH; + if (dropFromLast > 1.0f && verticalVelocity > -6.0f) { + *groundH = std::max(*groundH, lastGroundZ - 0.20f); + } + } + // 1b. Multi-sample WMO floors when in/near WMO space to avoid // falling through narrow board/plank gaps where center ray misses. - if (wmoRenderer && cachedInsideWMO) { + if (wmoRenderer && nearWmoSpace) { constexpr float WMO_FOOTPRINT = 0.35f; const glm::vec2 wmoOffsets[] = { {0.0f, 0.0f}, @@ -827,7 +978,7 @@ void CameraController::update(float deltaTime) { // Keep to nearby, walkable steps only. if (*wh > targetPos.z + stepUpBudget) continue; - if (*wh < targetPos.z - 2.5f) continue; + if (*wh < lastGroundZ - 3.5f) continue; if (!groundH || *wh > *groundH) { groundH = wh; @@ -835,14 +986,112 @@ void CameraController::update(float deltaTime) { } } + // WMO recovery probe: when no floor is found while descending, do a wider + // footprint sample around the player to catch narrow plank/stair misses. + if (!groundH && wmoRenderer && hasRealGround_ && verticalVelocity <= 0.0f) { + constexpr float RESCUE_FOOTPRINT = 0.65f; + const glm::vec2 rescueOffsets[] = { + {0.0f, 0.0f}, + { RESCUE_FOOTPRINT, 0.0f}, {-RESCUE_FOOTPRINT, 0.0f}, + {0.0f, RESCUE_FOOTPRINT}, {0.0f, -RESCUE_FOOTPRINT}, + { RESCUE_FOOTPRINT, RESCUE_FOOTPRINT}, + { RESCUE_FOOTPRINT, -RESCUE_FOOTPRINT}, + {-RESCUE_FOOTPRINT, RESCUE_FOOTPRINT}, + {-RESCUE_FOOTPRINT, -RESCUE_FOOTPRINT} + }; + float rescueProbeZ = std::max(lastGroundZ, targetPos.z) + stepUpBudget + 1.2f; + std::optional rescueFloor; + for (const auto& o : rescueOffsets) { + float nz = 1.0f; + auto wh = wmoRenderer->getFloorHeight(targetPos.x + o.x, targetPos.y + o.y, rescueProbeZ, &nz); + if (!wh) continue; + if (nz < MIN_WALKABLE_NORMAL_WMO) continue; + if (*wh > lastGroundZ + stepUpBudget + 0.75f) continue; + if (*wh < lastGroundZ - 6.0f) continue; + if (!rescueFloor || *wh > *rescueFloor) { + rescueFloor = wh; + } + } + if (rescueFloor) { + groundH = rescueFloor; + } + } + + // M2 recovery probe: Booty Bay-style wooden platforms can be represented + // as M2 collision where center probes intermittently miss. + if (!groundH && m2Renderer && !externalFollow_ && hasRealGround_ && verticalVelocity <= 0.0f) { + constexpr float RESCUE_FOOTPRINT = 0.75f; + const glm::vec2 rescueOffsets[] = { + {0.0f, 0.0f}, + { RESCUE_FOOTPRINT, 0.0f}, {-RESCUE_FOOTPRINT, 0.0f}, + {0.0f, RESCUE_FOOTPRINT}, {0.0f, -RESCUE_FOOTPRINT}, + { RESCUE_FOOTPRINT, RESCUE_FOOTPRINT}, + { RESCUE_FOOTPRINT, -RESCUE_FOOTPRINT}, + {-RESCUE_FOOTPRINT, RESCUE_FOOTPRINT}, + {-RESCUE_FOOTPRINT, -RESCUE_FOOTPRINT} + }; + float rescueProbeZ = std::max(lastGroundZ, targetPos.z) + stepUpBudget + 1.4f; + std::optional rescueFloor; + for (const auto& o : rescueOffsets) { + float nz = 1.0f; + auto mh = m2Renderer->getFloorHeight(targetPos.x + o.x, targetPos.y + o.y, rescueProbeZ, &nz); + if (!mh) continue; + if (nz < MIN_WALKABLE_NORMAL_M2) continue; + if (*mh > lastGroundZ + stepUpBudget + 0.90f) continue; + if (*mh < lastGroundZ - 6.0f) continue; + if (!rescueFloor || *mh > *rescueFloor) { + rescueFloor = mh; + } + } + if (rescueFloor) { + groundH = rescueFloor; + } + } + + // Path recovery probe: sample structure floors along the movement segment + // (prev -> current) to catch narrow plank gaps missed at endpoints. + if (!groundH && hasRealGround_ && (wmoRenderer || (m2Renderer && !externalFollow_))) { + std::optional segmentFloor; + const float probeZ = std::max(lastGroundZ, targetPos.z) + stepUpBudget + 1.2f; + const float ts[] = {0.25f, 0.5f, 0.75f}; + for (float t : ts) { + float sx = prevTargetPos.x + (targetPos.x - prevTargetPos.x) * t; + float sy = prevTargetPos.y + (targetPos.y - prevTargetPos.y) * t; + + if (wmoRenderer) { + float nz = 1.0f; + auto wh = wmoRenderer->getFloorHeight(sx, sy, probeZ, &nz); + if (wh && nz >= MIN_WALKABLE_NORMAL_WMO && + *wh <= lastGroundZ + stepUpBudget + 0.9f && + *wh >= lastGroundZ - 3.0f) { + if (!segmentFloor || *wh > *segmentFloor) segmentFloor = wh; + } + } + if (m2Renderer && !externalFollow_) { + float nz = 1.0f; + auto mh = m2Renderer->getFloorHeight(sx, sy, probeZ, &nz); + if (mh && nz >= MIN_WALKABLE_NORMAL_M2 && + *mh <= lastGroundZ + stepUpBudget + 0.9f && + *mh >= lastGroundZ - 3.0f) { + if (!segmentFloor || *mh > *segmentFloor) segmentFloor = mh; + } + } + } + if (segmentFloor) { + groundH = segmentFloor; + } + } + // 2. Multi-sample for M2 objects (rugs, planks, bridges, ships) — // these are narrow and need offset probes to detect reliably. if (m2Renderer && !externalFollow_) { - constexpr float FOOTPRINT = 0.4f; + constexpr float FOOTPRINT = 0.6f; const glm::vec2 offsets[] = { {0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, - {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT} + {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT}, + {FOOTPRINT, FOOTPRINT}, {FOOTPRINT, -FOOTPRINT}, + {-FOOTPRINT, FOOTPRINT}, {-FOOTPRINT, -FOOTPRINT} }; float m2ProbeZ = std::max(targetPos.z, lastGroundZ) + 6.0f; for (const auto& o : offsets) { @@ -895,15 +1144,33 @@ void CameraController::update(float deltaTime) { } } else { hasRealGround_ = false; - noGroundTimer_ += deltaTime; + noGroundTimer_ += physicsDeltaTime; float dropFromLastGround = lastGroundZ - targetPos.z; - bool seamSizedGap = dropFromLastGround <= 0.35f; + bool seamSizedGap = dropFromLastGround <= (nearStructureSpace ? 2.5f : 0.35f); if (noGroundTimer_ < NO_GROUND_GRACE && seamSizedGap) { - // Micro-gap grace only: keep continuity for tiny seam misses, - // but never convert air into persistent ground. - targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f); + // Near WMO floors, prefer continuity over falling on transient + // floor-query misses (stairs/planks/portal seams). + float maxSlip = nearStructureSpace ? 1.0f : 0.10f; + targetPos.z = std::max(targetPos.z, lastGroundZ - maxSlip); + if (nearStructureSpace && verticalVelocity < -2.0f) { + verticalVelocity = -2.0f; + } grounded = false; + } else if (nearStructureSpace && noGroundTimer_ < 1.0f && dropFromLastGround <= 3.0f) { + // Extended WMO rescue window: hold close to last valid floor so we + // do not tunnel through walkable geometry during short hitches. + targetPos.z = std::max(targetPos.z, lastGroundZ - 0.35f); + if (verticalVelocity < -1.5f) { + verticalVelocity = -1.5f; + } + grounded = false; + } else if (nearStructureSpace && noGroundTimer_ < 1.20f && dropFromLastGround <= 4.0f && !nowJump) { + // Extended adhesion for sparse dock/bridge collision: keep us on the + // last valid support long enough for adjacent structure probes to hit. + targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f); + if (verticalVelocity < -0.5f) verticalVelocity = -0.5f; + grounded = true; } else { grounded = false; } @@ -918,7 +1185,7 @@ void CameraController::update(float deltaTime) { // Player is safely on real geometry — save periodically continuousFallTime_ = 0.0f; autoUnstuckFired_ = false; - safePosSaveTimer_ += deltaTime; + safePosSaveTimer_ += physicsDeltaTime; if (safePosSaveTimer_ >= SAFE_POS_SAVE_INTERVAL) { safePosSaveTimer_ = 0.0f; lastSafePos_ = targetPos; @@ -926,7 +1193,7 @@ void CameraController::update(float deltaTime) { } } else if (!grounded && !swimming && !externalFollow_) { // Falling (or standing on nothing past grace period) — accumulate fall time - continuousFallTime_ += deltaTime; + continuousFallTime_ += physicsDeltaTime; if (continuousFallTime_ >= AUTO_UNSTUCK_FALL_TIME && !autoUnstuckFired_) { autoUnstuckFired_ = true; if (autoUnstuckCallback_) { @@ -1179,27 +1446,27 @@ void CameraController::update(float deltaTime) { if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); - newPos += movement * swimSpeed * deltaTime; + newPos += movement * swimSpeed * physicsDeltaTime; } if (nowJump) { verticalVelocity = SWIM_BUOYANCY; } else { - verticalVelocity += SWIM_GRAVITY * deltaTime; + verticalVelocity += SWIM_GRAVITY * physicsDeltaTime; if (verticalVelocity < SWIM_SINK_SPEED) { verticalVelocity = SWIM_SINK_SPEED; } if (!diveIntent) { float surfaceErr = (waterSurfaceCamZ - newPos.z); - verticalVelocity += surfaceErr * 7.0f * deltaTime; - verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime); + verticalVelocity += surfaceErr * 7.0f * physicsDeltaTime; + verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * physicsDeltaTime); if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) { verticalVelocity = 0.0f; } } } - newPos.z += verticalVelocity * deltaTime; + newPos.z += verticalVelocity * physicsDeltaTime; // Don't rise above water surface (feet at water level) if (waterH && (newPos.z - eyeHeight) > *waterH - WATER_SURFACE_OFFSET) { @@ -1213,7 +1480,7 @@ void CameraController::update(float deltaTime) { if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); - newPos += movement * speed * deltaTime; + newPos += movement * speed * physicsDeltaTime; } // Jump with input buffering and coyote time @@ -1227,12 +1494,12 @@ void CameraController::update(float deltaTime) { coyoteTimer = 0.0f; } - jumpBufferTimer -= deltaTime; - coyoteTimer -= deltaTime; + jumpBufferTimer -= physicsDeltaTime; + coyoteTimer -= physicsDeltaTime; // Apply gravity - verticalVelocity += gravity * deltaTime; - newPos.z += verticalVelocity * deltaTime; + verticalVelocity += gravity * physicsDeltaTime; + newPos.z += verticalVelocity * physicsDeltaTime; } // Wall sweep collision before grounding (skip when stationary). diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 33ff425a..bc2c77d6 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -812,10 +812,10 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector& } } - // Debug: dump composite to /tmp for visual inspection + // Debug: dump composite to temp dir for visual inspection { - std::string dumpPath = "/tmp/wowee_composite_debug_" + - std::to_string(width) + "x" + std::to_string(height) + ".raw"; + std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" + + std::to_string(width) + "x" + std::to_string(height) + ".raw")).string(); std::ofstream dump(dumpPath, std::ios::binary); if (dump) { dump.write(reinterpret_cast(composite.data()), @@ -1310,8 +1310,9 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, } void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { - // Distance culling for animation updates (150 unit radius) - const float animUpdateRadiusSq = 150.0f * 150.0f; + // Distance culling for animation updates in dense areas. + const float animUpdateRadius = static_cast(envSizeOrDefault("WOWEE_CHAR_ANIM_RADIUS", 120)); + const float animUpdateRadiusSq = animUpdateRadius * animUpdateRadius; // Update fade-in opacity for (auto& [id, inst] : instances) { @@ -1404,6 +1405,7 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { for (auto& pair : instances) { auto& instance = pair.second; if (instance.weaponAttachments.empty()) continue; + if (glm::distance2(instance.position, cameraPos) > animUpdateRadiusSq) continue; glm::mat4 charModelMat = instance.hasOverrideModelMatrix ? instance.overrideModelMatrix @@ -1614,6 +1616,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, if (instances.empty() || !opaquePipeline_) { return; } + const float renderRadius = static_cast(envSizeOrDefault("WOWEE_CHAR_RENDER_RADIUS", 130)); + const float renderRadiusSq = renderRadius * renderRadius; + const float nearNoConeCullSq = 16.0f * 16.0f; + const float backfaceDotCull = -0.30f; + const glm::vec3 camPos = camera.getPosition(); + const glm::vec3 camForward = camera.getForward(); uint32_t frameIndex = vkCtx_->getCurrentFrame(); uint32_t frameSlot = frameIndex % 2u; @@ -1647,6 +1655,18 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, // Skip invisible instances (e.g., player in first-person mode) if (!instance.visible) continue; + // Character instance culling: avoid drawing far-away / strongly behind-camera + // actors in dense city scenes. + if (!instance.hasOverrideModelMatrix) { + glm::vec3 toInst = instance.position - camPos; + float distSq = glm::dot(toInst, toInst); + if (distSq > renderRadiusSq) continue; + if (distSq > nearNoConeCullSq) { + float invDist = 1.0f / std::sqrt(distSq); + float facingDot = glm::dot(toInst, camForward) * invDist; + if (facingDot < backfaceDotCull) continue; + } + } auto modelIt = models.find(instance.modelId); if (modelIt == models.end()) continue; diff --git a/src/rendering/quest_marker_renderer.cpp b/src/rendering/quest_marker_renderer.cpp index d07096e3..bc481d5a 100644 --- a/src/rendering/quest_marker_renderer.cpp +++ b/src/rendering/quest_marker_renderer.cpp @@ -363,6 +363,8 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe constexpr float MIN_DIST = 4.0f; // Near clamp constexpr float MAX_DIST = 90.0f; // Far fade-out start constexpr float FADE_RANGE = 25.0f; // Fade-out range + constexpr float CULL_DIST = MAX_DIST + FADE_RANGE; + constexpr float CULL_DIST_SQ = CULL_DIST * CULL_DIST; // Get time for bob animation float timeSeconds = SDL_GetTicks() / 1000.0f; @@ -373,6 +375,7 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe // Get camera right and up vectors for billboarding glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]); glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]); + const glm::vec3 cameraForward = glm::cross(cameraRight, cameraUp); // Bind pipeline vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); @@ -391,7 +394,9 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe // Calculate distance for LOD and culling glm::vec3 toCamera = cameraPos - marker.position; - float dist = glm::length(toCamera); + float distSq = glm::dot(toCamera, toCamera); + if (distSq > CULL_DIST_SQ) continue; + float dist = std::sqrt(distSq); // Calculate fade alpha float fadeAlpha = 1.0f; @@ -425,7 +430,7 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe // Billboard: align quad to face camera model[0] = glm::vec4(cameraRight * size, 0.0f); model[1] = glm::vec4(cameraUp * size, 0.0f); - model[2] = glm::vec4(glm::cross(cameraRight, cameraUp), 0.0f); + model[2] = glm::vec4(cameraForward, 0.0f); // Bind material descriptor set (set 1) for this marker's texture vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index cfd9c21b..d6d3f4b1 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -99,6 +99,15 @@ static bool envFlagEnabled(const char* key, bool defaultValue) { return !(v == "0" || v == "false" || v == "off" || v == "no"); } +static int envIntOrDefault(const char* key, int defaultValue) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + char* end = nullptr; + long n = std::strtol(raw, &end, 10); + if (end == raw) return defaultValue; + return static_cast(n); +} + static std::vector parseEmoteCommands(const std::string& raw) { std::vector out; std::string cur; @@ -2678,15 +2687,19 @@ void Renderer::update(float deltaTime) { } } + const bool canQueryWmo = (camera && wmoRenderer); + const glm::vec3 camPos = camera ? camera->getPosition() : glm::vec3(0.0f); + uint32_t insideWmoId = 0; + const bool insideWmo = canQueryWmo && + wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &insideWmoId); + // Ambient environmental sounds: fireplaces, water, birds, etc. if (ambientSoundManager && camera && wmoRenderer && cameraController) { - glm::vec3 camPos = camera->getPosition(); - uint32_t wmoId = 0; - bool isIndoor = wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &wmoId); + bool isIndoor = insideWmo; bool isSwimming = cameraController->isSwimming(); // Check if inside blacksmith (96048 = Goldshire blacksmith) - bool isBlacksmith = (wmoId == 96048); + bool isBlacksmith = (insideWmoId == 96048); // Sync weather audio with visual weather system if (weather) { @@ -2747,9 +2760,8 @@ void Renderer::update(float deltaTime) { // Override with WMO-based detection (e.g., inside Stormwind, taverns, blacksmiths) if (wmoRenderer) { - glm::vec3 camPos = camera->getPosition(); - uint32_t wmoModelId = 0; - if (wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &wmoModelId)) { + uint32_t wmoModelId = insideWmoId; + if (insideWmo) { // Check if inside Stormwind WMO (model ID 10047) if (wmoModelId == 10047) { zoneId = 1519; // Stormwind City @@ -3839,6 +3851,19 @@ void Renderer::renderShadowPass() { if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return; if (currentCmd == VK_NULL_HANDLE) return; + const int baseInterval = std::max(1, envIntOrDefault("WOWEE_SHADOW_INTERVAL", 1)); + const int denseInterval = std::max(baseInterval, envIntOrDefault("WOWEE_SHADOW_INTERVAL_DENSE", 3)); + const uint32_t denseCharThreshold = static_cast(std::max(1, envIntOrDefault("WOWEE_DENSE_CHAR_THRESHOLD", 120))); + const uint32_t denseM2Threshold = static_cast(std::max(1, envIntOrDefault("WOWEE_DENSE_M2_THRESHOLD", 900))); + const bool denseScene = + (characterRenderer && characterRenderer->getInstanceCount() >= denseCharThreshold) || + (m2Renderer && m2Renderer->getInstanceCount() >= denseM2Threshold); + const int shadowInterval = denseScene ? denseInterval : baseInterval; + if (++shadowFrameCounter_ < static_cast(shadowInterval)) { + return; + } + shadowFrameCounter_ = 0; + // Compute and store light space matrix; write to per-frame UBO lightSpaceMatrix = computeLightSpaceMatrix(); // Zero matrix means character position isn't set yet — skip shadow pass entirely. @@ -3890,15 +3915,17 @@ void Renderer::renderShadowPass() { vkCmdSetScissor(currentCmd, 0, 1, &sc); // Phase 7/8: render shadow casters - constexpr float kShadowCullRadius = 180.0f; // match kShadowHalfExtent + const float baseShadowCullRadius = static_cast(std::max(40, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS", 180))); + const float denseShadowCullRadius = static_cast(std::max(30, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS_DENSE", 90))); + const float shadowCullRadius = denseScene ? std::min(baseShadowCullRadius, denseShadowCullRadius) : baseShadowCullRadius; if (wmoRenderer) { - wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius); + wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius); } if (m2Renderer) { - m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, kShadowCullRadius); + m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, shadowCullRadius); } if (characterRenderer) { - characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius); + characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius); } vkCmdEndRenderPass(currentCmd); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 40338e11..8a3bd00e 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -2699,6 +2699,42 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ } }; + // Fast path: current active interior group and its neighbors are usually + // the right answer for player-floor queries while moving in cities/buildings. + if (activeGroup_.isValid() && activeGroup_.instanceIdx < instances.size()) { + const auto& instance = instances[activeGroup_.instanceIdx]; + auto it = loadedModels.find(instance.modelId); + if (it != loadedModels.end() && instance.modelId == activeGroup_.modelId) { + const ModelData& model = it->second; + glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(worldOrigin, 1.0f)); + glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(worldDir, 0.0f))); + + auto testGroupIdx = [&](uint32_t gi) { + if (gi >= model.groups.size()) return; + if (gi < instance.worldGroupBounds.size()) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + if (glX < gMin.x || glX > gMax.x || + glY < gMin.y || glY > gMax.y || + glZ - 4.0f > gMax.z) { + return; + } + } + const auto& group = model.groups[gi]; + if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) { + return; + } + testGroupFloor(instance, model, group, localOrigin, localDir); + }; + + if (activeGroup_.groupIdx >= 0) { + testGroupIdx(static_cast(activeGroup_.groupIdx)); + } + for (uint32_t ngi : activeGroup_.neighborGroups) { + testGroupIdx(ngi); + } + } + } + // Full scan: test all instances (active group fast path removed to fix // bridge clipping where early-return missed other WMO instances) glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f); @@ -2720,6 +2756,9 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ float zMarginUp = model.isLowPlatform ? 20.0f : 4.0f; // Broad-phase reject in world space to avoid expensive matrix transforms. + if (bestFloor && instance.worldBoundsMax.z <= (*bestFloor + 0.05f)) { + continue; + } if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || glZ < instance.worldBoundsMin.z - zMarginDown || glZ > instance.worldBoundsMax.z + zMarginUp) { diff --git a/src/ui/auth_screen.cpp b/src/ui/auth_screen.cpp index f76b21ba..f57133d5 100644 --- a/src/ui/auth_screen.cpp +++ b/src/ui/auth_screen.cpp @@ -220,50 +220,64 @@ void AuthScreen::render(auth::AuthHandler& authHandler) { music->update(ImGui::GetIO().DeltaTime); if (!music->isPlaying()) { static std::mt19937 rng(std::random_device{}()); - // Tracks in assets/ root - static const std::array kRootTracks = { - "Raise the Mug, Sound the Warcry.mp3", - }; - // Tracks in assets/Original Music/ - static const std::array kOriginalTracks = { - "Gold on the Tide in Booty Bay.mp3", - "Lanterns Over Lordaeron.mp3", - "Loot the Dogs.mp3", - "One More Pull.mp3", - "Roll Need Greed.mp3", - "RunBackPolka.mp3", - "The Barrens Has No End.mp3", - "The Bone Collector.mp3", - "Wanderwewill.mp3", - "WHO PULLED_.mp3", - "You No Take Candle!.mp3", - }; + if (!introTracksScanned_) { + introTracksScanned_ = true; - std::vector availableTracks; - auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) { - std::filesystem::path p = base / track; - if (std::filesystem::exists(p)) { - availableTracks.push_back(p.string()); + // Tracks in assets/ root + static const std::array kRootTracks = { + "Raise the Mug, Sound the Warcry.mp3", + }; + // Tracks in assets/Original Music/ + static const std::array kOriginalTracks = { + "Gold on the Tide in Booty Bay.mp3", + "Lanterns Over Lordaeron.mp3", + "Loot the Dogs.mp3", + "One More Pull.mp3", + "Roll Need Greed.mp3", + "RunBackPolka.mp3", + "The Barrens Has No End.mp3", + "The Bone Collector.mp3", + "Wanderwewill.mp3", + "WHO PULLED_.mp3", + "You No Take Candle!.mp3", + }; + + auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) { + std::filesystem::path p = base / track; + if (std::filesystem::exists(p)) { + introTracks_.push_back(p.string()); + } + }; + for (const char* track : kRootTracks) { + tryAddTrack("assets", track); + if (introTracks_.empty()) { + tryAddTrack(std::filesystem::current_path() / "assets", track); + } } - }; - for (const char* track : kRootTracks) { - tryAddTrack("assets", track); - if (availableTracks.empty()) - tryAddTrack(std::filesystem::current_path() / "assets", track); - } - for (const char* track : kOriginalTracks) { - tryAddTrack(std::filesystem::path("assets") / "Original Music", track); - tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track); + for (const char* track : kOriginalTracks) { + tryAddTrack(std::filesystem::path("assets") / "Original Music", track); + tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track); + } + + std::sort(introTracks_.begin(), introTracks_.end()); + introTracks_.erase(std::unique(introTracks_.begin(), introTracks_.end()), introTracks_.end()); } - if (!availableTracks.empty()) { - std::uniform_int_distribution pick(0, availableTracks.size() - 1); - const std::string& path = availableTracks[pick(rng)]; + if (!introTracks_.empty()) { + std::uniform_int_distribution pick(0, introTracks_.size() - 1); + const size_t idx = pick(rng); + const std::string path = introTracks_[idx]; music->playFilePath(path, true, 1800.0f); - LOG_INFO("AuthScreen: Playing login intro track: ", path); musicPlaying = music->isPlaying(); - } else { + if (musicPlaying) { + LOG_INFO("AuthScreen: Playing login intro track: ", path); + } else { + // Drop bad paths to avoid retrying the same failed file every frame. + introTracks_.erase(introTracks_.begin() + idx); + } + } else if (!missingIntroTracksLogged_) { LOG_WARNING("AuthScreen: No login intro tracks found in assets/"); + missingIntroTracksLogged_ = true; } } } diff --git a/tools/asset_extract/extractor.cpp b/tools/asset_extract/extractor.cpp index b4b6c9d5..615b43b9 100644 --- a/tools/asset_extract/extractor.cpp +++ b/tools/asset_extract/extractor.cpp @@ -548,6 +548,12 @@ bool Extractor::enumerateFiles(const Options& opts, continue; } + // Verify file actually exists in this archive's hash table + // (listfiles can reference files from other archives) + if (!SFileHasFile(hMpq, fileName.c_str())) { + continue; + } + std::string norm = normalizeWowPath(fileName); if (opts.onlyUsedDbcs && !wantedDbcs.empty() && !wantedDbcs.contains(norm)) { continue; @@ -624,24 +630,36 @@ bool Extractor::run(const Options& opts) { std::atomic fileIndex{0}; size_t totalFiles = files.size(); + // Open archives ONCE in main thread — StormLib has global state that is not + // thread-safe even with separate handles, so we serialize all MPQ reads. + struct SharedArchive { + HANDLE handle; + int priority; + std::string path; + }; + std::vector sharedHandles; + for (const auto& ad : archives) { + HANDLE h = nullptr; + if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) { + sharedHandles.push_back({h, ad.priority, ad.path}); + } else { + std::cerr << " Failed to open archive: " << ad.path << "\n"; + } + } + if (sharedHandles.empty()) { + std::cerr << "Failed to open any archives for extraction\n"; + return false; + } + if (sharedHandles.size() < archives.size()) { + std::cerr << " Opened " << sharedHandles.size() + << "/" << archives.size() << " archives\n"; + } + + // Mutex protecting all StormLib calls (open/read/close are not thread-safe) + std::mutex mpqMutex; + auto workerFn = [&]() { - // Each thread opens ALL archives independently (StormLib is not thread-safe per handle). - // Sorted highest-priority last, so we iterate in reverse to find the winning version. - struct ThreadArchive { - HANDLE handle; - int priority; - }; - std::vector threadHandles; - for (const auto& ad : archives) { - HANDLE h = nullptr; - if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) { - threadHandles.push_back({h, ad.priority}); - } - } - if (threadHandles.empty()) { - std::cerr << "Worker thread: failed to open any archives\n"; - return; - } + int failLogCount = 0; while (true) { size_t idx = fileIndex.fetch_add(1); @@ -654,35 +672,52 @@ bool Extractor::run(const Options& opts) { std::string mappedPath = PathMapper::mapPath(wowPath); std::string fullOutputPath = effectiveOutputDir + "/" + mappedPath; - // Search archives in reverse priority order (highest priority first) - HANDLE hFile = nullptr; - for (auto it = threadHandles.rbegin(); it != threadHandles.rend(); ++it) { - if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) { - break; + // Read file data from MPQ under lock + std::vector data; + { + std::lock_guard lock(mpqMutex); + + // Search archives in reverse priority order (highest priority first) + HANDLE hFile = nullptr; + for (auto it = sharedHandles.rbegin(); it != sharedHandles.rend(); ++it) { + if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) { + break; + } + hFile = nullptr; + } + if (!hFile) { + stats.filesFailed++; + if (failLogCount < 5) { + failLogCount++; + std::cerr << " FAILED open: " << wowPath + << " (tried " << sharedHandles.size() << " archives)\n"; + } + continue; } - hFile = nullptr; - } - if (!hFile) { - stats.filesFailed++; - continue; - } - DWORD fileSize = SFileGetFileSize(hFile, nullptr); - if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) { - SFileCloseFile(hFile); - stats.filesSkipped++; - continue; - } + DWORD fileSize = SFileGetFileSize(hFile, nullptr); + if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) { + SFileCloseFile(hFile); + stats.filesSkipped++; + continue; + } - std::vector data(fileSize); - DWORD bytesRead = 0; - if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) { + data.resize(fileSize); + DWORD bytesRead = 0; + if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) { + SFileCloseFile(hFile); + stats.filesFailed++; + if (failLogCount < 5) { + failLogCount++; + std::cerr << " FAILED read: " << wowPath + << " (size=" << fileSize << ")\n"; + } + continue; + } SFileCloseFile(hFile); - stats.filesFailed++; - continue; + data.resize(bytesRead); } - SFileCloseFile(hFile); - data.resize(bytesRead); + // Lock released — CRC computation and disk write happen in parallel // Compute CRC32 uint32_t crc = ManifestWriter::computeCRC32(data.data(), data.size()); @@ -694,6 +729,11 @@ bool Extractor::run(const Options& opts) { std::ofstream out(fullOutputPath, std::ios::binary); if (!out.is_open()) { stats.filesFailed++; + if (failLogCount < 5) { + failLogCount++; + std::lock_guard lock(manifestMutex); + std::cerr << " FAILED write: " << fullOutputPath << "\n"; + } continue; } out.write(reinterpret_cast(data.data()), data.size()); @@ -721,10 +761,6 @@ bool Extractor::run(const Options& opts) { << std::flush; } } - - for (auto& th : threadHandles) { - SFileCloseArchive(th.handle); - } }; std::cout << "Extracting " << totalFiles << " files using " << numThreads << " threads...\n"; @@ -737,10 +773,30 @@ bool Extractor::run(const Options& opts) { t.join(); } - std::cout << "\r Extracted " << stats.filesExtracted.load() << " files (" + // Close archives (opened once in main thread) + for (auto& sh : sharedHandles) { + SFileCloseArchive(sh.handle); + } + + auto extracted = stats.filesExtracted.load(); + auto failed = stats.filesFailed.load(); + auto skipped = stats.filesSkipped.load(); + std::cout << "\n Extracted " << extracted << " files (" << stats.bytesExtracted.load() / (1024 * 1024) << " MB), " - << stats.filesSkipped.load() << " skipped, " - << stats.filesFailed.load() << " failed\n"; + << skipped << " skipped, " + << failed << " failed\n"; + + // If most files failed, print a diagnostic hint + if (failed > 0 && failed > extracted * 10) { + std::cerr << "\nWARNING: " << failed << " out of " << totalFiles + << " files failed to extract.\n" + << " This usually means worker threads could not open one or more MPQ archives.\n" + << " Common causes:\n" + << " - MPQ files on a network/external drive with access restrictions\n" + << " - Another program (WoW client, antivirus) has the MPQ files locked\n" + << " - Too many threads for the OS file-handle limit (try --threads 1)\n" + << " Re-run with --verbose for detailed diagnostics.\n"; + } // Merge with existing manifest so partial extractions don't nuke prior entries std::string manifestPath = effectiveOutputDir + "/manifest.json";