From ce6887071cd3bdb5a2d0b027acbfd6844443cabf Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 20 Feb 2026 22:28:07 -0800 Subject: [PATCH] Refine NPC hair and facial geoset selection - select humanoid scalp geoset by race/sex/style and filter overlapping scalp variants\n- constrain group 1 connectors to selected hair connector with safe fallback\n- add facial geoset handling for groups 100/200/300 from CharFacialHairStyles\n- allow selected facial 100 and fallback 100/101 when needed\n- map facial 200/300 directly from DBC indices (supports valid 200/300 variants)\n- keep existing NPC arm accessory suppression and clothing normalization --- src/core/application.cpp | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/core/application.cpp b/src/core/application.cpp index 7a8d113c..37562550 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -4472,11 +4472,47 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x 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 selectedFacial100 = 101; + uint16_t selectedFacial200 = 200; + uint16_t selectedFacial300 = 300; + bool wantsFacialHair = false; + 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()) { + selectedFacial100 = static_cast(100 + itFacial->second.geoset100); + selectedFacial200 = static_cast(200 + itFacial->second.geoset200); + selectedFacial300 = static_cast(300 + itFacial->second.geoset300); + } + 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; @@ -4556,6 +4592,46 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // 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)); + const bool allowSelectedFacial100 = wantsFacialHair && + sid == selectedFacial100 && allGeosets.count(selectedFacial100) > 0; + const bool allowFacialFallback = wantsFacialHair && (sid == 100 || sid == 101); + if (allowSelectedFacial100) { + normalizedGeosets.insert(sid); + continue; + } + if (allowFacialFallback) { + normalizedGeosets.insert(sid); + continue; + } + 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 carries facial hair variants. + if (hasHumanoidExtra && group == 2) { + const bool allowSelectedFacial200 = wantsFacialHair && + sid == selectedFacial200 && allGeosets.count(selectedFacial200) > 0; + const bool allowFacial200Fallback = wantsFacialHair && (sid == 200 || sid == 201); + if (!allowSelectedFacial200 && !allowFacial200Fallback) { + continue; + } + // If selected variant exists, do not keep fallback variants. + if (allowFacial200Fallback && allGeosets.count(selectedFacial200) > 0 && + sid != selectedFacial200) { + continue; + } + } normalizedGeosets.insert(sid); } @@ -4582,6 +4658,13 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (tabardSid != 0) normalizedGeosets.insert(tabardSid); } + // Some mustache/goatee variants are authored in facial group 3xx. + // Re-add only the selected facial 3xx geoset (not generic 3xx arm meshes). + if (hasHumanoidExtra && wantsFacialHair) { + uint16_t facial300Sid = pickFromGroup(selectedFacial300, 3); + if (facial300Sid != 0) normalizedGeosets.insert(facial300Sid); + } + // Prefer trousers geoset, not robe/kilt overlays. if (hasGroup13) { uint16_t pantsSid = pickFromGroup(1301, 13);