diff --git a/src/core/application.cpp b/src/core/application.cpp index 325282a8..0e2f5440 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3606,7 +3606,10 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x case 3: // chest return region <= 4; case 4: // belt - return region == 4; + // TODO(#npc-belt-region): belt torso-lower overlay can + // cut out male abdomen on some humanoid NPCs. + // Keep disabled until region compositing is fixed. + return false; case 5: // legs return region == 5 || region == 6; case 6: // feet @@ -3639,10 +3642,12 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x std::string(npcComponentDirs[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 (assetManager->fileExists(genderPath)) fullPath = genderPath; else if (assetManager->fileExists(unisexPath)) fullPath = unisexPath; - else fullPath = base + ".blp"; + else if (assetManager->fileExists(basePath)) fullPath = basePath; + else continue; npcRegionLayers.emplace_back(region, fullPath); } @@ -3722,12 +3727,13 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Use baked texture for body skin (types 1, 2) // Type 6 (hair) needs its own texture from CharSections.dbc + const bool allowNpcRegionComposite = true; if (!extra.bakeName.empty()) { std::string bakePath = "Textures\\BakedNpcTextures\\" + extra.bakeName; // Composite equipment textures over baked NPC texture, or just load baked texture GLuint finalTex = 0; - if (!npcRegionLayers.empty()) { + if (allowNpcRegionComposite && !npcRegionLayers.empty()) { finalTex = charRenderer->compositeWithRegions(bakePath, {}, npcRegionLayers); LOG_DEBUG("Composited NPC baked texture with ", npcRegionLayers.size(), " equipment regions: ", bakePath); @@ -3802,7 +3808,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x for (const auto& uw : npcUnderwear) skinLayers.push_back(uw); GLuint npcSkinTex = 0; - if (!npcRegionLayers.empty()) { + if (allowNpcRegionComposite && !npcRegionLayers.empty()) { npcSkinTex = charRenderer->compositeWithRegions(npcSkinPath, std::vector(skinLayers.begin() + 1, skinLayers.end()), npcRegionLayers); @@ -3863,27 +3869,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } - // Apply cape texture only to object-skin slots (type 2) so body/face - // textures never bleed onto cloaks. - if (!npcCapeTexturePath.empty() && modelData) { - GLuint capeTex = charRenderer->loadTexture(npcCapeTexturePath); - if (capeTex != 0) { - for (size_t ti = 0; ti < modelData->textures.size(); ti++) { - if (modelData->textures[ti].type == 2) { - charRenderer->setModelTexture(modelId, static_cast(ti), capeTex); - LOG_DEBUG("Applied NPC cape texture to slot ", ti, ": ", npcCapeTexturePath); - } - } - } - } else if (modelData) { - // Hide cloak mesh when no cape texture exists for this NPC. - GLuint hiddenTex = charRenderer->getTransparentTexture(); - for (size_t ti = 0; ti < modelData->textures.size(); ti++) { - if (modelData->textures[ti].type == 2) { - charRenderer->setModelTexture(modelId, static_cast(ti), hiddenTex); - } - } - } + // Do not apply cape textures at model scope here. Type-2 texture slots are + // shared per model and this can leak cape textures/white fallbacks onto + // unrelated humanoid NPCs that use the same modelId. } else { LOG_WARNING(" extraDisplayId ", dispData.extraDisplayId, " not found in humanoidExtraMap"); } @@ -3997,16 +3985,45 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x return; } - // Use a safe humanoid geoset mask to avoid rendering conflicting geosets - // (e.g. robe skirt + pants simultaneously) when model defaults expose all groups. - if (itDisplayData != displayDataMap_.end() && + // 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; - for (uint16_t i = 0; i <= 99; i++) safeGeosets.insert(i); - + 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) | @@ -4015,8 +4032,24 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (itHairGeo != hairGeosetMap_.end() && itHairGeo->second > 0) { hairGeoset = itHairGeo->second; } - safeGeosets.insert(hairGeoset > 0 ? hairGeoset : 1); - safeGeosets.insert(static_cast(100 + std::max(hairGeoset, 1))); + 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) | @@ -4024,23 +4057,25 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x auto itFacial = facialHairGeosetMap_.find(facialKey); if (itFacial != facialHairGeosetMap_.end()) { const auto& fhg = itFacial->second; - safeGeosets.insert(static_cast(200 + std::max(fhg.geoset200, 1))); - safeGeosets.insert(static_cast(300 + std::max(fhg.geoset300, 1))); + addSafeGeoset(static_cast(200 + std::max(fhg.geoset200, 1))); + addSafeGeoset(static_cast(300 + std::max(fhg.geoset300, 1))); } else { - safeGeosets.insert(201); - safeGeosets.insert(301); + addSafeGeoset(201); + addSafeGeoset(301); } // Force pants (1301) and avoid robe skirt variants unless we re-enable full slot-accurate geosets. - safeGeosets.insert(401); - safeGeosets.insert(502); - safeGeosets.insert(701); - safeGeosets.insert(801); - safeGeosets.insert(902); - safeGeosets.insert(1201); - safeGeosets.insert(1301); - safeGeosets.insert(1502); - safeGeosets.insert(2002); + addSafeGeoset(301); + addSafeGeoset(401); + addSafeGeoset(402); + addSafeGeoset(501); + addSafeGeoset(701); + addSafeGeoset(801); + addSafeGeoset(901); + addSafeGeoset(1201); + addSafeGeoset(1301); + addSafeGeoset(2002); + charRenderer->setActiveGeosets(instanceId, safeGeosets); } } @@ -4099,12 +4134,34 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Default equipment geosets (bare/no armor) // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 9=kneepads, 13=pants - uint16_t geosetGloves = 401; // Bare forearms (group 4) - uint16_t geosetBoots = 502; // Bare shins (group 5) - uint16_t geosetSleeves = 801; // Bare wrists (group 8, controlled by chest) - uint16_t geosetPants = 1301; // Bare legs (group 13) - uint16_t geosetCape = 1502; // No cape (group 15) - uint16_t geosetTabard = 1201; // No tabard (group 12) + 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(301, 3); // Bare gloves/forearms (group 3) + uint16_t geosetBoots = pickGeoset(401, 4); // Bare boots/shins (group 4) + uint16_t geosetTorso = pickGeoset(501, 5); // Base torso/waist (group 5) + uint16_t geosetSleeves = pickGeoset(801, 8); // Bare wrists (group 8, controlled by chest) + uint16_t geosetPants = pickGeoset(1301, 13); // Bare legs (group 13) + uint16_t geosetCape = 0; // Group 15 disabled unless cape is equipped + uint16_t geosetTabard = 0; // TODO: NPC tabard geosets currently flicker/apron; keep hidden for now + GLuint npcCapeTextureId = 0; // Load equipment geosets from ItemDisplayInfo.dbc // DBC columns: 7=GeosetGroup[0], 8=GeosetGroup[1], 9=GeosetGroup[2] @@ -4113,17 +4170,17 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x 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; - const uint32_t fGG3 = idiL ? (*idiL)["GeosetGroup3"] : 9; - // Chest (slot 3) → group 8 (sleeves/wristbands) + // Chest (slot 3) → group 5 (torso) + group 8 (sleeves/wristbands) if (extra.equipDisplayId[3] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[3]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetSleeves = static_cast(801 + gg); - // Robes: GeosetGroup[2] > 0 shows kilt legs - uint32_t gg3 = itemDisplayDbc->getUInt32(static_cast(idx), fGG3); - if (gg3 > 0) geosetPants = static_cast(1301 + gg3); + if (gg > 0) geosetTorso = pickGeoset(static_cast(501 + gg), 5); + if (gg > 0) geosetSleeves = pickGeoset(static_cast(801 + gg), 8); + // Do not derive robe/kilt from chest by default here. + // Some NPC datasets set chest geosets that cause persistent + // apron/robe overlays; prefer explicit legs slot for trousers. } } @@ -4132,41 +4189,91 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[5]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetPants = static_cast(1301 + gg); + if (gg > 0) geosetPants = pickGeoset(static_cast(1301 + gg), 13); } } - // Feet (slot 6) → group 5 (boots/shins) + // Feet (slot 6) → group 4 (boots/shins) if (extra.equipDisplayId[6] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[6]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetBoots = static_cast(501 + gg); + if (gg > 0) geosetBoots = pickGeoset(static_cast(401 + gg), 4); } } - // Hands (slot 8) → group 4 (gloves/forearms) + // Hands (slot 8) → group 3 (gloves/forearms) if (extra.equipDisplayId[8] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[8]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetGloves = static_cast(401 + gg); + if (gg > 0) geosetGloves = pickGeoset(static_cast(301 + gg), 3); } } - // Tabard (slot 9) → group 12 - if (extra.equipDisplayId[9] != 0) { - int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[9]); - if (idx >= 0) { - geosetTabard = 1202; - } - } + // Tabard (slot 9) intentionally disabled for now (see geosetTabard TODO above). // Cape (slot 10) → group 15 if (extra.equipDisplayId[10] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[10]); if (idx >= 0) { geosetCape = 1502; + 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 GLuint whiteTex = charRenderer->loadTexture(""); + for (const auto& candidate : capeCandidates) { + GLuint tex = charRenderer->loadTexture(candidate); + if (tex != 0 && tex != whiteTex) { + npcCapeTextureId = tex; + break; + } + } } } } @@ -4174,13 +4281,28 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Apply equipment geosets activeGeosets.insert(geosetGloves); activeGeosets.insert(geosetBoots); + activeGeosets.insert(geosetTorso); activeGeosets.insert(geosetSleeves); activeGeosets.insert(geosetPants); - activeGeosets.insert(geosetCape); - activeGeosets.insert(geosetTabard); - activeGeosets.insert(702); // Ears: default - activeGeosets.insert(902); // Kneepads: default - activeGeosets.insert(2002); // Bare feet mesh + if (geosetCape != 0) { + activeGeosets.insert(geosetCape); + } + if (geosetTabard != 0) { + activeGeosets.insert(geosetTabard); + } + activeGeosets.insert(pickGeoset(702, 7)); // Ears: default + activeGeosets.insert(pickGeoset(902, 9)); // Kneepads: default + activeGeosets.insert(pickGeoset(2002, 20)); // Bare feet mesh + // 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) { @@ -4208,12 +4330,25 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]"); charRenderer->setActiveGeosets(instanceId, activeGeosets); + if (geosetCape != 0 && npcCapeTextureId != 0) { + 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=", (int)hairGeoset, " sleeves=", geosetSleeves, " pants=", geosetPants, " boots=", geosetBoots, " gloves=", geosetGloves); + // TODO(#helmet-attach): NPC helmet attachment anchors are currently unreliable + // on some humanoid models (floating/incorrect bone bind). Keep hidden for now. + static constexpr bool kEnableNpcHelmetAttachmentsMainPath = false; // Load and attach helmet model if equipped - if (extra.equipDisplayId[0] != 0 && itemDisplayDbc) { + 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) @@ -4278,8 +4413,13 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; } } - charRenderer->attachWeapon(instanceId, 11, helmModel, helmModelId, helmTexPath); - LOG_DEBUG("Attached helmet model: ", helmPath, " tex: ", helmTexPath); + 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); + } } } } @@ -4288,6 +4428,141 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } + // 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 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 == 13) hasGroup13 = true; + if (group == 15) hasGroup15 = true; + } + + // Only apply to humanoid-like clothing models. + if (hasGroup13 || hasGroup15) { + bool hasRenderableCape = false; + if (itDisplayData != displayDataMap_.end() && + itDisplayData->second.extraDisplayId != 0) { + auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); + if (itExtra != humanoidExtraMap_.end()) { + uint32_t capeDisplayId = itExtra->second.equipDisplayId[10]; + if (capeDisplayId != 0) { + auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + if (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; + } + } + } + } + } + } + } + + std::unordered_set normalizedGeosets; + for (uint16_t sid : allGeosets) { + const uint16_t group = static_cast(sid / 100); + if (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; + 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; + }; + + // Prefer trousers geoset, not robe/kilt overlays. + if (hasGroup13) { + uint16_t pantsSid = pickFromGroup(1301, 13); + if (pantsSid != 0) normalizedGeosets.insert(pantsSid); + } + + // Prefer explicit cloak variant only when a cape is equipped. + if (hasGroup15 && hasRenderableCape) { + uint16_t capeSid = pickFromGroup(1502, 15); + if (capeSid != 0) normalizedGeosets.insert(capeSid); + } + + if (!normalizedGeosets.empty()) { + charRenderer->setActiveGeosets(instanceId, normalizedGeosets); + } + } + } + // Optional NPC helmet attachments (kept disabled for stability: this path // can increase spawn-time pressure and regress NPC visibility in crowded areas). static constexpr bool kEnableNpcHelmetAttachments = false; diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 24fba9ee..663a9324 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -8,6 +8,7 @@ #include "core/logger.hpp" #include #include +#include #include namespace wowee { @@ -495,12 +496,15 @@ bool CharacterPreview::applyEquipment(const std::vector& eq std::string genderPath = base + genderSuffix; std::string unisexPath = base + "_U.blp"; std::string fullPath; + std::string basePath = base + ".blp"; if (assetManager_->fileExists(genderPath)) { fullPath = genderPath; } else if (assetManager_->fileExists(unisexPath)) { fullPath = unisexPath; + } else if (assetManager_->fileExists(basePath)) { + fullPath = basePath; } else { - fullPath = base + ".blp"; + continue; } regionLayers.emplace_back(region, fullPath); } @@ -513,6 +517,91 @@ bool CharacterPreview::applyEquipment(const std::vector& eq } } + // Cloak texture (group 15) is separate from body compositing. + if (hasInvType({16})) { + uint32_t capeDisplayId = findDisplayId({16}); + if (capeDisplayId != 0) { + int32_t capeRecIdx = displayInfoDbc->findRecordById(capeDisplayId); + if (capeRecIdx >= 0) { + 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 = displayInfoDbc->getString(static_cast(capeRecIdx), 3); + std::string rightName = displayInfoDbc->getString(static_cast(capeRecIdx), 4); + if (gender_ == game::Gender::FEMALE) { + addName(rightName); + addName(leftName); + } else { + addName(leftName); + addName(rightName); + } + + 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 candidates; + auto addCandidate = [&](const std::string& p) { + if (!p.empty() && std::find(candidates.begin(), candidates.end(), p) == candidates.end()) { + candidates.push_back(p); + } + }; + for (const auto& nameRaw : capeNames) { + std::string name = nameRaw; + std::replace(name.begin(), name.end(), '/', '\\'); + bool hasDir = (name.find('\\') != std::string::npos); + 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 + (gender_ == game::Gender::FEMALE ? "_F.blp" : "_M.blp")); + addCandidate(baseObj + "_U.blp"); + addCandidate(baseTex + (gender_ == game::Gender::FEMALE ? "_F.blp" : "_M.blp")); + addCandidate(baseTex + "_U.blp"); + } + } + const GLuint whiteTex = charRenderer_->loadTexture(""); + for (const auto& c : candidates) { + GLuint capeTex = charRenderer_->loadTexture(c); + if (capeTex != 0 && capeTex != whiteTex) { + charRenderer_->setGroupTextureOverride(instanceId_, 15, capeTex); + if (const auto* md = charRenderer_->getModelData(PREVIEW_MODEL_ID)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + if (md->textures[ti].type == 2) { + charRenderer_->setTextureSlotOverride(instanceId_, static_cast(ti), capeTex); + } + } + } + break; + } + } + } + } + } else { + if (const auto* md = charRenderer_->getModelData(PREVIEW_MODEL_ID)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + if (md->textures[ti].type == 2) { + charRenderer_->clearTextureSlotOverride(instanceId_, static_cast(ti)); + } + } + } + } + return true; } diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 2f50323d..c431da9b 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1587,6 +1587,11 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons // Resolve texture for this batch (prefer hair textures for hair geosets). GLuint texId = resolveBatchTexture(instance, gpuModel, batch); + const uint16_t batchGroup = static_cast(batch.submeshId / 100); + auto groupTexIt = instance.groupTextureOverrides.find(batchGroup); + if (groupTexIt != instance.groupTextureOverrides.end() && groupTexIt->second != 0) { + texId = groupTexIt->second; + } // Respect M2 material blend mode for creature/character submeshes. uint16_t blendMode = 0; @@ -1611,7 +1616,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons // Groups that share the body skin atlas: 0=body, 3=gloves, 4=boots, 5=chest, // 8=wristbands, 9=pelvis, 13=pants. Hair (group 1) and facial hair (group 2) do NOT. if (texId == whiteTexture) { - uint16_t group = batch.submeshId / 100; + uint16_t group = batchGroup; bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 || group == 8 || group == 9 || group == 13); if (isSkinGroup) { @@ -2080,8 +2085,8 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen } } } - // Fallback: scan bones for keyBoneId 26 (right hand) / 27 (left hand) - if (!found) { + // Fallback to key-bone lookup only for weapon hand attachment IDs. + if (!found && (attachmentId == 1 || attachmentId == 2)) { int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; for (size_t i = 0; i < charModel.bones.size(); i++) { if (charModel.bones[i].keyBoneId == targetKeyBone) { @@ -2096,7 +2101,7 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen if (found && boneIndex >= charModel.bones.size()) { found = false; } - if (!found) { + if (!found && (attachmentId == 1 || attachmentId == 2)) { int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; for (size_t i = 0; i < charModel.bones.size(); i++) { if (charModel.bones[i].keyBoneId == targetKeyBone) { @@ -2247,18 +2252,22 @@ bool CharacterRenderer::getAttachmentTransform(uint32_t instanceId, uint32_t att // Validate bone index; invalid indices bind attachments to origin (looks like weapons at feet). if (boneIndex >= model.bones.size()) { - // Fallback: key bones (26/27) for hand attachments. - int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; - found = false; - for (size_t i = 0; i < model.bones.size(); i++) { - if (model.bones[i].keyBoneId == targetKeyBone) { - boneIndex = static_cast(i); - offset = glm::vec3(0.0f); - found = true; - break; + // Fallback: key bones (26/27) only for hand attachments. + if (attachmentId == 1 || attachmentId == 2) { + int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; + found = false; + for (size_t i = 0; i < model.bones.size(); i++) { + if (model.bones[i].keyBoneId == targetKeyBone) { + boneIndex = static_cast(i); + offset = glm::vec3(0.0f); + found = true; + break; + } } + if (!found) return false; + } else { + return false; } - if (!found) return false; } // Get bone matrix diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index fb985bd9..4879f89d 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -196,162 +196,20 @@ void InventoryScreen::updatePreview(float deltaTime) { } void InventoryScreen::updatePreviewEquipment(game::Inventory& inventory) { - if (!charPreview_ || !charPreview_->isModelLoaded() || !assetManager_) return; + if (!charPreview_ || !charPreview_->isModelLoaded()) return; - auto* charRenderer = charPreview_->getCharacterRenderer(); - uint32_t instanceId = charPreview_->getInstanceId(); - if (!charRenderer || instanceId == 0) return; - - // --- Geosets (mirroring GameScreen::updateCharacterGeosets) --- - auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); - - auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t { - if (!displayInfoDbc || displayInfoId == 0) return 0; - int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); - if (recIdx < 0) return 0; - return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); - }; - - auto findEquippedDisplayId = [&](std::initializer_list types) -> uint32_t { - for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { - const auto& slot = inventory.getEquipSlot(static_cast(s)); - if (!slot.empty()) { - for (uint8_t t : types) { - if (slot.item.inventoryType == t) - return slot.item.displayInfoId; - } - } - } - return 0; - }; - - auto hasEquippedType = [&](std::initializer_list types) -> bool { - for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { - const auto& slot = inventory.getEquipSlot(static_cast(s)); - if (!slot.empty()) { - for (uint8_t t : types) { - if (slot.item.inventoryType == t) return true; - } - } - } - return false; - }; - - 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); - - // Hair geoset: group 1 = 100 + hairStyle + 1 - geosets.insert(static_cast(100 + playerHairStyle_ + 1)); - // Facial hair geoset: group 2 = 200 + facialHair + 1 - geosets.insert(static_cast(200 + playerFacialHair_ + 1)); - geosets.insert(701); // Ears - - // Chest/Shirt - { - uint32_t did = findEquippedDisplayId({4, 5, 20}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 501 + gg : 501)); - uint32_t gg3 = getGeosetGroup(did, 2); - if (gg3 > 0) { - geosets.insert(static_cast(1301 + gg3)); - } - } - - // Legs - { - uint32_t did = findEquippedDisplayId({7}); - uint32_t gg = getGeosetGroup(did, 0); - if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { - geosets.insert(static_cast(gg > 0 ? 1301 + gg : 1301)); - } - } - - // Feet - { - uint32_t did = findEquippedDisplayId({8}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 401 + gg : 401)); - } - - // Gloves - { - uint32_t did = findEquippedDisplayId({10}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 301 + gg : 301)); - } - - // Cloak - geosets.insert(hasEquippedType({16}) ? 1502 : 1501); - - // Tabard - if (hasEquippedType({19})) { - geosets.insert(1201); - } - - charRenderer->setActiveGeosets(instanceId, geosets); - - // --- Textures (mirroring GameScreen::updateCharacterTextures) --- - auto& app = core::Application::getInstance(); - const auto& bodySkinPath = app.getBodySkinPath(); - const auto& underwearPaths = app.getUnderwearPaths(); - - if (bodySkinPath.empty() || !displayInfoDbc) return; - - static const char* componentDirs[] = { - "ArmUpperTexture", "ArmLowerTexture", "HandTexture", - "TorsoUpperTexture", "TorsoLowerTexture", - "LegUpperTexture", "LegLowerTexture", "FootTexture", - }; - - std::vector> regionLayers; + std::vector equipped; + equipped.reserve(game::Inventory::NUM_EQUIP_SLOTS); for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty() || slot.item.displayInfoId == 0) continue; - - int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId); - if (recIdx < 0) continue; - - for (int region = 0; region < 8; region++) { - uint32_t fieldIdx = 14 + region; - std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); - if (texName.empty()) continue; - - std::string base = "Item\\TextureComponents\\" + - std::string(componentDirs[region]) + "\\" + texName; - std::string genderSuffix = (playerGender_ == game::Gender::FEMALE) ? "_F.blp" : "_M.blp"; - std::string genderPath = base + genderSuffix; - 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); - } + game::EquipmentItem ei; + ei.displayModel = slot.item.displayInfoId; + ei.inventoryType = slot.item.inventoryType; + ei.enchantment = 0; + equipped.push_back(ei); } - - // Find the skin texture slot index in the preview model - // The preview model uses model ID PREVIEW_MODEL_ID; find slot for type-1 (body skin) - const auto* modelData = charRenderer->getModelData(charPreview_->getModelId()); - uint32_t skinSlot = 0; - if (modelData) { - for (size_t ti = 0; ti < modelData->textures.size(); ti++) { - if (modelData->textures[ti].type == 1) { - skinSlot = static_cast(ti); - break; - } - } - } - - GLuint newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); - if (newTex != 0) { - charRenderer->setModelTexture(charPreview_->getModelId(), skinSlot, newTex); - } - + charPreview_->applyEquipment(equipped); previewDirty_ = false; }