#include "core/entity_spawner.hpp" #include "core/coordinates.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" #include "rendering/animation_controller.hpp" #include "rendering/vk_context.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" #include "audio/npc_voice_manager.hpp" #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" #include "rendering/animation/animation_ids.hpp" #include "pipeline/dbc_loader.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_layout.hpp" #include "game/game_handler.hpp" #include "game/game_services.hpp" #include "game/transport_manager.hpp" #include #include #include #include #include namespace wowee { namespace core { void EntitySpawner::processAsyncCreatureResults(bool unlimited) { // Check completed async model loads and finalize on main thread (GPU upload + instance creation). // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. static constexpr int kMaxModelUploadsPerTick = 1; static constexpr int kMaxModelUploadsPerTickWarmup = 1; static constexpr float kFinalizeBudgetMs = 2.0f; static constexpr float kFinalizeBudgetWarmupMs = 2.0f; const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; const auto tickStart = std::chrono::steady_clock::now(); int modelUploads = 0; for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { if (std::chrono::duration( std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { break; } if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; continue; } auto result = it->future.get(); it = asyncCreatureLoads_.erase(it); asyncCreatureDisplayLoads_.erase(result.displayId); // Failures and cache hits need no GPU work — process them even when the // upload budget is exhausted. Previously the budget check was above this // point, blocking ALL ready futures (including zero-cost ones) after a // single upload, which throttled creature spawn throughput during world load. if (result.permanent_failure) { nonRenderableCreatureDisplayIds_.insert(result.displayId); creaturePermanentFailureGuids_.insert(result.guid); pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); continue; } if (!result.valid || !result.model) { pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); continue; } // Another async result may have already uploaded this displayId while this // task was still running; in that case, skip duplicate GPU upload. if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); if (!creatureInstances_.count(result.guid) && !creaturePermanentFailureGuids_.count(result.guid)) { PendingCreatureSpawn s{}; s.guid = result.guid; s.displayId = result.displayId; s.x = result.x; s.y = result.y; s.z = result.z; s.orientation = result.orientation; s.scale = result.scale; pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(result.guid); } continue; } // Only actual GPU uploads count toward the per-tick budget. if (modelUploads >= maxUploadsThisTick) { // Re-queue this result — it needs a GPU upload but we're at budget. // Push a new pending spawn so it's retried next frame. pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); PendingCreatureSpawn s{}; s.guid = result.guid; s.displayId = result.displayId; s.x = result.x; s.y = result.y; s.z = result.z; s.orientation = result.orientation; s.scale = result.scale; pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(result.guid); continue; } // Model parsed on background thread — upload to GPU on main thread. auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; if (!charRenderer) { pendingCreatureSpawnGuids_.erase(result.guid); continue; } // Count upload attempts toward the frame budget even if upload fails. // Otherwise repeated failures can consume an unbounded amount of frame time. modelUploads++; // Upload model to GPU (must happen on main thread) // Use pre-decoded BLP cache to skip main-thread texture decode auto uploadStart = std::chrono::steady_clock::now(); charRenderer->setPredecodedBLPCache(&result.predecodedTextures); if (!charRenderer->loadModel(*result.model, result.modelId)) { charRenderer->setPredecodedBLPCache(nullptr); nonRenderableCreatureDisplayIds_.insert(result.displayId); creaturePermanentFailureGuids_.insert(result.guid); pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); continue; } charRenderer->setPredecodedBLPCache(nullptr); { auto uploadEnd = std::chrono::steady_clock::now(); float uploadMs = std::chrono::duration(uploadEnd - uploadStart).count(); if (uploadMs > 100.0f) { LOG_WARNING("charRenderer->loadModel took ", uploadMs, "ms displayId=", result.displayId, " preDecoded=", result.predecodedTextures.size()); } } // Save remaining pre-decoded textures (display skins) for spawnOnlineCreature if (!result.predecodedTextures.empty()) { displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); } displayIdModelCache_[result.displayId] = result.modelId; pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); // Re-queue as a normal pending spawn — model is now cached, so sync spawn is fast // (only creates instance + applies textures, no file I/O). if (!creatureInstances_.count(result.guid) && !creaturePermanentFailureGuids_.count(result.guid)) { PendingCreatureSpawn s{}; s.guid = result.guid; s.displayId = result.displayId; s.x = result.x; s.y = result.y; s.z = result.z; s.orientation = result.orientation; s.scale = result.scale; pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(result.guid); } } } void EntitySpawner::processAsyncNpcCompositeResults(bool unlimited) { auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; if (!charRenderer) return; // Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing // simultaneously. In unlimited mode (load screen), process everything without cap. static constexpr float kCompositeBudgetMs = 2.0f; auto startTime = std::chrono::steady_clock::now(); for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) { if (!unlimited) { float elapsed = std::chrono::duration( std::chrono::steady_clock::now() - startTime).count(); if (elapsed >= kCompositeBudgetMs) break; } if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; continue; } auto result = it->future.get(); it = asyncNpcCompositeLoads_.erase(it); const auto& info = result.info; // Set pre-decoded cache so texture loads skip synchronous BLP decode charRenderer->setPredecodedBLPCache(&result.predecodedTextures); // --- Apply skin to type-1 slots --- rendering::VkTexture* skinTex = nullptr; if (info.hasBakedSkin) { // Baked skin: load from pre-decoded cache skinTex = charRenderer->loadTexture(info.bakedSkinPath); } if (info.hasComposite) { // Composite with face/underwear/equipment regions on top of base skin rendering::VkTexture* compositeTex = nullptr; if (!info.regionLayers.empty()) { compositeTex = charRenderer->compositeWithRegions(info.basePath, info.overlayPaths, info.regionLayers); } else if (!info.overlayPaths.empty()) { std::vector skinLayers; skinLayers.push_back(info.basePath); for (const auto& op : info.overlayPaths) skinLayers.push_back(op); compositeTex = charRenderer->compositeTextures(skinLayers); } if (compositeTex) skinTex = compositeTex; } else if (info.hasSimpleSkin) { // Simple skin: just base texture, no compositing auto* baseTex = charRenderer->loadTexture(info.basePath); if (baseTex) skinTex = baseTex; } if (skinTex) { for (uint32_t slot : info.skinTextureSlots) { charRenderer->setModelTexture(info.modelId, slot, skinTex); } } // --- Apply hair texture to type-6 slots --- if (!info.hairTexturePath.empty()) { rendering::VkTexture* hairTex = charRenderer->loadTexture(info.hairTexturePath); rendering::VkTexture* whTex = charRenderer->loadTexture(""); if (hairTex && hairTex != whTex) { for (uint32_t slot : info.hairTextureSlots) { charRenderer->setModelTexture(info.modelId, slot, hairTex); } } } else if (info.useBakedForHair && skinTex) { // Bald NPC: use skin/baked texture for scalp cap for (uint32_t slot : info.hairTextureSlots) { charRenderer->setModelTexture(info.modelId, slot, skinTex); } } charRenderer->setPredecodedBLPCache(nullptr); } } void EntitySpawner::processCreatureSpawnQueue(bool unlimited) { auto startTime = std::chrono::steady_clock::now(); // Budget: max 2ms per frame for creature spawning to prevent stutter. // In unlimited mode (load screen), process everything without budget cap. static constexpr float kSpawnBudgetMs = 2.0f; // First, finalize any async model loads that completed on background threads. processAsyncCreatureResults(unlimited); { auto now = std::chrono::steady_clock::now(); float asyncMs = std::chrono::duration(now - startTime).count(); if (asyncMs > 100.0f) { LOG_WARNING("processAsyncCreatureResults took ", asyncMs, "ms"); } } if (pendingCreatureSpawns_.empty()) return; if (!creatureLookupsBuilt_) { buildCreatureDisplayLookups(); if (!creatureLookupsBuilt_) return; } int processed = 0; int asyncLaunched = 0; size_t rotationsLeft = pendingCreatureSpawns_.size(); while (!pendingCreatureSpawns_.empty() && (unlimited || processed < MAX_SPAWNS_PER_FRAME) && rotationsLeft > 0) { // Check time budget every iteration (including first — async results may // have already consumed the budget via GPU model uploads). if (!unlimited) { auto now = std::chrono::steady_clock::now(); float elapsedMs = std::chrono::duration(now - startTime).count(); if (elapsedMs >= kSpawnBudgetMs) break; } PendingCreatureSpawn s = pendingCreatureSpawns_.front(); pendingCreatureSpawns_.pop_front(); if (nonRenderableCreatureDisplayIds_.count(s.displayId)) { pendingCreatureSpawnGuids_.erase(s.guid); creatureSpawnRetryCounts_.erase(s.guid); processed++; rotationsLeft = pendingCreatureSpawns_.size(); continue; } const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end()); // For new models: launch async load on background thread instead of blocking. if (needsNewModel) { // Keep exactly one background load per displayId. Additional spawns for // the same displayId stay queued and will spawn once cache is populated. if (asyncCreatureDisplayLoads_.count(s.displayId)) { pendingCreatureSpawns_.push_back(s); rotationsLeft--; continue; } const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { // Too many in-flight — defer to next frame pendingCreatureSpawns_.push_back(s); rotationsLeft--; continue; } std::string m2Path = getModelPathForDisplayId(s.displayId); if (m2Path.empty()) { nonRenderableCreatureDisplayIds_.insert(s.displayId); creaturePermanentFailureGuids_.insert(s.guid); pendingCreatureSpawnGuids_.erase(s.guid); creatureSpawnRetryCounts_.erase(s.guid); processed++; rotationsLeft = pendingCreatureSpawns_.size(); continue; } // Check for invisible stalkers { std::string lowerPath = m2Path; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (lowerPath.find("invisiblestalker") != std::string::npos || lowerPath.find("invisible_stalker") != std::string::npos) { nonRenderableCreatureDisplayIds_.insert(s.displayId); creaturePermanentFailureGuids_.insert(s.guid); pendingCreatureSpawnGuids_.erase(s.guid); processed++; rotationsLeft = pendingCreatureSpawns_.size(); continue; } } // Launch async M2 load — file I/O and parsing happen off the main thread. uint32_t modelId = nextCreatureModelId_++; auto* am = assetManager_; // Collect display skin texture paths for background pre-decode std::vector displaySkinPaths; { auto itDD = displayDataMap_.find(s.displayId); if (itDD != displayDataMap_.end()) { std::string modelDir; size_t lastSlash = m2Path.find_last_of("\\/"); if (lastSlash != std::string::npos) modelDir = m2Path.substr(0, lastSlash + 1); auto resolveForAsync = [&](const std::string& skinField) { if (skinField.empty()) return; std::string raw = skinField; std::replace(raw.begin(), raw.end(), '/', '\\'); while (!raw.empty() && std::isspace(static_cast(raw.front()))) raw.erase(raw.begin()); while (!raw.empty() && std::isspace(static_cast(raw.back()))) raw.pop_back(); if (raw.empty()) return; bool hasExt = raw.size() >= 4 && raw.substr(raw.size()-4) == ".blp"; bool hasDir = raw.find('\\') != std::string::npos; std::vector candidates; if (hasDir) { candidates.push_back(raw); if (!hasExt) candidates.push_back(raw + ".blp"); } else { candidates.push_back(modelDir + raw); if (!hasExt) candidates.push_back(modelDir + raw + ".blp"); candidates.push_back(raw); if (!hasExt) candidates.push_back(raw + ".blp"); } for (const auto& c : candidates) { if (am->fileExists(c)) { displaySkinPaths.push_back(c); return; } } }; resolveForAsync(itDD->second.skin1); resolveForAsync(itDD->second.skin2); resolveForAsync(itDD->second.skin3); // Pre-decode humanoid NPC textures (bake, skin, face, underwear, hair, equipment) if (itDD->second.extraDisplayId != 0) { auto itHE = humanoidExtraMap_.find(itDD->second.extraDisplayId); if (itHE != humanoidExtraMap_.end()) { const auto& he = itHE->second; // Baked texture if (!he.bakeName.empty()) { displaySkinPaths.push_back("Textures\\BakedNpcTextures\\" + he.bakeName); } // CharSections: skin, face, underwear auto csDbc = am->loadDBC("CharSections.dbc"); if (csDbc) { const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; auto csF = pipeline::detectCharSectionsFields(csDbc.get(), csL); uint32_t nRace = static_cast(he.raceId); uint32_t nSex = static_cast(he.sexId); uint32_t nSkin = static_cast(he.skinId); uint32_t nFace = static_cast(he.faceId); for (uint32_t r = 0; r < csDbc->getRecordCount(); r++) { uint32_t rId = csDbc->getUInt32(r, csF.raceId); uint32_t sId = csDbc->getUInt32(r, csF.sexId); if (rId != nRace || sId != nSex) continue; uint32_t section = csDbc->getUInt32(r, csF.baseSection); uint32_t variation = csDbc->getUInt32(r, csF.variationIndex); uint32_t color = csDbc->getUInt32(r, csF.colorIndex); if (section == 0 && color == nSkin) { std::string t = csDbc->getString(r, csF.texture1); if (!t.empty()) displaySkinPaths.push_back(t); } else if (section == 1 && variation == nFace && color == nSkin) { std::string t1 = csDbc->getString(r, csF.texture1); std::string t2 = csDbc->getString(r, csF.texture2); if (!t1.empty()) displaySkinPaths.push_back(t1); if (!t2.empty()) displaySkinPaths.push_back(t2); } else if (section == 3 && variation == static_cast(he.hairStyleId) && color == static_cast(he.hairColorId)) { std::string t = csDbc->getString(r, csF.texture1); if (!t.empty()) displaySkinPaths.push_back(t); } else if (section == 4 && color == nSkin) { for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { std::string t = csDbc->getString(r, f); if (!t.empty()) displaySkinPaths.push_back(t); } } } } // Equipment region textures auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc"); if (idiDbc) { static constexpr const char* compDirs[] = { "ArmUpperTexture", "ArmLowerTexture", "HandTexture", "TorsoUpperTexture", "TorsoLowerTexture", "LegUpperTexture", "LegLowerTexture", "FootTexture", }; const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; const uint32_t trf[8] = { idiL ? (*idiL)["TextureArmUpper"] : 14u, idiL ? (*idiL)["TextureArmLower"] : 15u, idiL ? (*idiL)["TextureHand"] : 16u, idiL ? (*idiL)["TextureTorsoUpper"]: 17u, idiL ? (*idiL)["TextureTorsoLower"]: 18u, idiL ? (*idiL)["TextureLegUpper"] : 19u, idiL ? (*idiL)["TextureLegLower"] : 20u, idiL ? (*idiL)["TextureFoot"] : 21u, }; const bool isFem = (he.sexId == 1); for (int eq = 0; eq < 11; eq++) { uint32_t did = he.equipDisplayId[eq]; if (did == 0) continue; int32_t recIdx = idiDbc->findRecordById(did); if (recIdx < 0) continue; for (int region = 0; region < 8; region++) { std::string texName = idiDbc->getString(static_cast(recIdx), trf[region]); if (texName.empty()) continue; std::string base = "Item\\TextureComponents\\" + std::string(compDirs[region]) + "\\" + texName; std::string gp = base + (isFem ? "_F.blp" : "_M.blp"); std::string up = base + "_U.blp"; std::string bp = base + ".blp"; if (am->fileExists(gp)) displaySkinPaths.push_back(gp); else if (am->fileExists(up)) displaySkinPaths.push_back(up); else if (am->fileExists(bp)) displaySkinPaths.push_back(bp); } } } } } } } AsyncCreatureLoad load; load.future = std::async(std::launch::async, [am, m2Path, modelId, s, skinPaths = std::move(displaySkinPaths)]() -> PreparedCreatureModel { PreparedCreatureModel result; result.guid = s.guid; result.displayId = s.displayId; result.modelId = modelId; result.x = s.x; result.y = s.y; result.z = s.z; result.orientation = s.orientation; result.scale = s.scale; auto m2Data = am->readFile(m2Path); if (m2Data.empty()) { result.permanent_failure = true; return result; } auto model = std::make_shared(pipeline::M2Loader::load(m2Data)); if (model->vertices.empty()) { result.permanent_failure = true; return result; } // Load skin file if (model->version >= 264) { std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; auto skinData = am->readFile(skinPath); if (!skinData.empty()) { pipeline::M2Loader::loadSkin(skinData, *model); } } // Load external .anim files std::string basePath = m2Path.substr(0, m2Path.size() - 3); for (uint32_t si = 0; si < model->sequences.size(); si++) { if (!(model->sequences[si].flags & 0x20)) { char animFileName[256]; snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", basePath.c_str(), model->sequences[si].id, model->sequences[si].variationIndex); auto animData = am->readFileOptional(animFileName); if (!animData.empty()) { pipeline::M2Loader::loadAnimFile(m2Data, animData, si, *model); } } } // Pre-decode model textures on background thread for (const auto& tex : model->textures) { if (tex.filename.empty()) continue; std::string texKey = tex.filename; std::replace(texKey.begin(), texKey.end(), '/', '\\'); std::transform(texKey.begin(), texKey.end(), texKey.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; auto blp = am->loadTexture(texKey); if (blp.isValid()) { result.predecodedTextures[texKey] = std::move(blp); } } // Pre-decode display skin textures (skin1/skin2/skin3 from CreatureDisplayInfo) for (const auto& sp : skinPaths) { std::string key = sp; std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (result.predecodedTextures.count(key)) continue; auto blp = am->loadTexture(key); if (blp.isValid()) { result.predecodedTextures[key] = std::move(blp); } } result.model = std::move(model); result.valid = true; return result; }); asyncCreatureLoads_.push_back(std::move(load)); asyncCreatureDisplayLoads_.insert(s.displayId); asyncLaunched++; // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it rotationsLeft = pendingCreatureSpawns_.size(); processed++; continue; } // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) { auto spawnStart = std::chrono::steady_clock::now(); spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); auto spawnEnd = std::chrono::steady_clock::now(); float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); if (spawnMs > 100.0f) { LOG_WARNING("spawnOnlineCreature took ", spawnMs, "ms displayId=", s.displayId); } } pendingCreatureSpawnGuids_.erase(s.guid); // If spawn still failed, retry for a limited number of frames. if (!creatureInstances_.count(s.guid)) { if (creaturePermanentFailureGuids_.erase(s.guid) > 0) { creatureSpawnRetryCounts_.erase(s.guid); processed++; continue; } uint16_t retries = 0; auto it = creatureSpawnRetryCounts_.find(s.guid); if (it != creatureSpawnRetryCounts_.end()) { retries = it->second; } if (retries < MAX_CREATURE_SPAWN_RETRIES) { creatureSpawnRetryCounts_[s.guid] = static_cast(retries + 1); pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(s.guid); } else { creatureSpawnRetryCounts_.erase(s.guid); LOG_WARNING("Dropping creature spawn after retries: guid=0x", std::hex, s.guid, std::dec, " displayId=", s.displayId); } } else { creatureSpawnRetryCounts_.erase(s.guid); } rotationsLeft = pendingCreatureSpawns_.size(); processed++; } } void EntitySpawner::processPlayerSpawnQueue() { if (pendingPlayerSpawns_.empty()) return; if (!assetManager_ || !assetManager_->isInitialized()) return; int processed = 0; while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) { PendingPlayerSpawn s = pendingPlayerSpawns_.front(); pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin()); pendingPlayerSpawnGuids_.erase(s.guid); // Skip if already spawned (could have been spawned by a previous update this frame) if (playerInstances_.count(s.guid)) { processed++; continue; } spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); // Apply any equipment updates that arrived before the player was spawned. auto pit = pendingOnlinePlayerEquipment_.find(s.guid); if (pit != pendingOnlinePlayerEquipment_.end()) { deferredEquipmentQueue_.push_back({s.guid, pit->second}); pendingOnlinePlayerEquipment_.erase(pit); } processed++; } } std::vector EntitySpawner::resolveEquipmentTexturePaths(uint64_t guid, const std::array& displayInfoIds, const std::array& /*inventoryTypes*/) const { std::vector paths; auto it = onlinePlayerAppearance_.find(guid); if (it == onlinePlayerAppearance_.end()) return paths; const OnlinePlayerAppearanceState& st = it->second; // Add base skin + underwear paths if (!st.bodySkinPath.empty()) paths.push_back(st.bodySkinPath); for (const auto& up : st.underwearPaths) { if (!up.empty()) paths.push_back(up); } // Resolve equipment region texture paths (same logic as setOnlinePlayerEquipment) auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc) return paths; const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; static constexpr const char* componentDirs[] = { "ArmUpperTexture", "ArmLowerTexture", "HandTexture", "TorsoUpperTexture", "TorsoLowerTexture", "LegUpperTexture", "LegLowerTexture", "FootTexture", }; uint32_t texRegionFields[8]; pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); const bool isFemale = (st.genderId == 1); for (int s = 0; s < 19; s++) { uint32_t did = displayInfoIds[s]; if (did == 0) continue; int32_t recIdx = displayInfoDbc->findRecordById(did); if (recIdx < 0) continue; for (int region = 0; region < 8; region++) { std::string texName = displayInfoDbc->getString( static_cast(recIdx), texRegionFields[region]); if (texName.empty()) continue; std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); std::string unisexPath = base + "_U.blp"; std::string basePath = base + ".blp"; if (assetManager_->fileExists(genderPath)) paths.push_back(genderPath); else if (assetManager_->fileExists(unisexPath)) paths.push_back(unisexPath); else if (assetManager_->fileExists(basePath)) paths.push_back(basePath); } } return paths; } void EntitySpawner::processAsyncEquipmentResults() { for (auto it = asyncEquipmentLoads_.begin(); it != asyncEquipmentLoads_.end(); ) { if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; continue; } auto result = it->future.get(); it = asyncEquipmentLoads_.erase(it); auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; if (!charRenderer) continue; // Set pre-decoded cache so compositeWithRegions skips synchronous BLP decode charRenderer->setPredecodedBLPCache(&result.predecodedTextures); setOnlinePlayerEquipment(result.guid, result.displayInfoIds, result.inventoryTypes); charRenderer->setPredecodedBLPCache(nullptr); } } void EntitySpawner::processDeferredEquipmentQueue() { // First, finalize any completed async pre-decodes processAsyncEquipmentResults(); if (deferredEquipmentQueue_.empty()) return; // Limit in-flight async equipment loads if (asyncEquipmentLoads_.size() >= 2) return; auto [guid, equipData] = deferredEquipmentQueue_.front(); deferredEquipmentQueue_.erase(deferredEquipmentQueue_.begin()); // Resolve all texture paths that compositeWithRegions will need auto texturePaths = resolveEquipmentTexturePaths(guid, equipData.first, equipData.second); if (texturePaths.empty()) { // No textures to pre-decode — just apply directly (fast path) LOG_WARNING("Equipment fast path for guid=0x", std::hex, guid, std::dec, " (no textures to pre-decode)"); setOnlinePlayerEquipment(guid, equipData.first, equipData.second); return; } LOG_DEBUG("Equipment async pre-decode for guid=0x", std::hex, guid, std::dec, " textures=", texturePaths.size()); // Launch background BLP pre-decode auto* am = assetManager_; auto displayInfoIds = equipData.first; auto inventoryTypes = equipData.second; AsyncEquipmentLoad load; load.future = std::async(std::launch::async, [am, guid, displayInfoIds, inventoryTypes, paths = std::move(texturePaths)]() -> PreparedEquipmentUpdate { PreparedEquipmentUpdate result; result.guid = guid; result.displayInfoIds = displayInfoIds; result.inventoryTypes = inventoryTypes; for (const auto& path : paths) { std::string key = path; std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (result.predecodedTextures.count(key)) continue; auto blp = am->loadTexture(key); if (blp.isValid()) { result.predecodedTextures[key] = std::move(blp); } } return result; }); asyncEquipmentLoads_.push_back(std::move(load)); } void EntitySpawner::processAsyncGameObjectResults() { for (auto it = asyncGameObjectLoads_.begin(); it != asyncGameObjectLoads_.end(); ) { if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; continue; } auto result = it->future.get(); it = asyncGameObjectLoads_.erase(it); if (!result.valid || !result.isWmo || !result.wmoModel) { // Fallback: spawn via sync path (likely an M2 or failed WMO) spawnOnlineGameObject(result.guid, result.entry, result.displayId, result.x, result.y, result.z, result.orientation, result.scale); continue; } // WMO parsed on background thread — do GPU upload + instance creation on main thread auto* wmoRenderer = renderer_ ? renderer_->getWMORenderer() : nullptr; if (!wmoRenderer) continue; uint32_t modelId = 0; auto itCache = gameObjectDisplayIdWmoCache_.find(result.displayId); if (itCache != gameObjectDisplayIdWmoCache_.end()) { modelId = itCache->second; } else { modelId = nextGameObjectWmoModelId_++; wmoRenderer->setPredecodedBLPCache(&result.predecodedTextures); if (!wmoRenderer->loadModel(*result.wmoModel, modelId)) { wmoRenderer->setPredecodedBLPCache(nullptr); LOG_WARNING("Failed to load async gameobject WMO: ", result.modelPath); continue; } wmoRenderer->setPredecodedBLPCache(nullptr); gameObjectDisplayIdWmoCache_[result.displayId] = modelId; } glm::vec3 renderPos = core::coords::canonicalToRender( glm::vec3(result.x, result.y, result.z)); uint32_t instanceId = wmoRenderer->createInstance( modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); if (instanceId == 0) continue; gameObjectInstances_[result.guid] = {modelId, instanceId, true}; // Queue transport doodad loading if applicable std::string lowerPath = result.modelPath; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (lowerPath.find("transport") != std::string::npos) { const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); if (doodadTemplates && !doodadTemplates->empty()) { PendingTransportDoodadBatch batch; batch.guid = result.guid; batch.modelId = modelId; batch.instanceId = instanceId; batch.x = result.x; batch.y = result.y; batch.z = result.z; batch.orientation = result.orientation; batch.doodadBudget = doodadTemplates->size(); pendingTransportDoodadBatches_.push_back(batch); } } } } void EntitySpawner::processGameObjectSpawnQueue() { // Finalize any completed async WMO loads first processAsyncGameObjectResults(); if (pendingGameObjectSpawns_.empty()) return; static int goQueueLogCounter = 0; if (++goQueueLogCounter % 60 == 1) { LOG_DEBUG("GO queue: ", pendingGameObjectSpawns_.size(), " pending, ", gameObjectInstances_.size(), " spawned, ", gameObjectDisplayIdFailedCache_.size(), " failed"); } // Process spawns: cached WMOs and M2s go sync (cheap), uncached WMOs go async auto startTime = std::chrono::steady_clock::now(); static constexpr float kBudgetMs = 2.0f; static constexpr int kMaxAsyncLoads = 2; while (!pendingGameObjectSpawns_.empty()) { float elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - startTime).count(); if (elapsedMs >= kBudgetMs) break; auto& s = pendingGameObjectSpawns_.front(); // Check if this is an uncached WMO that needs async loading std::string modelPath; if (gameObjectLookupsBuilt_) { // Check transport overrides first bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(s.guid); if (isTransport) { if (s.entry == 20808 || s.entry == 176231 || s.entry == 176310) modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; else if (s.displayId == 807 || s.displayId == 808 || s.displayId == 175080 || s.displayId == 176495 || s.displayId == 164871) modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; else if (s.displayId == 1587) modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; else if (s.displayId == 2454 || s.displayId == 181688 || s.displayId == 190536) modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; } if (modelPath.empty()) modelPath = getGameObjectModelPathForDisplayId(s.displayId); } std::string lowerPath = modelPath; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; bool isCached = isWmo && gameObjectDisplayIdWmoCache_.count(s.displayId); if (isWmo && !isCached && !modelPath.empty() && static_cast(asyncGameObjectLoads_.size()) < kMaxAsyncLoads) { // Launch async WMO load — file I/O + parse on background thread auto* am = assetManager_; PendingGameObjectSpawn capture = s; std::string capturePath = modelPath; AsyncGameObjectLoad load; load.future = std::async(std::launch::async, [am, capture, capturePath]() -> PreparedGameObjectWMO { PreparedGameObjectWMO result; result.guid = capture.guid; result.entry = capture.entry; result.displayId = capture.displayId; result.x = capture.x; result.y = capture.y; result.z = capture.z; result.orientation = capture.orientation; result.scale = capture.scale; result.modelPath = capturePath; result.isWmo = true; auto wmoData = am->readFile(capturePath); if (wmoData.empty()) return result; auto wmo = std::make_shared( pipeline::WMOLoader::load(wmoData)); // Load groups if (wmo->nGroups > 0) { std::string basePath = capturePath; std::string ext; if (basePath.size() > 4) { ext = basePath.substr(basePath.size() - 4); basePath = basePath.substr(0, basePath.size() - 4); } for (uint32_t gi = 0; gi < wmo->nGroups; gi++) { char suffix[16]; snprintf(suffix, sizeof(suffix), "_%03u%s", gi, ext.c_str()); auto groupData = am->readFile(basePath + suffix); if (groupData.empty()) { snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); groupData = am->readFile(basePath + suffix); } if (!groupData.empty()) { pipeline::WMOLoader::loadGroup(groupData, *wmo, gi); } } } // Pre-decode WMO textures on background thread for (const auto& texPath : wmo->textures) { if (texPath.empty()) continue; std::string texKey = texPath; size_t nul = texKey.find('\0'); if (nul != std::string::npos) texKey.resize(nul); std::replace(texKey.begin(), texKey.end(), '/', '\\'); std::transform(texKey.begin(), texKey.end(), texKey.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (texKey.empty()) continue; // Convert to .blp extension if (texKey.size() >= 4) { std::string ext = texKey.substr(texKey.size() - 4); if (ext == ".tga" || ext == ".dds") { texKey = texKey.substr(0, texKey.size() - 4) + ".blp"; } } if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; auto blp = am->loadTexture(texKey); if (blp.isValid()) { result.predecodedTextures[texKey] = std::move(blp); } } result.wmoModel = wmo; result.valid = true; return result; }); asyncGameObjectLoads_.push_back(std::move(load)); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); continue; } // Cached WMO or M2 — spawn synchronously (cheap) spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); } } void EntitySpawner::processPendingTransportRegistrations() { if (pendingTransportRegistrations_.empty()) return; if (!gameHandler_ || !renderer_) return; auto* transportManager = gameHandler_->getTransportManager(); if (!transportManager) return; auto startTime = std::chrono::steady_clock::now(); static constexpr int kMaxRegistrationsPerFrame = 2; static constexpr float kRegistrationBudgetMs = 2.0f; int processed = 0; for (auto it = pendingTransportRegistrations_.begin(); it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { float elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - startTime).count(); if (elapsedMs >= kRegistrationBudgetMs) break; const PendingTransportRegistration pending = *it; auto goIt = gameObjectInstances_.find(pending.guid); if (goIt == gameObjectInstances_.end()) { it = pendingTransportRegistrations_.erase(it); continue; } if (transportManager->getTransport(pending.guid)) { transportManager->updateServerTransport( pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); it = pendingTransportRegistrations_.erase(it); continue; } const uint32_t wmoInstanceId = goIt->second.instanceId; LOG_DEBUG("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); // TransportAnimation.dbc is indexed by GameObject entry. uint32_t pathId = pending.entry; const bool preferServerData = gameHandler_->hasServerTransportUpdate(pending.guid); bool clientAnim = transportManager->isClientSideAnimation(); LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, " guid=0x", std::hex, pending.guid, std::dec, " entry=", pending.entry, " pathId=", pathId, " preferServer=", preferServerData); glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); const bool shipOrZeppelinDisplay = (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || pending.displayId == 807 || pending.displayId == 808); bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); if (shipOrZeppelinDisplay) { hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); } LOG_DEBUG("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); if (preferServerData) { if (!hasUsablePath) { std::vector path = { canonicalSpawnPos }; transportManager->loadPathFromNodes(pathId, path, false, 0.0f); LOG_DEBUG("Server-first strict registration: stationary fallback for GUID 0x", std::hex, pending.guid, std::dec, " entry=", pending.entry); } else { LOG_DEBUG("Server-first transport registration: using entry DBC path for entry ", pending.entry); } } else if (!hasUsablePath) { bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); uint32_t inferredPath = transportManager->inferDbcPathForSpawn( canonicalSpawnPos, 1200.0f, allowZOnly); if (inferredPath != 0) { pathId = inferredPath; LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); } else { uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); if (remappedPath != 0) { pathId = remappedPath; LOG_WARNING("Using remapped fallback transport path ", pathId, " for entry ", pending.entry, " displayId=", pending.displayId, " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); } else { LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, " - transport will be stationary"); std::vector path = { canonicalSpawnPos }; transportManager->loadPathFromNodes(pathId, path, false, 0.0f); } } } else { LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); } transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); if (!goIt->second.isWmo) { if (auto* tr = transportManager->getTransport(pending.guid)) { tr->isM2 = true; } } transportManager->updateServerTransport( pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); auto moveIt = pendingTransportMoves_.find(pending.guid); if (moveIt != pendingTransportMoves_.end()) { const PendingTransportMove latestMove = moveIt->second; transportManager->updateServerTransport( pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, ") orientation=", latestMove.orientation); pendingTransportMoves_.erase(moveIt); } if (glm::dot(canonicalSpawnPos, canonicalSpawnPos) < 1.0f) { auto goData = gameHandler_->getCachedGameObjectInfo(pending.entry); if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { uint32_t taxiPathId = goData->data[0]; if (transportManager->hasTaxiPath(taxiPathId)) { transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, " taxiPathId=", taxiPathId); } } } if (auto* tr = transportManager->getTransport(pending.guid); tr) { LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, " entry=", pending.entry, " displayId=", pending.displayId, " pathId=", tr->pathId, " mode=", (tr->useClientAnimation ? "client" : "server"), " serverUpdates=", tr->serverUpdateCount); } else { LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, " entry=", pending.entry, " displayId=", pending.displayId, " (TransportManager instance missing)"); } ++processed; it = pendingTransportRegistrations_.erase(it); } } void EntitySpawner::processPendingTransportDoodads() { if (pendingTransportDoodadBatches_.empty()) return; if (!renderer_ || !assetManager_) return; auto* wmoRenderer = renderer_->getWMORenderer(); auto* m2Renderer = renderer_->getM2Renderer(); if (!wmoRenderer || !m2Renderer) return; auto startTime = std::chrono::steady_clock::now(); static constexpr float kDoodadBudgetMs = 4.0f; // Batch all GPU uploads into a single async command buffer submission so that // N doodads with multiple textures each don't each block on vkQueueSubmit + // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. auto* vkCtx = renderer_->getVkContext(); if (vkCtx) vkCtx->beginUploadBatch(); size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; for (auto it = pendingTransportDoodadBatches_.begin(); it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { // Time budget check float elapsedMs = std::chrono::duration( std::chrono::steady_clock::now() - startTime).count(); if (elapsedMs >= kDoodadBudgetMs) break; auto goIt = gameObjectInstances_.find(it->guid); if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo || goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) { it = pendingTransportDoodadBatches_.erase(it); continue; } const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId); if (!doodadTemplates || doodadTemplates->empty()) { it = pendingTransportDoodadBatches_.erase(it); continue; } const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size()); while (it->nextIndex < maxIndex && budgetLeft > 0) { // Per-doodad time budget (each does synchronous file I/O + parse + GPU upload) float innerMs = std::chrono::duration( std::chrono::steady_clock::now() - startTime).count(); if (innerMs >= kDoodadBudgetMs) { budgetLeft = 0; break; } const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex]; it->nextIndex++; budgetLeft--; uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); auto m2Data = assetManager_->readFile(doodadTemplate.m2Path); if (m2Data.empty()) continue; pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin"; std::vector skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && m2Model.version >= 264) { pipeline::M2Loader::loadSkin(skinData, m2Model); } if (!m2Model.isValid()) continue; if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); if (m2InstanceId == 0) continue; m2Renderer->setSkipCollision(m2InstanceId, true); wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform); it->spawnedDoodads++; } if (it->nextIndex >= maxIndex) { if (it->spawnedDoodads > 0) { LOG_DEBUG("Spawned ", it->spawnedDoodads, " transport doodads for WMO instance ", it->instanceId); glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z)); glm::mat4 wmoTransform(1.0f); wmoTransform = glm::translate(wmoTransform, renderPos); wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1)); wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform); } it = pendingTransportDoodadBatches_.erase(it); } else { ++it; } } // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). if (vkCtx) vkCtx->endUploadBatch(); } void EntitySpawner::processPendingMount() { if (pendingMountDisplayId_ == 0) return; uint32_t mountDisplayId = pendingMountDisplayId_; pendingMountDisplayId_ = 0; LOG_INFO("processPendingMount: loading displayId ", mountDisplayId); if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_) return; auto* charRenderer = renderer_->getCharacterRenderer(); std::string m2Path = getModelPathForDisplayId(mountDisplayId); if (m2Path.empty()) { LOG_WARNING("No model path for mount displayId ", mountDisplayId); return; } // Check model cache uint32_t modelId = 0; auto cacheIt = displayIdModelCache_.find(mountDisplayId); if (cacheIt != displayIdModelCache_.end()) { modelId = cacheIt->second; } else { modelId = nextCreatureModelId_++; auto m2Data = assetManager_->readFile(m2Path); if (m2Data.empty()) { LOG_WARNING("Failed to read mount M2: ", m2Path); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse mount M2: ", m2Path); return; } // Load skin file (only for WotLK M2s - vanilla has embedded skin) if (model.version >= 264) { std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty()) { pipeline::M2Loader::loadSkin(skinData, model); } else { LOG_WARNING("Missing skin file for WotLK mount M2: ", skinPath); } } // Load external .anim files (only idle + run needed for mounts) std::string basePath = m2Path.substr(0, m2Path.size() - 3); for (uint32_t si = 0; si < model.sequences.size(); si++) { if (!(model.sequences[si].flags & 0x20)) { uint32_t animId = model.sequences[si].id; // Only load stand, walk, run anims to avoid hang if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue; char animFileName[256]; snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", basePath.c_str(), animId, model.sequences[si].variationIndex); auto animData = assetManager_->readFileOptional(animFileName); if (!animData.empty()) { pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); } } } if (!charRenderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load mount model: ", m2Path); return; } displayIdModelCache_[mountDisplayId] = modelId; } // Apply creature skin textures from CreatureDisplayInfo.dbc. // Re-apply even for cached models so transient failures can self-heal. std::string modelDir; size_t lastSlash = m2Path.find_last_of("\\/"); if (lastSlash != std::string::npos) { modelDir = m2Path.substr(0, lastSlash + 1); } auto itDisplayData = displayDataMap_.find(mountDisplayId); bool haveDisplayData = false; CreatureDisplayData dispData{}; if (itDisplayData != displayDataMap_.end()) { dispData = itDisplayData->second; haveDisplayData = true; } else { // Some taxi mount display IDs are sparse; recover skins by matching model path. std::string lowerMountPath = m2Path; std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); int bestScore = -1; for (const auto& [dispId, data] : displayDataMap_) { auto pit = modelIdToPath_.find(data.modelId); if (pit == modelIdToPath_.end()) continue; std::string p = pit->second; std::transform(p.begin(), p.end(), p.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (p != lowerMountPath) continue; int score = 0; if (!data.skin1.empty()) { std::string p1 = modelDir + data.skin1 + ".blp"; score += assetManager_->fileExists(p1) ? 30 : 3; } if (!data.skin2.empty()) { std::string p2 = modelDir + data.skin2 + ".blp"; score += assetManager_->fileExists(p2) ? 20 : 2; } if (!data.skin3.empty()) { std::string p3 = modelDir + data.skin3 + ".blp"; score += assetManager_->fileExists(p3) ? 10 : 1; } if (score > bestScore) { bestScore = score; dispData = data; haveDisplayData = true; } } if (haveDisplayData) { LOG_INFO("Recovered mount display data by model path for displayId=", mountDisplayId, " skin1='", dispData.skin1, "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); } } if (haveDisplayData) { // If this displayId has no skins, try to find another displayId for the same model with skins. if (dispData.skin1.empty() && dispData.skin2.empty() && dispData.skin3.empty()) { uint32_t sourceModelId = dispData.modelId; int bestScore = -1; for (const auto& [dispId, data] : displayDataMap_) { if (data.modelId != sourceModelId) continue; int score = 0; if (!data.skin1.empty()) { std::string p = modelDir + data.skin1 + ".blp"; score += assetManager_->fileExists(p) ? 30 : 3; } if (!data.skin2.empty()) { std::string p = modelDir + data.skin2 + ".blp"; score += assetManager_->fileExists(p) ? 20 : 2; } if (!data.skin3.empty()) { std::string p = modelDir + data.skin3 + ".blp"; score += assetManager_->fileExists(p) ? 10 : 1; } if (score > bestScore) { bestScore = score; dispData = data; } } LOG_INFO("Mount skin fallback for displayId=", mountDisplayId, " modelId=", sourceModelId, " skin1='", dispData.skin1, "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); } const auto* md = charRenderer->getModelData(modelId); if (md) { LOG_INFO("Mount model textures: ", md->textures.size(), " slots, skin1='", dispData.skin1, "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); for (size_t ti = 0; ti < md->textures.size(); ti++) { LOG_INFO(" tex[", ti, "] type=", md->textures[ti].type, " filename='", md->textures[ti].filename, "'"); } int replaced = 0; for (size_t ti = 0; ti < md->textures.size(); ti++) { const auto& tex = md->textures[ti]; std::string texPath; if (tex.type == 11 && !dispData.skin1.empty()) { texPath = modelDir + dispData.skin1 + ".blp"; } else if (tex.type == 12 && !dispData.skin2.empty()) { texPath = modelDir + dispData.skin2 + ".blp"; } else if (tex.type == 13 && !dispData.skin3.empty()) { texPath = modelDir + dispData.skin3 + ".blp"; } if (!texPath.empty()) { rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); if (skinTex) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath); replaced++; } else { LOG_WARNING(" Failed to load skin texture slot ", ti, ": ", texPath); } } } // Force skin textures onto type-0 (hardcoded) slots that have no filename if (replaced == 0) { for (size_t ti = 0; ti < md->textures.size(); ti++) { const auto& tex = md->textures[ti]; if (tex.type == 0 && tex.filename.empty()) { // Empty hardcoded slot — try skin1 then skin2 std::string texPath; if (!dispData.skin1.empty() && replaced == 0) { texPath = modelDir + dispData.skin1 + ".blp"; } else if (!dispData.skin2.empty()) { texPath = modelDir + dispData.skin2 + ".blp"; } if (!texPath.empty()) { rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); if (skinTex) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath); replaced++; } } } } } // If still no textures, try hardcoded model texture filenames if (replaced == 0) { for (size_t ti = 0; ti < md->textures.size(); ti++) { if (!md->textures[ti].filename.empty()) { rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename); if (texId) { charRenderer->setModelTexture(modelId, static_cast(ti), texId); LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename); replaced++; } } } } // Final fallback for gryphon/wyvern: try well-known skin texture names if (replaced == 0 && !md->textures.empty()) { std::string lowerMountPath = m2Path; std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (lowerMountPath.find("gryphon") != std::string::npos) { const char* gryphonSkins[] = { "Creature\\Gryphon\\Gryphon_Skin.blp", "Creature\\Gryphon\\Gryphon_Skin01.blp", "Creature\\Gryphon\\GRYPHON_SKIN01.BLP", nullptr }; for (const char** p = gryphonSkins; *p; ++p) { rendering::VkTexture* texId = charRenderer->loadTexture(*p); if (texId) { charRenderer->setModelTexture(modelId, 0, texId); LOG_INFO(" Forced gryphon skin fallback: ", *p); replaced++; break; } } } else if (lowerMountPath.find("wyvern") != std::string::npos) { const char* wyvernSkins[] = { "Creature\\Wyvern\\Wyvern_Skin.blp", "Creature\\Wyvern\\Wyvern_Skin01.blp", nullptr }; for (const char** p = wyvernSkins; *p; ++p) { rendering::VkTexture* texId = charRenderer->loadTexture(*p); if (texId) { charRenderer->setModelTexture(modelId, 0, texId); LOG_INFO(" Forced wyvern skin fallback: ", *p); replaced++; break; } } } } LOG_INFO("Mount texture setup: ", replaced, " textures applied"); } } mountModelId_ = modelId; // Create mount instance at player position glm::vec3 mountPos = renderer_->getCharacterPosition(); float yawRad = glm::radians(renderer_->getCharacterYaw()); uint32_t instanceId = charRenderer->createInstance(modelId, mountPos, glm::vec3(0.0f, 0.0f, yawRad), 1.0f); if (instanceId == 0) { LOG_WARNING("Failed to create mount instance"); return; } mountInstanceId_ = instanceId; // Compute height offset — place player above mount's back // Use tight bounds from actual vertices (M2 header bounds can be inaccurate) const auto* modelData = charRenderer->getModelData(modelId); float heightOffset = 1.8f; if (modelData && !modelData->vertices.empty()) { float minZ = std::numeric_limits::max(); float maxZ = -std::numeric_limits::max(); for (const auto& v : modelData->vertices) { if (v.position.z < minZ) minZ = v.position.z; if (v.position.z > maxZ) maxZ = v.position.z; } float extentZ = maxZ - minZ; LOG_INFO("Mount tight bounds: minZ=", minZ, " maxZ=", maxZ, " extentZ=", extentZ); if (extentZ > 0.5f) { // Saddle point is roughly 75% up the model, measured from model origin heightOffset = maxZ * 0.8f; if (heightOffset < 1.0f) heightOffset = extentZ * 0.75f; if (heightOffset < 1.0f) heightOffset = 1.8f; } } if (auto* ac = renderer_->getAnimationController()) ac->setMounted(instanceId, mountDisplayId, heightOffset, m2Path); // For taxi mounts, start with flying animation; for ground mounts, start with stand bool isTaxi = gameHandler_ && gameHandler_->isOnTaxiFlight(); uint32_t startAnim = rendering::anim::STAND; if (isTaxi) { // Try WotLK fly anims first, then Vanilla-friendly fallbacks using namespace rendering::anim; uint32_t taxiCandidates[] = {FLY_FORWARD, FLY_IDLE, FLY_RUN_2, FLY_SPELL, FLY_RISE, SPELL_KNEEL_LOOP, FLY_CUSTOM_SPELL_10, DEAD, RUN}; for (uint32_t anim : taxiCandidates) { if (charRenderer->hasAnimation(instanceId, anim)) { startAnim = anim; break; } } // If none found, startAnim stays 0 (Stand/hover) which is fine for flying creatures } charRenderer->playAnimation(instanceId, startAnim, true); LOG_INFO("processPendingMount: DONE displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset); } void EntitySpawner::despawnCreature(uint64_t guid) { // If this guid is a PLAYER, it will be tracked in playerInstances_. // Route to the correct despawn path so we don't leak instances. if (playerInstances_.count(guid)) { despawnPlayer(guid); return; } pendingCreatureSpawnGuids_.erase(guid); creatureSpawnRetryCounts_.erase(guid); creaturePermanentFailureGuids_.erase(guid); deadCreatureGuids_.erase(guid); auto it = creatureInstances_.find(guid); if (it == creatureInstances_.end()) return; if (renderer_ && renderer_->getCharacterRenderer()) { renderer_->getCharacterRenderer()->removeInstance(it->second); } creatureInstances_.erase(it); creatureModelIds_.erase(guid); creatureRenderPosCache_.erase(guid); creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); creatureWasMoving_.erase(guid); creatureWasSwimming_.erase(guid); creatureWasFlying_.erase(guid); creatureWasWalking_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); creatureFlyingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } void EntitySpawner::despawnGameObject(uint64_t guid) { pendingTransportDoodadBatches_.erase( std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(), [guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }), pendingTransportDoodadBatches_.end()); auto it = gameObjectInstances_.find(guid); if (it == gameObjectInstances_.end()) return; if (renderer_) { if (it->second.isWmo) { if (auto* wmoRenderer = renderer_->getWMORenderer()) { wmoRenderer->removeInstance(it->second.instanceId); } } else { if (auto* m2Renderer = renderer_->getM2Renderer()) { m2Renderer->removeInstance(it->second.instanceId); } } } gameObjectInstances_.erase(it); LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec); } bool EntitySpawner::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) { auto m2Data = assetManager_->readFile(m2Path); if (m2Data.empty()) return false; outModel = pipeline::M2Loader::load(m2Data); // Load skin (WotLK+ M2 format): strip .m2, append 00.skin std::string skinPath = m2Path; size_t dotPos = skinPath.rfind('.'); if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos); skinPath += "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && outModel.version >= 264) pipeline::M2Loader::loadSkin(skinData, outModel); return outModel.isValid(); } } // namespace core } // namespace wowee