Compare commits

...

19 commits

Author SHA1 Message Date
Kelsi
94e4a0bdb3 Fix letter-named patch MPQs not loading on case-sensitive filesystems
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
Two bugs in loadPatchArchives():
1. isLetterPatch detection was inverted (rfind != 0 is false for all
   "patch-*" entries), making the disable flags non-functional
2. Patch file lookup used exact std::filesystem::exists() which is
   case-sensitive on Linux — Patch-A.MPQ wouldn't match patch-a.mpq

Now scans the data directory once and builds a case-insensitive lookup
map, so any case variant (Patch-A.MPQ, patch-a.mpq, PATCH-A.MPQ) is
found correctly.
2026-02-25 12:18:36 -08:00
Kelsi
956e2c8bb1 Reduce city stutter: lower spawn rate, resync interval, M2 render distance
- MAX_SPAWNS_PER_FRAME 8→3 (each spawn does sync M2 load, 5-50ms each)
- Creature resync scan interval 1s→3s (O(N) entity iteration)
- M2 render distance: add 1000+ instance tier at 500 units, reduce 2000+
  tier from 350→300 units to cap draw call count in dense cities
2026-02-25 12:16:55 -08:00
Kelsi
9c25713b72 Disable login screen music 2026-02-25 12:11:19 -08:00
Kelsi
53405ea322 Fix crash on re-login by clearing all per-session state on logout
logoutToLogin() was only clearing a handful of flags, leaving stale
entity instance maps, pending spawn queues, transport state, mount
state, and charge state from the previous session.  On second login,
these stale GUIDs and instance IDs caused invalid renderer operations
and crashes.

Now clears: creature/player/gameObject instance maps, all pending
spawn queues, transport doodad batches, mount/charge state, player
identity, and renderer world geometry (WMO instances, M2 models,
quest markers).  Also disconnects TransportManager from WMORenderer
before teardown to prevent dangling pointer access.
2026-02-25 12:09:00 -08:00
Kelsi
401cb6928c Disable WMO/M2 camera collision raycasts
Camera collision with WMO walls and M2 doodads caused erratic zoom
and pull-through at doorway transitions. Terrain-only camera floor
clamping is retained.
2026-02-25 12:06:24 -08:00
Kelsi
64879b8aab Overhaul WMO collision: precompute normals, fix floor selection, optimize queries
- Precompute triangle normals in buildCollisionGrid, eliminating per-query
  cross+normalize in getFloorHeight, checkWallCollision, and raycastBoundingBoxes
- Fix floor selection: remove redundant allowAbove (callers already elevate
  probeZ by stepUpBudget), preventing upper-story snap at doorway transitions
- Align wall classification threshold (absNz < 0.35) with runtime skip check,
  eliminating ~30% wasted wall triangle fetches
- Replace O(n log n) sort+unique dedup in range queries with O(n) visited bitset
- Rename wallTriScratch to triScratch_, fix stale threshold comments
2026-02-25 11:56:58 -08:00
Kelsi
35384b2c52 Fix Windows ERROR macro collision in logger 2026-02-25 11:14:53 -08:00
Kelsi
e1614d55a2 chore: ignore and untrack python cache files 2026-02-25 10:42:50 -08:00
Kelsi
c849f9cdfa chore: update camera controller changes 2026-02-25 10:41:54 -08:00
Kelsi
7557e388fb Fix Booty Bay floor fall-through via M2-aware grounding rescue 2026-02-25 10:26:41 -08:00
Kelsi
bdde5f305a Harden WMO grounding rescue to prevent fall-through 2026-02-25 10:24:54 -08:00
Kelsi
2219ccde51 Optimize city performance and harden WMO grounding 2026-02-25 10:22:05 -08:00
Kelsi
dd4b72e046 Avoid log argument evaluation when level is disabled 2026-02-25 09:55:12 -08:00
Kelsi
52accfde80 Optimize login music scanning and warden debug formatting 2026-02-25 09:50:33 -08:00
Kelsi
c26353eda1 Reduce release log spam and harden release logging defaults 2026-02-25 09:46:27 -08:00
Kelsi
5966fedc59 Use outbound local IP for logon challenge with safe fallback 2026-02-25 09:37:52 -08:00
Kelsi
0e8f305087 Add tavern music tracks 2026-02-25 09:29:42 -08:00
Kelsi
fc68c6c6b7 Fix Warden module parse fallback and macOS FMOD integrity aliases 2026-02-25 09:26:34 -08:00
Kelsidavis
1fab17e639 Add Windows build scripts, fix multi-threaded MPQ extraction, and cross-platform temp paths
- Add build.ps1/bat, rebuild.ps1/bat, debug_texture.ps1/bat (Windows equivalents
  of existing bash scripts, using directory junctions for Data link)
- Fix asset extractor: StormLib is not thread-safe even with separate handles per
  thread. Serialize all MPQ reads behind a mutex while keeping CRC computation and
  disk writes parallel. Previously caused 99.8% extraction failures with >1 thread.
- Add SFileHasFile() check during enumeration to skip listfile-only entries
- Add diagnostic logging for extraction failures (first 5 per thread + summary)
- Use std::filesystem::temp_directory_path() instead of hardcoded /tmp/ in
  character_renderer.cpp debug dumps
- Update debug_texture.sh to use $TMPDIR fallback and glob for actual dump filenames
2026-02-25 08:22:45 -08:00
35 changed files with 1435 additions and 423 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,23 +8,32 @@ 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
png="${raw%.raw}.png" # Find raw dumps — filenames include dimensions (e.g. wowee_composite_debug_1024x1024.raw)
# Try ImageMagick first, fall back to ffmpeg shopt -s nullglob
if command -v convert &>/dev/null; then RAW_FILES=("$TMPD"/wowee_*_debug*.raw)
convert -size ${W}x${H} -depth 8 rgba:"$raw" "$png" 2>/dev/null && \ shopt -u nullglob
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw" if [ ${#RAW_FILES[@]} -eq 0 ]; then
elif command -v ffmpeg &>/dev/null; then echo "No debug dumps found in $TMPD"
ffmpeg -y -f rawvideo -pix_fmt rgba -s ${W}x${H} -i "$raw" "$png" 2>/dev/null && \ echo " (looked for $TMPD/wowee_*_debug*.raw)"
echo "Created $png (${W}x${H})" || \ exit 0
echo "Failed to convert $raw" fi
else
echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw" for raw in "${RAW_FILES[@]}"; do
echo " Install: sudo apt install imagemagick" png="${raw%.raw}.png"
fi # Try ImageMagick first, fall back to ffmpeg
if command -v convert &>/dev/null; then
convert -size ${W}x${H} -depth 8 rgba:"$raw" "$png" 2>/dev/null && \
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw"
elif command -v ffmpeg &>/dev/null; then
ffmpeg -y -f rawvideo -pix_fmt rgba -s ${W}x${H} -i "$raw" "$png" 2>/dev/null && \
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw"
else else
echo "Not found: $raw" echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw"
echo " Install: sudo apt install imagemagick"
fi fi
done done

