This commit is contained in:
kittnz 2026-02-25 19:57:27 +01:00
commit f849f583b2
29 changed files with 1205 additions and 314 deletions

View file

@ -59,6 +59,10 @@ jobs:
- name: Configure - name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release 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 - name: Build
run: cmake --build build --parallel $(nproc) run: cmake --build build --parallel $(nproc)
@ -123,6 +127,10 @@ jobs:
-DCMAKE_PREFIX_PATH="$BREW" \ -DCMAKE_PREFIX_PATH="$BREW" \
-DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" -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 - name: Build
run: cmake --build build --parallel $(sysctl -n hw.logicalcpu) run: cmake --build build --parallel $(sysctl -n hw.logicalcpu)
@ -271,6 +279,11 @@ jobs:
shell: msys2 {0} shell: msys2 {0}
run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release 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 - name: Build
shell: msys2 {0} shell: msys2 {0}
run: cmake --build build --parallel $(nproc) run: cmake --build build --parallel $(nproc)

4
.gitignore vendored
View file

@ -95,3 +95,7 @@ asset_pipeline/
# Local texture dumps / extracted art should never be committed # Local texture dumps / extracted art should never be committed
assets/textures/ assets/textures/
node_modules/ node_modules/
# Python cache artifacts
tools/__pycache__/
*.pyc

View file

@ -6,6 +6,13 @@ set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Explicitly tag optimized configs so runtime defaults can enforce low-noise logging.
add_compile_definitions(
$<$<CONFIG:Release>:WOWEE_RELEASE_LOGGING>
$<$<CONFIG:RelWithDebInfo>:WOWEE_RELEASE_LOGGING>
$<$<CONFIG:MinSizeRel>:WOWEE_RELEASE_LOGGING>
)
# Output directories # Output directories
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)

Binary file not shown.

Binary file not shown.

3
build.bat Normal file
View file

@ -0,0 +1,3 @@
@echo off
REM Convenience wrapper — launches the PowerShell build script.
powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*

44
build.ps1 Normal file
View file

@ -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"

3
debug_texture.bat Normal file
View file

@ -0,0 +1,3 @@
@echo off
REM Convenience wrapper — launches the PowerShell texture debug script.
powershell -ExecutionPolicy Bypass -File "%~dp0debug_texture.ps1" %*

64
debug_texture.ps1 Normal file
View file

@ -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"
}
}

View file

@ -8,8 +8,20 @@ H=${2:-1024}
echo "Converting debug textures (${W}x${H})..." echo "Converting debug textures (${W}x${H})..."
for raw in /tmp/wowee_composite_debug.raw /tmp/wowee_equip_composite_debug.raw; do TMPD="${TMPDIR:-/tmp}"
if [ -f "$raw" ]; then
# 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" png="${raw%.raw}.png"
# Try ImageMagick first, fall back to ffmpeg # Try ImageMagick first, fall back to ffmpeg
if command -v convert &>/dev/null; then if command -v convert &>/dev/null; then
@ -24,7 +36,4 @@ for raw in /tmp/wowee_composite_debug.raw /tmp/wowee_equip_composite_debug.raw;
echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw" echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw"
echo " Install: sudo apt install imagemagick" echo " Install: sudo apt install imagemagick"
fi fi
else
echo "Not found: $raw"
fi
done done

View file

@ -65,6 +65,13 @@ public:
} }
private: private:
static constexpr int kDefaultMinLevelValue =
#if defined(NDEBUG) || defined(WOWEE_RELEASE_LOGGING)
static_cast<int>(LogLevel::WARNING);
#else
static_cast<int>(LogLevel::INFO);
#endif
Logger() = default; Logger() = default;
~Logger() = default; ~Logger() = default;
Logger(const Logger&) = delete; Logger(const Logger&) = delete;
@ -77,22 +84,61 @@ private:
return oss.str(); return oss.str();
} }
std::atomic<int> minLevel_{static_cast<int>(LogLevel::INFO)}; std::atomic<int> minLevel_{kDefaultMinLevelValue};
std::mutex mutex; std::mutex mutex;
std::ofstream fileStream; std::ofstream fileStream;
bool fileReady = false; bool fileReady = false;
bool echoToStdout_ = true; bool echoToStdout_ = true;
std::chrono::steady_clock::time_point lastFlushTime_{}; std::chrono::steady_clock::time_point lastFlushTime_{};
uint32_t flushIntervalMs_ = 250; 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(); void ensureFile();
}; };
// Convenience macros // Convenience macros.
#define LOG_DEBUG(...) wowee::core::Logger::getInstance().debug(__VA_ARGS__) // Guard calls at the macro site so variadic arguments are not evaluated
#define LOG_INFO(...) wowee::core::Logger::getInstance().info(__VA_ARGS__) // when the corresponding level is disabled.
#define LOG_WARNING(...) wowee::core::Logger::getInstance().warning(__VA_ARGS__) #define LOG_DEBUG(...) do { \
#define LOG_ERROR(...) wowee::core::Logger::getInstance().error(__VA_ARGS__) auto& _wowee_logger = wowee::core::Logger::getInstance(); \
#define LOG_FATAL(...) wowee::core::Logger::getInstance().fatal(__VA_ARGS__) if (_wowee_logger.shouldLog(wowee::core::LogLevel::DEBUG)) { \
_wowee_logger.debug(__VA_ARGS__); \
} \
} while (0)
#define LOG_INFO(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::INFO)) { \
_wowee_logger.info(__VA_ARGS__); \
} \
} while (0)
#define LOG_WARNING(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::WARNING)) { \
_wowee_logger.warning(__VA_ARGS__); \
} \
} while (0)
#define LOG_ERROR(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::ERROR)) { \
_wowee_logger.error(__VA_ARGS__); \
} \
} while (0)
#define LOG_FATAL(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::FATAL)) { \
_wowee_logger.fatal(__VA_ARGS__); \
} \
} while (0)
} // namespace core } // namespace core
} // namespace wowee } // namespace wowee

View file

@ -171,6 +171,8 @@ private:
// Cached isInsideWMO result (throttled to avoid per-frame cost) // Cached isInsideWMO result (throttled to avoid per-frame cost)
bool cachedInsideWMO = false; bool cachedInsideWMO = false;
bool cachedInsideInteriorWMO = false; bool cachedInsideInteriorWMO = false;
int insideStateCheckCounter_ = 0;
glm::vec3 lastInsideStateCheckPos_ = glm::vec3(0.0f);
int insideWMOCheckCounter = 0; int insideWMOCheckCounter = 0;
glm::vec3 lastInsideWMOCheckPos = glm::vec3(0.0f); glm::vec3 lastInsideWMOCheckPos = glm::vec3(0.0f);

View file

@ -238,6 +238,7 @@ private:
glm::vec3 shadowCenter = glm::vec3(0.0f); glm::vec3 shadowCenter = glm::vec3(0.0f);
bool shadowCenterInitialized = false; bool shadowCenterInitialized = false;
bool shadowsEnabled = true; bool shadowsEnabled = true;
uint32_t shadowFrameCounter_ = 0;
public: public:

View file

@ -120,6 +120,9 @@ private:
bool musicInitAttempted = false; bool musicInitAttempted = false;
bool musicPlaying = false; bool musicPlaying = false;
bool missingIntroTracksLogged_ = false;
bool introTracksScanned_ = false;
std::vector<std::string> introTracks_;
bool loginMusicVolumeAdjusted_ = false; bool loginMusicVolumeAdjusted_ = false;
int savedMusicVolume_ = 30; int savedMusicVolume_ = 30;
}; };

3
rebuild.bat Normal file
View file

@ -0,0 +1,3 @@
@echo off
REM Convenience wrapper — launches the PowerShell clean rebuild script.
powershell -ExecutionPolicy Bypass -File "%~dp0rebuild.ps1" %*

50
rebuild.ps1 Normal file
View file

@ -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"

View file

