From 5171f9cad476149b6d000a842645248bcae62cf8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 11 Feb 2026 22:27:02 -0800 Subject: [PATCH] Fix taxi state sync and transport authority; reduce runtime log overhead; restore first-person self-hide --- include/audio/audio_engine.hpp | 2 +- include/core/application.hpp | 1 - include/game/game_handler.hpp | 1 + include/pipeline/mpq_manager.hpp | 7 + include/rendering/camera_controller.hpp | 1 + include/rendering/m2_renderer.hpp | 4 + include/rendering/renderer.hpp | 1 + include/rendering/terrain_manager.hpp | 3 + include/rendering/terrain_renderer.hpp | 2 + include/rendering/wmo_renderer.hpp | 4 + src/audio/audio_engine.cpp | 220 +++++++++++------------- src/audio/music_manager.cpp | 6 - src/core/application.cpp | 101 +++++++---- src/game/game_handler.cpp | 79 +++++++-- src/game/transport_manager.cpp | 77 +-------- src/game/world_packets.cpp | 4 +- src/pipeline/adt_loader.cpp | 24 +-- src/pipeline/asset_manager.cpp | 2 +- src/pipeline/m2_loader.cpp | 2 +- src/pipeline/mpq_manager.cpp | 25 ++- src/pipeline/wmo_loader.cpp | 38 ++-- src/rendering/camera_controller.cpp | 3 +- src/rendering/character_renderer.cpp | 24 +-- src/rendering/m2_renderer.cpp | 18 ++ src/rendering/renderer.cpp | 66 ++++--- src/rendering/terrain_manager.cpp | 48 ++++-- src/rendering/terrain_renderer.cpp | 50 +++++- src/rendering/water_renderer.cpp | 20 +-- src/rendering/wmo_renderer.cpp | 56 ++++-- 29 files changed, 529 insertions(+), 360 deletions(-) diff --git a/include/audio/audio_engine.hpp b/include/audio/audio_engine.hpp index 0237168c..0eb07a19 100644 --- a/include/audio/audio_engine.hpp +++ b/include/audio/audio_engine.hpp @@ -65,7 +65,7 @@ private: struct ActiveSound { ma_sound* sound; void* buffer; // ma_audio_buffer* - Keep audio buffer alive - std::vector pcmData; // Keep PCM data alive + std::shared_ptr> pcmDataRef; // Keep decoded PCM alive }; std::vector activeSounds_; diff --git a/include/core/application.hpp b/include/core/application.hpp index adc677e3..66f22a79 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -112,7 +112,6 @@ private: bool npcsSpawned = false; bool spawnSnapToGround = true; float lastFrameTime = 0.0f; - float movementHeartbeatTimer = 0.0f; // Player character info (for model spawning) game::Race playerRace_ = game::Race::HUMAN; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index aae38bde..5dc9ff98 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1103,6 +1103,7 @@ private: std::unordered_map taxiCostMap_; // destNodeId -> total cost in copper void buildTaxiCostMap(); void applyTaxiMountForCurrentNode(); + void sanitizeMovementForTaxi(); void startClientTaxiPath(const std::vector& pathNodes); void updateClientTaxi(float deltaTime); diff --git a/include/pipeline/mpq_manager.hpp b/include/pipeline/mpq_manager.hpp index c46c74ad..104092b8 100644 --- a/include/pipeline/mpq_manager.hpp +++ b/include/pipeline/mpq_manager.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include // Forward declare StormLib handle typedef void* HANDLE; @@ -103,6 +105,11 @@ private: * @param locale Locale string (e.g., "enUS") */ bool loadLocaleArchives(const std::string& locale); + + void logMissingFileOnce(const std::string& filename) const; + + mutable std::mutex missingFileMutex_; + mutable std::unordered_set missingFileWarnings_; }; } // namespace pipeline diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 79f44dd0..a9293da0 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -64,6 +64,7 @@ public: float getPitch() const { return pitch; } float getFacingYaw() const { return facingYaw; } bool isThirdPerson() const { return thirdPerson; } + bool isFirstPersonView() const { return thirdPerson && (userTargetDistance <= MIN_DISTANCE + 0.15f); } bool isGrounded() const { return grounded; } bool isJumping() const { return !grounded && verticalVelocity > 0.0f; } bool isFalling() const { return !grounded && verticalVelocity <= 0.0f; } diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 296ce782..46c87c5d 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -264,6 +264,10 @@ public: * @param instanceId Instance ID returned by createInstance() */ void removeInstance(uint32_t instanceId); + /** + * Remove multiple instances with one spatial-index rebuild. + */ + void removeInstances(const std::vector& instanceIds); /** * Clear all models and instances diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index b39c0c4f..a341d745 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -243,6 +243,7 @@ private: std::string currentZoneName; bool inTavern_ = false; bool inBlacksmith_ = false; + float musicSwitchCooldown_ = 0.0f; // Third-person character state glm::vec3 characterPosition = glm::vec3(0.0f); diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 4f0d3265..791895de 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -319,10 +319,13 @@ private: std::shared_ptr getCachedTile(const TileCoord& coord); void putCachedTile(const std::shared_ptr& tile); size_t estimatePendingTileBytes(const PendingTile& tile) const; + void logMissingAdtOnce(const std::string& adtPath); std::atomic workerRunning{false}; // Track tiles currently queued or being processed to avoid duplicates std::unordered_map pendingTiles; + std::unordered_set missingAdtWarnings_; + std::mutex missingAdtWarningsMutex_; // Dedup set for doodad placements across tile boundaries std::unordered_set placedDoodadIds; diff --git a/include/rendering/terrain_renderer.hpp b/include/rendering/terrain_renderer.hpp index 4e39922a..4a76ba11 100644 --- a/include/rendering/terrain_renderer.hpp +++ b/include/rendering/terrain_renderer.hpp @@ -207,6 +207,8 @@ private: // Default white texture (fallback) GLuint whiteTexture = 0; + // Opaque alpha fallback for missing/invalid layer alpha maps + GLuint opaqueAlphaTexture = 0; // Shadow mapping (receiving) GLuint shadowDepthTex = 0; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 29adc48a..71ecb8b9 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -121,6 +121,10 @@ public: * @param instanceId Instance to remove */ void removeInstance(uint32_t instanceId); + /** + * Remove multiple WMO instances with a single spatial-index rebuild. + */ + void removeInstances(const std::vector& instanceIds); /** * Remove all instances diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index 85694990..88f0d8cb 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp @@ -6,10 +6,87 @@ #include "../../extern/miniaudio.h" #include +#include +#include namespace wowee { namespace audio { +namespace { + +struct DecodedWavCacheEntry { + ma_format format = ma_format_unknown; + ma_uint32 channels = 0; + ma_uint32 sampleRate = 0; + ma_uint64 frames = 0; + std::shared_ptr> pcmData; +}; + +static std::unordered_map gDecodedWavCache; + +static uint64_t makeWavCacheKey(const std::vector& wavData) { + uint64_t ptr = static_cast(reinterpret_cast(wavData.data())); + uint64_t sz = static_cast(wavData.size()); + return (ptr * 11400714819323198485ull) ^ (sz + 0x9e3779b97f4a7c15ull + (ptr << 6) + (ptr >> 2)); +} + +static bool decodeWavCached(const std::vector& wavData, DecodedWavCacheEntry& out) { + if (wavData.empty()) return false; + + const uint64_t key = makeWavCacheKey(wavData); + if (auto it = gDecodedWavCache.find(key); it != gDecodedWavCache.end()) { + out = it->second; + return true; + } + + ma_decoder decoder; + ma_decoder_config decoderConfig = ma_decoder_config_init_default(); + ma_result result = ma_decoder_init_memory( + wavData.data(), + wavData.size(), + &decoderConfig, + &decoder + ); + if (result != MA_SUCCESS) { + LOG_ERROR("AudioEngine: Failed to decode WAV data (", wavData.size(), " bytes): error ", result); + return false; + } + + ma_uint64 totalFrames = 0; + result = ma_decoder_get_length_in_pcm_frames(&decoder, &totalFrames); + if (result != MA_SUCCESS) totalFrames = 0; + + ma_format format = decoder.outputFormat; + ma_uint32 channels = decoder.outputChannels; + ma_uint32 sampleRate = decoder.outputSampleRate; + ma_uint64 maxFrames = sampleRate * 60; + if (totalFrames == 0 || totalFrames > maxFrames) totalFrames = maxFrames; + + size_t bufferSize = totalFrames * channels * ma_get_bytes_per_sample(format); + auto pcmData = std::make_shared>(bufferSize); + ma_uint64 framesRead = 0; + result = ma_decoder_read_pcm_frames(&decoder, pcmData->data(), totalFrames, &framesRead); + ma_decoder_uninit(&decoder); + if (result != MA_SUCCESS || framesRead == 0) { + LOG_ERROR("AudioEngine: Failed to read frames from WAV: error ", result, ", framesRead=", framesRead); + return false; + } + + pcmData->resize(framesRead * channels * ma_get_bytes_per_sample(format)); + + DecodedWavCacheEntry entry; + entry.format = format; + entry.channels = channels; + entry.sampleRate = sampleRate; + entry.frames = framesRead; + entry.pcmData = pcmData; + gDecodedWavCache.emplace(key, entry); + out = entry; + return true; +} + +} // namespace + AudioEngine& AudioEngine::instance() { static AudioEngine instance; return instance; @@ -121,76 +198,26 @@ void AudioEngine::setListenerOrientation(const glm::vec3& forward, const glm::ve } bool AudioEngine::playSound2D(const std::vector& wavData, float volume, float pitch) { - if (!initialized_ || !engine_ || wavData.empty()) { + (void)pitch; + if (!initialized_ || !engine_ || wavData.empty()) return false; + + DecodedWavCacheEntry decoded; + if (!decodeWavCached(wavData, decoded) || !decoded.pcmData || decoded.frames == 0) { return false; } - // Decode the WAV data first to get PCM format - ma_decoder decoder; - ma_decoder_config decoderConfig = ma_decoder_config_init_default(); - ma_result result = ma_decoder_init_memory( - wavData.data(), - wavData.size(), - &decoderConfig, - &decoder - ); - - if (result != MA_SUCCESS) { - LOG_ERROR("AudioEngine: Failed to decode WAV data (", wavData.size(), " bytes): error ", result); - return false; - } - - // Get decoder format info - ma_format format = decoder.outputFormat; - ma_uint32 channels = decoder.outputChannels; - ma_uint32 sampleRate = decoder.outputSampleRate; - - // Calculate total frame count - ma_uint64 totalFrames; - result = ma_decoder_get_length_in_pcm_frames(&decoder, &totalFrames); - if (result != MA_SUCCESS) { - totalFrames = 0; // Unknown length, will decode what we can - } - - // Allocate buffer for decoded PCM data (limit to 60 seconds max for ambient loops) - ma_uint64 maxFrames = sampleRate * 60; - if (totalFrames == 0 || totalFrames > maxFrames) { - totalFrames = maxFrames; - } - - size_t bufferSize = totalFrames * channels * ma_get_bytes_per_sample(format); - std::vector pcmData(bufferSize); - - // Decode all frames - ma_uint64 framesRead = 0; - result = ma_decoder_read_pcm_frames(&decoder, pcmData.data(), totalFrames, &framesRead); - ma_decoder_uninit(&decoder); - - if (result != MA_SUCCESS || framesRead == 0) { - LOG_ERROR("AudioEngine: Failed to read frames from WAV: error ", result, ", framesRead=", framesRead); - return false; - } - - // Only log for large files (>1MB) - if (wavData.size() > 1000000) { - LOG_INFO("AudioEngine: Decoded ", framesRead, " frames (", framesRead / (float)sampleRate, "s) from ", wavData.size(), " byte WAV"); - } - - // Resize pcmData to actual size used - pcmData.resize(framesRead * channels * ma_get_bytes_per_sample(format)); - // Create audio buffer from decoded PCM data (heap allocated to keep alive) ma_audio_buffer_config bufferConfig = ma_audio_buffer_config_init( - format, - channels, - framesRead, - pcmData.data(), + decoded.format, + decoded.channels, + decoded.frames, + decoded.pcmData->data(), nullptr // No custom allocator ); - bufferConfig.sampleRate = sampleRate; // Critical: preserve original sample rate! + bufferConfig.sampleRate = decoded.sampleRate; // Critical: preserve original sample rate! ma_audio_buffer* audioBuffer = new ma_audio_buffer(); - result = ma_audio_buffer_init(&bufferConfig, audioBuffer); + ma_result result = ma_audio_buffer_init(&bufferConfig, audioBuffer); if (result != MA_SUCCESS) { LOG_WARNING("Failed to create audio buffer: ", result); delete audioBuffer; @@ -229,8 +256,8 @@ bool AudioEngine::playSound2D(const std::vector& wavData, float volume, return false; } - // Track this sound for cleanup (move pcmData to keep it alive) - activeSounds_.push_back({sound, audioBuffer, std::move(pcmData)}); + // Track this sound for cleanup (decoded PCM shared across plays) + activeSounds_.push_back({sound, audioBuffer, decoded.pcmData}); return true; } @@ -244,68 +271,29 @@ bool AudioEngine::playSound2D(const std::string& mpqPath, float volume, float pi bool AudioEngine::playSound3D(const std::vector& wavData, const glm::vec3& position, float volume, float pitch, float maxDistance) { - if (!initialized_ || !engine_ || wavData.empty()) { + if (!initialized_ || !engine_ || wavData.empty()) return false; + + DecodedWavCacheEntry decoded; + if (!decodeWavCached(wavData, decoded) || !decoded.pcmData || decoded.frames == 0) { return false; } - // Decode WAV data first - ma_decoder decoder; - ma_decoder_config decoderConfig = ma_decoder_config_init_default(); - ma_result result = ma_decoder_init_memory( - wavData.data(), - wavData.size(), - &decoderConfig, - &decoder - ); - - if (result != MA_SUCCESS) { - LOG_WARNING("playSound3D: Failed to decode WAV, error: ", result); - return false; - } - - ma_format format = decoder.outputFormat; - ma_uint32 channels = decoder.outputChannels; - ma_uint32 sampleRate = decoder.outputSampleRate; - - LOG_DEBUG("playSound3D: Decoded WAV - format:", format, " channels:", channels, " sampleRate:", sampleRate, " pitch:", pitch); - - ma_uint64 totalFrames; - result = ma_decoder_get_length_in_pcm_frames(&decoder, &totalFrames); - if (result != MA_SUCCESS) { - totalFrames = 0; - } - - // Limit to 60 seconds max for ambient loops (same as 2D) - ma_uint64 maxFrames = sampleRate * 60; - if (totalFrames == 0 || totalFrames > maxFrames) { - totalFrames = maxFrames; - } - - size_t bufferSize = totalFrames * channels * ma_get_bytes_per_sample(format); - std::vector pcmData(bufferSize); - - ma_uint64 framesRead = 0; - result = ma_decoder_read_pcm_frames(&decoder, pcmData.data(), totalFrames, &framesRead); - ma_decoder_uninit(&decoder); - - if (result != MA_SUCCESS || framesRead == 0) { - return false; - } - - pcmData.resize(framesRead * channels * ma_get_bytes_per_sample(format)); + LOG_DEBUG("playSound3D: cached WAV - format:", decoded.format, + " channels:", decoded.channels, " sampleRate:", decoded.sampleRate, + " pitch:", pitch); // Create audio buffer with correct sample rate ma_audio_buffer_config bufferConfig = ma_audio_buffer_config_init( - format, - channels, - framesRead, - pcmData.data(), + decoded.format, + decoded.channels, + decoded.frames, + decoded.pcmData->data(), nullptr ); - bufferConfig.sampleRate = sampleRate; // Critical: preserve original sample rate! + bufferConfig.sampleRate = decoded.sampleRate; // Critical: preserve original sample rate! ma_audio_buffer* audioBuffer = new ma_audio_buffer(); - result = ma_audio_buffer_init(&bufferConfig, audioBuffer); + ma_result result = ma_audio_buffer_init(&bufferConfig, audioBuffer); if (result != MA_SUCCESS) { delete audioBuffer; return false; @@ -350,7 +338,7 @@ bool AudioEngine::playSound3D(const std::vector& wavData, const glm::ve } // Track for cleanup - activeSounds_.push_back({sound, audioBuffer, std::move(pcmData)}); + activeSounds_.push_back({sound, audioBuffer, decoded.pcmData}); return true; } diff --git a/src/audio/music_manager.cpp b/src/audio/music_manager.cpp index 57003a28..e98f9fcc 100644 --- a/src/audio/music_manager.cpp +++ b/src/audio/music_manager.cpp @@ -58,9 +58,6 @@ void MusicManager::playMusic(const std::string& mpqPath, bool loop) { return; } - // Stop current playback - AudioEngine::instance().stopMusic(); - // Play with AudioEngine (non-blocking, streams from memory) float volume = volumePercent / 100.0f; if (AudioEngine::instance().playMusic(cacheIt->second, volume, loop)) { @@ -103,9 +100,6 @@ void MusicManager::playFilePath(const std::string& filePath, bool loop) { return; } - // Stop current playback - AudioEngine::instance().stopMusic(); - // Play with AudioEngine float volume = volumePercent / 100.0f; if (AudioEngine::instance().playMusic(data, volume, loop)) { diff --git a/src/core/application.cpp b/src/core/application.cpp index 7e075560..4f11f354 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -57,9 +57,10 @@ const char* Application::mapIdToName(uint32_t mapId) { switch (mapId) { case 0: return "Azeroth"; case 1: return "Kalimdor"; + case 369: return "DeeprunTram"; case 530: return "Outland"; case 571: return "Northrend"; - default: return "Azeroth"; + default: return ""; } } @@ -568,11 +569,10 @@ void Application::update(float deltaTime) { } if (renderer && renderer->getTerrainManager()) { renderer->getTerrainManager()->setStreamingEnabled(true); - // Slightly slower stream tick on taxi reduces bursty I/O and frame hitches. - renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.2f : 0.1f); - // Keep taxi streaming focused ahead on the route to reduce burst loads. - renderer->getTerrainManager()->setLoadRadius(onTaxi ? 2 : 4); - renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 5 : 7); + // Keep taxi streaming responsive so flight paths don't outrun terrain/model uploads. + renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.1f : 0.1f); + renderer->getTerrainManager()->setLoadRadius(onTaxi ? 3 : 4); + renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 6 : 7); renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi); } lastTaxiFlight_ = onTaxi; @@ -637,17 +637,8 @@ void Application::update(float deltaTime) { } } - // Send periodic movement heartbeats. - // Keep them active during taxi as well to avoid occasional server-side - // flight stalls waiting for movement sync updates. - if (gameHandler && renderer) { - movementHeartbeatTimer += deltaTime; - float hbInterval = onTaxi ? 0.25f : 0.5f; - if (movementHeartbeatTimer >= hbInterval) { - movementHeartbeatTimer = 0.0f; - gameHandler->sendMovement(game::Opcode::CMSG_MOVE_HEARTBEAT); - } - } + // Movement heartbeat is sent from GameHandler::update() to avoid + // duplicate packets from multiple update loops. auto sync2 = std::chrono::high_resolution_clock::now(); syncTime += std::chrono::duration(sync2 - sync1).count(); @@ -1048,17 +1039,24 @@ void Application::setupUICallbacks() { std::set> uniqueTiles; // Sample waypoints along path and gather tiles. - // Use stride to avoid enqueueing huge numbers of tiles at once. - const size_t stride = 4; + // Denser sampling + neighbor coverage reduces in-flight stream spikes. + const size_t stride = 2; for (size_t i = 0; i < path.size(); i += stride) { const auto& waypoint = path[i]; glm::vec3 renderPos = core::coords::canonicalToRender(waypoint); int tileX = static_cast(32 - (renderPos.x / 533.33333f)); int tileY = static_cast(32 - (renderPos.y / 533.33333f)); - // Precache only the sampled tile itself; terrain streaming handles neighbors. if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { - uniqueTiles.insert({tileX, tileY}); + for (int dx = -1; dx <= 1; ++dx) { + for (int dy = -1; dy <= 1; ++dy) { + int nx = tileX + dx; + int ny = tileY + dy; + if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { + uniqueTiles.insert({nx, ny}); + } + } + } } } // Ensure final destination tile is included. @@ -1067,11 +1065,22 @@ void Application::setupUICallbacks() { int tileX = static_cast(32 - (renderPos.x / 533.33333f)); int tileY = static_cast(32 - (renderPos.y / 533.33333f)); if (tileX >= 0 && tileX <= 63 && tileY >= 0 && tileY <= 63) { - uniqueTiles.insert({tileX, tileY}); + for (int dx = -1; dx <= 1; ++dx) { + for (int dy = -1; dy <= 1; ++dy) { + int nx = tileX + dx; + int ny = tileY + dy; + if (nx >= 0 && nx <= 63 && ny >= 0 && ny <= 63) { + uniqueTiles.insert({nx, ny}); + } + } + } } } std::vector> tilesToLoad(uniqueTiles.begin(), uniqueTiles.end()); + if (tilesToLoad.size() > 512) { + tilesToLoad.resize(512); + } LOG_INFO("Precaching ", tilesToLoad.size(), " tiles for taxi route"); renderer->getTerrainManager()->precacheTiles(tilesToLoad); }); @@ -2051,7 +2060,37 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float showProgress("Entering world...", 0.0f); - std::string mapName = mapIdToName(mapId); + // Resolve map folder name from Map.dbc (authoritative for world/instance maps). + // This is required for instances like DeeprunTram (map 369) that are not Azeroth/Kalimdor. + static bool mapNameCacheLoaded = false; + static std::unordered_map mapNameById; + if (!mapNameCacheLoaded && assetManager) { + mapNameCacheLoaded = true; + if (auto mapDbc = assetManager->loadDBC("Map.dbc"); mapDbc && mapDbc->isLoaded()) { + mapNameById.reserve(mapDbc->getRecordCount()); + for (uint32_t i = 0; i < mapDbc->getRecordCount(); i++) { + uint32_t id = mapDbc->getUInt32(i, 0); + std::string internalName = mapDbc->getString(i, 1); + if (!internalName.empty()) { + mapNameById[id] = std::move(internalName); + } + } + LOG_INFO("Loaded Map.dbc map-name cache: ", mapNameById.size(), " entries"); + } else { + LOG_WARNING("Map.dbc not available; using fallback map-id mapping"); + } + } + + std::string mapName; + if (auto it = mapNameById.find(mapId); it != mapNameById.end()) { + mapName = it->second; + } else { + mapName = mapIdToName(mapId); + } + if (mapName.empty()) { + LOG_WARNING("Unknown mapId ", mapId, " (no Map.dbc entry); falling back to Azeroth"); + mapName = "Azeroth"; + } LOG_INFO("Loading online world terrain for map '", mapName, "' (ID ", mapId, ")"); // Convert server coordinates to canonical WoW coordinates @@ -3091,7 +3130,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Track instance creatureInstances_[guid] = instanceId; creatureModelIds_[guid] = modelId; - LOG_INFO("Spawned creature: guid=0x", std::hex, guid, std::dec, + LOG_DEBUG("Spawned creature: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } @@ -3107,7 +3146,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t // Already have a render instance — update its position (e.g. transport re-creation) auto& info = gameObjectInstances_[guid]; glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - LOG_INFO("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, + LOG_DEBUG("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, " pos=(", x, ", ", y, ", ", z, ")"); if (renderer) { if (info.isWmo) { @@ -3157,7 +3196,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } // Log spawns to help debug duplicate objects (e.g., cathedral issue) - LOG_INFO("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, + LOG_DEBUG("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, " model=", modelPath, " pos=(", x, ", ", y, ", ", z, ")"); std::string lowerPath = modelPath; @@ -3182,7 +3221,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto wmoData = assetManager->readFile(modelPath); if (!wmoData.empty()) { pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); - LOG_INFO("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); + LOG_DEBUG("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); int loadedGroups = 0; if (wmoModel.nGroups > 0) { std::string basePath = modelPath; @@ -3244,7 +3283,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } gameObjectInstances_[guid] = {modelId, instanceId, true}; - LOG_INFO("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, + LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); // Spawn WMO doodads (chairs, furniture, etc.) as child M2 instances @@ -3372,7 +3411,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t gameObjectInstances_[guid] = {modelId, instanceId, false}; } - LOG_INFO("Spawned gameobject: guid=0x", std::hex, guid, std::dec, + LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } @@ -3697,7 +3736,7 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureInstances_.erase(it); creatureModelIds_.erase(guid); - LOG_INFO("Despawned creature: guid=0x", std::hex, guid, std::dec); + LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } void Application::despawnOnlineGameObject(uint64_t guid) { @@ -3718,7 +3757,7 @@ void Application::despawnOnlineGameObject(uint64_t guid) { gameObjectInstances_.erase(it); - LOG_INFO("Despawned gameobject: guid=0x", std::hex, guid, std::dec); + LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec); } void Application::loadQuestMarkerModels() { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 03f5f5cc..f42740ab 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -169,7 +170,8 @@ void GameHandler::update(float deltaTime) { timeSinceLastPing = 0.0f; } - if (timeSinceLastMoveHeartbeat_ >= moveHeartbeatInterval_) { + float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_) ? 0.25f : moveHeartbeatInterval_; + if (timeSinceLastMoveHeartbeat_ >= heartbeatInterval) { sendMovement(Opcode::CMSG_MOVE_HEARTBEAT); timeSinceLastMoveHeartbeat_ = 0.0f; } @@ -311,7 +313,13 @@ void GameHandler::update(float deltaTime) { if (taxiActivatePending_) { taxiActivateTimer_ += deltaTime; - if (!onTaxiFlight_ && taxiActivateTimer_ > 5.0f) { + if (taxiActivateTimer_ > 5.0f) { + // If client taxi simulation is already active, server reply may be missing/late. + // Do not cancel the flight in that case; clear pending state and continue. + if (onTaxiFlight_ || taxiClientActive_ || taxiMountActive_) { + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + } else { taxiActivatePending_ = false; taxiActivateTimer_ = 0.0f; if (taxiMountActive_ && mountCallback_) { @@ -323,6 +331,7 @@ void GameHandler::update(float deltaTime) { taxiClientPath_.clear(); onTaxiFlight_ = false; LOG_WARNING("Taxi activation timed out"); + } } } @@ -531,7 +540,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_UPDATE_OBJECT: - LOG_INFO("Received SMSG_UPDATE_OBJECT, state=", static_cast(state), " size=", packet.getSize()); + LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast(state), " size=", packet.getSize()); // Can be received after entering world if (state == WorldState::IN_WORLD) { handleUpdateObject(packet); @@ -539,7 +548,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_COMPRESSED_UPDATE_OBJECT: - LOG_INFO("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(state), " size=", packet.getSize()); + LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(state), " size=", packet.getSize()); // Compressed version of UPDATE_OBJECT if (state == WorldState::IN_WORLD) { handleCompressedUpdateObject(packet); @@ -1243,7 +1252,11 @@ void GameHandler::handlePacket(network::Packet& packet) { break; default: - LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec); + // Log each unknown opcode once to avoid log-file I/O spikes in busy areas. + static std::unordered_set loggedUnhandledOpcodes; + if (loggedUnhandledOpcodes.insert(static_cast(opcode)).second) { + LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec); + } break; } } @@ -1755,8 +1768,7 @@ void GameHandler::sendMovement(Opcode opcode) { (opcode == Opcode::CMSG_MOVE_STOP) || (opcode == Opcode::CMSG_MOVE_STOP_STRAFE) || (opcode == Opcode::CMSG_MOVE_STOP_TURN) || - (opcode == Opcode::CMSG_MOVE_STOP_SWIM) || - (opcode == Opcode::CMSG_MOVE_FALL_LAND); + (opcode == Opcode::CMSG_MOVE_STOP_SWIM); if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return; if (resurrectPending_ && !taxiAllowed) return; @@ -1811,6 +1823,10 @@ void GameHandler::sendMovement(Opcode opcode) { break; } + if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) { + sanitizeMovementForTaxi(); + } + // Add transport data if player is on a transport if (isOnTransport()) { movementInfo.flags |= static_cast(MovementFlags::ONTRANSPORT); @@ -1868,6 +1884,29 @@ void GameHandler::sendMovement(Opcode opcode) { socket->send(packet); } +void GameHandler::sanitizeMovementForTaxi() { + constexpr uint32_t kClearTaxiFlags = + static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT) | + static_cast(MovementFlags::TURN_LEFT) | + static_cast(MovementFlags::TURN_RIGHT) | + static_cast(MovementFlags::PITCH_UP) | + static_cast(MovementFlags::PITCH_DOWN) | + static_cast(MovementFlags::FALLING) | + static_cast(MovementFlags::FALLINGFAR) | + static_cast(MovementFlags::SWIMMING); + + movementInfo.flags &= ~kClearTaxiFlags; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; + movementInfo.pitch = 0.0f; +} + void GameHandler::forceClearTaxiAndMovementState() { taxiActivatePending_ = false; taxiActivateTimer_ = 0.0f; @@ -1934,7 +1973,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { continue; } - LOG_INFO("Entity went out of range: 0x", std::hex, guid, std::dec); + LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); // Trigger despawn callbacks before removing entity auto entity = entityManager.getEntity(guid); if (entity) { @@ -2092,6 +2131,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { onTaxiFlight_ = true; taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); + sanitizeMovementForTaxi(); applyTaxiMountForCurrentNode(); } } @@ -2598,7 +2638,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } void GameHandler::handleCompressedUpdateObject(network::Packet& packet) { - LOG_INFO("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize()); + LOG_DEBUG("Handling SMSG_COMPRESSED_UPDATE_OBJECT, packet size: ", packet.getSize()); // First 4 bytes = decompressed size if (packet.getSize() < 4) { @@ -2607,7 +2647,7 @@ void GameHandler::handleCompressedUpdateObject(network::Packet& packet) { } uint32_t decompressedSize = packet.readUInt32(); - LOG_INFO(" Decompressed size: ", decompressedSize); + LOG_DEBUG(" Decompressed size: ", decompressedSize); if (decompressedSize == 0 || decompressedSize > 1024 * 1024) { LOG_WARNING("Invalid decompressed size: ", decompressedSize); @@ -6335,6 +6375,7 @@ void GameHandler::startClientTaxiPath(const std::vector& pathNodes) { movementInfo.y = start.y; movementInfo.z = start.z; movementInfo.orientation = initialOrientation; + sanitizeMovementForTaxi(); auto playerEntity = entityManager.getEntity(playerGuid); if (playerEntity) { @@ -6495,8 +6536,8 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { } // Guard against stray/mis-mapped packets being treated as taxi replies. - // Only honor taxi replies when a taxi flow is actually active. - if (!taxiActivatePending_ && !taxiWindowOpen_ && !onTaxiFlight_) { + // We only consume a reply while an activation request is pending. + if (!taxiActivatePending_) { LOG_DEBUG("Ignoring stray taxi reply: result=", data.result); return; } @@ -6509,6 +6550,7 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { } onTaxiFlight_ = true; taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); + sanitizeMovementForTaxi(); taxiWindowOpen_ = false; taxiActivatePending_ = false; taxiActivateTimer_ = 0.0f; @@ -6518,6 +6560,13 @@ void GameHandler::handleActivateTaxiReply(network::Packet& packet) { } LOG_INFO("Taxi flight started!"); } else { + // If local taxi motion already started, treat late failure as stale and ignore. + if (onTaxiFlight_ || taxiClientActive_) { + LOG_WARNING("Ignoring stale taxi failure reply while flight is active: result=", data.result); + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + return; + } LOG_WARNING("Taxi activation failed, result=", data.result); addSystemChatMessage("Cannot take that flight path."); taxiActivatePending_ = false; @@ -6678,6 +6727,7 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { taxiStartGrace_ = 2.0f; if (!onTaxiFlight_) { onTaxiFlight_ = true; + sanitizeMovementForTaxi(); applyTaxiMountForCurrentNode(); } if (socket) { @@ -6720,6 +6770,11 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { taxiFlightStartCallback_(); } startClientTaxiPath(path); + // We run taxi movement locally immediately; don't keep a long-lived pending state. + if (taxiClientActive_) { + taxiActivatePending_ = false; + taxiActivateTimer_ = 0.0f; + } addSystemChatMessage("Flight started."); diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index c0d283e0..d2bb03bb 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -254,84 +254,15 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float } pathTimeMs = transport.localClockMs % path.durationMs; } else { - // Server-driven transport without clock sync. - // Do not auto-fallback to local DBC paths; remapped paths can be wrong and cause - // fast sideways movement, diving below water, or despawn-like behavior. - // Instead, briefly dead-reckon from recent authoritative velocity to avoid visual stutter - // when update bursts are sparse. + // Server-driven transport without clock sync: + // stay server-authoritative and never switch to DBC/client animation fallback. + // For short server update gaps, apply lightweight dead-reckoning only when we + // have measured velocity from at least one authoritative delta. constexpr float kMaxExtrapolationSec = 8.0f; const float ageSec = elapsedTime_ - transport.lastServerUpdate; if (transport.hasServerVelocity && ageSec > 0.0f && ageSec <= kMaxExtrapolationSec) { const float blend = glm::clamp(1.0f - (ageSec / kMaxExtrapolationSec), 0.0f, 1.0f); transport.position += transport.serverLinearVelocity * (deltaTime * blend); - } else if (transport.serverUpdateCount <= 1 && - ageSec >= 1.0f && - path.fromDBC && !path.zOnly && path.durationMs > 0 && path.points.size() > 1 && - ((transport.guid & 0xFFF0000000000000ULL) == 0x1FC0000000000000ULL)) { - // Spawn-only fallback: only for world transport GUIDs (0x1fc...), not all transport-like objects. - glm::vec3 localTarget = transport.position - transport.basePosition; - uint32_t bestTimeMs = 0; - float bestScore = FLT_MAX; - float bestD2 = FLT_MAX; - constexpr uint32_t samples = 600; - for (uint32_t i = 0; i < samples; ++i) { - uint32_t t = static_cast((static_cast(i) * path.durationMs) / samples); - glm::vec3 off = evalTimedCatmullRom(path, t); - glm::vec3 d = off - localTarget; - float d2 = glm::dot(d, d); - - float score = d2; - if (transport.hasServerYaw) { - constexpr uint32_t kHeadingDtMs = 250; - uint32_t tNext = (t + kHeadingDtMs) % path.durationMs; - glm::vec3 offNext = evalTimedCatmullRom(path, tNext); - glm::vec3 tangent = offNext - off; - if (glm::length2(tangent) > 1e-6f) { - float yaw = std::atan2(tangent.y, tangent.x); - float dyaw = yaw - transport.serverYaw; - while (dyaw > glm::pi()) dyaw -= glm::two_pi(); - while (dyaw < -glm::pi()) dyaw += glm::two_pi(); - constexpr float kHeadingWeight = 60.0f; - score += (kHeadingWeight * std::abs(dyaw)) * (kHeadingWeight * std::abs(dyaw)); - } - } - - if (score < bestScore) { - bestScore = score; - bestD2 = d2; - bestTimeMs = t; - } - } - - constexpr float kMaxPhaseDrift = 120.0f; - if (bestD2 <= (kMaxPhaseDrift * kMaxPhaseDrift)) { - bool reverse = false; - if (transport.hasServerYaw) { - constexpr uint32_t kYawDtMs = 250; - uint32_t tNext = (bestTimeMs + kYawDtMs) % path.durationMs; - glm::vec3 p0 = evalTimedCatmullRom(path, bestTimeMs); - glm::vec3 p1 = evalTimedCatmullRom(path, tNext); - glm::vec3 d = p1 - p0; - if (glm::length2(d) > 1e-6f) { - float yawFwd = std::atan2(d.y, d.x); - float yawRev = yawFwd + glm::pi(); - auto angleDiff = [](float a, float b) -> float { - float d = a - b; - while (d > glm::pi()) d -= glm::two_pi(); - while (d < -glm::pi()) d += glm::two_pi(); - return std::abs(d); - }; - reverse = angleDiff(yawRev, transport.serverYaw) < angleDiff(yawFwd, transport.serverYaw); - } - } - - transport.useClientAnimation = true; - transport.localClockMs = bestTimeMs; - transport.clientAnimationReverse = reverse; - LOG_WARNING("TransportManager: No follow-up server updates for world transport 0x", std::hex, transport.guid, std::dec, - " (", ageSec, "s since spawn); enabling guarded fallback at t=", bestTimeMs, - "ms (phaseDrift=", std::sqrt(bestD2), ", reverse=", reverse, ")"); - } } updateTransformMatrices(transport); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 3ecee765..bb1b6616 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1683,7 +1683,7 @@ bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryRe // Skip remaining fields (kill credits, display IDs, modifiers, quest items, etc.) // We've got what we need for display purposes - LOG_INFO("Creature query response: ", data.name, " (type=", data.creatureType, + LOG_DEBUG("Creature query response: ", data.name, " (type=", data.creatureType, " rank=", data.rank, ")"); return true; } @@ -1718,7 +1718,7 @@ bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQue packet.readString(); packet.readString(); - LOG_INFO("GameObject query response: ", data.name, " (type=", data.type, " entry=", data.entry, ")"); + LOG_DEBUG("GameObject query response: ", data.name, " (type=", data.type, " entry=", data.entry, ")"); return true; } diff --git a/src/pipeline/adt_loader.cpp b/src/pipeline/adt_loader.cpp index 4bf260af..6368bf2f 100644 --- a/src/pipeline/adt_loader.cpp +++ b/src/pipeline/adt_loader.cpp @@ -45,7 +45,7 @@ ADTTerrain ADTLoader::load(const std::vector& adtData) { return terrain; } - LOG_INFO("Loading ADT terrain (", adtData.size(), " bytes)"); + LOG_DEBUG("Loading ADT terrain (", adtData.size(), " bytes)"); size_t offset = 0; int chunkIndex = 0; @@ -88,7 +88,7 @@ ADTTerrain ADTLoader::load(const std::vector& adtData) { parseMODF(chunkData, chunkSize, terrain); } else if (header.magic == MH2O) { - LOG_INFO("Found MH2O chunk (", chunkSize, " bytes)"); + LOG_DEBUG("Found MH2O chunk (", chunkSize, " bytes)"); parseMH2O(chunkData, chunkSize, terrain); } else if (header.magic == MCNK) { @@ -204,13 +204,13 @@ void ADTLoader::parseMWMO(const uint8_t* data, size_t size, ADTTerrain& terrain) offset += nameLen + 1; } - LOG_INFO("Loaded ", terrain.wmoNames.size(), " WMO names from MWMO chunk"); + LOG_DEBUG("Loaded ", terrain.wmoNames.size(), " WMO names from MWMO chunk"); for (size_t i = 0; i < terrain.wmoNames.size(); i++) { - LOG_INFO(" WMO[", i, "]: ", terrain.wmoNames[i]); + LOG_DEBUG(" WMO[", i, "]: ", terrain.wmoNames[i]); // Flag potential duplicate cathedral models if (terrain.wmoNames[i].find("cathedral") != std::string::npos || terrain.wmoNames[i].find("Cathedral") != std::string::npos) { - LOG_INFO("*** CATHEDRAL WMO FOUND: ", terrain.wmoNames[i]); + LOG_DEBUG("*** CATHEDRAL WMO FOUND: ", terrain.wmoNames[i]); } } } @@ -238,7 +238,7 @@ void ADTLoader::parseMDDF(const uint8_t* data, size_t size, ADTTerrain& terrain) terrain.doodadPlacements.push_back(placement); } - LOG_INFO("Loaded ", terrain.doodadPlacements.size(), " doodad placements"); + LOG_DEBUG("Loaded ", terrain.doodadPlacements.size(), " doodad placements"); } void ADTLoader::parseMODF(const uint8_t* data, size_t size, ADTTerrain& terrain) { @@ -276,7 +276,7 @@ void ADTLoader::parseMODF(const uint8_t* data, size_t size, ADTTerrain& terrain) std::transform(upperName.begin(), upperName.end(), upperName.begin(), ::toupper); if (upperName.find("STORMWIND.WMO") != std::string::npos) { - LOG_INFO("*** STORMWIND.WMO PLACEMENT:", + LOG_DEBUG("*** STORMWIND.WMO PLACEMENT:", " uniqueId=", placement.uniqueId, " pos=(", placement.position[0], ", ", placement.position[1], ", ", placement.position[2], ")", " rot=(", placement.rotation[0], ", ", placement.rotation[1], ", ", placement.rotation[2], ")", @@ -286,7 +286,7 @@ void ADTLoader::parseMODF(const uint8_t* data, size_t size, ADTTerrain& terrain) } } - LOG_INFO("Loaded ", terrain.wmoPlacements.size(), " WMO placements"); + LOG_DEBUG("Loaded ", terrain.wmoPlacements.size(), " WMO placements"); } void ADTLoader::parseMCNK(const uint8_t* data, size_t size, int chunkIndex, ADTTerrain& terrain) { @@ -321,7 +321,7 @@ void ADTLoader::parseMCNK(const uint8_t* data, size_t size, int chunkIndex, ADTT // Debug first chunk only if (chunkIndex == 0) { - LOG_INFO("MCNK[0] offsets: nLayers=", nLayers, + LOG_DEBUG("MCNK[0] offsets: nLayers=", nLayers, " height=", ofsHeight, " normal=", ofsNormal, " layer=", ofsLayer, " alpha=", ofsAlpha, " sizeAlpha=", sizeAlpha, " size=", size, @@ -345,7 +345,7 @@ void ADTLoader::parseMCNK(const uint8_t* data, size_t size, int chunkIndex, ADTT if (possibleMagic == MCVT) { headerSkip = 8; // Skip magic + size if (chunkIndex == 0) { - LOG_INFO("MCNK sub-chunks have headers (MCVT magic found at offset ", ofsHeight, ")"); + LOG_DEBUG("MCNK sub-chunks have headers (MCVT magic found at offset ", ofsHeight, ")"); } } parseMCVT(data + ofsHeight + headerSkip, 580, chunk); @@ -434,7 +434,7 @@ void ADTLoader::parseMCLY(const uint8_t* data, size_t size, MapChunk& chunk) { layer.effectId = readUInt32(data, i * 16 + 12); if (layerLogCount < 10) { - LOG_INFO(" MCLY[", i, "]: texId=", layer.textureId, + LOG_DEBUG(" MCLY[", i, "]: texId=", layer.textureId, " flags=0x", std::hex, layer.flags, std::dec, " alphaOfs=", layer.offsetMCAL, " useAlpha=", layer.useAlpha(), @@ -574,7 +574,7 @@ void ADTLoader::parseMH2O(const uint8_t* data, size_t size, ADTTerrain& terrain) } } - LOG_INFO("Loaded MH2O water data: ", totalLayers, " liquid layers across ", size, " bytes"); + LOG_DEBUG("Loaded MH2O water data: ", totalLayers, " liquid layers across ", size, " bytes"); } } // namespace pipeline diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 365ce801..0522f9fb 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -83,7 +83,7 @@ BLPImage AssetManager::loadTexture(const std::string& path) { return BLPImage(); } - LOG_INFO("Loaded texture: ", normalizedPath, " (", image.width, "x", image.height, ")"); + LOG_DEBUG("Loaded texture: ", normalizedPath, " (", image.width, "x", image.height, ")"); return image; } diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 7d530c36..30e248e9 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -967,7 +967,7 @@ void M2Loader::loadAnimFile(const std::vector& m2Data, patchTrack(db.scale, bone.scale, TrackType::VEC3); } - core::Logger::getInstance().info("Loaded .anim for sequence ", sequenceIndex, + core::Logger::getInstance().debug("Loaded .anim for sequence ", sequenceIndex, " (id=", model.sequences[sequenceIndex].id, "): patched ", patchedTracks, " bone tracks"); } diff --git a/src/pipeline/mpq_manager.cpp b/src/pipeline/mpq_manager.cpp index 29c6b0cf..67583b16 100644 --- a/src/pipeline/mpq_manager.cpp +++ b/src/pipeline/mpq_manager.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #ifdef HAVE_STORMLIB #include @@ -21,6 +22,14 @@ typedef void* HANDLE; namespace wowee { namespace pipeline { +namespace { +std::string toLowerCopy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} +} + MPQManager::MPQManager() = default; MPQManager::~MPQManager() { @@ -95,6 +104,10 @@ void MPQManager::shutdown() { archives.clear(); archiveNames.clear(); + { + std::lock_guard lock(missingFileMutex_); + missingFileWarnings_.clear(); + } initialized = false; } @@ -229,7 +242,7 @@ std::vector MPQManager::readFile(const std::string& filename) const { } } if (!found) { - LOG_WARNING("File not found: ", filename); + logMissingFileOnce(filename); return std::vector(); } } @@ -247,10 +260,18 @@ std::vector MPQManager::readFile(const std::string& filename) const { } } - LOG_WARNING("File not found: ", filename); + logMissingFileOnce(filename); return std::vector(); } +void MPQManager::logMissingFileOnce(const std::string& filename) const { + std::string normalized = toLowerCopy(filename); + std::lock_guard lock(missingFileMutex_); + if (missingFileWarnings_.insert(normalized).second) { + LOG_WARNING("File not found: ", filename); + } +} + uint32_t MPQManager::getFileSize(const std::string& filename) const { #ifndef HAVE_STORMLIB return 0; diff --git a/src/pipeline/wmo_loader.cpp b/src/pipeline/wmo_loader.cpp index 7c1453d3..8241f2cc 100644 --- a/src/pipeline/wmo_loader.cpp +++ b/src/pipeline/wmo_loader.cpp @@ -123,7 +123,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { // flags and numLod (uint16 each) - skip for now offset += 4; - core::Logger::getInstance().info("WMO header: nTextures=", model.nTextures, " nGroups=", model.nGroups); + core::Logger::getInstance().debug("WMO header: nTextures=", model.nTextures, " nGroups=", model.nGroups); break; } @@ -133,7 +133,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { // We must map every offset to its texture index. uint32_t texOffset = chunkStart; uint32_t texIndex = 0; - core::Logger::getInstance().info("MOTX chunk: ", chunkSize, " bytes"); + core::Logger::getInstance().debug("MOTX chunk: ", chunkSize, " bytes"); while (texOffset < chunkEnd) { uint32_t relativeOffset = texOffset - chunkStart; @@ -187,7 +187,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.materials.push_back(mat); } - core::Logger::getInstance().info("WMO materials: ", model.materials.size()); + core::Logger::getInstance().debug("WMO materials: ", model.materials.size()); break; } @@ -220,7 +220,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.groupInfo.push_back(info); } - core::Logger::getInstance().info("WMO group info: ", model.groupInfo.size()); + core::Logger::getInstance().debug("WMO group info: ", model.groupInfo.size()); break; } @@ -254,7 +254,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.lights.push_back(light); } - core::Logger::getInstance().info("WMO lights: ", model.lights.size()); + core::Logger::getInstance().debug("WMO lights: ", model.lights.size()); break; } @@ -303,7 +303,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.doodads.push_back(doodad); } - core::Logger::getInstance().info("WMO doodads: ", model.doodads.size()); + core::Logger::getInstance().debug("WMO doodads: ", model.doodads.size()); break; } @@ -320,7 +320,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.doodadSets.push_back(set); } - core::Logger::getInstance().info("WMO doodad sets: ", model.doodadSets.size()); + core::Logger::getInstance().debug("WMO doodad sets: ", model.doodadSets.size()); break; } @@ -352,7 +352,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.portals.push_back(portal); } - core::Logger::getInstance().info("WMO portals: ", model.portals.size()); + core::Logger::getInstance().debug("WMO portals: ", model.portals.size()); break; } @@ -367,7 +367,7 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { ref.padding = read(wmoData, offset); model.portalRefs.push_back(ref); } - core::Logger::getInstance().info("WMO portal refs: ", model.portalRefs.size()); + core::Logger::getInstance().debug("WMO portal refs: ", model.portalRefs.size()); break; } @@ -429,8 +429,8 @@ bool WMOLoader::loadGroup(const std::vector& groupData, uint32_t mogpOffset = offset; group.flags = read(groupData, mogpOffset); bool isInterior = (group.flags & 0x2000) != 0; - core::Logger::getInstance().info(" Group flags: 0x", std::hex, group.flags, std::dec, - (isInterior ? " (INTERIOR)" : " (exterior)")); + core::Logger::getInstance().debug(" Group flags: 0x", std::hex, group.flags, std::dec, + (isInterior ? " (INTERIOR)" : " (exterior)")); group.boundingBoxMin.x = read(groupData, mogpOffset); group.boundingBoxMin.y = read(groupData, mogpOffset); group.boundingBoxMin.z = read(groupData, mogpOffset); @@ -493,7 +493,7 @@ bool WMOLoader::loadGroup(const std::vector& groupData, } else if (subChunkId == 0x4D4F4E52) { // MONR - Normals uint32_t normalCount = subChunkSize / 12; - core::Logger::getInstance().info(" MONR: ", normalCount, " normals for ", group.vertices.size(), " vertices"); + core::Logger::getInstance().debug(" MONR: ", normalCount, " normals for ", group.vertices.size(), " vertices"); for (uint32_t i = 0; i < normalCount && i < group.vertices.size(); i++) { group.vertices[i].normal.x = read(groupData, mogpOffset); group.vertices[i].normal.y = read(groupData, mogpOffset); @@ -507,7 +507,7 @@ bool WMOLoader::loadGroup(const std::vector& groupData, else if (subChunkId == 0x4D4F5456) { // MOTV - Texture coords // Update texture coords for existing vertices uint32_t texCoordCount = subChunkSize / 8; - core::Logger::getInstance().info(" MOTV: ", texCoordCount, " tex coords for ", group.vertices.size(), " vertices"); + core::Logger::getInstance().debug(" MOTV: ", texCoordCount, " tex coords for ", group.vertices.size(), " vertices"); for (uint32_t i = 0; i < texCoordCount && i < group.vertices.size(); i++) { group.vertices[i].texCoord.x = read(groupData, mogpOffset); group.vertices[i].texCoord.y = read(groupData, mogpOffset); @@ -519,7 +519,7 @@ bool WMOLoader::loadGroup(const std::vector& groupData, else if (subChunkId == 0x4D4F4356) { // MOCV - Vertex colors // Update vertex colors uint32_t colorCount = subChunkSize / 4; - core::Logger::getInstance().info(" MOCV: ", colorCount, " vertex colors for ", group.vertices.size(), " vertices"); + core::Logger::getInstance().debug(" MOCV: ", colorCount, " vertex colors for ", group.vertices.size(), " vertices"); for (uint32_t i = 0; i < colorCount && i < group.vertices.size(); i++) { uint8_t b = read(groupData, mogpOffset); uint8_t g = read(groupData, mogpOffset); @@ -555,7 +555,7 @@ bool WMOLoader::loadGroup(const std::vector& groupData, static int batchLogCount = 0; if (batchLogCount < 15) { - core::Logger::getInstance().info(" Batch[", i, "]: start=", batch.startIndex, + core::Logger::getInstance().debug(" Batch[", i, "]: start=", batch.startIndex, " count=", batch.indexCount, " verts=[", batch.startVertex, "-", batch.lastVertex, "] mat=", (int)batch.materialId, " flags=", (int)batch.flags); batchLogCount++; @@ -633,10 +633,10 @@ bool WMOLoader::loadGroup(const std::vector& groupData, group.batches.push_back(batch); } - core::Logger::getInstance().info("WMO group ", groupIndex, " loaded: ", - group.vertices.size(), " vertices, ", - group.indices.size(), " indices, ", - group.batches.size(), " batches"); + core::Logger::getInstance().debug("WMO group ", groupIndex, " loaded: ", + group.vertices.size(), " vertices, ", + group.indices.size(), " indices, ", + group.batches.size(), " batches"); return !group.vertices.empty() && !group.indices.empty(); } diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 550a6219..5ce6ef03 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -898,7 +898,8 @@ void CameraController::update(float deltaTime) { // WoW fades between ~1.0m and ~0.5m, hides fully below 0.5m // For now, just hide below first-person threshold if (characterRenderer && playerInstanceId > 0) { - bool shouldHidePlayer = (actualDist < MIN_DISTANCE + 0.1f); // Hide in first-person + // Honor first-person intent even if anti-clipping pushes camera back slightly. + bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f); characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer); } } else { diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 0d22d735..25787897 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -733,7 +733,7 @@ void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, } gpuModel.textureIds[textureSlot] = textureId; - core::Logger::getInstance().info("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture"); + core::Logger::getInstance().debug("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture"); } void CharacterRenderer::resetModelTexture(uint32_t modelId, uint32_t textureSlot) { @@ -773,9 +773,9 @@ bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) { models[id] = std::move(gpuModel); - core::Logger::getInstance().info("Loaded M2 model ", id, " (", model.vertices.size(), - " verts, ", model.bones.size(), " bones, ", model.sequences.size(), - " anims, ", model.textures.size(), " textures)"); + core::Logger::getInstance().debug("Loaded M2 model ", id, " (", model.vertices.size(), + " verts, ", model.bones.size(), " bones, ", model.sequences.size(), + " anims, ", model.textures.size(), " textures)"); return true; } @@ -1306,16 +1306,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons } if (filtered) skipped++; else rendered++; - LOG_INFO("Batch ", bIdx, ": submesh=", b.submeshId, - " level=", b.submeshLevel, - " idxStart=", b.indexStart, " idxCount=", b.indexCount, - " tex=", texInfo, - filtered ? " [SKIP]" : " [RENDER]"); + LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId, + " level=", b.submeshLevel, + " idxStart=", b.indexStart, " idxCount=", b.indexCount, + " tex=", texInfo, + filtered ? " [SKIP]" : " [RENDER]"); bIdx++; } - LOG_INFO("Batch summary: ", rendered, " rendered, ", skipped, " skipped, ", - gpuModel.textureIds.size(), " textures loaded, ", - gpuModel.data.textureLookup.size(), " in lookup table"); + LOG_DEBUG("Batch summary: ", rendered, " rendered, ", skipped, " skipped, ", + gpuModel.textureIds.size(), " textures loaded, ", + gpuModel.data.textureLookup.size(), " in lookup table"); for (size_t t = 0; t < gpuModel.data.textures.size(); t++) { } } diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 86685217..cf68b3b6 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -2520,6 +2520,24 @@ void M2Renderer::removeInstance(uint32_t instanceId) { } } +void M2Renderer::removeInstances(const std::vector& instanceIds) { + if (instanceIds.empty() || instances.empty()) { + return; + } + + std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); + const size_t oldSize = instances.size(); + instances.erase(std::remove_if(instances.begin(), instances.end(), + [&toRemove](const M2Instance& inst) { + return toRemove.find(inst.id) != toRemove.end(); + }), + instances.end()); + + if (instances.size() != oldSize) { + rebuildSpatialIndex(); + } +} + void M2Renderer::clear() { for (auto& [id, model] : models) { if (model.vao != 0) glDeleteVertexArrays(1, &model.vao); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index a1685283..bf436188 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -574,7 +574,7 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h } // Discover mount animation capabilities (property-based, not hardcoded IDs) - LOG_INFO("=== Mount Animation Dump (Display ID ", mountDisplayId, ") ==="); + LOG_DEBUG("=== Mount Animation Dump (Display ID ", mountDisplayId, ") ==="); characterRenderer->dumpAnimations(mountInstId); // Get all sequences for property-based analysis @@ -597,9 +597,9 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h // Property-based jump animation discovery with chain-based scoring auto discoverJumpSet = [&]() { // Debug: log all sequences for analysis - LOG_INFO("=== Full sequence table for mount ==="); + LOG_DEBUG("=== Full sequence table for mount ==="); for (const auto& seq : sequences) { - LOG_INFO("SEQ id=", seq.id, + LOG_DEBUG("SEQ id=", seq.id, " dur=", seq.duration, " flags=0x", std::hex, seq.flags, std::dec, " moveSpd=", seq.movingSpeed, @@ -607,7 +607,7 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h " next=", seq.nextAnimation, " alias=", seq.aliasNext); } - LOG_INFO("=== End sequence table ==="); + LOG_DEBUG("=== End sequence table ==="); // Known combat/bad animation IDs to avoid std::set forbiddenIds = {53, 54, 16}; // jumpkick, attack @@ -701,7 +701,7 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h } } - LOG_INFO("Property-based jump discovery: start=", start, " loop=", loop, " end=", end, + LOG_DEBUG("Property-based jump discovery: start=", start, " loop=", loop, " end=", end, " scores: start=", bestStart, " end=", bestEnd); return std::make_tuple(start, loop, end); }; @@ -718,17 +718,17 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h // Discover idle fidget animations using proper WoW M2 metadata (frequency, replay timers) mountAnims_.fidgets.clear(); - core::Logger::getInstance().info("Scanning for fidget animations in ", sequences.size(), " sequences"); + core::Logger::getInstance().debug("Scanning for fidget animations in ", sequences.size(), " sequences"); // DEBUG: Log ALL non-looping, short, stationary animations to identify stamps/tosses - core::Logger::getInstance().info("=== ALL potential fidgets (no metadata filter) ==="); + core::Logger::getInstance().debug("=== ALL potential fidgets (no metadata filter) ==="); for (const auto& seq : sequences) { bool isLoop = (seq.flags & 0x01) == 0; bool isStationary = std::abs(seq.movingSpeed) < 0.05f; bool reasonableDuration = seq.duration >= 400 && seq.duration <= 2500; if (!isLoop && reasonableDuration && isStationary) { - core::Logger::getInstance().info(" ALL: id=", seq.id, + core::Logger::getInstance().debug(" ALL: id=", seq.id, " dur=", seq.duration, "ms", " freq=", seq.frequency, " replay=", seq.replayMin, "-", seq.replayMax, @@ -747,7 +747,7 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h // Log candidates with metadata if (!isLoop && reasonableDuration && isStationary && (hasFrequency || hasReplay)) { - core::Logger::getInstance().info(" Candidate: id=", seq.id, + core::Logger::getInstance().debug(" Candidate: id=", seq.id, " dur=", seq.duration, "ms", " freq=", seq.frequency, " replay=", seq.replayMin, "-", seq.replayMax, @@ -770,7 +770,7 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h (seq.nextAnimation == -1); mountAnims_.fidgets.push_back(seq.id); - core::Logger::getInstance().info(" >> Selected fidget: id=", seq.id, + core::Logger::getInstance().debug(" >> Selected fidget: id=", seq.id, (chainsToStand ? " (chains to stand)" : "")); } } @@ -779,7 +779,7 @@ void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float h if (mountAnims_.stand == 0) mountAnims_.stand = 0; // Force 0 even if not found if (mountAnims_.run == 0) mountAnims_.run = mountAnims_.stand; // Fallback to stand if no run - core::Logger::getInstance().info("Mount animation set: jumpStart=", mountAnims_.jumpStart, + core::Logger::getInstance().debug("Mount animation set: jumpStart=", mountAnims_.jumpStart, " jumpLoop=", mountAnims_.jumpLoop, " jumpEnd=", mountAnims_.jumpEnd, " rearUp=", mountAnims_.rearUp, @@ -1001,7 +1001,7 @@ void Renderer::updateCharacterAnimation() { if (cameraController->isJumpKeyPressed() && grounded && mountAction_ == MountAction::None) { if (moving && mountAnims_.jumpLoop > 0) { // Moving: skip JumpStart (looks like stopping), go straight to airborne loop - LOG_INFO("Mount jump triggered while moving: using jumpLoop anim ", mountAnims_.jumpLoop); + LOG_DEBUG("Mount jump triggered while moving: using jumpLoop anim ", mountAnims_.jumpLoop); characterRenderer->playAnimation(mountInstanceId_, mountAnims_.jumpLoop, true); mountAction_ = MountAction::Jump; mountActionPhase_ = 1; // Start in airborne phase @@ -1014,7 +1014,7 @@ void Renderer::updateCharacterAnimation() { } } else if (!moving && mountAnims_.rearUp > 0) { // Standing still: rear-up flourish - LOG_INFO("Mount rear-up triggered: playing rearUp anim ", mountAnims_.rearUp); + LOG_DEBUG("Mount rear-up triggered: playing rearUp anim ", mountAnims_.rearUp); characterRenderer->playAnimation(mountInstanceId_, mountAnims_.rearUp, false); mountAction_ = MountAction::RearUp; mountActionPhase_ = 0; @@ -1035,17 +1035,17 @@ void Renderer::updateCharacterAnimation() { // Jump sequence: start → loop → end (physics-driven) if (mountActionPhase_ == 0 && animFinished && mountAnims_.jumpLoop > 0) { // JumpStart finished, go to JumpLoop (airborne) - LOG_INFO("Mount jump: phase 0→1 (JumpStart→JumpLoop anim ", mountAnims_.jumpLoop, ")"); + LOG_DEBUG("Mount jump: phase 0→1 (JumpStart→JumpLoop anim ", mountAnims_.jumpLoop, ")"); characterRenderer->playAnimation(mountInstanceId_, mountAnims_.jumpLoop, true); mountActionPhase_ = 1; mountAnimId = mountAnims_.jumpLoop; } else if (mountActionPhase_ == 0 && animFinished && mountAnims_.jumpLoop == 0) { // No JumpLoop, go straight to airborne phase 1 (hold JumpStart pose) - LOG_INFO("Mount jump: phase 0→1 (no JumpLoop, holding JumpStart)"); + LOG_DEBUG("Mount jump: phase 0→1 (no JumpLoop, holding JumpStart)"); mountActionPhase_ = 1; } else if (mountActionPhase_ == 1 && grounded && mountAnims_.jumpEnd > 0) { // Landed after airborne phase! Go to JumpEnd (grounded-triggered) - LOG_INFO("Mount jump: phase 1→2 (landed, JumpEnd anim ", mountAnims_.jumpEnd, ")"); + LOG_DEBUG("Mount jump: phase 1→2 (landed, JumpEnd anim ", mountAnims_.jumpEnd, ")"); characterRenderer->playAnimation(mountInstanceId_, mountAnims_.jumpEnd, false); mountActionPhase_ = 2; mountAnimId = mountAnims_.jumpEnd; @@ -1055,14 +1055,14 @@ void Renderer::updateCharacterAnimation() { } } else if (mountActionPhase_ == 1 && grounded && mountAnims_.jumpEnd == 0) { // No JumpEnd animation, return directly to movement after landing - LOG_INFO("Mount jump: phase 1→done (landed, no JumpEnd, returning to ", + LOG_DEBUG("Mount jump: phase 1→done (landed, no JumpEnd, returning to ", moving ? "run" : "stand", " anim ", (moving ? mountAnims_.run : mountAnims_.stand), ")"); mountAction_ = MountAction::None; mountAnimId = moving ? mountAnims_.run : mountAnims_.stand; characterRenderer->playAnimation(mountInstanceId_, mountAnimId, true); } else if (mountActionPhase_ == 2 && animFinished) { // JumpEnd finished, return to movement - LOG_INFO("Mount jump: phase 2→done (JumpEnd finished, returning to ", + LOG_DEBUG("Mount jump: phase 2→done (JumpEnd finished, returning to ", moving ? "run" : "stand", " anim ", (moving ? mountAnims_.run : mountAnims_.stand), ")"); mountAction_ = MountAction::None; mountAnimId = moving ? mountAnims_.run : mountAnims_.stand; @@ -1073,7 +1073,7 @@ void Renderer::updateCharacterAnimation() { } else if (mountAction_ == MountAction::RearUp) { // Rear-up: single animation, return to stand when done if (animFinished) { - LOG_INFO("Mount rear-up: finished, returning to ", + LOG_DEBUG("Mount rear-up: finished, returning to ", moving ? "run" : "stand", " anim ", (moving ? mountAnims_.run : mountAnims_.stand)); mountAction_ = MountAction::None; mountAnimId = moving ? mountAnims_.run : mountAnims_.stand; @@ -1110,7 +1110,7 @@ void Renderer::updateCharacterAnimation() { // If animation changed or completed, clear active fidget if (curAnim != mountActiveFidget_ || curTime >= curDur * 0.95f) { mountActiveFidget_ = 0; - LOG_INFO("Mount fidget completed"); + LOG_DEBUG("Mount fidget completed"); } } } @@ -1131,7 +1131,7 @@ void Renderer::updateCharacterAnimation() { mountIdleFidgetTimer_ = 0.0f; nextFidgetTime = 6.0f + (rand() % 7); // Randomize next fidget time - LOG_INFO("Mount idle fidget: playing anim ", fidgetAnim); + LOG_DEBUG("Mount idle fidget: playing anim ", fidgetAnim); } } if (moving) { @@ -1666,6 +1666,10 @@ audio::FootstepSurface Renderer::resolveFootstepSurface() const { } void Renderer::update(float deltaTime) { + if (musicSwitchCooldown_ > 0.0f) { + musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime); + } + auto updateStart = std::chrono::steady_clock::now(); lastDeltaTime_ = deltaTime; // Cache for use in updateCharacterAnimation() @@ -1696,6 +1700,14 @@ void Renderer::update(float deltaTime) { auto cam2 = std::chrono::high_resolution_clock::now(); camTime += std::chrono::duration(cam2 - cam1).count(); + // Visibility hardening: ensure player instance cannot stay hidden after + // taxi/camera transitions, but preserve first-person self-hide. + if (characterRenderer && characterInstanceId > 0 && cameraController) { + if ((cameraController->isThirdPerson() && !cameraController->isFirstPersonView()) || taxiFlight_) { + characterRenderer->setInstanceVisible(characterInstanceId, true); + } + } + // Update lighting system auto light1 = std::chrono::high_resolution_clock::now(); if (lightingManager) { @@ -2082,6 +2094,7 @@ void Renderer::update(float deltaTime) { inTavern_ = true; LOG_INFO("Entered tavern"); musicManager->playMusic(tavernMusic, true); // Immediate playback, looping + musicSwitchCooldown_ = 6.0f; } } else if (inTavern_) { // Exited tavern - restore zone music with crossfade @@ -2092,6 +2105,7 @@ void Renderer::update(float deltaTime) { std::string music = zoneManager->getRandomMusic(currentZoneId); if (!music.empty()) { musicManager->crossfadeTo(music); + musicSwitchCooldown_ = 6.0f; } } } @@ -2112,6 +2126,7 @@ void Renderer::update(float deltaTime) { std::string music = zoneManager->getRandomMusic(currentZoneId); if (!music.empty()) { musicManager->crossfadeTo(music); + musicSwitchCooldown_ = 6.0f; } } } @@ -2123,9 +2138,12 @@ void Renderer::update(float deltaTime) { if (info) { currentZoneName = info->name; LOG_INFO("Entered zone: ", info->name); - std::string music = zoneManager->getRandomMusic(zoneId); - if (!music.empty()) { - musicManager->crossfadeTo(music); + if (musicSwitchCooldown_ <= 0.0f) { + std::string music = zoneManager->getRandomMusic(zoneId); + if (!music.empty()) { + musicManager->crossfadeTo(music); + musicSwitchCooldown_ = 6.0f; + } } } } diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 6eec76d6..22f68d8b 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -244,7 +244,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { auto adtData = assetManager->readFile(adtPath); if (adtData.empty()) { - LOG_WARNING("Failed to load ADT file: ", adtPath); + logMissingAdtOnce(adtPath); return nullptr; } @@ -322,7 +322,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { preparedModelIds.insert(modelId); } else { skippedInvalid++; - LOG_WARNING("M2 model invalid (no verts/indices): ", m2Path); + LOG_DEBUG("M2 model invalid (no verts/indices): ", m2Path); } } else { skippedFileNotFound++; @@ -352,7 +352,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { } if (skippedNameId > 0 || skippedFileNotFound > 0 || skippedInvalid > 0) { - LOG_WARNING("Tile [", x, ",", y, "] doodad issues: ", + LOG_DEBUG("Tile [", x, ",", y, "] doodad issues: ", skippedNameId, " bad nameId, ", skippedFileNotFound, " file not found, ", skippedInvalid, " invalid model, ", @@ -547,6 +547,17 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { return pending; } +void TerrainManager::logMissingAdtOnce(const std::string& adtPath) { + std::string normalized = adtPath; + std::transform(normalized.begin(), normalized.end(), normalized.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + std::lock_guard lock(missingAdtWarningsMutex_); + if (missingAdtWarnings_.insert(normalized).second) { + LOG_WARNING("Failed to load ADT file: ", adtPath); + } +} + void TerrainManager::finalizeTile(const std::shared_ptr& pending) { int x = pending->coord.x; int y = pending->coord.y; @@ -723,7 +734,7 @@ void TerrainManager::finalizeTile(const std::shared_ptr& pending) { } } if (loadedWMOs > 0 || skippedWmoDedup > 0) { - LOG_INFO(" Loaded WMOs for tile [", x, ",", y, "]: ", + LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped"); } if (loadedLiquids > 0) { @@ -817,8 +828,8 @@ void TerrainManager::workerLoop() { void TerrainManager::processReadyTiles() { // Process tiles with time budget to avoid frame spikes - // Budget: 5ms per frame (allows 3 tiles at ~1.5ms each or 1 heavy tile) - const float timeBudgetMs = 5.0f; + // Taxi mode gets a slightly larger budget to avoid visible late-pop terrain/models. + const float timeBudgetMs = taxiStreamingMode_ ? 8.0f : 5.0f; auto startTime = std::chrono::high_resolution_clock::now(); int processed = 0; @@ -1010,9 +1021,7 @@ void TerrainManager::unloadTile(int x, int y) { // Remove M2 doodad instances if (m2Renderer) { - for (uint32_t id : tile->m2InstanceIds) { - m2Renderer->removeInstance(id); - } + m2Renderer->removeInstances(tile->m2InstanceIds); LOG_DEBUG(" Removed ", tile->m2InstanceIds.size(), " M2 instances"); } @@ -1023,8 +1032,8 @@ void TerrainManager::unloadTile(int x, int y) { if (waterRenderer) { waterRenderer->removeWMO(id); } - wmoRenderer->removeInstance(id); } + wmoRenderer->removeInstances(tile->wmoInstanceIds); LOG_DEBUG(" Removed ", tile->wmoInstanceIds.size(), " WMO instances"); } @@ -1328,6 +1337,18 @@ std::optional TerrainManager::getDominantTextureAt(float glX, float } void TerrainManager::streamTiles() { + auto shouldSkipMissingAdt = [this](const TileCoord& coord) -> bool { + if (!assetManager) return false; + if (failedTiles.find(coord) != failedTiles.end()) return true; + const std::string adtPath = getADTPath(coord); + if (!assetManager->fileExists(adtPath)) { + // Mark permanently failed so future stream/precache passes do not retry. + failedTiles[coord] = true; + return true; + } + return false; + }; + // Enqueue tiles in radius around current tile for async loading { std::lock_guard lock(queueMutex); @@ -1353,6 +1374,7 @@ void TerrainManager::streamTiles() { if (loadedTiles.find(coord) != loadedTiles.end()) continue; if (pendingTiles.find(coord) != pendingTiles.end()) continue; if (failedTiles.find(coord) != failedTiles.end()) continue; + if (shouldSkipMissingAdt(coord)) continue; loadQueue.push_back(coord); pendingTiles[coord] = true; @@ -1403,12 +1425,18 @@ void TerrainManager::precacheTiles(const std::vector>& tiles std::lock_guard lock(queueMutex); for (const auto& [x, y] : tiles) { + if (x < 0 || x > 63 || y < 0 || y > 63) continue; + TileCoord coord = {x, y}; // Skip if already loaded, pending, or failed if (loadedTiles.find(coord) != loadedTiles.end()) continue; if (pendingTiles.find(coord) != pendingTiles.end()) continue; if (failedTiles.find(coord) != failedTiles.end()) continue; + if (assetManager && !assetManager->fileExists(getADTPath(coord))) { + failedTiles[coord] = true; + continue; + } // Precache work is prioritized so taxi-route tiles are prepared before // opportunistic radius streaming tiles. diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 0874d572..77a38ff9 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -46,6 +46,17 @@ bool TerrainRenderer::initialize(pipeline::AssetManager* assets) { glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, 0); + // Create default opaque alpha texture for terrain layer masks + uint8_t opaqueAlpha = 255; + glGenTextures(1, &opaqueAlphaTexture); + glBindTexture(GL_TEXTURE_2D, opaqueAlphaTexture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, 1, 1, 0, GL_RED, GL_UNSIGNED_BYTE, &opaqueAlpha); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + LOG_INFO("Terrain renderer initialized"); return true; } @@ -60,6 +71,10 @@ void TerrainRenderer::shutdown() { glDeleteTextures(1, &whiteTexture); whiteTexture = 0; } + if (opaqueAlphaTexture) { + glDeleteTextures(1, &opaqueAlphaTexture); + opaqueAlphaTexture = 0; + } // Delete cached textures for (auto& pair : textureCache) { @@ -73,7 +88,7 @@ void TerrainRenderer::shutdown() { bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, const std::vector& texturePaths, int tileX, int tileY) { - LOG_INFO("Loading terrain mesh: ", mesh.validChunkCount, " chunks"); + LOG_DEBUG("Loading terrain mesh: ", mesh.validChunkCount, " chunks"); // Upload each chunk to GPU for (int y = 0; y < 16; y++) { @@ -116,7 +131,7 @@ bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, gpuChunk.layerTextures.push_back(layerTex); // Create alpha texture - GLuint alphaTex = 0; + GLuint alphaTex = opaqueAlphaTexture; if (!layer.alphaData.empty()) { alphaTex = createAlphaTexture(layer.alphaData); } @@ -133,7 +148,7 @@ bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, } } - LOG_INFO("Loaded ", chunks.size(), " terrain chunks to GPU"); + LOG_DEBUG("Loaded ", chunks.size(), " terrain chunks to GPU"); return !chunks.empty(); } @@ -218,7 +233,8 @@ GLuint TerrainRenderer::loadTexture(const std::string& path) { pipeline::BLPImage blp = assetManager->loadTexture(path); if (!blp.isValid()) { LOG_WARNING("Failed to load texture: ", path); - textureCache[path] = whiteTexture; + // Do not cache failure as white: MPQ/file reads can fail transiently + // during heavy streaming and should be allowed to recover. return whiteTexture; } @@ -257,7 +273,8 @@ void TerrainRenderer::uploadPreloadedTextures(const std::unordered_map& alphaData) { if (alphaData.empty()) { - return 0; + return opaqueAlphaTexture; + } + + if (alphaData.size() != 4096) { + LOG_WARNING("Unexpected terrain alpha size: ", alphaData.size(), " (expected 4096)"); } GLuint textureID; glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D, textureID); - // Alpha data is always expanded to 4096 bytes (64x64 at 8-bit) by terrain_mesh + // Alpha data should be 64x64 (4096 bytes). Clamp to a sane fallback when malformed. + std::vector expanded; + const uint8_t* src = alphaData.data(); + if (alphaData.size() < 4096) { + expanded.assign(4096, 255); + std::copy(alphaData.begin(), alphaData.end(), expanded.begin()); + src = expanded.data(); + } + int width = 64; - int height = static_cast(alphaData.size()) / 64; + int height = 64; glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, width, height, 0, - GL_RED, GL_UNSIGNED_BYTE, alphaData.data()); + GL_RED, GL_UNSIGNED_BYTE, src); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); @@ -329,6 +358,9 @@ void TerrainRenderer::render(const Camera& camera) { // Enable depth testing glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LESS); + glDepthMask(GL_TRUE); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glDisable(GL_BLEND); // Disable backface culling temporarily to debug flashing glDisable(GL_CULL_FACE); diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 8c789e14..f0404bf2 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -184,10 +184,10 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap constexpr float TILE_SIZE = 33.33333f / 8.0f; if (!append) { - LOG_INFO("Loading water from terrain (replacing)"); + LOG_DEBUG("Loading water from terrain (replacing)"); clear(); } else { - LOG_INFO("Loading water from terrain (appending)"); + LOG_DEBUG("Loading water from terrain (appending)"); } // Load water surfaces from MH2O data @@ -285,14 +285,14 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap glm::vec2(moonwellPos.x, moonwellPos.y)); if (distToMoonwell > 300.0f) { // Terrain tiles are large, use bigger exclusion radius - LOG_INFO(" -> LOWERING water at tile (", tileX, ",", tileY, ") from height ", layer.minHeight, " by 1 unit"); + LOG_DEBUG(" -> LOWERING water at tile (", tileX, ",", tileY, ") from height ", layer.minHeight, " by 1 unit"); for (float& h : surface.heights) { h -= 1.0f; } surface.minHeight -= 1.0f; surface.maxHeight -= 1.0f; } else { - LOG_INFO(" -> SKIPPING tile (", tileX, ",", tileY, ") - moonwell exclusion (dist: ", distToMoonwell, ")"); + LOG_DEBUG(" -> SKIPPING tile (", tileX, ",", tileY, ") - moonwell exclusion (dist: ", distToMoonwell, ")"); } } @@ -307,7 +307,7 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap } } - LOG_INFO("Loaded ", totalLayers, " water layers from MH2O data"); + LOG_DEBUG("Loaded ", totalLayers, " water layers from MH2O data"); } void WaterRenderer::removeTile(int tileX, int tileY) { @@ -391,7 +391,7 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu int tileY = static_cast(std::floor((32.0f - surface.origin.y / 533.33333f))); // Log all WMO water to debug park issue - LOG_INFO("WMO water at pos=(", surface.origin.x, ",", surface.origin.y, ",", surface.origin.z, + LOG_DEBUG("WMO water at pos=(", surface.origin.x, ",", surface.origin.y, ",", surface.origin.z, ") tile=(", tileX, ",", tileY, ") wmoId=", wmoId); // Expanded bounds to cover all of Stormwind including outlying areas and park @@ -405,27 +405,27 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu glm::vec2(moonwellPos.x, moonwellPos.y)); if (distToMoonwell > 20.0f) { - LOG_INFO(" -> LOWERING by 1 unit (dist to moonwell: ", distToMoonwell, ")"); + LOG_DEBUG(" -> LOWERING by 1 unit (dist to moonwell: ", distToMoonwell, ")"); for (float& h : surface.heights) { h -= 1.0f; } surface.minHeight -= 1.0f; surface.maxHeight -= 1.0f; } else { - LOG_INFO(" -> SKIPPING (moonwell exclusion zone, dist: ", distToMoonwell, ")"); + LOG_DEBUG(" -> SKIPPING (moonwell exclusion zone, dist: ", distToMoonwell, ")"); } } // Skip WMO water that's clearly invalid (extremely high - above 300 units) // This is a conservative global filter that won't affect normal gameplay if (surface.origin.z > 300.0f) { - LOG_INFO("WMO water filtered: height=", surface.origin.z, " wmoId=", wmoId, " (too high)"); + LOG_DEBUG("WMO water filtered: height=", surface.origin.z, " wmoId=", wmoId, " (too high)"); return; } // Skip WMO water that's extremely low (deep underground where it shouldn't be) if (surface.origin.z < -100.0f) { - LOG_INFO("WMO water filtered: height=", surface.origin.z, " wmoId=", wmoId, " (too low)"); + LOG_DEBUG("WMO water filtered: height=", surface.origin.z, " wmoId=", wmoId, " (too low)"); return; } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index d52e8a8b..60e08df9 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -268,8 +268,8 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { return true; } - core::Logger::getInstance().info("Loading WMO model ", id, " with ", model.groups.size(), " groups, ", - model.textures.size(), " textures..."); + core::Logger::getInstance().debug("Loading WMO model ", id, " with ", model.groups.size(), " groups, ", + model.textures.size(), " textures..."); ModelData modelData; modelData.id = id; @@ -282,11 +282,11 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { modelData.isLowPlatform = (vert < 6.0f && horiz > 20.0f); } - core::Logger::getInstance().info(" WMO bounds: min=(", model.boundingBoxMin.x, ", ", model.boundingBoxMin.y, ", ", model.boundingBoxMin.z, - ") max=(", model.boundingBoxMax.x, ", ", model.boundingBoxMax.y, ", ", model.boundingBoxMax.z, ")"); + core::Logger::getInstance().debug(" WMO bounds: min=(", model.boundingBoxMin.x, ", ", model.boundingBoxMin.y, ", ", model.boundingBoxMin.z, + ") max=(", model.boundingBoxMax.x, ", ", model.boundingBoxMax.y, ", ", model.boundingBoxMax.z, ")"); // Load textures for this model - core::Logger::getInstance().info(" WMO has ", model.textures.size(), " texture paths, ", model.materials.size(), " materials"); + core::Logger::getInstance().debug(" WMO has ", model.textures.size(), " texture paths, ", model.materials.size(), " materials"); if (assetManager && !model.textures.empty()) { for (size_t i = 0; i < model.textures.size(); i++) { const auto& texPath = model.textures[i]; @@ -294,13 +294,13 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { GLuint texId = loadTexture(texPath); modelData.textures.push_back(texId); } - core::Logger::getInstance().info(" Loaded ", modelData.textures.size(), " textures for WMO"); + core::Logger::getInstance().debug(" Loaded ", modelData.textures.size(), " textures for WMO"); } // Store material -> texture index mapping // IMPORTANT: mat.texture1 is a byte offset into MOTX, not an array index! // We need to convert it using the textureOffsetToIndex map - core::Logger::getInstance().info(" textureOffsetToIndex map has ", model.textureOffsetToIndex.size(), " entries"); + core::Logger::getInstance().debug(" textureOffsetToIndex map has ", model.textureOffsetToIndex.size(), " entries"); static int matLogCount = 0; for (size_t i = 0; i < model.materials.size(); i++) { const auto& mat = model.materials[i]; @@ -310,19 +310,19 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { if (it != model.textureOffsetToIndex.end()) { texIndex = it->second; if (matLogCount < 20) { - core::Logger::getInstance().info(" Material ", i, ": texture1 offset ", mat.texture1, " -> texture index ", texIndex); + core::Logger::getInstance().debug(" Material ", i, ": texture1 offset ", mat.texture1, " -> texture index ", texIndex); matLogCount++; } } else if (mat.texture1 < model.textures.size()) { // Fallback: maybe it IS an index in some files? texIndex = mat.texture1; if (matLogCount < 20) { - core::Logger::getInstance().info(" Material ", i, ": using texture1 as direct index: ", texIndex); + core::Logger::getInstance().debug(" Material ", i, ": using texture1 as direct index: ", texIndex); matLogCount++; } } else { if (matLogCount < 20) { - core::Logger::getInstance().info(" Material ", i, ": texture1 offset ", mat.texture1, " NOT FOUND, using default"); + core::Logger::getInstance().debug(" Material ", i, ": texture1 offset ", mat.texture1, " NOT FOUND, using default"); matLogCount++; } } @@ -435,8 +435,8 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } if (!modelData.portals.empty()) { - core::Logger::getInstance().info("WMO portals: ", modelData.portals.size(), - " refs: ", modelData.portalRefs.size()); + core::Logger::getInstance().debug("WMO portals: ", modelData.portals.size(), + " refs: ", modelData.portalRefs.size()); } // Store doodad templates (M2 models placed in WMO) for instancing later @@ -478,12 +478,12 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } if (!modelData.doodadTemplates.empty()) { - core::Logger::getInstance().info("WMO has ", modelData.doodadTemplates.size(), " doodad templates"); + core::Logger::getInstance().debug("WMO has ", modelData.doodadTemplates.size(), " doodad templates"); } } loadedModels[id] = std::move(modelData); - core::Logger::getInstance().info("WMO model ", id, " loaded successfully (", loadedGroups, " groups)"); + core::Logger::getInstance().debug("WMO model ", id, " loaded successfully (", loadedGroups, " groups)"); return true; } @@ -570,7 +570,7 @@ uint32_t WMORenderer::createInstance(uint32_t modelId, const glm::vec3& position } } } - core::Logger::getInstance().info("Created WMO instance ", instance.id, " (model ", modelId, ")"); + core::Logger::getInstance().debug("Created WMO instance ", instance.id, " (model ", modelId, ")"); return instance.id; } @@ -677,7 +677,27 @@ void WMORenderer::removeInstance(uint32_t instanceId) { if (it != instances.end()) { instances.erase(it); rebuildSpatialIndex(); - core::Logger::getInstance().info("Removed WMO instance ", instanceId); + core::Logger::getInstance().debug("Removed WMO instance ", instanceId); + } +} + +void WMORenderer::removeInstances(const std::vector& instanceIds) { + if (instanceIds.empty() || instances.empty()) { + return; + } + + std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); + const size_t oldSize = instances.size(); + instances.erase(std::remove_if(instances.begin(), instances.end(), + [&toRemove](const WMOInstance& inst) { + return toRemove.find(inst.id) != toRemove.end(); + }), + instances.end()); + + if (instances.size() != oldSize) { + rebuildSpatialIndex(); + core::Logger::getInstance().debug("Removed ", (oldSize - instances.size()), + " WMO instances (batched)"); } } @@ -1582,7 +1602,9 @@ GLuint WMORenderer::loadTexture(const std::string& path) { pipeline::BLPImage blp = assetManager->loadTexture(path); if (!blp.isValid()) { core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); - textureCache[path] = whiteTexture; + // Do not cache failures as white. MPQ reads can fail transiently + // during streaming/contention, and caching white here permanently + // poisons the texture for this session. return whiteTexture; }