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

4
.gitignore vendored
View file

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

View file

@ -6,6 +6,13 @@ set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Explicitly tag optimized configs so runtime defaults can enforce low-noise logging.
add_compile_definitions(
$<$<CONFIG:Release>:WOWEE_RELEASE_LOGGING>
$<$<CONFIG:RelWithDebInfo>:WOWEE_RELEASE_LOGGING>
$<$<CONFIG:MinSizeRel>:WOWEE_RELEASE_LOGGING>
)
# Output directories
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
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})..."
for raw in /tmp/wowee_composite_debug.raw /tmp/wowee_equip_composite_debug.raw; do
if [ -f "$raw" ]; then
png="${raw%.raw}.png"
# Try ImageMagick first, fall back to ffmpeg
if command -v convert &>/dev/null; then
convert -size ${W}x${H} -depth 8 rgba:"$raw" "$png" 2>/dev/null && \
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw"
elif command -v ffmpeg &>/dev/null; then
ffmpeg -y -f rawvideo -pix_fmt rgba -s ${W}x${H} -i "$raw" "$png" 2>/dev/null && \
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw"
else
echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw"
echo " Install: sudo apt install imagemagick"
fi
TMPD="${TMPDIR:-/tmp}"
# Find raw dumps — filenames include dimensions (e.g. wowee_composite_debug_1024x1024.raw)
shopt -s nullglob
RAW_FILES=("$TMPD"/wowee_*_debug*.raw)
shopt -u nullglob
if [ ${#RAW_FILES[@]} -eq 0 ]; then
echo "No debug dumps found in $TMPD"
echo " (looked for $TMPD/wowee_*_debug*.raw)"
exit 0
fi
for raw in "${RAW_FILES[@]}"; do
png="${raw%.raw}.png"
# Try ImageMagick first, fall back to ffmpeg
if command -v convert &>/dev/null; then
convert -size ${W}x${H} -depth 8 rgba:"$raw" "$png" 2>/dev/null && \
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw"
elif command -v ffmpeg &>/dev/null; then
ffmpeg -y -f rawvideo -pix_fmt rgba -s ${W}x${H} -i "$raw" "$png" 2>/dev/null && \
echo "Created $png (${W}x${H})" || \
echo "Failed to convert $raw"
else
echo "Not found: $raw"
echo "Need 'convert' (ImageMagick) or 'ffmpeg' to convert $raw"
echo " Install: sudo apt install imagemagick"
fi
done

View file

@ -244,7 +244,7 @@ private:
float x, y, z, orientation;
};
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 uint16_t MAX_CREATURE_SPAWN_RETRIES = 300;
std::unordered_set<uint64_t> pendingCreatureSpawnGuids_;

View file

@ -26,6 +26,10 @@ enum class LogLevel {
FATAL
};
// Avoid direct token use of `ERROR` at call sites because Windows headers
// define `ERROR` as a macro.
inline constexpr LogLevel kLogLevelError = LogLevel::ERROR;
class Logger {
public:
static Logger& getInstance();
@ -65,6 +69,13 @@ public:
}
private:
static constexpr int kDefaultMinLevelValue =
#if defined(NDEBUG) || defined(WOWEE_RELEASE_LOGGING)
static_cast<int>(LogLevel::WARNING);
#else
static_cast<int>(LogLevel::INFO);
#endif
Logger() = default;
~Logger() = default;
Logger(const Logger&) = delete;
@ -77,22 +88,61 @@ private:
return oss.str();
}
std::atomic<int> minLevel_{static_cast<int>(LogLevel::INFO)};
std::atomic<int> minLevel_{kDefaultMinLevelValue};
std::mutex mutex;
std::ofstream fileStream;
bool fileReady = false;
bool echoToStdout_ = true;
std::chrono::steady_clock::time_point lastFlushTime_{};
uint32_t flushIntervalMs_ = 250;
bool dedupeEnabled_ = true;
uint32_t dedupeWindowMs_ = 250;
LogLevel lastLevel_ = LogLevel::DEBUG;
std::string lastMessage_;
std::chrono::steady_clock::time_point lastMessageTime_{};
uint64_t suppressedCount_ = 0;
void emitLineLocked(LogLevel level, const std::string& message);
void flushSuppressedLocked();
void ensureFile();
};
// Convenience macros
#define LOG_DEBUG(...) wowee::core::Logger::getInstance().debug(__VA_ARGS__)
#define LOG_INFO(...) wowee::core::Logger::getInstance().info(__VA_ARGS__)
#define LOG_WARNING(...) wowee::core::Logger::getInstance().warning(__VA_ARGS__)
#define LOG_ERROR(...) wowee::core::Logger::getInstance().error(__VA_ARGS__)
#define LOG_FATAL(...) wowee::core::Logger::getInstance().fatal(__VA_ARGS__)
// Convenience macros.
// Guard calls at the macro site so variadic arguments are not evaluated
// when the corresponding level is disabled.
#define LOG_DEBUG(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::DEBUG)) { \
_wowee_logger.debug(__VA_ARGS__); \
} \
} while (0)
#define LOG_INFO(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::INFO)) { \
_wowee_logger.info(__VA_ARGS__); \
} \
} while (0)
#define LOG_WARNING(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::WARNING)) { \
_wowee_logger.warning(__VA_ARGS__); \
} \
} while (0)
#define LOG_ERROR(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::kLogLevelError)) { \
_wowee_logger.error(__VA_ARGS__); \
} \
} while (0)
#define LOG_FATAL(...) do { \
auto& _wowee_logger = wowee::core::Logger::getInstance(); \
if (_wowee_logger.shouldLog(wowee::core::LogLevel::FATAL)) { \
_wowee_logger.fatal(__VA_ARGS__); \
} \
} while (0)
} // namespace core
} // namespace wowee

View file

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

View file

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

View file

@ -390,13 +390,19 @@ private:
std::vector<std::vector<uint32_t>> cellTriangles;
// 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>> cellWallTriangles; // abs(normal.z) < 0.55
std::vector<std::vector<uint32_t>> cellFloorTriangles; // abs(normal.z) >= 0.35
std::vector<std::vector<uint32_t>> cellWallTriangles; // abs(normal.z) < 0.35
// Pre-computed per-triangle Z bounds for fast vertical reject
struct TriBounds { float minZ; float maxZ; };
std::vector<TriBounds> 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
void buildCollisionGrid();
@ -675,7 +681,7 @@ private:
std::unordered_map<GridCell, std::vector<uint32_t>, GridCellHash> spatialGrid;
std::unordered_map<uint32_t, size_t> instanceIndexById;
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;
// Parallel visibility culling

View file