@ -296,7 +296,8 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v
const int MAX_ACTIVE_WATER = 3; // Max 3 water sounds at once const int MAX_ACTIVE_WATER = 3; // Max 3 water sounds at once
for (auto& emitter : emitters_) { 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 // Determine max distance based on type
float maxDist = MAX_AMBIENT_DISTANCE; 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 // 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) { if (isFire && withinRange && activeFireCount < MAX_ACTIVE_FIRE) {
emitter.active = true; emitter.active = true;
@ -336,6 +338,9 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v
// Update play timer // Update play timer
emitter.lastPlayTime += deltaTime; 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 // Handle different emitter types
switch (emitter.type) { switch (emitter.type) {
case AmbientType::FIREPLACE_SMALL: case AmbientType::FIREPLACE_SMALL:

View file

@ -1,12 +1,59 @@
#include "auth/auth_packets.hpp" #include "auth/auth_packets.hpp"
#include "core/logger.hpp" #include "core/logger.hpp"
#include "network/net_platform.hpp"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstring> #include <cstring>
#include <array>
namespace wowee { namespace wowee {
namespace auth { namespace auth {
namespace {
bool detectOutboundIPv4(std::array<uint8_t, 4>& 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<sockaddr*>(&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<sockaddr*>(&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<uint8_t>((ip >> 24) & 0xFF);
outIp[1] = static_cast<uint8_t>((ip >> 16) & 0xFF);
outIp[2] = static_cast<uint8_t>((ip >> 8) & 0xFF);
outIp[3] = static_cast<uint8_t>(ip & 0xFF);
return (ip != 0);
}
} // namespace
network::Packet LogonChallengePacket::build(const std::string& account, const ClientInfo& info) { network::Packet LogonChallengePacket::build(const std::string& account, const ClientInfo& info) {
// Convert account to uppercase // Convert account to uppercase
std::string upperAccount = account; std::string upperAccount = account;
@ -66,8 +113,20 @@ network::Packet LogonChallengePacket::build(const std::string& account, const Cl
// Timezone // Timezone
packet.writeUInt32(info.timezone); packet.writeUInt32(info.timezone);
// IP address (always 0) // Client IP: use the real outbound local IPv4 when detectable.
// Fallback to 0.0.0.0 if detection fails.
{
std::array<uint8_t, 4> 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); packet.writeUInt32(0);
LOG_DEBUG("LOGON_CHALLENGE client IP detection failed; using 0.0.0.0 fallback");
}
}
// Account length and name // Account length and name
packet.writeUInt8(static_cast<uint8_t>(upperAccount.length())); packet.writeUInt8(static_cast<uint8_t>(upperAccount.length()));

View file

@ -3,6 +3,7 @@
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <vector>
namespace wowee { namespace wowee {
namespace auth { namespace auth {
@ -41,39 +42,46 @@ bool computeIntegrityHashWin32WithExe(const std::array<uint8_t, 16>& checksumSal
// that distribution rather than a stock 1.12.1 client, so when using Turtle's executable we include // that distribution rather than a stock 1.12.1 client, so when using Turtle's executable we include
// Turtle-specific DLLs as well. // Turtle-specific DLLs as well.
const bool isTurtleExe = (exeName == "TurtleWoW.exe"); const bool isTurtleExe = (exeName == "TurtleWoW.exe");
const char* kFilesBase[] = { // Some macOS client layouts use FMOD dylib naming instead of fmod.dll.
nullptr, // exeName // We accept the first matching filename in each alias group.
"fmod.dll", std::vector<std::vector<std::string>> fileGroups = {
"ijl15.dll", { exeName },
"dbghelp.dll", { "fmod.dll", "fmod.dylib", "libfmod.dylib", "fmodex.dll", "fmodex.dylib", "libfmod.so" },
"unicows.dll", { "ijl15.dll" },
{ "dbghelp.dll" },
{ "unicows.dll" },
}; };
const char* kFilesTurtleExtra[] = {
"twloader.dll",
"twdiscord.dll",
};
std::vector<std::string> 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) { 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<uint8_t> allFiles; std::vector<uint8_t> allFiles;
std::string err; for (const auto& group : fileGroups) {
for (const auto& nameStr : files) { bool foundInGroup = false;
std::string groupErr;
for (const auto& nameStr : group) {
std::vector<uint8_t> bytes; std::vector<uint8_t> bytes;
std::string path = miscDir; std::string path = miscDir;
if (!path.empty() && path.back() != '/') path += '/'; if (!path.empty() && path.back() != '/') path += '/';
path += nameStr; path += nameStr;
std::string err;
if (!readWholeFile(path, bytes, err)) { if (!readWholeFile(path, bytes, err)) {
outError = 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; return false;
} }
allFiles.insert(allFiles.end(), bytes.begin(), bytes.end());
} }
// HMAC_SHA1(checksumSalt, allFiles) // HMAC_SHA1(checksumSalt, allFiles)

View file

@ -4,6 +4,8 @@
#include <ctime> #include <ctime>
#include <filesystem> #include <filesystem>
#include <cstdlib> #include <cstdlib>
#include <algorithm>
#include <cctype>
namespace wowee { namespace wowee {
namespace core { namespace core {
@ -28,20 +30,35 @@ void Logger::ensureFile() {
flushIntervalMs_ = static_cast<uint32_t>(parsed); flushIntervalMs_ = static_cast<uint32_t>(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<uint32_t>(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<char>(std::tolower(c));
});
if (v == "debug") setLogLevel(LogLevel::DEBUG);
else if (v == "info") setLogLevel(LogLevel::INFO);
else if (v == "warn" || v == "warning") setLogLevel(LogLevel::WARNING);
else if (v == "error") setLogLevel(LogLevel::ERROR);
else if (v == "fatal") setLogLevel(LogLevel::FATAL);
}
std::error_code ec; std::error_code ec;
std::filesystem::create_directories("logs", ec); std::filesystem::create_directories("logs", ec);
fileStream.open("logs/wowee.log", std::ios::out | std::ios::trunc); fileStream.open("logs/wowee.log", std::ios::out | std::ios::trunc);
lastFlushTime_ = std::chrono::steady_clock::now(); lastFlushTime_ = std::chrono::steady_clock::now();
} }
void Logger::log(LogLevel level, const std::string& message) { void Logger::emitLineLocked(LogLevel level, const std::string& message) {
if (!shouldLog(level)) {
return;
}
std::lock_guard<std::mutex> lock(mutex);
ensureFile();
// Get current time // Get current time
auto now = std::chrono::system_clock::now(); auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now); auto time = std::chrono::system_clock::to_time_t(now);
@ -92,6 +109,38 @@ void Logger::log(LogLevel level, const std::string& message) {
} }
} }
void Logger::flushSuppressedLocked() {
if (suppressedCount_ == 0) return;
emitLineLocked(lastLevel_, "Previous message repeated " + std::to_string(suppressedCount_) + " times");
suppressedCount_ = 0;
}
void Logger::log(LogLevel level, const std::string& message) {
if (!shouldLog(level)) {
return;
}
std::lock_guard<std::mutex> lock(mutex);
ensureFile();
auto nowSteady = std::chrono::steady_clock::now();
if (dedupeEnabled_ && !lastMessage_.empty() &&
level == lastLevel_ && message == lastMessage_) {
auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(nowSteady - lastMessageTime_).count();
if (elapsedMs >= 0 && elapsedMs <= static_cast<long long>(dedupeWindowMs_)) {
++suppressedCount_;
lastMessageTime_ = nowSteady;
return;
}
}
flushSuppressedLocked();
emitLineLocked(level, message);
lastLevel_ = level;
lastMessage_ = message;
lastMessageTime_ = nowSteady;
}
void Logger::setLogLevel(LogLevel level) { void Logger::setLogLevel(LogLevel level) {
minLevel_.store(static_cast<int>(level), std::memory_order_relaxed); minLevel_.store(static_cast<int>(level), std::memory_order_relaxed);
} }

View file

@ -1235,7 +1235,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
++wardenPacketsAfterGate_; ++wardenPacketsAfterGate_;
} }
if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) { 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), " state=", worldStateName(state),
" size=", packet.getSize()); " size=", packet.getSize());
} }
@ -3462,7 +3462,7 @@ bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) {
for (int i = 0; i < 9; i++) { for (int i = 0; i < 9; i++) {
char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s; 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<size_t>(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE; size_t entryCount = (static_cast<size_t>(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE;
@ -3512,17 +3512,20 @@ void GameHandler::handleWardenData(network::Packet& packet) {
// Decrypt the payload // Decrypt the payload
std::vector<uint8_t> decrypted = wardenCrypto_->decrypt(data); std::vector<uint8_t> 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; std::string hex;
size_t logSize = std::min(decrypted.size(), size_t(256)); size_t logSize = std::min(decrypted.size(), size_t(256));
hex.reserve(logSize * 3); hex.reserve(logSize * 3);
for (size_t i = 0; i < logSize; ++i) { 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)"; 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()) { if (decrypted.empty()) {
@ -3541,7 +3544,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
} }
if (socket && socket->isConnected()) { if (socket && socket->isConnected()) {
socket->send(response); 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; std::string hashHex;
for (auto b : wardenModuleHash_) { char s[4]; snprintf(s, 4, "%02x", b); hashHex += s; } 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 // Try to load pre-computed challenge/response entries
loadWardenCRFile(hashHex); loadWardenCRFile(hashHex);
@ -3574,7 +3577,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
std::vector<uint8_t> resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING std::vector<uint8_t> resp = { 0x00 }; // WARDEN_CMSG_MODULE_MISSING
sendWardenResponse(resp); sendWardenResponse(resp);
wardenState_ = WardenState::WAIT_MODULE_CACHE; 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; break;
} }
@ -3598,7 +3601,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
decrypted.begin() + 3, decrypted.begin() + 3,
decrypted.begin() + 3 + chunkSize); 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_); wardenModuleData_.size(), "/", wardenModuleSize_);
// Check if module download is complete // Check if module download is complete
@ -3627,7 +3630,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
std::ofstream wf(cachePath, std::ios::binary); std::ofstream wf(cachePath, std::ios::binary);
if (wf) { if (wf) {
wf.write(reinterpret_cast<const char*>(wardenModuleData_.data()), wardenModuleData_.size()); wf.write(reinterpret_cast<const char*>(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) // Send MODULE_OK (opcode 0x01)
std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK std::vector<uint8_t> resp = { 0x01 }; // WARDEN_CMSG_MODULE_OK
sendWardenResponse(resp); sendWardenResponse(resp);
LOG_INFO("Warden: Sent MODULE_OK"); LOG_DEBUG("Warden: Sent MODULE_OK");
} }
// No response for intermediate chunks // No response for intermediate chunks
break; break;
@ -3670,7 +3673,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
} }
if (match) { 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 // Log the reply we're sending
{ {
@ -3678,7 +3681,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
for (int i = 0; i < 20; i++) { for (int i = 0; i < 20; i++) {
char s[4]; snprintf(s, 4, "%02x", match->reply[i]); replyHex += s; 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) // Send HASH_RESULT (opcode 0x04 + 20-byte reply)
@ -3693,7 +3696,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
std::vector<uint8_t> newDecryptKey(match->serverKey, match->serverKey + 16); std::vector<uint8_t> newDecryptKey(match->serverKey, match->serverKey + 16);
wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey); wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey);
LOG_INFO("Warden: Switched to CR key set"); LOG_DEBUG("Warden: Switched to CR key set");
wardenState_ = WardenState::WAIT_CHECKS; wardenState_ = WardenState::WAIT_CHECKS;
break; break;
@ -3721,7 +3724,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
const auto& firstCR = wardenCREntries_[0]; const auto& firstCR = wardenCREntries_[0];
std::string expectedHex; std::string expectedHex;
for (int i = 0; i < 20; i++) { char s[4]; snprintf(s, 4, "%02x", firstCR.reply[i]); expectedHex += s; } 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) // Test 1: SHA1(moduleImage)
{ {
@ -3729,7 +3732,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
auto h = auth::Crypto::sha1(data); auto h = auth::Crypto::sha1(data);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); 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; } 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) // Test 2: SHA1(seed || moduleImage)
{ {
@ -3739,7 +3742,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
auto h = auth::Crypto::sha1(data); auto h = auth::Crypto::sha1(data);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); 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; } 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) // Test 3: SHA1(moduleImage || seed)
{ {
@ -3748,21 +3751,21 @@ void GameHandler::handleWardenData(network::Packet& packet) {
auto h = auth::Crypto::sha1(data); auto h = auth::Crypto::sha1(data);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); 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; } 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) // Test 4: SHA1(decompressedData)
{ {
auto h = auth::Crypto::sha1(decompressedData); auto h = auth::Crypto::sha1(decompressedData);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); 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; } 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) // Test 5: SHA1(rawModuleData)
{ {
auto h = auth::Crypto::sha1(wardenModuleData_); auto h = auth::Crypto::sha1(wardenModuleData_);
bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); 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; } 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) // Test 6: Check if all CR replies are the same (constant hash)
{ {
@ -3773,7 +3776,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
break; 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; std::string hex;
for (auto b : reply) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } 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) // Send HASH_RESULT (opcode 0x04 + 20-byte hash)
@ -3807,7 +3810,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
wardenCrypto_->replaceKeys(ek, dk); wardenCrypto_->replaceKeys(ek, dk);
for (auto& b : newEncryptKey) b = 0; for (auto& b : newEncryptKey) b = 0;
for (auto& b : newDecryptKey) 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; wardenState_ = WardenState::WAIT_CHECKS;
@ -3815,7 +3818,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
} }
case 0x02: { // WARDEN_SMSG_CHEAT_CHECKS_REQUEST 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) { if (decrypted.size() < 3) {
LOG_ERROR("Warden: CHEAT_CHECKS_REQUEST too short"); LOG_ERROR("Warden: CHEAT_CHECKS_REQUEST too short");
@ -3833,14 +3836,14 @@ void GameHandler::handleWardenData(network::Packet& packet) {
strings.emplace_back(reinterpret_cast<const char*>(decrypted.data() + pos), slen); strings.emplace_back(reinterpret_cast<const char*>(decrypted.data() + pos), slen);
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++) { 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 // XOR byte is the last byte of the packet
uint8_t xorByte = decrypted.back(); 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 // Check type enum indices
enum CheckType { CT_MEM=0, CT_PAGE_A=1, CT_PAGE_B=2, CT_MPQ=3, CT_LUA=4, 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++; pos++;
checkCount++; checkCount++;
LOG_INFO("Warden: Check #", checkCount, " type=", checkTypeNames[ct], LOG_DEBUG("Warden: Check #", checkCount, " type=", checkTypeNames[ct],
" at offset ", pos - 1); " at offset ", pos - 1);
switch (ct) { switch (ct) {
@ -3984,10 +3987,10 @@ void GameHandler::handleWardenData(network::Packet& packet) {
| (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24); | (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24);
pos += 4; pos += 4;
uint8_t readLen = decrypted[pos++]; 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); " len=", (int)readLen);
if (!moduleName.empty()) { 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 // 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) // Read bytes from PE image (includes patched runtime globals)
std::vector<uint8_t> memBuf(readLen, 0); std::vector<uint8_t> memBuf(readLen, 0);
if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) { 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 { } else {
LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x", LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x",
[&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}()); [&]{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 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);}()); " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
pos += consume; pos += consume;
resultData.push_back(pageResult); resultData.push_back(pageResult);
@ -4093,7 +4096,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
pageResult = 0x4A; // PatternFound 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);}()); " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}());
pos += consume; pos += consume;
resultData.push_back(pageResult); resultData.push_back(pageResult);
@ -4104,7 +4107,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
if (pos + 1 > checkEnd) { pos = checkEnd; break; } if (pos + 1 > checkEnd) { pos = checkEnd; break; }
uint8_t strIdx = decrypted[pos++]; uint8_t strIdx = decrypted[pos++];
std::string filePath = resolveWardenString(strIdx); std::string filePath = resolveWardenString(strIdx);
LOG_INFO("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); LOG_DEBUG("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\"");
bool found = false; bool found = false;
std::vector<uint8_t> hash(20, 0); std::vector<uint8_t> hash(20, 0);
@ -4150,7 +4153,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
if (pos + 1 > checkEnd) { pos = checkEnd; break; } if (pos + 1 > checkEnd) { pos = checkEnd; break; }
uint8_t strIdx = decrypted[pos++]; uint8_t strIdx = decrypted[pos++];
std::string luaVar = resolveWardenString(strIdx); 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] // Response: [uint8 result=0][uint16 len=0]
// Lua string doesn't exist // Lua string doesn't exist
resultData.push_back(0x01); // not found resultData.push_back(0x01); // not found
@ -4162,7 +4165,7 @@ void GameHandler::handleWardenData(network::Packet& packet) {
pos += 24; // skip seed + sha1 pos += 24; // skip seed + sha1
uint8_t strIdx = decrypted[pos++]; uint8_t strIdx = decrypted[pos++];
std::string driverName = resolveWardenString(strIdx); 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) // Response: [uint8 result=1] (driver NOT found = clean)
resultData.push_back(0x01); resultData.push_back(0x01);
break; 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) --- // --- Compute checksum: XOR of 5 uint32s from SHA1(resultData) ---
auto resultHash = auth::Crypto::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.push_back((checksum >> 24) & 0xFF);
resp.insert(resp.end(), resultData.begin(), resultData.end()); resp.insert(resp.end(), resultData.begin(), resultData.end());
sendWardenResponse(resp); 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", checkCount, " checks, checksum=0x",
[&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")"); [&]{char s[12];snprintf(s,12,"%08x",checksum);return std::string(s);}(), ")");
break; break;
} }
case 0x03: // WARDEN_SMSG_MODULE_INITIALIZE 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; break;
default: 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(), ")"); " (state=", (int)wardenState_, ", size=", decrypted.size(), ")");
break; break;
} }

