From ae88b226b50e17db0692ba3e0dea45fd7c9d8e63 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 22 Feb 2026 07:26:54 -0800 Subject: [PATCH] Stabilize streaming memory and parser handling; revert socket recv optimizations --- include/game/packet_parsers.hpp | 1 + include/rendering/m2_renderer.hpp | 5 + include/rendering/terrain_renderer.hpp | 4 + include/rendering/wmo_renderer.hpp | 5 + src/core/application.cpp | 215 +++++++++++++++++++------ src/game/game_handler.cpp | 104 ++++++++---- src/game/packet_parsers_classic.cpp | 18 +++ src/game/packet_parsers_tbc.cpp | 174 ++++++++++++-------- src/game/world_packets.cpp | 12 ++ src/pipeline/asset_manager.cpp | 18 ++- src/rendering/character_renderer.cpp | 2 +- src/rendering/m2_renderer.cpp | 61 ++++++- src/rendering/renderer.cpp | 13 ++ src/rendering/terrain_renderer.cpp | 43 ++++- src/rendering/wmo_renderer.cpp | 77 ++++++++- 15 files changed, 591 insertions(+), 161 deletions(-) diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 42ad5b5f..edd97e8c 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -353,6 +353,7 @@ class TurtlePacketParsers : public ClassicPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; + bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; }; /** diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 2b626b86..88edb24f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -356,6 +356,8 @@ private: uint32_t nextInstanceId = 1; uint32_t lastDrawCallCount = 0; + size_t modelCacheLimit_ = 6000; + uint32_t modelLimitRejectWarnings_ = 0; VkTexture* loadTexture(const std::string& path, uint32_t texFlags = 0); struct TextureCacheEntry { @@ -371,6 +373,9 @@ private: size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; + std::unordered_set failedTextureCache_; + std::unordered_set loggedTextureLoadFails_; + uint32_t textureBudgetRejectWarnings_ = 0; std::unique_ptr whiteTexture_; std::unique_ptr glowTexture_; diff --git a/include/rendering/terrain_renderer.hpp b/include/rendering/terrain_renderer.hpp index 0a451290..91279e9c 100644 --- a/include/rendering/terrain_renderer.hpp +++ b/include/rendering/terrain_renderer.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace wowee { @@ -156,6 +157,9 @@ private: size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 4096ull * 1024 * 1024; + std::unordered_set failedTextureCache_; + std::unordered_set loggedTextureLoadFails_; + uint32_t textureBudgetRejectWarnings_ = 0; // Fallback textures std::unique_ptr whiteTexture; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index b64b1073..21cbda9f 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -587,12 +587,17 @@ private: size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; // Default, overridden at init + std::unordered_set failedTextureCache_; + std::unordered_set loggedTextureLoadFails_; + uint32_t textureBudgetRejectWarnings_ = 0; // Default white texture std::unique_ptr whiteTexture_; // Loaded models (modelId -> ModelData) std::unordered_map loadedModels; + size_t modelCacheLimit_ = 4000; + uint32_t modelLimitRejectWarnings_ = 0; // Active instances std::vector instances; diff --git a/src/core/application.cpp b/src/core/application.cpp index 1cdcdb31..f841544e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -320,15 +320,41 @@ void Application::run() { auto t1 = std::chrono::steady_clock::now(); // Update application state - update(deltaTime); + try { + update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } auto t2 = std::chrono::steady_clock::now(); // Render - render(); + try { + render(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } auto t3 = std::chrono::steady_clock::now(); // Swap buffers - window->swapBuffers(); + try { + window->swapBuffers(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during swapBuffers: ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during swapBuffers: ", e.what()); + throw; + } auto t4 = std::chrono::steady_clock::now(); totalUpdateMs += std::chrono::duration(t2 - t1).count(); @@ -533,7 +559,10 @@ void Application::logoutToLogin() { } void Application::update(float deltaTime) { + const char* updateCheckpoint = "enter"; + try { // Update based on current state + updateCheckpoint = "state switch"; switch (state) { case AppState::AUTHENTICATION: if (authHandler) { @@ -563,20 +592,40 @@ void Application::update(float deltaTime) { break; case AppState::IN_GAME: { + const char* inGameStep = "begin"; + try { + auto runInGameStage = [&](const char* stageName, auto&& fn) { + try { + fn(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during IN_GAME update stage '", stageName, "': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during IN_GAME update stage '", stageName, "': ", e.what()); + throw; + } + }; // Application update profiling + updateCheckpoint = "in_game: profile init"; static int appProfileCounter = 0; static float ghTime = 0.0f, worldTime = 0.0f, spawnTime = 0.0f; static float creatureQTime = 0.0f, goQTime = 0.0f, mountTime = 0.0f; static float npcMgrTime = 0.0f, questMarkTime = 0.0f, syncTime = 0.0f; auto gh1 = std::chrono::high_resolution_clock::now(); - if (gameHandler) { - gameHandler->update(deltaTime); - } + inGameStep = "gameHandler update"; + updateCheckpoint = "in_game: gameHandler update"; + runInGameStage("gameHandler->update", [&] { + if (gameHandler) { + gameHandler->update(deltaTime); + } + }); auto gh2 = std::chrono::high_resolution_clock::now(); ghTime += std::chrono::duration(gh2 - gh1).count(); // Always unsheath on combat engage. + inGameStep = "auto-unsheathe"; + updateCheckpoint = "in_game: auto-unsheathe"; if (gameHandler) { const bool autoAttacking = gameHandler->isAutoAttacking(); if (autoAttacking && !wasAutoAttacking_ && weaponsSheathed_) { @@ -587,6 +636,8 @@ void Application::update(float deltaTime) { } // Toggle weapon sheathe state with Z (ignored while UI captures keyboard). + inGameStep = "weapon-toggle input"; + updateCheckpoint = "in_game: weapon-toggle input"; { const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; auto& input = Input::getInstance(); @@ -597,23 +648,33 @@ void Application::update(float deltaTime) { } auto w1 = std::chrono::high_resolution_clock::now(); - if (world) { - world->update(deltaTime); - } + inGameStep = "world update"; + updateCheckpoint = "in_game: world update"; + runInGameStage("world->update", [&] { + if (world) { + world->update(deltaTime); + } + }); auto w2 = std::chrono::high_resolution_clock::now(); worldTime += std::chrono::duration(w2 - w1).count(); auto cq1 = std::chrono::high_resolution_clock::now(); - processPlayerSpawnQueue(); - // Process deferred online creature spawns (throttled) - processCreatureSpawnQueue(); - // Process deferred equipment compositing (max 1 per frame to avoid stutter) - processDeferredEquipmentQueue(); + inGameStep = "spawn/equipment queues"; + updateCheckpoint = "in_game: spawn/equipment queues"; + runInGameStage("spawn/equipment queues", [&] { + processPlayerSpawnQueue(); + // Process deferred online creature spawns (throttled) + processCreatureSpawnQueue(); + // Process deferred equipment compositing (max 1 per frame to avoid stutter) + processDeferredEquipmentQueue(); + }); auto cq2 = std::chrono::high_resolution_clock::now(); creatureQTime += std::chrono::duration(cq2 - cq1).count(); // Self-heal missing creature visuals: if a nearby UNIT exists in // entity state but has no render instance, queue a spawn retry. + inGameStep = "creature resync scan"; + updateCheckpoint = "in_game: creature resync scan"; if (gameHandler) { static float creatureResyncTimer = 0.0f; creatureResyncTimer += deltaTime; @@ -659,13 +720,21 @@ void Application::update(float deltaTime) { } auto goq1 = std::chrono::high_resolution_clock::now(); - processGameObjectSpawnQueue(); - processPendingTransportDoodads(); + inGameStep = "gameobject/transport queues"; + updateCheckpoint = "in_game: gameobject/transport queues"; + runInGameStage("gameobject/transport queues", [&] { + processGameObjectSpawnQueue(); + processPendingTransportDoodads(); + }); auto goq2 = std::chrono::high_resolution_clock::now(); goQTime += std::chrono::duration(goq2 - goq1).count(); auto m1 = std::chrono::high_resolution_clock::now(); - processPendingMount(); + inGameStep = "pending mount"; + updateCheckpoint = "in_game: pending mount"; + runInGameStage("processPendingMount", [&] { + processPendingMount(); + }); auto m2 = std::chrono::high_resolution_clock::now(); mountTime += std::chrono::duration(m2 - m1).count(); @@ -675,26 +744,33 @@ void Application::update(float deltaTime) { auto qm1 = std::chrono::high_resolution_clock::now(); // Update 3D quest markers above NPCs - updateQuestMarkers(); + inGameStep = "quest markers"; + updateCheckpoint = "in_game: quest markers"; + runInGameStage("updateQuestMarkers", [&] { + updateQuestMarkers(); + }); auto qm2 = std::chrono::high_resolution_clock::now(); questMarkTime += std::chrono::duration(qm2 - qm1).count(); auto sync1 = std::chrono::high_resolution_clock::now(); // Sync server run speed to camera controller - if (renderer && gameHandler && renderer->getCameraController()) { - renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); - } + inGameStep = "post-update sync"; + updateCheckpoint = "in_game: post-update sync"; + runInGameStage("post-update sync", [&] { + if (renderer && gameHandler && renderer->getCameraController()) { + renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); + } - bool onTaxi = gameHandler && - (gameHandler->isOnTaxiFlight() || - gameHandler->isTaxiMountActive() || - gameHandler->isTaxiActivationPending()); - bool onTransportNow = gameHandler && gameHandler->isOnTransport(); - if (worldEntryMovementGraceTimer_ > 0.0f) { - worldEntryMovementGraceTimer_ -= deltaTime; - } - if (renderer && renderer->getCameraController()) { + bool onTaxi = gameHandler && + (gameHandler->isOnTaxiFlight() || + gameHandler->isTaxiMountActive() || + gameHandler->isTaxiActivationPending()); + bool onTransportNow = gameHandler && gameHandler->isOnTransport(); + if (worldEntryMovementGraceTimer_ > 0.0f) { + worldEntryMovementGraceTimer_ -= deltaTime; + } + if (renderer && renderer->getCameraController()) { const bool externallyDrivenMotion = onTaxi || onTransportNow || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. @@ -759,22 +835,22 @@ void Application::update(float deltaTime) { } else if (!idleOrbit) { idleYawned_ = false; } - } - if (renderer) { - renderer->setTaxiFlight(onTaxi); - } - if (renderer && renderer->getTerrainManager()) { + } + if (renderer) { + renderer->setTaxiFlight(onTaxi); + } + if (renderer && renderer->getTerrainManager()) { renderer->getTerrainManager()->setStreamingEnabled(true); // 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; + } + lastTaxiFlight_ = onTaxi; - // Sync character render position ↔ canonical WoW coords each frame - if (renderer && gameHandler) { + // Sync character render position ↔ canonical WoW coords each frame + if (renderer && gameHandler) { bool onTransport = gameHandler->isOnTransport(); // Debug: Log transport state changes @@ -922,11 +998,14 @@ void Application::update(float deltaTime) { } } } - } + } + }); // Keep creature render instances aligned with authoritative entity positions. // This prevents desync where target circles move with server entities but // creature models remain at stale spawn positions. + inGameStep = "creature render sync"; + updateCheckpoint = "in_game: creature render sync"; if (renderer && gameHandler && renderer->getCharacterRenderer()) { auto* charRenderer = renderer->getCharacterRenderer(); static float npcWeaponRetryTimer = 0.0f; @@ -1061,16 +1140,26 @@ void Application::update(float deltaTime) { // Log profiling every 60 frames if (++appProfileCounter >= 60) { - LOG_DEBUG("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f, - "ms world=", worldTime / 60.0f, "ms spawn=", spawnTime / 60.0f, - "ms creatureQ=", creatureQTime / 60.0f, "ms goQ=", goQTime / 60.0f, - "ms mount=", mountTime / 60.0f, "ms npcMgr=", npcMgrTime / 60.0f, - "ms questMark=", questMarkTime / 60.0f, "ms sync=", syncTime / 60.0f, "ms"); + updateCheckpoint = "in_game: profile log"; + if (core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG)) { + LOG_DEBUG("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f, + "ms world=", worldTime / 60.0f, "ms spawn=", spawnTime / 60.0f, + "ms creatureQ=", creatureQTime / 60.0f, "ms goQ=", goQTime / 60.0f, + "ms mount=", mountTime / 60.0f, "ms npcMgr=", npcMgrTime / 60.0f, + "ms questMark=", questMarkTime / 60.0f, "ms sync=", syncTime / 60.0f, "ms"); + } appProfileCounter = 0; ghTime = worldTime = spawnTime = 0.0f; creatureQTime = goQTime = mountTime = 0.0f; npcMgrTime = questMarkTime = syncTime = 0.0f; } + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM inside AppState::IN_GAME at step '", inGameStep, "': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception inside AppState::IN_GAME at step '", inGameStep, "': ", e.what()); + throw; + } break; } @@ -1083,27 +1172,55 @@ void Application::update(float deltaTime) { static int rendererProfileCounter = 0; static float rendererTime = 0.0f, uiTime = 0.0f; + updateCheckpoint = "renderer update"; auto r1 = std::chrono::high_resolution_clock::now(); if (renderer && state == AppState::IN_GAME) { - renderer->update(deltaTime); + try { + renderer->update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update stage 'renderer->update': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update stage 'renderer->update': ", e.what()); + throw; + } } auto r2 = std::chrono::high_resolution_clock::now(); rendererTime += std::chrono::duration(r2 - r1).count(); // Update UI + updateCheckpoint = "ui update"; auto u1 = std::chrono::high_resolution_clock::now(); if (uiManager) { - uiManager->update(deltaTime); + try { + uiManager->update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update stage 'uiManager->update': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update stage 'uiManager->update': ", e.what()); + throw; + } } auto u2 = std::chrono::high_resolution_clock::now(); uiTime += std::chrono::duration(u2 - u1).count(); + updateCheckpoint = "renderer/ui profile log"; if (state == AppState::IN_GAME && ++rendererProfileCounter >= 60) { - LOG_DEBUG("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f, - "ms ui=", uiTime / 60.0f, "ms"); + if (core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG)) { + LOG_DEBUG("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f, + "ms ui=", uiTime / 60.0f, "ms"); + } rendererProfileCounter = 0; rendererTime = uiTime = 0.0f; } + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM in Application::update checkpoint '", updateCheckpoint, "': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception in Application::update checkpoint '", updateCheckpoint, "': ", e.what()); + throw; + } } void Application::render() { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 07b3221c..5f0fef5e 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1143,6 +1143,9 @@ void GameHandler::handlePacket(network::Packet& packet) { } uint16_t opcode = packet.getOpcode(); + try { + + const bool allowVanillaAliases = isClassicLikeExpansion() || isActiveExpansion("tbc"); // Vanilla compatibility aliases: // - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers @@ -1150,7 +1153,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers) // // We gate these by payload shape so expansion-native mappings remain intact. - if (opcode == 0x006B) { + if (allowVanillaAliases && opcode == 0x006B) { // Try compressed movement batch first: // [u8 subSize][u16 subOpcode][subPayload...] ... // where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT. @@ -1198,7 +1201,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Not weather-shaped: rewind and fall through to normal opcode table handling. packet.setReadPos(0); } - } else if (opcode == 0x0103) { + } else if (allowVanillaAliases && opcode == 0x0103) { // Expected play-music payload: uint32 sound/music id if (packet.getSize() - packet.getReadPos() == 4) { uint32_t soundId = packet.readUInt32(); @@ -2858,6 +2861,23 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM while handling world opcode=0x", std::hex, opcode, std::dec, + " state=", worldStateName(state), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " what=", e.what()); + if (socket && state == WorldState::IN_WORLD) { + disconnect(); + fail("Out of memory while parsing world packet"); + } + } catch (const std::exception& e) { + LOG_ERROR("Exception while handling world opcode=0x", std::hex, opcode, std::dec, + " state=", worldStateName(state), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " what=", e.what()); + } } void GameHandler::handleAuthChallenge(network::Packet& packet) { @@ -8659,10 +8679,30 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { void GameHandler::handleMonsterMove(network::Packet& packet) { MonsterMoveData data; + auto logMonsterMoveParseFailure = [&](const std::string& msg) { + static uint32_t failCount = 0; + ++failCount; + if (failCount <= 10 || (failCount % 100) == 0) { + LOG_WARNING(msg, " (occurrence=", failCount, ")"); + } + }; + auto stripWrappedSubpacket = [&](const std::vector& bytes, std::vector& stripped) -> bool { + if (bytes.size() < 3) return false; + uint8_t subSize = bytes[0]; + if (subSize < 2) return false; + size_t wrappedLen = static_cast(subSize) + 1; // size byte + body + if (wrappedLen != bytes.size()) return false; + size_t payloadLen = static_cast(subSize) - 2; // opcode(2) stripped + if (3 + payloadLen > bytes.size()) return false; + stripped.assign(bytes.begin() + 3, bytes.begin() + 3 + payloadLen); + return true; + }; // Turtle WoW (1.17+) compresses each SMSG_MONSTER_MOVE individually: // format: uint32 decompressedSize + zlib data (zlib magic = 0x78 ??) const auto& rawData = packet.getData(); - bool isCompressed = rawData.size() >= 6 && + const bool allowTurtleMoveCompression = isActiveExpansion("turtle"); + bool isCompressed = allowTurtleMoveCompression && + rawData.size() >= 6 && rawData[4] == 0x78 && (rawData[5] == 0x01 || rawData[5] == 0x9C || rawData[5] == 0xDA || rawData[5] == 0x5E); @@ -8694,36 +8734,42 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { } LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex); } - // Some Turtle WoW compressed move payloads include an inner - // sub-packet wrapper: uint8 size + uint16 opcode + payload. - // Do not key this on expansion opcode mappings; strip by structure. - std::vector parseBytes = decompressed; - if (destLen >= 3) { - uint8_t subSize = decompressed[0]; - size_t wrappedLen = static_cast(subSize) + 1; // size byte + subSize bytes - uint16_t innerOpcode = static_cast(decompressed[1]) | - (static_cast(decompressed[2]) << 8); - uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); - bool looksLikeMonsterMoveWrapper = - (innerOpcode == 0x00DD) || (innerOpcode == monsterMoveWire); - // Strict case: one exact wrapped sub-packet in this decompressed blob. - if (subSize >= 2 && wrappedLen == destLen && looksLikeMonsterMoveWrapper) { - size_t payloadStart = 3; - size_t payloadLen = static_cast(subSize) - 2; - parseBytes.assign(decompressed.begin() + payloadStart, - decompressed.begin() + payloadStart + payloadLen); - } - } + std::vector stripped; + bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped); - network::Packet decompPacket(packet.getOpcode(), parseBytes); + // Try unwrapped payload first (common form), then wrapped-subpacket fallback. + network::Packet decompPacket(packet.getOpcode(), decompressed); if (!packetParsers_->parseMonsterMove(decompPacket, data)) { - LOG_WARNING("Failed to parse vanilla SMSG_MONSTER_MOVE (decompressed ", - destLen, " bytes, parseBytes ", parseBytes.size(), " bytes)"); - return; + if (!hasWrappedForm) { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + + std::to_string(destLen) + " bytes)"); + return; + } + network::Packet wrappedPacket(packet.getOpcode(), stripped); + if (!packetParsers_->parseMonsterMove(wrappedPacket, data)) { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + + std::to_string(destLen) + " bytes, wrapped payload " + + std::to_string(stripped.size()) + " bytes)"); + return; + } + LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback"); } } else if (!packetParsers_->parseMonsterMove(packet, data)) { - LOG_WARNING("Failed to parse SMSG_MONSTER_MOVE"); - return; + // Some realms occasionally embed an extra [size|opcode] wrapper even when the + // outer packet wasn't zlib-compressed. Retry with wrapper stripped by structure. + std::vector stripped; + if (stripWrappedSubpacket(rawData, stripped)) { + network::Packet wrappedPacket(packet.getOpcode(), stripped); + if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { + LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback"); + } else { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); + return; + } + } else { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); + return; + } } // Update entity position in entity manager diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 0e598125..6dcfe934 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1192,6 +1192,24 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc return true; } +bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { + // Turtle realms can emit both vanilla-like and WotLK-like monster move bodies. + // Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout. + size_t start = packet.getReadPos(); + if (MonsterMoveParser::parseVanilla(packet, data)) { + return true; + } + + packet.setReadPos(start); + if (MonsterMoveParser::parse(packet, data)) { + LOG_WARNING("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); + return true; + } + + packet.setReadPos(start); + return false; +} + // ============================================================================ // Classic/Vanilla quest giver status // diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 3223ae42..e4275640 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -376,83 +376,125 @@ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& // (WotLK removed this field) // ============================================================================ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectData& data) { - // Read block count - data.blockCount = packet.readUInt32(); + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + auto parseWithLayout = [&](bool withHasTransportByte, UpdateObjectData& out) -> bool { + out = UpdateObjectData{}; + size_t start = packet.getReadPos(); + if (packet.getSize() - start < 4) return false; - // TBC/Classic: has_transport byte (WotLK removed this) - /*uint8_t hasTransport =*/ packet.readUInt8(); - - LOG_DEBUG("[TBC] SMSG_UPDATE_OBJECT: objectCount=", data.blockCount); - - // Check for out-of-range objects first - if (packet.getReadPos() + 1 <= packet.getSize()) { - uint8_t firstByte = packet.readUInt8(); - - if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { - uint32_t count = packet.readUInt32(); - for (uint32_t i = 0; i < count; ++i) { - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); - data.outOfRangeGuids.push_back(guid); - LOG_DEBUG(" Out of range: 0x", std::hex, guid, std::dec); - } - } else { - packet.setReadPos(packet.getReadPos() - 1); + out.blockCount = packet.readUInt32(); + if (out.blockCount > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; } - } - // Parse update blocks — dispatching movement via virtual parseMovementBlock() - data.blocks.reserve(data.blockCount); - for (uint32_t i = 0; i < data.blockCount; ++i) { - LOG_DEBUG("Parsing block ", i + 1, " / ", data.blockCount); - UpdateBlock block; - - // Read update type - uint8_t updateTypeVal = packet.readUInt8(); - block.updateType = static_cast(updateTypeVal); - LOG_DEBUG("Update block: type=", (int)updateTypeVal); - - bool ok = false; - switch (block.updateType) { - case UpdateType::VALUES: { - block.guid = UpdateObjectParser::readPackedGuid(packet); - ok = UpdateObjectParser::parseUpdateFields(packet, block); - break; + if (withHasTransportByte) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; } - case UpdateType::MOVEMENT: { - block.guid = UpdateObjectParser::readPackedGuid(packet); - ok = this->parseMovementBlock(packet, block); - break; - } - case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - block.guid = UpdateObjectParser::readPackedGuid(packet); - uint8_t objectTypeVal = packet.readUInt8(); - block.objectType = static_cast(objectTypeVal); - ok = this->parseMovementBlock(packet, block); - if (ok) { - ok = UpdateObjectParser::parseUpdateFields(packet, block); + /*uint8_t hasTransport =*/ packet.readUInt8(); + } + + if (packet.getReadPos() + 1 <= packet.getSize()) { + uint8_t firstByte = packet.readUInt8(); + if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (packet.getReadPos() + 4 > packet.getSize()) { + packet.setReadPos(start); + return false; } - break; + uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + for (uint32_t i = 0; i < count; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + out.outOfRangeGuids.push_back(guid); + } + } else { + packet.setReadPos(packet.getReadPos() - 1); } - case UpdateType::OUT_OF_RANGE_OBJECTS: - case UpdateType::NEAR_OBJECTS: - ok = true; - break; - default: - LOG_WARNING("Unknown update type: ", (int)updateTypeVal); - ok = false; - break; } - if (!ok) { - LOG_WARNING("Failed to parse update block ", i + 1, " of ", data.blockCount, - " — keeping ", data.blocks.size(), " parsed blocks"); - break; + out.blocks.reserve(out.blockCount); + for (uint32_t i = 0; i < out.blockCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + + UpdateBlock block; + uint8_t updateTypeVal = packet.readUInt8(); + if (updateTypeVal > static_cast(UpdateType::NEAR_OBJECTS)) { + packet.setReadPos(start); + return false; + } + block.updateType = static_cast(updateTypeVal); + + bool ok = false; + switch (block.updateType) { + case UpdateType::VALUES: { + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + } + case UpdateType::MOVEMENT: { + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = this->parseMovementBlock(packet, block); + break; + } + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: { + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getReadPos() >= packet.getSize()) { + ok = false; + break; + } + uint8_t objectTypeVal = packet.readUInt8(); + block.objectType = static_cast(objectTypeVal); + ok = this->parseMovementBlock(packet, block); + if (ok) ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + } + case UpdateType::OUT_OF_RANGE_OBJECTS: + case UpdateType::NEAR_OBJECTS: + ok = true; + break; + default: + ok = false; + break; + } + + if (!ok) { + packet.setReadPos(start); + return false; + } + out.blocks.push_back(block); } - data.blocks.push_back(block); + return true; + }; + + size_t startPos = packet.getReadPos(); + UpdateObjectData parsed; + if (parseWithLayout(true, parsed)) { + data = std::move(parsed); + return true; } - return true; + packet.setReadPos(startPos); + if (parseWithLayout(false, parsed)) { + LOG_WARNING("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + return false; } network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) { diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 54a3dc1d..da46dec5 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1131,9 +1131,16 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) { + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + constexpr uint32_t kMaxReasonableOutOfRangeGuids = 16384; // Read block count data.blockCount = packet.readUInt32(); + if (data.blockCount > kMaxReasonableUpdateBlocks) { + LOG_ERROR("SMSG_UPDATE_OBJECT rejected: unreasonable blockCount=", data.blockCount, + " packetSize=", packet.getSize()); + return false; + } LOG_DEBUG("SMSG_UPDATE_OBJECT:"); LOG_DEBUG(" objectCount = ", data.blockCount); @@ -1146,6 +1153,11 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { // Read out-of-range GUID count uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableOutOfRangeGuids) { + LOG_ERROR("SMSG_UPDATE_OBJECT rejected: unreasonable outOfRange count=", count, + " packetSize=", packet.getSize()); + return false; + } for (uint32_t i = 0; i < count; ++i) { uint64_t guid = readPackedGuid(packet); diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 7a3df05d..f3ebacfc 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -147,8 +147,15 @@ BLPImage AssetManager::loadTexture(const std::string& path) { std::vector blpData = readFile(normalizedPath); if (blpData.empty()) { static std::unordered_set loggedMissingTextures; - if (loggedMissingTextures.insert(normalizedPath).second) { + static bool missingTextureLogSuppressed = false; + static constexpr size_t kMaxMissingTextureLogKeys = 20000; + if (loggedMissingTextures.size() < kMaxMissingTextureLogKeys && + loggedMissingTextures.insert(normalizedPath).second) { LOG_WARNING("Texture not found: ", normalizedPath); + } else if (!missingTextureLogSuppressed && loggedMissingTextures.size() >= kMaxMissingTextureLogKeys) { + LOG_WARNING("Texture-not-found warning key cache reached ", kMaxMissingTextureLogKeys, + " entries; suppressing new unique texture-miss logs"); + missingTextureLogSuppressed = true; } return BLPImage(); } @@ -156,8 +163,15 @@ BLPImage AssetManager::loadTexture(const std::string& path) { BLPImage image = BLPLoader::load(blpData); if (!image.isValid()) { static std::unordered_set loggedDecodeFails; - if (loggedDecodeFails.insert(normalizedPath).second) { + static bool decodeFailLogSuppressed = false; + static constexpr size_t kMaxDecodeFailLogKeys = 8000; + if (loggedDecodeFails.size() < kMaxDecodeFailLogKeys && + loggedDecodeFails.insert(normalizedPath).second) { LOG_ERROR("Failed to load texture: ", normalizedPath); + } else if (!decodeFailLogSuppressed && loggedDecodeFails.size() >= kMaxDecodeFailLogKeys) { + LOG_WARNING("Texture-decode warning key cache reached ", kMaxDecodeFailLogKeys, + " entries; suppressing new unique decode-failure logs"); + decodeFailLogSuppressed = true; } return BLPImage(); } diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index c7ab1f10..99d85814 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -250,7 +250,7 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram } // Diagnostics-only: cache lifetime is currently tied to renderer lifetime. - textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 2048) * 1024ull * 1024ull; + textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 512) * 1024ull * 1024ull; core::Logger::getInstance().info("Character renderer initialized (Vulkan)"); return true; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 7caed4db..2fc61bb7 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -40,6 +40,15 @@ bool envFlagEnabled(const char* key, bool defaultValue) { return !(v == "0" || v == "false" || v == "off" || v == "no"); } +size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(raw, &end, 10); + if (end == raw || mb == 0) return defMb; + return static_cast(mb); +} + static constexpr uint32_t kParticleFlagRandomized = 0x40; static constexpr uint32_t kParticleFlagTiled = 0x80; @@ -601,6 +610,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout glowTexture_->upload(*vkCtx_, px.data(), SZ, SZ, VK_FORMAT_R8G8B8A8_UNORM); glowTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); } + textureCacheBudgetBytes_ = + envSizeMBOrDefault("WOWEE_M2_TEX_CACHE_MB", 512) * 1024ull * 1024ull; + modelCacheLimit_ = envSizeMBOrDefault("WOWEE_M2_MODEL_LIMIT", 6000); + LOG_INFO("M2 texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); + LOG_INFO("M2 model cache limit: ", modelCacheLimit_); LOG_INFO("M2 renderer initialized (Vulkan)"); initialized_ = true; @@ -635,6 +649,9 @@ void M2Renderer::shutdown() { textureCacheCounter_ = 0; textureHasAlphaByPtr_.clear(); textureColorKeyBlackByPtr_.clear(); + failedTextureCache_.clear(); + loggedTextureLoadFails_.clear(); + textureBudgetRejectWarnings_ = 0; whiteTexture_.reset(); glowTexture_.reset(); @@ -827,6 +844,14 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Already loaded return true; } + if (models.size() >= modelCacheLimit_) { + if (modelLimitRejectWarnings_ < 8 || (modelLimitRejectWarnings_ % 120) == 0) { + LOG_WARNING("M2 model cache full (", models.size(), "/", modelCacheLimit_, + "), skipping model load: id=", modelId, " name=", model.name); + } + ++modelLimitRejectWarnings_; + return false; + } bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); bool hasParticles = !model.particleEmitters.empty(); @@ -1134,10 +1159,15 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { VkTexture* texPtr = loadTexture(texPath, tex.flags); bool failed = (texPtr == whiteTexture_.get()); if (failed) { - static std::unordered_set loggedModelTextureFails; - std::string failKey = model.name + "|" + texKey; - if (loggedModelTextureFails.insert(failKey).second) { + static uint32_t loggedModelTextureFails = 0; + static bool loggedModelTextureFailSuppressed = false; + if (loggedModelTextureFails < 250) { LOG_WARNING("M2 model ", model.name, " texture[", ti, "] failed to load: ", texPath); + ++loggedModelTextureFails; + } else if (!loggedModelTextureFailSuppressed) { + LOG_WARNING("M2 model texture-failure warnings suppressed after ", + loggedModelTextureFails, " entries"); + loggedModelTextureFailSuppressed = true; } } if (isInvisibleTrap) { @@ -3155,6 +3185,9 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } + if (failedTextureCache_.count(key)) { + return whiteTexture_.get(); + } auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; @@ -3175,13 +3208,28 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { // Load BLP texture pipeline::BLPImage blp = assetManager->loadTexture(key); if (!blp.isValid()) { - static std::unordered_set loggedTextureLoadFails; - if (loggedTextureLoadFails.insert(key).second) { + static constexpr size_t kMaxFailedTextureCache = 200000; + if (failedTextureCache_.size() < kMaxFailedTextureCache) { + failedTextureCache_.insert(key); + } + if (loggedTextureLoadFails_.insert(key).second) { LOG_WARNING("M2: Failed to load texture: ", path); } return whiteTexture_.get(); } + size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + if (textureBudgetRejectWarnings_ < 8 || (textureBudgetRejectWarnings_ % 120) == 0) { + LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture_.get(); + } + // Track whether the texture actually uses alpha (any pixel with alpha < 255). bool hasAlpha = false; for (size_t i = 3; i < blp.data.size(); i += 4) { @@ -3204,8 +3252,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { TextureCacheEntry e; e.texture = std::move(tex); - size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - e.approxBytes = base + (base / 3); + e.approxBytes = approxBytes; e.hasAlpha = hasAlpha; e.colorKeyBlack = colorKeyBlackHint; e.lastUse = ++textureCacheCounter_; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 78034f94..fa29b333 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2756,6 +2756,19 @@ void Renderer::update(float deltaTime) { performanceHUD->update(deltaTime); } + // Periodic cache hygiene: drop model GPU data no longer referenced by active instances. + static float modelCleanupTimer = 0.0f; + modelCleanupTimer += deltaTime; + if (modelCleanupTimer >= 5.0f) { + if (wmoRenderer) { + wmoRenderer->cleanupUnusedModels(); + } + if (m2Renderer) { + m2Renderer->cleanupUnusedModels(); + } + modelCleanupTimer = 0.0f; + } + auto updateEnd = std::chrono::steady_clock::now(); lastUpdateMs = std::chrono::duration(updateEnd - updateStart).count(); diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 8d26cc42..93284c68 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -20,6 +20,17 @@ namespace wowee { namespace rendering { +namespace { +size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(raw, &end, 10); + if (end == raw || mb == 0) return defMb; + return static_cast(mb); +} +} // namespace + // Matches set 1 binding 7 in terrain.frag.glsl struct TerrainParamsUBO { int32_t layerCount; @@ -185,6 +196,9 @@ bool TerrainRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameL opaqueAlphaTexture->upload(*vkCtx, &opaqueAlpha, 1, 1, VK_FORMAT_R8_UNORM, false); opaqueAlphaTexture->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); + textureCacheBudgetBytes_ = + envSizeMBOrDefault("WOWEE_TERRAIN_TEX_CACHE_MB", 512) * 1024ull * 1024ull; + LOG_INFO("Terrain texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); LOG_INFO("Terrain renderer initialized (Vulkan)"); return true; @@ -287,6 +301,9 @@ void TerrainRenderer::shutdown() { textureCache.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; + failedTextureCache_.clear(); + loggedTextureLoadFails_.clear(); + textureBudgetRejectWarnings_ = 0; if (whiteTexture) { whiteTexture->destroy(device, allocator); whiteTexture.reset(); } if (opaqueAlphaTexture) { opaqueAlphaTexture->destroy(device, allocator); opaqueAlphaTexture.reset(); } @@ -425,10 +442,31 @@ VkTexture* TerrainRenderer::loadTexture(const std::string& path) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } + if (failedTextureCache_.count(key)) { + return whiteTexture.get(); + } pipeline::BLPImage blp = assetManager->loadTexture(key); if (!blp.isValid()) { - LOG_WARNING("Failed to load texture: ", path); + static constexpr size_t kMaxFailedTextureCache = 200000; + if (failedTextureCache_.size() < kMaxFailedTextureCache) { + failedTextureCache_.insert(key); + } + if (loggedTextureLoadFails_.insert(key).second) { + LOG_WARNING("Failed to load texture: ", path); + } + return whiteTexture.get(); + } + + size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + if (textureBudgetRejectWarnings_ < 8 || (textureBudgetRejectWarnings_ % 120) == 0) { + LOG_WARNING("Terrain texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; return whiteTexture.get(); } @@ -444,8 +482,7 @@ VkTexture* TerrainRenderer::loadTexture(const std::string& path) { VkTexture* raw = tex.get(); TextureCacheEntry e; e.texture = std::move(tex); - size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - e.approxBytes = base + (base / 3); + e.approxBytes = approxBytes; e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; textureCache[key] = std::move(e); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index cfa4ab24..a8e50922 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,17 @@ namespace wowee { namespace rendering { +namespace { +size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(raw, &end, 10); + if (end == raw || mb == 0) return defMb; + return static_cast(mb); +} +} // namespace + static void transformAABB(const glm::mat4& modelMatrix, const glm::vec3& localMin, const glm::vec3& localMax, @@ -214,6 +226,12 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou whiteTexture_->upload(*vkCtx_, whitePixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); whiteTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_REPEAT); + textureCacheBudgetBytes_ = + envSizeMBOrDefault("WOWEE_WMO_TEX_CACHE_MB", 512) * 1024ull * 1024ull; + modelCacheLimit_ = envSizeMBOrDefault("WOWEE_WMO_MODEL_LIMIT", 4000); + core::Logger::getInstance().info("WMO texture cache budget: ", + textureCacheBudgetBytes_ / (1024 * 1024), " MB"); + core::Logger::getInstance().info("WMO model cache limit: ", modelCacheLimit_); core::Logger::getInstance().info("WMO renderer initialized (Vulkan)"); initialized_ = true; @@ -251,6 +269,9 @@ void WMORenderer::shutdown() { textureCache.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; + failedTextureCache_.clear(); + loggedTextureLoadFails_.clear(); + textureBudgetRejectWarnings_ = 0; // Free white texture if (whiteTexture_) { whiteTexture_->destroy(device, allocator); whiteTexture_.reset(); } @@ -301,6 +322,14 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } } static std::unordered_set retryReloadedModels; + static bool retryReloadedModelsCapped = false; + if (retryReloadedModels.size() > 8192) { + retryReloadedModels.clear(); + if (!retryReloadedModelsCapped) { + core::Logger::getInstance().warning("WMO fallback-retry set exceeded 8192 entries; reset"); + retryReloadedModelsCapped = true; + } + } if (!hasResolvedTexture && retryReloadedModels.insert(id).second) { core::Logger::getInstance().warning( "WMO model ", id, @@ -313,6 +342,15 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { return true; } } + if (loadedModels.size() >= modelCacheLimit_) { + if (modelLimitRejectWarnings_ < 8 || (modelLimitRejectWarnings_ % 120) == 0) { + core::Logger::getInstance().warning("WMO model cache full (", + loadedModels.size(), "/", modelCacheLimit_, + "), skipping model load: id=", id); + } + ++modelLimitRejectWarnings_; + return false; + } core::Logger::getInstance().debug("Loading WMO model ", id, " with ", model.groups.size(), " groups, ", model.textures.size(), " textures..."); @@ -1863,10 +1901,21 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } + std::vector attemptedCandidates; + attemptedCandidates.reserve(uniqueCandidates.size()); + for (const auto& c : uniqueCandidates) { + if (!failedTextureCache_.count(c)) { + attemptedCandidates.push_back(c); + } + } + if (attemptedCandidates.empty()) { + return whiteTexture_.get(); + } + // Try loading all candidates until one succeeds pipeline::BLPImage blp; std::string resolvedKey; - for (const auto& c : uniqueCandidates) { + for (const auto& c : attemptedCandidates) { blp = assetManager->loadTexture(c); if (blp.isValid()) { resolvedKey = c; @@ -1874,7 +1923,15 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } if (!blp.isValid()) { - core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); + static constexpr size_t kMaxFailedTextureCache = 200000; + for (const auto& c : attemptedCandidates) { + if (failedTextureCache_.size() < kMaxFailedTextureCache) { + failedTextureCache_.insert(c); + } + } + if (loggedTextureLoadFails_.insert(key).second) { + core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); + } // 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. @@ -1883,6 +1940,19 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { core::Logger::getInstance().debug("WMO texture: ", path, " size=", blp.width, "x", blp.height); + size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + if (textureBudgetRejectWarnings_ < 8 || (textureBudgetRejectWarnings_ % 120) == 0) { + core::Logger::getInstance().warning( + "WMO texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture_.get(); + } + // Create Vulkan texture auto texture = std::make_unique(); if (!texture->upload(*vkCtx_, blp.data.data(), blp.width, blp.height, @@ -1896,8 +1966,7 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { // Cache it TextureCacheEntry e; VkTexture* rawPtr = texture.get(); - size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - e.approxBytes = base + (base / 3); + e.approxBytes = approxBytes; e.lastUse = ++textureCacheCounter_; e.texture = std::move(texture); textureCacheBytes_ += e.approxBytes;