View file

@ -244,7 +244,7 @@ private:
float x, y, z, orientation; float x, y, z, orientation;
}; };
std::vector<PendingCreatureSpawn> pendingCreatureSpawns_; std::vector<PendingCreatureSpawn> pendingCreatureSpawns_;
static constexpr int MAX_SPAWNS_PER_FRAME = 8; static constexpr int MAX_SPAWNS_PER_FRAME = 3;
static constexpr int MAX_NEW_CREATURE_MODELS_PER_FRAME = 1; static constexpr int MAX_NEW_CREATURE_MODELS_PER_FRAME = 1;
static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300; static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300;
std::unordered_set<uint64_t> pendingCreatureSpawnGuids_; std::unordered_set<uint64_t> pendingCreatureSpawnGuids_;

View file

@ -26,6 +26,10 @@ enum class LogLevel {
FATAL FATAL
}; };
// Avoid direct token use of `ERROR` at call sites because Windows headers
// define `ERROR` as a macro.
inline constexpr LogLevel kLogLevelError = LogLevel::ERROR;
class Logger { class Logger {
public: public:
static Logger& getInstance(); static Logger& getInstance();
@ -65,6 +69,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 +88,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::kLogLevelError)) { \
_wowee_logger.error(__VA_ARGS__); \
} \
} while (0)
#define LOG_FATAL(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::FATAL)) { \
_wowee_logger.fatal(__VA_ARGS__); \
} \
} while (0)
} // namespace core } // namespace 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

@ -390,13 +390,19 @@ private:
std::vector<std::vector<uint32_t>> cellTriangles; std::vector<std::vector<uint32_t>> cellTriangles;
// Pre-classified triangle lists per cell (built at load time) // Pre-classified triangle lists per cell (built at load time)
std::vector<std::vector<uint32_t>> cellFloorTriangles; // abs(normal.z) >= 0.45 std::vector<std::vector<uint32_t>> cellFloorTriangles; // abs(normal.z) >= 0.35
std::vector<std::vector<uint32_t>> cellWallTriangles; // abs(normal.z) < 0.55 std::vector<std::vector<uint32_t>> cellWallTriangles; // abs(normal.z) < 0.35
// Pre-computed per-triangle Z bounds for fast vertical reject // Pre-computed per-triangle Z bounds for fast vertical reject
struct TriBounds { float minZ; float maxZ; }; struct TriBounds { float minZ; float maxZ; };
std::vector<TriBounds> triBounds; // indexed by triStart/3 std::vector<TriBounds> triBounds; // indexed by triStart/3
// Pre-computed per-triangle normals (unit length, indexed by triStart/3)
std::vector<glm::vec3> triNormals;
// Scratch bitset for deduplicating triangle queries (sized to numTriangles)
mutable std::vector<uint8_t> triVisited;
// Build the spatial grid from collision geometry // Build the spatial grid from collision geometry
void buildCollisionGrid(); void buildCollisionGrid();
@ -675,7 +681,7 @@ private:
std::unordered_map<GridCell, std::vector<uint32_t>, GridCellHash> spatialGrid; std::unordered_map<GridCell, std::vector<uint32_t>, GridCellHash> spatialGrid;
std::unordered_map<uint32_t, size_t> instanceIndexById; std::unordered_map<uint32_t, size_t> instanceIndexById;
mutable std::vector<size_t> candidateScratch; mutable std::vector<size_t> candidateScratch;
mutable std::vector<uint32_t> wallTriScratch; // Scratch for wall collision grid queries mutable std::vector<uint32_t> triScratch_; // Scratch for collision grid queries
mutable std::unordered_set<uint32_t> candidateIdScratch; mutable std::unordered_set<uint32_t> candidateIdScratch;
// Parallel visibility culling // Parallel visibility culling

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.
packet.writeUInt32(0); // 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);
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::vector<uint8_t> bytes; std::string groupErr;
std::string path = miscDir;
if (!path.empty() && path.back() != '/') path += '/'; for (const auto& nameStr : group) {
path += nameStr; std::vector<uint8_t> bytes;
if (!readWholeFile(path, bytes, err)) { std::string path = miscDir;
outError = err; if (!path.empty() && path.back() != '/') path += '/';
path += nameStr;
std::string err;
if (!readWholeFile(path, bytes, err)) {
if (groupErr.empty()) groupErr = err;
continue;
}
allFiles.insert(allFiles.end(), bytes.begin(), bytes.end());
foundInGroup = true;
break;
}
if (!foundInGroup) {
outError = groupErr.empty() ? "missing required integrity file group" : groupErr;
return false; return false;
} }
allFiles.insert(allFiles.end(), bytes.begin(), bytes.end());
} }
// HMAC_SHA1(checksumSalt, allFiles) // HMAC_SHA1(checksumSalt, allFiles)

View file