View file

@ -529,78 +529,165 @@ bool WardenModule::parseExecutableFormat(const std::vector<uint8_t>& exeData) {
std::cout << "[WardenModule] Allocated " << moduleSize_ << " bytes of executable memory at " std::cout << "[WardenModule] Allocated " << moduleSize_ << " bytes of executable memory at "
<< moduleMemory_ << '\n'; << moduleMemory_ << '\n';
// Parse copy/skip pairs (MaNGOS/TrinityCore format) auto readU16LE = [&](size_t at) -> uint16_t {
// Format: repeated [2B copy_count][copy_count bytes data][2B skip_count] return static_cast<uint16_t>(exeData[at] | (exeData[at + 1] << 8));
// 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 enum class PairFormat {
CopyDataSkip, // [copy][data][skip]
SkipCopyData, // [skip][copy][data]
CopySkipData // [copy][skip][data]
};
auto tryParsePairs = [&](PairFormat format,
std::vector<uint8_t>& 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; size_t destOffset = 0;
int pairCount = 0; int pairCount = 0;
while (pos + 2 <= exeData.size()) { while (pos + 2 <= exeData.size()) {
// Read copy count (2 bytes LE) uint16_t copyCount = 0;
uint16_t copyCount = exeData[pos] | (exeData[pos + 1] << 8); uint16_t skipCount = 0;
switch (format) {
case PairFormat::CopyDataSkip: {
copyCount = readU16LE(pos);
pos += 2; pos += 2;
if (copyCount == 0) { if (copyCount == 0) {
break; // End of copy/skip pairs relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
} }
if (copyCount > 0) { if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
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; return false;
} }
if (destOffset + copyCount > moduleSize_) { std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
std::cerr << "[WardenModule] Copy section exceeds module size" << '\n';
#ifdef _WIN32
VirtualFree(moduleMemory_, 0, MEM_RELEASE);
#else
munmap(moduleMemory_, moduleSize_);
#endif
moduleMemory_ = nullptr;
return false;
}
std::memcpy(
static_cast<uint8_t*>(moduleMemory_) + destOffset,
exeData.data() + pos,
copyCount
);
pos += copyCount; pos += copyCount;
destOffset += copyCount; destOffset += copyCount;
}
// Read skip count (2 bytes LE) if (pos + 2 > exeData.size()) {
uint16_t skipCount = 0; return false;
if (pos + 2 <= exeData.size()) { }
skipCount = exeData[pos] | (exeData[pos + 1] << 8); skipCount = readU16LE(pos);
pos += 2; pos += 2;
break;
} }
// Advance dest pointer by skipCount (gaps are zero-filled from memset) 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; destOffset += skipCount;
pairCount++; if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
std::cout << "[WardenModule] Pair " << pairCount << ": copy " << copyCount return false;
<< ", skip " << skipCount << " (dest offset=" << destOffset << ")" << '\n'; }
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
break;
} }
// Save position — remaining decompressed data contains relocation entries case PairFormat::CopySkipData: {
relocDataOffset_ = pos; if (pos + 4 > exeData.size()) {
return false;
}
copyCount = readU16LE(pos);
pos += 2;
skipCount = readU16LE(pos);
pos += 2;
std::cout << "[WardenModule] Parsed " << pairCount << " skip/copy pairs, final offset: " if (copyCount == 0 && skipCount == 0) {
<< destOffset << "/" << finalCodeSize << '\n'; 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 + skipCount > moduleSize_) {
return false;
}
destOffset += skipCount;
pairCount++;
}
return false;
};
std::vector<uint8_t> parsedImage;
size_t parsedRelocPos = 0;
size_t parsedFinalOffset = 0;
int parsedPairCount = 0;
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);
}
if (parsed) {
std::memcpy(moduleMemory_, parsedImage.data(), parsedImage.size());
relocDataOffset_ = parsedRelocPos;
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_ std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_
<< " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n'; << " (" << (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; return true;
} }

