#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 // --- Constructor / Destructor --- EntitySpawner::EntitySpawner(rendering::Renderer* renderer, pipeline::AssetManager* assetManager, game::GameHandler* gameHandler, pipeline::DBCLayout* dbcLayout, game::GameServices* gameServices) : renderer_(renderer) , assetManager_(assetManager) , gameHandler_(gameHandler) , dbcLayout_(dbcLayout) , gameServices_(gameServices) { } EntitySpawner::~EntitySpawner() = default; // --- Lifecycle --- void EntitySpawner::initialize() { buildCharSectionsCache(); buildCreatureDisplayLookups(); buildGameObjectDisplayLookups(); } void EntitySpawner::update() { processPlayerSpawnQueue(); processCreatureSpawnQueue(); processAsyncNpcCompositeResults(); processDeferredEquipmentQueue(); processGameObjectSpawnQueue(); processPendingTransportRegistrations(); processPendingTransportDoodads(); processPendingMount(); } void EntitySpawner::shutdown() { clearAllQueues(); // Clear all instances creatureInstances_.clear(); creatureModelIds_.clear(); creatureRenderPosCache_.clear(); creatureWasMoving_.clear(); creatureWasSwimming_.clear(); creatureWasFlying_.clear(); creatureWasWalking_.clear(); creatureSwimmingState_.clear(); creatureWalkingState_.clear(); creatureFlyingState_.clear(); creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); playerInstances_.clear(); onlinePlayerAppearance_.clear(); gameObjectInstances_.clear(); } void EntitySpawner::resetAllState() { // Wait for in-flight async loads before clearing state for (auto& load : asyncCreatureLoads_) { if (load.future.valid()) load.future.wait(); } // Despawn all entities (renderer cleanup) despawnAllCreatures(); despawnAllPlayers(); despawnAllGameObjects(); clearMountState(); // Clear all queues and async loads clearAllQueues(); // Clear all instance tracking creatureInstances_.clear(); creatureModelIds_.clear(); creatureRenderPosCache_.clear(); playerInstances_.clear(); onlinePlayerAppearance_.clear(); gameObjectInstances_.clear(); // Clear animation state maps creatureWasMoving_.clear(); creatureWasSwimming_.clear(); creatureWasFlying_.clear(); creatureWasWalking_.clear(); creatureSwimmingState_.clear(); creatureWalkingState_.clear(); creatureFlyingState_.clear(); creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); modelIdIsWolfLike_.clear(); // Clear display/spawn caches nonRenderableCreatureDisplayIds_.clear(); displayIdTexturesApplied_.clear(); charSectionsCache_.clear(); charSectionsCacheBuilt_ = false; // Clear GO display caches gameObjectDisplayIdModelCache_.clear(); gameObjectDisplayIdWmoCache_.clear(); gameObjectDisplayIdFailedCache_.clear(); } void EntitySpawner::rebuildLookups() { creatureLookupsBuilt_ = false; displayDataMap_.clear(); humanoidExtraMap_.clear(); creatureModelIds_.clear(); creatureRenderPosCache_.clear(); nonRenderableCreatureDisplayIds_.clear(); initialize(); } bool EntitySpawner::hasWorkPending() const { return !pendingCreatureSpawns_.empty() || !asyncCreatureLoads_.empty() || !asyncNpcCompositeLoads_.empty() || !pendingPlayerSpawns_.empty() || !asyncEquipmentLoads_.empty() || !deferredEquipmentQueue_.empty() || !pendingGameObjectSpawns_.empty() || !asyncGameObjectLoads_.empty(); } void EntitySpawner::clearMountState() { if (mountInstanceId_ != 0 && renderer_) { if (auto* charRenderer = renderer_->getCharacterRenderer()) { charRenderer->removeInstance(mountInstanceId_); } } mountInstanceId_ = 0; mountModelId_ = 0; pendingMountDisplayId_ = 0; } void EntitySpawner::queueTransportRegistration(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { pendingTransportRegistrations_.push_back({guid, entry, displayId, x, y, z, orientation}); } void EntitySpawner::setTransportPendingMove(uint64_t guid, float x, float y, float z, float orientation) { pendingTransportMoves_[guid] = {x, y, z, orientation}; } bool EntitySpawner::hasTransportRegistrationPending(uint64_t guid) const { return std::any_of(pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), [guid](const PendingTransportRegistration& reg) { return reg.guid == guid; }); } void EntitySpawner::updateTransportRegistration(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { for (auto& reg : pendingTransportRegistrations_) { if (reg.guid == guid) { reg.displayId = displayId; reg.x = x; reg.y = y; reg.z = z; reg.orientation = orientation; return; } } } // --- Queue API --- void EntitySpawner::queueCreatureSpawn(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (creatureInstances_.count(guid)) return; if (pendingCreatureSpawnGuids_.count(guid)) return; pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation, scale}); pendingCreatureSpawnGuids_.insert(guid); } void EntitySpawner::queuePlayerSpawn(uint64_t guid, uint8_t raceId, uint8_t genderId, uint32_t appearanceBytes, uint8_t facialFeatures, float x, float y, float z, float orientation) { if (playerInstances_.count(guid)) return; if (pendingPlayerSpawnGuids_.count(guid)) return; pendingPlayerSpawns_.push_back({guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation}); pendingPlayerSpawnGuids_.insert(guid); } void EntitySpawner::queueGameObjectSpawn(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation, scale}); } void EntitySpawner::queuePlayerEquipment(uint64_t guid, const std::array& displayInfoIds, const std::array& inventoryTypes) { deferredEquipmentQueue_.push_back({guid, {displayInfoIds, inventoryTypes}}); } // --- Immediate despawn wrappers --- void EntitySpawner::clearAllQueues() { pendingCreatureSpawns_.clear(); pendingCreatureSpawnGuids_.clear(); creatureSpawnRetryCounts_.clear(); creaturePermanentFailureGuids_.clear(); deadCreatureGuids_.clear(); pendingPlayerSpawns_.clear(); pendingPlayerSpawnGuids_.clear(); pendingOnlinePlayerEquipment_.clear(); deferredEquipmentQueue_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportRegistrations_.clear(); pendingTransportMoves_.clear(); pendingTransportDoodadBatches_.clear(); asyncCreatureLoads_.clear(); asyncCreatureDisplayLoads_.clear(); asyncEquipmentLoads_.clear(); asyncNpcCompositeLoads_.clear(); asyncGameObjectLoads_.clear(); } void EntitySpawner::despawnAllCreatures() { std::vector guids; guids.reserve(creatureInstances_.size()); for (const auto& [g, _] : creatureInstances_) guids.push_back(g); for (auto g : guids) despawnCreature(g); } void EntitySpawner::despawnAllPlayers() { std::vector guids; guids.reserve(playerInstances_.size()); for (const auto& [g, _] : playerInstances_) guids.push_back(g); for (auto g : guids) despawnPlayer(g); } void EntitySpawner::despawnAllGameObjects() { std::vector guids; guids.reserve(gameObjectInstances_.size()); for (const auto& [g, _] : gameObjectInstances_) guids.push_back(g); for (auto g : guids) despawnGameObject(g); } // --- Methods extracted from Application (with comments preserved) --- bool EntitySpawner::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId) { if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !gameHandler_) return false; auto* charRenderer = renderer_->getCharacterRenderer(); if (!charRenderer) return false; auto entity = gameHandler_->getEntityManager().getEntity(guid); if (!entity || entity->getType() != game::ObjectType::UNIT) return false; auto unit = std::static_pointer_cast(entity); if (!unit) return false; // Virtual weapons are only appropriate for humanoid-style displays. // Non-humanoids (wolves/boars/etc.) can expose non-zero virtual item fields // and otherwise end up with comedic floating weapons. uint32_t displayId = unit->getDisplayId(); auto dIt = displayDataMap_.find(displayId); if (dIt == displayDataMap_.end()) return false; uint32_t extraDisplayId = dIt->second.extraDisplayId; if (extraDisplayId == 0 || humanoidExtraMap_.find(extraDisplayId) == humanoidExtraMap_.end()) { return false; } auto itemDisplayDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!itemDisplayDbc) return false; // Item.dbc is not distributed to clients in Vanilla 1.12; on those expansions // item display IDs are resolved via the server-sent item cache instead. auto itemDbc = assetManager_->loadDBCOptional("Item.dbc"); const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; const auto* itemL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Item") : nullptr; auto resolveDisplayInfoId = [&](uint32_t rawId) -> uint32_t { if (rawId == 0) return 0; // Primary path: AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID. // Resolve strictly through Item.dbc entry -> DisplayID to avoid // accidental ItemDisplayInfo ID collisions (staff/hilt mismatches). if (itemDbc) { int32_t itemRec = itemDbc->findRecordById(rawId); // treat as item entry if (itemRec >= 0) { const uint32_t dispFieldPrimary = itemL ? (*itemL)["DisplayID"] : 5u; uint32_t displayIdA = itemDbc->getUInt32(static_cast(itemRec), dispFieldPrimary); if (displayIdA != 0 && itemDisplayDbc->findRecordById(displayIdA) >= 0) { return displayIdA; } } } // Fallback: Vanilla 1.12 does not distribute Item.dbc to clients. // Items arrive via SMSG_ITEM_QUERY_SINGLE_RESPONSE and are cached in // itemInfoCache_. Use the server-sent displayInfoId when available. if (!itemDbc && gameHandler_) { if (const auto* info = gameHandler_->getItemInfo(rawId)) { uint32_t displayIdB = info->displayInfoId; if (displayIdB != 0 && itemDisplayDbc->findRecordById(displayIdB) >= 0) { return displayIdB; } } } return 0; }; auto attachNpcWeaponDisplay = [&](uint32_t itemDisplayId, uint32_t attachmentId) -> bool { uint32_t resolvedDisplayId = resolveDisplayInfoId(itemDisplayId); if (resolvedDisplayId == 0) return false; int32_t recIdx = itemDisplayDbc->findRecordById(resolvedDisplayId); if (recIdx < 0) return false; 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; // Prefer LeftModel (stock player equipment path uses LeftModel and avoids // the "hilt-only" variants seen when forcing RightModel). std::string modelName = itemDisplayDbc->getString(static_cast(recIdx), modelFieldL); std::string textureName = itemDisplayDbc->getString(static_cast(recIdx), texFieldL); if (modelName.empty()) { modelName = itemDisplayDbc->getString(static_cast(recIdx), modelFieldR); textureName = itemDisplayDbc->getString(static_cast(recIdx), texFieldR); } if (modelName.empty()) return false; std::string modelFile = modelName; size_t dotPos = modelFile.rfind('.'); if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); modelFile += ".m2"; // Main-hand NPC weapon path: only use actual weapon models. std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; pipeline::M2Model weaponModel; if (!loadWeaponM2(m2Path, weaponModel)) return false; std::string texturePath; if (!textureName.empty()) { texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; if (!assetManager_->fileExists(texturePath)) texturePath.clear(); } uint32_t weaponModelId = nextWeaponModelId_++; return charRenderer->attachWeapon(instanceId, attachmentId, weaponModel, weaponModelId, texturePath); }; auto hasResolvableWeaponModel = [&](uint32_t itemDisplayId) -> bool { uint32_t resolvedDisplayId = resolveDisplayInfoId(itemDisplayId); if (resolvedDisplayId == 0) return false; int32_t recIdx = itemDisplayDbc->findRecordById(resolvedDisplayId); if (recIdx < 0) return false; const uint32_t modelFieldL = idiL ? (*idiL)["LeftModel"] : 1u; const uint32_t modelFieldR = idiL ? (*idiL)["RightModel"] : 2u; std::string modelName = itemDisplayDbc->getString(static_cast(recIdx), modelFieldL); if (modelName.empty()) { modelName = itemDisplayDbc->getString(static_cast(recIdx), modelFieldR); } if (modelName.empty()) return false; std::string modelFile = modelName; size_t dotPos = modelFile.rfind('.'); if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); modelFile += ".m2"; return assetManager_->fileExists("Item\\ObjectComponents\\Weapon\\" + modelFile); }; bool attachedMain = false; bool hadWeaponCandidate = false; const uint16_t candidateBases[] = {56, 57, 58, 70, 148, 149, 150, 151, 152}; for (uint16_t base : candidateBases) { uint32_t v0 = entity->getField(static_cast(base + 0)); if (v0 != 0) hadWeaponCandidate = true; if (!attachedMain && v0 != 0) attachedMain = attachNpcWeaponDisplay(v0, 1); if (attachedMain) break; } uint16_t unitEnd = game::fieldIndex(game::UF::UNIT_END); uint16_t scanLo = 60; uint16_t scanHi = (unitEnd != 0xFFFF) ? static_cast(unitEnd + 96) : 320; std::map candidateByIndex; for (const auto& [idx, val] : entity->getFields()) { if (idx < scanLo || idx > scanHi) continue; if (val == 0) continue; if (hasResolvableWeaponModel(val)) { candidateByIndex[idx] = val; hadWeaponCandidate = true; } } for (const auto& [idx, val] : candidateByIndex) { if (!attachedMain) attachedMain = attachNpcWeaponDisplay(val, 1); if (attachedMain) break; } // Force off-hand clear in NPC path to avoid incorrect shields/placeholder hilts. charRenderer->detachWeapon(instanceId, 2); // Success if main-hand attached when there was at least one candidate. return hadWeaponCandidate && attachedMain; } void EntitySpawner::buildCharSectionsCache() { if (charSectionsCacheBuilt_ || !assetManager_ || !assetManager_->isInitialized()) return; auto dbc = assetManager_->loadDBC("CharSections.dbc"); if (!dbc) return; const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; auto csF = pipeline::detectCharSectionsFields(dbc.get(), csL); for (uint32_t r = 0; r < dbc->getRecordCount(); r++) { uint32_t race = dbc->getUInt32(r, csF.raceId); uint32_t sex = dbc->getUInt32(r, csF.sexId); uint32_t section = dbc->getUInt32(r, csF.baseSection); uint32_t variation = dbc->getUInt32(r, csF.variationIndex); uint32_t color = dbc->getUInt32(r, csF.colorIndex); // We only cache sections 0 (skin), 1 (face), 3 (hair), 4 (underwear) if (section != 0 && section != 1 && section != 3 && section != 4) continue; for (int ti = 0; ti < 3; ti++) { std::string tex = dbc->getString(r, csF.texture1 + ti); if (tex.empty()) continue; // Key: race(8)|sex(4)|section(4)|variation(8)|color(8)|texIndex(2) packed into 64 bits uint64_t key = (static_cast(race) << 26) | (static_cast(sex & 0xF) << 22) | (static_cast(section & 0xF) << 18) | (static_cast(variation & 0xFF) << 10) | (static_cast(color & 0xFF) << 2) | static_cast(ti); charSectionsCache_.emplace(key, tex); } } charSectionsCacheBuilt_ = true; LOG_INFO("CharSections cache built: ", charSectionsCache_.size(), " entries"); } std::string EntitySpawner::lookupCharSection(uint8_t race, uint8_t sex, uint8_t section, uint8_t variation, uint8_t color, int texIndex) const { uint64_t key = (static_cast(race) << 26) | (static_cast(sex & 0xF) << 22) | (static_cast(section & 0xF) << 18) | (static_cast(variation & 0xFF) << 10) | (static_cast(color & 0xFF) << 2) | static_cast(texIndex); auto it = charSectionsCache_.find(key); return (it != charSectionsCache_.end()) ? it->second : std::string(); } void EntitySpawner::buildCreatureDisplayLookups() { if (creatureLookupsBuilt_ || !assetManager_ || !assetManager_->isInitialized()) return; LOG_INFO("Building creature display lookups from DBC files"); // CreatureDisplayInfo.dbc structure (3.3.5a): // Col 0: displayId // Col 1: modelId // Col 3: extendedDisplayInfoID (link to CreatureDisplayInfoExtra.dbc) // Col 6: Skin1 (texture name) // Col 7: Skin2 // Col 8: Skin3 if (auto cdi = assetManager_->loadDBC("CreatureDisplayInfo.dbc"); cdi && cdi->isLoaded()) { const auto* cdiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CreatureDisplayInfo") : nullptr; for (uint32_t i = 0; i < cdi->getRecordCount(); i++) { CreatureDisplayData data; data.modelId = cdi->getUInt32(i, cdiL ? (*cdiL)["ModelID"] : 1); data.extraDisplayId = cdi->getUInt32(i, cdiL ? (*cdiL)["ExtraDisplayId"] : 3); data.skin1 = cdi->getString(i, cdiL ? (*cdiL)["Skin1"] : 6); data.skin2 = cdi->getString(i, cdiL ? (*cdiL)["Skin2"] : 7); data.skin3 = cdi->getString(i, cdiL ? (*cdiL)["Skin3"] : 8); displayDataMap_[cdi->getUInt32(i, cdiL ? (*cdiL)["ID"] : 0)] = data; } LOG_INFO("Loaded ", displayDataMap_.size(), " display→model mappings"); } // CreatureDisplayInfoExtra.dbc structure (3.3.5a): // Col 0: ID // Col 1: DisplayRaceID // Col 2: DisplaySexID // Col 3: SkinID // Col 4: FaceID // Col 5: HairStyleID // Col 6: HairColorID // Col 7: FacialHairID // CreatureDisplayInfoExtra.dbc field layout depends on actual field count: // 19 fields: 10 equip slots (8-17), BakeName=18 (no Flags field) // 21 fields: 11 equip slots (8-18), Flags=19, BakeName=20 if (auto cdie = assetManager_->loadDBC("CreatureDisplayInfoExtra.dbc"); cdie && cdie->isLoaded()) { const auto* cdieL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CreatureDisplayInfoExtra") : nullptr; const uint32_t cdieEquip0 = cdieL ? (*cdieL)["EquipDisplay0"] : 8; // Detect actual field count to determine equip slot count and BakeName position const uint32_t dbcFieldCount = cdie->getFieldCount(); int numEquipSlots; uint32_t bakeField; if (dbcFieldCount <= 19) { // 19 fields: 10 equip slots (8-17), BakeName at 18 numEquipSlots = 10; bakeField = 18; } else { // 21 fields: 11 equip slots (8-18), Flags=19, BakeName=20 numEquipSlots = 11; bakeField = cdieL ? (*cdieL)["BakeName"] : 20; } uint32_t withBakeName = 0; for (uint32_t i = 0; i < cdie->getRecordCount(); i++) { HumanoidDisplayExtra extra; extra.raceId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["RaceID"] : 1)); extra.sexId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["SexID"] : 2)); extra.skinId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["SkinID"] : 3)); extra.faceId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["FaceID"] : 4)); extra.hairStyleId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["HairStyleID"] : 5)); extra.hairColorId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["HairColorID"] : 6)); extra.facialHairId = static_cast(cdie->getUInt32(i, cdieL ? (*cdieL)["FacialHairID"] : 7)); for (int eq = 0; eq < numEquipSlots; eq++) { extra.equipDisplayId[eq] = cdie->getUInt32(i, cdieEquip0 + eq); } extra.bakeName = cdie->getString(i, bakeField); if (!extra.bakeName.empty()) withBakeName++; humanoidExtraMap_[cdie->getUInt32(i, cdieL ? (*cdieL)["ID"] : 0)] = extra; } LOG_DEBUG("Loaded ", humanoidExtraMap_.size(), " humanoid display extra entries (", withBakeName, " with baked textures, ", numEquipSlots, " equip slots, ", dbcFieldCount, " DBC fields, bakeField=", bakeField, ")"); } // CreatureModelData.dbc: modelId (col 0) → modelPath (col 2, .mdx → .m2) if (auto cmd = assetManager_->loadDBC("CreatureModelData.dbc"); cmd && cmd->isLoaded()) { const auto* cmdL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CreatureModelData") : nullptr; for (uint32_t i = 0; i < cmd->getRecordCount(); i++) { std::string mdx = cmd->getString(i, cmdL ? (*cmdL)["ModelPath"] : 2); if (mdx.empty()) continue; if (mdx.size() >= 4) { mdx = mdx.substr(0, mdx.size() - 4) + ".m2"; } modelIdToPath_[cmd->getUInt32(i, cmdL ? (*cmdL)["ID"] : 0)] = mdx; } LOG_INFO("Loaded ", modelIdToPath_.size(), " model→path mappings"); } // Resolve gryphon/wyvern display IDs by exact model path so taxi mounts have textures. auto toLower = [](std::string s) { for (char& c : s) c = static_cast(std::tolower(static_cast(c))); return s; }; auto normalizePath = [&](const std::string& p) { std::string s = p; for (char& c : s) if (c == '/') c = '\\'; return toLower(s); }; auto resolveDisplayIdForExactPath = [&](const std::string& exactPath) -> uint32_t { const std::string target = normalizePath(exactPath); // Collect ALL model IDs that map to this path (multiple model IDs can // share the same .m2 file, e.g. modelId 147 and 792 both → Gryphon.m2) std::vector modelIds; for (const auto& [mid, path] : modelIdToPath_) { if (normalizePath(path) == target) { modelIds.push_back(mid); } } if (modelIds.empty()) return 0; uint32_t bestDisplayId = 0; int bestScore = -1; for (const auto& [dispId, data] : displayDataMap_) { bool matches = false; for (uint32_t mid : modelIds) { if (data.modelId == mid) { matches = true; break; } } if (!matches) continue; int score = 0; if (!data.skin1.empty()) score += 3; if (!data.skin2.empty()) score += 2; if (!data.skin3.empty()) score += 1; if (score > bestScore) { bestScore = score; bestDisplayId = dispId; } } return bestDisplayId; }; gryphonDisplayId_ = resolveDisplayIdForExactPath("Creature\\Gryphon\\Gryphon.m2"); wyvernDisplayId_ = resolveDisplayIdForExactPath("Creature\\Wyvern\\Wyvern.m2"); gameServices_->gryphonDisplayId = gryphonDisplayId_; gameServices_->wyvernDisplayId = wyvernDisplayId_; LOG_INFO("Taxi mount displayIds: gryphon=", gryphonDisplayId_, " wyvern=", wyvernDisplayId_); // CharHairGeosets.dbc: maps (race, sex, hairStyleId) → skinSectionId for hair mesh // Col 0: ID, Col 1: RaceID, Col 2: SexID, Col 3: VariationID, Col 4: GeosetID, Col 5: Showscalp if (auto chg = assetManager_->loadDBC("CharHairGeosets.dbc"); chg && chg->isLoaded()) { const auto* chgL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharHairGeosets") : nullptr; for (uint32_t i = 0; i < chg->getRecordCount(); i++) { uint32_t raceId = chg->getUInt32(i, chgL ? (*chgL)["RaceID"] : 1); uint32_t sexId = chg->getUInt32(i, chgL ? (*chgL)["SexID"] : 2); uint32_t variation = chg->getUInt32(i, chgL ? (*chgL)["Variation"] : 3); uint32_t geosetId = chg->getUInt32(i, chgL ? (*chgL)["GeosetID"] : 4); uint32_t key = (raceId << 16) | (sexId << 8) | variation; hairGeosetMap_[key] = static_cast(geosetId); } LOG_INFO("Loaded ", hairGeosetMap_.size(), " hair geoset mappings from CharHairGeosets.dbc"); } // CharacterFacialHairStyles.dbc: maps (race, sex, facialHairId) → geoset IDs // No ID column: Col 0: RaceID, Col 1: SexID, Col 2: VariationID // Col 3: Geoset100, Col 4: Geoset300, Col 5: Geoset200 if (auto cfh = assetManager_->loadDBC("CharacterFacialHairStyles.dbc"); cfh && cfh->isLoaded()) { const auto* cfhL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharacterFacialHairStyles") : nullptr; for (uint32_t i = 0; i < cfh->getRecordCount(); i++) { uint32_t raceId = cfh->getUInt32(i, cfhL ? (*cfhL)["RaceID"] : 0); uint32_t sexId = cfh->getUInt32(i, cfhL ? (*cfhL)["SexID"] : 1); uint32_t variation = cfh->getUInt32(i, cfhL ? (*cfhL)["Variation"] : 2); uint32_t key = (raceId << 16) | (sexId << 8) | variation; FacialHairGeosets fhg; fhg.geoset100 = static_cast(cfh->getUInt32(i, cfhL ? (*cfhL)["Geoset100"] : 3)); fhg.geoset300 = static_cast(cfh->getUInt32(i, cfhL ? (*cfhL)["Geoset300"] : 4)); fhg.geoset200 = static_cast(cfh->getUInt32(i, cfhL ? (*cfhL)["Geoset200"] : 5)); facialHairGeosetMap_[key] = fhg; } LOG_INFO("Loaded ", facialHairGeosetMap_.size(), " facial hair geoset mappings from CharacterFacialHairStyles.dbc"); } creatureLookupsBuilt_ = true; } std::string EntitySpawner::getModelPathForDisplayId(uint32_t displayId) const { if (displayId == 30412) return "Creature\\Gryphon\\Gryphon.m2"; if (displayId == 30413) return "Creature\\Wyvern\\Wyvern.m2"; // WotLK servers can send display IDs that do not exist in older/local // CreatureDisplayInfo datasets. Keep those creatures visible by falling // back to a close base model instead of dropping spawn entirely. switch (displayId) { case 31048: // Diseased Young Wolf variants (AzerothCore WotLK) case 31049: // Diseased Wolf variants (AzerothCore WotLK) return "Creature\\Wolf\\Wolf.m2"; default: break; } auto itData = displayDataMap_.find(displayId); if (itData == displayDataMap_.end()) { // Some sources (e.g., taxi nodes) may provide a modelId directly. auto itPath = modelIdToPath_.find(displayId); if (itPath != modelIdToPath_.end()) { return itPath->second; } if (displayId == 30412) return "Creature\\Gryphon\\Gryphon.m2"; if (displayId == 30413) return "Creature\\Wyvern\\Wyvern.m2"; if (warnedMissingDisplayDataIds_.insert(displayId).second) { LOG_WARNING("No display data for displayId ", displayId, " (displayDataMap_ has ", displayDataMap_.size(), " entries)"); } return ""; } auto itPath = modelIdToPath_.find(itData->second.modelId); if (itPath == modelIdToPath_.end()) { if (warnedMissingModelPathIds_.insert(displayId).second) { LOG_WARNING("No model path for modelId ", itData->second.modelId, " from displayId ", displayId, " (modelIdToPath_ has ", modelIdToPath_.size(), " entries)"); } return ""; } return itPath->second; } audio::VoiceType EntitySpawner::detectVoiceTypeFromDisplayId(uint32_t displayId) const { // Look up display data auto itDisplay = displayDataMap_.find(displayId); if (itDisplay == displayDataMap_.end() || itDisplay->second.extraDisplayId == 0) { LOG_INFO("Voice detection: displayId ", displayId, " -> GENERIC (no display data)"); return audio::VoiceType::GENERIC; // Not a humanoid or no extra data } // Look up humanoid extra data (race/sex info) auto itExtra = humanoidExtraMap_.find(itDisplay->second.extraDisplayId); if (itExtra == humanoidExtraMap_.end()) { LOG_INFO("Voice detection: displayId ", displayId, " -> GENERIC (no humanoid extra data)"); return audio::VoiceType::GENERIC; } uint8_t raceId = itExtra->second.raceId; uint8_t sexId = itExtra->second.sexId; const char* raceName = "Unknown"; const char* sexName = (sexId == 0) ? "Male" : "Female"; // Map (raceId, sexId) to VoiceType // Race IDs: 1=Human, 2=Orc, 3=Dwarf, 4=NightElf, 5=Undead, 6=Tauren, 7=Gnome, 8=Troll // Sex IDs: 0=Male, 1=Female audio::VoiceType result; switch (raceId) { case 1: raceName = "Human"; result = (sexId == 0) ? audio::VoiceType::HUMAN_MALE : audio::VoiceType::HUMAN_FEMALE; break; case 2: raceName = "Orc"; result = (sexId == 0) ? audio::VoiceType::ORC_MALE : audio::VoiceType::ORC_FEMALE; break; case 3: raceName = "Dwarf"; result = (sexId == 0) ? audio::VoiceType::DWARF_MALE : audio::VoiceType::DWARF_FEMALE; break; case 4: raceName = "NightElf"; result = (sexId == 0) ? audio::VoiceType::NIGHTELF_MALE : audio::VoiceType::NIGHTELF_FEMALE; break; case 5: raceName = "Undead"; result = (sexId == 0) ? audio::VoiceType::UNDEAD_MALE : audio::VoiceType::UNDEAD_FEMALE; break; case 6: raceName = "Tauren"; result = (sexId == 0) ? audio::VoiceType::TAUREN_MALE : audio::VoiceType::TAUREN_FEMALE; break; case 7: raceName = "Gnome"; result = (sexId == 0) ? audio::VoiceType::GNOME_MALE : audio::VoiceType::GNOME_FEMALE; break; case 8: raceName = "Troll"; result = (sexId == 0) ? audio::VoiceType::TROLL_MALE : audio::VoiceType::TROLL_FEMALE; break; case 10: raceName = "BloodElf"; result = (sexId == 0) ? audio::VoiceType::BLOODELF_MALE : audio::VoiceType::BLOODELF_FEMALE; break; case 11: raceName = "Draenei"; result = (sexId == 0) ? audio::VoiceType::DRAENEI_MALE : audio::VoiceType::DRAENEI_FEMALE; break; default: result = audio::VoiceType::GENERIC; break; } LOG_INFO("Voice detection: displayId ", displayId, " -> ", raceName, " ", sexName, " (race=", static_cast(raceId), ", sex=", static_cast(sexId), ")"); return result; } void EntitySpawner::buildGameObjectDisplayLookups() { if (gameObjectLookupsBuilt_ || !assetManager_ || !assetManager_->isInitialized()) return; LOG_INFO("Building gameobject display lookups from DBC files"); // GameObjectDisplayInfo.dbc structure (3.3.5a): // Col 0: ID (displayId) // Col 1: ModelName if (auto godi = assetManager_->loadDBC("GameObjectDisplayInfo.dbc"); godi && godi->isLoaded()) { const auto* godiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("GameObjectDisplayInfo") : nullptr; for (uint32_t i = 0; i < godi->getRecordCount(); i++) { uint32_t displayId = godi->getUInt32(i, godiL ? (*godiL)["ID"] : 0); std::string modelName = godi->getString(i, godiL ? (*godiL)["ModelName"] : 1); if (modelName.empty()) continue; if (modelName.size() >= 4) { std::string ext = modelName.substr(modelName.size() - 4); for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); if (ext == ".mdx") { modelName = modelName.substr(0, modelName.size() - 4) + ".m2"; } } gameObjectDisplayIdToPath_[displayId] = modelName; } LOG_INFO("Loaded ", gameObjectDisplayIdToPath_.size(), " gameobject display mappings"); } else { LOG_WARNING("GameObjectDisplayInfo.dbc failed to load — no GO display mappings available"); } if (gameObjectDisplayIdToPath_.empty()) { LOG_WARNING("GO display mapping table is EMPTY — game objects will not render"); } gameObjectLookupsBuilt_ = true; } std::string EntitySpawner::getGameObjectModelPathForDisplayId(uint32_t displayId) const { auto it = gameObjectDisplayIdToPath_.find(displayId); if (it == gameObjectDisplayIdToPath_.end()) return ""; return it->second; } bool EntitySpawner::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const { if (!renderer_ || !renderer_->getCharacterRenderer()) return false; uint32_t instanceId = 0; if (gameHandler_ && guid == gameHandler_->getPlayerGuid()) { instanceId = renderer_->getCharacterInstanceId(); } if (instanceId == 0) { auto pit = playerInstances_.find(guid); if (pit != playerInstances_.end()) instanceId = pit->second; } if (instanceId == 0) { auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; } if (instanceId == 0) return false; return renderer_->getCharacterRenderer()->getInstanceBounds(instanceId, outCenter, outRadius); } bool EntitySpawner::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const { if (!renderer_ || !renderer_->getCharacterRenderer()) return false; uint32_t instanceId = 0; if (gameHandler_ && guid == gameHandler_->getPlayerGuid()) { instanceId = renderer_->getCharacterInstanceId(); } if (instanceId == 0) { auto pit = playerInstances_.find(guid); if (pit != playerInstances_.end()) instanceId = pit->second; } if (instanceId == 0) { auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; } if (instanceId == 0) return false; return renderer_->getCharacterRenderer()->getInstanceFootZ(instanceId, outFootZ); } bool EntitySpawner::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const { if (!renderer_ || !renderer_->getCharacterRenderer()) return false; uint32_t instanceId = 0; if (gameHandler_ && guid == gameHandler_->getPlayerGuid()) { instanceId = renderer_->getCharacterInstanceId(); } if (instanceId == 0) { auto pit = playerInstances_.find(guid); if (pit != playerInstances_.end()) instanceId = pit->second; } if (instanceId == 0) { auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; } if (instanceId == 0) return false; return renderer_->getCharacterRenderer()->getInstancePosition(instanceId, outPos); } pipeline::M2Model EntitySpawner::loadCreatureM2Sync(const std::string& m2Path) { auto m2Data = assetManager_->readFile(m2Path); if (m2Data.empty()) { LOG_WARNING("Failed to read creature M2: ", m2Path); return {}; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse creature 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 creature M2: ", skinPath); } } // Load external .anim files for sequences without flag 0x20 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 = assetManager_->readFileOptional(animFileName); if (!animData.empty()) { pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); } } } return model; } void EntitySpawner::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_) return; // Skip if lookups not yet built (asset manager not ready) if (!creatureLookupsBuilt_) return; // Skip if already spawned if (creatureInstances_.count(guid)) return; if (nonRenderableCreatureDisplayIds_.count(displayId)) { creaturePermanentFailureGuids_.insert(guid); return; } // Get model path from displayId std::string m2Path = getModelPathForDisplayId(displayId); if (m2Path.empty()) { nonRenderableCreatureDisplayIds_.insert(displayId); creaturePermanentFailureGuids_.insert(guid); return; } { // Intentionally invisible helper creatures should not consume retry budget. 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(displayId); creaturePermanentFailureGuids_.insert(guid); return; } } auto* charRenderer = renderer_->getCharacterRenderer(); // Check model cache - reuse if same displayId was already loaded uint32_t modelId = 0; auto cacheIt = displayIdModelCache_.find(displayId); if (cacheIt != displayIdModelCache_.end()) { modelId = cacheIt->second; } else { // Load model from disk (only once per displayId) modelId = nextCreatureModelId_++; pipeline::M2Model model = loadCreatureM2Sync(m2Path); if (!model.isValid()) { nonRenderableCreatureDisplayIds_.insert(displayId); creaturePermanentFailureGuids_.insert(guid); return; } if (!charRenderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load creature model: ", m2Path); nonRenderableCreatureDisplayIds_.insert(displayId); creaturePermanentFailureGuids_.insert(guid); return; } displayIdModelCache_[displayId] = modelId; } // Apply skin textures from CreatureDisplayInfo.dbc (only once per displayId model). // Track separately from model cache because async loading may upload the model // before textures are applied. auto itDisplayData = displayDataMap_.find(displayId); bool needsTextures = (displayIdTexturesApplied_.find(displayId) == displayIdTexturesApplied_.end()); if (needsTextures && itDisplayData != displayDataMap_.end()) { auto texStart = std::chrono::steady_clock::now(); displayIdTexturesApplied_.insert(displayId); const auto& dispData = itDisplayData->second; // Use pre-decoded textures from async creature load (if available) auto itPreDec = displayIdPredecodedTextures_.find(displayId); bool hasPreDec = (itPreDec != displayIdPredecodedTextures_.end()); if (hasPreDec) { charRenderer->setPredecodedBLPCache(&itPreDec->second); } // Get model directory for texture path construction std::string modelDir; size_t lastSlash = m2Path.find_last_of("\\/"); if (lastSlash != std::string::npos) { modelDir = m2Path.substr(0, lastSlash + 1); } LOG_DEBUG("DisplayId ", displayId, " skins: '", dispData.skin1, "', '", dispData.skin2, "', '", dispData.skin3, "' extraDisplayId=", dispData.extraDisplayId); // Get model data from CharacterRenderer for texture iteration const auto* modelData = charRenderer->getModelData(modelId); if (!modelData) { LOG_WARNING("Model data not found for modelId ", modelId); } // Log texture types in the model if (modelData) { for (size_t ti = 0; ti < modelData->textures.size(); ti++) { LOG_DEBUG(" Model texture ", ti, ": type=", modelData->textures[ti].type, " filename='", modelData->textures[ti].filename, "'"); } } // Check if this is a humanoid NPC with extra display info bool hasHumanoidTexture = false; if (dispData.extraDisplayId != 0) { auto itExtra = humanoidExtraMap_.find(dispData.extraDisplayId); if (itExtra != humanoidExtraMap_.end()) { const auto& extra = itExtra->second; LOG_DEBUG(" Found humanoid extra: raceId=", static_cast(extra.raceId), " sexId=", static_cast(extra.sexId), " hairStyle=", static_cast(extra.hairStyleId), " hairColor=", static_cast(extra.hairColorId), " bakeName='", extra.bakeName, "'"); // Collect model texture slot info (type 1 = skin, type 6 = hair) std::vector skinSlots, hairSlots; if (modelData) { for (size_t ti = 0; ti < modelData->textures.size(); ti++) { uint32_t texType = modelData->textures[ti].type; if (texType == 1 || texType == 11 || texType == 12 || texType == 13) skinSlots.push_back(static_cast(ti)); if (texType == 6) hairSlots.push_back(static_cast(ti)); } } // Copy extra data for the async task (avoid dangling reference) HumanoidDisplayExtra extraCopy = extra; // Launch async task: ALL DBC lookups, path resolution, and BLP pre-decode // happen on a background thread. Only GPU texture upload runs on main thread // (in processAsyncNpcCompositeResults). auto* am = assetManager_; AsyncNpcCompositeLoad load; load.future = std::async(std::launch::async, [am, extraCopy, skinSlots = std::move(skinSlots), hairSlots = std::move(hairSlots), modelId, displayId]() mutable -> PreparedNpcComposite { PreparedNpcComposite result; DeferredNpcComposite& def = result.info; def.modelId = modelId; def.displayId = displayId; def.skinTextureSlots = std::move(skinSlots); def.hairTextureSlots = std::move(hairSlots); std::vector allPaths; // paths to pre-decode // --- Baked skin texture --- if (!extraCopy.bakeName.empty()) { def.bakedSkinPath = "Textures\\BakedNpcTextures\\" + extraCopy.bakeName; def.hasBakedSkin = true; allPaths.push_back(def.bakedSkinPath); } // --- CharSections fallback (skin/face/underwear) --- if (!def.hasBakedSkin) { 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 npcRace = static_cast(extraCopy.raceId); uint32_t npcSex = static_cast(extraCopy.sexId); uint32_t npcSkin = static_cast(extraCopy.skinId); uint32_t npcFace = static_cast(extraCopy.faceId); std::string npcFaceLower, npcFaceUpper; std::vector npcUnderwear; 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 != npcRace || sId != npcSex) 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 && def.basePath.empty() && color == npcSkin) { def.basePath = csDbc->getString(r, csF.texture1); } else if (section == 1 && npcFaceLower.empty() && variation == npcFace && color == npcSkin) { npcFaceLower = csDbc->getString(r, csF.texture1); npcFaceUpper = csDbc->getString(r, csF.texture2); } else if (section == 4 && npcUnderwear.empty() && color == npcSkin) { for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { std::string tex = csDbc->getString(r, f); if (!tex.empty()) npcUnderwear.push_back(tex); } } } if (!def.basePath.empty()) { allPaths.push_back(def.basePath); if (!npcFaceLower.empty()) { def.overlayPaths.push_back(npcFaceLower); allPaths.push_back(npcFaceLower); } if (!npcFaceUpper.empty()) { def.overlayPaths.push_back(npcFaceUpper); allPaths.push_back(npcFaceUpper); } for (const auto& uw : npcUnderwear) { def.overlayPaths.push_back(uw); allPaths.push_back(uw); } } } } // --- Equipment region layers (ItemDisplayInfo DBC) --- auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc"); if (idiDbc) { static constexpr const char* componentDirs[] = { "ArmUpperTexture", "ArmLowerTexture", "HandTexture", "TorsoUpperTexture", "TorsoLowerTexture", "LegUpperTexture", "LegLowerTexture", "FootTexture", }; const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; uint32_t texRegionFields[8]; pipeline::getItemDisplayInfoTextureFields(*idiDbc, idiL, texRegionFields); const bool npcIsFemale = (extraCopy.sexId == 1); const bool npcHasArmArmor = (extraCopy.equipDisplayId[7] != 0 || extraCopy.equipDisplayId[8] != 0); auto regionAllowedForNpcSlot = [](int eqSlot, int region) -> bool { switch (eqSlot) { case 2: case 3: return region <= 4; case 4: return false; case 5: return region == 5 || region == 6; case 6: return region == 7; case 7: return false; case 8: return region == 2; case 9: return region == 3 || region == 4; default: return false; } }; for (int eqSlot = 0; eqSlot < 11; eqSlot++) { uint32_t did = extraCopy.equipDisplayId[eqSlot]; if (did == 0) continue; int32_t recIdx = idiDbc->findRecordById(did); if (recIdx < 0) continue; for (int region = 0; region < 8; region++) { if (!regionAllowedForNpcSlot(eqSlot, region)) continue; if (eqSlot == 2 && !npcHasArmArmor && !(region == 3 || region == 4)) continue; std::string texName = idiDbc->getString( static_cast(recIdx), texRegionFields[region]); if (texName.empty()) continue; std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; std::string genderPath = base + (npcIsFemale ? "_F.blp" : "_M.blp"); std::string unisexPath = base + "_U.blp"; std::string basePath = base + ".blp"; std::string fullPath; if (am->fileExists(genderPath)) fullPath = genderPath; else if (am->fileExists(unisexPath)) fullPath = unisexPath; else if (am->fileExists(basePath)) fullPath = basePath; else continue; def.regionLayers.emplace_back(region, fullPath); allPaths.push_back(fullPath); } } } // Determine compositing mode if (!def.basePath.empty()) { bool needsComposite = !def.overlayPaths.empty() || !def.regionLayers.empty(); if (needsComposite && !def.skinTextureSlots.empty()) { def.hasComposite = true; } else if (!def.skinTextureSlots.empty()) { def.hasSimpleSkin = true; } } // --- Hair texture from CharSections (section 3) --- { 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 targetRace = static_cast(extraCopy.raceId); uint32_t targetSex = static_cast(extraCopy.sexId); for (uint32_t r = 0; r < csDbc->getRecordCount(); r++) { uint32_t raceId = csDbc->getUInt32(r, csF.raceId); uint32_t sexId = csDbc->getUInt32(r, csF.sexId); if (raceId != targetRace || sexId != targetSex) continue; uint32_t section = csDbc->getUInt32(r, csF.baseSection); if (section != 3) continue; uint32_t variation = csDbc->getUInt32(r, csF.variationIndex); uint32_t colorIdx = csDbc->getUInt32(r, csF.colorIndex); if (variation != static_cast(extraCopy.hairStyleId)) continue; if (colorIdx != static_cast(extraCopy.hairColorId)) continue; def.hairTexturePath = csDbc->getString(r, csF.texture1); break; } if (!def.hairTexturePath.empty()) { allPaths.push_back(def.hairTexturePath); } else if (def.hasBakedSkin && !def.hairTextureSlots.empty()) { def.useBakedForHair = true; // bakedSkinPath already in allPaths } } } // --- Pre-decode all BLP textures on this background thread --- for (const auto& path : allPaths) { 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; }); asyncNpcCompositeLoads_.push_back(std::move(load)); hasHumanoidTexture = true; // skip non-humanoid skin block } else { LOG_WARNING(" extraDisplayId ", dispData.extraDisplayId, " not found in humanoidExtraMap"); } } // Apply creature skin textures (for non-humanoid creatures) if (!hasHumanoidTexture && modelData) { auto resolveCreatureSkinPath = [&](const std::string& skinField) -> std::string { if (skinField.empty()) return ""; std::string raw = skinField; std::replace(raw.begin(), raw.end(), '/', '\\'); auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; }; raw.erase(raw.begin(), std::find_if(raw.begin(), raw.end(), [&](unsigned char c) { return !isSpace(c); })); raw.erase(std::find_if(raw.rbegin(), raw.rend(), [&](unsigned char c) { return !isSpace(c); }).base(), raw.end()); if (raw.empty()) return ""; 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"; }; auto addCandidate = [](std::vector& out, const std::string& p) { if (p.empty()) return; if (std::find(out.begin(), out.end(), p) == out.end()) out.push_back(p); }; std::vector candidates; const bool hasDir = (raw.find('\\') != std::string::npos || raw.find('/') != std::string::npos); const bool hasExt = hasBlpExt(raw); if (hasDir) { addCandidate(candidates, raw); if (!hasExt) addCandidate(candidates, raw + ".blp"); } else { addCandidate(candidates, modelDir + raw); if (!hasExt) addCandidate(candidates, modelDir + raw + ".blp"); addCandidate(candidates, raw); if (!hasExt) addCandidate(candidates, raw + ".blp"); } for (const auto& c : candidates) { if (assetManager_->fileExists(c)) return c; } return ""; }; for (size_t ti = 0; ti < modelData->textures.size(); ti++) { const auto& tex = modelData->textures[ti]; std::string skinPath; // Creature skin types: 11 = skin1, 12 = skin2, 13 = skin3 if (tex.type == 11 && !dispData.skin1.empty()) { skinPath = resolveCreatureSkinPath(dispData.skin1); } else if (tex.type == 12 && !dispData.skin2.empty()) { skinPath = resolveCreatureSkinPath(dispData.skin2); } else if (tex.type == 13 && !dispData.skin3.empty()) { skinPath = resolveCreatureSkinPath(dispData.skin3); } if (!skinPath.empty()) { rendering::VkTexture* skinTex = charRenderer->loadTexture(skinPath); if (skinTex) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); LOG_DEBUG("Applied creature skin texture: ", skinPath, " to slot ", ti); } } else if ((tex.type == 11 && !dispData.skin1.empty()) || (tex.type == 12 && !dispData.skin2.empty()) || (tex.type == 13 && !dispData.skin3.empty())) { LOG_WARNING("Creature skin texture not found for displayId ", displayId, " slot ", ti, " type ", tex.type, " (skin fields: '", dispData.skin1, "', '", dispData.skin2, "', '", dispData.skin3, "')"); } } } // Clear pre-decoded cache after applying all display textures charRenderer->setPredecodedBLPCache(nullptr); displayIdPredecodedTextures_.erase(displayId); { auto texEnd = std::chrono::steady_clock::now(); float texMs = std::chrono::duration(texEnd - texStart).count(); if (texMs > 50.0f) { LOG_WARNING("spawnCreature texture setup took ", texMs, "ms displayId=", displayId, " hasPreDec=", hasPreDec, " extra=", dispData.extraDisplayId); } } } // Use the entity's latest server-authoritative position rather than the stale spawn // position. Movement packets (SMSG_MONSTER_MOVE) can arrive while a creature is still // queued in pendingCreatureSpawns_ and get silently dropped. getLatestX/Y/Z returns // the movement destination if the entity is mid-move, which is always up-to-date // regardless of distance culling (unlike getX/Y/Z which requires updateMovement). if (gameHandler_) { if (auto entity = gameHandler_->getEntityManager().getEntity(guid)) { x = entity->getLatestX(); y = entity->getLatestY(); z = entity->getLatestZ(); orientation = entity->getOrientation(); } } // Convert canonical → render coordinates glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); // Keep authoritative server Z for online creature spawns. // Terrain-based lifting can incorrectly move elevated NPCs (e.g. flight masters on // Stormwind ramparts) to bad heights relative to WMO geometry. // Convert canonical WoW orientation (0=north) -> render yaw (0=west) float renderYaw = orientation + glm::radians(90.0f); // Create instance (apply server-provided scale from OBJECT_FIELD_SCALE_X) uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), scale); if (instanceId == 0) { LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec); return; } // Per-instance hair/skin texture overrides — runs for ALL NPCs (including cached models) // so that each NPC gets its own hair/skin color regardless of model sharing. // Uses pre-built CharSections cache (O(1) lookup instead of O(N) DBC scan). { if (!charSectionsCacheBuilt_) buildCharSectionsCache(); auto itDD = displayDataMap_.find(displayId); if (itDD != displayDataMap_.end() && itDD->second.extraDisplayId != 0) { auto itExtra2 = humanoidExtraMap_.find(itDD->second.extraDisplayId); if (itExtra2 != humanoidExtraMap_.end()) { const auto& extra = itExtra2->second; const auto* md = charRenderer->getModelData(modelId); if (md) { // Look up hair texture (section 3) via cache rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); std::string hairPath = lookupCharSection( extra.raceId, extra.sexId, 3, extra.hairStyleId, extra.hairColorId, 0); if (!hairPath.empty()) { rendering::VkTexture* hairTex = charRenderer->loadTexture(hairPath); if (hairTex && hairTex != whiteTex) { for (size_t ti = 0; ti < md->textures.size(); ti++) { if (md->textures[ti].type == 6) { charRenderer->setTextureSlotOverride(instanceId, static_cast(ti), hairTex); } } } } // Look up skin texture (section 0) for per-instance skin color. // Skip when the NPC has a baked texture or composited equipment — // those already encode armor over skin and must not be replaced. bool hasEquipOrBake = !extra.bakeName.empty(); if (!hasEquipOrBake) { for (int s = 0; s < 11 && !hasEquipOrBake; s++) if (extra.equipDisplayId[s] != 0) hasEquipOrBake = true; } if (!hasEquipOrBake) { std::string skinPath = lookupCharSection( extra.raceId, extra.sexId, 0, 0, extra.skinId, 0); if (!skinPath.empty()) { rendering::VkTexture* skinTex = charRenderer->loadTexture(skinPath); if (skinTex) { for (size_t ti = 0; ti < md->textures.size(); ti++) { uint32_t tt = md->textures[ti].type; if (tt == 1 || tt == 11) { charRenderer->setTextureSlotOverride(instanceId, static_cast(ti), skinTex); } } } } } } } } } // Optional humanoid NPC geoset mask. Disabled by default because forcing geosets // causes long-standing visual artifacts on some models (missing waist, phantom // bracers, flickering apron overlays). Prefer model defaults. static constexpr bool kEnableNpcSafeGeosetMask = false; if (kEnableNpcSafeGeosetMask && itDisplayData != displayDataMap_.end() && itDisplayData->second.extraDisplayId != 0) { auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); if (itExtra != humanoidExtraMap_.end()) { const auto& extra = itExtra->second; std::unordered_set safeGeosets; std::unordered_set modelGeosets; std::unordered_map firstGeosetByGroup; if (const auto* md = charRenderer->getModelData(modelId)) { for (const auto& b : md->batches) { const uint16_t sid = b.submeshId; modelGeosets.insert(sid); const uint16_t group = static_cast(sid / 100); auto it = firstGeosetByGroup.find(group); if (it == firstGeosetByGroup.end() || sid < it->second) { firstGeosetByGroup[group] = sid; } } } auto addSafeGeoset = [&](uint16_t preferredId) { if (preferredId < 100 || modelGeosets.empty()) { safeGeosets.insert(preferredId); return; } if (modelGeosets.count(preferredId) > 0) { safeGeosets.insert(preferredId); return; } const uint16_t group = static_cast(preferredId / 100); auto it = firstGeosetByGroup.find(group); if (it != firstGeosetByGroup.end()) { safeGeosets.insert(it->second); } }; uint16_t hairGeoset = 1; uint32_t hairKey = (static_cast(extra.raceId) << 16) | (static_cast(extra.sexId) << 8) | static_cast(extra.hairStyleId); auto itHairGeo = hairGeosetMap_.find(hairKey); if (itHairGeo != hairGeosetMap_.end() && itHairGeo->second > 0) { hairGeoset = itHairGeo->second; } const uint16_t selectedHairScalp = (hairGeoset > 0 ? hairGeoset : 1); std::unordered_set hairScalpGeosetsForRaceSex; for (const auto& [k, v] : hairGeosetMap_) { uint8_t race = static_cast((k >> 16) & 0xFF); uint8_t sex = static_cast((k >> 8) & 0xFF); if (race == extra.raceId && sex == extra.sexId && v > 0 && v < 100) { hairScalpGeosetsForRaceSex.insert(v); } } // Group 0 contains both base body parts and race/sex hair scalp variants. // Keep all non-hair body submeshes, but only the selected hair scalp. for (uint16_t sid : modelGeosets) { if (sid >= 100) continue; if (hairScalpGeosetsForRaceSex.count(sid) > 0 && sid != selectedHairScalp) continue; safeGeosets.insert(sid); } safeGeosets.insert(selectedHairScalp); addSafeGeoset(static_cast(100 + std::max(hairGeoset, 1))); uint32_t facialKey = (static_cast(extra.raceId) << 16) | (static_cast(extra.sexId) << 8) | static_cast(extra.facialHairId); auto itFacial = facialHairGeosetMap_.find(facialKey); if (itFacial != facialHairGeosetMap_.end()) { const auto& fhg = itFacial->second; addSafeGeoset(static_cast(200 + std::max(fhg.geoset200, 1))); addSafeGeoset(static_cast(300 + std::max(fhg.geoset300, 1))); } else { addSafeGeoset(201); addSafeGeoset(301); } // Force pants (1301) and avoid robe skirt variants unless we re-enable full slot-accurate geosets. addSafeGeoset(301); addSafeGeoset(kGeosetBareForearms); addSafeGeoset(402); addSafeGeoset(501); addSafeGeoset(701); addSafeGeoset(kGeosetBareSleeves); addSafeGeoset(901); addSafeGeoset(kGeosetDefaultTabard); addSafeGeoset(kGeosetBarePants); addSafeGeoset(kGeosetBareFeet); charRenderer->setActiveGeosets(instanceId, safeGeosets); } } // NOTE: Custom humanoid NPC geoset/equipment overrides are currently too // aggressive and can make NPCs invisible (targetable but not rendered). // Keep default model geosets for online creatures until this path is made // data-accurate per display model. static constexpr bool kEnableNpcHumanoidOverrides = false; // Set geosets for humanoid NPCs based on CreatureDisplayInfoExtra if (kEnableNpcHumanoidOverrides && itDisplayData != displayDataMap_.end() && itDisplayData->second.extraDisplayId != 0) { auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); if (itExtra != humanoidExtraMap_.end()) { const auto& extra = itExtra->second; std::unordered_set activeGeosets; // Group 0: body base (id=0 always) + hair scalp mesh from CharHairGeosets.dbc activeGeosets.insert(0); // Body base mesh // Hair: CharHairGeosets.dbc maps (race, sex, hairStyleId) → group 0 scalp submeshId uint32_t hairKey = (static_cast(extra.raceId) << 16) | (static_cast(extra.sexId) << 8) | static_cast(extra.hairStyleId); auto itHairGeo = hairGeosetMap_.find(hairKey); uint16_t hairScalpId = (itHairGeo != hairGeosetMap_.end()) ? itHairGeo->second : 0; if (hairScalpId > 0) { activeGeosets.insert(hairScalpId); // Group 0 scalp/hair mesh activeGeosets.insert(static_cast(100 + hairScalpId)); // Group 1 connector (if exists) } else { // Bald (geosetId=0): body base has a hole at the crown, so include // submeshId=1 (bald scalp cap with body skin texture) to cover it. activeGeosets.insert(1); // Group 0 bald scalp mesh activeGeosets.insert(kGeosetDefaultConnector); // Group 1 connector } uint16_t hairGeoset = (hairScalpId > 0) ? hairScalpId : 1; // Facial hair geosets from CharFacialHairStyles.dbc lookup uint32_t facialKey = (static_cast(extra.raceId) << 16) | (static_cast(extra.sexId) << 8) | static_cast(extra.facialHairId); auto itFacial = facialHairGeosetMap_.find(facialKey); if (itFacial != facialHairGeosetMap_.end()) { const auto& fhg = itFacial->second; // DBC values are variation indices within each group; add group base activeGeosets.insert(static_cast(100 + std::max(fhg.geoset100, static_cast(1)))); activeGeosets.insert(static_cast(300 + std::max(fhg.geoset300, static_cast(1)))); activeGeosets.insert(static_cast(200 + std::max(fhg.geoset200, static_cast(1)))); } else { activeGeosets.insert(kGeosetDefaultConnector); // Default group 1: no extra activeGeosets.insert(201); // Default group 2: no facial hair activeGeosets.insert(301); // Default group 3: no facial hair } // Default equipment geosets (bare/no armor) // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 12=tabard, 13=pants std::unordered_set modelGeosets; std::unordered_map firstByGroup; if (const auto* md = charRenderer->getModelData(modelId)) { for (const auto& b : md->batches) { const uint16_t sid = b.submeshId; modelGeosets.insert(sid); const uint16_t group = static_cast(sid / 100); auto it = firstByGroup.find(group); if (it == firstByGroup.end() || sid < it->second) { firstByGroup[group] = sid; } } } auto pickGeoset = [&](uint16_t preferred, uint16_t group) -> uint16_t { if (preferred != 0 && modelGeosets.count(preferred) > 0) return preferred; auto it = firstByGroup.find(group); if (it != firstByGroup.end()) return it->second; return preferred; }; uint16_t geosetGloves = pickGeoset(kGeosetBareForearms, 4); uint16_t geosetBoots = pickGeoset(kGeosetBareShins, 5); uint16_t geosetSleeves = pickGeoset(kGeosetBareSleeves, 8); uint16_t geosetPants = pickGeoset(kGeosetBarePants, 13); uint16_t geosetCape = 0; // Group 15 disabled unless cape is equipped uint16_t geosetTabard = pickGeoset(kGeosetDefaultTabard, 12); uint16_t geosetBelt = 0; // Group 18 disabled unless belt is equipped rendering::VkTexture* npcCapeTextureId = nullptr; // Load equipment geosets from ItemDisplayInfo.dbc // DBC columns: 7=GeosetGroup[0], 8=GeosetGroup[1], 9=GeosetGroup[2] auto itemDisplayDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; if (itemDisplayDbc) { // Equipment slots: 0=helm, 1=shoulder, 2=shirt, 3=chest, 4=belt, 5=legs, 6=feet, 7=wrist, 8=hands, 9=tabard, 10=cape const uint32_t fGG1 = idiL ? (*idiL)["GeosetGroup1"] : 7; auto readGeosetGroup = [&](int slot, const char* slotName) -> uint32_t { uint32_t did = extra.equipDisplayId[slot]; if (did == 0) return 0; int32_t idx = itemDisplayDbc->findRecordById(did); if (idx < 0) { LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc"); return 0; } uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg); return gg; }; // Chest (slot 3) → group 8 (sleeves/wristbands) { uint32_t gg = readGeosetGroup(3, "chest"); if (gg > 0) geosetSleeves = pickGeoset(static_cast(kGeosetBareSleeves + gg), 8); } // Legs (slot 5) → group 13 (trousers) { uint32_t gg = readGeosetGroup(5, "legs"); if (gg > 0) geosetPants = pickGeoset(static_cast(kGeosetBarePants + gg), 13); } // Feet (slot 6) → group 5 (boots/shins) { uint32_t gg = readGeosetGroup(6, "feet"); if (gg > 0) geosetBoots = pickGeoset(static_cast(501 + gg), 5); } // Hands (slot 8) → group 4 (gloves/forearms) { uint32_t gg = readGeosetGroup(8, "hands"); if (gg > 0) geosetGloves = pickGeoset(static_cast(kGeosetBareForearms + gg), 4); } // Wrists (slot 7) → group 8 (sleeves, only if chest didn't set it) { uint32_t gg = readGeosetGroup(7, "wrist"); if (gg > 0 && geosetSleeves == pickGeoset(kGeosetBareSleeves, 8)) geosetSleeves = pickGeoset(static_cast(kGeosetBareSleeves + gg), 8); } // Belt (slot 4) → group 18 (buckle) { uint32_t gg = readGeosetGroup(4, "belt"); if (gg > 0) geosetBelt = static_cast(1801 + gg); } // Tabard (slot 9) → group 12 (tabard/robe mesh) { uint32_t gg = readGeosetGroup(9, "tabard"); if (gg > 0) geosetTabard = pickGeoset(static_cast(1200 + gg), 12); } // Cape (slot 10) → group 15 if (extra.equipDisplayId[10] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[10]); if (idx >= 0) { geosetCape = kGeosetWithCape; const bool npcIsFemale = (extra.sexId == 1); const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; std::vector capeNames; auto addName = [&](const std::string& n) { if (!n.empty() && std::find(capeNames.begin(), capeNames.end(), n) == capeNames.end()) { capeNames.push_back(n); } }; std::string leftName = itemDisplayDbc->getString(static_cast(idx), leftTexField); addName(leftName); 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"; }; 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); } }; for (const auto& nameRaw : capeNames) { std::string name = nameRaw; std::replace(name.begin(), name.end(), '/', '\\'); const bool hasDir = (name.find('\\') != std::string::npos); const bool hasExt = hasBlpExt(name); if (hasDir) { addCapeCandidate(name); if (!hasExt) addCapeCandidate(name + ".blp"); } else { std::string baseObj = "Item\\ObjectComponents\\Cape\\" + name; std::string baseTex = "Item\\TextureComponents\\Cape\\" + name; addCapeCandidate(baseObj); addCapeCandidate(baseTex); if (!hasExt) { addCapeCandidate(baseObj + ".blp"); addCapeCandidate(baseTex + ".blp"); } addCapeCandidate(baseObj + (npcIsFemale ? "_F.blp" : "_M.blp")); addCapeCandidate(baseObj + "_U.blp"); addCapeCandidate(baseTex + (npcIsFemale ? "_F.blp" : "_M.blp")); addCapeCandidate(baseTex + "_U.blp"); } } const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); for (const auto& candidate : capeCandidates) { rendering::VkTexture* tex = charRenderer->loadTexture(candidate); if (tex && tex != whiteTex) { npcCapeTextureId = tex; break; } } } } } // Apply equipment geosets activeGeosets.insert(geosetGloves); activeGeosets.insert(geosetBoots); activeGeosets.insert(geosetSleeves); activeGeosets.insert(geosetPants); if (geosetCape != 0) { activeGeosets.insert(geosetCape); } if (geosetTabard != 0) { activeGeosets.insert(geosetTabard); } if (geosetBelt != 0) { activeGeosets.insert(geosetBelt); } activeGeosets.insert(pickGeoset(kGeosetDefaultEars, 7)); activeGeosets.insert(pickGeoset(kGeosetDefaultKneepads, 9)); activeGeosets.insert(pickGeoset(kGeosetBareFeet, 20)); // Keep all model-present torso variants active to avoid missing male // abdomen/waist sections when a single 5xx pick is wrong. for (uint16_t sid : modelGeosets) { if ((sid / 100) == 5) activeGeosets.insert(sid); } // Keep all model-present pelvis variants active to avoid missing waist/belt // sections on some humanoid males when a single 9xx variant is wrong. for (uint16_t sid : modelGeosets) { if ((sid / 100) == 9) activeGeosets.insert(sid); } // Hide hair under helmets: replace style-specific scalp with bald scalp if (extra.equipDisplayId[0] != 0 && hairGeoset > 1) { activeGeosets.erase(hairGeoset); // Remove style scalp activeGeosets.erase(static_cast(100 + hairGeoset)); // Remove style group 1 activeGeosets.insert(1); // Bald scalp cap (group 0) activeGeosets.insert(kGeosetDefaultConnector); // Default group 1 connector } charRenderer->setActiveGeosets(instanceId, activeGeosets); if (geosetCape != 0 && npcCapeTextureId) { charRenderer->setGroupTextureOverride(instanceId, 15, npcCapeTextureId); if (const auto* md = charRenderer->getModelData(modelId)) { for (size_t ti = 0; ti < md->textures.size(); ti++) { if (md->textures[ti].type == 2) { charRenderer->setTextureSlotOverride(instanceId, static_cast(ti), npcCapeTextureId); } } } } LOG_DEBUG("Set humanoid geosets: hair=", static_cast(hairGeoset), " sleeves=", geosetSleeves, " pants=", geosetPants, " boots=", geosetBoots, " gloves=", geosetGloves); // NOTE: NPC helmet attachment with fallback logic to use bone 0 if attachment // point 11 is missing. This improves compatibility with models that don't have // attachment 11 explicitly defined. static constexpr bool kEnableNpcHelmetAttachmentsMainPath = true; // Load and attach helmet model if equipped if (kEnableNpcHelmetAttachmentsMainPath && extra.equipDisplayId[0] != 0 && itemDisplayDbc) { int32_t helmIdx = itemDisplayDbc->findRecordById(extra.equipDisplayId[0]); if (helmIdx >= 0) { // Get helmet model name from ItemDisplayInfo.dbc (LeftModel) std::string helmModelName = itemDisplayDbc->getString(static_cast(helmIdx), idiL ? (*idiL)["LeftModel"] : 1); if (!helmModelName.empty()) { // Convert .mdx to .m2 size_t dotPos = helmModelName.rfind('.'); if (dotPos != std::string::npos) { helmModelName = helmModelName.substr(0, dotPos); } // WoW helmet M2 files have per-race/gender variants with a suffix // e.g. Helm_Plate_B_01Stormwind_HuM.M2 for Human Male // ChrRaces.dbc ClientPrefix values (raceId → prefix): 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 = (extra.sexId == 0) ? "M" : "F"; std::string raceSuffix; auto itRace = racePrefix.find(extra.raceId); if (itRace != racePrefix.end()) { raceSuffix = "_" + itRace->second + genderSuffix; } // Try race/gender-specific variant first, then base name std::string helmPath; std::vector helmData; if (!raceSuffix.empty()) { helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; helmData = assetManager_->readFile(helmPath); } if (helmData.empty()) { helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; helmData = assetManager_->readFile(helmPath); } if (!helmData.empty()) { auto helmModel = pipeline::M2Loader::load(helmData); // Load skin (only for WotLK M2s) std::string skinPath = helmPath.substr(0, helmPath.size() - 3) + "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && helmModel.version >= 264) { pipeline::M2Loader::loadSkin(skinData, helmModel); } if (helmModel.isValid()) { // Attachment point 11 = Head uint32_t helmModelId = nextCreatureModelId_++; // Get texture from ItemDisplayInfo (LeftModelTexture) std::string helmTexName = itemDisplayDbc->getString(static_cast(helmIdx), idiL ? (*idiL)["LeftModelTexture"] : 3); std::string helmTexPath; if (!helmTexName.empty()) { // Try race/gender suffixed texture first 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"; } } bool attached = charRenderer->attachWeapon(instanceId, 0, helmModel, helmModelId, helmTexPath); if (!attached) { attached = charRenderer->attachWeapon(instanceId, 11, helmModel, helmModelId, helmTexPath); } if (attached) { LOG_DEBUG("Attached helmet model: ", helmPath, " tex: ", helmTexPath); } } } } } } // NPC shoulder attachment: slot 1 = shoulder in the NPC equipment array. // Shoulders have TWO M2 models (left + right) at attachment points 5 and 6. if (extra.equipDisplayId[1] != 0) { int32_t shoulderIdx = itemDisplayDbc->findRecordById(extra.equipDisplayId[1]); if (shoulderIdx >= 0) { const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; const uint32_t rightModelField = idiL ? (*idiL)["RightModel"] : 2u; const uint32_t leftTexFieldS = idiL ? (*idiL)["LeftModelTexture"] : 3u; const uint32_t rightTexFieldS = idiL ? (*idiL)["RightModelTexture"] : 4u; 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 = (extra.sexId == 0) ? "M" : "F"; std::string raceSuffix; { auto itRace = shoulderRacePrefix.find(extra.raceId); if (itRace != shoulderRacePrefix.end()) { raceSuffix = "_" + itRace->second + genderSuffix; } } // Left shoulder (attachment point 5) using LeftModel std::string leftModelName = itemDisplayDbc->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; std::vector leftData; if (!raceSuffix.empty()) { leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + raceSuffix + ".m2"; leftData = assetManager_->readFile(leftPath); } if (leftData.empty()) { leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + ".m2"; leftData = assetManager_->readFile(leftPath); } if (!leftData.empty()) { auto leftModel = pipeline::M2Loader::load(leftData); std::string skinPath = leftPath.substr(0, leftPath.size() - 3) + "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && leftModel.version >= 264) { pipeline::M2Loader::loadSkin(skinData, leftModel); } if (leftModel.isValid()) { uint32_t leftModelId = nextCreatureModelId_++; std::string leftTexName = itemDisplayDbc->getString(static_cast(shoulderIdx), leftTexFieldS); 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(instanceId, 5, leftModel, leftModelId, leftTexPath); if (attached) { LOG_DEBUG("NPC attached left shoulder: ", leftPath, " tex: ", leftTexPath); } } } } // Right shoulder (attachment point 6) using RightModel std::string rightModelName = itemDisplayDbc->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; std::vector rightData; if (!raceSuffix.empty()) { rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + raceSuffix + ".m2"; rightData = assetManager_->readFile(rightPath); } if (rightData.empty()) { rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + ".m2"; rightData = assetManager_->readFile(rightPath); } if (!rightData.empty()) { auto rightModel = pipeline::M2Loader::load(rightData); std::string skinPath = rightPath.substr(0, rightPath.size() - 3) + "00.skin"; auto skinData = assetManager_->readFile(skinPath); if (!skinData.empty() && rightModel.version >= 264) { pipeline::M2Loader::loadSkin(skinData, rightModel); } if (rightModel.isValid()) { uint32_t rightModelId = nextCreatureModelId_++; std::string rightTexName = itemDisplayDbc->getString(static_cast(shoulderIdx), rightTexFieldS); 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(instanceId, 6, rightModel, rightModelId, rightTexPath); if (attached) { LOG_DEBUG("NPC attached right shoulder: ", rightPath, " tex: ", rightTexPath); } } } } } } } } // With full humanoid overrides disabled, some character-style NPC models still render // conflicting clothing geosets at once (global capes, robe skirts over trousers). // Normalize only clothing groups while leaving all other model batches untouched. if (const auto* md = charRenderer->getModelData(modelId)) { std::unordered_set allGeosets; std::unordered_map firstByGroup; bool hasGroup3 = false; // glove/forearm variants bool hasGroup4 = false; // glove/forearm variants (some models) bool hasGroup8 = false; // sleeve/wrist variants bool hasGroup12 = false; // tabard variants bool hasGroup13 = false; // trousers/robe skirt variants bool hasGroup15 = false; // cloak variants for (const auto& b : md->batches) { const uint16_t sid = b.submeshId; const uint16_t group = static_cast(sid / 100); allGeosets.insert(sid); auto itFirst = firstByGroup.find(group); if (itFirst == firstByGroup.end() || sid < itFirst->second) { firstByGroup[group] = sid; } if (group == 3) hasGroup3 = true; if (group == 4) hasGroup4 = true; if (group == 8) hasGroup8 = true; if (group == 12) hasGroup12 = true; if (group == 13) hasGroup13 = true; if (group == 15) hasGroup15 = true; } // Only apply to humanoid-like clothing models. if (hasGroup3 || hasGroup4 || hasGroup8 || hasGroup12 || hasGroup13 || hasGroup15) { bool hasRenderableCape = false; bool hasEquippedTabard = false; bool hasHumanoidExtra = false; uint8_t extraRaceId = 0; uint8_t extraSexId = 0; uint16_t selectedHairScalp = 1; uint16_t selectedFacial200 = 200; uint16_t selectedFacial300 = 300; uint16_t selectedFacial300Alt = 300; bool wantsFacialHair = false; uint32_t equipChestGG = 0, equipLegsGG = 0, equipFeetGG = 0; std::unordered_set hairScalpGeosetsForRaceSex; if (itDisplayData != displayDataMap_.end() && itDisplayData->second.extraDisplayId != 0) { auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); if (itExtra != humanoidExtraMap_.end()) { hasHumanoidExtra = true; extraRaceId = itExtra->second.raceId; extraSexId = itExtra->second.sexId; hasEquippedTabard = (itExtra->second.equipDisplayId[9] != 0); uint32_t hairKey = (static_cast(extraRaceId) << 16) | (static_cast(extraSexId) << 8) | static_cast(itExtra->second.hairStyleId); auto itHairGeo = hairGeosetMap_.find(hairKey); if (itHairGeo != hairGeosetMap_.end() && itHairGeo->second > 0) { selectedHairScalp = itHairGeo->second; } uint32_t facialKey = (static_cast(extraRaceId) << 16) | (static_cast(extraSexId) << 8) | static_cast(itExtra->second.facialHairId); wantsFacialHair = (itExtra->second.facialHairId != 0); auto itFacial = facialHairGeosetMap_.find(facialKey); if (itFacial != facialHairGeosetMap_.end()) { selectedFacial200 = static_cast(200 + itFacial->second.geoset200); selectedFacial300 = static_cast(300 + itFacial->second.geoset300); selectedFacial300Alt = static_cast(300 + itFacial->second.geoset200); } for (const auto& [k, v] : hairGeosetMap_) { uint8_t race = static_cast((k >> 16) & 0xFF); uint8_t sex = static_cast((k >> 8) & 0xFF); if (race == extraRaceId && sex == extraSexId && v > 0 && v < 100) { hairScalpGeosetsForRaceSex.insert(v); } } auto itemDisplayDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; uint32_t capeDisplayId = itExtra->second.equipDisplayId[10]; if (capeDisplayId != 0 && itemDisplayDbc) { int32_t recIdx = itemDisplayDbc->findRecordById(capeDisplayId); if (recIdx >= 0) { const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; const uint32_t rightTexField = idiL ? (*idiL)["RightModelTexture"] : 4u; std::vector capeNames; auto addName = [&](const std::string& n) { if (!n.empty() && std::find(capeNames.begin(), capeNames.end(), n) == capeNames.end()) { capeNames.push_back(n); } }; addName(itemDisplayDbc->getString(static_cast(recIdx), leftTexField)); addName(itemDisplayDbc->getString(static_cast(recIdx), rightTexField)); 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 npcIsFemale = (itExtra->second.sexId == 1); std::vector candidates; auto addCandidate = [&](const std::string& p) { if (p.empty()) return; if (std::find(candidates.begin(), candidates.end(), p) == candidates.end()) { candidates.push_back(p); } }; for (const auto& raw : capeNames) { std::string name = raw; std::replace(name.begin(), name.end(), '/', '\\'); const bool hasDir = (name.find('\\') != std::string::npos); const bool hasExt = hasBlpExt(name); if (hasDir) { addCandidate(name); if (!hasExt) addCandidate(name + ".blp"); } else { std::string baseObj = "Item\\ObjectComponents\\Cape\\" + name; std::string baseTex = "Item\\TextureComponents\\Cape\\" + name; addCandidate(baseObj); addCandidate(baseTex); if (!hasExt) { addCandidate(baseObj + ".blp"); addCandidate(baseTex + ".blp"); } addCandidate(baseObj + (npcIsFemale ? "_F.blp" : "_M.blp")); addCandidate(baseObj + "_U.blp"); addCandidate(baseTex + (npcIsFemale ? "_F.blp" : "_M.blp")); addCandidate(baseTex + "_U.blp"); } } for (const auto& p : candidates) { if (assetManager_->fileExists(p)) { hasRenderableCape = true; break; } } } } // Read GeosetGroup1 from equipment to drive clothed mesh selection if (itemDisplayDbc) { const uint32_t fGG1 = idiL ? (*idiL)["GeosetGroup1"] : 7; auto readGG = [&](uint32_t did) -> uint32_t { if (did == 0) return 0; int32_t idx = itemDisplayDbc->findRecordById(did); return (idx >= 0) ? itemDisplayDbc->getUInt32(static_cast(idx), fGG1) : 0; }; equipChestGG = readGG(itExtra->second.equipDisplayId[3]); if (equipChestGG == 0) equipChestGG = readGG(itExtra->second.equipDisplayId[2]); // shirt fallback equipLegsGG = readGG(itExtra->second.equipDisplayId[5]); equipFeetGG = readGG(itExtra->second.equipDisplayId[6]); } } } std::unordered_set normalizedGeosets; for (uint16_t sid : allGeosets) { const uint16_t group = static_cast(sid / 100); if (group == 3 || group == 4 || group == 8 || group == 12 || group == 13 || group == 15) continue; // Some humanoid models carry cloak cloth in group 16. Strip this too // when no cape is equipped to avoid "everyone has a cape". if (!hasRenderableCape && group == 16) continue; // Group 0 can contain multiple scalp/hair meshes. Keep only the selected // race/sex/style scalp to avoid overlapping broken hair. if (hasHumanoidExtra && sid < 100 && hairScalpGeosetsForRaceSex.count(sid) > 0 && sid != selectedHairScalp) { continue; } // Group 1 contains connector variants that mirror scalp style. if (hasHumanoidExtra && group == 1) { const uint16_t selectedConnector = static_cast(100 + std::max(selectedHairScalp, 1)); if (sid != selectedConnector) { // Keep fallback connector only when selected one does not exist on this model. if (sid != 101 || allGeosets.count(selectedConnector) > 0) { continue; } } } // Group 2 facial variants: keep selected variant; fallback only if missing. if (hasHumanoidExtra && group == 2) { if (!wantsFacialHair) { continue; } if (sid != selectedFacial200) { if (sid != 200 && sid != 201) { continue; } if (allGeosets.count(selectedFacial200) > 0) { continue; } } } normalizedGeosets.insert(sid); } auto pickFromGroup = [&](uint16_t preferredSid, uint16_t group) -> uint16_t { if (allGeosets.count(preferredSid) > 0) return preferredSid; auto it = firstByGroup.find(group); if (it != firstByGroup.end()) return it->second; return 0; }; // Intentionally do not add group 3 (glove/forearm accessory meshes). // Even "bare" variants can produce unwanted looped arm geometry on NPCs. if (hasGroup4) { uint16_t wantBoots = (equipFeetGG > 0) ? static_cast(400 + equipFeetGG) : kGeosetBareForearms; uint16_t bootsSid = pickFromGroup(wantBoots, 4); if (bootsSid != 0) normalizedGeosets.insert(bootsSid); } // Add sleeve/wrist meshes when chest armor calls for them. if (hasGroup8 && equipChestGG > 0) { uint16_t wantSleeves = static_cast(800 + equipChestGG); uint16_t sleeveSid = pickFromGroup(wantSleeves, 8); if (sleeveSid != 0) normalizedGeosets.insert(sleeveSid); } // Show tabard mesh only when CreatureDisplayInfoExtra equips one. if (hasGroup12 && hasEquippedTabard) { uint16_t wantTabard = kGeosetDefaultTabard; // Default fallback // Try to read tabard geoset variant from ItemDisplayInfo.dbc (slot 9) if (hasHumanoidExtra && itDisplayData != displayDataMap_.end() && itDisplayData->second.extraDisplayId != 0) { auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); if (itExtra != humanoidExtraMap_.end()) { uint32_t tabardDisplayId = itExtra->second.equipDisplayId[9]; if (tabardDisplayId != 0) { auto itemDisplayDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; if (itemDisplayDbc && idiL) { int32_t tabardIdx = itemDisplayDbc->findRecordById(tabardDisplayId); if (tabardIdx >= 0) { // Get geoset variant from ItemDisplayInfo GeosetGroup1 field const uint32_t ggField = (*idiL)["GeosetGroup1"]; uint32_t tabardGG = itemDisplayDbc->getUInt32(static_cast(tabardIdx), ggField); if (tabardGG > 0) { wantTabard = static_cast(1200 + tabardGG); } } } } } } uint16_t tabardSid = pickFromGroup(wantTabard, 12); if (tabardSid != 0) normalizedGeosets.insert(tabardSid); } // Some mustache/goatee variants are authored in facial group 3xx. // Re-add selected facial 3xx plus low-index facial fallbacks. if (hasHumanoidExtra && wantsFacialHair) { // Prefer alt channel first (often chin-beard), then primary. uint16_t facial300Sid = pickFromGroup(selectedFacial300Alt, 3); if (facial300Sid == 0) facial300Sid = pickFromGroup(selectedFacial300, 3); if (facial300Sid != 0) normalizedGeosets.insert(facial300Sid); if (facial300Sid == 0) { if (allGeosets.count(300) > 0) normalizedGeosets.insert(300); else if (allGeosets.count(301) > 0) normalizedGeosets.insert(301); } } // Prefer trousers geoset; use covered variant when legs armor exists. if (hasGroup13) { uint16_t wantPants = (equipLegsGG > 0) ? static_cast(1300 + equipLegsGG) : kGeosetBarePants; uint16_t pantsSid = pickFromGroup(wantPants, 13); if (pantsSid != 0) normalizedGeosets.insert(pantsSid); } // Prefer explicit cloak variant only when a cape is equipped. if (hasGroup15 && hasRenderableCape) { uint16_t capeSid = pickFromGroup(kGeosetWithCape, 15); if (capeSid != 0) normalizedGeosets.insert(capeSid); } if (!normalizedGeosets.empty()) { charRenderer->setActiveGeosets(instanceId, normalizedGeosets); } } } // Try attaching NPC held weapons; if update fields are not ready yet, // IN_GAME retry loop will attempt again shortly. bool weaponsAttachedNow = tryAttachCreatureVirtualWeapons(guid, instanceId); // Spawn in the correct pose. If the server marked this creature dead before // the queued spawn was processed, start directly in death animation. if (deadCreatureGuids_.count(guid)) { charRenderer->playAnimation(instanceId, rendering::anim::DEATH, false); } else { // Check if this NPC has a persistent emote state (e.g. working, eating, dancing) uint32_t npcEmote = 0; if (gameHandler_) { auto entity = gameHandler_->getEntityManager().getEntity(guid); if (entity && entity->getType() == game::ObjectType::UNIT) { npcEmote = std::static_pointer_cast(entity)->getNpcEmoteState(); } } if (npcEmote != 0 && charRenderer->hasAnimation(instanceId, npcEmote)) { charRenderer->playAnimation(instanceId, npcEmote, true); } else if (charRenderer->hasAnimation(instanceId, rendering::anim::BIRTH)) { // Play birth animation (one-shot) — will return to STAND after charRenderer->playAnimation(instanceId, rendering::anim::BIRTH, false); } else if (charRenderer->hasAnimation(instanceId, rendering::anim::SPAWN)) { charRenderer->playAnimation(instanceId, rendering::anim::SPAWN, false); } else { charRenderer->playAnimation(instanceId, rendering::anim::STAND, true); } } charRenderer->startFadeIn(instanceId, 0.5f); // Track instance creatureInstances_[guid] = instanceId; creatureModelIds_[guid] = modelId; creatureRenderPosCache_[guid] = renderPos; if (weaponsAttachedNow) { creatureWeaponsAttached_.insert(guid); creatureWeaponAttachAttempts_.erase(guid); } else { creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_[guid] = 1; } LOG_DEBUG("Spawned creature: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } } // namespace core } // namespace wowee