@ -578,25 +578,100 @@ void Application::reloadExpansionData() {
void Application::logoutToLogin() { void Application::logoutToLogin() {
LOG_INFO("Logout requested"); LOG_INFO("Logout requested");
// Disconnect TransportManager from WMORenderer before tearing down
if (gameHandler && gameHandler->getTransportManager()) {
gameHandler->getTransportManager()->setWMORenderer(nullptr);
}
if (gameHandler) { if (gameHandler) {
gameHandler->disconnect(); gameHandler->disconnect();
} }
// --- Per-session flags ---
npcsSpawned = false; npcsSpawned = false;
playerCharacterSpawned = false; playerCharacterSpawned = false;
weaponsSheathed_ = false; weaponsSheathed_ = false;
wasAutoAttacking_ = false; wasAutoAttacking_ = false;
loadedMapId_ = 0xFFFFFFFF; loadedMapId_ = 0xFFFFFFFF;
lastTaxiFlight_ = false;
taxiLandingClampTimer_ = 0.0f;
worldEntryMovementGraceTimer_ = 0.0f;
facingSendCooldown_ = 0.0f;
lastSentCanonicalYaw_ = 1000.0f;
taxiStreamCooldown_ = 0.0f;
idleYawned_ = false;
// --- Charge state ---
chargeActive_ = false;
chargeTimer_ = 0.0f;
chargeDuration_ = 0.0f;
chargeTargetGuid_ = 0;
// --- Player identity ---
spawnedPlayerGuid_ = 0;
spawnedAppearanceBytes_ = 0;
spawnedFacialFeatures_ = 0;
// --- Mount state ---
mountInstanceId_ = 0;
mountModelId_ = 0;
pendingMountDisplayId_ = 0;
// --- Creature instance tracking ---
creatureInstances_.clear();
creatureModelIds_.clear();
creatureRenderPosCache_.clear();
creatureWeaponsAttached_.clear();
creatureWeaponAttachAttempts_.clear();
deadCreatureGuids_.clear();
nonRenderableCreatureDisplayIds_.clear(); nonRenderableCreatureDisplayIds_.clear();
creaturePermanentFailureGuids_.clear();
// --- Creature spawn queues ---
pendingCreatureSpawns_.clear();
pendingCreatureSpawnGuids_.clear();
creatureSpawnRetryCounts_.clear();
// --- Player instance tracking ---
playerInstances_.clear();
onlinePlayerAppearance_.clear();
pendingOnlinePlayerEquipment_.clear();
deferredEquipmentQueue_.clear();
pendingPlayerSpawns_.clear();
pendingPlayerSpawnGuids_.clear();
// --- GameObject instance tracking ---
gameObjectInstances_.clear();
pendingGameObjectSpawns_.clear();
pendingTransportMoves_.clear();
pendingTransportDoodadBatches_.clear();
world.reset(); world.reset();
if (renderer) { if (renderer) {
// Remove old player model so it doesn't persist into next session // Remove old player model so it doesn't persist into next session
if (auto* charRenderer = renderer->getCharacterRenderer()) { if (auto* charRenderer = renderer->getCharacterRenderer()) {
charRenderer->removeInstance(1); charRenderer->removeInstance(1);
} }
// Clear all world geometry renderers
if (auto* wmo = renderer->getWMORenderer()) {
wmo->clearInstances();
}
if (auto* m2 = renderer->getM2Renderer()) {
m2->clear();
}
// TerrainManager will be re-initialized on next world entry
if (auto* questMarkers = renderer->getQuestMarkerRenderer()) {
questMarkers->clear();
}
renderer->clearMount();
renderer->setCharacterFollow(0);
if (auto* music = renderer->getMusicManager()) { if (auto* music = renderer->getMusicManager()) {
music->stopMusic(0.0f); music->stopMusic(0.0f);
} }
} }
// Clear stale realm/character selection so switching servers starts fresh // Clear stale realm/character selection so switching servers starts fresh
if (uiManager) { if (uiManager) {
uiManager->getRealmScreen().reset(); uiManager->getRealmScreen().reset();
@ -711,7 +786,7 @@ void Application::update(float deltaTime) {
if (gameHandler) { if (gameHandler) {
static float creatureResyncTimer = 0.0f; static float creatureResyncTimer = 0.0f;
creatureResyncTimer += deltaTime; creatureResyncTimer += deltaTime;
if (creatureResyncTimer >= 1.0f) { if (creatureResyncTimer >= 3.0f) {
creatureResyncTimer = 0.0f; creatureResyncTimer = 0.0f;
glm::vec3 playerPos(0.0f); glm::vec3 playerPos(0.0f);

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(kLogLevelError);
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);
@ -66,7 +83,7 @@ void Logger::log(LogLevel level, const std::string& message) {
case LogLevel::DEBUG: line << "DEBUG"; break; case LogLevel::DEBUG: line << "DEBUG"; break;
case LogLevel::INFO: line << "INFO "; break; case LogLevel::INFO: line << "INFO "; break;
case LogLevel::WARNING: line << "WARN "; break; case LogLevel::WARNING: line << "WARN "; break;
case LogLevel::ERROR: line << "ERROR"; break; case kLogLevelError: line << "ERROR"; break;
case LogLevel::FATAL: line << "FATAL"; break; case LogLevel::FATAL: line << "FATAL"; break;
} }
@ -92,6 +109,38 @@ void Logger::log(LogLevel level, const std::string& message) {
} }
} }
void Logger::flushSuppressedLocked() {
if (suppressedCount_ == 0) return;
emitLineLocked(lastLevel_, "Previous message repeated " + std::to_string(suppressedCount_) + " times");
suppressedCount_ = 0;
}
void Logger::log(LogLevel level, const std::string& message) {
if (!shouldLog(level)) {
return;
}
std::lock_guard<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
size_t destOffset = 0;
int pairCount = 0;
while (pos + 2 <= exeData.size()) { enum class PairFormat {
// Read copy count (2 bytes LE) CopyDataSkip, // [copy][data][skip]
uint16_t copyCount = exeData[pos] | (exeData[pos + 1] << 8); SkipCopyData, // [skip][copy][data]
pos += 2; CopySkipData // [copy][skip][data]
};
if (copyCount == 0) { auto tryParsePairs = [&](PairFormat format,
break; // End of copy/skip pairs 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;
int pairCount = 0;
if (copyCount > 0) { while (pos + 2 <= exeData.size()) {
if (pos + copyCount > exeData.size()) { uint16_t copyCount = 0;
std::cerr << "[WardenModule] Copy section extends beyond data bounds" << '\n'; uint16_t skipCount = 0;
#ifdef _WIN32
VirtualFree(moduleMemory_, 0, MEM_RELEASE); switch (format) {
#else case PairFormat::CopyDataSkip: {
munmap(moduleMemory_, moduleSize_); copyCount = readU16LE(pos);
#endif pos += 2;
moduleMemory_ = nullptr; if (copyCount == 0) {
return false; relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
}
if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
return false;
}
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
if (pos + 2 > exeData.size()) {
return false;
}
skipCount = readU16LE(pos);
pos += 2;
break;
}
case PairFormat::SkipCopyData: {
if (pos + 4 > exeData.size()) {
return false;
}
skipCount = readU16LE(pos);
pos += 2;
copyCount = readU16LE(pos);
pos += 2;
if (skipCount == 0 && copyCount == 0) {
relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
}
if (destOffset + skipCount > moduleSize_) {
return false;
}
destOffset += skipCount;
if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
return false;
}
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
break;
}
case PairFormat::CopySkipData: {
if (pos + 4 > exeData.size()) {
return false;
}
copyCount = readU16LE(pos);
pos += 2;
skipCount = readU16LE(pos);
pos += 2;
if (copyCount == 0 && skipCount == 0) {
relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
}
if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
return false;
}
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
break;
}
} }
if (destOffset + copyCount > moduleSize_) { if (destOffset + skipCount > moduleSize_) {
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; return false;
} }
destOffset += skipCount;
std::memcpy( pairCount++;
static_cast<uint8_t*>(moduleMemory_) + destOffset,
exeData.data() + pos,
copyCount
);
pos += copyCount;
destOffset += copyCount;
} }
// Read skip count (2 bytes LE) return false;
uint16_t skipCount = 0; };
if (pos + 2 <= exeData.size()) {
skipCount = exeData[pos] | (exeData[pos + 1] << 8);
pos += 2;
}
// Advance dest pointer by skipCount (gaps are zero-filled from memset) std::vector<uint8_t> parsedImage;
destOffset += skipCount; size_t parsedRelocPos = 0;
size_t parsedFinalOffset = 0;
int parsedPairCount = 0;
pairCount++; PairFormat usedFormat = PairFormat::CopyDataSkip;
std::cout << "[WardenModule] Pair " << pairCount << ": copy " << copyCount bool parsed = tryParsePairs(PairFormat::CopyDataSkip, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount);
<< ", skip " << skipCount << " (dest offset=" << destOffset << ")" << '\n'; if (!parsed) {
usedFormat = PairFormat::SkipCopyData;
parsed = tryParsePairs(PairFormat::SkipCopyData, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount);
}
if (!parsed) {
usedFormat = PairFormat::CopySkipData;
parsed = tryParsePairs(PairFormat::CopySkipData, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount);
} }
// Save position — remaining decompressed data contains relocation entries if (parsed) {
relocDataOffset_ = pos; std::memcpy(moduleMemory_, parsedImage.data(), parsedImage.size());
relocDataOffset_ = parsedRelocPos;
std::cout << "[WardenModule] Parsed " << pairCount << " skip/copy pairs, final offset: " const char* formatName = "copy/data/skip";
<< destOffset << "/" << finalCodeSize << '\n'; if (usedFormat == PairFormat::SkipCopyData) formatName = "skip/copy/data";
std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_ if (usedFormat == PairFormat::CopySkipData) formatName = "copy/skip/data";
<< " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n';
std::cout << "[WardenModule] Parsed " << parsedPairCount << " pairs using format "
<< formatName << ", final offset: " << parsedFinalOffset << "/" << finalCodeSize << '\n';
std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_
<< " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n';
return true;
}
// Fallback: copy raw payload (without the 4-byte size header) into module memory.
// This keeps loading alive for servers where packet flow can continue with hash/check fallbacks.
if (exeData.size() > 4) {
size_t rawCopySize = std::min(moduleSize_, exeData.size() - 4);
std::memcpy(moduleMemory_, exeData.data() + 4, rawCopySize);
}
relocDataOffset_ = 0;
std::cerr << "[WardenModule] Could not parse copy/skip pairs (all known layouts failed); using raw payload fallback" << '\n';
return true; return true;
} }

