Kelsidavis-WoWee/src/core/entity_spawner_player.cpp
Kelsi 01fecbf3e0
Some checks failed
Build / Build (arm64) (push) Has been cancelled
Build / Build (x86-64) (push) Has been cancelled
Build / Build (macOS arm64) (push) Has been cancelled
Build / Build (windows-arm64) (push) Has been cancelled
Build / Build (windows-x86-64) (push) Has been cancelled
Security / CodeQL (C/C++) (push) Has been cancelled
Security / Semgrep (push) Has been cancelled
Security / Sanitizer Build (ASan/UBSan) (push) Has been cancelled
fix(parsing): correct UPDATE_OBJECT PackedGuid, cape textures, and missing asset guards
- Fix MOVEMENT update type to use readPackedGuid() instead of readUInt64() (WotLK 3.3.5a)
- Add desync diagnostic logging to UPDATE_OBJECT parser for future debugging
- Register MSG_MOVE_SET_COLLISION_HGT (0x518) as skip handler
- Fix cape texture lookup to only try .blp extension variants (4 files)
- Add fileExists() guards for underwear textures referencing missing BLP files (4 files)
- Add spell visual impact→cast M2 path fallback
- Skip WMO doodad instance creation when model load fails
- Demote spell caster position warning to debug level
2026-04-14 06:06:50 -07:00

1245 lines
58 KiB
C++

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