#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 { namespace { // Default (bare) geoset IDs per equipment group. // Each group's base is groupNumber * 100; variant 01 is typically bare/default. constexpr uint16_t kGeosetDefaultConnector = 101; // Group 1: default hair connector constexpr uint16_t kGeosetBareForearms = 401; // Group 4: no gloves constexpr uint16_t kGeosetBareShins = 503; // Group 5: no boots constexpr uint16_t kGeosetDefaultEars = 702; // Group 7: ears constexpr uint16_t kGeosetBareSleeves = 801; // Group 8: no chest armor sleeves constexpr uint16_t kGeosetDefaultKneepads = 902; // Group 9: kneepads constexpr uint16_t kGeosetDefaultTabard = 1201; // Group 12: tabard base constexpr uint16_t kGeosetBarePants = 1301; // Group 13: no leggings constexpr uint16_t kGeosetNoCape = 1501; // Group 15: no cape constexpr uint16_t kGeosetWithCape = 1502; // Group 15: with cape constexpr uint16_t kGeosetBareFeet = 2002; // Group 20: bare feet } // namespace void EntitySpawner::spawnOnlinePlayer(uint64_t guid, uint8_t raceId, uint8_t genderId, uint32_t appearanceBytes, uint8_t facialFeatures, float x, float y, float z, float orientation) { if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; if (playerInstances_.count(guid)) return; // Skip local player — already spawned as the main character if (gameHandler_) { uint64_t localGuid = gameHandler_->getPlayerGuid(); uint64_t activeGuid = gameHandler_->getActiveCharacterGuid(); if ((localGuid != 0 && guid == localGuid) || (activeGuid != 0 && guid == activeGuid) || (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { return; } } auto* charRenderer = renderer_->getCharacterRenderer(); // Base geometry model: cache by (race, gender) uint32_t cacheKey = (static_cast(raceId) << 8) | static_cast(genderId & 0xFF); uint32_t modelId = 0; auto itCache = playerModelCache_.find(cacheKey); if (itCache != playerModelCache_.end()) { modelId = itCache->second; } else { game::Race race = static_cast(raceId); game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE; std::string m2Path = game::getPlayerModelPath(race, gender); if (m2Path.empty()) { LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec, " race=", static_cast(raceId), " gender=", static_cast(genderId)); return; } // Parse modelDir/baseName for skin/anim loading std::string modelDir; std::string baseName; { size_t slash = m2Path.rfind('\\'); if (slash != std::string::npos) { modelDir = m2Path.substr(0, slash + 1); baseName = m2Path.substr(slash + 1); } else { baseName = m2Path; } size_t dot = baseName.rfind('.'); if (dot != std::string::npos) baseName = baseName.substr(0, dot); } auto m2Data = assetManager_->readFile(m2Path); if (m2Data.empty()) { LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); return; } // Skin file (only for WotLK M2s - vanilla has embedded skin) std::string skinPath = modelDir + baseName + "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && model.version >= 264) { pipeline::M2Loader::loadSkin(skinData, model); } // After skin loading, full model must be valid (vertices + indices) if (!model.isValid()) { LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); return; } // Load only core external animations (stand/walk/run) to avoid stalls for (uint32_t si = 0; si < model.sequences.size(); si++) { if (!(model.sequences[si].flags & 0x20)) { uint32_t animId = model.sequences[si].id; if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue; char animFileName[256]; snprintf(animFileName, sizeof(animFileName), "%s%s%04u-%02u.anim", modelDir.c_str(), baseName.c_str(), animId, model.sequences[si].variationIndex); auto animData = assetManager_->readFileOptional(animFileName); if (!animData.empty()) { pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); } } } modelId = nextPlayerModelId_++; if (!charRenderer->loadModel(model, modelId)) { LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path); return; } playerModelCache_[cacheKey] = modelId; } // Determine texture slots once per model { auto [slotIt, inserted] = playerTextureSlotsByModelId_.try_emplace(modelId); if (inserted) { PlayerTextureSlots slots; if (const auto* md = charRenderer->getModelData(modelId)) { for (size_t ti = 0; ti < md->textures.size(); ti++) { uint32_t t = md->textures[ti].type; if (t == 1 && slots.skin < 0) slots.skin = static_cast(ti); else if (t == 6 && slots.hair < 0) slots.hair = static_cast(ti); else if (t == 8 && slots.underwear < 0) slots.underwear = static_cast(ti); } } slotIt->second = slots; } } // Create instance at server position glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float renderYaw = orientation + glm::radians(90.0f); uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); if (instanceId == 0) return; // Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides const char* raceFolderName = "Human"; switch (static_cast(raceId)) { case game::Race::HUMAN: raceFolderName = "Human"; break; case game::Race::ORC: raceFolderName = "Orc"; break; case game::Race::DWARF: raceFolderName = "Dwarf"; break; case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break; case game::Race::UNDEAD: raceFolderName = "Scourge"; break; case game::Race::TAUREN: raceFolderName = "Tauren"; break; case game::Race::GNOME: raceFolderName = "Gnome"; break; case game::Race::TROLL: raceFolderName = "Troll"; break; case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break; case game::Race::DRAENEI: raceFolderName = "Draenei"; break; default: break; } const char* genderFolder = (genderId == 1) ? "Female" : "Male"; std::string raceGender = std::string(raceFolderName) + genderFolder; std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp"; std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp"; std::vector underwearPaths; std::string hairTexturePath; std::string faceLowerPath; std::string faceUpperPath; uint8_t skinId = appearanceBytes & 0xFF; uint8_t faceId = (appearanceBytes >> 8) & 0xFF; uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF; uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF; if (auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) { const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL); uint32_t targetRaceId = raceId; uint32_t targetSexId = genderId; bool foundSkin = false; bool foundUnderwear = false; bool foundHair = false; bool foundFaceLower = false; for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { uint32_t rRace = charSectionsDbc->getUInt32(r, csF.raceId); uint32_t rSex = charSectionsDbc->getUInt32(r, csF.sexId); uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection); uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex); uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex); if (rRace != targetRaceId || rSex != targetSexId) continue; if (baseSection == 0 && !foundSkin && colorIndex == skinId) { std::string tex1 = charSectionsDbc->getString(r, csF.texture1); if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; } } else if (baseSection == 3 && !foundHair && variationIndex == hairStyleId && colorIndex == hairColorId) { hairTexturePath = charSectionsDbc->getString(r, csF.texture1); if (!hairTexturePath.empty()) foundHair = true; } else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) { for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { std::string tex = charSectionsDbc->getString(r, f); if (!tex.empty()) underwearPaths.push_back(tex); } foundUnderwear = true; } else if (baseSection == 1 && !foundFaceLower && variationIndex == faceId && colorIndex == skinId) { std::string tex1 = charSectionsDbc->getString(r, csF.texture1); std::string tex2 = charSectionsDbc->getString(r, csF.texture2); if (!tex1.empty()) faceLowerPath = tex1; if (!tex2.empty()) faceUpperPath = tex2; foundFaceLower = true; } if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break; } } // Composite base skin + face + underwear overlays rendering::VkTexture* compositeTex = nullptr; { std::vector layers; layers.push_back(bodySkinPath); if (!faceLowerPath.empty()) layers.push_back(faceLowerPath); if (!faceUpperPath.empty()) layers.push_back(faceUpperPath); for (const auto& up : underwearPaths) layers.push_back(up); if (layers.size() > 1) { compositeTex = charRenderer->compositeTextures(layers); } else { compositeTex = charRenderer->loadTexture(bodySkinPath); } } rendering::VkTexture* hairTex = nullptr; if (!hairTexturePath.empty()) { hairTex = charRenderer->loadTexture(hairTexturePath); } rendering::VkTexture* underwearTex = nullptr; if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); else underwearTex = charRenderer->loadTexture(pelvisPath); const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; if (slots.skin >= 0 && compositeTex) { charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); } if (slots.hair >= 0 && hairTex) { charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); } if (slots.underwear >= 0 && underwearTex) { charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); } // Geosets: body + hair/facial hair selections std::unordered_set activeGeosets; // Body parts (group 0: IDs 0-99, some models use up to 27) for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); activeGeosets.insert(static_cast(100 + hairStyleId + 1)); activeGeosets.insert(static_cast(200 + facialFeatures + 1)); activeGeosets.insert(kGeosetBareForearms); activeGeosets.insert(kGeosetBareShins); activeGeosets.insert(kGeosetDefaultEars); activeGeosets.insert(kGeosetBareSleeves); activeGeosets.insert(kGeosetDefaultKneepads); activeGeosets.insert(kGeosetBarePants); activeGeosets.insert(kGeosetWithCape); activeGeosets.insert(kGeosetBareFeet); charRenderer->setActiveGeosets(instanceId, activeGeosets); charRenderer->playAnimation(instanceId, rendering::anim::STAND, true); playerInstances_[guid] = instanceId; OnlinePlayerAppearanceState st; st.instanceId = instanceId; st.modelId = modelId; st.raceId = raceId; st.genderId = genderId; st.appearanceBytes = appearanceBytes; st.facialFeatures = facialFeatures; st.bodySkinPath = bodySkinPath; // Include face textures so compositeWithRegions can rebuild the full base if (!faceLowerPath.empty()) st.underwearPaths.push_back(faceLowerPath); if (!faceUpperPath.empty()) st.underwearPaths.push_back(faceUpperPath); for (const auto& up : underwearPaths) st.underwearPaths.push_back(up); onlinePlayerAppearance_[guid] = std::move(st); } void EntitySpawner::setOnlinePlayerEquipment(uint64_t guid, const std::array& displayInfoIds, const std::array& inventoryTypes) { if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; // Skip local player — equipment handled by GameScreen::updateCharacterGeosets/Textures // via consumeOnlineEquipmentDirty(), which fires on the same server update. if (gameHandler_) { uint64_t localGuid = gameHandler_->getPlayerGuid(); if (localGuid != 0 && guid == localGuid) return; } // If the player isn't spawned yet, store equipment until spawn. auto appIt = onlinePlayerAppearance_.find(guid); if (!playerInstances_.count(guid) || appIt == onlinePlayerAppearance_.end()) { pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; return; } const OnlinePlayerAppearanceState& st = appIt->second; auto* charRenderer = renderer_->getCharacterRenderer(); if (!charRenderer) return; if (st.instanceId == 0 || st.modelId == 0) return; if (st.bodySkinPath.empty()) { LOG_DEBUG("setOnlinePlayerEquipment: bodySkinPath empty for guid=0x", std::hex, guid, std::dec, " instanceId=", st.instanceId, " — skipping equipment"); return; } int nonZeroDisplay = 0; for (uint32_t d : displayInfoIds) if (d != 0) nonZeroDisplay++; LOG_DEBUG("setOnlinePlayerEquipment: guid=0x", std::hex, guid, std::dec, " instanceId=", st.instanceId, " nonZeroDisplayIds=", nonZeroDisplay, " head=", displayInfoIds[0], " chest=", displayInfoIds[4], " legs=", displayInfoIds[6], " mainhand=", displayInfoIds[15]); auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc) return; const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t { if (displayInfoId == 0) return 0; int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); if (recIdx < 0) return 0; return displayInfoDbc->getUInt32(static_cast(recIdx), fieldIdx); }; auto findDisplayIdByInvType = [&](std::initializer_list types) -> uint32_t { for (int s = 0; s < 19; s++) { uint8_t inv = inventoryTypes[s]; if (inv == 0 || displayInfoIds[s] == 0) continue; for (uint8_t t : types) { if (inv == t) return displayInfoIds[s]; } } return 0; }; auto hasInvType = [&](std::initializer_list types) -> bool { for (int s = 0; s < 19; s++) { uint8_t inv = inventoryTypes[s]; if (inv == 0) continue; for (uint8_t t : types) { if (inv == t) return true; } } return false; }; // --- Geosets --- // Mirror the same group-range logic as CharacterPreview::applyEquipment to // keep other-player rendering consistent with the local character preview. // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). std::unordered_set geosets; // Body parts (group 0: IDs 0-99, some models use up to 27) for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); geosets.insert(static_cast(100 + hairStyleId + 1)); geosets.insert(static_cast(200 + st.facialFeatures + 1)); geosets.insert(701); // Ears geosets.insert(kGeosetDefaultKneepads); // Kneepads geosets.insert(kGeosetBareFeet); // Bare feet mesh const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; // Per-group defaults — overridden below when equipment provides a geoset value. uint16_t geosetGloves = kGeosetBareForearms; uint16_t geosetBoots = kGeosetBareShins; uint16_t geosetSleeves = kGeosetBareSleeves; uint16_t geosetPants = kGeosetBarePants; // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 { uint32_t did = findDisplayIdByInvType({4, 5, 20}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); // Robe kilt → leg group 13 uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); if (gg3 > 0) geosetPants = static_cast(kGeosetBarePants + gg3); } // Legs (invType 7) → leg group 13 { uint32_t did = findDisplayIdByInvType({7}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); if (gg1 > 0) geosetPants = static_cast(kGeosetBarePants + gg1); } // Feet/Boots (invType 8) → shin group 5 { uint32_t did = findDisplayIdByInvType({8}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); if (gg1 > 0) geosetBoots = static_cast(501 + gg1); } // Hands/Gloves (invType 10) → forearm group 4 { uint32_t did = findDisplayIdByInvType({10}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); if (gg1 > 0) geosetGloves = static_cast(kGeosetBareForearms + gg1); } // Wrists/Bracers (invType 9) → sleeve group 8 (only if chest/shirt didn't set it) { uint32_t did = findDisplayIdByInvType({9}); if (did != 0 && geosetSleeves == kGeosetBareSleeves) { uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); } } // Waist/Belt (invType 6) → buckle group 18 uint16_t geosetBelt = 0; { uint32_t did = findDisplayIdByInvType({6}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); if (gg1 > 0) geosetBelt = static_cast(1801 + gg1); } geosets.insert(geosetGloves); geosets.insert(geosetBoots); geosets.insert(geosetSleeves); geosets.insert(geosetPants); if (geosetBelt != 0) geosets.insert(geosetBelt); // Back/Cloak (invType 16) geosets.insert(hasInvType({16}) ? kGeosetWithCape : kGeosetNoCape); // Tabard (invType 19) if (hasInvType({19})) geosets.insert(kGeosetDefaultTabard); // Hide hair under helmets: replace style-specific scalp with bald scalp // HEAD slot is index 0 in the 19-element equipment array if (displayInfoIds[0] != 0 && hairStyleId > 0) { uint16_t hairGeoset = static_cast(hairStyleId + 1); geosets.erase(static_cast(100 + hairGeoset)); // Remove style group 1 geosets.insert(kGeosetDefaultConnector); // Default group 1 connector } charRenderer->setActiveGeosets(st.instanceId, geosets); // --- Helmet model attachment --- // HEAD slot is index 0 in the 19-element equipment array. // Helmet M2s are race/gender-specific (e.g. Helm_Plate_B_01_HuM.m2 for Human Male). if (displayInfoIds[0] != 0) { // Detach any previously attached helmet before attaching a new one charRenderer->detachWeapon(st.instanceId, 0); charRenderer->detachWeapon(st.instanceId, 11); int32_t helmIdx = displayInfoDbc->findRecordById(displayInfoIds[0]); if (helmIdx >= 0) { const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; std::string helmModelName = displayInfoDbc->getString(static_cast(helmIdx), leftModelField); if (!helmModelName.empty()) { // Strip .mdx/.m2 extension size_t dotPos = helmModelName.rfind('.'); if (dotPos != std::string::npos) helmModelName = helmModelName.substr(0, dotPos); // Race/gender suffix for helmet variants static const std::unordered_map racePrefix = { {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} }; std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; std::string raceSuffix; auto itRace = racePrefix.find(st.raceId); if (itRace != racePrefix.end()) { raceSuffix = "_" + itRace->second + genderSuffix; } // Try race/gender-specific variant first, then base name std::string helmPath; pipeline::M2Model helmModel; if (!raceSuffix.empty()) { helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; if (!loadWeaponM2(helmPath, helmModel)) helmModel = {}; } if (!helmModel.isValid()) { helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; loadWeaponM2(helmPath, helmModel); } if (helmModel.isValid()) { uint32_t helmModelId = nextWeaponModelId_++; // Get texture from ItemDisplayInfo (LeftModelTexture) const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; std::string helmTexName = displayInfoDbc->getString(static_cast(helmIdx), leftTexField); std::string helmTexPath; if (!helmTexName.empty()) { if (!raceSuffix.empty()) { std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; if (assetManager_->fileExists(suffixedTex)) helmTexPath = suffixedTex; } if (helmTexPath.empty()) { helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; } } // Attachment point 0 (head bone), fallback to 11 (explicit head attachment) bool attached = charRenderer->attachWeapon(st.instanceId, 0, helmModel, helmModelId, helmTexPath); if (!attached) { attached = charRenderer->attachWeapon(st.instanceId, 11, helmModel, helmModelId, helmTexPath); } if (attached) { LOG_DEBUG("Attached player helmet: ", helmPath, " tex: ", helmTexPath); } } } } } else { // No helmet equipped — detach any existing helmet model charRenderer->detachWeapon(st.instanceId, 0); charRenderer->detachWeapon(st.instanceId, 11); } // --- Shoulder model attachment --- // SHOULDERS slot is index 2 in the 19-element equipment array. // Shoulders have TWO M2 models (left + right) attached at points 5 and 6. // ItemDisplayInfo.dbc: LeftModel → left shoulder, RightModel → right shoulder. if (displayInfoIds[2] != 0) { // Detach any previously attached shoulder models charRenderer->detachWeapon(st.instanceId, 5); charRenderer->detachWeapon(st.instanceId, 6); int32_t shoulderIdx = displayInfoDbc->findRecordById(displayInfoIds[2]); if (shoulderIdx >= 0) { const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; const uint32_t rightModelField = idiL ? (*idiL)["RightModel"] : 2u; const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; const uint32_t rightTexField = idiL ? (*idiL)["RightModelTexture"] : 4u; // Race/gender suffix for shoulder variants (same as helmets) static const std::unordered_map shoulderRacePrefix = { {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} }; std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; std::string raceSuffix; auto itRace = shoulderRacePrefix.find(st.raceId); if (itRace != shoulderRacePrefix.end()) { raceSuffix = "_" + itRace->second + genderSuffix; } // Attach left shoulder (attachment point 5) using LeftModel std::string leftModelName = displayInfoDbc->getString(static_cast(shoulderIdx), leftModelField); if (!leftModelName.empty()) { size_t dotPos = leftModelName.rfind('.'); if (dotPos != std::string::npos) leftModelName = leftModelName.substr(0, dotPos); std::string leftPath; pipeline::M2Model leftModel; if (!raceSuffix.empty()) { leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + raceSuffix + ".m2"; if (!loadWeaponM2(leftPath, leftModel)) leftModel = {}; } if (!leftModel.isValid()) { leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + ".m2"; loadWeaponM2(leftPath, leftModel); } if (leftModel.isValid()) { uint32_t leftModelId = nextWeaponModelId_++; std::string leftTexName = displayInfoDbc->getString(static_cast(shoulderIdx), leftTexField); std::string leftTexPath; if (!leftTexName.empty()) { if (!raceSuffix.empty()) { std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + raceSuffix + ".blp"; if (assetManager_->fileExists(suffixedTex)) leftTexPath = suffixedTex; } if (leftTexPath.empty()) { leftTexPath = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + ".blp"; } } bool attached = charRenderer->attachWeapon(st.instanceId, 5, leftModel, leftModelId, leftTexPath); if (attached) { LOG_DEBUG("Attached left shoulder: ", leftPath, " tex: ", leftTexPath); } } } // Attach right shoulder (attachment point 6) using RightModel std::string rightModelName = displayInfoDbc->getString(static_cast(shoulderIdx), rightModelField); if (!rightModelName.empty()) { size_t dotPos = rightModelName.rfind('.'); if (dotPos != std::string::npos) rightModelName = rightModelName.substr(0, dotPos); std::string rightPath; pipeline::M2Model rightModel; if (!raceSuffix.empty()) { rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + raceSuffix + ".m2"; if (!loadWeaponM2(rightPath, rightModel)) rightModel = {}; } if (!rightModel.isValid()) { rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + ".m2"; loadWeaponM2(rightPath, rightModel); } if (rightModel.isValid()) { uint32_t rightModelId = nextWeaponModelId_++; std::string rightTexName = displayInfoDbc->getString(static_cast(shoulderIdx), rightTexField); std::string rightTexPath; if (!rightTexName.empty()) { if (!raceSuffix.empty()) { std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + raceSuffix + ".blp"; if (assetManager_->fileExists(suffixedTex)) rightTexPath = suffixedTex; } if (rightTexPath.empty()) { rightTexPath = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + ".blp"; } } bool attached = charRenderer->attachWeapon(st.instanceId, 6, rightModel, rightModelId, rightTexPath); if (attached) { LOG_DEBUG("Attached right shoulder: ", rightPath, " tex: ", rightTexPath); } } } } } else { // No shoulders equipped — detach any existing shoulder models charRenderer->detachWeapon(st.instanceId, 5); charRenderer->detachWeapon(st.instanceId, 6); } // --- Cape texture (group 15 / texture type 2) --- // The geoset above enables the cape mesh, but without a texture it renders blank. if (hasInvType({16})) { // Back/cloak is WoW equipment slot 14 (BACK) in the 19-element array. uint32_t capeDid = displayInfoIds[14]; if (capeDid != 0) { int32_t capeRecIdx = displayInfoDbc->findRecordById(capeDid); if (capeRecIdx >= 0) { const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; std::string capeName = displayInfoDbc->getString( static_cast(capeRecIdx), leftTexField); if (!capeName.empty()) { std::replace(capeName.begin(), capeName.end(), '/', '\\'); auto hasBlpExt = [](const std::string& p) { if (p.size() < 4) return false; std::string ext = p.substr(p.size() - 4); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return ext == ".blp"; }; const bool hasDir = (capeName.find('\\') != std::string::npos); const bool hasExt = hasBlpExt(capeName); std::vector capeCandidates; auto addCapeCandidate = [&](const std::string& p) { if (p.empty()) return; if (std::find(capeCandidates.begin(), capeCandidates.end(), p) == capeCandidates.end()) { capeCandidates.push_back(p); } }; if (hasDir) { addCapeCandidate(capeName); if (!hasExt) addCapeCandidate(capeName + ".blp"); } else { std::string baseObj = "Item\\ObjectComponents\\Cape\\" + capeName; std::string baseTex = "Item\\TextureComponents\\Cape\\" + capeName; addCapeCandidate(baseObj); addCapeCandidate(baseTex); if (!hasExt) { addCapeCandidate(baseObj + ".blp"); addCapeCandidate(baseTex + ".blp"); } addCapeCandidate(baseObj + (st.genderId == 1 ? "_F.blp" : "_M.blp")); addCapeCandidate(baseObj + "_U.blp"); addCapeCandidate(baseTex + (st.genderId == 1 ? "_F.blp" : "_M.blp")); addCapeCandidate(baseTex + "_U.blp"); } const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); rendering::VkTexture* capeTexture = nullptr; for (const auto& candidate : capeCandidates) { rendering::VkTexture* tex = charRenderer->loadTexture(candidate); if (tex && tex != whiteTex) { capeTexture = tex; break; } } if (capeTexture) { charRenderer->setGroupTextureOverride(st.instanceId, 15, capeTexture); if (const auto* md = charRenderer->getModelData(st.modelId)) { for (size_t ti = 0; ti < md->textures.size(); ti++) { if (md->textures[ti].type == 2) { charRenderer->setTextureSlotOverride( st.instanceId, static_cast(ti), capeTexture); } } } } } } } } // --- Textures (skin atlas compositing) --- static constexpr const char* componentDirs[] = { "ArmUpperTexture", "ArmLowerTexture", "HandTexture", "TorsoUpperTexture", "TorsoLowerTexture", "LegUpperTexture", "LegLowerTexture", "FootTexture", }; uint32_t texRegionFields[8]; pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); std::vector> regionLayers; 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 fullPath; if (assetManager_->fileExists(genderPath)) fullPath = genderPath; else if (assetManager_->fileExists(unisexPath)) fullPath = unisexPath; else fullPath = base + ".blp"; regionLayers.emplace_back(region, fullPath); } } const auto slotsIt = playerTextureSlotsByModelId_.find(st.modelId); if (slotsIt == playerTextureSlotsByModelId_.end()) return; const PlayerTextureSlots& slots = slotsIt->second; if (slots.skin < 0) return; rendering::VkTexture* newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); if (newTex) { charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); } // --- Weapon model attachment --- // Slot indices in the 19-element EquipSlot array: // 15 = MAIN_HAND → attachment 1 (right hand) // 16 = OFF_HAND → attachment 2 (left hand) struct OnlineWeaponSlot { int slotIndex; uint32_t attachmentId; }; static constexpr OnlineWeaponSlot weaponSlots[] = { { 15, 1 }, // MAIN_HAND → right hand { 16, 2 }, // OFF_HAND → left hand }; const uint32_t modelFieldL = idiL ? (*idiL)["LeftModel"] : 1u; const uint32_t modelFieldR = idiL ? (*idiL)["RightModel"] : 2u; const uint32_t texFieldL = idiL ? (*idiL)["LeftModelTexture"] : 3u; const uint32_t texFieldR = idiL ? (*idiL)["RightModelTexture"] : 4u; for (const auto& ws : weaponSlots) { uint32_t weapDisplayId = displayInfoIds[ws.slotIndex]; if (weapDisplayId == 0) { charRenderer->detachWeapon(st.instanceId, ws.attachmentId); continue; } int32_t recIdx = displayInfoDbc->findRecordById(weapDisplayId); if (recIdx < 0) { charRenderer->detachWeapon(st.instanceId, ws.attachmentId); continue; } // Prefer LeftModel (full weapon), fall back to RightModel (hilt variants) std::string modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldL); std::string textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldL); if (modelName.empty()) { modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldR); textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldR); } if (modelName.empty()) { charRenderer->detachWeapon(st.instanceId, ws.attachmentId); continue; } // Convert .mdx → .m2 std::string modelFile = modelName; { size_t dotPos = modelFile.rfind('.'); if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); modelFile += ".m2"; } // Try Weapon directory first, then Shield std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; pipeline::M2Model weaponModel; if (!loadWeaponM2(m2Path, weaponModel)) { m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; if (!loadWeaponM2(m2Path, weaponModel)) { charRenderer->detachWeapon(st.instanceId, ws.attachmentId); continue; } } // Build texture path std::string texturePath; if (!textureName.empty()) { texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; if (!assetManager_->fileExists(texturePath)) { texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; if (!assetManager_->fileExists(texturePath)) texturePath.clear(); } } uint32_t weaponModelId = nextWeaponModelId_++; charRenderer->attachWeapon(st.instanceId, ws.attachmentId, weaponModel, weaponModelId, texturePath); } } void EntitySpawner::despawnPlayer(uint64_t guid) { if (!renderer_ || !renderer_->getCharacterRenderer()) return; auto it = playerInstances_.find(guid); if (it == playerInstances_.end()) return; renderer_->getCharacterRenderer()->removeInstance(it->second); playerInstances_.erase(it); onlinePlayerAppearance_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid); creatureRenderPosCache_.erase(guid); creatureSwimmingState_.erase(guid); creatureWalkingState_.erase(guid); creatureFlyingState_.erase(guid); creatureWasMoving_.erase(guid); creatureWasSwimming_.erase(guid); creatureWasFlying_.erase(guid); creatureWasWalking_.erase(guid); } void EntitySpawner::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer_ || !assetManager_) return; if (!gameObjectLookupsBuilt_) { buildGameObjectDisplayLookups(); } if (!gameObjectLookupsBuilt_) return; LOG_DEBUG("GO spawn attempt: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " entry=", entry, " pos=(", x, ", ", y, ", ", z, ")"); auto goIt = gameObjectInstances_.find(guid); if (goIt != gameObjectInstances_.end()) { // Already have a render instance — update its position (e.g. transport re-creation) auto& info = goIt->second; glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); LOG_DEBUG("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, " pos=(", x, ", ", y, ", ", z, ")"); if (renderer_) { if (info.isWmo) { if (auto* wr = renderer_->getWMORenderer()) { glm::mat4 transform(1.0f); transform = glm::translate(transform, renderPos); transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); wr->setInstanceTransform(info.instanceId, transform); } } else { if (auto* mr = renderer_->getM2Renderer()) { glm::mat4 transform(1.0f); transform = glm::translate(transform, renderPos); mr->setInstanceTransform(info.instanceId, transform); } } } return; } std::string modelPath; // Override model path for transports with wrong displayIds (preloaded transports) // Check if this GUID is a known transport bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(guid); if (isTransport) { // Map common transport displayIds to correct WMO paths // NOTE: displayIds 455/462 are elevators in Thunder Bluff and should NOT be forced to ships. // Keep ship/zeppelin overrides entry-driven where possible. // DisplayIds 807, 808 = Zeppelins // DisplayIds 2454, 1587 = Special ships/icebreakers if (entry == 20808 || entry == 176231 || entry == 176310) { modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; LOG_INFO("Overriding transport entry/display ", entry, "/", displayId, " → transportship.wmo"); } else if (displayId == 807 || displayId == 808 || displayId == 175080 || displayId == 176495 || displayId == 164871) { modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; LOG_INFO("Overriding transport displayId ", displayId, " → transport_zeppelin.wmo"); } else if (displayId == 1587) { modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Horde_Zeppelin.wmo"); } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); } else if (displayId == 3831) { // Deeprun Tram car modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); } } // Fallback to normal displayId lookup if not a transport or no override matched if (modelPath.empty()) { modelPath = getGameObjectModelPathForDisplayId(displayId); } if (modelPath.empty()) { LOG_WARNING("No model path for gameobject displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")"); return; } // Log spawns to help debug duplicate objects (e.g., cathedral issue) LOG_DEBUG("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, " model=", modelPath, " pos=(", x, ", ", y, ", ", z, ")"); 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"; glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); const float renderYawWmo = orientation; // M2 game objects: model default faces +renderX. renderYaw = canonical + 90° = server_yaw // (same offset as creature/character renderer_ so all M2 models face consistently) const float renderYawM2go = orientation + glm::radians(90.0f); bool loadedAsWmo = false; if (isWmo) { auto* wmoRenderer = renderer_->getWMORenderer(); if (!wmoRenderer) return; uint32_t modelId = 0; auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); if (itCache != gameObjectDisplayIdWmoCache_.end()) { modelId = itCache->second; // Only use cached entry if the model is still resident in the renderer_ if (wmoRenderer->isModelLoaded(modelId)) { loadedAsWmo = true; } else { gameObjectDisplayIdWmoCache_.erase(itCache); modelId = 0; } } if (!loadedAsWmo && modelId == 0) { auto wmoData = assetManager_->readFile(modelPath); if (!wmoData.empty()) { pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); LOG_DEBUG("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); int loadedGroups = 0; if (wmoModel.nGroups > 0) { std::string basePath = modelPath; std::string extension; if (basePath.size() > 4) { extension = basePath.substr(basePath.size() - 4); std::string extLower = extension; for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); if (extLower == ".wmo") { basePath = basePath.substr(0, basePath.size() - 4); } } for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { char groupSuffix[16]; snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); std::string groupPath = basePath + groupSuffix; std::vector groupData = assetManager_->readFile(groupPath); if (groupData.empty()) { snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); groupData = assetManager_->readFile(basePath + groupSuffix); } if (groupData.empty()) { snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); groupData = assetManager_->readFile(basePath + groupSuffix); } if (!groupData.empty()) { pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); loadedGroups++; } else { LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); } } } if (loadedGroups > 0 || wmoModel.nGroups == 0) { modelId = nextGameObjectWmoModelId_++; if (wmoRenderer->loadModel(wmoModel, modelId)) { gameObjectDisplayIdWmoCache_[displayId] = modelId; loadedAsWmo = true; } else { LOG_WARNING("Failed to load gameobject WMO model: ", modelPath); } } else { LOG_WARNING("No WMO groups loaded for gameobject: ", modelPath, " — falling back to M2"); } } else { LOG_WARNING("Failed to read gameobject WMO: ", modelPath, " — falling back to M2"); } } if (loadedAsWmo) { uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYawWmo), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); return; } gameObjectInstances_[guid] = {modelId, instanceId, true}; LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); // Spawn transport WMO doodads (chairs, furniture, etc.) as child M2 instances bool isTransport = false; if (gameHandler_) { std::string lowerModelPath = modelPath; std::transform(lowerModelPath.begin(), lowerModelPath.end(), lowerModelPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); isTransport = (lowerModelPath.find("transport") != std::string::npos); } auto* m2Renderer = renderer_->getM2Renderer(); if (m2Renderer && isTransport) { const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); if (doodadTemplates && !doodadTemplates->empty()) { constexpr size_t kMaxTransportDoodads = 192; const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads); LOG_DEBUG("Queueing ", doodadBudget, "/", doodadTemplates->size(), " transport doodads for WMO instance ", instanceId); pendingTransportDoodadBatches_.push_back(PendingTransportDoodadBatch{ guid, modelId, instanceId, 0, doodadBudget, 0, x, y, z, orientation }); } else { LOG_DEBUG("Transport WMO has no doodads or templates not available"); } } // Transport GameObjects are not always named "transport" in their WMO path // (e.g. elevators/lifts). If the server marks it as a transport, always // notify so TransportManager can animate/carry passengers. bool isTG = gameHandler_ && gameHandler_->isTransportGuid(guid); LOG_DEBUG("WMO GO spawned: guid=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, " isTransport=", isTG, " pos=(", x, ", ", y, ", ", z, ")"); if (isTG) { gameHandler_->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); } return; } // WMO failed — fall through to try as M2 // Convert .wmo path to .m2 for fallback modelPath = modelPath.substr(0, modelPath.size() - 4) + ".m2"; } { auto* m2Renderer = renderer_->getM2Renderer(); if (!m2Renderer) return; // Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s). // Without this guard the same empty model is re-parsed every frame, causing // sustained log spam and wasted CPU. if (gameObjectDisplayIdFailedCache_.count(displayId)) return; uint32_t modelId = 0; auto itCache = gameObjectDisplayIdModelCache_.find(displayId); if (itCache != gameObjectDisplayIdModelCache_.end()) { modelId = itCache->second; if (!m2Renderer->hasModel(modelId)) { LOG_WARNING("GO M2 cache hit but model gone: displayId=", displayId, " modelId=", modelId, " path=", modelPath, " — reloading"); gameObjectDisplayIdModelCache_.erase(itCache); itCache = gameObjectDisplayIdModelCache_.end(); } } if (itCache == gameObjectDisplayIdModelCache_.end()) { modelId = nextGameObjectModelId_++; auto m2Data = assetManager_->readFile(modelPath); if (m2Data.empty()) { LOG_WARNING("Failed to read gameobject M2: ", modelPath); gameObjectDisplayIdFailedCache_.insert(displayId); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse gameobject M2: ", modelPath); gameObjectDisplayIdFailedCache_.insert(displayId); return; } std::string skinPath = modelPath.substr(0, modelPath.size() - 3) + "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && model.version >= 264) { pipeline::M2Loader::loadSkin(skinData, model); } else if (skinData.empty() && model.version >= 264) { LOG_WARNING("GO skin file MISSING for WotLK M2 (no indices/batches): ", skinPath); } LOG_DEBUG("GO model: ", modelPath, " v=", model.version, " verts=", model.vertices.size(), " idx=", model.indices.size(), " batches=", model.batches.size(), " bones=", model.bones.size(), " skin=", (skinData.empty() ? "MISSING" : "ok")); if (!m2Renderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load gameobject model: ", modelPath); gameObjectDisplayIdFailedCache_.insert(displayId); return; } gameObjectDisplayIdModelCache_[displayId] = modelId; } uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYawM2go), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); return; } // Freeze animation for static gameobjects, but let portals/effects/transports animate bool isTransportGO = gameHandler_ && gameHandler_->isTransportGuid(guid); std::string lowerPath = modelPath; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || lowerPath.find("instancenewportal") != std::string::npos || lowerPath.find("portalfx") != std::string::npos || lowerPath.find("spellportal") != std::string::npos); if (!isAnimatedEffect && !isTransportGO) { // Check for totem idle animations — totems should animate, not freeze bool isTotem = false; if (m2Renderer->hasAnimation(instanceId, 245)) { // TOTEM_SMALL m2Renderer->setInstanceAnimation(instanceId, 245, true); isTotem = true; } else if (m2Renderer->hasAnimation(instanceId, 246)) { // TOTEM_MEDIUM m2Renderer->setInstanceAnimation(instanceId, 246, true); isTotem = true; } else if (m2Renderer->hasAnimation(instanceId, 247)) { // TOTEM_LARGE m2Renderer->setInstanceAnimation(instanceId, 247, true); isTotem = true; } if (!isTotem) { m2Renderer->setInstanceAnimationFrozen(instanceId, true); } } gameObjectInstances_[guid] = {modelId, instanceId, false}; // Notify transport system for M2 transports (e.g. Deeprun Tram cars) if (gameHandler_ && gameHandler_->isTransportGuid(guid)) { LOG_DEBUG("M2 transport spawned: guid=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, " instanceId=", instanceId); gameHandler_->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); } } LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } } // namespace core } // namespace wowee