View file

@ -38,7 +38,7 @@ static wowee::core::LogLevel readLogLevelFromEnv() {
if (level == "debug") return wowee::core::LogLevel::DEBUG; if (level == "debug") return wowee::core::LogLevel::DEBUG;
if (level == "info") return wowee::core::LogLevel::INFO; if (level == "info") return wowee::core::LogLevel::INFO;
if (level == "warn" || level == "warning") return wowee::core::LogLevel::WARNING; if (level == "warn" || level == "warning") return wowee::core::LogLevel::WARNING;
if (level == "error") return wowee::core::LogLevel::ERROR; if (level == "error") return wowee::core::kLogLevelError;
if (level == "fatal") return wowee::core::LogLevel::FATAL; if (level == "fatal") return wowee::core::LogLevel::FATAL;
return wowee::core::LogLevel::WARNING; return wowee::core::LogLevel::WARNING;
} }

View file

@ -458,12 +458,27 @@ bool MPQManager::loadPatchArchives() {
{"patch.MPQ", 150}, {"patch.MPQ", 150},
}; };
// Build a case-insensitive lookup of files in the data directory so that
// Patch-A.MPQ, patch-a.mpq, PATCH-A.MPQ, etc. all resolve correctly on
// case-sensitive filesystems (Linux).
std::unordered_map<std::string, std::string> lowerToActual; // lowercase name → actual path
if (std::filesystem::is_directory(dataPath)) {
for (const auto& entry : std::filesystem::directory_iterator(dataPath)) {
if (!entry.is_regular_file()) continue;
std::string fname = entry.path().filename().string();
std::string lower = toLowerCopy(fname);
lowerToActual[lower] = entry.path().string();
}
}
int loadedPatches = 0; int loadedPatches = 0;
for (const auto& [archive, priority] : patchArchives) { for (const auto& [archive, priority] : patchArchives) {
// Classify letter vs numeric patch for the disable flags
std::string lowerArchive = toLowerCopy(archive);
const bool isLetterPatch = const bool isLetterPatch =
(archive.size() >= 10) && (lowerArchive.size() >= 11) && // "patch-X.mpq" = 11 chars
(toLowerCopy(archive).rfind("patch-", 0) != 0) && // not patch-*.MPQ (lowerArchive.rfind("patch-", 0) == 0) && // starts with "patch-"
(toLowerCopy(archive).rfind("patch.", 0) != 0); // not patch.MPQ (lowerArchive[6] >= 'a' && lowerArchive[6] <= 'z'); // letter after dash
if (isLetterPatch && disableLetterPatches) { if (isLetterPatch && disableLetterPatches) {
continue; continue;
} }
@ -471,9 +486,10 @@ bool MPQManager::loadPatchArchives() {
continue; continue;
} }
std::string fullPath = dataPath + "/" + archive; // Case-insensitive file lookup
if (std::filesystem::exists(fullPath)) { auto it = lowerToActual.find(lowerArchive);
if (loadArchive(fullPath, priority)) { if (it != lowerToActual.end()) {
if (loadArchive(it->second, priority)) {
loadedPatches++; loadedPatches++;
} }
} }

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,14 +696,21 @@ 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_) {
bool prevInside = cachedInsideWMO; const float insideDist = glm::length(targetPos - lastInsideStateCheckPos_);
bool prevInsideInterior = cachedInsideInteriorWMO; if (++insideStateCheckCounter_ >= 2 || insideDist > 0.35f) {
cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); insideStateCheckCounter_ = 0;
cachedInsideInteriorWMO = wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f); lastInsideStateCheckPos_ = targetPos;
if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) {
hasCachedFloor_ = false; bool prevInside = cachedInsideWMO;
hasCachedCamFloor = false; bool prevInsideInterior = cachedInsideInteriorWMO;
cachedPivotLift_ = 0.0f; cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr);
cachedInsideInteriorWMO = cachedInsideWMO &&
wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f);
if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) {
hasCachedFloor_ = false;
hasCachedCamFloor = false;
cachedPivotLift_ = 0.0f;
}
} }
} }
@ -654,15 +768,17 @@ void CameraController::update(float deltaTime) {
// WMO tunnel/bridge ramps are often steeper than outdoor terrain ramps. // 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_) {
@ -1005,28 +1272,8 @@ void CameraController::update(float deltaTime) {
// Find max safe distance using raycast + sphere radius // Find max safe distance using raycast + sphere radius
collisionDistance = currentDistance; collisionDistance = currentDistance;
// WMO raycast collision: zoom in when camera would clip through walls/floors // WMO/M2 camera collision disabled — was pulling camera through
if (wmoRenderer && currentDistance > MIN_DISTANCE) { // geometry at doorway transitions and causing erratic zoom behaviour.
glm::vec3 camRayOrigin = pivot;
glm::vec3 camRayDir = camDir;
float wmoHitDist = wmoRenderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance);
if (wmoHitDist < currentDistance) {
// Hit WMO geometry — pull camera in to avoid clipping
constexpr float CAM_RADIUS = 0.3f;
collisionDistance = std::max(MIN_DISTANCE, wmoHitDist - CAM_RADIUS);
}
}
// M2 raycast collision: zoom in when camera would clip through doodads
if (m2Renderer && !externalFollow_ && currentDistance > MIN_DISTANCE) {
glm::vec3 camRayOrigin = pivot;
glm::vec3 camRayDir = camDir;
float m2HitDist = m2Renderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance);
if (m2HitDist < collisionDistance) {
constexpr float CAM_RADIUS = 0.3f;
collisionDistance = std::max(MIN_DISTANCE, m2HitDist - CAM_RADIUS);
}
}
// Camera collision: terrain-only floor clamping // Camera collision: terrain-only floor clamping
auto getTerrainFloorAt = [&](float x, float y) -> std::optional<float> { auto getTerrainFloorAt = [&](float x, float y) -> std::optional<float> {
@ -1179,27 +1426,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 +1460,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 +1474,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

@ -2081,8 +2081,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
lastDrawCallCount = 0; lastDrawCallCount = 0;
// Adaptive render distance: balanced for performance without excessive pop-in // Adaptive render distance: tiered by instance density to cap draw calls
const float maxRenderDistance = (instances.size() > 2000) ? 350.0f : 1000.0f; const float maxRenderDistance = (instances.size() > 2000) ? 300.0f
: (instances.size() > 1000) ? 500.0f
: 1000.0f;
const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance; const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance;
const float fadeStartFraction = 0.75f; const float fadeStartFraction = 0.75f;
const glm::vec3 camPos = camera.getPosition(); const glm::vec3 camPos = camera.getPosition();

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

@ -1263,6 +1263,8 @@ void WMORenderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& q
} }
void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) {
++currentFrameId;
if (!opaquePipeline_ || instances.empty()) { if (!opaquePipeline_ || instances.empty()) {
lastDrawCalls = 0; lastDrawCalls = 0;
return; return;
@ -2474,6 +2476,8 @@ void WMORenderer::GroupResources::buildCollisionGrid() {
size_t numTriangles = collisionIndices.size() / 3; size_t numTriangles = collisionIndices.size() / 3;
triBounds.resize(numTriangles); triBounds.resize(numTriangles);
triNormals.resize(numTriangles);
triVisited.resize(numTriangles, 0);
float invCellW = gridCellsX / std::max(0.01f, extentX); float invCellW = gridCellsX / std::max(0.01f, extentX);
float invCellH = gridCellsY / std::max(0.01f, extentY); float invCellH = gridCellsY / std::max(0.01f, extentY);
@ -2494,16 +2498,23 @@ void WMORenderer::GroupResources::buildCollisionGrid() {
float triMaxZ = std::max({v0.z, v1.z, v2.z}); float triMaxZ = std::max({v0.z, v1.z, v2.z});
triBounds[i / 3] = { triMinZ, triMaxZ }; triBounds[i / 3] = { triMinZ, triMaxZ };
// Classify floor vs wall by normal. // Precompute and store unit normal
// Wall threshold matches MAX_WALK_SLOPE_DOT (cos 50° ≈ 0.6428) so that
// surfaces too steep to walk on are always tested for wall collision.
glm::vec3 edge1 = v1 - v0; glm::vec3 edge1 = v1 - v0;
glm::vec3 edge2 = v2 - v0; glm::vec3 edge2 = v2 - v0;
glm::vec3 normal = glm::cross(edge1, edge2); glm::vec3 normal = glm::cross(edge1, edge2);
float normalLen = glm::length(normal); float normalLen = glm::length(normal);
float absNz = (normalLen > 0.001f) ? std::abs(normal.z / normalLen) : 0.0f; if (normalLen > 0.001f) {
normal /= normalLen;
} else {
normal = glm::vec3(0.0f, 0.0f, 1.0f);
}
triNormals[i / 3] = normal;
// Classify floor vs wall by normal.
// Wall threshold matches the runtime skip in checkWallCollision (absNz >= 0.35).
float absNz = std::abs(normal.z);
bool isFloor = (absNz >= 0.35f); // ~70° max slope (relaxed for steep stairs) bool isFloor = (absNz >= 0.35f); // ~70° max slope (relaxed for steep stairs)
bool isWall = (absNz < 0.65f); // Matches walkable slope threshold bool isWall = (absNz < 0.35f); // Matches checkWallCollision skip threshold
int cellMinX = std::max(0, static_cast<int>((triMinX - gridOrigin.x) * invCellW)); int cellMinX = std::max(0, static_cast<int>((triMinX - gridOrigin.x) * invCellW));
int cellMinY = std::max(0, static_cast<int>((triMinY - gridOrigin.y) * invCellH)); int cellMinY = std::max(0, static_cast<int>((triMinY - gridOrigin.y) * invCellH));
@ -2556,18 +2567,30 @@ void WMORenderer::GroupResources::getTrianglesInRange(
if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; if (cellMinX > cellMaxX || cellMinY > cellMaxY) return;
// Collect unique triangle indices from all overlapping cells // Collect unique triangle indices using visited bitset (O(n) dedup)
for (int cy = cellMinY; cy <= cellMaxY; ++cy) { bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY);
for (int cx = cellMinX; cx <= cellMaxX; ++cx) { if (multiCell && !triVisited.empty()) {
const auto& cell = cellTriangles[cy * gridCellsX + cx]; for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
out.insert(out.end(), cell.begin(), cell.end()); for (int cx = cellMinX; cx <= cellMaxX; ++cx) {
const auto& cell = cellTriangles[cy * gridCellsX + cx];
for (uint32_t tri : cell) {
uint32_t idx = tri / 3;
if (!triVisited[idx]) {
triVisited[idx] = 1;
out.push_back(tri);
}
}
}
}
// Clear visited bits
for (uint32_t tri : out) triVisited[tri / 3] = 0;
} else {
for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
for (int cx = cellMinX; cx <= cellMaxX; ++cx) {
const auto& cell = cellTriangles[cy * gridCellsX + cx];
out.insert(out.end(), cell.begin(), cell.end());
}
} }
}
// Remove duplicates (triangles spanning multiple cells)
if (cellMinX != cellMaxX || cellMinY != cellMaxY) {
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
} }
} }
@ -2589,16 +2612,28 @@ void WMORenderer::GroupResources::getFloorTrianglesInRange(
if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; if (cellMinX > cellMaxX || cellMinY > cellMaxY) return;
for (int cy = cellMinY; cy <= cellMaxY; ++cy) { bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY);
for (int cx = cellMinX; cx <= cellMaxX; ++cx) { if (multiCell && !triVisited.empty()) {
const auto& cell = cellFloorTriangles[cy * gridCellsX + cx]; for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
out.insert(out.end(), cell.begin(), cell.end()); for (int cx = cellMinX; cx <= cellMaxX; ++cx) {
const auto& cell = cellFloorTriangles[cy * gridCellsX + cx];
for (uint32_t tri : cell) {
uint32_t idx = tri / 3;
if (!triVisited[idx]) {
triVisited[idx] = 1;
out.push_back(tri);
}
}
}
}
for (uint32_t tri : out) triVisited[tri / 3] = 0;
} else {
for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
for (int cx = cellMinX; cx <= cellMaxX; ++cx) {
const auto& cell = cellFloorTriangles[cy * gridCellsX + cx];
out.insert(out.end(), cell.begin(), cell.end());
}
} }
}
if (cellMinX != cellMaxX || cellMinY != cellMaxY) {
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
} }
} }
@ -2620,22 +2655,35 @@ void WMORenderer::GroupResources::getWallTrianglesInRange(
if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; if (cellMinX > cellMaxX || cellMinY > cellMaxY) return;
for (int cy = cellMinY; cy <= cellMaxY; ++cy) { bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY);
for (int cx = cellMinX; cx <= cellMaxX; ++cx) { if (multiCell && !triVisited.empty()) {
const auto& cell = cellWallTriangles[cy * gridCellsX + cx]; for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
out.insert(out.end(), cell.begin(), cell.end()); for (int cx = cellMinX; cx <= cellMaxX; ++cx) {
const auto& cell = cellWallTriangles[cy * gridCellsX + cx];
for (uint32_t tri : cell) {
uint32_t idx = tri / 3;
if (!triVisited[idx]) {
triVisited[idx] = 1;
out.push_back(tri);
}
}
}
}
for (uint32_t tri : out) triVisited[tri / 3] = 0;
} else {
for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
for (int cx = cellMinX; cx <= cellMaxX; ++cx) {
const auto& cell = cellWallTriangles[cy * gridCellsX + cx];
out.insert(out.end(), cell.begin(), cell.end());
}
} }
}
if (cellMinX != cellMaxX || cellMinY != cellMaxY) {
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
} }
} }
std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const { std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const {
// All floor caching disabled - even per-frame cache can return stale results // Per-frame cache disabled: camera and player query the same (x,y) at
// when player Z changes between queries, causing fall-through at stairs. // different Z within a single frame. The allowAbove filter depends on glZ,
// so caching by (x,y) alone returns wrong floors across Z contexts.
QueryTimer timer(&queryTimeMs, &queryCallCount); QueryTimer timer(&queryTimeMs, &queryCallCount);
std::optional<float> bestFloor; std::optional<float> bestFloor;
@ -2660,9 +2708,9 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
group.getTrianglesInRange( group.getTrianglesInRange(
localOrigin.x - 1.0f, localOrigin.y - 1.0f, localOrigin.x - 1.0f, localOrigin.y - 1.0f,
localOrigin.x + 1.0f, localOrigin.y + 1.0f, localOrigin.x + 1.0f, localOrigin.y + 1.0f,
wallTriScratch); triScratch_);
for (uint32_t triStart : wallTriScratch) { for (uint32_t triStart : triScratch_) {
const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v0 = verts[indices[triStart]];
const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v1 = verts[indices[triStart + 1]];
const glm::vec3& v2 = verts[indices[triStart + 2]]; const glm::vec3& v2 = verts[indices[triStart + 2]];
@ -2676,31 +2724,64 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
glm::vec3 hitLocal = localOrigin + localDir * t; glm::vec3 hitLocal = localOrigin + localDir * t;
glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f)); glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f));
float allowAbove = model.isLowPlatform ? 12.0f : 2.0f; // Accept floors at or below glZ (the caller already elevates
if (hitWorld.z <= glZ + allowAbove) { // glZ by stepUpBudget to handle step-up range). Among those,
// pick the highest (closest to feet).
if (hitWorld.z <= glZ) {
if (!bestFloor || hitWorld.z > *bestFloor) { if (!bestFloor || hitWorld.z > *bestFloor) {
bestFloor = hitWorld.z; bestFloor = hitWorld.z;
bestFromLowPlatform = model.isLowPlatform; bestFromLowPlatform = model.isLowPlatform;
// Compute local normal and transform to world space // Use precomputed normal, ensure upward, transform to world
glm::vec3 localNormal = glm::cross(v1 - v0, v2 - v0); glm::vec3 localNormal = group.triNormals[triStart / 3];
float len = glm::length(localNormal); if (localNormal.z < 0.0f) localNormal = -localNormal;
if (len > 0.001f) { glm::vec3 worldNormal = glm::normalize(
localNormal /= len; glm::vec3(instance.modelMatrix * glm::vec4(localNormal, 0.0f)));
// Ensure normal points upward bestNormalZ = std::abs(worldNormal.z);
if (localNormal.z < 0.0f) localNormal = -localNormal;
glm::vec3 worldNormal = glm::normalize(
glm::vec3(instance.modelMatrix * glm::vec4(localNormal, 0.0f)));
bestNormalZ = std::abs(worldNormal.z);
}
} }
} }
} }
} }
}; };
// Full scan: test all instances (active group fast path removed to fix // Fast path: current active interior group and its neighbors are usually
// bridge clipping where early-return missed other WMO instances) // 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 result above is not
// early-returned because overlapping WMO instances need full coverage).
glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f); glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f);
glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f); glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f);
gatherCandidates(queryMin, queryMax, candidateScratch); gatherCandidates(queryMin, queryMax, candidateScratch);
@ -2720,6 +2801,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) {
@ -2859,9 +2943,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
float rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f; float rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f;
float rangeMaxX = std::max(localFrom.x, localTo.x) + PLAYER_RADIUS + 1.5f; float rangeMaxX = std::max(localFrom.x, localTo.x) + PLAYER_RADIUS + 1.5f;
float rangeMaxY = std::max(localFrom.y, localTo.y) + PLAYER_RADIUS + 1.5f; float rangeMaxY = std::max(localFrom.y, localTo.y) + PLAYER_RADIUS + 1.5f;
group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, wallTriScratch); group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_);
for (uint32_t triStart : wallTriScratch) { for (uint32_t triStart : triScratch_) {
// Use pre-computed Z bounds for fast vertical reject // Use pre-computed Z bounds for fast vertical reject
const auto& tb = group.triBounds[triStart / 3]; const auto& tb = group.triBounds[triStart / 3];
@ -2880,13 +2964,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v1 = verts[indices[triStart + 1]];
const glm::vec3& v2 = verts[indices[triStart + 2]]; const glm::vec3& v2 = verts[indices[triStart + 2]];
// Triangle normal for swept test and push fallback // Use precomputed normal for swept test and push fallback
glm::vec3 edge1 = v1 - v0; glm::vec3 normal = group.triNormals[triStart / 3];
glm::vec3 edge2 = v2 - v0; if (glm::dot(normal, normal) < 0.5f) continue; // degenerate
glm::vec3 normal = glm::cross(edge1, edge2);
float normalLen = glm::length(normal);
if (normalLen < 0.001f) continue;
normal /= normalLen;
// Recompute plane distances with current (possibly pushed) localTo // Recompute plane distances with current (possibly pushed) localTo
float fromDist = glm::dot(localFrom - v0, normal); float fromDist = glm::dot(localFrom - v0, normal);
@ -3229,19 +3309,15 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3
float rMinY = std::min(localOrigin.y, localEnd.y) - 1.0f; float rMinY = std::min(localOrigin.y, localEnd.y) - 1.0f;
float rMaxX = std::max(localOrigin.x, localEnd.x) + 1.0f; float rMaxX = std::max(localOrigin.x, localEnd.x) + 1.0f;
float rMaxY = std::max(localOrigin.y, localEnd.y) + 1.0f; float rMaxY = std::max(localOrigin.y, localEnd.y) + 1.0f;
group.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, wallTriScratch); group.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, triScratch_);
for (uint32_t triStart : wallTriScratch) { for (uint32_t triStart : triScratch_) {
const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v0 = verts[indices[triStart]];
const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v1 = verts[indices[triStart + 1]];
const glm::vec3& v2 = verts[indices[triStart + 2]]; const glm::vec3& v2 = verts[indices[triStart + 2]];
glm::vec3 triNormal = glm::cross(v1 - v0, v2 - v0); glm::vec3 triNormal = group.triNormals[triStart / 3];
float normalLenSq = glm::dot(triNormal, triNormal); if (glm::dot(triNormal, triNormal) < 0.5f) continue; // degenerate
if (normalLenSq < 1e-8f) { // Wall list pre-filters at 0.35; apply stricter camera threshold
continue;
}
triNormal /= std::sqrt(normalLenSq);
// Wall list pre-filters at 0.55; apply stricter camera threshold
if (std::abs(triNormal.z) > MAX_WALKABLE_ABS_NORMAL_Z) { if (std::abs(triNormal.z) > MAX_WALKABLE_ABS_NORMAL_Z) {
continue; continue;
} }

View file

@ -206,7 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
} }
} }
} }
if (renderer) { // Login screen music disabled
if (false && renderer) {
auto* music = renderer->getMusicManager(); auto* music = renderer->getMusicManager();
if (music) { if (music) {
if (!loginMusicVolumeAdjusted_) { if (!loginMusicVolumeAdjusted_) {
@ -220,50 +221,64 @@ 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{}());
// Tracks in assets/ root if (!introTracksScanned_) {
static const std::array<const char*, 1> kRootTracks = { introTracksScanned_ = true;
"Raise the Mug, Sound the Warcry.mp3",
};
// Tracks in assets/Original Music/
static const std::array<const char*, 11> kOriginalTracks = {
"Gold on the Tide in Booty Bay.mp3",
"Lanterns Over Lordaeron.mp3",
"Loot the Dogs.mp3",
"One More Pull.mp3",
"Roll Need Greed.mp3",
"RunBackPolka.mp3",
"The Barrens Has No End.mp3",
"The Bone Collector.mp3",
"Wanderwewill.mp3",
"WHO PULLED_.mp3",
"You No Take Candle!.mp3",
};
std::vector<std::string> availableTracks; // Tracks in assets/ root
auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) { static const std::array<const char*, 1> kRootTracks = {
std::filesystem::path p = base / track; "Raise the Mug, Sound the Warcry.mp3",
if (std::filesystem::exists(p)) { };
availableTracks.push_back(p.string()); // Tracks in assets/Original Music/
static const std::array<const char*, 11> kOriginalTracks = {
"Gold on the Tide in Booty Bay.mp3",
"Lanterns Over Lordaeron.mp3",
"Loot the Dogs.mp3",
"One More Pull.mp3",
"Roll Need Greed.mp3",
"RunBackPolka.mp3",
"The Barrens Has No End.mp3",
"The Bone Collector.mp3",
"Wanderwewill.mp3",
"WHO PULLED_.mp3",
"You No Take Candle!.mp3",
};
auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) {
std::filesystem::path p = base / track;
if (std::filesystem::exists(p)) {
introTracks_.push_back(p.string());
}
};
for (const char* track : kRootTracks) {
tryAddTrack("assets", track);
if (introTracks_.empty()) {
tryAddTrack(std::filesystem::current_path() / "assets", track);
}
} }
}; for (const char* track : kOriginalTracks) {
for (const char* track : kRootTracks) { tryAddTrack(std::filesystem::path("assets") / "Original Music", track);
tryAddTrack("assets", track); tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track);
if (availableTracks.empty()) }
tryAddTrack(std::filesystem::current_path() / "assets", track);
} std::sort(introTracks_.begin(), introTracks_.end());
for (const char* track : kOriginalTracks) { introTracks_.erase(std::unique(introTracks_.begin(), introTracks_.end()), introTracks_.end());
tryAddTrack(std::filesystem::path("assets") / "Original Music", track);
tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track);
} }
if (!availableTracks.empty()) { if (!introTracks_.empty()) {
std::uniform_int_distribution<size_t> pick(0, availableTracks.size() - 1); std::uniform_int_distribution<size_t> pick(0, introTracks_.size() - 1);
const std::string& path = availableTracks[pick(rng)]; 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();
} else { if (musicPlaying) {
LOG_INFO("AuthScreen: Playing login intro track: ", path);
} else {
// Drop bad paths to avoid retrying the same failed file every frame.
introTracks_.erase(introTracks_.begin() + idx);
}
} else if (!missingIntroTracksLogged_) {
LOG_WARNING("AuthScreen: No login intro tracks found in assets/"); 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();
// Open archives ONCE in main thread — StormLib has global state that is not
// thread-safe even with separate handles, so we serialize all MPQ reads.
struct SharedArchive {
HANDLE handle;
int priority;
std::string path;
};
std::vector<SharedArchive> sharedHandles;
for (const auto& ad : archives) {
HANDLE h = nullptr;
if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) {
sharedHandles.push_back({h, ad.priority, ad.path});
} else {
std::cerr << " Failed to open archive: " << ad.path << "\n";
}
}
if (sharedHandles.empty()) {
std::cerr << "Failed to open any archives for extraction\n";
return false;
}
if (sharedHandles.size() < archives.size()) {
std::cerr << " Opened " << sharedHandles.size()
<< "/" << archives.size() << " archives\n";
}
// Mutex protecting all StormLib calls (open/read/close are not thread-safe)
std::mutex mpqMutex;
auto workerFn = [&]() { auto workerFn = [&]() {
// Each thread opens ALL archives independently (StormLib is not thread-safe per handle). int failLogCount = 0;
// Sorted highest-priority last, so we iterate in reverse to find the winning version.
struct ThreadArchive {
HANDLE handle;
int priority;
};
std::vector<ThreadArchive> threadHandles;
for (const auto& ad : archives) {
HANDLE h = nullptr;
if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) {
threadHandles.push_back({h, ad.priority});
}
}
if (threadHandles.empty()) {
std::cerr << "Worker thread: failed to open any archives\n";
return;
}
while (true) { while (true) {
size_t idx = fileIndex.fetch_add(1); size_t idx = fileIndex.fetch_add(1);
@ -654,35 +672,52 @@ bool Extractor::run(const Options& opts) {
std::string mappedPath = PathMapper::mapPath(wowPath); std::string mappedPath = PathMapper::mapPath(wowPath);
std::string fullOutputPath = effectiveOutputDir + "/" + mappedPath; std::string fullOutputPath = effectiveOutputDir + "/" + mappedPath;
// Search archives in reverse priority order (highest priority first) // Read file data from MPQ under lock
HANDLE hFile = nullptr; std::vector<uint8_t> data;
for (auto it = threadHandles.rbegin(); it != threadHandles.rend(); ++it) { {
if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) { std::lock_guard<std::mutex> lock(mpqMutex);
break;
// Search archives in reverse priority order (highest priority first)
HANDLE hFile = nullptr;
for (auto it = sharedHandles.rbegin(); it != sharedHandles.rend(); ++it) {
if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) {
break;
}
hFile = nullptr;
}
if (!hFile) {
stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::cerr << " FAILED open: " << wowPath
<< " (tried " << sharedHandles.size() << " archives)\n";
}
continue;
} }
hFile = nullptr;
}
if (!hFile) {
stats.filesFailed++;
continue;
}
DWORD fileSize = SFileGetFileSize(hFile, nullptr); DWORD fileSize = SFileGetFileSize(hFile, nullptr);
if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) { if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) {
SFileCloseFile(hFile); SFileCloseFile(hFile);
stats.filesSkipped++; stats.filesSkipped++;
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);
stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::cerr << " FAILED read: " << wowPath
<< " (size=" << fileSize << ")\n";
}
continue;
}
SFileCloseFile(hFile); SFileCloseFile(hFile);
stats.filesFailed++; data.resize(bytesRead);
continue;
} }
SFileCloseFile(hFile); // Lock released — CRC computation and disk write happen in parallel
data.resize(bytesRead);
// 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";