View file

@ -65,6 +65,23 @@ std::optional<float> selectClosestFloor(const std::optional<float>& a,
return std::nullopt; return std::nullopt;
} }
std::optional<float> selectReachableFloor3(const std::optional<float>& a,
const std::optional<float>& b,
const std::optional<float>& c,
float refZ,
float maxStepUp) {
std::optional<float> best;
auto consider = [&](const std::optional<float>& h) {
if (!h) return;
if (*h > refZ + maxStepUp) return;
if (!best || *h > *best) best = *h;
};
consider(a);
consider(b);
consider(c);
return best;
}
} // namespace } // namespace
CameraController::CameraController(Camera* cam) : camera(cam) { CameraController::CameraController(Camera* cam) : camera(cam) {
@ -126,6 +143,8 @@ void CameraController::update(float deltaTime) {
if (!enabled || !camera) { if (!enabled || !camera) {
return; 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. // During taxi flights, skip movement logic but keep camera orbit/zoom controls.
if (externalFollow_) { if (externalFollow_) {
@ -360,6 +379,7 @@ void CameraController::update(float deltaTime) {
if (thirdPerson && followTarget) { if (thirdPerson && followTarget) {
// Move the follow target (character position) instead of the camera // Move the follow target (character position) instead of the camera
glm::vec3 targetPos = *followTarget; glm::vec3 targetPos = *followTarget;
const glm::vec3 prevTargetPos = *followTarget;
if (!externalFollow_) { if (!externalFollow_) {
if (wmoRenderer) { if (wmoRenderer) {
wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON); wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON);
@ -403,6 +423,68 @@ void CameraController::update(float deltaTime) {
float depthFromFeet = (*waterH - targetPos.z); float depthFromFeet = (*waterH - targetPos.z);
inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) || inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) ||
(!floorH && (depthFromFeet >= 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<float> {
std::optional<float> 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<float> 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. // Keep swimming through water-data gaps at chunk boundaries.
@ -442,7 +524,7 @@ void CameraController::update(float deltaTime) {
if (glm::length(swimMove) > 0.001f) { if (glm::length(swimMove) > 0.001f) {
swimMove = glm::normalize(swimMove); swimMove = glm::normalize(swimMove);
targetPos += swimMove * swimSpeed * deltaTime; targetPos += swimMove * swimSpeed * physicsDeltaTime;
} }
// Spacebar = swim up (continuous, not a jump) // Spacebar = swim up (continuous, not a jump)
@ -451,7 +533,7 @@ void CameraController::update(float deltaTime) {
verticalVelocity = SWIM_BUOYANCY; verticalVelocity = SWIM_BUOYANCY;
} else { } else {
// Gentle sink when not pressing space // Gentle sink when not pressing space
verticalVelocity += SWIM_GRAVITY * deltaTime; verticalVelocity += SWIM_GRAVITY * physicsDeltaTime;
if (verticalVelocity < SWIM_SINK_SPEED) { if (verticalVelocity < SWIM_SINK_SPEED) {
verticalVelocity = SWIM_SINK_SPEED; verticalVelocity = SWIM_SINK_SPEED;
} }
@ -459,15 +541,15 @@ void CameraController::update(float deltaTime) {
// you afloat unless you're intentionally diving. // you afloat unless you're intentionally diving.
if (!diveIntent) { if (!diveIntent) {
float surfaceErr = (waterSurfaceZ - targetPos.z); float surfaceErr = (waterSurfaceZ - targetPos.z);
verticalVelocity += surfaceErr * 7.0f * deltaTime; verticalVelocity += surfaceErr * 7.0f * physicsDeltaTime;
verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime); verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * physicsDeltaTime);
if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) { if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) {
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
} }
} }
} }
targetPos.z += verticalVelocity * deltaTime; targetPos.z += verticalVelocity * physicsDeltaTime;
// Don't rise above water surface // Don't rise above water surface
if (waterH && targetPos.z > *waterH - WATER_SURFACE_OFFSET) { if (waterH && targetPos.z > *waterH - WATER_SURFACE_OFFSET) {
@ -486,17 +568,42 @@ void CameraController::update(float deltaTime) {
if (updateFloorCache) { if (updateFloorCache) {
floorQueryFrameCounter = 0; floorQueryFrameCounter = 0;
lastFloorQueryPos = targetPos; 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<float> ceilingH;
auto considerFloor = [&](const std::optional<float>& 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) { if (terrainManager) {
floorH = terrainManager->getHeightAt(targetPos.x, targetPos.y); considerFloor(terrainManager->getHeightAt(targetPos.x, targetPos.y));
} }
if (wmoRenderer) { if (wmoRenderer) {
auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f); auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f);
if (wh && (!floorH || *wh > *floorH)) floorH = wh; considerFloor(wh);
} }
if (m2Renderer && !externalFollow_) { if (m2Renderer && !externalFollow_) {
auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z); auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f);
if (mh && (!floorH || *mh > *floorH)) floorH = mh; considerFloor(mh);
}
if (ceilingH && verticalVelocity > 0.0f) {
float ceilingLimit = *ceilingH - 0.35f;
if (targetPos.z > ceilingLimit) {
targetPos.z = ceilingLimit;
verticalVelocity = 0.0f;
}
} }
cachedFloorHeight = floorH; cachedFloorHeight = floorH;
@ -557,7 +664,7 @@ void CameraController::update(float deltaTime) {
if (glm::length(movement) > 0.001f) { if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement); movement = glm::normalize(movement);
targetPos += movement * speed * deltaTime; targetPos += movement * speed * physicsDeltaTime;
} }
// Jump with input buffering and coyote time // Jump with input buffering and coyote time
@ -572,12 +679,12 @@ void CameraController::update(float deltaTime) {
coyoteTimer = 0.0f; coyoteTimer = 0.0f;
} }
jumpBufferTimer -= deltaTime; jumpBufferTimer -= physicsDeltaTime;
coyoteTimer -= deltaTime; coyoteTimer -= physicsDeltaTime;
// Apply gravity // Apply gravity
verticalVelocity += gravity * deltaTime; verticalVelocity += gravity * physicsDeltaTime;
targetPos.z += verticalVelocity * deltaTime; targetPos.z += verticalVelocity * physicsDeltaTime;
} }
} else { } else {
// External follow (e.g., taxi): trust server position without grounding. // External follow (e.g., taxi): trust server position without grounding.
@ -589,16 +696,23 @@ void CameraController::update(float deltaTime) {
// Refresh inside-WMO state before collision/grounding so we don't use stale // Refresh inside-WMO state before collision/grounding so we don't use stale
// terrain-first caches while entering enclosed tunnel/building spaces. // terrain-first caches while entering enclosed tunnel/building spaces.
if (wmoRenderer && !externalFollow_) { if (wmoRenderer && !externalFollow_) {
const float insideDist = glm::length(targetPos - lastInsideStateCheckPos_);
if (++insideStateCheckCounter_ >= 2 || insideDist > 0.35f) {
insideStateCheckCounter_ = 0;
lastInsideStateCheckPos_ = targetPos;
bool prevInside = cachedInsideWMO; bool prevInside = cachedInsideWMO;
bool prevInsideInterior = cachedInsideInteriorWMO; bool prevInsideInterior = cachedInsideInteriorWMO;
cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr);
cachedInsideInteriorWMO = wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f); cachedInsideInteriorWMO = cachedInsideWMO &&
wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f);
if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) { if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) {
hasCachedFloor_ = false; hasCachedFloor_ = false;
hasCachedCamFloor = false; hasCachedCamFloor = false;
cachedPivotLift_ = 0.0f; cachedPivotLift_ = 0.0f;
} }
} }
}
// Sweep collisions in small steps to reduce tunneling through thin walls/floors. // Sweep collisions in small steps to reduce tunneling through thin walls/floors.
// Skip entirely when stationary to avoid wasting collision calls. // Skip entirely when stationary to avoid wasting collision calls.
@ -654,15 +768,17 @@ void CameraController::update(float deltaTime) {
// WMO tunnel/bridge ramps are often steeper than outdoor terrain ramps. // 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_TERRAIN = 0.7f; // ~45°
constexpr float MIN_WALKABLE_NORMAL_WMO = 0.45f; // allow tunnel ramps 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<float> groundH; std::optional<float> groundH;
std::optional<float> centerTerrainH; std::optional<float> centerTerrainH;
std::optional<float> centerWmoH; std::optional<float> centerWmoH;
std::optional<float> centerM2H;
{ {
// Collision cache: skip expensive checks if barely moved (15cm threshold) // Collision cache: skip expensive checks if barely moved (15cm threshold)
float distMoved = glm::length(glm::vec2(targetPos.x, targetPos.y) - float distMoved = glm::length(glm::vec2(targetPos.x, targetPos.y) -
glm::vec2(lastCollisionCheckPos_.x, lastCollisionCheckPos_.y)); glm::vec2(lastCollisionCheckPos_.x, lastCollisionCheckPos_.y));
bool useCached = hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE; bool useCached = grounded && hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE;
if (useCached) { if (useCached) {
// Never trust cached ground while actively descending or when // Never trust cached ground while actively descending or when
// vertical drift from cached floor is meaningful. // vertical drift from cached floor is meaningful.
@ -678,6 +794,7 @@ void CameraController::update(float deltaTime) {
// Full collision check // Full collision check
std::optional<float> terrainH; std::optional<float> terrainH;
std::optional<float> wmoH; std::optional<float> wmoH;
std::optional<float> m2H;
if (terrainManager) { if (terrainManager) {
terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y); terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y);
} }
@ -689,6 +806,13 @@ void CameraController::update(float deltaTime) {
if (wmoRenderer) { if (wmoRenderer) {
wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &wmoNormalZ); 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 // Reject steep WMO slopes
float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN; float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN;
@ -704,6 +828,7 @@ void CameraController::update(float deltaTime) {
} }
centerTerrainH = terrainH; centerTerrainH = terrainH;
centerWmoH = wmoH; centerWmoH = wmoH;
centerM2H = m2H;
// Guard against extremely bad WMO void ramps, but keep normal tunnel // Guard against extremely bad WMO void ramps, but keep normal tunnel
// transitions valid. Only reject when the WMO sample is implausibly far // 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. // to avoid oscillating between top terrain and deep WMO floors.
groundH = selectClosestFloor(terrainH, wmoH, targetPos.z); groundH = selectClosestFloor(terrainH, wmoH, targetPos.z);
} else { } else {
groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); groundH = selectReachableFloor3(terrainH, wmoH, m2H, targetPos.z, stepUpBudget);
} }
} else { } else {
groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); groundH = selectReachableFloor3(terrainH, wmoH, m2H, targetPos.z, stepUpBudget);
} }
// Update cache // Update cache
@ -759,13 +884,29 @@ void CameraController::update(float deltaTime) {
// Transition safety: if no reachable floor was selected, choose the higher // Transition safety: if no reachable floor was selected, choose the higher
// of terrain/WMO center surfaces when it is still near the player. // of terrain/WMO center surfaces when it is still near the player.
// This avoids dropping into void gaps at terrain<->WMO seams. // 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) { if (!groundH) {
auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, std::nullopt); auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, centerM2H);
if (highestCenter) { if (highestCenter) {
float dz = targetPos.z - *highestCenter; float dz = targetPos.z - *highestCenter;
// Keep this fallback narrow: only for WMO seam cases, or very short // Keep this fallback narrow: only for WMO seam cases, or very short
// transient misses while still almost touching the last floor. // 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) { if (allowFallback && dz >= -0.5f && dz < 2.0f) {
groundH = highestCenter; groundH = highestCenter;
} }
@ -774,7 +915,7 @@ void CameraController::update(float deltaTime) {
// Continuity guard only for WMO seam overlap: avoid instantly switching to a // 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). // 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; float dropFromLast = lastGroundZ - *groundH;
if (dropFromLast > 1.5f) { if (dropFromLast > 1.5f) {
if (centerTerrainH && *centerTerrainH > *groundH + 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 // 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. // 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 maxDropPerFrame = (verticalVelocity < -8.0f) ? 2.0f : 0.60f;
float minAllowed = lastGroundZ - maxDropPerFrame; float minAllowed = lastGroundZ - maxDropPerFrame;
// Extra seam guard: outside interior groups, avoid accepting floors that // 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 // 1b. Multi-sample WMO floors when in/near WMO space to avoid
// falling through narrow board/plank gaps where center ray misses. // falling through narrow board/plank gaps where center ray misses.
if (wmoRenderer && cachedInsideWMO) { if (wmoRenderer && nearWmoSpace) {
constexpr float WMO_FOOTPRINT = 0.35f; constexpr float WMO_FOOTPRINT = 0.35f;
const glm::vec2 wmoOffsets[] = { const glm::vec2 wmoOffsets[] = {
{0.0f, 0.0f}, {0.0f, 0.0f},
@ -827,7 +978,7 @@ void CameraController::update(float deltaTime) {
// Keep to nearby, walkable steps only. // Keep to nearby, walkable steps only.
if (*wh > targetPos.z + stepUpBudget) continue; if (*wh > targetPos.z + stepUpBudget) continue;
if (*wh < targetPos.z - 2.5f) continue; if (*wh < lastGroundZ - 3.5f) continue;
if (!groundH || *wh > *groundH) { if (!groundH || *wh > *groundH) {
groundH = wh; 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<float> 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<float> 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<float> 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) — // 2. Multi-sample for M2 objects (rugs, planks, bridges, ships) —
// these are narrow and need offset probes to detect reliably. // these are narrow and need offset probes to detect reliably.
if (m2Renderer && !externalFollow_) { if (m2Renderer && !externalFollow_) {
constexpr float FOOTPRINT = 0.4f; constexpr float FOOTPRINT = 0.6f;
const glm::vec2 offsets[] = { const glm::vec2 offsets[] = {
{0.0f, 0.0f}, {0.0f, 0.0f},
{FOOTPRINT, 0.0f}, {-FOOTPRINT, 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; float m2ProbeZ = std::max(targetPos.z, lastGroundZ) + 6.0f;
for (const auto& o : offsets) { for (const auto& o : offsets) {
@ -895,15 +1144,33 @@ void CameraController::update(float deltaTime) {
} }
} else { } else {
hasRealGround_ = false; hasRealGround_ = false;
noGroundTimer_ += deltaTime; noGroundTimer_ += physicsDeltaTime;
float dropFromLastGround = lastGroundZ - targetPos.z; float dropFromLastGround = lastGroundZ - targetPos.z;
bool seamSizedGap = dropFromLastGround <= 0.35f; bool seamSizedGap = dropFromLastGround <= (nearStructureSpace ? 2.5f : 0.35f);
if (noGroundTimer_ < NO_GROUND_GRACE && seamSizedGap) { if (noGroundTimer_ < NO_GROUND_GRACE && seamSizedGap) {
// Micro-gap grace only: keep continuity for tiny seam misses, // Near WMO floors, prefer continuity over falling on transient
// but never convert air into persistent ground. // floor-query misses (stairs/planks/portal seams).
targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f); 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; 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 { } else {
grounded = false; grounded = false;
} }
@ -918,7 +1185,7 @@ void CameraController::update(float deltaTime) {
// Player is safely on real geometry — save periodically // Player is safely on real geometry — save periodically
continuousFallTime_ = 0.0f; continuousFallTime_ = 0.0f;
autoUnstuckFired_ = false; autoUnstuckFired_ = false;
safePosSaveTimer_ += deltaTime; safePosSaveTimer_ += physicsDeltaTime;
if (safePosSaveTimer_ >= SAFE_POS_SAVE_INTERVAL) { if (safePosSaveTimer_ >= SAFE_POS_SAVE_INTERVAL) {
safePosSaveTimer_ = 0.0f; safePosSaveTimer_ = 0.0f;
lastSafePos_ = targetPos; lastSafePos_ = targetPos;
@ -926,7 +1193,7 @@ void CameraController::update(float deltaTime) {
} }
} else if (!grounded && !swimming && !externalFollow_) { } else if (!grounded && !swimming && !externalFollow_) {
// Falling (or standing on nothing past grace period) — accumulate fall time // Falling (or standing on nothing past grace period) — accumulate fall time
continuousFallTime_ += deltaTime; continuousFallTime_ += physicsDeltaTime;
if (continuousFallTime_ >= AUTO_UNSTUCK_FALL_TIME && !autoUnstuckFired_) { if (continuousFallTime_ >= AUTO_UNSTUCK_FALL_TIME && !autoUnstuckFired_) {
autoUnstuckFired_ = true; autoUnstuckFired_ = true;
if (autoUnstuckCallback_) { if (autoUnstuckCallback_) {
@ -1179,27 +1446,27 @@ void CameraController::update(float deltaTime) {
if (glm::length(movement) > 0.001f) { if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement); movement = glm::normalize(movement);
newPos += movement * swimSpeed * deltaTime; newPos += movement * swimSpeed * physicsDeltaTime;
} }
if (nowJump) { if (nowJump) {
verticalVelocity = SWIM_BUOYANCY; verticalVelocity = SWIM_BUOYANCY;
} else { } else {
verticalVelocity += SWIM_GRAVITY * deltaTime; verticalVelocity += SWIM_GRAVITY * physicsDeltaTime;
if (verticalVelocity < SWIM_SINK_SPEED) { if (verticalVelocity < SWIM_SINK_SPEED) {
verticalVelocity = SWIM_SINK_SPEED; verticalVelocity = SWIM_SINK_SPEED;
} }
if (!diveIntent) { if (!diveIntent) {
float surfaceErr = (waterSurfaceCamZ - newPos.z); float surfaceErr = (waterSurfaceCamZ - newPos.z);
verticalVelocity += surfaceErr * 7.0f * deltaTime; verticalVelocity += surfaceErr * 7.0f * physicsDeltaTime;
verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime); verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * physicsDeltaTime);
if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) { if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) {
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
} }
} }
} }
newPos.z += verticalVelocity * deltaTime; newPos.z += verticalVelocity * physicsDeltaTime;
// Don't rise above water surface (feet at water level) // Don't rise above water surface (feet at water level)
if (waterH && (newPos.z - eyeHeight) > *waterH - WATER_SURFACE_OFFSET) { if (waterH && (newPos.z - eyeHeight) > *waterH - WATER_SURFACE_OFFSET) {
@ -1213,7 +1480,7 @@ void CameraController::update(float deltaTime) {
if (glm::length(movement) > 0.001f) { if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement); movement = glm::normalize(movement);
newPos += movement * speed * deltaTime; newPos += movement * speed * physicsDeltaTime;
} }
// Jump with input buffering and coyote time // Jump with input buffering and coyote time
@ -1227,12 +1494,12 @@ void CameraController::update(float deltaTime) {
coyoteTimer = 0.0f; coyoteTimer = 0.0f;
} }
jumpBufferTimer -= deltaTime; jumpBufferTimer -= physicsDeltaTime;
coyoteTimer -= deltaTime; coyoteTimer -= physicsDeltaTime;
// Apply gravity // Apply gravity
verticalVelocity += gravity * deltaTime; verticalVelocity += gravity * physicsDeltaTime;
newPos.z += verticalVelocity * deltaTime; newPos.z += verticalVelocity * physicsDeltaTime;
} }
// Wall sweep collision before grounding (skip when stationary). // Wall sweep collision before grounding (skip when stationary).

View file

@ -812,10 +812,10 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector<std::string>&
} }
} }
// 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::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" +
std::to_string(width) + "x" + std::to_string(height) + ".raw"; std::to_string(width) + "x" + std::to_string(height) + ".raw")).string();
std::ofstream dump(dumpPath, std::ios::binary); std::ofstream dump(dumpPath, std::ios::binary);
if (dump) { if (dump) {
dump.write(reinterpret_cast<const char*>(composite.data()), dump.write(reinterpret_cast<const char*>(composite.data()),
@ -1310,8 +1310,9 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId,
} }
void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
// Distance culling for animation updates (150 unit radius) // Distance culling for animation updates in dense areas.
const float animUpdateRadiusSq = 150.0f * 150.0f; const float animUpdateRadius = static_cast<float>(envSizeOrDefault("WOWEE_CHAR_ANIM_RADIUS", 120));
const float animUpdateRadiusSq = animUpdateRadius * animUpdateRadius;
// Update fade-in opacity // Update fade-in opacity
for (auto& [id, inst] : instances) { for (auto& [id, inst] : instances) {
@ -1404,6 +1405,7 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
for (auto& pair : instances) { for (auto& pair : instances) {
auto& instance = pair.second; auto& instance = pair.second;
if (instance.weaponAttachments.empty()) continue; if (instance.weaponAttachments.empty()) continue;
if (glm::distance2(instance.position, cameraPos) > animUpdateRadiusSq) continue;
glm::mat4 charModelMat = instance.hasOverrideModelMatrix glm::mat4 charModelMat = instance.hasOverrideModelMatrix
? instance.overrideModelMatrix ? instance.overrideModelMatrix
@ -1614,6 +1616,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
if (instances.empty() || !opaquePipeline_) { if (instances.empty() || !opaquePipeline_) {
return; return;
} }
const float renderRadius = static_cast<float>(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 frameIndex = vkCtx_->getCurrentFrame();
uint32_t frameSlot = frameIndex % 2u; 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) // Skip invisible instances (e.g., player in first-person mode)
if (!instance.visible) continue; 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); auto modelIt = models.find(instance.modelId);
if (modelIt == models.end()) continue; if (modelIt == models.end()) continue;

View file

@ -363,6 +363,8 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
constexpr float MIN_DIST = 4.0f; // Near clamp constexpr float MIN_DIST = 4.0f; // Near clamp
constexpr float MAX_DIST = 90.0f; // Far fade-out start constexpr float MAX_DIST = 90.0f; // Far fade-out start
constexpr float FADE_RANGE = 25.0f; // Fade-out range 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 // Get time for bob animation
float timeSeconds = SDL_GetTicks() / 1000.0f; 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 // Get camera right and up vectors for billboarding
glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]); 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]); glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]);
const glm::vec3 cameraForward = glm::cross(cameraRight, cameraUp);
// Bind pipeline // Bind pipeline
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, 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 // Calculate distance for LOD and culling
glm::vec3 toCamera = cameraPos - marker.position; 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 // Calculate fade alpha
float fadeAlpha = 1.0f; float fadeAlpha = 1.0f;
@ -425,7 +430,7 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe
// Billboard: align quad to face camera // Billboard: align quad to face camera
model[0] = glm::vec4(cameraRight * size, 0.0f); model[0] = glm::vec4(cameraRight * size, 0.0f);
model[1] = glm::vec4(cameraUp * 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 // Bind material descriptor set (set 1) for this marker's texture
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_,

View file

@ -99,6 +99,15 @@ static bool envFlagEnabled(const char* key, bool defaultValue) {
return !(v == "0" || v == "false" || v == "off" || v == "no"); 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<int>(n);
}
static std::vector<std::string> parseEmoteCommands(const std::string& raw) { static std::vector<std::string> parseEmoteCommands(const std::string& raw) {
std::vector<std::string> out; std::vector<std::string> out;
std::string cur; 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. // Ambient environmental sounds: fireplaces, water, birds, etc.
if (ambientSoundManager && camera && wmoRenderer && cameraController) { if (ambientSoundManager && camera && wmoRenderer && cameraController) {
glm::vec3 camPos = camera->getPosition(); bool isIndoor = insideWmo;
uint32_t wmoId = 0;
bool isIndoor = wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &wmoId);
bool isSwimming = cameraController->isSwimming(); bool isSwimming = cameraController->isSwimming();
// Check if inside blacksmith (96048 = Goldshire blacksmith) // Check if inside blacksmith (96048 = Goldshire blacksmith)
bool isBlacksmith = (wmoId == 96048); bool isBlacksmith = (insideWmoId == 96048);
// Sync weather audio with visual weather system // Sync weather audio with visual weather system
if (weather) { if (weather) {
@ -2747,9 +2760,8 @@ void Renderer::update(float deltaTime) {
// Override with WMO-based detection (e.g., inside Stormwind, taverns, blacksmiths) // Override with WMO-based detection (e.g., inside Stormwind, taverns, blacksmiths)
if (wmoRenderer) { if (wmoRenderer) {
glm::vec3 camPos = camera->getPosition(); uint32_t wmoModelId = insideWmoId;
uint32_t wmoModelId = 0; if (insideWmo) {
if (wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &wmoModelId)) {
// Check if inside Stormwind WMO (model ID 10047) // Check if inside Stormwind WMO (model ID 10047)
if (wmoModelId == 10047) { if (wmoModelId == 10047) {
zoneId = 1519; // Stormwind City zoneId = 1519; // Stormwind City
@ -3839,6 +3851,19 @@ void Renderer::renderShadowPass() {
if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return; if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return;
if (currentCmd == 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<uint32_t>(std::max(1, envIntOrDefault("WOWEE_DENSE_CHAR_THRESHOLD", 120)));
const uint32_t denseM2Threshold = static_cast<uint32_t>(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<uint32_t>(shadowInterval)) {
return;
}
shadowFrameCounter_ = 0;
// Compute and store light space matrix; write to per-frame UBO // Compute and store light space matrix; write to per-frame UBO
lightSpaceMatrix = computeLightSpaceMatrix(); lightSpaceMatrix = computeLightSpaceMatrix();
// Zero matrix means character position isn't set yet — skip shadow pass entirely. // 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); vkCmdSetScissor(currentCmd, 0, 1, &sc);
// Phase 7/8: render shadow casters // Phase 7/8: render shadow casters
constexpr float kShadowCullRadius = 180.0f; // match kShadowHalfExtent const float baseShadowCullRadius = static_cast<float>(std::max(40, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS", 180)));
const float denseShadowCullRadius = static_cast<float>(std::max(30, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS_DENSE", 90)));
const float shadowCullRadius = denseScene ? std::min(baseShadowCullRadius, denseShadowCullRadius) : baseShadowCullRadius;
if (wmoRenderer) { if (wmoRenderer) {
wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius); wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius);
} }
if (m2Renderer) { if (m2Renderer) {
m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, kShadowCullRadius); m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, shadowCullRadius);
} }
if (characterRenderer) { if (characterRenderer) {
characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius); characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius);
} }
vkCmdEndRenderPass(currentCmd); vkCmdEndRenderPass(currentCmd);

View file

@ -2699,6 +2699,42 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
} }
}; };
// Fast path: current active interior group and its neighbors are usually
// the right answer for player-floor queries while moving in cities/buildings.
if (activeGroup_.isValid() && activeGroup_.instanceIdx < instances.size()) {
const auto& instance = instances[activeGroup_.instanceIdx];
auto it = loadedModels.find(instance.modelId);
if (it != loadedModels.end() && instance.modelId == activeGroup_.modelId) {
const ModelData& model = it->second;
glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(worldOrigin, 1.0f));
glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(worldDir, 0.0f)));
auto testGroupIdx = [&](uint32_t gi) {
if (gi >= model.groups.size()) return;
if (gi < instance.worldGroupBounds.size()) {
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
if (glX < gMin.x || glX > gMax.x ||
glY < gMin.y || glY > gMax.y ||
glZ - 4.0f > gMax.z) {
return;
}
}
const auto& group = model.groups[gi];
if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) {
return;
}
testGroupFloor(instance, model, group, localOrigin, localDir);
};
if (activeGroup_.groupIdx >= 0) {
testGroupIdx(static_cast<uint32_t>(activeGroup_.groupIdx));
}
for (uint32_t ngi : activeGroup_.neighborGroups) {
testGroupIdx(ngi);
}
}
}
// Full scan: test all instances (active group fast path removed to fix // Full scan: test all instances (active group fast path removed to fix
// bridge clipping where early-return missed other WMO instances) // bridge clipping where early-return missed other WMO instances)
glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f); glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f);
@ -2720,6 +2756,9 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
float zMarginUp = model.isLowPlatform ? 20.0f : 4.0f; float zMarginUp = model.isLowPlatform ? 20.0f : 4.0f;
// Broad-phase reject in world space to avoid expensive matrix transforms. // 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 || if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x ||
glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y ||
glZ < instance.worldBoundsMin.z - zMarginDown || glZ > instance.worldBoundsMax.z + zMarginUp) { glZ < instance.worldBoundsMin.z - zMarginDown || glZ > instance.worldBoundsMax.z + zMarginUp) {

View file

@ -220,6 +220,9 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
music->update(ImGui::GetIO().DeltaTime); music->update(ImGui::GetIO().DeltaTime);
if (!music->isPlaying()) { if (!music->isPlaying()) {
static std::mt19937 rng(std::random_device{}()); static std::mt19937 rng(std::random_device{}());
if (!introTracksScanned_) {
introTracksScanned_ = true;
// Tracks in assets/ root // Tracks in assets/ root
static const std::array<const char*, 1> kRootTracks = { static const std::array<const char*, 1> kRootTracks = {
"Raise the Mug, Sound the Warcry.mp3", "Raise the Mug, Sound the Warcry.mp3",
@ -239,31 +242,42 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
"You No Take Candle!.mp3", "You No Take Candle!.mp3",
}; };
std::vector<std::string> availableTracks;
auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) { auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) {
std::filesystem::path p = base / track; std::filesystem::path p = base / track;
if (std::filesystem::exists(p)) { if (std::filesystem::exists(p)) {
availableTracks.push_back(p.string()); introTracks_.push_back(p.string());
} }
}; };
for (const char* track : kRootTracks) { for (const char* track : kRootTracks) {
tryAddTrack("assets", track); tryAddTrack("assets", track);
if (availableTracks.empty()) if (introTracks_.empty()) {
tryAddTrack(std::filesystem::current_path() / "assets", track); tryAddTrack(std::filesystem::current_path() / "assets", track);
} }
}
for (const char* track : kOriginalTracks) { for (const char* track : kOriginalTracks) {
tryAddTrack(std::filesystem::path("assets") / "Original Music", track); tryAddTrack(std::filesystem::path("assets") / "Original Music", track);
tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track); tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track);
} }
if (!availableTracks.empty()) { std::sort(introTracks_.begin(), introTracks_.end());
std::uniform_int_distribution<size_t> pick(0, availableTracks.size() - 1); introTracks_.erase(std::unique(introTracks_.begin(), introTracks_.end()), introTracks_.end());
const std::string& path = availableTracks[pick(rng)]; }
if (!introTracks_.empty()) {
std::uniform_int_distribution<size_t> pick(0, introTracks_.size() - 1);
const size_t idx = pick(rng);
const std::string path = introTracks_[idx];
music->playFilePath(path, true, 1800.0f); music->playFilePath(path, true, 1800.0f);
LOG_INFO("AuthScreen: Playing login intro track: ", path);
musicPlaying = music->isPlaying(); musicPlaying = music->isPlaying();
if (musicPlaying) {
LOG_INFO("AuthScreen: Playing login intro track: ", path);
} else { } 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/"); LOG_WARNING("AuthScreen: No login intro tracks found in assets/");
missingIntroTracksLogged_ = true;
} }
} }
} }

View file

@ -548,6 +548,12 @@ bool Extractor::enumerateFiles(const Options& opts,
continue; 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); std::string norm = normalizeWowPath(fileName);
if (opts.onlyUsedDbcs && !wantedDbcs.empty() && !wantedDbcs.contains(norm)) { if (opts.onlyUsedDbcs && !wantedDbcs.empty() && !wantedDbcs.contains(norm)) {
continue; continue;
@ -624,24 +630,36 @@ bool Extractor::run(const Options& opts) {
std::atomic<size_t> fileIndex{0}; std::atomic<size_t> fileIndex{0};
size_t totalFiles = files.size(); size_t totalFiles = files.size();
auto workerFn = [&]() { // Open archives ONCE in main thread — StormLib has global state that is not
// Each thread opens ALL archives independently (StormLib is not thread-safe per handle). // thread-safe even with separate handles, so we serialize all MPQ reads.
// Sorted highest-priority last, so we iterate in reverse to find the winning version. struct SharedArchive {
struct ThreadArchive {
HANDLE handle; HANDLE handle;
int priority; int priority;
std::string path;
}; };
std::vector<ThreadArchive> threadHandles; std::vector<SharedArchive> sharedHandles;
for (const auto& ad : archives) { for (const auto& ad : archives) {
HANDLE h = nullptr; HANDLE h = nullptr;
if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) { if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) {
threadHandles.push_back({h, ad.priority}); sharedHandles.push_back({h, ad.priority, ad.path});
} else {
std::cerr << " Failed to open archive: " << ad.path << "\n";
} }
} }
if (threadHandles.empty()) { if (sharedHandles.empty()) {
std::cerr << "Worker thread: failed to open any archives\n"; std::cerr << "Failed to open any archives for extraction\n";
return; 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 = [&]() {
int failLogCount = 0;
while (true) { while (true) {
size_t idx = fileIndex.fetch_add(1); size_t idx = fileIndex.fetch_add(1);
@ -654,9 +672,14 @@ bool Extractor::run(const Options& opts) {
std::string mappedPath = PathMapper::mapPath(wowPath); std::string mappedPath = PathMapper::mapPath(wowPath);
std::string fullOutputPath = effectiveOutputDir + "/" + mappedPath; std::string fullOutputPath = effectiveOutputDir + "/" + mappedPath;
// Read file data from MPQ under lock
std::vector<uint8_t> data;
{
std::lock_guard<std::mutex> lock(mpqMutex);
// Search archives in reverse priority order (highest priority first) // Search archives in reverse priority order (highest priority first)
HANDLE hFile = nullptr; HANDLE hFile = nullptr;
for (auto it = threadHandles.rbegin(); it != threadHandles.rend(); ++it) { for (auto it = sharedHandles.rbegin(); it != sharedHandles.rend(); ++it) {
if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) { if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) {
break; break;
} }
@ -664,6 +687,11 @@ bool Extractor::run(const Options& opts) {
} }
if (!hFile) { if (!hFile) {
stats.filesFailed++; stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::cerr << " FAILED open: " << wowPath
<< " (tried " << sharedHandles.size() << " archives)\n";
}
continue; continue;
} }
@ -674,15 +702,22 @@ bool Extractor::run(const Options& opts) {
continue; continue;
} }
std::vector<uint8_t> data(fileSize); data.resize(fileSize);
DWORD bytesRead = 0; DWORD bytesRead = 0;
if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) { if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) {
SFileCloseFile(hFile); SFileCloseFile(hFile);
stats.filesFailed++; stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::cerr << " FAILED read: " << wowPath
<< " (size=" << fileSize << ")\n";
}
continue; continue;
} }
SFileCloseFile(hFile); SFileCloseFile(hFile);
data.resize(bytesRead); data.resize(bytesRead);
}
// Lock released — CRC computation and disk write happen in parallel
// Compute CRC32 // Compute CRC32
uint32_t crc = ManifestWriter::computeCRC32(data.data(), data.size()); 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); std::ofstream out(fullOutputPath, std::ios::binary);
if (!out.is_open()) { if (!out.is_open()) {
stats.filesFailed++; stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::lock_guard<std::mutex> lock(manifestMutex);
std::cerr << " FAILED write: " << fullOutputPath << "\n";
}
continue; continue;
} }
out.write(reinterpret_cast<const char*>(data.data()), data.size()); out.write(reinterpret_cast<const char*>(data.data()), data.size());
@ -721,10 +761,6 @@ bool Extractor::run(const Options& opts) {
<< std::flush; << std::flush;
} }
} }
for (auto& th : threadHandles) {
SFileCloseArchive(th.handle);
}
}; };
std::cout << "Extracting " << totalFiles << " files using " << numThreads << " threads...\n"; std::cout << "Extracting " << totalFiles << " files using " << numThreads << " threads...\n";
@ -737,10 +773,30 @@ bool Extractor::run(const Options& opts) {
t.join(); 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.bytesExtracted.load() / (1024 * 1024) << " MB), "
<< stats.filesSkipped.load() << " skipped, " << skipped << " skipped, "
<< stats.filesFailed.load() << " failed\n"; << 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 // Merge with existing manifest so partial extractions don't nuke prior entries
std::string manifestPath = effectiveOutputDir + "/manifest.json"; std::string manifestPath = effectiveOutputDir + "/manifest.json";