@ -120,6 +120,9 @@ private:
bool musicInitAttempted = false;
bool musicPlaying = false;
bool missingIntroTracksLogged_ = false;
bool introTracksScanned_ = false;
std::vector<std::string> introTracks_;
bool loginMusicVolumeAdjusted_ = false;
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
for (auto& emitter : emitters_) {
float distance = glm::distance(emitter.position, cameraPos);
const glm::vec3 delta = emitter.position - cameraPos;
const float distSq = glm::dot(delta, delta);
// Determine max distance based on type
float maxDist = MAX_AMBIENT_DISTANCE;
@ -317,7 +318,8 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v
}
// Update active state based on distance AND limits
bool withinRange = (distance < maxDist);
const float maxDistSq = maxDist * maxDist;
const bool withinRange = (distSq < maxDistSq);
if (isFire && withinRange && activeFireCount < MAX_ACTIVE_FIRE) {
emitter.active = true;
@ -336,6 +338,9 @@ void AmbientSoundManager::updatePositionalEmitters(float deltaTime, const glm::v
// Update play timer
emitter.lastPlayTime += deltaTime;
// We only need the true distance for volume attenuation once the emitter is active.
const float distance = std::sqrt(distSq);
// Handle different emitter types
switch (emitter.type) {
case AmbientType::FIREPLACE_SMALL:

View file

@ -1,12 +1,59 @@
#include "auth/auth_packets.hpp"
#include "core/logger.hpp"
#include "network/net_platform.hpp"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <array>
namespace wowee {
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) {
// Convert account to uppercase
std::string upperAccount = account;
@ -66,8 +113,20 @@ network::Packet LogonChallengePacket::build(const std::string& account, const Cl
// Timezone
packet.writeUInt32(info.timezone);
// IP address (always 0)
packet.writeUInt32(0);
// Client IP: use the real outbound local IPv4 when detectable.
// Fallback to 0.0.0.0 if detection fails.
{
std::array<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
packet.writeUInt8(static_cast<uint8_t>(upperAccount.length()));

View file

@ -3,6 +3,7 @@
#include <fstream>
#include <sstream>
#include <vector>
namespace wowee {
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
// Turtle-specific DLLs as well.
const bool isTurtleExe = (exeName == "TurtleWoW.exe");
const char* kFilesBase[] = {
nullptr, // exeName
"fmod.dll",
"ijl15.dll",
"dbghelp.dll",
"unicows.dll",
// Some macOS client layouts use FMOD dylib naming instead of fmod.dll.
// We accept the first matching filename in each alias group.
std::vector<std::vector<std::string>> fileGroups = {
{ exeName },
{ "fmod.dll", "fmod.dylib", "libfmod.dylib", "fmodex.dll", "fmodex.dylib", "libfmod.so" },
{ "ijl15.dll" },
{ "dbghelp.dll" },
{ "unicows.dll" },
};
const char* kFilesTurtleExtra[] = {
"twloader.dll",
"twdiscord.dll",
};
std::vector<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) {
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::string err;
for (const auto& nameStr : files) {
std::vector<uint8_t> bytes;
std::string path = miscDir;
if (!path.empty() && path.back() != '/') path += '/';
path += nameStr;
if (!readWholeFile(path, bytes, err)) {
outError = err;
for (const auto& group : fileGroups) {
bool foundInGroup = false;
std::string groupErr;
for (const auto& nameStr : group) {
std::vector<uint8_t> bytes;
std::string path = miscDir;
if (!path.empty() && path.back() != '/') path += '/';
path += nameStr;
std::string err;
if (!readWholeFile(path, bytes, err)) {
if (groupErr.empty()) groupErr = err;
continue;
}
allFiles.insert(allFiles.end(), bytes.begin(), bytes.end());
foundInGroup = true;
break;
}
if (!foundInGroup) {
outError = groupErr.empty() ? "missing required integrity file group" : groupErr;
return false;
}
allFiles.insert(allFiles.end(), bytes.begin(), bytes.end());
}
// HMAC_SHA1(checksumSalt, allFiles)

View file

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

View file

@ -4,6 +4,8 @@
#include <ctime>
#include <filesystem>
#include <cstdlib>
#include <algorithm>
#include <cctype>
namespace wowee {
namespace core {
@ -28,20 +30,35 @@ void Logger::ensureFile() {
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::filesystem::create_directories("logs", ec);
fileStream.open("logs/wowee.log", std::ios::out | std::ios::trunc);
lastFlushTime_ = std::chrono::steady_clock::now();
}
void Logger::log(LogLevel level, const std::string& message) {
if (!shouldLog(level)) {
return;
}
std::lock_guard<std::mutex> lock(mutex);
ensureFile();
void Logger::emitLineLocked(LogLevel level, const std::string& message) {
// Get current time
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
@ -66,7 +83,7 @@ void Logger::log(LogLevel level, const std::string& message) {
case LogLevel::DEBUG: line << "DEBUG"; break;
case LogLevel::INFO: line << "INFO "; break;
case LogLevel::WARNING: line << "WARN "; break;
case LogLevel::ERROR: line << "ERROR"; break;
case kLogLevelError: line << "ERROR"; break;
case LogLevel::FATAL: line << "FATAL"; break;
}
@ -92,6 +109,38 @@ void Logger::log(LogLevel level, const std::string& message) {
}
}
void Logger::flushSuppressedLocked() {
if (suppressedCount_ == 0) return;
emitLineLocked(lastLevel_, "Previous message repeated " + std::to_string(suppressedCount_) + " times");
suppressedCount_ = 0;
}
void Logger::log(LogLevel level, const std::string& message) {
if (!shouldLog(level)) {
return;
}
std::lock_guard<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) {
minLevel_.store(static_cast<int>(level), std::memory_order_relaxed);
}

View file

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

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 "
<< moduleMemory_ << '\n';
// Parse copy/skip pairs (MaNGOS/TrinityCore format)
// Format: repeated [2B copy_count][copy_count bytes data][2B skip_count]
// Copy = copy from source to dest, Skip = advance dest pointer (zeros)
// Terminates when copy_count == 0
size_t pos = 4; // Skip 4-byte size header
size_t destOffset = 0;
int pairCount = 0;
auto readU16LE = [&](size_t at) -> uint16_t {
return static_cast<uint16_t>(exeData[at] | (exeData[at + 1] << 8));
};
while (pos + 2 <= exeData.size()) {
// Read copy count (2 bytes LE)
uint16_t copyCount = exeData[pos] | (exeData[pos + 1] << 8);
pos += 2;
enum class PairFormat {
CopyDataSkip, // [copy][data][skip]
SkipCopyData, // [skip][copy][data]
CopySkipData // [copy][skip][data]
};
if (copyCount == 0) {
break; // End of copy/skip pairs
}
auto tryParsePairs = [&](PairFormat format,
std::vector<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) {
if (pos + copyCount > exeData.size()) {
std::cerr << "[WardenModule] Copy section extends beyond data bounds" << '\n';
#ifdef _WIN32
VirtualFree(moduleMemory_, 0, MEM_RELEASE);
#else
munmap(moduleMemory_, moduleSize_);
#endif
moduleMemory_ = nullptr;
return false;
while (pos + 2 <= exeData.size()) {
uint16_t copyCount = 0;
uint16_t skipCount = 0;
switch (format) {
case PairFormat::CopyDataSkip: {
copyCount = readU16LE(pos);
pos += 2;
if (copyCount == 0) {
relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
}
if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
return false;
}
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
if (pos + 2 > exeData.size()) {
return false;
}
skipCount = readU16LE(pos);
pos += 2;
break;
}
case PairFormat::SkipCopyData: {
if (pos + 4 > exeData.size()) {
return false;
}
skipCount = readU16LE(pos);
pos += 2;
copyCount = readU16LE(pos);
pos += 2;
if (skipCount == 0 && copyCount == 0) {
relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
}
if (destOffset + skipCount > moduleSize_) {
return false;
}
destOffset += skipCount;
if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
return false;
}
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
break;
}
case PairFormat::CopySkipData: {
if (pos + 4 > exeData.size()) {
return false;
}
copyCount = readU16LE(pos);
pos += 2;
skipCount = readU16LE(pos);
pos += 2;
if (copyCount == 0 && skipCount == 0) {
relocPosOut = pos;
finalOffsetOut = destOffset;
pairCountOut = pairCount;
imageOut.resize(moduleSize_);
return true;
}
if (pos + copyCount > exeData.size() || destOffset + copyCount > moduleSize_) {
return false;
}
std::memcpy(imageOut.data() + destOffset, exeData.data() + pos, copyCount);
pos += copyCount;
destOffset += copyCount;
break;
}
}
if (destOffset + copyCount > moduleSize_) {
std::cerr << "[WardenModule] Copy section exceeds module size" << '\n';
#ifdef _WIN32
VirtualFree(moduleMemory_, 0, MEM_RELEASE);
#else
munmap(moduleMemory_, moduleSize_);
#endif
moduleMemory_ = nullptr;
if (destOffset + skipCount > moduleSize_) {
return false;
}
std::memcpy(
static_cast<uint8_t*>(moduleMemory_) + destOffset,
exeData.data() + pos,
copyCount
);
pos += copyCount;
destOffset += copyCount;
destOffset += skipCount;
pairCount++;
}
// Read skip count (2 bytes LE)
uint16_t skipCount = 0;
if (pos + 2 <= exeData.size()) {
skipCount = exeData[pos] | (exeData[pos + 1] << 8);
pos += 2;
}
return false;
};
// Advance dest pointer by skipCount (gaps are zero-filled from memset)
destOffset += skipCount;
std::vector<uint8_t> parsedImage;
size_t parsedRelocPos = 0;
size_t parsedFinalOffset = 0;
int parsedPairCount = 0;
pairCount++;
std::cout << "[WardenModule] Pair " << pairCount << ": copy " << copyCount
<< ", skip " << skipCount << " (dest offset=" << destOffset << ")" << '\n';
PairFormat usedFormat = PairFormat::CopyDataSkip;
bool parsed = tryParsePairs(PairFormat::CopyDataSkip, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount);
if (!parsed) {
usedFormat = PairFormat::SkipCopyData;
parsed = tryParsePairs(PairFormat::SkipCopyData, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount);
}
if (!parsed) {
usedFormat = PairFormat::CopySkipData;
parsed = tryParsePairs(PairFormat::CopySkipData, parsedImage, parsedRelocPos, parsedFinalOffset, parsedPairCount);
}
// Save position — remaining decompressed data contains relocation entries
relocDataOffset_ = pos;
if (parsed) {
std::memcpy(moduleMemory_, parsedImage.data(), parsedImage.size());
relocDataOffset_ = parsedRelocPos;
std::cout << "[WardenModule] Parsed " << pairCount << " skip/copy pairs, final offset: "
<< destOffset << "/" << finalCodeSize << '\n';
std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_
<< " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n';
const char* formatName = "copy/data/skip";
if (usedFormat == PairFormat::SkipCopyData) formatName = "skip/copy/data";
if (usedFormat == PairFormat::CopySkipData) formatName = "copy/skip/data";
std::cout << "[WardenModule] Parsed " << parsedPairCount << " pairs using format "
<< formatName << ", final offset: " << parsedFinalOffset << "/" << finalCodeSize << '\n';
std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_
<< " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n';
return true;
}
// Fallback: copy raw payload (without the 4-byte size header) into module memory.
// This keeps loading alive for servers where packet flow can continue with hash/check fallbacks.
if (exeData.size() > 4) {
size_t rawCopySize = std::min(moduleSize_, exeData.size() - 4);
std::memcpy(moduleMemory_, exeData.data() + 4, rawCopySize);
}
relocDataOffset_ = 0;
std::cerr << "[WardenModule] Could not parse copy/skip pairs (all known layouts failed); using raw payload fallback" << '\n';
return true;
}

View file

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

View file

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

View file

@ -65,6 +65,23 @@ std::optional<float> selectClosestFloor(const std::optional<float>& a,
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
CameraController::CameraController(Camera* cam) : camera(cam) {
@ -126,6 +143,8 @@ void CameraController::update(float deltaTime) {
if (!enabled || !camera) {
return;
}
// Keep physics integration stable during render hitches to avoid floor tunneling.
const float physicsDeltaTime = std::min(deltaTime, 1.0f / 30.0f);
// During taxi flights, skip movement logic but keep camera orbit/zoom controls.
if (externalFollow_) {
@ -360,6 +379,7 @@ void CameraController::update(float deltaTime) {
if (thirdPerson && followTarget) {
// Move the follow target (character position) instead of the camera
glm::vec3 targetPos = *followTarget;
const glm::vec3 prevTargetPos = *followTarget;
if (!externalFollow_) {
if (wmoRenderer) {
wmoRenderer->setCollisionFocus(targetPos, COLLISION_FOCUS_RADIUS_THIRD_PERSON);
@ -403,6 +423,68 @@ void CameraController::update(float deltaTime) {
float depthFromFeet = (*waterH - targetPos.z);
inWater = (floorH && ((*waterH - *floorH) >= MIN_SWIM_WATER_DEPTH)) ||
(!floorH && (depthFromFeet >= MIN_SWIM_WATER_DEPTH));
// Ramp exit assist: when swimming forward near the surface toward a
// reachable floor (dock/shore ramp), switch to walking sooner.
if (swimming && inWater && floorH && nowForward) {
float floorDelta = *floorH - targetPos.z;
float waterOverFloor = *waterH - *floorH;
bool nearSurface = depthFromFeet <= 1.45f;
bool reachableRamp = (floorDelta >= -0.30f && floorDelta <= 1.10f);
bool shallowRampWater = waterOverFloor <= 1.55f;
bool notDiving = forward3D.z > -0.20f;
if (nearSurface && reachableRamp && shallowRampWater && notDiving) {
inWater = false;
}
}
// Forward plank/ramp assist: sample structure floors ahead so water exit
// can happen when the ramp is in front of us (not only under our feet).
if (swimming && inWater && nowForward && forward3D.z > -0.20f) {
auto queryFloorAt = [&](float x, float y, float probeZ) -> std::optional<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.
@ -442,7 +524,7 @@ void CameraController::update(float deltaTime) {
if (glm::length(swimMove) > 0.001f) {
swimMove = glm::normalize(swimMove);
targetPos += swimMove * swimSpeed * deltaTime;
targetPos += swimMove * swimSpeed * physicsDeltaTime;
}
// Spacebar = swim up (continuous, not a jump)
@ -451,7 +533,7 @@ void CameraController::update(float deltaTime) {
verticalVelocity = SWIM_BUOYANCY;
} else {
// Gentle sink when not pressing space
verticalVelocity += SWIM_GRAVITY * deltaTime;
verticalVelocity += SWIM_GRAVITY * physicsDeltaTime;
if (verticalVelocity < SWIM_SINK_SPEED) {
verticalVelocity = SWIM_SINK_SPEED;
}
@ -459,15 +541,15 @@ void CameraController::update(float deltaTime) {
// you afloat unless you're intentionally diving.
if (!diveIntent) {
float surfaceErr = (waterSurfaceZ - targetPos.z);
verticalVelocity += surfaceErr * 7.0f * deltaTime;
verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime);
verticalVelocity += surfaceErr * 7.0f * physicsDeltaTime;
verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * physicsDeltaTime);
if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) {
verticalVelocity = 0.0f;
}
}
}
targetPos.z += verticalVelocity * deltaTime;
targetPos.z += verticalVelocity * physicsDeltaTime;
// Don't rise above water surface
if (waterH && targetPos.z > *waterH - WATER_SURFACE_OFFSET) {
@ -486,17 +568,42 @@ void CameraController::update(float deltaTime) {
if (updateFloorCache) {
floorQueryFrameCounter = 0;
lastFloorQueryPos = targetPos;
constexpr float MAX_SWIM_FLOOR_ABOVE_FEET = 0.25f;
constexpr float MIN_SWIM_CEILING_ABOVE_FEET = 0.30f;
constexpr float MAX_SWIM_CEILING_ABOVE_FEET = 1.80f;
std::optional<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) {
floorH = terrainManager->getHeightAt(targetPos.x, targetPos.y);
considerFloor(terrainManager->getHeightAt(targetPos.x, targetPos.y));
}
if (wmoRenderer) {
auto wh = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f);
if (wh && (!floorH || *wh > *floorH)) floorH = wh;
considerFloor(wh);
}
if (m2Renderer && !externalFollow_) {
auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z);
if (mh && (!floorH || *mh > *floorH)) floorH = mh;
auto mh = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + 2.0f);
considerFloor(mh);
}
if (ceilingH && verticalVelocity > 0.0f) {
float ceilingLimit = *ceilingH - 0.35f;
if (targetPos.z > ceilingLimit) {
targetPos.z = ceilingLimit;
verticalVelocity = 0.0f;
}
}
cachedFloorHeight = floorH;
@ -557,7 +664,7 @@ void CameraController::update(float deltaTime) {
if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement);
targetPos += movement * speed * deltaTime;
targetPos += movement * speed * physicsDeltaTime;
}
// Jump with input buffering and coyote time
@ -572,12 +679,12 @@ void CameraController::update(float deltaTime) {
coyoteTimer = 0.0f;
}
jumpBufferTimer -= deltaTime;
coyoteTimer -= deltaTime;
jumpBufferTimer -= physicsDeltaTime;
coyoteTimer -= physicsDeltaTime;
// Apply gravity
verticalVelocity += gravity * deltaTime;
targetPos.z += verticalVelocity * deltaTime;
verticalVelocity += gravity * physicsDeltaTime;
targetPos.z += verticalVelocity * physicsDeltaTime;
}
} else {
// External follow (e.g., taxi): trust server position without grounding.
@ -589,14 +696,21 @@ void CameraController::update(float deltaTime) {
// Refresh inside-WMO state before collision/grounding so we don't use stale
// terrain-first caches while entering enclosed tunnel/building spaces.
if (wmoRenderer && !externalFollow_) {
bool prevInside = cachedInsideWMO;
bool prevInsideInterior = cachedInsideInteriorWMO;
cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr);
cachedInsideInteriorWMO = wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f);
if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) {
hasCachedFloor_ = false;
hasCachedCamFloor = false;
cachedPivotLift_ = 0.0f;
const float insideDist = glm::length(targetPos - lastInsideStateCheckPos_);
if (++insideStateCheckCounter_ >= 2 || insideDist > 0.35f) {
insideStateCheckCounter_ = 0;
lastInsideStateCheckPos_ = targetPos;
bool prevInside = cachedInsideWMO;
bool prevInsideInterior = cachedInsideInteriorWMO;
cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr);
cachedInsideInteriorWMO = cachedInsideWMO &&
wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f);
if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) {
hasCachedFloor_ = false;
hasCachedCamFloor = false;
cachedPivotLift_ = 0.0f;
}
}
}
@ -654,15 +768,17 @@ void CameraController::update(float deltaTime) {
// WMO tunnel/bridge ramps are often steeper than outdoor terrain ramps.
constexpr float MIN_WALKABLE_NORMAL_TERRAIN = 0.7f; // ~45°
constexpr float MIN_WALKABLE_NORMAL_WMO = 0.45f; // allow tunnel ramps
constexpr float MIN_WALKABLE_NORMAL_M2 = 0.45f; // allow bridge/deck ramps
std::optional<float> groundH;
std::optional<float> centerTerrainH;
std::optional<float> centerWmoH;
std::optional<float> centerM2H;
{
// Collision cache: skip expensive checks if barely moved (15cm threshold)
float distMoved = glm::length(glm::vec2(targetPos.x, targetPos.y) -
glm::vec2(lastCollisionCheckPos_.x, lastCollisionCheckPos_.y));
bool useCached = hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE;
bool useCached = grounded && hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE;
if (useCached) {
// Never trust cached ground while actively descending or when
// vertical drift from cached floor is meaningful.
@ -678,6 +794,7 @@ void CameraController::update(float deltaTime) {
// Full collision check
std::optional<float> terrainH;
std::optional<float> wmoH;
std::optional<float> m2H;
if (terrainManager) {
terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y);
}
@ -689,6 +806,13 @@ void CameraController::update(float deltaTime) {
if (wmoRenderer) {
wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &wmoNormalZ);
}
if (m2Renderer && !externalFollow_) {
float m2NormalZ = 1.0f;
m2H = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &m2NormalZ);
if (m2H && m2NormalZ < MIN_WALKABLE_NORMAL_M2) {
m2H = std::nullopt;
}
}
// Reject steep WMO slopes
float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN;
@ -704,6 +828,7 @@ void CameraController::update(float deltaTime) {
}
centerTerrainH = terrainH;
centerWmoH = wmoH;
centerM2H = m2H;
// Guard against extremely bad WMO void ramps, but keep normal tunnel
// transitions valid. Only reject when the WMO sample is implausibly far
@ -739,10 +864,10 @@ void CameraController::update(float deltaTime) {
// to avoid oscillating between top terrain and deep WMO floors.
groundH = selectClosestFloor(terrainH, wmoH, targetPos.z);
} else {
groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget);
groundH = selectReachableFloor3(terrainH, wmoH, m2H, targetPos.z, stepUpBudget);
}
} else {
groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget);
groundH = selectReachableFloor3(terrainH, wmoH, m2H, targetPos.z, stepUpBudget);
}
// Update cache
@ -759,13 +884,29 @@ void CameraController::update(float deltaTime) {
// Transition safety: if no reachable floor was selected, choose the higher
// of terrain/WMO center surfaces when it is still near the player.
// This avoids dropping into void gaps at terrain<->WMO seams.
const bool nearWmoSpace = cachedInsideWMO || centerWmoH.has_value();
bool nearStructureSpace = nearWmoSpace || centerM2H.has_value();
if (!nearStructureSpace && hasRealGround_) {
// Plank-gap hint: center probes can miss sparse bridge segments.
// Probe once around last known ground before allowing a full drop.
if (wmoRenderer) {
auto whHint = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, lastGroundZ + 1.5f);
if (whHint && std::abs(*whHint - lastGroundZ) <= 2.0f) nearStructureSpace = true;
}
if (!nearStructureSpace && m2Renderer && !externalFollow_) {
float nz = 1.0f;
auto mhHint = m2Renderer->getFloorHeight(targetPos.x, targetPos.y, lastGroundZ + 1.5f, &nz);
if (mhHint && nz >= MIN_WALKABLE_NORMAL_M2 &&
std::abs(*mhHint - lastGroundZ) <= 2.0f) nearStructureSpace = true;
}
}
if (!groundH) {
auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, std::nullopt);
auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, centerM2H);
if (highestCenter) {
float dz = targetPos.z - *highestCenter;
// Keep this fallback narrow: only for WMO seam cases, or very short
// transient misses while still almost touching the last floor.
bool allowFallback = cachedInsideWMO || (noGroundTimer_ < 0.10f && dz < 0.6f);
bool allowFallback = nearStructureSpace || (noGroundTimer_ < 0.10f && dz < 0.6f);
if (allowFallback && dz >= -0.5f && dz < 2.0f) {
groundH = highestCenter;
}
@ -774,7 +915,7 @@ void CameraController::update(float deltaTime) {
// Continuity guard only for WMO seam overlap: avoid instantly switching to a
// much lower floor sample at tunnel mouths (bad WMO ramp chains into void).
if (groundH && hasRealGround_ && cachedInsideWMO && !cachedInsideInteriorWMO) {
if (groundH && hasRealGround_ && nearWmoSpace && !cachedInsideInteriorWMO) {
float dropFromLast = lastGroundZ - *groundH;
if (dropFromLast > 1.5f) {
if (centerTerrainH && *centerTerrainH > *groundH + 1.5f) {
@ -785,7 +926,7 @@ void CameraController::update(float deltaTime) {
// Seam stability: while overlapping WMO shells, cap how fast floor height can
// step downward in a single frame to avoid following bad ramp samples into void.
if (groundH && cachedInsideWMO && !cachedInsideInteriorWMO && lastGroundZ > 1.0f) {
if (groundH && nearWmoSpace && !cachedInsideInteriorWMO && lastGroundZ > 1.0f) {
float maxDropPerFrame = (verticalVelocity < -8.0f) ? 2.0f : 0.60f;
float minAllowed = lastGroundZ - maxDropPerFrame;
// Extra seam guard: outside interior groups, avoid accepting floors that
@ -802,9 +943,19 @@ void CameraController::update(float deltaTime) {
}
}
// Structure continuity guard: if a floor query suddenly jumps far below
// recent support while near dock/bridge geometry, keep a conservative
// support height to avoid dropping through sparse collision seams.
if (groundH && hasRealGround_ && nearStructureSpace && !nowJump) {
float dropFromLast = lastGroundZ - *groundH;
if (dropFromLast > 1.0f && verticalVelocity > -6.0f) {
*groundH = std::max(*groundH, lastGroundZ - 0.20f);
}
}
// 1b. Multi-sample WMO floors when in/near WMO space to avoid
// falling through narrow board/plank gaps where center ray misses.
if (wmoRenderer && cachedInsideWMO) {
if (wmoRenderer && nearWmoSpace) {
constexpr float WMO_FOOTPRINT = 0.35f;
const glm::vec2 wmoOffsets[] = {
{0.0f, 0.0f},
@ -827,7 +978,7 @@ void CameraController::update(float deltaTime) {
// Keep to nearby, walkable steps only.
if (*wh > targetPos.z + stepUpBudget) continue;
if (*wh < targetPos.z - 2.5f) continue;
if (*wh < lastGroundZ - 3.5f) continue;
if (!groundH || *wh > *groundH) {
groundH = wh;
@ -835,14 +986,112 @@ void CameraController::update(float deltaTime) {
}
}
// WMO recovery probe: when no floor is found while descending, do a wider
// footprint sample around the player to catch narrow plank/stair misses.
if (!groundH && wmoRenderer && hasRealGround_ && verticalVelocity <= 0.0f) {
constexpr float RESCUE_FOOTPRINT = 0.65f;
const glm::vec2 rescueOffsets[] = {
{0.0f, 0.0f},
{ RESCUE_FOOTPRINT, 0.0f}, {-RESCUE_FOOTPRINT, 0.0f},
{0.0f, RESCUE_FOOTPRINT}, {0.0f, -RESCUE_FOOTPRINT},
{ RESCUE_FOOTPRINT, RESCUE_FOOTPRINT},
{ RESCUE_FOOTPRINT, -RESCUE_FOOTPRINT},
{-RESCUE_FOOTPRINT, RESCUE_FOOTPRINT},
{-RESCUE_FOOTPRINT, -RESCUE_FOOTPRINT}
};
float rescueProbeZ = std::max(lastGroundZ, targetPos.z) + stepUpBudget + 1.2f;
std::optional<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) —
// these are narrow and need offset probes to detect reliably.
if (m2Renderer && !externalFollow_) {
constexpr float FOOTPRINT = 0.4f;
constexpr float FOOTPRINT = 0.6f;
const glm::vec2 offsets[] = {
{0.0f, 0.0f},
{FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f},
{0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT}
{0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT},
{FOOTPRINT, FOOTPRINT}, {FOOTPRINT, -FOOTPRINT},
{-FOOTPRINT, FOOTPRINT}, {-FOOTPRINT, -FOOTPRINT}
};
float m2ProbeZ = std::max(targetPos.z, lastGroundZ) + 6.0f;
for (const auto& o : offsets) {
@ -895,15 +1144,33 @@ void CameraController::update(float deltaTime) {
}
} else {
hasRealGround_ = false;
noGroundTimer_ += deltaTime;
noGroundTimer_ += physicsDeltaTime;
float dropFromLastGround = lastGroundZ - targetPos.z;
bool seamSizedGap = dropFromLastGround <= 0.35f;
bool seamSizedGap = dropFromLastGround <= (nearStructureSpace ? 2.5f : 0.35f);
if (noGroundTimer_ < NO_GROUND_GRACE && seamSizedGap) {
// Micro-gap grace only: keep continuity for tiny seam misses,
// but never convert air into persistent ground.
targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f);
// Near WMO floors, prefer continuity over falling on transient
// floor-query misses (stairs/planks/portal seams).
float maxSlip = nearStructureSpace ? 1.0f : 0.10f;
targetPos.z = std::max(targetPos.z, lastGroundZ - maxSlip);
if (nearStructureSpace && verticalVelocity < -2.0f) {
verticalVelocity = -2.0f;
}
grounded = false;
} else if (nearStructureSpace && noGroundTimer_ < 1.0f && dropFromLastGround <= 3.0f) {
// Extended WMO rescue window: hold close to last valid floor so we
// do not tunnel through walkable geometry during short hitches.
targetPos.z = std::max(targetPos.z, lastGroundZ - 0.35f);
if (verticalVelocity < -1.5f) {
verticalVelocity = -1.5f;
}
grounded = false;
} else if (nearStructureSpace && noGroundTimer_ < 1.20f && dropFromLastGround <= 4.0f && !nowJump) {
// Extended adhesion for sparse dock/bridge collision: keep us on the
// last valid support long enough for adjacent structure probes to hit.
targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f);
if (verticalVelocity < -0.5f) verticalVelocity = -0.5f;
grounded = true;
} else {
grounded = false;
}
@ -918,7 +1185,7 @@ void CameraController::update(float deltaTime) {
// Player is safely on real geometry — save periodically
continuousFallTime_ = 0.0f;
autoUnstuckFired_ = false;
safePosSaveTimer_ += deltaTime;
safePosSaveTimer_ += physicsDeltaTime;
if (safePosSaveTimer_ >= SAFE_POS_SAVE_INTERVAL) {
safePosSaveTimer_ = 0.0f;
lastSafePos_ = targetPos;
@ -926,7 +1193,7 @@ void CameraController::update(float deltaTime) {
}
} else if (!grounded && !swimming && !externalFollow_) {
// Falling (or standing on nothing past grace period) — accumulate fall time
continuousFallTime_ += deltaTime;
continuousFallTime_ += physicsDeltaTime;
if (continuousFallTime_ >= AUTO_UNSTUCK_FALL_TIME && !autoUnstuckFired_) {
autoUnstuckFired_ = true;
if (autoUnstuckCallback_) {
@ -1005,28 +1272,8 @@ void CameraController::update(float deltaTime) {
// Find max safe distance using raycast + sphere radius
collisionDistance = currentDistance;
// WMO raycast collision: zoom in when camera would clip through walls/floors
if (wmoRenderer && currentDistance > MIN_DISTANCE) {
glm::vec3 camRayOrigin = pivot;
glm::vec3 camRayDir = camDir;
float wmoHitDist = wmoRenderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance);
if (wmoHitDist < currentDistance) {
// Hit WMO geometry — pull camera in to avoid clipping
constexpr float CAM_RADIUS = 0.3f;
collisionDistance = std::max(MIN_DISTANCE, wmoHitDist - CAM_RADIUS);
}
}
// M2 raycast collision: zoom in when camera would clip through doodads
if (m2Renderer && !externalFollow_ && currentDistance > MIN_DISTANCE) {
glm::vec3 camRayOrigin = pivot;
glm::vec3 camRayDir = camDir;
float m2HitDist = m2Renderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance);
if (m2HitDist < collisionDistance) {
constexpr float CAM_RADIUS = 0.3f;
collisionDistance = std::max(MIN_DISTANCE, m2HitDist - CAM_RADIUS);
}
}
// WMO/M2 camera collision disabled — was pulling camera through
// geometry at doorway transitions and causing erratic zoom behaviour.
// Camera collision: terrain-only floor clamping
auto getTerrainFloorAt = [&](float x, float y) -> std::optional<float> {
@ -1179,27 +1426,27 @@ void CameraController::update(float deltaTime) {
if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement);
newPos += movement * swimSpeed * deltaTime;
newPos += movement * swimSpeed * physicsDeltaTime;
}
if (nowJump) {
verticalVelocity = SWIM_BUOYANCY;
} else {
verticalVelocity += SWIM_GRAVITY * deltaTime;
verticalVelocity += SWIM_GRAVITY * physicsDeltaTime;
if (verticalVelocity < SWIM_SINK_SPEED) {
verticalVelocity = SWIM_SINK_SPEED;
}
if (!diveIntent) {
float surfaceErr = (waterSurfaceCamZ - newPos.z);
verticalVelocity += surfaceErr * 7.0f * deltaTime;
verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * deltaTime);
verticalVelocity += surfaceErr * 7.0f * physicsDeltaTime;
verticalVelocity *= std::max(0.0f, 1.0f - 3.2f * physicsDeltaTime);
if (std::abs(surfaceErr) < 0.06f && std::abs(verticalVelocity) < 0.35f) {
verticalVelocity = 0.0f;
}
}
}
newPos.z += verticalVelocity * deltaTime;
newPos.z += verticalVelocity * physicsDeltaTime;
// Don't rise above water surface (feet at water level)
if (waterH && (newPos.z - eyeHeight) > *waterH - WATER_SURFACE_OFFSET) {
@ -1213,7 +1460,7 @@ void CameraController::update(float deltaTime) {
if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement);
newPos += movement * speed * deltaTime;
newPos += movement * speed * physicsDeltaTime;
}
// Jump with input buffering and coyote time
@ -1227,12 +1474,12 @@ void CameraController::update(float deltaTime) {
coyoteTimer = 0.0f;
}
jumpBufferTimer -= deltaTime;
coyoteTimer -= deltaTime;
jumpBufferTimer -= physicsDeltaTime;
coyoteTimer -= physicsDeltaTime;
// Apply gravity
verticalVelocity += gravity * deltaTime;
newPos.z += verticalVelocity * deltaTime;
verticalVelocity += gravity * physicsDeltaTime;
newPos.z += verticalVelocity * physicsDeltaTime;
}
// Wall sweep collision before grounding (skip when stationary).

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::to_string(width) + "x" + std::to_string(height) + ".raw";
std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" +
std::to_string(width) + "x" + std::to_string(height) + ".raw")).string();
std::ofstream dump(dumpPath, std::ios::binary);
if (dump) {
dump.write(reinterpret_cast<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) {
// Distance culling for animation updates (150 unit radius)
const float animUpdateRadiusSq = 150.0f * 150.0f;
// Distance culling for animation updates in dense areas.
const float animUpdateRadius = static_cast<float>(envSizeOrDefault("WOWEE_CHAR_ANIM_RADIUS", 120));
const float animUpdateRadiusSq = animUpdateRadius * animUpdateRadius;
// Update fade-in opacity
for (auto& [id, inst] : instances) {
@ -1404,6 +1405,7 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
for (auto& pair : instances) {
auto& instance = pair.second;
if (instance.weaponAttachments.empty()) continue;
if (glm::distance2(instance.position, cameraPos) > animUpdateRadiusSq) continue;
glm::mat4 charModelMat = instance.hasOverrideModelMatrix
? instance.overrideModelMatrix
@ -1614,6 +1616,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
if (instances.empty() || !opaquePipeline_) {
return;
}
const float renderRadius = static_cast<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 frameSlot = frameIndex % 2u;
@ -1647,6 +1655,18 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
// Skip invisible instances (e.g., player in first-person mode)
if (!instance.visible) continue;
// Character instance culling: avoid drawing far-away / strongly behind-camera
// actors in dense city scenes.
if (!instance.hasOverrideModelMatrix) {
glm::vec3 toInst = instance.position - camPos;
float distSq = glm::dot(toInst, toInst);
if (distSq > renderRadiusSq) continue;
if (distSq > nearNoConeCullSq) {
float invDist = 1.0f / std::sqrt(distSq);
float facingDot = glm::dot(toInst, camForward) * invDist;
if (facingDot < backfaceDotCull) continue;
}
}
auto modelIt = models.find(instance.modelId);
if (modelIt == models.end()) continue;

View file

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

View file

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

View file

@ -99,6 +99,15 @@ static bool envFlagEnabled(const char* key, bool defaultValue) {
return !(v == "0" || v == "false" || v == "off" || v == "no");
}
static int envIntOrDefault(const char* key, int defaultValue) {
const char* raw = std::getenv(key);
if (!raw || !*raw) return defaultValue;
char* end = nullptr;
long n = std::strtol(raw, &end, 10);
if (end == raw) return defaultValue;
return static_cast<int>(n);
}
static std::vector<std::string> parseEmoteCommands(const std::string& raw) {
std::vector<std::string> out;
std::string cur;
@ -2678,15 +2687,19 @@ void Renderer::update(float deltaTime) {
}
}
const bool canQueryWmo = (camera && wmoRenderer);
const glm::vec3 camPos = camera ? camera->getPosition() : glm::vec3(0.0f);
uint32_t insideWmoId = 0;
const bool insideWmo = canQueryWmo &&
wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &insideWmoId);
// Ambient environmental sounds: fireplaces, water, birds, etc.
if (ambientSoundManager && camera && wmoRenderer && cameraController) {
glm::vec3 camPos = camera->getPosition();
uint32_t wmoId = 0;
bool isIndoor = wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &wmoId);
bool isIndoor = insideWmo;
bool isSwimming = cameraController->isSwimming();
// Check if inside blacksmith (96048 = Goldshire blacksmith)
bool isBlacksmith = (wmoId == 96048);
bool isBlacksmith = (insideWmoId == 96048);
// Sync weather audio with visual weather system
if (weather) {
@ -2747,9 +2760,8 @@ void Renderer::update(float deltaTime) {
// Override with WMO-based detection (e.g., inside Stormwind, taverns, blacksmiths)
if (wmoRenderer) {
glm::vec3 camPos = camera->getPosition();
uint32_t wmoModelId = 0;
if (wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &wmoModelId)) {
uint32_t wmoModelId = insideWmoId;
if (insideWmo) {
// Check if inside Stormwind WMO (model ID 10047)
if (wmoModelId == 10047) {
zoneId = 1519; // Stormwind City
@ -3839,6 +3851,19 @@ void Renderer::renderShadowPass() {
if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return;
if (currentCmd == VK_NULL_HANDLE) return;
const int baseInterval = std::max(1, envIntOrDefault("WOWEE_SHADOW_INTERVAL", 1));
const int denseInterval = std::max(baseInterval, envIntOrDefault("WOWEE_SHADOW_INTERVAL_DENSE", 3));
const uint32_t denseCharThreshold = static_cast<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
lightSpaceMatrix = computeLightSpaceMatrix();
// Zero matrix means character position isn't set yet — skip shadow pass entirely.
@ -3890,15 +3915,17 @@ void Renderer::renderShadowPass() {
vkCmdSetScissor(currentCmd, 0, 1, &sc);
// Phase 7/8: render shadow casters
constexpr float kShadowCullRadius = 180.0f; // match kShadowHalfExtent
const float baseShadowCullRadius = static_cast<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) {
wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius);
wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius);
}
if (m2Renderer) {
m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, kShadowCullRadius);
m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, shadowCullRadius);
}
if (characterRenderer) {
characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius);
characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius);
}
vkCmdEndRenderPass(currentCmd);

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

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();
if (music) {
if (!loginMusicVolumeAdjusted_) {
@ -220,50 +221,64 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
music->update(ImGui::GetIO().DeltaTime);
if (!music->isPlaying()) {
static std::mt19937 rng(std::random_device{}());
// Tracks in assets/ root
static const std::array<const char*, 1> kRootTracks = {
"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",
};
if (!introTracksScanned_) {
introTracksScanned_ = true;
std::vector<std::string> availableTracks;
auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) {
std::filesystem::path p = base / track;
if (std::filesystem::exists(p)) {
availableTracks.push_back(p.string());
// Tracks in assets/ root
static const std::array<const char*, 1> kRootTracks = {
"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",
};
auto tryAddTrack = [&](const std::filesystem::path& base, const char* track) {
std::filesystem::path p = base / track;
if (std::filesystem::exists(p)) {
introTracks_.push_back(p.string());
}
};
for (const char* track : kRootTracks) {
tryAddTrack("assets", track);
if (introTracks_.empty()) {
tryAddTrack(std::filesystem::current_path() / "assets", track);
}
}
};
for (const char* track : kRootTracks) {
tryAddTrack("assets", track);
if (availableTracks.empty())
tryAddTrack(std::filesystem::current_path() / "assets", track);
}
for (const char* track : kOriginalTracks) {
tryAddTrack(std::filesystem::path("assets") / "Original Music", track);
tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track);
for (const char* track : kOriginalTracks) {
tryAddTrack(std::filesystem::path("assets") / "Original Music", track);
tryAddTrack(std::filesystem::current_path() / "assets" / "Original Music", track);
}
std::sort(introTracks_.begin(), introTracks_.end());
introTracks_.erase(std::unique(introTracks_.begin(), introTracks_.end()), introTracks_.end());
}
if (!availableTracks.empty()) {
std::uniform_int_distribution<size_t> pick(0, availableTracks.size() - 1);
const std::string& path = availableTracks[pick(rng)];
if (!introTracks_.empty()) {
std::uniform_int_distribution<size_t> pick(0, introTracks_.size() - 1);
const size_t idx = pick(rng);
const std::string path = introTracks_[idx];
music->playFilePath(path, true, 1800.0f);
LOG_INFO("AuthScreen: Playing login intro track: ", path);
musicPlaying = music->isPlaying();
} else {
if (musicPlaying) {
LOG_INFO("AuthScreen: Playing login intro track: ", path);
} else {
// Drop bad paths to avoid retrying the same failed file every frame.
introTracks_.erase(introTracks_.begin() + idx);
}
} else if (!missingIntroTracksLogged_) {
LOG_WARNING("AuthScreen: No login intro tracks found in assets/");
missingIntroTracksLogged_ = true;
}
}
}

View file

@ -548,6 +548,12 @@ bool Extractor::enumerateFiles(const Options& opts,
continue;
}
// Verify file actually exists in this archive's hash table
// (listfiles can reference files from other archives)
if (!SFileHasFile(hMpq, fileName.c_str())) {
continue;
}
std::string norm = normalizeWowPath(fileName);
if (opts.onlyUsedDbcs && !wantedDbcs.empty() && !wantedDbcs.contains(norm)) {
continue;
@ -624,24 +630,36 @@ bool Extractor::run(const Options& opts) {
std::atomic<size_t> fileIndex{0};
size_t totalFiles = files.size();
// Open archives ONCE in main thread — StormLib has global state that is not
// thread-safe even with separate handles, so we serialize all MPQ reads.
struct SharedArchive {
HANDLE handle;
int priority;
std::string path;
};
std::vector<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 = [&]() {
// Each thread opens ALL archives independently (StormLib is not thread-safe per handle).
// Sorted highest-priority last, so we iterate in reverse to find the winning version.
struct ThreadArchive {
HANDLE handle;
int priority;
};
std::vector<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;
}
int failLogCount = 0;
while (true) {
size_t idx = fileIndex.fetch_add(1);
@ -654,35 +672,52 @@ bool Extractor::run(const Options& opts) {
std::string mappedPath = PathMapper::mapPath(wowPath);
std::string fullOutputPath = effectiveOutputDir + "/" + mappedPath;
// Search archives in reverse priority order (highest priority first)
HANDLE hFile = nullptr;
for (auto it = threadHandles.rbegin(); it != threadHandles.rend(); ++it) {
if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) {
break;
// Read file data from MPQ under lock
std::vector<uint8_t> data;
{
std::lock_guard<std::mutex> lock(mpqMutex);
// Search archives in reverse priority order (highest priority first)
HANDLE hFile = nullptr;
for (auto it = sharedHandles.rbegin(); it != sharedHandles.rend(); ++it) {
if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) {
break;
}
hFile = nullptr;
}
if (!hFile) {
stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::cerr << " FAILED open: " << wowPath
<< " (tried " << sharedHandles.size() << " archives)\n";
}
continue;
}
hFile = nullptr;
}
if (!hFile) {
stats.filesFailed++;
continue;
}
DWORD fileSize = SFileGetFileSize(hFile, nullptr);
if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) {
SFileCloseFile(hFile);
stats.filesSkipped++;
continue;
}
DWORD fileSize = SFileGetFileSize(hFile, nullptr);
if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) {
SFileCloseFile(hFile);
stats.filesSkipped++;
continue;
}
std::vector<uint8_t> data(fileSize);
DWORD bytesRead = 0;
if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) {
data.resize(fileSize);
DWORD bytesRead = 0;
if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) {
SFileCloseFile(hFile);
stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::cerr << " FAILED read: " << wowPath
<< " (size=" << fileSize << ")\n";
}
continue;
}
SFileCloseFile(hFile);
stats.filesFailed++;
continue;
data.resize(bytesRead);
}
SFileCloseFile(hFile);
data.resize(bytesRead);
// Lock released — CRC computation and disk write happen in parallel
// Compute CRC32
uint32_t crc = ManifestWriter::computeCRC32(data.data(), data.size());
@ -694,6 +729,11 @@ bool Extractor::run(const Options& opts) {
std::ofstream out(fullOutputPath, std::ios::binary);
if (!out.is_open()) {
stats.filesFailed++;
if (failLogCount < 5) {
failLogCount++;
std::lock_guard<std::mutex> lock(manifestMutex);
std::cerr << " FAILED write: " << fullOutputPath << "\n";
}
continue;
}
out.write(reinterpret_cast<const char*>(data.data()), data.size());
@ -721,10 +761,6 @@ bool Extractor::run(const Options& opts) {
<< std::flush;
}
}
for (auto& th : threadHandles) {
SFileCloseArchive(th.handle);
}
};
std::cout << "Extracting " << totalFiles << " files using " << numThreads << " threads...\n";
@ -737,10 +773,30 @@ bool Extractor::run(const Options& opts) {
t.join();
}
std::cout << "\r Extracted " << stats.filesExtracted.load() << " files ("
// Close archives (opened once in main thread)
for (auto& sh : sharedHandles) {
SFileCloseArchive(sh.handle);
}
auto extracted = stats.filesExtracted.load();
auto failed = stats.filesFailed.load();
auto skipped = stats.filesSkipped.load();
std::cout << "\n Extracted " << extracted << " files ("
<< stats.bytesExtracted.load() / (1024 * 1024) << " MB), "
<< stats.filesSkipped.load() << " skipped, "
<< stats.filesFailed.load() << " failed\n";
<< skipped << " skipped, "
<< failed << " failed\n";
// If most files failed, print a diagnostic hint
if (failed > 0 && failed > extracted * 10) {
std::cerr << "\nWARNING: " << failed << " out of " << totalFiles
<< " files failed to extract.\n"
<< " This usually means worker threads could not open one or more MPQ archives.\n"
<< " Common causes:\n"
<< " - MPQ files on a network/external drive with access restrictions\n"
<< " - Another program (WoW client, antivirus) has the MPQ files locked\n"
<< " - Too many threads for the OS file-handle limit (try --threads 1)\n"
<< " Re-run with --verbose for detailed diagnostics.\n";
}
// Merge with existing manifest so partial extractions don't nuke prior entries
std::string manifestPath = effectiveOutputDir + "/manifest.json";