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 ecfc7c1d..14f55887 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/application.hpp b/include/core/application.hpp index 08da7458..1294ee12 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -244,7 +244,7 @@ private: float x, y, z, orientation; }; std::vector pendingCreatureSpawns_; - static constexpr int MAX_SPAWNS_PER_FRAME = 8; + static constexpr int MAX_SPAWNS_PER_FRAME = 3; static constexpr int MAX_NEW_CREATURE_MODELS_PER_FRAME = 1; static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300; std::unordered_set pendingCreatureSpawnGuids_; diff --git a/include/core/logger.hpp b/include/core/logger.hpp index 4ba4e3ce..fa8e8158 100644 --- a/include/core/logger.hpp +++ b/include/core/logger.hpp @@ -26,6 +26,10 @@ enum class LogLevel { FATAL }; +// Avoid direct token use of `ERROR` at call sites because Windows headers +// define `ERROR` as a macro. +inline constexpr LogLevel kLogLevelError = LogLevel::ERROR; + class Logger { public: static Logger& getInstance(); @@ -65,6 +69,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 +88,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::kLogLevelError)) { \ + _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/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 9d4a62a6..1b1526c6 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -390,13 +390,19 @@ private: std::vector> cellTriangles; // Pre-classified triangle lists per cell (built at load time) - std::vector> cellFloorTriangles; // abs(normal.z) >= 0.45 - std::vector> cellWallTriangles; // abs(normal.z) < 0.55 + std::vector> cellFloorTriangles; // abs(normal.z) >= 0.35 + std::vector> cellWallTriangles; // abs(normal.z) < 0.35 // Pre-computed per-triangle Z bounds for fast vertical reject struct TriBounds { float minZ; float maxZ; }; std::vector triBounds; // indexed by triStart/3 + // Pre-computed per-triangle normals (unit length, indexed by triStart/3) + std::vector triNormals; + + // Scratch bitset for deduplicating triangle queries (sized to numTriangles) + mutable std::vector triVisited; + // Build the spatial grid from collision geometry void buildCollisionGrid(); @@ -675,7 +681,7 @@ private: std::unordered_map, GridCellHash> spatialGrid; std::unordered_map instanceIndexById; mutable std::vector candidateScratch; - mutable std::vector wallTriScratch; // Scratch for wall collision grid queries + mutable std::vector triScratch_; // Scratch for collision grid queries mutable std::unordered_set candidateIdScratch; // Parallel visibility culling 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/application.cpp b/src/core/application.cpp index 9e34d7aa..eb9d0a7a 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -578,25 +578,100 @@ void Application::reloadExpansionData() { void Application::logoutToLogin() { LOG_INFO("Logout requested"); + + // Disconnect TransportManager from WMORenderer before tearing down + if (gameHandler && gameHandler->getTransportManager()) { + gameHandler->getTransportManager()->setWMORenderer(nullptr); + } + if (gameHandler) { gameHandler->disconnect(); } + + // --- Per-session flags --- npcsSpawned = false; playerCharacterSpawned = false; weaponsSheathed_ = false; wasAutoAttacking_ = false; loadedMapId_ = 0xFFFFFFFF; + lastTaxiFlight_ = false; + taxiLandingClampTimer_ = 0.0f; + worldEntryMovementGraceTimer_ = 0.0f; + facingSendCooldown_ = 0.0f; + lastSentCanonicalYaw_ = 1000.0f; + taxiStreamCooldown_ = 0.0f; + idleYawned_ = false; + + // --- Charge state --- + chargeActive_ = false; + chargeTimer_ = 0.0f; + chargeDuration_ = 0.0f; + chargeTargetGuid_ = 0; + + // --- Player identity --- + spawnedPlayerGuid_ = 0; + spawnedAppearanceBytes_ = 0; + spawnedFacialFeatures_ = 0; + + // --- Mount state --- + mountInstanceId_ = 0; + mountModelId_ = 0; + pendingMountDisplayId_ = 0; + + // --- Creature instance tracking --- + creatureInstances_.clear(); + creatureModelIds_.clear(); + creatureRenderPosCache_.clear(); + creatureWeaponsAttached_.clear(); + creatureWeaponAttachAttempts_.clear(); + deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); + creaturePermanentFailureGuids_.clear(); + + // --- Creature spawn queues --- + pendingCreatureSpawns_.clear(); + pendingCreatureSpawnGuids_.clear(); + creatureSpawnRetryCounts_.clear(); + + // --- Player instance tracking --- + playerInstances_.clear(); + onlinePlayerAppearance_.clear(); + pendingOnlinePlayerEquipment_.clear(); + deferredEquipmentQueue_.clear(); + pendingPlayerSpawns_.clear(); + pendingPlayerSpawnGuids_.clear(); + + // --- GameObject instance tracking --- + gameObjectInstances_.clear(); + pendingGameObjectSpawns_.clear(); + pendingTransportMoves_.clear(); + pendingTransportDoodadBatches_.clear(); + world.reset(); + if (renderer) { // Remove old player model so it doesn't persist into next session if (auto* charRenderer = renderer->getCharacterRenderer()) { charRenderer->removeInstance(1); } + // Clear all world geometry renderers + if (auto* wmo = renderer->getWMORenderer()) { + wmo->clearInstances(); + } + if (auto* m2 = renderer->getM2Renderer()) { + m2->clear(); + } + // TerrainManager will be re-initialized on next world entry + if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { + questMarkers->clear(); + } + renderer->clearMount(); + renderer->setCharacterFollow(0); if (auto* music = renderer->getMusicManager()) { music->stopMusic(0.0f); } } + // Clear stale realm/character selection so switching servers starts fresh if (uiManager) { uiManager->getRealmScreen().reset(); @@ -711,7 +786,7 @@ void Application::update(float deltaTime) { if (gameHandler) { static float creatureResyncTimer = 0.0f; creatureResyncTimer += deltaTime; - if (creatureResyncTimer >= 1.0f) { + if (creatureResyncTimer >= 3.0f) { creatureResyncTimer = 0.0f; glm::vec3 playerPos(0.0f); diff --git a/src/core/logger.cpp b/src/core/logger.cpp index 498dd219..cdc1afc6 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(kLogLevelError); + 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); @@ -66,7 +83,7 @@ void Logger::log(LogLevel level, const std::string& message) { case LogLevel::DEBUG: line << "DEBUG"; break; case LogLevel::INFO: line << "INFO "; break; case LogLevel::WARNING: line << "WARN "; break; - case LogLevel::ERROR: line << "ERROR"; break; + case kLogLevelError: line << "ERROR"; break; case LogLevel::FATAL: line << "FATAL"; break; } @@ -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/main.cpp b/src/main.cpp index 97930e30..d3811b3b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -38,7 +38,7 @@ static wowee::core::LogLevel readLogLevelFromEnv() { if (level == "debug") return wowee::core::LogLevel::DEBUG; if (level == "info") return wowee::core::LogLevel::INFO; if (level == "warn" || level == "warning") return wowee::core::LogLevel::WARNING; - if (level == "error") return wowee::core::LogLevel::ERROR; + if (level == "error") return wowee::core::kLogLevelError; if (level == "fatal") return wowee::core::LogLevel::FATAL; return wowee::core::LogLevel::WARNING; } diff --git a/src/pipeline/mpq_manager.cpp b/src/pipeline/mpq_manager.cpp index cef57405..65f74239 100644 --- a/src/pipeline/mpq_manager.cpp +++ b/src/pipeline/mpq_manager.cpp @@ -458,12 +458,27 @@ bool MPQManager::loadPatchArchives() { {"patch.MPQ", 150}, }; + // Build a case-insensitive lookup of files in the data directory so that + // Patch-A.MPQ, patch-a.mpq, PATCH-A.MPQ, etc. all resolve correctly on + // case-sensitive filesystems (Linux). + std::unordered_map lowerToActual; // lowercase name → actual path + if (std::filesystem::is_directory(dataPath)) { + for (const auto& entry : std::filesystem::directory_iterator(dataPath)) { + if (!entry.is_regular_file()) continue; + std::string fname = entry.path().filename().string(); + std::string lower = toLowerCopy(fname); + lowerToActual[lower] = entry.path().string(); + } + } + int loadedPatches = 0; for (const auto& [archive, priority] : patchArchives) { + // Classify letter vs numeric patch for the disable flags + std::string lowerArchive = toLowerCopy(archive); const bool isLetterPatch = - (archive.size() >= 10) && - (toLowerCopy(archive).rfind("patch-", 0) != 0) && // not patch-*.MPQ - (toLowerCopy(archive).rfind("patch.", 0) != 0); // not patch.MPQ + (lowerArchive.size() >= 11) && // "patch-X.mpq" = 11 chars + (lowerArchive.rfind("patch-", 0) == 0) && // starts with "patch-" + (lowerArchive[6] >= 'a' && lowerArchive[6] <= 'z'); // letter after dash if (isLetterPatch && disableLetterPatches) { continue; } @@ -471,9 +486,10 @@ bool MPQManager::loadPatchArchives() { continue; } - std::string fullPath = dataPath + "/" + archive; - if (std::filesystem::exists(fullPath)) { - if (loadArchive(fullPath, priority)) { + // Case-insensitive file lookup + auto it = lowerToActual.find(lowerArchive); + if (it != lowerToActual.end()) { + if (loadArchive(it->second, priority)) { loadedPatches++; } } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 9dbc2b71..b96ec321 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_) { @@ -1005,28 +1272,8 @@ void CameraController::update(float deltaTime) { // Find max safe distance using raycast + sphere radius collisionDistance = currentDistance; - // WMO raycast collision: zoom in when camera would clip through walls/floors - if (wmoRenderer && currentDistance > MIN_DISTANCE) { - glm::vec3 camRayOrigin = pivot; - glm::vec3 camRayDir = camDir; - float wmoHitDist = wmoRenderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance); - if (wmoHitDist < currentDistance) { - // Hit WMO geometry — pull camera in to avoid clipping - constexpr float CAM_RADIUS = 0.3f; - collisionDistance = std::max(MIN_DISTANCE, wmoHitDist - CAM_RADIUS); - } - } - - // M2 raycast collision: zoom in when camera would clip through doodads - if (m2Renderer && !externalFollow_ && currentDistance > MIN_DISTANCE) { - glm::vec3 camRayOrigin = pivot; - glm::vec3 camRayDir = camDir; - float m2HitDist = m2Renderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance); - if (m2HitDist < collisionDistance) { - constexpr float CAM_RADIUS = 0.3f; - collisionDistance = std::max(MIN_DISTANCE, m2HitDist - CAM_RADIUS); - } - } + // WMO/M2 camera collision disabled — was pulling camera through + // geometry at doorway transitions and causing erratic zoom behaviour. // Camera collision: terrain-only floor clamping auto getTerrainFloorAt = [&](float x, float y) -> std::optional { @@ -1179,27 +1426,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 +1460,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 +1474,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/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 378a7b41..23c1c6d9 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -2081,8 +2081,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const lastDrawCallCount = 0; - // Adaptive render distance: balanced for performance without excessive pop-in - const float maxRenderDistance = (instances.size() > 2000) ? 350.0f : 1000.0f; + // Adaptive render distance: tiered by instance density to cap draw calls + const float maxRenderDistance = (instances.size() > 2000) ? 300.0f + : (instances.size() > 1000) ? 500.0f + : 1000.0f; const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance; const float fadeStartFraction = 0.75f; const glm::vec3 camPos = camera.getPosition(); 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..145128a2 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1263,6 +1263,8 @@ void WMORenderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& q } void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + ++currentFrameId; + if (!opaquePipeline_ || instances.empty()) { lastDrawCalls = 0; return; @@ -2474,6 +2476,8 @@ void WMORenderer::GroupResources::buildCollisionGrid() { size_t numTriangles = collisionIndices.size() / 3; triBounds.resize(numTriangles); + triNormals.resize(numTriangles); + triVisited.resize(numTriangles, 0); float invCellW = gridCellsX / std::max(0.01f, extentX); float invCellH = gridCellsY / std::max(0.01f, extentY); @@ -2494,16 +2498,23 @@ void WMORenderer::GroupResources::buildCollisionGrid() { float triMaxZ = std::max({v0.z, v1.z, v2.z}); triBounds[i / 3] = { triMinZ, triMaxZ }; - // Classify floor vs wall by normal. - // Wall threshold matches MAX_WALK_SLOPE_DOT (cos 50° ≈ 0.6428) so that - // surfaces too steep to walk on are always tested for wall collision. + // Precompute and store unit normal glm::vec3 edge1 = v1 - v0; glm::vec3 edge2 = v2 - v0; glm::vec3 normal = glm::cross(edge1, edge2); float normalLen = glm::length(normal); - float absNz = (normalLen > 0.001f) ? std::abs(normal.z / normalLen) : 0.0f; + if (normalLen > 0.001f) { + normal /= normalLen; + } else { + normal = glm::vec3(0.0f, 0.0f, 1.0f); + } + triNormals[i / 3] = normal; + + // Classify floor vs wall by normal. + // Wall threshold matches the runtime skip in checkWallCollision (absNz >= 0.35). + float absNz = std::abs(normal.z); bool isFloor = (absNz >= 0.35f); // ~70° max slope (relaxed for steep stairs) - bool isWall = (absNz < 0.65f); // Matches walkable slope threshold + bool isWall = (absNz < 0.35f); // Matches checkWallCollision skip threshold int cellMinX = std::max(0, static_cast((triMinX - gridOrigin.x) * invCellW)); int cellMinY = std::max(0, static_cast((triMinY - gridOrigin.y) * invCellH)); @@ -2556,18 +2567,30 @@ void WMORenderer::GroupResources::getTrianglesInRange( if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; - // Collect unique triangle indices from all overlapping cells - for (int cy = cellMinY; cy <= cellMaxY; ++cy) { - for (int cx = cellMinX; cx <= cellMaxX; ++cx) { - const auto& cell = cellTriangles[cy * gridCellsX + cx]; - out.insert(out.end(), cell.begin(), cell.end()); + // Collect unique triangle indices using visited bitset (O(n) dedup) + bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY); + if (multiCell && !triVisited.empty()) { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellTriangles[cy * gridCellsX + cx]; + for (uint32_t tri : cell) { + uint32_t idx = tri / 3; + if (!triVisited[idx]) { + triVisited[idx] = 1; + out.push_back(tri); + } + } + } + } + // Clear visited bits + for (uint32_t tri : out) triVisited[tri / 3] = 0; + } else { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellTriangles[cy * gridCellsX + cx]; + out.insert(out.end(), cell.begin(), cell.end()); + } } - } - - // Remove duplicates (triangles spanning multiple cells) - if (cellMinX != cellMaxX || cellMinY != cellMaxY) { - std::sort(out.begin(), out.end()); - out.erase(std::unique(out.begin(), out.end()), out.end()); } } @@ -2589,16 +2612,28 @@ void WMORenderer::GroupResources::getFloorTrianglesInRange( if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; - for (int cy = cellMinY; cy <= cellMaxY; ++cy) { - for (int cx = cellMinX; cx <= cellMaxX; ++cx) { - const auto& cell = cellFloorTriangles[cy * gridCellsX + cx]; - out.insert(out.end(), cell.begin(), cell.end()); + bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY); + if (multiCell && !triVisited.empty()) { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellFloorTriangles[cy * gridCellsX + cx]; + for (uint32_t tri : cell) { + uint32_t idx = tri / 3; + if (!triVisited[idx]) { + triVisited[idx] = 1; + out.push_back(tri); + } + } + } + } + for (uint32_t tri : out) triVisited[tri / 3] = 0; + } else { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellFloorTriangles[cy * gridCellsX + cx]; + out.insert(out.end(), cell.begin(), cell.end()); + } } - } - - if (cellMinX != cellMaxX || cellMinY != cellMaxY) { - std::sort(out.begin(), out.end()); - out.erase(std::unique(out.begin(), out.end()), out.end()); } } @@ -2620,22 +2655,35 @@ void WMORenderer::GroupResources::getWallTrianglesInRange( if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; - for (int cy = cellMinY; cy <= cellMaxY; ++cy) { - for (int cx = cellMinX; cx <= cellMaxX; ++cx) { - const auto& cell = cellWallTriangles[cy * gridCellsX + cx]; - out.insert(out.end(), cell.begin(), cell.end()); + bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY); + if (multiCell && !triVisited.empty()) { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellWallTriangles[cy * gridCellsX + cx]; + for (uint32_t tri : cell) { + uint32_t idx = tri / 3; + if (!triVisited[idx]) { + triVisited[idx] = 1; + out.push_back(tri); + } + } + } + } + for (uint32_t tri : out) triVisited[tri / 3] = 0; + } else { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellWallTriangles[cy * gridCellsX + cx]; + out.insert(out.end(), cell.begin(), cell.end()); + } } - } - - if (cellMinX != cellMaxX || cellMinY != cellMaxY) { - std::sort(out.begin(), out.end()); - out.erase(std::unique(out.begin(), out.end()), out.end()); } } std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const { - // All floor caching disabled - even per-frame cache can return stale results - // when player Z changes between queries, causing fall-through at stairs. + // Per-frame cache disabled: camera and player query the same (x,y) at + // different Z within a single frame. The allowAbove filter depends on glZ, + // so caching by (x,y) alone returns wrong floors across Z contexts. QueryTimer timer(&queryTimeMs, &queryCallCount); std::optional bestFloor; @@ -2660,9 +2708,9 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ group.getTrianglesInRange( localOrigin.x - 1.0f, localOrigin.y - 1.0f, localOrigin.x + 1.0f, localOrigin.y + 1.0f, - wallTriScratch); + triScratch_); - for (uint32_t triStart : wallTriScratch) { + for (uint32_t triStart : triScratch_) { const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; @@ -2676,31 +2724,64 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ glm::vec3 hitLocal = localOrigin + localDir * t; glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f)); - float allowAbove = model.isLowPlatform ? 12.0f : 2.0f; - if (hitWorld.z <= glZ + allowAbove) { + // Accept floors at or below glZ (the caller already elevates + // glZ by stepUpBudget to handle step-up range). Among those, + // pick the highest (closest to feet). + if (hitWorld.z <= glZ) { if (!bestFloor || hitWorld.z > *bestFloor) { bestFloor = hitWorld.z; bestFromLowPlatform = model.isLowPlatform; - // Compute local normal and transform to world space - glm::vec3 localNormal = glm::cross(v1 - v0, v2 - v0); - float len = glm::length(localNormal); - if (len > 0.001f) { - localNormal /= len; - // Ensure normal points upward - if (localNormal.z < 0.0f) localNormal = -localNormal; - glm::vec3 worldNormal = glm::normalize( - glm::vec3(instance.modelMatrix * glm::vec4(localNormal, 0.0f))); - bestNormalZ = std::abs(worldNormal.z); - } + // Use precomputed normal, ensure upward, transform to world + glm::vec3 localNormal = group.triNormals[triStart / 3]; + if (localNormal.z < 0.0f) localNormal = -localNormal; + glm::vec3 worldNormal = glm::normalize( + glm::vec3(instance.modelMatrix * glm::vec4(localNormal, 0.0f))); + bestNormalZ = std::abs(worldNormal.z); } } } } }; - // Full scan: test all instances (active group fast path removed to fix - // bridge clipping where early-return missed other WMO instances) + // 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 result above is not + // early-returned because overlapping WMO instances need full coverage). glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f); glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f); gatherCandidates(queryMin, queryMax, candidateScratch); @@ -2720,6 +2801,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) { @@ -2859,9 +2943,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, float rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f; float rangeMaxX = std::max(localFrom.x, localTo.x) + PLAYER_RADIUS + 1.5f; float rangeMaxY = std::max(localFrom.y, localTo.y) + PLAYER_RADIUS + 1.5f; - group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, wallTriScratch); + group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_); - for (uint32_t triStart : wallTriScratch) { + for (uint32_t triStart : triScratch_) { // Use pre-computed Z bounds for fast vertical reject const auto& tb = group.triBounds[triStart / 3]; @@ -2880,13 +2964,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; - // Triangle normal for swept test and push fallback - glm::vec3 edge1 = v1 - v0; - glm::vec3 edge2 = v2 - v0; - glm::vec3 normal = glm::cross(edge1, edge2); - float normalLen = glm::length(normal); - if (normalLen < 0.001f) continue; - normal /= normalLen; + // Use precomputed normal for swept test and push fallback + glm::vec3 normal = group.triNormals[triStart / 3]; + if (glm::dot(normal, normal) < 0.5f) continue; // degenerate // Recompute plane distances with current (possibly pushed) localTo float fromDist = glm::dot(localFrom - v0, normal); @@ -3229,19 +3309,15 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 float rMinY = std::min(localOrigin.y, localEnd.y) - 1.0f; float rMaxX = std::max(localOrigin.x, localEnd.x) + 1.0f; float rMaxY = std::max(localOrigin.y, localEnd.y) + 1.0f; - group.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, wallTriScratch); + group.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, triScratch_); - for (uint32_t triStart : wallTriScratch) { + for (uint32_t triStart : triScratch_) { const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; - glm::vec3 triNormal = glm::cross(v1 - v0, v2 - v0); - float normalLenSq = glm::dot(triNormal, triNormal); - if (normalLenSq < 1e-8f) { - continue; - } - triNormal /= std::sqrt(normalLenSq); - // Wall list pre-filters at 0.55; apply stricter camera threshold + glm::vec3 triNormal = group.triNormals[triStart / 3]; + if (glm::dot(triNormal, triNormal) < 0.5f) continue; // degenerate + // Wall list pre-filters at 0.35; apply stricter camera threshold if (std::abs(triNormal.z) > MAX_WALKABLE_ABS_NORMAL_Z) { continue; } diff --git a/src/ui/auth_screen.cpp b/src/ui/auth_screen.cpp index f76b21ba..2f4b83cc 100644 --- a/src/ui/auth_screen.cpp +++ b/src/ui/auth_screen.cpp @@ -206,7 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) { } } } - if (renderer) { + // Login screen music disabled + if (false && renderer) { auto* music = renderer->getMusicManager(); if (music) { if (!loginMusicVolumeAdjusted_) { @@ -220,50 +221,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";