diff --git a/CMakeLists.txt b/CMakeLists.txt index e1b1b091..432c0f65 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -486,6 +486,7 @@ endif() set(WOWEE_SOURCES # Core src/core/application.cpp + src/core/entity_spawner.cpp src/core/window.cpp src/core/input.cpp src/core/logger.cpp diff --git a/include/core/application.hpp b/include/core/application.hpp index 5cbed79b..a3f20984 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -2,6 +2,7 @@ #include "core/window.hpp" #include "core/input.hpp" +#include "core/entity_spawner.hpp" #include "game/character.hpp" #include "game/game_services.hpp" #include "pipeline/blp_loader.hpp" @@ -79,7 +80,7 @@ public: // Logout to login screen void logoutToLogin(); - // Render bounds lookup (for click targeting / selection) + // Render bounds lookup (for click targeting / selection) — delegates to EntitySpawner bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const; bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const; bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const; @@ -89,8 +90,11 @@ public: const std::vector& getUnderwearPaths() const { return underwearPaths_; } uint32_t getSkinTextureSlotIndex() const { return skinTextureSlotIndex_; } uint32_t getCloakTextureSlotIndex() const { return cloakTextureSlotIndex_; } - uint32_t getGryphonDisplayId() const { return gryphonDisplayId_; } - uint32_t getWyvernDisplayId() const { return wyvernDisplayId_; } + uint32_t getGryphonDisplayId() const { return entitySpawner_ ? entitySpawner_->getGryphonDisplayId() : 0; } + uint32_t getWyvernDisplayId() const { return entitySpawner_ ? entitySpawner_->getWyvernDisplayId() : 0; } + + // Entity spawner access + EntitySpawner* getEntitySpawner() { return entitySpawner_.get(); } private: void update(float deltaTime); @@ -102,27 +106,6 @@ private: static const char* mapDisplayName(uint32_t mapId); void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); void buildFactionHostilityMap(uint8_t playerRace); - pipeline::M2Model loadCreatureM2Sync(const std::string& m2Path); - void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); - void despawnOnlineCreature(uint64_t guid); - bool tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId); - void spawnOnlinePlayer(uint64_t guid, - uint8_t raceId, - uint8_t genderId, - uint32_t appearanceBytes, - uint8_t facialFeatures, - float x, float y, float z, float orientation); - void setOnlinePlayerEquipment(uint64_t guid, - const std::array& displayInfoIds, - const std::array& inventoryTypes); - void despawnOnlinePlayer(uint64_t guid); - void buildCreatureDisplayLookups(); - std::string getModelPathForDisplayId(uint32_t displayId) const; - void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); - void despawnOnlineGameObject(uint64_t guid); - void buildGameObjectDisplayLookups(); - std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const; - audio::VoiceType detectVoiceTypeFromDisplayId(uint32_t displayId) const; void setupTestTransport(); // Test transport boat for development static Application* instance; @@ -139,6 +122,7 @@ private: bool addonsLoaded_ = false; std::unique_ptr expansionRegistry_; std::unique_ptr dbcLayout_; + std::unique_ptr entitySpawner_; AppState state = AppState::AUTHENTICATION; bool running = false; @@ -156,94 +140,12 @@ private: uint32_t spawnedAppearanceBytes_ = 0; uint8_t spawnedFacialFeatures_ = 0; - // Weapon model ID counter (starting high to avoid collision with character model IDs) - uint32_t nextWeaponModelId_ = 1000; - // Saved at spawn for skin re-compositing std::string bodySkinPath_; std::vector underwearPaths_; uint32_t skinTextureSlotIndex_ = 0; uint32_t cloakTextureSlotIndex_ = 0; - // Online creature model spawning - struct CreatureDisplayData { - uint32_t modelId = 0; - std::string skin1, skin2, skin3; // Texture names from CreatureDisplayInfo.dbc - uint32_t extraDisplayId = 0; // Link to CreatureDisplayInfoExtra.dbc - }; - struct HumanoidDisplayExtra { - uint8_t raceId = 0; - uint8_t sexId = 0; - uint8_t skinId = 0; - uint8_t faceId = 0; - uint8_t hairStyleId = 0; - uint8_t hairColorId = 0; - uint8_t facialHairId = 0; - std::string bakeName; // Pre-baked texture path if available - // Equipment display IDs (from columns 8-18) - // 0=helm, 1=shoulder, 2=shirt, 3=chest, 4=belt, 5=legs, 6=feet, 7=wrist, 8=hands, 9=tabard, 10=cape - uint32_t equipDisplayId[11] = {0}; - }; - std::unordered_map displayDataMap_; // displayId → display data - std::unordered_map humanoidExtraMap_; // extraDisplayId → humanoid data - std::unordered_map modelIdToPath_; // modelId → M2 path (from CreatureModelData.dbc) - // CharHairGeosets.dbc: key = (raceId<<16)|(sexId<<8)|variationId → geosetId (skinSectionId) - std::unordered_map hairGeosetMap_; - // CharFacialHairStyles.dbc: key = (raceId<<16)|(sexId<<8)|variationId → {geoset100, geoset300, geoset200} - struct FacialHairGeosets { uint16_t geoset100 = 0; uint16_t geoset300 = 0; uint16_t geoset200 = 0; }; - std::unordered_map facialHairGeosetMap_; - std::unordered_map creatureInstances_; // guid → render instanceId - std::unordered_map creatureModelIds_; // guid → loaded modelId - std::unordered_map creatureRenderPosCache_; // guid -> last synced render position - std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state - std::unordered_map creatureWasSwimming_; // guid -> previous-frame swim state (for anim transition detection) - std::unordered_map creatureWasFlying_; // guid -> previous-frame flying state (for anim transition detection) - std::unordered_map creatureWasWalking_; // guid -> previous-frame walking state (walk vs run transition detection) - std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) - std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) - std::unordered_map creatureFlyingState_; // guid -> currently flying (FLYING flag) - std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached - std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts - std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check - static constexpr int MAX_WEAPON_ATTACHES_PER_TICK = 2; // limit weapon attach work per 1s tick - - // CharSections.dbc lookup cache to avoid O(N) DBC scan per NPC spawn. - // Key: (race<<24)|(sex<<16)|(section<<12)|(variation<<8)|color → texture path - std::unordered_map charSectionsCache_; - bool charSectionsCacheBuilt_ = false; - void buildCharSectionsCache(); - std::string lookupCharSection(uint8_t race, uint8_t sex, uint8_t section, - uint8_t variation, uint8_t color, int texIndex = 0) const; - - // Async creature model loading: file I/O + M2 parsing on background thread, - // GPU upload + instance creation on main thread. - struct PreparedCreatureModel { - uint64_t guid; - uint32_t displayId; - uint32_t modelId; - float x, y, z, orientation; - float scale = 1.0f; - std::shared_ptr model; // parsed on background thread - std::unordered_map predecodedTextures; // decoded on bg thread - bool valid = false; - bool permanent_failure = false; - }; - struct AsyncCreatureLoad { - std::future future; - }; - std::vector asyncCreatureLoads_; - std::unordered_set asyncCreatureDisplayLoads_; // displayIds currently loading in background - void processAsyncCreatureResults(bool unlimited = false); - static constexpr int MAX_ASYNC_CREATURE_LOADS = 4; // concurrent background loads - std::unordered_set deadCreatureGuids_; // GUIDs that should spawn in corpse/death pose - std::unordered_map displayIdModelCache_; // displayId → modelId (model caching) - std::unordered_set displayIdTexturesApplied_; // displayIds with per-model textures applied - std::unordered_map> displayIdPredecodedTextures_; // displayId → pre-decoded skin textures - mutable std::unordered_set warnedMissingDisplayDataIds_; // displayIds already warned - mutable std::unordered_set warnedMissingModelPathIds_; // modelIds/displayIds already warned - uint32_t nextCreatureModelId_ = 5000; // Model IDs for online creatures - uint32_t gryphonDisplayId_ = 0; - uint32_t wyvernDisplayId_ = 0; bool lastTaxiFlight_ = false; uint32_t loadedMapId_ = 0xFFFFFFFF; // Map ID of currently loaded terrain (0xFFFFFFFF = none) uint32_t worldLoadGeneration_ = 0; // Incremented on each world entry to detect re-entrant loads @@ -272,190 +174,11 @@ private: glm::vec3 chargeEndPos_{0.0f}; // Render coordinates uint64_t chargeTargetGuid_ = 0; - // Online gameobject model spawning - struct GameObjectInstanceInfo { - uint32_t modelId = 0; - uint32_t instanceId = 0; - bool isWmo = false; - }; - std::unordered_map gameObjectDisplayIdToPath_; - std::unordered_map gameObjectDisplayIdModelCache_; // displayId → M2 modelId - std::unordered_set gameObjectDisplayIdFailedCache_; // displayIds that permanently fail to load - std::unordered_map gameObjectDisplayIdWmoCache_; // displayId → WMO modelId - std::unordered_map gameObjectInstances_; // guid → instance info - struct PendingTransportMove { - float x = 0.0f; - float y = 0.0f; - float z = 0.0f; - float orientation = 0.0f; - }; - struct PendingTransportRegistration { - uint64_t guid = 0; - uint32_t entry = 0; - uint32_t displayId = 0; - float x = 0.0f; - float y = 0.0f; - float z = 0.0f; - float orientation = 0.0f; - }; - std::unordered_map pendingTransportMoves_; // guid -> latest pre-registration move - std::deque pendingTransportRegistrations_; - uint32_t nextGameObjectModelId_ = 20000; - uint32_t nextGameObjectWmoModelId_ = 40000; - bool testTransportSetup_ = false; - bool gameObjectLookupsBuilt_ = false; - - // Mount model tracking - uint32_t mountInstanceId_ = 0; - uint32_t mountModelId_ = 0; - uint32_t pendingMountDisplayId_ = 0; // Deferred mount load (0 = none pending) bool weaponsSheathed_ = false; bool wasAutoAttacking_ = false; - void processPendingMount(); - bool creatureLookupsBuilt_ = false; bool mapNameCacheLoaded_ = false; std::unordered_map mapNameById_; - // Deferred creature spawn queue (throttles spawning to avoid hangs) - struct PendingCreatureSpawn { - uint64_t guid; - uint32_t displayId; - float x, y, z, orientation; - float scale = 1.0f; - }; - std::deque pendingCreatureSpawns_; - static constexpr int MAX_SPAWNS_PER_FRAME = 3; - static constexpr int MAX_NEW_CREATURE_MODELS_PER_FRAME = 1; - static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300; - std::unordered_set pendingCreatureSpawnGuids_; - std::unordered_map creatureSpawnRetryCounts_; - std::unordered_set nonRenderableCreatureDisplayIds_; - - // Online player instances (separate from creatures so we can apply per-player skin/hair textures). - std::unordered_map playerInstances_; // guid → render instanceId - struct OnlinePlayerAppearanceState { - uint32_t instanceId = 0; - uint32_t modelId = 0; - uint8_t raceId = 0; - uint8_t genderId = 0; - uint32_t appearanceBytes = 0; - uint8_t facialFeatures = 0; - std::string bodySkinPath; - std::vector underwearPaths; - }; - std::unordered_map onlinePlayerAppearance_; - std::unordered_map, std::array>> pendingOnlinePlayerEquipment_; - // Deferred equipment compositing queue — processes max 1 per frame to avoid stutter - // deque: consumed from front in a loop; vector::erase(begin) would be O(n²). - std::deque, std::array>>> deferredEquipmentQueue_; - void processDeferredEquipmentQueue(); - // Async equipment texture pre-decode: BLP decode on background thread, composite on main thread - struct PreparedEquipmentUpdate { - uint64_t guid; - std::array displayInfoIds; - std::array inventoryTypes; - std::unordered_map predecodedTextures; - }; - struct AsyncEquipmentLoad { - std::future future; - }; - std::vector asyncEquipmentLoads_; - void processAsyncEquipmentResults(); - std::vector resolveEquipmentTexturePaths(uint64_t guid, - const std::array& displayInfoIds, - const std::array& inventoryTypes) const; - // Deferred NPC texture setup — async DBC lookups + BLP pre-decode to avoid main-thread stalls - struct DeferredNpcComposite { - uint32_t modelId; - uint32_t displayId; - // Skin compositing (type-1 slots) - std::string basePath; // CharSections skin base texture - std::vector overlayPaths; // face + underwear overlays - std::vector> regionLayers; // equipment region overlays - std::vector skinTextureSlots; // model texture slots needing skin composite - bool hasComposite = false; // needs compositing (overlays or equipment regions) - bool hasSimpleSkin = false; // just base skin, no compositing needed - // Baked skin (type-1 slots) - std::string bakedSkinPath; // baked texture path (if available) - bool hasBakedSkin = false; // baked skin resolved successfully - // Hair (type-6 slots) - std::vector hairTextureSlots; // model texture slots needing hair texture - std::string hairTexturePath; // resolved hair texture path - bool useBakedForHair = false; // bald NPC: use baked skin for type-6 - }; - struct PreparedNpcComposite { - DeferredNpcComposite info; - std::unordered_map predecodedTextures; - }; - struct AsyncNpcCompositeLoad { - std::future future; - }; - std::vector asyncNpcCompositeLoads_; - void processAsyncNpcCompositeResults(bool unlimited = false); - // Cache base player model geometry by (raceId, genderId) - std::unordered_map playerModelCache_; // key=(race<<8)|gender → modelId - struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; }; - std::unordered_map playerTextureSlotsByModelId_; - uint32_t nextPlayerModelId_ = 60000; - struct PendingPlayerSpawn { - uint64_t guid; - uint8_t raceId; - uint8_t genderId; - uint32_t appearanceBytes; - uint8_t facialFeatures; - float x, y, z, orientation; - }; - std::deque pendingPlayerSpawns_; - std::unordered_set pendingPlayerSpawnGuids_; - void processPlayerSpawnQueue(); - std::unordered_set creaturePermanentFailureGuids_; - void processCreatureSpawnQueue(bool unlimited = false); - - struct PendingGameObjectSpawn { - uint64_t guid; - uint32_t entry; - uint32_t displayId; - float x, y, z, orientation; - float scale = 1.0f; - }; - std::deque pendingGameObjectSpawns_; - void processGameObjectSpawnQueue(); - - // Async WMO loading for game objects (file I/O + parse on background thread) - struct PreparedGameObjectWMO { - uint64_t guid; - uint32_t entry; - uint32_t displayId; - float x, y, z, orientation; - float scale = 1.0f; - std::shared_ptr wmoModel; - std::unordered_map predecodedTextures; // decoded on bg thread - bool valid = false; - bool isWmo = false; - std::string modelPath; - }; - struct AsyncGameObjectLoad { - std::future future; - }; - std::vector asyncGameObjectLoads_; - void processAsyncGameObjectResults(); - struct PendingTransportDoodadBatch { - uint64_t guid = 0; - uint32_t modelId = 0; - uint32_t instanceId = 0; - size_t nextIndex = 0; - size_t doodadBudget = 0; - size_t spawnedDoodads = 0; - float x = 0.0f; - float y = 0.0f; - float z = 0.0f; - float orientation = 0.0f; - }; - std::vector pendingTransportDoodadBatches_; - static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4; - void processPendingTransportRegistrations(); - void processPendingTransportDoodads(); - // Quest marker billboard sprites (above NPCs) void loadQuestMarkerModels(); // Now loads BLP textures void updateQuestMarkers(); // Updates billboard positions diff --git a/include/core/entity_spawner.hpp b/include/core/entity_spawner.hpp new file mode 100644 index 00000000..301faf28 --- /dev/null +++ b/include/core/entity_spawner.hpp @@ -0,0 +1,465 @@ +#pragma once + +#include "game/character.hpp" +#include "game/game_services.hpp" +#include "pipeline/blp_loader.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { + +// Forward declarations +namespace rendering { class Renderer; } +namespace pipeline { class AssetManager; class DBCLayout; struct M2Model; struct WMOModel; } +namespace audio { enum class VoiceType; } +namespace game { class GameHandler; } + +namespace core { + +class Application; + +class EntitySpawner { +public: + EntitySpawner(rendering::Renderer* renderer, + pipeline::AssetManager* assetManager, + game::GameHandler* gameHandler, + pipeline::DBCLayout* dbcLayout, + game::GameServices* gameServices); + ~EntitySpawner(); + + EntitySpawner(const EntitySpawner&) = delete; + EntitySpawner& operator=(const EntitySpawner&) = delete; + + // Lifecycle + void initialize(); // Build DBC lookups + void update(); // Process all spawn/despawn queues (called from Application::update) + void shutdown(); // Clear all instances and queues + + // Queue-based spawn API (called from GameHandler callbacks) + void queueCreatureSpawn(uint64_t guid, uint32_t displayId, + float x, float y, float z, float orientation, float scale = 1.0f); + void queuePlayerSpawn(uint64_t guid, uint8_t raceId, uint8_t genderId, + uint32_t appearanceBytes, uint8_t facialFeatures, + float x, float y, float z, float orientation); + void queueGameObjectSpawn(uint64_t guid, uint32_t entry, uint32_t displayId, + float x, float y, float z, float orientation, float scale = 1.0f); + void queuePlayerEquipment(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes); + + // Immediate despawn + void despawnCreature(uint64_t guid); + void despawnPlayer(uint64_t guid); + void despawnGameObject(uint64_t guid); + + // Clear all queues and instances (logout, reconnect) + void clearAllQueues(); + void despawnAllCreatures(); + void despawnAllPlayers(); + void despawnAllGameObjects(); + + // Full reset — despawns entities, waits for async loads, clears all state. + // Used by logoutToLogin() and loadOnlineWorldTerrain() for clean slate. + void resetAllState(); + + // Rebuild lookup tables (for reloadExpansionData after expansion change) + void rebuildLookups(); + + // Check if any spawn/async work is still pending + bool hasWorkPending() const; + + // Status queries + bool isCreatureSpawned(uint64_t guid) const { return creatureInstances_.count(guid) > 0; } + bool isCreaturePending(uint64_t guid) const { return pendingCreatureSpawnGuids_.count(guid) > 0; } + bool isPlayerSpawned(uint64_t guid) const { return playerInstances_.count(guid) > 0; } + bool isPlayerPending(uint64_t guid) const { return pendingPlayerSpawnGuids_.count(guid) > 0; } + bool isGameObjectSpawned(uint64_t guid) const { return gameObjectInstances_.count(guid) > 0; } + + // Quick instance ID lookups (returns 0 if not found) + uint32_t getCreatureInstanceId(uint64_t guid) const { + auto it = creatureInstances_.find(guid); return (it != creatureInstances_.end()) ? it->second : 0; + } + uint32_t getPlayerInstanceId(uint64_t guid) const { + auto it = playerInstances_.find(guid); return (it != playerInstances_.end()) ? it->second : 0; + } + + // Render bounds/position queries (used by click targeting, etc.) + bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const; + bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const; + bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const; + + // Display data lookups + bool areCreatureLookupsBuilt() const { return creatureLookupsBuilt_; } + bool areGameObjectLookupsBuilt() const { return gameObjectLookupsBuilt_; } + std::string getModelPathForDisplayId(uint32_t displayId) const; + std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const; + audio::VoiceType detectVoiceTypeFromDisplayId(uint32_t displayId) const; + + // Weapon attachment for NPC virtual weapons + bool tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId); + + // Mount + void setMountDisplayId(uint32_t displayId) { pendingMountDisplayId_ = displayId; } + uint32_t getMountInstanceId() const { return mountInstanceId_; } + uint32_t getMountModelId() const { return mountModelId_; } + + // Local player GUID exclusion (EntitySpawner skips spawning the local player) + void setLocalPlayerGuid(uint64_t guid) { spawnedPlayerGuid_ = guid; } + + // Weapon model IDs (shared counter for creature weapons + equipment helm/weapon models) + uint32_t allocateWeaponModelId() { return nextWeaponModelId_++; } + + // Maximum weapon attachment operations per tick (used by Application update loop) + static constexpr int MAX_WEAPON_ATTACHES_PER_TICK = 2; + + // Dead creature tracking (spawn in corpse/death pose) + void markCreatureDead(uint64_t guid) { deadCreatureGuids_.insert(guid); } + void unmarkCreatureDead(uint64_t guid) { deadCreatureGuids_.erase(guid); } + void clearDeadCreatureGuids() { deadCreatureGuids_.clear(); } + + // Mount state management + void clearMountState(); + + // Transport registration API (used by setupUICallbacks transport spawn callback) + void queueTransportRegistration(uint64_t guid, uint32_t entry, uint32_t displayId, + float x, float y, float z, float orientation); + void setTransportPendingMove(uint64_t guid, float x, float y, float z, float orientation); + bool hasTransportRegistrationPending(uint64_t guid) const; + void updateTransportRegistration(uint64_t guid, uint32_t displayId, + float x, float y, float z, float orientation); + + // Test transport setup flag + bool isTestTransportSetup() const { return testTransportSetup_; } + void setTestTransportSetup(bool v) { testTransportSetup_ = v; } + + // Creature animation state accessors (for movement sync in Application::update) + std::unordered_map& getCreatureInstances() { return creatureInstances_; } + const std::unordered_map& getCreatureInstances() const { return creatureInstances_; } + std::unordered_map& getCreatureModelIds() { return creatureModelIds_; } + std::unordered_map& getCreatureRenderPosCache() { return creatureRenderPosCache_; } + std::unordered_map& getCreatureWasMoving() { return creatureWasMoving_; } + std::unordered_map& getCreatureWasSwimming() { return creatureWasSwimming_; } + std::unordered_map& getCreatureWasFlying() { return creatureWasFlying_; } + std::unordered_map& getCreatureWasWalking() { return creatureWasWalking_; } + std::unordered_map& getCreatureSwimmingState() { return creatureSwimmingState_; } + std::unordered_map& getCreatureWalkingState() { return creatureWalkingState_; } + std::unordered_map& getCreatureFlyingState() { return creatureFlyingState_; } + std::unordered_set& getCreatureWeaponsAttached() { return creatureWeaponsAttached_; } + std::unordered_map& getCreatureWeaponAttachAttempts() { return creatureWeaponAttachAttempts_; } + std::unordered_map& getModelIdIsWolfLike() { return modelIdIsWolfLike_; } + + // Player instance accessors (for movement sync in Application::update) + std::unordered_map& getPlayerInstances() { return playerInstances_; } + const std::unordered_map& getPlayerInstances() const { return playerInstances_; } + + // GameObject instance accessors + auto& getGameObjectInstances() { return gameObjectInstances_; } + const auto& getGameObjectInstances() const { return gameObjectInstances_; } + + // Display data accessors (needed by Application for gryphon/wyvern display IDs) + const auto& getDisplayDataMap() const { return displayDataMap_; } + uint32_t getGryphonDisplayId() const { return gryphonDisplayId_; } + uint32_t getWyvernDisplayId() const { return wyvernDisplayId_; } + + // Character section lookups (needed for player character skin compositing) + std::string lookupCharSection(uint8_t race, uint8_t sex, uint8_t section, + uint8_t variation, uint8_t color, int texIndex = 0) const; + const std::unordered_map& getHairGeosetMap() const { return hairGeosetMap_; } + + struct FacialHairGeosets { uint16_t geoset100 = 0; uint16_t geoset300 = 0; uint16_t geoset200 = 0; }; + const std::unordered_map& getFacialHairGeosetMap() const { return facialHairGeosetMap_; } + + // Creature M2 sync loader (used by spawnPlayerCharacter in Application) + pipeline::M2Model loadCreatureM2Sync(const std::string& m2Path); + +private: + // Dependencies (non-owning) + rendering::Renderer* renderer_; + pipeline::AssetManager* assetManager_; + game::GameHandler* gameHandler_; + pipeline::DBCLayout* dbcLayout_; + game::GameServices* gameServices_; + + // --- Creature display data (from DBC files) --- + struct CreatureDisplayData { + uint32_t modelId = 0; + std::string skin1, skin2, skin3; // Texture names from CreatureDisplayInfo.dbc + uint32_t extraDisplayId = 0; // Link to CreatureDisplayInfoExtra.dbc + }; + struct HumanoidDisplayExtra { + uint8_t raceId = 0; + uint8_t sexId = 0; + uint8_t skinId = 0; + uint8_t faceId = 0; + uint8_t hairStyleId = 0; + uint8_t hairColorId = 0; + uint8_t facialHairId = 0; + std::string bakeName; // Pre-baked texture path if available + // Equipment display IDs (from columns 8-18) + // 0=helm, 1=shoulder, 2=shirt, 3=chest, 4=belt, 5=legs, 6=feet, 7=wrist, 8=hands, 9=tabard, 10=cape + uint32_t equipDisplayId[11] = {0}; + }; + std::unordered_map displayDataMap_; // displayId → display data + std::unordered_map humanoidExtraMap_; // extraDisplayId → humanoid data + std::unordered_map modelIdToPath_; // modelId → M2 path (from CreatureModelData.dbc) + // CharHairGeosets.dbc: key = (raceId<<16)|(sexId<<8)|variationId → geosetId (skinSectionId) + std::unordered_map hairGeosetMap_; + // CharFacialHairStyles.dbc: key = (raceId<<16)|(sexId<<8)|variationId → {geoset100, geoset300, geoset200} + std::unordered_map facialHairGeosetMap_; + bool creatureLookupsBuilt_ = false; + + // CharSections.dbc lookup cache + std::unordered_map charSectionsCache_; + bool charSectionsCacheBuilt_ = false; + void buildCharSectionsCache(); + + // Creature display lookup building + void buildCreatureDisplayLookups(); + + // Weapon M2 loading helper + bool loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel); + + // GameObject display lookups + std::unordered_map gameObjectDisplayIdToPath_; + bool gameObjectLookupsBuilt_ = false; + void buildGameObjectDisplayLookups(); + + // --- Creature instances and tracking --- + std::unordered_map creatureInstances_; // guid → render instanceId + std::unordered_map creatureModelIds_; // guid → loaded modelId + std::unordered_map creatureRenderPosCache_; // guid → last synced render position + std::unordered_map creatureWasMoving_; + std::unordered_map creatureWasSwimming_; + std::unordered_map creatureWasFlying_; + std::unordered_map creatureWasWalking_; + std::unordered_map creatureSwimmingState_; + std::unordered_map creatureWalkingState_; + std::unordered_map creatureFlyingState_; + std::unordered_set creatureWeaponsAttached_; + std::unordered_map creatureWeaponAttachAttempts_; + std::unordered_map modelIdIsWolfLike_; + + // --- Creature async loads --- + struct PreparedCreatureModel { + uint64_t guid; + uint32_t displayId; + uint32_t modelId; + float x, y, z, orientation; + float scale = 1.0f; + std::shared_ptr model; + std::unordered_map predecodedTextures; + bool valid = false; + bool permanent_failure = false; + }; + struct AsyncCreatureLoad { + std::future future; + }; + std::vector asyncCreatureLoads_; + std::unordered_set asyncCreatureDisplayLoads_; + void processAsyncCreatureResults(bool unlimited = false); + static constexpr int MAX_ASYNC_CREATURE_LOADS = 4; + std::unordered_set deadCreatureGuids_; + std::unordered_map displayIdModelCache_; + std::unordered_set displayIdTexturesApplied_; + std::unordered_map> displayIdPredecodedTextures_; + mutable std::unordered_set warnedMissingDisplayDataIds_; + mutable std::unordered_set warnedMissingModelPathIds_; + uint32_t nextCreatureModelId_ = 5000; + uint32_t gryphonDisplayId_ = 0; + uint32_t wyvernDisplayId_ = 0; + + // --- Creature spawn queue --- + struct PendingCreatureSpawn { + uint64_t guid; + uint32_t displayId; + float x, y, z, orientation; + float scale = 1.0f; + }; + std::deque pendingCreatureSpawns_; + static constexpr int MAX_SPAWNS_PER_FRAME = 3; + static constexpr int MAX_NEW_CREATURE_MODELS_PER_FRAME = 1; + static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300; + std::unordered_set pendingCreatureSpawnGuids_; + std::unordered_map creatureSpawnRetryCounts_; + std::unordered_set nonRenderableCreatureDisplayIds_; + std::unordered_set creaturePermanentFailureGuids_; + void processCreatureSpawnQueue(bool unlimited = false); + + // --- NPC composite loads --- + struct DeferredNpcComposite { + uint32_t modelId; + uint32_t displayId; + std::string basePath; + std::vector overlayPaths; + std::vector> regionLayers; + std::vector skinTextureSlots; + bool hasComposite = false; + bool hasSimpleSkin = false; + std::string bakedSkinPath; + bool hasBakedSkin = false; + std::vector hairTextureSlots; + std::string hairTexturePath; + bool useBakedForHair = false; + }; + struct PreparedNpcComposite { + DeferredNpcComposite info; + std::unordered_map predecodedTextures; + }; + struct AsyncNpcCompositeLoad { + std::future future; + }; + std::vector asyncNpcCompositeLoads_; + void processAsyncNpcCompositeResults(bool unlimited = false); + + // --- Player instances --- + std::unordered_map playerInstances_; // guid → render instanceId + struct OnlinePlayerAppearanceState { + uint32_t instanceId = 0; + uint32_t modelId = 0; + uint8_t raceId = 0; + uint8_t genderId = 0; + uint32_t appearanceBytes = 0; + uint8_t facialFeatures = 0; + std::string bodySkinPath; + std::vector underwearPaths; + }; + std::unordered_map onlinePlayerAppearance_; + std::unordered_map, std::array>> pendingOnlinePlayerEquipment_; + std::deque, std::array>>> deferredEquipmentQueue_; + void processDeferredEquipmentQueue(); + struct PreparedEquipmentUpdate { + uint64_t guid; + std::array displayInfoIds; + std::array inventoryTypes; + std::unordered_map predecodedTextures; + }; + struct AsyncEquipmentLoad { + std::future future; + }; + std::vector asyncEquipmentLoads_; + void processAsyncEquipmentResults(); + std::vector resolveEquipmentTexturePaths(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes) const; + std::unordered_map playerModelCache_; + struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; }; + std::unordered_map playerTextureSlotsByModelId_; + uint32_t nextPlayerModelId_ = 60000; + + // --- Player spawn queue --- + struct PendingPlayerSpawn { + uint64_t guid; + uint8_t raceId; + uint8_t genderId; + uint32_t appearanceBytes; + uint8_t facialFeatures; + float x, y, z, orientation; + }; + std::deque pendingPlayerSpawns_; + std::unordered_set pendingPlayerSpawnGuids_; + void processPlayerSpawnQueue(); + + // --- GameObject instances --- + struct GameObjectInstanceInfo { + uint32_t modelId = 0; + uint32_t instanceId = 0; + bool isWmo = false; + }; + std::unordered_map gameObjectDisplayIdModelCache_; + std::unordered_set gameObjectDisplayIdFailedCache_; + std::unordered_map gameObjectDisplayIdWmoCache_; + std::unordered_map gameObjectInstances_; + struct PendingTransportMove { + float x = 0.0f, y = 0.0f, z = 0.0f, orientation = 0.0f; + }; + struct PendingTransportRegistration { + uint64_t guid = 0; + uint32_t entry = 0; + uint32_t displayId = 0; + float x = 0.0f, y = 0.0f, z = 0.0f, orientation = 0.0f; + }; + std::unordered_map pendingTransportMoves_; + std::deque pendingTransportRegistrations_; + uint32_t nextGameObjectModelId_ = 20000; + uint32_t nextGameObjectWmoModelId_ = 40000; + bool testTransportSetup_ = false; + + // --- GameObject spawn queue --- + struct PendingGameObjectSpawn { + uint64_t guid; + uint32_t entry; + uint32_t displayId; + float x, y, z, orientation; + float scale = 1.0f; + }; + std::deque pendingGameObjectSpawns_; + void processGameObjectSpawnQueue(); + + // --- Async WMO loading for game objects --- + struct PreparedGameObjectWMO { + uint64_t guid; + uint32_t entry; + uint32_t displayId; + float x, y, z, orientation; + float scale = 1.0f; + std::shared_ptr wmoModel; + std::unordered_map predecodedTextures; + bool valid = false; + bool isWmo = false; + std::string modelPath; + }; + struct AsyncGameObjectLoad { + std::future future; + }; + std::vector asyncGameObjectLoads_; + void processAsyncGameObjectResults(); + struct PendingTransportDoodadBatch { + uint64_t guid = 0; + uint32_t modelId = 0; + uint32_t instanceId = 0; + size_t nextIndex = 0; + size_t doodadBudget = 0; + size_t spawnedDoodads = 0; + float x = 0.0f, y = 0.0f, z = 0.0f, orientation = 0.0f; + }; + std::vector pendingTransportDoodadBatches_; + static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4; + void processPendingTransportRegistrations(); + void processPendingTransportDoodads(); + + // --- Mount --- + uint32_t mountInstanceId_ = 0; + uint32_t mountModelId_ = 0; + uint32_t pendingMountDisplayId_ = 0; + void processPendingMount(); + + // --- Local player GUID exclusion --- + uint64_t spawnedPlayerGuid_ = 0; + + // --- Weapon model ID counter --- + uint32_t nextWeaponModelId_ = 1000; + + // --- Spawn internal methods --- + void spawnOnlineCreature(uint64_t guid, uint32_t displayId, + float x, float y, float z, float orientation, float scale = 1.0f); + void spawnOnlinePlayer(uint64_t guid, uint8_t raceId, uint8_t genderId, + uint32_t appearanceBytes, uint8_t facialFeatures, + float x, float y, float z, float orientation); + void setOnlinePlayerEquipment(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes); + void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, + float x, float y, float z, float orientation, float scale = 1.0f); +}; + +} // namespace core +} // namespace wowee diff --git a/src/core/application.cpp b/src/core/application.cpp index 5c411a55..6257bbf3 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -337,7 +337,10 @@ bool Application::initialize() { if (assetManager->initialize(assetPath)) { LOG_INFO("Asset manager initialized successfully"); // Eagerly load creature display DBC lookups so first spawn doesn't stall - buildCreatureDisplayLookups(); + entitySpawner_ = std::make_unique( + renderer.get(), assetManager.get(), gameHandler.get(), + dbcLayout_.get(), &gameServices_); + entitySpawner_->initialize(); // Ensure the main in-world CharacterRenderer can load textures immediately. // Previously this was only wired during terrain initialization, which meant early spawns @@ -1084,13 +1087,7 @@ void Application::reloadExpansionData() { } // Rebuild creature display lookups with the new expansion's DBC layout - creatureLookupsBuilt_ = false; - displayDataMap_.clear(); - humanoidExtraMap_.clear(); - creatureModelIds_.clear(); - creatureRenderPosCache_.clear(); - nonRenderableCreatureDisplayIds_.clear(); - buildCreatureDisplayLookups(); + if (entitySpawner_) entitySpawner_->rebuildLookups(); } void Application::logoutToLogin() { @@ -1130,58 +1127,8 @@ void Application::logoutToLogin() { spawnedAppearanceBytes_ = 0; spawnedFacialFeatures_ = 0; - // --- Mount state --- - mountInstanceId_ = 0; - mountModelId_ = 0; - pendingMountDisplayId_ = 0; - - // --- Creature instance tracking --- - creatureInstances_.clear(); - creatureModelIds_.clear(); - creatureRenderPosCache_.clear(); - creatureWeaponsAttached_.clear(); - creatureWeaponAttachAttempts_.clear(); - creatureWasMoving_.clear(); - creatureWasSwimming_.clear(); - creatureWasFlying_.clear(); - creatureWasWalking_.clear(); - creatureSwimmingState_.clear(); - creatureWalkingState_.clear(); - creatureFlyingState_.clear(); - deadCreatureGuids_.clear(); - nonRenderableCreatureDisplayIds_.clear(); - creaturePermanentFailureGuids_.clear(); - modelIdIsWolfLike_.clear(); - displayIdTexturesApplied_.clear(); - charSectionsCache_.clear(); - charSectionsCacheBuilt_ = false; - - // Wait for any in-flight async creature loads before clearing state - for (auto& load : asyncCreatureLoads_) { - if (load.future.valid()) load.future.wait(); - } - asyncCreatureLoads_.clear(); - asyncCreatureDisplayLoads_.clear(); - - // --- Creature spawn queues --- - pendingCreatureSpawns_.clear(); - pendingCreatureSpawnGuids_.clear(); - creatureSpawnRetryCounts_.clear(); - - // --- Player instance tracking --- - playerInstances_.clear(); - onlinePlayerAppearance_.clear(); - pendingOnlinePlayerEquipment_.clear(); - deferredEquipmentQueue_.clear(); - pendingPlayerSpawns_.clear(); - pendingPlayerSpawnGuids_.clear(); - - // --- GameObject instance tracking --- - gameObjectInstances_.clear(); - pendingGameObjectSpawns_.clear(); - pendingTransportMoves_.clear(); - pendingTransportRegistrations_.clear(); - pendingTransportDoodadBatches_.clear(); + // --- Reset all EntitySpawner state (mount, creatures, players, GOs, queues, caches) --- + if (entitySpawner_) entitySpawner_->resetAllState(); world.reset(); @@ -1323,10 +1270,7 @@ void Application::update(float deltaTime) { inGameStep = "spawn/equipment queues"; updateCheckpoint = "in_game: spawn/equipment queues"; runInGameStage("spawn/equipment queues", [&] { - processPlayerSpawnQueue(); - processCreatureSpawnQueue(); - processAsyncNpcCompositeResults(); - processDeferredEquipmentQueue(); + if (entitySpawner_) entitySpawner_->update(); if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { cr->processPendingNormalMaps(4); } @@ -1357,7 +1301,7 @@ void Application::update(float deltaTime) { if (entity->getType() != game::ObjectType::UNIT) continue; auto unit = std::dynamic_pointer_cast(entity); if (!unit || unit->getDisplayId() == 0) continue; - if (creatureInstances_.count(guid) || pendingCreatureSpawnGuids_.count(guid)) continue; + if (entitySpawner_->isCreatureSpawned(guid) || entitySpawner_->isCreaturePending(guid)) continue; if (havePlayerPos) { glm::vec3 pos(unit->getX(), unit->getY(), unit->getZ()); @@ -1366,13 +1310,7 @@ void Application::update(float deltaTime) { if (distSq > kResyncRadiusSq) continue; } - PendingCreatureSpawn retrySpawn{}; - retrySpawn.guid = guid; - retrySpawn.displayId = unit->getDisplayId(); - retrySpawn.x = unit->getX(); - retrySpawn.y = unit->getY(); - retrySpawn.z = unit->getZ(); - retrySpawn.orientation = unit->getOrientation(); + float retryScale = 1.0f; { using game::fieldIndex; using game::UF; uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X); @@ -1381,12 +1319,13 @@ void Application::update(float deltaTime) { if (raw != 0) { float s2 = 1.0f; std::memcpy(&s2, &raw, sizeof(float)); - if (s2 > 0.01f && s2 < 100.0f) retrySpawn.scale = s2; + if (s2 > 0.01f && s2 < 100.0f) retryScale = s2; } } } - pendingCreatureSpawns_.push_back(retrySpawn); - pendingCreatureSpawnGuids_.insert(guid); + entitySpawner_->queueCreatureSpawn(guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), + unit->getOrientation(), retryScale); } } } @@ -1394,14 +1333,12 @@ void Application::update(float deltaTime) { inGameStep = "gameobject/transport queues"; updateCheckpoint = "in_game: gameobject/transport queues"; runInGameStage("gameobject/transport queues", [&] { - processGameObjectSpawnQueue(); - processPendingTransportRegistrations(); - processPendingTransportDoodads(); + // GO/transport queues handled by entitySpawner_->update() above }); inGameStep = "pending mount"; updateCheckpoint = "in_game: pending mount"; runInGameStage("processPendingMount", [&] { - processPendingMount(); + // Mount processing handled by entitySpawner_->update() above }); // Update 3D quest markers above NPCs inGameStep = "quest markers"; @@ -1863,23 +1800,36 @@ void Application::update(float deltaTime) { } } const float syncRadiusSq = 320.0f * 320.0f; - for (const auto& [guid, instanceId] : creatureInstances_) { + auto& _creatureInstances = entitySpawner_->getCreatureInstances(); + auto& _creatureWeaponsAttached = entitySpawner_->getCreatureWeaponsAttached(); + auto& _creatureWeaponAttachAttempts = entitySpawner_->getCreatureWeaponAttachAttempts(); + auto& _creatureModelIds = entitySpawner_->getCreatureModelIds(); + auto& _modelIdIsWolfLike = entitySpawner_->getModelIdIsWolfLike(); + auto& _creatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache(); + auto& _creatureSwimmingState = entitySpawner_->getCreatureSwimmingState(); + auto& _creatureWalkingState = entitySpawner_->getCreatureWalkingState(); + auto& _creatureFlyingState = entitySpawner_->getCreatureFlyingState(); + auto& _creatureWasMoving = entitySpawner_->getCreatureWasMoving(); + auto& _creatureWasSwimming = entitySpawner_->getCreatureWasSwimming(); + auto& _creatureWasFlying = entitySpawner_->getCreatureWasFlying(); + auto& _creatureWasWalking = entitySpawner_->getCreatureWasWalking(); + for (const auto& [guid, instanceId] : _creatureInstances) { auto entity = gameHandler->getEntityManager().getEntity(guid); if (!entity || entity->getType() != game::ObjectType::UNIT) continue; if (npcWeaponRetryTick && - weaponAttachesThisTick < MAX_WEAPON_ATTACHES_PER_TICK && - !creatureWeaponsAttached_.count(guid)) { + weaponAttachesThisTick < EntitySpawner::MAX_WEAPON_ATTACHES_PER_TICK && + !_creatureWeaponsAttached.count(guid)) { uint8_t attempts = 0; - auto itAttempts = creatureWeaponAttachAttempts_.find(guid); - if (itAttempts != creatureWeaponAttachAttempts_.end()) attempts = itAttempts->second; + auto itAttempts = _creatureWeaponAttachAttempts.find(guid); + if (itAttempts != _creatureWeaponAttachAttempts.end()) attempts = itAttempts->second; if (attempts < 30) { weaponAttachesThisTick++; - if (tryAttachCreatureVirtualWeapons(guid, instanceId)) { - creatureWeaponsAttached_.insert(guid); - creatureWeaponAttachAttempts_.erase(guid); + if (entitySpawner_->tryAttachCreatureVirtualWeapons(guid, instanceId)) { + _creatureWeaponsAttached.insert(guid); + _creatureWeaponAttachAttempts.erase(guid); } else { - creatureWeaponAttachAttempts_[guid] = static_cast(attempts + 1); + _creatureWeaponAttachAttempts[guid] = static_cast(attempts + 1); } } } @@ -1932,11 +1882,11 @@ void Application::update(float deltaTime) { // Species/model-specific spacing for wolf-like creatures (their lunge anims // often put head/torso inside the player capsule). - auto mit = creatureModelIds_.find(guid); - if (mit != creatureModelIds_.end()) { + auto mit = _creatureModelIds.find(guid); + if (mit != _creatureModelIds.end()) { uint32_t mid = mit->second; - auto wolfIt = modelIdIsWolfLike_.find(mid); - if (wolfIt == modelIdIsWolfLike_.end()) { + auto wolfIt = _modelIdIsWolfLike.find(mid); + if (wolfIt == _modelIdIsWolfLike.end()) { bool isWolf = false; if (const auto* md = charRenderer->getModelData(mid)) { std::string modelName = md->name; @@ -1945,7 +1895,7 @@ void Application::update(float deltaTime) { isWolf = (modelName.find("wolf") != std::string::npos || modelName.find("worg") != std::string::npos); } - wolfIt = modelIdIsWolfLike_.emplace(mid, isWolf).first; + wolfIt = _modelIdIsWolfLike.emplace(mid, isWolf).first; } if (wolfIt->second) { minSep = std::max(minSep, 2.45f); @@ -1965,10 +1915,10 @@ void Application::update(float deltaTime) { } } - auto posIt = creatureRenderPosCache_.find(guid); - if (posIt == creatureRenderPosCache_.end()) { + auto posIt = _creatureRenderPosCache.find(guid); + if (posIt == _creatureRenderPosCache.end()) { charRenderer->setInstancePosition(instanceId, renderPos); - creatureRenderPosCache_[guid] = renderPos; + _creatureRenderPosCache[guid] = renderPos; } else { const glm::vec3 prevPos = posIt->second; float ddx2 = renderPos.x - prevPos.x; @@ -2008,13 +1958,13 @@ void Application::update(float deltaTime) { // WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim. // Only switch on transitions to avoid resetting animation time. // Don't override Death (1) animation. - const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; - const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; - const bool isFlyingNow = creatureFlyingState_.count(guid) > 0; - bool prevMoving = creatureWasMoving_[guid]; - bool prevSwimming = creatureWasSwimming_[guid]; - bool prevFlying = creatureWasFlying_[guid]; - bool prevWalking = creatureWasWalking_[guid]; + const bool isSwimmingNow = _creatureSwimmingState.count(guid) > 0; + const bool isWalkingNow = _creatureWalkingState.count(guid) > 0; + const bool isFlyingNow = _creatureFlyingState.count(guid) > 0; + bool prevMoving = _creatureWasMoving[guid]; + bool prevSwimming = _creatureWasSwimming[guid]; + bool prevFlying = _creatureWasFlying[guid]; + bool prevWalking = _creatureWasWalking[guid]; // Trigger animation update on any locomotion-state transition, not just // moving/idle — e.g. creature lands while still moving → FlyForward→Run, // or server changes WALKING flag while creature is already running → Walk. @@ -2023,10 +1973,10 @@ void Application::update(float deltaTime) { (isFlyingNow != prevFlying) || (isWalkingNow != prevWalking && isMovingNow); if (stateChanged) { - creatureWasMoving_[guid] = isMovingNow; - creatureWasSwimming_[guid] = isSwimmingNow; - creatureWasFlying_[guid] = isFlyingNow; - creatureWasWalking_[guid] = isWalkingNow; + _creatureWasMoving[guid] = isMovingNow; + _creatureWasSwimming[guid] = isSwimmingNow; + _creatureWasFlying[guid] = isFlyingNow; + _creatureWasWalking[guid] = isWalkingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { @@ -2054,7 +2004,7 @@ void Application::update(float deltaTime) { std::chrono::steady_clock::now() - creatureSyncStart).count(); if (csMs > 5.0f) { LOG_WARNING("SLOW update stage 'creature render sync': ", csMs, "ms (", - creatureInstances_.size(), " creatures)"); + entitySpawner_->getCreatureInstances().size(), " creatures)"); } } @@ -2073,7 +2023,16 @@ void Application::update(float deltaTime) { } const float pSyncRadiusSq = 320.0f * 320.0f; - for (const auto& [guid, instanceId] : playerInstances_) { + auto& _playerInstances = entitySpawner_->getPlayerInstances(); + auto& _pCreatureWasMoving = entitySpawner_->getCreatureWasMoving(); + auto& _pCreatureWasSwimming = entitySpawner_->getCreatureWasSwimming(); + auto& _pCreatureWasFlying = entitySpawner_->getCreatureWasFlying(); + auto& _pCreatureWasWalking = entitySpawner_->getCreatureWasWalking(); + auto& _pCreatureSwimmingState = entitySpawner_->getCreatureSwimmingState(); + auto& _pCreatureWalkingState = entitySpawner_->getCreatureWalkingState(); + auto& _pCreatureFlyingState = entitySpawner_->getCreatureFlyingState(); + auto& _pCreatureRenderPosCache = entitySpawner_->getCreatureRenderPosCache(); + for (const auto& [guid, instanceId] : _playerInstances) { auto entity = gameHandler->getEntityManager().getEntity(guid); if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; @@ -2088,10 +2047,10 @@ void Application::update(float deltaTime) { glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - auto posIt = creatureRenderPosCache_.find(guid); - if (posIt == creatureRenderPosCache_.end()) { + auto posIt = _pCreatureRenderPosCache.find(guid); + if (posIt == _pCreatureRenderPosCache.end()) { charRenderer->setInstancePosition(instanceId, renderPos); - creatureRenderPosCache_[guid] = renderPos; + _pCreatureRenderPosCache[guid] = renderPos; } else { const glm::vec3 prevPos = posIt->second; float ddx2 = renderPos.x - prevPos.x; @@ -2116,22 +2075,22 @@ void Application::update(float deltaTime) { posIt->second = renderPos; // Drive movement animation (same logic as creatures) - const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; - const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; - const bool isFlyingNow = creatureFlyingState_.count(guid) > 0; - bool prevMoving = creatureWasMoving_[guid]; - bool prevSwimming = creatureWasSwimming_[guid]; - bool prevFlying = creatureWasFlying_[guid]; - bool prevWalking = creatureWasWalking_[guid]; + const bool isSwimmingNow = _pCreatureSwimmingState.count(guid) > 0; + const bool isWalkingNow = _pCreatureWalkingState.count(guid) > 0; + const bool isFlyingNow = _pCreatureFlyingState.count(guid) > 0; + bool prevMoving = _pCreatureWasMoving[guid]; + bool prevSwimming = _pCreatureWasSwimming[guid]; + bool prevFlying = _pCreatureWasFlying[guid]; + bool prevWalking = _pCreatureWasWalking[guid]; const bool stateChanged = (isMovingNow != prevMoving) || (isSwimmingNow != prevSwimming) || (isFlyingNow != prevFlying) || (isWalkingNow != prevWalking && isMovingNow); if (stateChanged) { - creatureWasMoving_[guid] = isMovingNow; - creatureWasSwimming_[guid] = isSwimmingNow; - creatureWasFlying_[guid] = isFlyingNow; - creatureWasWalking_[guid] = isWalkingNow; + _pCreatureWasMoving[guid] = isMovingNow; + _pCreatureWasSwimming[guid] = isSwimmingNow; + _pCreatureWasFlying[guid] = isFlyingNow; + _pCreatureWasWalking[guid] = isWalkingNow; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); if (!gotState || curAnimId != 1 /*Death*/) { @@ -2161,7 +2120,7 @@ void Application::update(float deltaTime) { std::chrono::steady_clock::now() - playerSyncStart).count(); if (psMs > 5.0f) { LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (", - playerInstances_.size(), " players)"); + entitySpawner_->getPlayerInstances().size(), " players)"); } } @@ -2396,37 +2355,14 @@ void Application::setupUICallbacks() { if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) { LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); - // Pending spawn queues and failure caches - pendingCreatureSpawns_.clear(); - pendingCreatureSpawnGuids_.clear(); - creatureSpawnRetryCounts_.clear(); - creaturePermanentFailureGuids_.clear(); // Clear so previously-failed GUIDs can retry - deadCreatureGuids_.clear(); // Will be re-populated from fresh server state - pendingPlayerSpawns_.clear(); - pendingPlayerSpawnGuids_.clear(); - pendingOnlinePlayerEquipment_.clear(); - deferredEquipmentQueue_.clear(); - pendingGameObjectSpawns_.clear(); + // Pending spawn queues and failure caches — clear so previously-failed GUIDs can retry. + // Dead creature guids will be re-populated from fresh server state. + entitySpawner_->clearAllQueues(); // Properly despawn all tracked instances from the renderer - { - std::vector guids; - guids.reserve(creatureInstances_.size()); - for (const auto& [g, _] : creatureInstances_) guids.push_back(g); - for (auto g : guids) despawnOnlineCreature(g); - } - { - std::vector guids; - guids.reserve(playerInstances_.size()); - for (const auto& [g, _] : playerInstances_) guids.push_back(g); - for (auto g : guids) despawnOnlinePlayer(g); - } - { - std::vector guids; - guids.reserve(gameObjectInstances_.size()); - for (const auto& [g, _] : gameObjectInstances_) guids.push_back(g); - for (auto g : guids) despawnOnlineGameObject(g); - } + entitySpawner_->despawnAllCreatures(); + entitySpawner_->despawnAllPlayers(); + entitySpawner_->despawnAllGameObjects(); // Update player position and re-queue nearby tiles (same logic as teleport) glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); @@ -2805,10 +2741,9 @@ void Application::setupUICallbacks() { gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { // Queue spawns to avoid hanging when many creatures appear at once. // Deduplicate so repeated updates don't flood pending queue. - if (creatureInstances_.count(guid)) return; - if (pendingCreatureSpawnGuids_.count(guid)) return; - pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation, scale}); - pendingCreatureSpawnGuids_.insert(guid); + if (entitySpawner_->isCreatureSpawned(guid)) return; + if (entitySpawner_->isCreaturePending(guid)) return; + entitySpawner_->queueCreatureSpawn(guid, displayId, x, y, z, orientation, scale); }); // Player spawn callback (online mode) - spawn player models with correct textures @@ -2830,10 +2765,9 @@ void Application::setupUICallbacks() { (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { return; } - 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); + if (entitySpawner_->isPlayerSpawned(guid)) return; + if (entitySpawner_->isPlayerPending(guid)) return; + entitySpawner_->queuePlayerSpawn(guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation); }); // Online player equipment callback - apply armor geosets/skin overlays per player instance. @@ -2843,32 +2777,33 @@ void Application::setupUICallbacks() { // Queue equipment compositing instead of doing it immediately — // compositeWithRegions is expensive (file I/O + CPU blit + GPU upload) // and causes frame stutters if multiple players update at once. - deferredEquipmentQueue_.push_back({guid, {displayInfoIds, inventoryTypes}}); + entitySpawner_->queuePlayerEquipment(guid, displayInfoIds, inventoryTypes); }); // Creature despawn callback (online mode) - remove creature models gameHandler->setCreatureDespawnCallback([this](uint64_t guid) { - despawnOnlineCreature(guid); + entitySpawner_->despawnCreature(guid); }); gameHandler->setPlayerDespawnCallback([this](uint64_t guid) { - despawnOnlinePlayer(guid); + entitySpawner_->despawnPlayer(guid); }); // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) gameHandler->setGameObjectSpawnCallback([this](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}); + entitySpawner_->queueGameObjectSpawn(guid, entry, displayId, x, y, z, orientation, scale); }); // GameObject despawn callback (online mode) - remove static models gameHandler->setGameObjectDespawnCallback([this](uint64_t guid) { - despawnOnlineGameObject(guid); + entitySpawner_->despawnGameObject(guid); }); // GameObject custom animation callback (e.g. chest opening) gameHandler->setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t /*animId*/) { - auto it = gameObjectInstances_.find(guid); - if (it == gameObjectInstances_.end() || !renderer) return; + auto& goInstances = entitySpawner_->getGameObjectInstances(); + auto it = goInstances.find(guid); + if (it == goInstances.end() || !renderer) return; auto& info = it->second; if (!info.isWmo) { if (auto* m2r = renderer->getM2Renderer()) { @@ -3054,18 +2989,17 @@ void Application::setupUICallbacks() { gameHandler->setMountCallback([this](uint32_t mountDisplayId) { if (mountDisplayId == 0) { // Dismount is instant (no loading needed) - if (renderer && renderer->getCharacterRenderer() && mountInstanceId_ != 0) { - renderer->getCharacterRenderer()->removeInstance(mountInstanceId_); - mountInstanceId_ = 0; + if (renderer && renderer->getCharacterRenderer() && entitySpawner_->getMountInstanceId() != 0) { + renderer->getCharacterRenderer()->removeInstance(entitySpawner_->getMountInstanceId()); + entitySpawner_->clearMountState(); } - mountModelId_ = 0; - pendingMountDisplayId_ = 0; + entitySpawner_->setMountDisplayId(0); if (renderer) renderer->clearMount(); LOG_INFO("Dismounted"); return; } // Queue the mount for processing in the next update() frame - pendingMountDisplayId_ = mountDisplayId; + entitySpawner_->setMountDisplayId(mountDisplayId); }); // Taxi precache callback - preload terrain tiles along flight path @@ -3153,11 +3087,10 @@ void Application::setupUICallbacks() { if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = 0; bool isPlayer = false; - auto pit = playerInstances_.find(guid); - if (pit != playerInstances_.end()) { instanceId = pit->second; isPlayer = true; } + instanceId = entitySpawner_->getPlayerInstanceId(guid); + if (instanceId != 0) { isPlayer = true; } else { - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) instanceId = it->second; + instanceId = entitySpawner_->getCreatureInstanceId(guid); } if (instanceId != 0) { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); @@ -3179,15 +3112,16 @@ void Application::setupUICallbacks() { if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) { cr->playAnimation(instanceId, 5u, /*loop=*/true); } - creatureWasMoving_[guid] = true; + entitySpawner_->getCreatureWasMoving()[guid] = true; } } } }); gameHandler->setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { - auto it = gameObjectInstances_.find(guid); - if (it == gameObjectInstances_.end() || !renderer) { + auto& goInstMap = entitySpawner_->getGameObjectInstances(); + auto it = goInstMap.find(guid); + if (it == goInstMap.end() || !renderer) { return; } glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); @@ -3213,25 +3147,18 @@ void Application::setupUICallbacks() { if (!renderer) return; // Get the GameObject instance now so late queue processing can rely on stable IDs. - auto it = gameObjectInstances_.find(guid); - if (it == gameObjectInstances_.end()) { + auto& goInstances2 = entitySpawner_->getGameObjectInstances(); + auto it = goInstances2.find(guid); + if (it == goInstances2.end()) { LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); return; } - auto pendingIt = std::find_if( - pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), - [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); - if (pendingIt != pendingTransportRegistrations_.end()) { - pendingIt->entry = entry; - pendingIt->displayId = displayId; - pendingIt->x = x; - pendingIt->y = y; - pendingIt->z = z; - pendingIt->orientation = orientation; + auto pendingIt = entitySpawner_->hasTransportRegistrationPending(guid); + if (pendingIt) { + entitySpawner_->updateTransportRegistration(guid, displayId, x, y, z, orientation); } else { - pendingTransportRegistrations_.push_back( - PendingTransportRegistration{guid, entry, displayId, x, y, z, orientation}); + entitySpawner_->queueTransportRegistration(guid, entry, displayId, x, y, z, orientation); } }); @@ -3246,11 +3173,8 @@ void Application::setupUICallbacks() { return; } - auto pendingRegIt = std::find_if( - pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), - [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); - if (pendingRegIt != pendingTransportRegistrations_.end()) { - pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; + if (entitySpawner_->hasTransportRegistrationPending(guid)) { + entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); return; } @@ -3268,8 +3192,9 @@ void Application::setupUICallbacks() { uint32_t displayId = go->getDisplayId(); // Find the WMO instance for this transport (should exist from earlier GameObject spawn) - auto it = gameObjectInstances_.find(guid); - if (it != gameObjectInstances_.end()) { + auto& goInstances3 = entitySpawner_->getGameObjectInstances(); + auto it = goInstances3.find(guid); + if (it != goInstances3.end()) { uint32_t wmoInstanceId = it->second.instanceId; // TransportAnimation.dbc is indexed by GameObject entry @@ -3336,13 +3261,13 @@ void Application::setupUICallbacks() { } } } else { - pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; + entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, " - WMO instance not found yet (queued move for replay)"); return; } } else { - pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; + entitySpawner_->setTransportPendingMove(guid, x, y, z, orientation); LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, " - entity not found in EntityManager (queued move for replay)"); return; @@ -3372,15 +3297,10 @@ void Application::setupUICallbacks() { // NPC/player death callback (online mode) - play death animation gameHandler->setNpcDeathCallback([this](uint64_t guid) { - deadCreatureGuids_.insert(guid); + entitySpawner_->markCreatureDead(guid); if (!renderer || !renderer->getCharacterRenderer()) return; - uint32_t instanceId = 0; - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) instanceId = it->second; - else { - auto pit = playerInstances_.find(guid); - if (pit != playerInstances_.end()) instanceId = pit->second; - } + uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death } @@ -3388,15 +3308,10 @@ void Application::setupUICallbacks() { // NPC/player respawn callback (online mode) - reset to idle animation gameHandler->setNpcRespawnCallback([this](uint64_t guid) { - deadCreatureGuids_.erase(guid); + entitySpawner_->unmarkCreatureDead(guid); if (!renderer || !renderer->getCharacterRenderer()) return; - uint32_t instanceId = 0; - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) instanceId = it->second; - else { - auto pit = playerInstances_.find(guid); - if (pit != playerInstances_.end()) instanceId = pit->second; - } + uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle } @@ -3405,13 +3320,8 @@ void Application::setupUICallbacks() { // NPC/player swing callback (online mode) - play attack animation gameHandler->setNpcSwingCallback([this](uint64_t guid) { if (!renderer || !renderer->getCharacterRenderer()) return; - uint32_t instanceId = 0; - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) instanceId = it->second; - else { - auto pit = playerInstances_.find(guid); - if (pit != playerInstances_.end()) instanceId = pit->second; - } + uint32_t instanceId = entitySpawner_->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0) { auto* cr = renderer->getCharacterRenderer(); // Try weapon-appropriate attack anim: 17=1H, 18=2H, 16=unarmed fallback @@ -3435,15 +3345,8 @@ void Application::setupUICallbacks() { if (!renderer) return; auto* cr = renderer->getCharacterRenderer(); if (!cr) return; - uint32_t instanceId = 0; - { - auto it = playerInstances_.find(guid); - if (it != playerInstances_.end()) instanceId = it->second; - } - if (instanceId == 0) { - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) instanceId = it->second; - } + uint32_t instanceId = entitySpawner_->getPlayerInstanceId(guid); + if (instanceId == 0) instanceId = entitySpawner_->getCreatureInstanceId(guid); if (instanceId == 0) return; // Don't override Death animation (1) uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f; @@ -3459,12 +3362,15 @@ void Application::setupUICallbacks() { const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; const bool isFlying = (moveFlags & static_cast(game::MovementFlags::FLYING)) != 0; - if (isSwimming) creatureSwimmingState_[guid] = true; - else creatureSwimmingState_.erase(guid); - if (isWalking) creatureWalkingState_[guid] = true; - else creatureWalkingState_.erase(guid); - if (isFlying) creatureFlyingState_[guid] = true; - else creatureFlyingState_.erase(guid); + auto& swimState = entitySpawner_->getCreatureSwimmingState(); + auto& walkState = entitySpawner_->getCreatureWalkingState(); + auto& flyState = entitySpawner_->getCreatureFlyingState(); + if (isSwimming) swimState[guid] = true; + else swimState.erase(guid); + if (isWalking) walkState[guid] = true; + else walkState.erase(guid); + if (isFlying) flyState[guid] = true; + else flyState.erase(guid); }); // Emote animation callback — play server-driven emote animations on NPCs and other players @@ -3473,18 +3379,14 @@ void Application::setupUICallbacks() { auto* cr = renderer->getCharacterRenderer(); if (!cr) return; // Look up creature instance first, then online players - { - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) { - cr->playAnimation(it->second, emoteAnim, false); - return; - } + uint32_t emoteInstanceId = entitySpawner_->getCreatureInstanceId(guid); + if (emoteInstanceId != 0) { + cr->playAnimation(emoteInstanceId, emoteAnim, false); + return; } - { - auto it = playerInstances_.find(guid); - if (it != playerInstances_.end()) { - cr->playAnimation(it->second, emoteAnim, false); - } + emoteInstanceId = entitySpawner_->getPlayerInstanceId(guid); + if (emoteInstanceId != 0) { + cr->playAnimation(emoteInstanceId, emoteAnim, false); } }); @@ -3506,16 +3408,16 @@ void Application::setupUICallbacks() { } // Check creatures and other online players { - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end()) { - if (start) cr->playAnimation(it->second, castAnim, false); + uint32_t cInst = entitySpawner_->getCreatureInstanceId(guid); + if (cInst != 0) { + if (start) cr->playAnimation(cInst, castAnim, false); return; } } { - auto it = playerInstances_.find(guid); - if (it != playerInstances_.end()) { - if (start) cr->playAnimation(it->second, castAnim, false); + uint32_t pInst = entitySpawner_->getPlayerInstanceId(guid); + if (pInst != 0) { + if (start) cr->playAnimation(pInst, castAnim, false); } } }); @@ -3575,7 +3477,7 @@ void Application::setupUICallbacks() { if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); - voiceType = detectVoiceTypeFromDisplayId(displayId); + voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playGreeting(guid, voiceType, renderPos); @@ -3592,7 +3494,7 @@ void Application::setupUICallbacks() { if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); - voiceType = detectVoiceTypeFromDisplayId(displayId); + voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playFarewell(guid, voiceType, renderPos); @@ -3609,7 +3511,7 @@ void Application::setupUICallbacks() { if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); - voiceType = detectVoiceTypeFromDisplayId(displayId); + voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playVendor(guid, voiceType, renderPos); @@ -3626,7 +3528,7 @@ void Application::setupUICallbacks() { if (entity && entity->getType() == game::ObjectType::UNIT) { auto unit = std::static_pointer_cast(entity); uint32_t displayId = unit->getDisplayId(); - voiceType = detectVoiceTypeFromDisplayId(displayId); + voiceType = entitySpawner_->detectVoiceTypeFromDisplayId(displayId); } renderer->getNpcVoiceManager()->playAggro(guid, voiceType, renderPos); @@ -4215,7 +4117,7 @@ void Application::loadEquippedWeapons() { } } - uint32_t weaponModelId = nextWeaponModelId_++; + uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId(); bool ok = charRenderer->attachWeapon(charInstanceId, ws.attachmentId, weaponModel, weaponModelId, texturePath); if (ok) { @@ -4224,159 +4126,6 @@ void Application::loadEquippedWeapons() { } } -bool Application::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 Application::buildFactionHostilityMap(uint8_t playerRace) { if (!assetManager || !assetManager->isInitialized() || !gameHandler) return; @@ -4572,17 +4321,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float LOG_WARNING("Map change: cleaning up old map ", loadedMapId_, " before loading map ", mapId); // Clear pending queues first (these don't touch GPU resources) - pendingCreatureSpawns_.clear(); - pendingCreatureSpawnGuids_.clear(); - creatureSpawnRetryCounts_.clear(); - pendingPlayerSpawns_.clear(); - pendingPlayerSpawnGuids_.clear(); - pendingOnlinePlayerEquipment_.clear(); - deferredEquipmentQueue_.clear(); - pendingGameObjectSpawns_.clear(); - pendingTransportMoves_.clear(); - pendingTransportRegistrations_.clear(); - pendingTransportDoodadBatches_.clear(); + entitySpawner_->clearAllQueues(); if (renderer) { // Clear all world geometry from old map (including textures/models). @@ -4621,31 +4360,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float } // Clear application-level instance tracking (after renderer cleanup) - creatureInstances_.clear(); - creatureModelIds_.clear(); - creatureRenderPosCache_.clear(); - creatureWeaponsAttached_.clear(); - creatureWeaponAttachAttempts_.clear(); - deadCreatureGuids_.clear(); - nonRenderableCreatureDisplayIds_.clear(); - creaturePermanentFailureGuids_.clear(); - modelIdIsWolfLike_.clear(); - displayIdTexturesApplied_.clear(); - charSectionsCache_.clear(); - charSectionsCacheBuilt_ = false; - for (auto& load : asyncCreatureLoads_) { - if (load.future.valid()) load.future.wait(); - } - asyncCreatureLoads_.clear(); - asyncCreatureDisplayLoads_.clear(); - - playerInstances_.clear(); - onlinePlayerAppearance_.clear(); - - gameObjectInstances_.clear(); - gameObjectDisplayIdModelCache_.clear(); - gameObjectDisplayIdWmoCache_.clear(); - gameObjectDisplayIdFailedCache_.clear(); + entitySpawner_->resetAllState(); // Force player character re-spawn on new map playerCharacterSpawned = false; @@ -5227,41 +4942,26 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float auto* app = this; gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) { - app->deadCreatureGuids_.insert(guid); - uint32_t instanceId = 0; - auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end()) instanceId = it->second; - else { - auto pit = app->playerInstances_.find(guid); - if (pit != app->playerInstances_.end()) instanceId = pit->second; - } + app->entitySpawner_->markCreatureDead(guid); + uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0 && cr) { cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death } }); gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) { - app->deadCreatureGuids_.erase(guid); - uint32_t instanceId = 0; - auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end()) instanceId = it->second; - else { - auto pit = app->playerInstances_.find(guid); - if (pit != app->playerInstances_.end()) instanceId = pit->second; - } + app->entitySpawner_->unmarkCreatureDead(guid); + uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0 && cr) { cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle } }); gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) { - uint32_t instanceId = 0; - auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end()) instanceId = it->second; - else { - auto pit = app->playerInstances_.find(guid); - if (pit != app->playerInstances_.end()) instanceId = pit->second; - } + uint32_t instanceId = app->entitySpawner_->getCreatureInstanceId(guid); + if (instanceId == 0) instanceId = app->entitySpawner_->getPlayerInstanceId(guid); if (instanceId != 0 && cr) { cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1 } @@ -5310,27 +5010,12 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float } if (world) world->update(1.0f / 60.0f); - processPlayerSpawnQueue(); - // Keep warmup bounded: unbounded queue draining can stall the main thread - // long enough to trigger socket timeouts. - processCreatureSpawnQueue(false); - processAsyncNpcCompositeResults(false); - // Process equipment queue with a small bounded burst during warmup. - for (int i = 0; i < 2 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { - processDeferredEquipmentQueue(); - } + // Process all spawn/equipment/transport queues during warmup + entitySpawner_->update(); if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { cr->processPendingNormalMaps(4); } - - // Keep warmup responsive: process gameobject queue with the same bounded - // budget logic used in-world instead of draining everything in one tick. - processGameObjectSpawnQueue(); - - processPendingTransportRegistrations(); - processPendingTransportDoodads(); - processPendingMount(); updateQuestMarkers(); // Update renderer (terrain streaming, animations) @@ -5342,15 +5027,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float const float elapsed = std::chrono::duration(now - warmupStart).count(); // Check if all queues are drained - bool queuesEmpty = - pendingCreatureSpawns_.empty() && - asyncCreatureLoads_.empty() && - asyncNpcCompositeLoads_.empty() && - deferredEquipmentQueue_.empty() && - asyncEquipmentLoads_.empty() && - pendingGameObjectSpawns_.empty() && - asyncGameObjectLoads_.empty() && - pendingPlayerSpawns_.empty(); + bool queuesEmpty = !entitySpawner_->hasWorkPending(); if (queuesEmpty) { idleIterations++; @@ -5491,4534 +5168,20 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float } } -void Application::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 Application::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 Application::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_WARNING("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 Application::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 Application::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 Application::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"); - } - - gameObjectLookupsBuilt_ = true; -} - -std::string Application::getGameObjectModelPathForDisplayId(uint32_t displayId) const { - auto it = gameObjectDisplayIdToPath_.find(displayId); - if (it == gameObjectDisplayIdToPath_.end()) return ""; - return it->second; -} - +// Render bounds/position queries — delegates to EntitySpawner bool Application::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); + if (entitySpawner_) return entitySpawner_->getRenderBoundsForGuid(guid, outCenter, outRadius); + return false; } bool Application::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); + if (entitySpawner_) return entitySpawner_->getRenderFootZForGuid(guid, outFootZ); + return false; } bool Application::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 Application::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 Application::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.get(); - 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; - const uint32_t texRegionFields[8] = { - idiL ? (*idiL)["TextureArmUpper"] : 14u, - idiL ? (*idiL)["TextureArmLower"] : 15u, - idiL ? (*idiL)["TextureHand"] : 16u, - idiL ? (*idiL)["TextureTorsoUpper"]: 17u, - idiL ? (*idiL)["TextureTorsoLower"]: 18u, - idiL ? (*idiL)["TextureLegUpper"] : 19u, - idiL ? (*idiL)["TextureLegLower"] : 20u, - idiL ? (*idiL)["TextureFoot"] : 21u, - }; - 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, 1, false); // Death - } else { - charRenderer->playAnimation(instanceId, 0, true); // Idle - } - 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, ")"); -} - -void Application::spawnOnlinePlayer(uint64_t guid, - uint8_t raceId, - uint8_t genderId, - uint32_t appearanceBytes, - uint8_t facialFeatures, - float x, float y, float z, float orientation) { - if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; - if (playerInstances_.count(guid)) return; - - // Skip local player — already spawned as the main character - if (gameHandler) { - uint64_t localGuid = gameHandler->getPlayerGuid(); - uint64_t activeGuid = gameHandler->getActiveCharacterGuid(); - if ((localGuid != 0 && guid == localGuid) || - (activeGuid != 0 && guid == activeGuid) || - (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { - return; - } - } - auto* charRenderer = renderer->getCharacterRenderer(); - - // Base geometry model: cache by (race, gender) - uint32_t cacheKey = (static_cast(raceId) << 8) | static_cast(genderId & 0xFF); - uint32_t modelId = 0; - auto itCache = playerModelCache_.find(cacheKey); - if (itCache != playerModelCache_.end()) { - modelId = itCache->second; - } else { - game::Race race = static_cast(raceId); - game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE; - std::string m2Path = game::getPlayerModelPath(race, gender); - if (m2Path.empty()) { - LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec, - " race=", static_cast(raceId), " gender=", static_cast(genderId)); - return; - } - - // Parse modelDir/baseName for skin/anim loading - std::string modelDir; - std::string baseName; - { - size_t slash = m2Path.rfind('\\'); - if (slash != std::string::npos) { - modelDir = m2Path.substr(0, slash + 1); - baseName = m2Path.substr(slash + 1); - } else { - baseName = m2Path; - } - size_t dot = baseName.rfind('.'); - if (dot != std::string::npos) baseName = baseName.substr(0, dot); - } - - auto m2Data = assetManager->readFile(m2Path); - if (m2Data.empty()) { - LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path); - return; - } - - pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (model.vertices.empty()) { - LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); - return; - } - - // Skin file (only for WotLK M2s - vanilla has embedded skin) - std::string skinPath = modelDir + baseName + "00.skin"; - auto skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, model); - } - - // After skin loading, full model must be valid (vertices + indices) - if (!model.isValid()) { - LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); - return; - } - - // Load only core external animations (stand/walk/run) to avoid stalls - for (uint32_t si = 0; si < model.sequences.size(); si++) { - if (!(model.sequences[si].flags & 0x20)) { - uint32_t animId = model.sequences[si].id; - if (animId != 0 && animId != 4 && animId != 5) continue; - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), - "%s%s%04u-%02u.anim", - modelDir.c_str(), - baseName.c_str(), - animId, - model.sequences[si].variationIndex); - auto animData = assetManager->readFileOptional(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); - } - } - } - - modelId = nextPlayerModelId_++; - if (!charRenderer->loadModel(model, modelId)) { - LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path); - return; - } - - playerModelCache_[cacheKey] = modelId; - } - - // Determine texture slots once per model - { - auto [slotIt, inserted] = playerTextureSlotsByModelId_.try_emplace(modelId); - if (inserted) { - PlayerTextureSlots slots; - if (const auto* md = charRenderer->getModelData(modelId)) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - uint32_t t = md->textures[ti].type; - if (t == 1 && slots.skin < 0) slots.skin = static_cast(ti); - else if (t == 6 && slots.hair < 0) slots.hair = static_cast(ti); - else if (t == 8 && slots.underwear < 0) slots.underwear = static_cast(ti); - } - } - slotIt->second = slots; - } - } - - // Create instance at server position - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - float renderYaw = orientation + glm::radians(90.0f); - uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); - if (instanceId == 0) return; - - // Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides - const char* raceFolderName = "Human"; - switch (static_cast(raceId)) { - case game::Race::HUMAN: raceFolderName = "Human"; break; - case game::Race::ORC: raceFolderName = "Orc"; break; - case game::Race::DWARF: raceFolderName = "Dwarf"; break; - case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break; - case game::Race::UNDEAD: raceFolderName = "Scourge"; break; - case game::Race::TAUREN: raceFolderName = "Tauren"; break; - case game::Race::GNOME: raceFolderName = "Gnome"; break; - case game::Race::TROLL: raceFolderName = "Troll"; break; - case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break; - case game::Race::DRAENEI: raceFolderName = "Draenei"; break; - default: break; - } - const char* genderFolder = (genderId == 1) ? "Female" : "Male"; - std::string raceGender = std::string(raceFolderName) + genderFolder; - std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp"; - std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp"; - std::vector underwearPaths; - std::string hairTexturePath; - std::string faceLowerPath; - std::string faceUpperPath; - - uint8_t skinId = appearanceBytes & 0xFF; - uint8_t faceId = (appearanceBytes >> 8) & 0xFF; - uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF; - uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF; - - if (auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) { - const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; - auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL); - uint32_t targetRaceId = raceId; - uint32_t targetSexId = genderId; - - bool foundSkin = false; - bool foundUnderwear = false; - bool foundHair = false; - bool foundFaceLower = false; - - for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { - uint32_t rRace = charSectionsDbc->getUInt32(r, csF.raceId); - uint32_t rSex = charSectionsDbc->getUInt32(r, csF.sexId); - uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection); - uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex); - uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex); - - if (rRace != targetRaceId || rSex != targetSexId) continue; - - if (baseSection == 0 && !foundSkin && colorIndex == skinId) { - std::string tex1 = charSectionsDbc->getString(r, csF.texture1); - if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; } - } else if (baseSection == 3 && !foundHair && - variationIndex == hairStyleId && colorIndex == hairColorId) { - hairTexturePath = charSectionsDbc->getString(r, csF.texture1); - if (!hairTexturePath.empty()) foundHair = true; - } else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) { - for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { - std::string tex = charSectionsDbc->getString(r, f); - if (!tex.empty()) underwearPaths.push_back(tex); - } - foundUnderwear = true; - } else if (baseSection == 1 && !foundFaceLower && - variationIndex == faceId && colorIndex == skinId) { - std::string tex1 = charSectionsDbc->getString(r, csF.texture1); - std::string tex2 = charSectionsDbc->getString(r, csF.texture2); - if (!tex1.empty()) faceLowerPath = tex1; - if (!tex2.empty()) faceUpperPath = tex2; - foundFaceLower = true; - } - - if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break; - } - } - - // Composite base skin + face + underwear overlays - rendering::VkTexture* compositeTex = nullptr; - { - std::vector layers; - layers.push_back(bodySkinPath); - if (!faceLowerPath.empty()) layers.push_back(faceLowerPath); - if (!faceUpperPath.empty()) layers.push_back(faceUpperPath); - for (const auto& up : underwearPaths) layers.push_back(up); - if (layers.size() > 1) { - compositeTex = charRenderer->compositeTextures(layers); - } else { - compositeTex = charRenderer->loadTexture(bodySkinPath); - } - } - - rendering::VkTexture* hairTex = nullptr; - if (!hairTexturePath.empty()) { - hairTex = charRenderer->loadTexture(hairTexturePath); - } - rendering::VkTexture* underwearTex = nullptr; - if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); - else underwearTex = charRenderer->loadTexture(pelvisPath); - - const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; - if (slots.skin >= 0 && compositeTex) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); - } - if (slots.hair >= 0 && hairTex) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); - } - if (slots.underwear >= 0 && underwearTex) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); - } - - // Geosets: body + hair/facial hair selections - std::unordered_set activeGeosets; - // Body parts (group 0: IDs 0-99, some models use up to 27) - for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); - activeGeosets.insert(static_cast(100 + hairStyleId + 1)); - activeGeosets.insert(static_cast(200 + facialFeatures + 1)); - activeGeosets.insert(kGeosetBareForearms); - activeGeosets.insert(kGeosetBareShins); - activeGeosets.insert(kGeosetDefaultEars); - activeGeosets.insert(kGeosetBareSleeves); - activeGeosets.insert(kGeosetDefaultKneepads); - activeGeosets.insert(kGeosetBarePants); - activeGeosets.insert(kGeosetWithCape); - activeGeosets.insert(kGeosetBareFeet); - charRenderer->setActiveGeosets(instanceId, activeGeosets); - - charRenderer->playAnimation(instanceId, 0, 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 Application::setOnlinePlayerEquipment(uint64_t guid, - const std::array& displayInfoIds, - const std::array& inventoryTypes) { - if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; - - // Skip local player — equipment handled by GameScreen::updateCharacterGeosets/Textures - // via consumeOnlineEquipmentDirty(), which fires on the same server update. - if (gameHandler) { - uint64_t localGuid = gameHandler->getPlayerGuid(); - if (localGuid != 0 && guid == localGuid) return; - } - - // If the player isn't spawned yet, store equipment until spawn. - auto appIt = onlinePlayerAppearance_.find(guid); - if (!playerInstances_.count(guid) || appIt == onlinePlayerAppearance_.end()) { - pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; - return; - } - - const OnlinePlayerAppearanceState& st = appIt->second; - - auto* charRenderer = renderer->getCharacterRenderer(); - if (!charRenderer) return; - if (st.instanceId == 0 || st.modelId == 0) return; - - if (st.bodySkinPath.empty()) { - LOG_WARNING("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_WARNING("setOnlinePlayerEquipment: guid=0x", std::hex, guid, std::dec, - " instanceId=", st.instanceId, " nonZeroDisplayIds=", nonZeroDisplay, - " head=", displayInfoIds[0], " chest=", displayInfoIds[4], - " legs=", displayInfoIds[6], " mainhand=", displayInfoIds[15]); - - auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); - if (!displayInfoDbc) return; - const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - - auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t { - if (displayInfoId == 0) return 0; - int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); - if (recIdx < 0) return 0; - return displayInfoDbc->getUInt32(static_cast(recIdx), fieldIdx); - }; - - auto findDisplayIdByInvType = [&](std::initializer_list types) -> uint32_t { - for (int s = 0; s < 19; s++) { - uint8_t inv = inventoryTypes[s]; - if (inv == 0 || displayInfoIds[s] == 0) continue; - for (uint8_t t : types) { - if (inv == t) return displayInfoIds[s]; - } - } - return 0; - }; - - auto hasInvType = [&](std::initializer_list types) -> bool { - for (int s = 0; s < 19; s++) { - uint8_t inv = inventoryTypes[s]; - if (inv == 0) continue; - for (uint8_t t : types) { - if (inv == t) return true; - } - } - return false; - }; - - // --- Geosets --- - // Mirror the same group-range logic as CharacterPreview::applyEquipment to - // keep other-player rendering consistent with the local character preview. - // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, - // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). - std::unordered_set geosets; - // Body parts (group 0: IDs 0-99, some models use up to 27) - for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); - - uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); - geosets.insert(static_cast(100 + hairStyleId + 1)); - geosets.insert(static_cast(200 + st.facialFeatures + 1)); - geosets.insert(701); // Ears - geosets.insert(kGeosetDefaultKneepads); // Kneepads - geosets.insert(kGeosetBareFeet); // Bare feet mesh - - const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; - const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; - - // Per-group defaults — overridden below when equipment provides a geoset value. - uint16_t geosetGloves = kGeosetBareForearms; - uint16_t geosetBoots = kGeosetBareShins; - uint16_t geosetSleeves = kGeosetBareSleeves; - uint16_t geosetPants = kGeosetBarePants; - - // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 - { - uint32_t did = findDisplayIdByInvType({4, 5, 20}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); - // Robe kilt → leg group 13 - uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); - if (gg3 > 0) geosetPants = static_cast(kGeosetBarePants + gg3); - } - - // Legs (invType 7) → leg group 13 - { - uint32_t did = findDisplayIdByInvType({7}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetPants = static_cast(kGeosetBarePants + gg1); - } - - // Feet/Boots (invType 8) → shin group 5 - { - uint32_t did = findDisplayIdByInvType({8}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetBoots = static_cast(501 + gg1); - } - - // Hands/Gloves (invType 10) → forearm group 4 - { - uint32_t did = findDisplayIdByInvType({10}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetGloves = static_cast(kGeosetBareForearms + gg1); - } - - // Wrists/Bracers (invType 9) → sleeve group 8 (only if chest/shirt didn't set it) - { - uint32_t did = findDisplayIdByInvType({9}); - if (did != 0 && geosetSleeves == kGeosetBareSleeves) { - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); - } - } - - // Waist/Belt (invType 6) → buckle group 18 - uint16_t geosetBelt = 0; - { - uint32_t did = findDisplayIdByInvType({6}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetBelt = static_cast(1801 + gg1); - } - - geosets.insert(geosetGloves); - geosets.insert(geosetBoots); - geosets.insert(geosetSleeves); - geosets.insert(geosetPants); - if (geosetBelt != 0) geosets.insert(geosetBelt); - // Back/Cloak (invType 16) - geosets.insert(hasInvType({16}) ? kGeosetWithCape : kGeosetNoCape); - // Tabard (invType 19) - if (hasInvType({19})) geosets.insert(kGeosetDefaultTabard); - - // Hide hair under helmets: replace style-specific scalp with bald scalp - // HEAD slot is index 0 in the 19-element equipment array - if (displayInfoIds[0] != 0 && hairStyleId > 0) { - uint16_t hairGeoset = static_cast(hairStyleId + 1); - geosets.erase(static_cast(100 + hairGeoset)); // Remove style group 1 - geosets.insert(kGeosetDefaultConnector); // Default group 1 connector - } - - charRenderer->setActiveGeosets(st.instanceId, geosets); - - // --- Helmet model attachment --- - // HEAD slot is index 0 in the 19-element equipment array. - // Helmet M2s are race/gender-specific (e.g. Helm_Plate_B_01_HuM.m2 for Human Male). - if (displayInfoIds[0] != 0) { - // Detach any previously attached helmet before attaching a new one - charRenderer->detachWeapon(st.instanceId, 0); - charRenderer->detachWeapon(st.instanceId, 11); - - int32_t helmIdx = displayInfoDbc->findRecordById(displayInfoIds[0]); - if (helmIdx >= 0) { - const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; - std::string helmModelName = displayInfoDbc->getString(static_cast(helmIdx), leftModelField); - if (!helmModelName.empty()) { - // Strip .mdx/.m2 extension - size_t dotPos = helmModelName.rfind('.'); - if (dotPos != std::string::npos) helmModelName = helmModelName.substr(0, dotPos); - - // Race/gender suffix for helmet variants - static const std::unordered_map racePrefix = { - {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, - {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} - }; - std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; - std::string raceSuffix; - auto itRace = racePrefix.find(st.raceId); - if (itRace != racePrefix.end()) { - raceSuffix = "_" + itRace->second + genderSuffix; - } - - // Try race/gender-specific variant first, then base name - std::string helmPath; - pipeline::M2Model helmModel; - if (!raceSuffix.empty()) { - helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; - if (!loadWeaponM2(helmPath, helmModel)) helmModel = {}; - } - if (!helmModel.isValid()) { - helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; - loadWeaponM2(helmPath, helmModel); - } - - if (helmModel.isValid()) { - uint32_t helmModelId = nextWeaponModelId_++; - // Get texture from ItemDisplayInfo (LeftModelTexture) - const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; - std::string helmTexName = displayInfoDbc->getString(static_cast(helmIdx), leftTexField); - std::string helmTexPath; - if (!helmTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; - if (assetManager->fileExists(suffixedTex)) helmTexPath = suffixedTex; - } - if (helmTexPath.empty()) { - helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; - } - } - // Attachment point 0 (head bone), fallback to 11 (explicit head attachment) - bool attached = charRenderer->attachWeapon(st.instanceId, 0, helmModel, helmModelId, helmTexPath); - if (!attached) { - attached = charRenderer->attachWeapon(st.instanceId, 11, helmModel, helmModelId, helmTexPath); - } - if (attached) { - LOG_DEBUG("Attached player helmet: ", helmPath, " tex: ", helmTexPath); - } - } - } - } - } else { - // No helmet equipped — detach any existing helmet model - charRenderer->detachWeapon(st.instanceId, 0); - charRenderer->detachWeapon(st.instanceId, 11); - } - - // --- Shoulder model attachment --- - // SHOULDERS slot is index 2 in the 19-element equipment array. - // Shoulders have TWO M2 models (left + right) attached at points 5 and 6. - // ItemDisplayInfo.dbc: LeftModel → left shoulder, RightModel → right shoulder. - if (displayInfoIds[2] != 0) { - // Detach any previously attached shoulder models - charRenderer->detachWeapon(st.instanceId, 5); - charRenderer->detachWeapon(st.instanceId, 6); - - int32_t shoulderIdx = displayInfoDbc->findRecordById(displayInfoIds[2]); - if (shoulderIdx >= 0) { - const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; - const uint32_t rightModelField = idiL ? (*idiL)["RightModel"] : 2u; - const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; - const uint32_t rightTexField = idiL ? (*idiL)["RightModelTexture"] : 4u; - - // Race/gender suffix for shoulder variants (same as helmets) - static const std::unordered_map shoulderRacePrefix = { - {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, - {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} - }; - std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; - std::string raceSuffix; - auto itRace = shoulderRacePrefix.find(st.raceId); - if (itRace != shoulderRacePrefix.end()) { - raceSuffix = "_" + itRace->second + genderSuffix; - } - - // Attach left shoulder (attachment point 5) using LeftModel - std::string leftModelName = displayInfoDbc->getString(static_cast(shoulderIdx), leftModelField); - if (!leftModelName.empty()) { - size_t dotPos = leftModelName.rfind('.'); - if (dotPos != std::string::npos) leftModelName = leftModelName.substr(0, dotPos); - - std::string leftPath; - pipeline::M2Model leftModel; - if (!raceSuffix.empty()) { - leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + raceSuffix + ".m2"; - if (!loadWeaponM2(leftPath, leftModel)) leftModel = {}; - } - if (!leftModel.isValid()) { - leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + ".m2"; - loadWeaponM2(leftPath, leftModel); - } - - if (leftModel.isValid()) { - uint32_t leftModelId = nextWeaponModelId_++; - std::string leftTexName = displayInfoDbc->getString(static_cast(shoulderIdx), leftTexField); - std::string leftTexPath; - if (!leftTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + raceSuffix + ".blp"; - if (assetManager->fileExists(suffixedTex)) leftTexPath = suffixedTex; - } - if (leftTexPath.empty()) { - leftTexPath = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + ".blp"; - } - } - bool attached = charRenderer->attachWeapon(st.instanceId, 5, leftModel, leftModelId, leftTexPath); - if (attached) { - LOG_DEBUG("Attached left shoulder: ", leftPath, " tex: ", leftTexPath); - } - } - } - - // Attach right shoulder (attachment point 6) using RightModel - std::string rightModelName = displayInfoDbc->getString(static_cast(shoulderIdx), rightModelField); - if (!rightModelName.empty()) { - size_t dotPos = rightModelName.rfind('.'); - if (dotPos != std::string::npos) rightModelName = rightModelName.substr(0, dotPos); - - std::string rightPath; - pipeline::M2Model rightModel; - if (!raceSuffix.empty()) { - rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + raceSuffix + ".m2"; - if (!loadWeaponM2(rightPath, rightModel)) rightModel = {}; - } - if (!rightModel.isValid()) { - rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + ".m2"; - loadWeaponM2(rightPath, rightModel); - } - - if (rightModel.isValid()) { - uint32_t rightModelId = nextWeaponModelId_++; - std::string rightTexName = displayInfoDbc->getString(static_cast(shoulderIdx), rightTexField); - std::string rightTexPath; - if (!rightTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + raceSuffix + ".blp"; - if (assetManager->fileExists(suffixedTex)) rightTexPath = suffixedTex; - } - if (rightTexPath.empty()) { - rightTexPath = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + ".blp"; - } - } - bool attached = charRenderer->attachWeapon(st.instanceId, 6, rightModel, rightModelId, rightTexPath); - if (attached) { - LOG_DEBUG("Attached right shoulder: ", rightPath, " tex: ", rightTexPath); - } - } - } - } - } else { - // No shoulders equipped — detach any existing shoulder models - charRenderer->detachWeapon(st.instanceId, 5); - charRenderer->detachWeapon(st.instanceId, 6); - } - - // --- Cape texture (group 15 / texture type 2) --- - // The geoset above enables the cape mesh, but without a texture it renders blank. - if (hasInvType({16})) { - // Back/cloak is WoW equipment slot 14 (BACK) in the 19-element array. - uint32_t capeDid = displayInfoIds[14]; - if (capeDid != 0) { - int32_t capeRecIdx = displayInfoDbc->findRecordById(capeDid); - if (capeRecIdx >= 0) { - const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; - std::string capeName = displayInfoDbc->getString( - static_cast(capeRecIdx), leftTexField); - - if (!capeName.empty()) { - std::replace(capeName.begin(), capeName.end(), '/', '\\'); - - auto hasBlpExt = [](const std::string& p) { - if (p.size() < 4) return false; - std::string ext = p.substr(p.size() - 4); - std::transform(ext.begin(), ext.end(), ext.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return ext == ".blp"; - }; - - const bool hasDir = (capeName.find('\\') != std::string::npos); - const bool hasExt = hasBlpExt(capeName); - - std::vector capeCandidates; - auto addCapeCandidate = [&](const std::string& p) { - if (p.empty()) return; - if (std::find(capeCandidates.begin(), capeCandidates.end(), p) == capeCandidates.end()) { - capeCandidates.push_back(p); - } - }; - - if (hasDir) { - addCapeCandidate(capeName); - if (!hasExt) addCapeCandidate(capeName + ".blp"); - } else { - std::string baseObj = "Item\\ObjectComponents\\Cape\\" + capeName; - std::string baseTex = "Item\\TextureComponents\\Cape\\" + capeName; - addCapeCandidate(baseObj); - addCapeCandidate(baseTex); - if (!hasExt) { - addCapeCandidate(baseObj + ".blp"); - addCapeCandidate(baseTex + ".blp"); - } - addCapeCandidate(baseObj + (st.genderId == 1 ? "_F.blp" : "_M.blp")); - addCapeCandidate(baseObj + "_U.blp"); - addCapeCandidate(baseTex + (st.genderId == 1 ? "_F.blp" : "_M.blp")); - addCapeCandidate(baseTex + "_U.blp"); - } - - const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); - rendering::VkTexture* capeTexture = nullptr; - for (const auto& candidate : capeCandidates) { - rendering::VkTexture* tex = charRenderer->loadTexture(candidate); - if (tex && tex != whiteTex) { - capeTexture = tex; - break; - } - } - - if (capeTexture) { - charRenderer->setGroupTextureOverride(st.instanceId, 15, capeTexture); - if (const auto* md = charRenderer->getModelData(st.modelId)) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - if (md->textures[ti].type == 2) { - charRenderer->setTextureSlotOverride( - st.instanceId, static_cast(ti), capeTexture); - } - } - } - } - } - } - } - } - - // --- Textures (skin atlas compositing) --- - static constexpr const char* componentDirs[] = { - "ArmUpperTexture", - "ArmLowerTexture", - "HandTexture", - "TorsoUpperTexture", - "TorsoLowerTexture", - "LegUpperTexture", - "LegLowerTexture", - "FootTexture", - }; - - // Texture component region fields from DBC layout - // Binary DBC (23 fields) has textures at 14+ - const uint32_t texRegionFields[8] = { - idiL ? (*idiL)["TextureArmUpper"] : 14u, - idiL ? (*idiL)["TextureArmLower"] : 15u, - idiL ? (*idiL)["TextureHand"] : 16u, - idiL ? (*idiL)["TextureTorsoUpper"]: 17u, - idiL ? (*idiL)["TextureTorsoLower"]: 18u, - idiL ? (*idiL)["TextureLegUpper"] : 19u, - idiL ? (*idiL)["TextureLegLower"] : 20u, - idiL ? (*idiL)["TextureFoot"] : 21u, - }; - - std::vector> regionLayers; - const bool isFemale = (st.genderId == 1); - - for (int s = 0; s < 19; s++) { - uint32_t did = displayInfoIds[s]; - if (did == 0) continue; - int32_t recIdx = displayInfoDbc->findRecordById(did); - if (recIdx < 0) continue; - - for (int region = 0; region < 8; region++) { - std::string texName = displayInfoDbc->getString( - static_cast(recIdx), texRegionFields[region]); - if (texName.empty()) continue; - - std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; - std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); - std::string unisexPath = base + "_U.blp"; - std::string fullPath; - if (assetManager->fileExists(genderPath)) fullPath = genderPath; - else if (assetManager->fileExists(unisexPath)) fullPath = unisexPath; - else fullPath = base + ".blp"; - - regionLayers.emplace_back(region, fullPath); - } - } - - const auto slotsIt = playerTextureSlotsByModelId_.find(st.modelId); - if (slotsIt == playerTextureSlotsByModelId_.end()) return; - const PlayerTextureSlots& slots = slotsIt->second; - if (slots.skin < 0) return; - - rendering::VkTexture* newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); - if (newTex) { - charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); - } - - // --- Weapon model attachment --- - // Slot indices in the 19-element EquipSlot array: - // 15 = MAIN_HAND → attachment 1 (right hand) - // 16 = OFF_HAND → attachment 2 (left hand) - struct OnlineWeaponSlot { - int slotIndex; - uint32_t attachmentId; - }; - static constexpr OnlineWeaponSlot weaponSlots[] = { - { 15, 1 }, // MAIN_HAND → right hand - { 16, 2 }, // OFF_HAND → left hand - }; - - const uint32_t modelFieldL = idiL ? (*idiL)["LeftModel"] : 1u; - const uint32_t modelFieldR = idiL ? (*idiL)["RightModel"] : 2u; - const uint32_t texFieldL = idiL ? (*idiL)["LeftModelTexture"] : 3u; - const uint32_t texFieldR = idiL ? (*idiL)["RightModelTexture"] : 4u; - - for (const auto& ws : weaponSlots) { - uint32_t weapDisplayId = displayInfoIds[ws.slotIndex]; - if (weapDisplayId == 0) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - - int32_t recIdx = displayInfoDbc->findRecordById(weapDisplayId); - if (recIdx < 0) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - - // Prefer LeftModel (full weapon), fall back to RightModel (hilt variants) - std::string modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldL); - std::string textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldL); - if (modelName.empty()) { - modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldR); - textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldR); - } - if (modelName.empty()) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - - // Convert .mdx → .m2 - std::string modelFile = modelName; - { - size_t dotPos = modelFile.rfind('.'); - if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); - modelFile += ".m2"; - } - - // Try Weapon directory first, then Shield - std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; - pipeline::M2Model weaponModel; - if (!loadWeaponM2(m2Path, weaponModel)) { - m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; - if (!loadWeaponM2(m2Path, weaponModel)) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - } - - // Build texture path - std::string texturePath; - if (!textureName.empty()) { - texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; - if (!assetManager->fileExists(texturePath)) { - texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; - if (!assetManager->fileExists(texturePath)) texturePath.clear(); - } - } - - uint32_t weaponModelId = nextWeaponModelId_++; - charRenderer->attachWeapon(st.instanceId, ws.attachmentId, - weaponModel, weaponModelId, texturePath); - } -} - -void Application::despawnOnlinePlayer(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 Application::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; - - auto goIt = gameObjectInstances_.find(guid); - if (goIt != gameObjectInstances_.end()) { - // Already have a render instance — update its position (e.g. transport re-creation) - auto& info = goIt->second; - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - LOG_DEBUG("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, - " pos=(", x, ", ", y, ", ", z, ")"); - if (renderer) { - if (info.isWmo) { - if (auto* wr = renderer->getWMORenderer()) { - glm::mat4 transform(1.0f); - transform = glm::translate(transform, renderPos); - transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); - wr->setInstanceTransform(info.instanceId, transform); - } - } else { - if (auto* mr = renderer->getM2Renderer()) { - glm::mat4 transform(1.0f); - transform = glm::translate(transform, renderPos); - mr->setInstanceTransform(info.instanceId, transform); - } - } - } - return; - } - - std::string modelPath; - - // Override model path for transports with wrong displayIds (preloaded transports) - // Check if this GUID is a known transport - bool isTransport = gameHandler && gameHandler->isTransportGuid(guid); - if (isTransport) { - // Map common transport displayIds to correct WMO paths - // NOTE: displayIds 455/462 are elevators in Thunder Bluff and should NOT be forced to ships. - // Keep ship/zeppelin overrides entry-driven where possible. - // DisplayIds 807, 808 = Zeppelins - // DisplayIds 2454, 1587 = Special ships/icebreakers - if (entry == 20808 || entry == 176231 || entry == 176310) { - modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; - LOG_INFO("Overriding transport entry/display ", entry, "/", displayId, " → transportship.wmo"); - } else if (displayId == 807 || displayId == 808 || displayId == 175080 || displayId == 176495 || displayId == 164871) { - modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → transport_zeppelin.wmo"); - } else if (displayId == 1587) { - modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Horde_Zeppelin.wmo"); - } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { - modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); - } else if (displayId == 3831) { - // Deeprun Tram car - modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; - LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); - } - } - - // Fallback to normal displayId lookup if not a transport or no override matched - if (modelPath.empty()) { - modelPath = getGameObjectModelPathForDisplayId(displayId); - } - - if (modelPath.empty()) { - LOG_WARNING("No model path for gameobject displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")"); - return; - } - - // Log spawns to help debug duplicate objects (e.g., cathedral issue) - LOG_DEBUG("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, - " model=", modelPath, " pos=(", x, ", ", y, ", ", z, ")"); - - std::string lowerPath = modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; - - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - const float renderYawWmo = orientation; - // M2 game objects: model default faces +renderX. renderYaw = canonical + 90° = server_yaw - // (same offset as creature/character renderer so all M2 models face consistently) - const float renderYawM2go = orientation + glm::radians(90.0f); - - bool loadedAsWmo = false; - if (isWmo) { - auto* wmoRenderer = renderer->getWMORenderer(); - if (!wmoRenderer) return; - - uint32_t modelId = 0; - auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); - if (itCache != gameObjectDisplayIdWmoCache_.end()) { - modelId = itCache->second; - // Only use cached entry if the model is still resident in the renderer - if (wmoRenderer->isModelLoaded(modelId)) { - loadedAsWmo = true; - } else { - gameObjectDisplayIdWmoCache_.erase(itCache); - modelId = 0; - } - } - if (!loadedAsWmo && modelId == 0) { - auto wmoData = assetManager->readFile(modelPath); - if (!wmoData.empty()) { - pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); - LOG_DEBUG("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); - int loadedGroups = 0; - if (wmoModel.nGroups > 0) { - std::string basePath = modelPath; - std::string extension; - if (basePath.size() > 4) { - extension = basePath.substr(basePath.size() - 4); - std::string extLower = extension; - for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); - if (extLower == ".wmo") { - basePath = basePath.substr(0, basePath.size() - 4); - } - } - - for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { - char groupSuffix[16]; - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); - std::string groupPath = basePath + groupSuffix; - std::vector groupData = assetManager->readFile(groupPath); - if (groupData.empty()) { - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); - groupData = assetManager->readFile(basePath + groupSuffix); - } - if (groupData.empty()) { - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); - groupData = assetManager->readFile(basePath + groupSuffix); - } - if (!groupData.empty()) { - pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); - loadedGroups++; - } else { - LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); - } - } - } - - if (loadedGroups > 0 || wmoModel.nGroups == 0) { - modelId = nextGameObjectWmoModelId_++; - if (wmoRenderer->loadModel(wmoModel, modelId)) { - gameObjectDisplayIdWmoCache_[displayId] = modelId; - loadedAsWmo = true; - } else { - LOG_WARNING("Failed to load gameobject WMO model: ", modelPath); - } - } else { - LOG_WARNING("No WMO groups loaded for gameobject: ", modelPath, - " — falling back to M2"); - } - } else { - LOG_WARNING("Failed to read gameobject WMO: ", modelPath, " — falling back to M2"); - } - } - - if (loadedAsWmo) { - uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawWmo), scale); - if (instanceId == 0) { - LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); - return; - } - - gameObjectInstances_[guid] = {modelId, instanceId, true}; - LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, - " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); - - // Spawn transport WMO doodads (chairs, furniture, etc.) as child M2 instances - bool isTransport = false; - if (gameHandler) { - std::string lowerModelPath = modelPath; - std::transform(lowerModelPath.begin(), lowerModelPath.end(), lowerModelPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - isTransport = (lowerModelPath.find("transport") != std::string::npos); - } - - auto* m2Renderer = renderer->getM2Renderer(); - if (m2Renderer && isTransport) { - const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); - if (doodadTemplates && !doodadTemplates->empty()) { - constexpr size_t kMaxTransportDoodads = 192; - const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads); - LOG_DEBUG("Queueing ", doodadBudget, "/", doodadTemplates->size(), - " transport doodads for WMO instance ", instanceId); - pendingTransportDoodadBatches_.push_back(PendingTransportDoodadBatch{ - guid, - modelId, - instanceId, - 0, - doodadBudget, - 0, - x, y, z, - orientation - }); - } else { - LOG_DEBUG("Transport WMO has no doodads or templates not available"); - } - } - - // Transport GameObjects are not always named "transport" in their WMO path - // (e.g. elevators/lifts). If the server marks it as a transport, always - // notify so TransportManager can animate/carry passengers. - bool isTG = gameHandler && gameHandler->isTransportGuid(guid); - LOG_WARNING("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); - } - - if (!m2Renderer->loadModel(model, modelId)) { - LOG_WARNING("Failed to load gameobject model: ", modelPath); - gameObjectDisplayIdFailedCache_.insert(displayId); - return; - } - - gameObjectDisplayIdModelCache_[displayId] = modelId; - } - - uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawM2go), scale); - if (instanceId == 0) { - LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); - return; - } - - // Freeze animation for static gameobjects, but let portals/effects/transports animate - bool isTransportGO = gameHandler && gameHandler->isTransportGuid(guid); - std::string lowerPath = modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || - lowerPath.find("instancenewportal") != std::string::npos || - lowerPath.find("portalfx") != std::string::npos || - lowerPath.find("spellportal") != std::string::npos); - if (!isAnimatedEffect && !isTransportGO) { - 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_WARNING("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, ")"); -} - -void Application::processAsyncCreatureResults(bool unlimited) { - // Check completed async model loads and finalize on main thread (GPU upload + instance creation). - // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. - // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. - static constexpr int kMaxModelUploadsPerTick = 1; - static constexpr int kMaxModelUploadsPerTickWarmup = 1; - static constexpr float kFinalizeBudgetMs = 2.0f; - static constexpr float kFinalizeBudgetWarmupMs = 2.0f; - const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; - const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; - const auto tickStart = std::chrono::steady_clock::now(); - int modelUploads = 0; - - for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { - if (std::chrono::duration( - std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { - break; - } - - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - - auto result = it->future.get(); - it = asyncCreatureLoads_.erase(it); - asyncCreatureDisplayLoads_.erase(result.displayId); - - // Failures and cache hits need no GPU work — process them even when the - // upload budget is exhausted. Previously the budget check was above this - // point, blocking ALL ready futures (including zero-cost ones) after a - // single upload, which throttled creature spawn throughput during world load. - if (result.permanent_failure) { - nonRenderableCreatureDisplayIds_.insert(result.displayId); - creaturePermanentFailureGuids_.insert(result.guid); - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - continue; - } - if (!result.valid || !result.model) { - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - continue; - } - - // Another async result may have already uploaded this displayId while this - // task was still running; in that case, skip duplicate GPU upload. - if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - if (!creatureInstances_.count(result.guid) && - !creaturePermanentFailureGuids_.count(result.guid)) { - PendingCreatureSpawn s{}; - s.guid = result.guid; - s.displayId = result.displayId; - s.x = result.x; - s.y = result.y; - s.z = result.z; - s.orientation = result.orientation; - s.scale = result.scale; - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(result.guid); - } - continue; - } - - // Only actual GPU uploads count toward the per-tick budget. - if (modelUploads >= maxUploadsThisTick) { - // Re-queue this result — it needs a GPU upload but we're at budget. - // Push a new pending spawn so it's retried next frame. - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - PendingCreatureSpawn s{}; - s.guid = result.guid; - s.displayId = result.displayId; - s.x = result.x; s.y = result.y; s.z = result.z; - s.orientation = result.orientation; - s.scale = result.scale; - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(result.guid); - continue; - } - - // Model parsed on background thread — upload to GPU on main thread. - auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; - if (!charRenderer) { - pendingCreatureSpawnGuids_.erase(result.guid); - continue; - } - - // Count upload attempts toward the frame budget even if upload fails. - // Otherwise repeated failures can consume an unbounded amount of frame time. - modelUploads++; - - // Upload model to GPU (must happen on main thread) - // Use pre-decoded BLP cache to skip main-thread texture decode - auto uploadStart = std::chrono::steady_clock::now(); - charRenderer->setPredecodedBLPCache(&result.predecodedTextures); - if (!charRenderer->loadModel(*result.model, result.modelId)) { - charRenderer->setPredecodedBLPCache(nullptr); - nonRenderableCreatureDisplayIds_.insert(result.displayId); - creaturePermanentFailureGuids_.insert(result.guid); - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - continue; - } - charRenderer->setPredecodedBLPCache(nullptr); - { - auto uploadEnd = std::chrono::steady_clock::now(); - float uploadMs = std::chrono::duration(uploadEnd - uploadStart).count(); - if (uploadMs > 100.0f) { - LOG_WARNING("charRenderer->loadModel took ", uploadMs, "ms displayId=", result.displayId, - " preDecoded=", result.predecodedTextures.size()); - } - } - // Save remaining pre-decoded textures (display skins) for spawnOnlineCreature - if (!result.predecodedTextures.empty()) { - displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); - } - displayIdModelCache_[result.displayId] = result.modelId; - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - - // Re-queue as a normal pending spawn — model is now cached, so sync spawn is fast - // (only creates instance + applies textures, no file I/O). - if (!creatureInstances_.count(result.guid) && - !creaturePermanentFailureGuids_.count(result.guid)) { - PendingCreatureSpawn s{}; - s.guid = result.guid; - s.displayId = result.displayId; - s.x = result.x; - s.y = result.y; - s.z = result.z; - s.orientation = result.orientation; - s.scale = result.scale; - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(result.guid); - } - } -} - -void Application::processAsyncNpcCompositeResults(bool unlimited) { - auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; - if (!charRenderer) return; - - // Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing - // simultaneously. In unlimited mode (load screen), process everything without cap. - static constexpr float kCompositeBudgetMs = 2.0f; - auto startTime = std::chrono::steady_clock::now(); - - for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) { - if (!unlimited) { - float elapsed = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsed >= kCompositeBudgetMs) break; - } - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - auto result = it->future.get(); - it = asyncNpcCompositeLoads_.erase(it); - - const auto& info = result.info; - - // Set pre-decoded cache so texture loads skip synchronous BLP decode - charRenderer->setPredecodedBLPCache(&result.predecodedTextures); - - // --- Apply skin to type-1 slots --- - rendering::VkTexture* skinTex = nullptr; - - if (info.hasBakedSkin) { - // Baked skin: load from pre-decoded cache - skinTex = charRenderer->loadTexture(info.bakedSkinPath); - } - - if (info.hasComposite) { - // Composite with face/underwear/equipment regions on top of base skin - rendering::VkTexture* compositeTex = nullptr; - if (!info.regionLayers.empty()) { - compositeTex = charRenderer->compositeWithRegions(info.basePath, - info.overlayPaths, info.regionLayers); - } else if (!info.overlayPaths.empty()) { - std::vector skinLayers; - skinLayers.push_back(info.basePath); - for (const auto& op : info.overlayPaths) skinLayers.push_back(op); - compositeTex = charRenderer->compositeTextures(skinLayers); - } - if (compositeTex) skinTex = compositeTex; - } else if (info.hasSimpleSkin) { - // Simple skin: just base texture, no compositing - auto* baseTex = charRenderer->loadTexture(info.basePath); - if (baseTex) skinTex = baseTex; - } - - if (skinTex) { - for (uint32_t slot : info.skinTextureSlots) { - charRenderer->setModelTexture(info.modelId, slot, skinTex); - } - } - - // --- Apply hair texture to type-6 slots --- - if (!info.hairTexturePath.empty()) { - rendering::VkTexture* hairTex = charRenderer->loadTexture(info.hairTexturePath); - rendering::VkTexture* whTex = charRenderer->loadTexture(""); - if (hairTex && hairTex != whTex) { - for (uint32_t slot : info.hairTextureSlots) { - charRenderer->setModelTexture(info.modelId, slot, hairTex); - } - } - } else if (info.useBakedForHair && skinTex) { - // Bald NPC: use skin/baked texture for scalp cap - for (uint32_t slot : info.hairTextureSlots) { - charRenderer->setModelTexture(info.modelId, slot, skinTex); - } - } - - charRenderer->setPredecodedBLPCache(nullptr); - } -} - -void Application::processCreatureSpawnQueue(bool unlimited) { - auto startTime = std::chrono::steady_clock::now(); - // Budget: max 2ms per frame for creature spawning to prevent stutter. - // In unlimited mode (load screen), process everything without budget cap. - static constexpr float kSpawnBudgetMs = 2.0f; - - // First, finalize any async model loads that completed on background threads. - processAsyncCreatureResults(unlimited); - { - auto now = std::chrono::steady_clock::now(); - float asyncMs = std::chrono::duration(now - startTime).count(); - if (asyncMs > 100.0f) { - LOG_WARNING("processAsyncCreatureResults took ", asyncMs, "ms"); - } - } - - if (pendingCreatureSpawns_.empty()) return; - if (!creatureLookupsBuilt_) { - buildCreatureDisplayLookups(); - if (!creatureLookupsBuilt_) return; - } - - int processed = 0; - int asyncLaunched = 0; - size_t rotationsLeft = pendingCreatureSpawns_.size(); - while (!pendingCreatureSpawns_.empty() && - (unlimited || processed < MAX_SPAWNS_PER_FRAME) && - rotationsLeft > 0) { - // Check time budget every iteration (including first — async results may - // have already consumed the budget via GPU model uploads). - if (!unlimited) { - auto now = std::chrono::steady_clock::now(); - float elapsedMs = std::chrono::duration(now - startTime).count(); - if (elapsedMs >= kSpawnBudgetMs) break; - } - - PendingCreatureSpawn s = pendingCreatureSpawns_.front(); - pendingCreatureSpawns_.pop_front(); - - if (nonRenderableCreatureDisplayIds_.count(s.displayId)) { - pendingCreatureSpawnGuids_.erase(s.guid); - creatureSpawnRetryCounts_.erase(s.guid); - processed++; - rotationsLeft = pendingCreatureSpawns_.size(); - continue; - } - - const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end()); - - // For new models: launch async load on background thread instead of blocking. - if (needsNewModel) { - // Keep exactly one background load per displayId. Additional spawns for - // the same displayId stay queued and will spawn once cache is populated. - if (asyncCreatureDisplayLoads_.count(s.displayId)) { - pendingCreatureSpawns_.push_back(s); - rotationsLeft--; - continue; - } - - const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; - if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { - // Too many in-flight — defer to next frame - pendingCreatureSpawns_.push_back(s); - rotationsLeft--; - continue; - } - - std::string m2Path = getModelPathForDisplayId(s.displayId); - if (m2Path.empty()) { - nonRenderableCreatureDisplayIds_.insert(s.displayId); - creaturePermanentFailureGuids_.insert(s.guid); - pendingCreatureSpawnGuids_.erase(s.guid); - creatureSpawnRetryCounts_.erase(s.guid); - processed++; - rotationsLeft = pendingCreatureSpawns_.size(); - continue; - } - - // Check for invisible stalkers - { - 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(s.displayId); - creaturePermanentFailureGuids_.insert(s.guid); - pendingCreatureSpawnGuids_.erase(s.guid); - processed++; - rotationsLeft = pendingCreatureSpawns_.size(); - continue; - } - } - - // Launch async M2 load — file I/O and parsing happen off the main thread. - uint32_t modelId = nextCreatureModelId_++; - auto* am = assetManager.get(); - - // Collect display skin texture paths for background pre-decode - std::vector displaySkinPaths; - { - auto itDD = displayDataMap_.find(s.displayId); - if (itDD != displayDataMap_.end()) { - std::string modelDir; - size_t lastSlash = m2Path.find_last_of("\\/"); - if (lastSlash != std::string::npos) modelDir = m2Path.substr(0, lastSlash + 1); - - auto resolveForAsync = [&](const std::string& skinField) { - if (skinField.empty()) return; - std::string raw = skinField; - std::replace(raw.begin(), raw.end(), '/', '\\'); - while (!raw.empty() && std::isspace(static_cast(raw.front()))) raw.erase(raw.begin()); - while (!raw.empty() && std::isspace(static_cast(raw.back()))) raw.pop_back(); - if (raw.empty()) return; - bool hasExt = raw.size() >= 4 && raw.substr(raw.size()-4) == ".blp"; - bool hasDir = raw.find('\\') != std::string::npos; - std::vector candidates; - if (hasDir) { - candidates.push_back(raw); - if (!hasExt) candidates.push_back(raw + ".blp"); - } else { - candidates.push_back(modelDir + raw); - if (!hasExt) candidates.push_back(modelDir + raw + ".blp"); - candidates.push_back(raw); - if (!hasExt) candidates.push_back(raw + ".blp"); - } - for (const auto& c : candidates) { - if (am->fileExists(c)) { displaySkinPaths.push_back(c); return; } - } - }; - resolveForAsync(itDD->second.skin1); - resolveForAsync(itDD->second.skin2); - resolveForAsync(itDD->second.skin3); - - // Pre-decode humanoid NPC textures (bake, skin, face, underwear, hair, equipment) - if (itDD->second.extraDisplayId != 0) { - auto itHE = humanoidExtraMap_.find(itDD->second.extraDisplayId); - if (itHE != humanoidExtraMap_.end()) { - const auto& he = itHE->second; - // Baked texture - if (!he.bakeName.empty()) { - displaySkinPaths.push_back("Textures\\BakedNpcTextures\\" + he.bakeName); - } - // CharSections: skin, face, underwear - 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 nRace = static_cast(he.raceId); - uint32_t nSex = static_cast(he.sexId); - uint32_t nSkin = static_cast(he.skinId); - uint32_t nFace = static_cast(he.faceId); - 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 != nRace || sId != nSex) 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 && color == nSkin) { - std::string t = csDbc->getString(r, csF.texture1); - if (!t.empty()) displaySkinPaths.push_back(t); - } else if (section == 1 && variation == nFace && color == nSkin) { - std::string t1 = csDbc->getString(r, csF.texture1); - std::string t2 = csDbc->getString(r, csF.texture2); - if (!t1.empty()) displaySkinPaths.push_back(t1); - if (!t2.empty()) displaySkinPaths.push_back(t2); - } else if (section == 3 && variation == static_cast(he.hairStyleId) - && color == static_cast(he.hairColorId)) { - std::string t = csDbc->getString(r, csF.texture1); - if (!t.empty()) displaySkinPaths.push_back(t); - } else if (section == 4 && color == nSkin) { - for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { - std::string t = csDbc->getString(r, f); - if (!t.empty()) displaySkinPaths.push_back(t); - } - } - } - } - // Equipment region textures - auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc"); - if (idiDbc) { - static constexpr const char* compDirs[] = { - "ArmUpperTexture", "ArmLowerTexture", "HandTexture", - "TorsoUpperTexture", "TorsoLowerTexture", - "LegUpperTexture", "LegLowerTexture", "FootTexture", - }; - const auto* idiL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - const uint32_t trf[8] = { - idiL ? (*idiL)["TextureArmUpper"] : 14u, - idiL ? (*idiL)["TextureArmLower"] : 15u, - idiL ? (*idiL)["TextureHand"] : 16u, - idiL ? (*idiL)["TextureTorsoUpper"]: 17u, - idiL ? (*idiL)["TextureTorsoLower"]: 18u, - idiL ? (*idiL)["TextureLegUpper"] : 19u, - idiL ? (*idiL)["TextureLegLower"] : 20u, - idiL ? (*idiL)["TextureFoot"] : 21u, - }; - const bool isFem = (he.sexId == 1); - for (int eq = 0; eq < 11; eq++) { - uint32_t did = he.equipDisplayId[eq]; - if (did == 0) continue; - int32_t recIdx = idiDbc->findRecordById(did); - if (recIdx < 0) continue; - for (int region = 0; region < 8; region++) { - std::string texName = idiDbc->getString(static_cast(recIdx), trf[region]); - if (texName.empty()) continue; - std::string base = "Item\\TextureComponents\\" + - std::string(compDirs[region]) + "\\" + texName; - std::string gp = base + (isFem ? "_F.blp" : "_M.blp"); - std::string up = base + "_U.blp"; - if (am->fileExists(gp)) displaySkinPaths.push_back(gp); - else if (am->fileExists(up)) displaySkinPaths.push_back(up); - else displaySkinPaths.push_back(base + ".blp"); - } - } - } - } - } - } - } - - AsyncCreatureLoad load; - load.future = std::async(std::launch::async, - [am, m2Path, modelId, s, skinPaths = std::move(displaySkinPaths)]() -> PreparedCreatureModel { - PreparedCreatureModel result; - result.guid = s.guid; - result.displayId = s.displayId; - result.modelId = modelId; - result.x = s.x; - result.y = s.y; - result.z = s.z; - result.orientation = s.orientation; - result.scale = s.scale; - - auto m2Data = am->readFile(m2Path); - if (m2Data.empty()) { - result.permanent_failure = true; - return result; - } - - auto model = std::make_shared(pipeline::M2Loader::load(m2Data)); - if (model->vertices.empty()) { - result.permanent_failure = true; - return result; - } - - // Load skin file - if (model->version >= 264) { - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - auto skinData = am->readFile(skinPath); - if (!skinData.empty()) { - pipeline::M2Loader::loadSkin(skinData, *model); - } - } - - // Load external .anim files - 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 = am->readFileOptional(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, *model); - } - } - } - - // Pre-decode model textures on background thread - for (const auto& tex : model->textures) { - if (tex.filename.empty()) continue; - std::string texKey = tex.filename; - std::replace(texKey.begin(), texKey.end(), '/', '\\'); - std::transform(texKey.begin(), texKey.end(), texKey.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; - auto blp = am->loadTexture(texKey); - if (blp.isValid()) { - result.predecodedTextures[texKey] = std::move(blp); - } - } - - // Pre-decode display skin textures (skin1/skin2/skin3 from CreatureDisplayInfo) - for (const auto& sp : skinPaths) { - std::string key = sp; - 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); - } - } - - result.model = std::move(model); - result.valid = true; - return result; - }); - asyncCreatureLoads_.push_back(std::move(load)); - asyncCreatureDisplayLoads_.insert(s.displayId); - asyncLaunched++; - // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it - rotationsLeft = pendingCreatureSpawns_.size(); - processed++; - continue; - } - - // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) - { - auto spawnStart = std::chrono::steady_clock::now(); - spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); - auto spawnEnd = std::chrono::steady_clock::now(); - float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); - if (spawnMs > 100.0f) { - LOG_WARNING("spawnOnlineCreature took ", spawnMs, "ms displayId=", s.displayId); - } - } - pendingCreatureSpawnGuids_.erase(s.guid); - - // If spawn still failed, retry for a limited number of frames. - if (!creatureInstances_.count(s.guid)) { - if (creaturePermanentFailureGuids_.erase(s.guid) > 0) { - creatureSpawnRetryCounts_.erase(s.guid); - processed++; - continue; - } - uint16_t retries = 0; - auto it = creatureSpawnRetryCounts_.find(s.guid); - if (it != creatureSpawnRetryCounts_.end()) { - retries = it->second; - } - if (retries < MAX_CREATURE_SPAWN_RETRIES) { - creatureSpawnRetryCounts_[s.guid] = static_cast(retries + 1); - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(s.guid); - } else { - creatureSpawnRetryCounts_.erase(s.guid); - LOG_WARNING("Dropping creature spawn after retries: guid=0x", std::hex, s.guid, std::dec, - " displayId=", s.displayId); - } - } else { - creatureSpawnRetryCounts_.erase(s.guid); - } - rotationsLeft = pendingCreatureSpawns_.size(); - processed++; - } -} - -void Application::processPlayerSpawnQueue() { - if (pendingPlayerSpawns_.empty()) return; - if (!assetManager || !assetManager->isInitialized()) return; - - int processed = 0; - while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) { - PendingPlayerSpawn s = pendingPlayerSpawns_.front(); - pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin()); - pendingPlayerSpawnGuids_.erase(s.guid); - - // Skip if already spawned (could have been spawned by a previous update this frame) - if (playerInstances_.count(s.guid)) { - processed++; - continue; - } - - spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); - // Apply any equipment updates that arrived before the player was spawned. - auto pit = pendingOnlinePlayerEquipment_.find(s.guid); - if (pit != pendingOnlinePlayerEquipment_.end()) { - deferredEquipmentQueue_.push_back({s.guid, pit->second}); - pendingOnlinePlayerEquipment_.erase(pit); - } - processed++; - } -} - -std::vector Application::resolveEquipmentTexturePaths(uint64_t guid, - const std::array& displayInfoIds, - const std::array& /*inventoryTypes*/) const { - std::vector paths; - - auto it = onlinePlayerAppearance_.find(guid); - if (it == onlinePlayerAppearance_.end()) return paths; - const OnlinePlayerAppearanceState& st = it->second; - - // Add base skin + underwear paths - if (!st.bodySkinPath.empty()) paths.push_back(st.bodySkinPath); - for (const auto& up : st.underwearPaths) { - if (!up.empty()) paths.push_back(up); - } - - // Resolve equipment region texture paths (same logic as setOnlinePlayerEquipment) - auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); - if (!displayInfoDbc) return paths; - const auto* idiL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - - static constexpr const char* componentDirs[] = { - "ArmUpperTexture", "ArmLowerTexture", "HandTexture", - "TorsoUpperTexture", "TorsoLowerTexture", - "LegUpperTexture", "LegLowerTexture", "FootTexture", - }; - const uint32_t texRegionFields[8] = { - idiL ? (*idiL)["TextureArmUpper"] : 14u, - idiL ? (*idiL)["TextureArmLower"] : 15u, - idiL ? (*idiL)["TextureHand"] : 16u, - idiL ? (*idiL)["TextureTorsoUpper"]: 17u, - idiL ? (*idiL)["TextureTorsoLower"]: 18u, - idiL ? (*idiL)["TextureLegUpper"] : 19u, - idiL ? (*idiL)["TextureLegLower"] : 20u, - idiL ? (*idiL)["TextureFoot"] : 21u, - }; - const bool isFemale = (st.genderId == 1); - - for (int s = 0; s < 19; s++) { - uint32_t did = displayInfoIds[s]; - if (did == 0) continue; - int32_t recIdx = displayInfoDbc->findRecordById(did); - if (recIdx < 0) continue; - for (int region = 0; region < 8; region++) { - std::string texName = displayInfoDbc->getString( - static_cast(recIdx), texRegionFields[region]); - if (texName.empty()) continue; - std::string base = "Item\\TextureComponents\\" + - std::string(componentDirs[region]) + "\\" + texName; - std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); - std::string unisexPath = base + "_U.blp"; - if (assetManager->fileExists(genderPath)) paths.push_back(genderPath); - else if (assetManager->fileExists(unisexPath)) paths.push_back(unisexPath); - else paths.push_back(base + ".blp"); - } - } - return paths; -} - -void Application::processAsyncEquipmentResults() { - for (auto it = asyncEquipmentLoads_.begin(); it != asyncEquipmentLoads_.end(); ) { - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - auto result = it->future.get(); - it = asyncEquipmentLoads_.erase(it); - - auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; - if (!charRenderer) continue; - - // Set pre-decoded cache so compositeWithRegions skips synchronous BLP decode - charRenderer->setPredecodedBLPCache(&result.predecodedTextures); - setOnlinePlayerEquipment(result.guid, result.displayInfoIds, result.inventoryTypes); - charRenderer->setPredecodedBLPCache(nullptr); - } -} - -void Application::processDeferredEquipmentQueue() { - // First, finalize any completed async pre-decodes - processAsyncEquipmentResults(); - - if (deferredEquipmentQueue_.empty()) return; - // Limit in-flight async equipment loads - if (asyncEquipmentLoads_.size() >= 2) return; - - auto [guid, equipData] = deferredEquipmentQueue_.front(); - deferredEquipmentQueue_.erase(deferredEquipmentQueue_.begin()); - - // Resolve all texture paths that compositeWithRegions will need - auto texturePaths = resolveEquipmentTexturePaths(guid, equipData.first, equipData.second); - - if (texturePaths.empty()) { - // No textures to pre-decode — just apply directly (fast path) - LOG_WARNING("Equipment fast path for guid=0x", std::hex, guid, std::dec, - " (no textures to pre-decode)"); - setOnlinePlayerEquipment(guid, equipData.first, equipData.second); - return; - } - LOG_WARNING("Equipment async pre-decode for guid=0x", std::hex, guid, std::dec, - " textures=", texturePaths.size()); - - // Launch background BLP pre-decode - auto* am = assetManager.get(); - auto displayInfoIds = equipData.first; - auto inventoryTypes = equipData.second; - AsyncEquipmentLoad load; - load.future = std::async(std::launch::async, - [am, guid, displayInfoIds, inventoryTypes, paths = std::move(texturePaths)]() -> PreparedEquipmentUpdate { - PreparedEquipmentUpdate result; - result.guid = guid; - result.displayInfoIds = displayInfoIds; - result.inventoryTypes = inventoryTypes; - for (const auto& path : paths) { - 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; - }); - asyncEquipmentLoads_.push_back(std::move(load)); -} - -void Application::processAsyncGameObjectResults() { - for (auto it = asyncGameObjectLoads_.begin(); it != asyncGameObjectLoads_.end(); ) { - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - - auto result = it->future.get(); - it = asyncGameObjectLoads_.erase(it); - - if (!result.valid || !result.isWmo || !result.wmoModel) { - // Fallback: spawn via sync path (likely an M2 or failed WMO) - spawnOnlineGameObject(result.guid, result.entry, result.displayId, - result.x, result.y, result.z, result.orientation, result.scale); - continue; - } - - // WMO parsed on background thread — do GPU upload + instance creation on main thread - auto* wmoRenderer = renderer ? renderer->getWMORenderer() : nullptr; - if (!wmoRenderer) continue; - - uint32_t modelId = 0; - auto itCache = gameObjectDisplayIdWmoCache_.find(result.displayId); - if (itCache != gameObjectDisplayIdWmoCache_.end()) { - modelId = itCache->second; - } else { - modelId = nextGameObjectWmoModelId_++; - wmoRenderer->setPredecodedBLPCache(&result.predecodedTextures); - if (!wmoRenderer->loadModel(*result.wmoModel, modelId)) { - wmoRenderer->setPredecodedBLPCache(nullptr); - LOG_WARNING("Failed to load async gameobject WMO: ", result.modelPath); - continue; - } - wmoRenderer->setPredecodedBLPCache(nullptr); - gameObjectDisplayIdWmoCache_[result.displayId] = modelId; - } - - glm::vec3 renderPos = core::coords::canonicalToRender( - glm::vec3(result.x, result.y, result.z)); - uint32_t instanceId = wmoRenderer->createInstance( - modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); - if (instanceId == 0) continue; - - gameObjectInstances_[result.guid] = {modelId, instanceId, true}; - - // Queue transport doodad loading if applicable - std::string lowerPath = result.modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lowerPath.find("transport") != std::string::npos) { - const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); - if (doodadTemplates && !doodadTemplates->empty()) { - PendingTransportDoodadBatch batch; - batch.guid = result.guid; - batch.modelId = modelId; - batch.instanceId = instanceId; - batch.x = result.x; - batch.y = result.y; - batch.z = result.z; - batch.orientation = result.orientation; - batch.doodadBudget = doodadTemplates->size(); - pendingTransportDoodadBatches_.push_back(batch); - } - } - } -} - -void Application::processGameObjectSpawnQueue() { - // Finalize any completed async WMO loads first - processAsyncGameObjectResults(); - - if (pendingGameObjectSpawns_.empty()) return; - - // Process spawns: cached WMOs and M2s go sync (cheap), uncached WMOs go async - auto startTime = std::chrono::steady_clock::now(); - static constexpr float kBudgetMs = 2.0f; - static constexpr int kMaxAsyncLoads = 2; - - while (!pendingGameObjectSpawns_.empty()) { - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsedMs >= kBudgetMs) break; - - auto& s = pendingGameObjectSpawns_.front(); - - // Check if this is an uncached WMO that needs async loading - std::string modelPath; - if (gameObjectLookupsBuilt_) { - // Check transport overrides first - bool isTransport = gameHandler && gameHandler->isTransportGuid(s.guid); - if (isTransport) { - if (s.entry == 20808 || s.entry == 176231 || s.entry == 176310) - modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; - else if (s.displayId == 807 || s.displayId == 808 || s.displayId == 175080 || s.displayId == 176495 || s.displayId == 164871) - modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; - else if (s.displayId == 1587) - modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; - else if (s.displayId == 2454 || s.displayId == 181688 || s.displayId == 190536) - modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; - } - if (modelPath.empty()) - modelPath = getGameObjectModelPathForDisplayId(s.displayId); - } - - std::string lowerPath = modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; - bool isCached = isWmo && gameObjectDisplayIdWmoCache_.count(s.displayId); - - if (isWmo && !isCached && !modelPath.empty() && - static_cast(asyncGameObjectLoads_.size()) < kMaxAsyncLoads) { - // Launch async WMO load — file I/O + parse on background thread - auto* am = assetManager.get(); - PendingGameObjectSpawn capture = s; - std::string capturePath = modelPath; - AsyncGameObjectLoad load; - load.future = std::async(std::launch::async, - [am, capture, capturePath]() -> PreparedGameObjectWMO { - PreparedGameObjectWMO result; - result.guid = capture.guid; - result.entry = capture.entry; - result.displayId = capture.displayId; - result.x = capture.x; - result.y = capture.y; - result.z = capture.z; - result.orientation = capture.orientation; - result.scale = capture.scale; - result.modelPath = capturePath; - result.isWmo = true; - - auto wmoData = am->readFile(capturePath); - if (wmoData.empty()) return result; - - auto wmo = std::make_shared( - pipeline::WMOLoader::load(wmoData)); - - // Load groups - if (wmo->nGroups > 0) { - std::string basePath = capturePath; - std::string ext; - if (basePath.size() > 4) { - ext = basePath.substr(basePath.size() - 4); - basePath = basePath.substr(0, basePath.size() - 4); - } - for (uint32_t gi = 0; gi < wmo->nGroups; gi++) { - char suffix[16]; - snprintf(suffix, sizeof(suffix), "_%03u%s", gi, ext.c_str()); - auto groupData = am->readFile(basePath + suffix); - if (groupData.empty()) { - snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); - groupData = am->readFile(basePath + suffix); - } - if (!groupData.empty()) { - pipeline::WMOLoader::loadGroup(groupData, *wmo, gi); - } - } - } - - // Pre-decode WMO textures on background thread - for (const auto& texPath : wmo->textures) { - if (texPath.empty()) continue; - std::string texKey = texPath; - size_t nul = texKey.find('\0'); - if (nul != std::string::npos) texKey.resize(nul); - std::replace(texKey.begin(), texKey.end(), '/', '\\'); - std::transform(texKey.begin(), texKey.end(), texKey.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (texKey.empty()) continue; - // Convert to .blp extension - if (texKey.size() >= 4) { - std::string ext = texKey.substr(texKey.size() - 4); - if (ext == ".tga" || ext == ".dds") { - texKey = texKey.substr(0, texKey.size() - 4) + ".blp"; - } - } - if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; - auto blp = am->loadTexture(texKey); - if (blp.isValid()) { - result.predecodedTextures[texKey] = std::move(blp); - } - } - - result.wmoModel = wmo; - result.valid = true; - return result; - }); - asyncGameObjectLoads_.push_back(std::move(load)); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - continue; - } - - // Cached WMO or M2 — spawn synchronously (cheap) - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - } -} - -void Application::processPendingTransportRegistrations() { - if (pendingTransportRegistrations_.empty()) return; - if (!gameHandler || !renderer) return; - - auto* transportManager = gameHandler->getTransportManager(); - if (!transportManager) return; - - auto startTime = std::chrono::steady_clock::now(); - static constexpr int kMaxRegistrationsPerFrame = 2; - static constexpr float kRegistrationBudgetMs = 2.0f; - int processed = 0; - - for (auto it = pendingTransportRegistrations_.begin(); - it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsedMs >= kRegistrationBudgetMs) break; - - const PendingTransportRegistration pending = *it; - auto goIt = gameObjectInstances_.find(pending.guid); - if (goIt == gameObjectInstances_.end()) { - it = pendingTransportRegistrations_.erase(it); - continue; - } - - if (transportManager->getTransport(pending.guid)) { - transportManager->updateServerTransport( - pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - it = pendingTransportRegistrations_.erase(it); - continue; - } - - const uint32_t wmoInstanceId = goIt->second.instanceId; - LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, - " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); - - // TransportAnimation.dbc is indexed by GameObject entry. - uint32_t pathId = pending.entry; - const bool preferServerData = gameHandler->hasServerTransportUpdate(pending.guid); - - bool clientAnim = transportManager->isClientSideAnimation(); - LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, - " guid=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " pathId=", pathId, - " preferServer=", preferServerData); - - glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); - const bool shipOrZeppelinDisplay = - (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || - pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || - pending.displayId == 807 || pending.displayId == 808); - bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); - if (shipOrZeppelinDisplay) { - hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); - } - - LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, - " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); - - if (preferServerData) { - if (!hasUsablePath) { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", - std::hex, pending.guid, std::dec, " entry=", pending.entry); - } else { - LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); - } - } else if (!hasUsablePath) { - bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); - uint32_t inferredPath = transportManager->inferDbcPathForSpawn( - canonicalSpawnPos, 1200.0f, allowZOnly); - if (inferredPath != 0) { - pathId = inferredPath; - LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); - } else { - uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); - if (remappedPath != 0) { - pathId = remappedPath; - LOG_WARNING("Using remapped fallback transport path ", pathId, - " for entry ", pending.entry, " displayId=", pending.displayId, - " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); - } else { - LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, - " - transport will be stationary"); - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - } - } - } else { - LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); - } - - transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); - - if (!goIt->second.isWmo) { - if (auto* tr = transportManager->getTransport(pending.guid)) { - tr->isM2 = true; - } - } - - transportManager->updateServerTransport( - pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - - auto moveIt = pendingTransportMoves_.find(pending.guid); - if (moveIt != pendingTransportMoves_.end()) { - const PendingTransportMove latestMove = moveIt->second; - transportManager->updateServerTransport( - pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); - LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, - " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, - ") orientation=", latestMove.orientation); - pendingTransportMoves_.erase(moveIt); - } - - if (glm::dot(canonicalSpawnPos, canonicalSpawnPos) < 1.0f) { - auto goData = gameHandler->getCachedGameObjectInfo(pending.entry); - if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { - uint32_t taxiPathId = goData->data[0]; - if (transportManager->hasTaxiPath(taxiPathId)) { - transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); - LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, - " taxiPathId=", taxiPathId); - } - } - } - - if (auto* tr = transportManager->getTransport(pending.guid); tr) { - LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " displayId=", pending.displayId, - " pathId=", tr->pathId, - " mode=", (tr->useClientAnimation ? "client" : "server"), - " serverUpdates=", tr->serverUpdateCount); - } else { - LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " displayId=", pending.displayId, - " (TransportManager instance missing)"); - } - - ++processed; - it = pendingTransportRegistrations_.erase(it); - } -} - -void Application::processPendingTransportDoodads() { - if (pendingTransportDoodadBatches_.empty()) return; - if (!renderer || !assetManager) return; - - auto* wmoRenderer = renderer->getWMORenderer(); - auto* m2Renderer = renderer->getM2Renderer(); - if (!wmoRenderer || !m2Renderer) return; - - auto startTime = std::chrono::steady_clock::now(); - static constexpr float kDoodadBudgetMs = 4.0f; - - // Batch all GPU uploads into a single async command buffer submission so that - // N doodads with multiple textures each don't each block on vkQueueSubmit + - // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds - // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. - auto* vkCtx = renderer->getVkContext(); - if (vkCtx) vkCtx->beginUploadBatch(); - - size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; - for (auto it = pendingTransportDoodadBatches_.begin(); - it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { - // Time budget check - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsedMs >= kDoodadBudgetMs) break; - auto goIt = gameObjectInstances_.find(it->guid); - if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo || - goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) { - it = pendingTransportDoodadBatches_.erase(it); - continue; - } - - const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId); - if (!doodadTemplates || doodadTemplates->empty()) { - it = pendingTransportDoodadBatches_.erase(it); - continue; - } - - const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size()); - while (it->nextIndex < maxIndex && budgetLeft > 0) { - // Per-doodad time budget (each does synchronous file I/O + parse + GPU upload) - float innerMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (innerMs >= kDoodadBudgetMs) { budgetLeft = 0; break; } - - const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex]; - it->nextIndex++; - budgetLeft--; - - uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); - auto m2Data = assetManager->readFile(doodadTemplate.m2Path); - if (m2Data.empty()) continue; - - pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); - std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin"; - std::vector skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && m2Model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, m2Model); - } - if (!m2Model.isValid()) continue; - - if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; - uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); - if (m2InstanceId == 0) continue; - m2Renderer->setSkipCollision(m2InstanceId, true); - - wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform); - it->spawnedDoodads++; - } - - if (it->nextIndex >= maxIndex) { - if (it->spawnedDoodads > 0) { - LOG_DEBUG("Spawned ", it->spawnedDoodads, - " transport doodads for WMO instance ", it->instanceId); - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z)); - glm::mat4 wmoTransform(1.0f); - wmoTransform = glm::translate(wmoTransform, renderPos); - wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1)); - wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform); - } - it = pendingTransportDoodadBatches_.erase(it); - } else { - ++it; - } - } - - // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). - if (vkCtx) vkCtx->endUploadBatch(); -} - -void Application::processPendingMount() { - if (pendingMountDisplayId_ == 0) return; - uint32_t mountDisplayId = pendingMountDisplayId_; - pendingMountDisplayId_ = 0; - LOG_INFO("processPendingMount: loading displayId ", mountDisplayId); - - if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return; - auto* charRenderer = renderer->getCharacterRenderer(); - - std::string m2Path = getModelPathForDisplayId(mountDisplayId); - if (m2Path.empty()) { - LOG_WARNING("No model path for mount displayId ", mountDisplayId); - return; - } - - // Check model cache - uint32_t modelId = 0; - auto cacheIt = displayIdModelCache_.find(mountDisplayId); - if (cacheIt != displayIdModelCache_.end()) { - modelId = cacheIt->second; - } else { - modelId = nextCreatureModelId_++; - - auto m2Data = assetManager->readFile(m2Path); - if (m2Data.empty()) { - LOG_WARNING("Failed to read mount M2: ", m2Path); - return; - } - - pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (model.vertices.empty()) { - LOG_WARNING("Failed to parse mount 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 mount M2: ", skinPath); - } - } - - // Load external .anim files (only idle + run needed for mounts) - 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)) { - uint32_t animId = model.sequences[si].id; - // Only load stand(0), walk(4), run(5) anims to avoid hang - if (animId != 0 && animId != 4 && animId != 5) continue; - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", - basePath.c_str(), animId, model.sequences[si].variationIndex); - auto animData = assetManager->readFileOptional(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); - } - } - } - - if (!charRenderer->loadModel(model, modelId)) { - LOG_WARNING("Failed to load mount model: ", m2Path); - return; - } - - displayIdModelCache_[mountDisplayId] = modelId; - } - - // Apply creature skin textures from CreatureDisplayInfo.dbc. - // Re-apply even for cached models so transient failures can self-heal. - std::string modelDir; - size_t lastSlash = m2Path.find_last_of("\\/"); - if (lastSlash != std::string::npos) { - modelDir = m2Path.substr(0, lastSlash + 1); - } - - auto itDisplayData = displayDataMap_.find(mountDisplayId); - bool haveDisplayData = false; - CreatureDisplayData dispData{}; - if (itDisplayData != displayDataMap_.end()) { - dispData = itDisplayData->second; - haveDisplayData = true; - } else { - // Some taxi mount display IDs are sparse; recover skins by matching model path. - std::string lowerMountPath = m2Path; - std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - int bestScore = -1; - for (const auto& [dispId, data] : displayDataMap_) { - auto pit = modelIdToPath_.find(data.modelId); - if (pit == modelIdToPath_.end()) continue; - std::string p = pit->second; - std::transform(p.begin(), p.end(), p.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (p != lowerMountPath) continue; - int score = 0; - if (!data.skin1.empty()) { - std::string p1 = modelDir + data.skin1 + ".blp"; - score += assetManager->fileExists(p1) ? 30 : 3; - } - if (!data.skin2.empty()) { - std::string p2 = modelDir + data.skin2 + ".blp"; - score += assetManager->fileExists(p2) ? 20 : 2; - } - if (!data.skin3.empty()) { - std::string p3 = modelDir + data.skin3 + ".blp"; - score += assetManager->fileExists(p3) ? 10 : 1; - } - if (score > bestScore) { - bestScore = score; - dispData = data; - haveDisplayData = true; - } - } - if (haveDisplayData) { - LOG_INFO("Recovered mount display data by model path for displayId=", mountDisplayId, - " skin1='", dispData.skin1, "' skin2='", dispData.skin2, - "' skin3='", dispData.skin3, "'"); - } - } - if (haveDisplayData) { - // If this displayId has no skins, try to find another displayId for the same model with skins. - if (dispData.skin1.empty() && dispData.skin2.empty() && dispData.skin3.empty()) { - uint32_t sourceModelId = dispData.modelId; - int bestScore = -1; - for (const auto& [dispId, data] : displayDataMap_) { - if (data.modelId != sourceModelId) continue; - int score = 0; - if (!data.skin1.empty()) { - std::string p = modelDir + data.skin1 + ".blp"; - score += assetManager->fileExists(p) ? 30 : 3; - } - if (!data.skin2.empty()) { - std::string p = modelDir + data.skin2 + ".blp"; - score += assetManager->fileExists(p) ? 20 : 2; - } - if (!data.skin3.empty()) { - std::string p = modelDir + data.skin3 + ".blp"; - score += assetManager->fileExists(p) ? 10 : 1; - } - if (score > bestScore) { - bestScore = score; - dispData = data; - } - } - LOG_INFO("Mount skin fallback for displayId=", mountDisplayId, - " modelId=", sourceModelId, " skin1='", dispData.skin1, - "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); - } - const auto* md = charRenderer->getModelData(modelId); - if (md) { - LOG_INFO("Mount model textures: ", md->textures.size(), " slots, skin1='", dispData.skin1, - "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); - for (size_t ti = 0; ti < md->textures.size(); ti++) { - LOG_INFO(" tex[", ti, "] type=", md->textures[ti].type, - " filename='", md->textures[ti].filename, "'"); - } - - int replaced = 0; - for (size_t ti = 0; ti < md->textures.size(); ti++) { - const auto& tex = md->textures[ti]; - std::string texPath; - if (tex.type == 11 && !dispData.skin1.empty()) { - texPath = modelDir + dispData.skin1 + ".blp"; - } else if (tex.type == 12 && !dispData.skin2.empty()) { - texPath = modelDir + dispData.skin2 + ".blp"; - } else if (tex.type == 13 && !dispData.skin3.empty()) { - texPath = modelDir + dispData.skin3 + ".blp"; - } - if (!texPath.empty()) { - rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); - if (skinTex) { - charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); - LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath); - replaced++; - } else { - LOG_WARNING(" Failed to load skin texture slot ", ti, ": ", texPath); - } - } - } - - // Force skin textures onto type-0 (hardcoded) slots that have no filename - if (replaced == 0) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - const auto& tex = md->textures[ti]; - if (tex.type == 0 && tex.filename.empty()) { - // Empty hardcoded slot — try skin1 then skin2 - std::string texPath; - if (!dispData.skin1.empty() && replaced == 0) { - texPath = modelDir + dispData.skin1 + ".blp"; - } else if (!dispData.skin2.empty()) { - texPath = modelDir + dispData.skin2 + ".blp"; - } - if (!texPath.empty()) { - rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); - if (skinTex) { - charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); - LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath); - replaced++; - } - } - } - } - } - - // If still no textures, try hardcoded model texture filenames - if (replaced == 0) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - if (!md->textures[ti].filename.empty()) { - rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename); - if (texId) { - charRenderer->setModelTexture(modelId, static_cast(ti), texId); - LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename); - replaced++; - } - } - } - } - - // Final fallback for gryphon/wyvern: try well-known skin texture names - if (replaced == 0 && !md->textures.empty()) { - std::string lowerMountPath = m2Path; - std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lowerMountPath.find("gryphon") != std::string::npos) { - const char* gryphonSkins[] = { - "Creature\\Gryphon\\Gryphon_Skin.blp", - "Creature\\Gryphon\\Gryphon_Skin01.blp", - "Creature\\Gryphon\\GRYPHON_SKIN01.BLP", - nullptr - }; - for (const char** p = gryphonSkins; *p; ++p) { - rendering::VkTexture* texId = charRenderer->loadTexture(*p); - if (texId) { - charRenderer->setModelTexture(modelId, 0, texId); - LOG_INFO(" Forced gryphon skin fallback: ", *p); - replaced++; - break; - } - } - } else if (lowerMountPath.find("wyvern") != std::string::npos) { - const char* wyvernSkins[] = { - "Creature\\Wyvern\\Wyvern_Skin.blp", - "Creature\\Wyvern\\Wyvern_Skin01.blp", - nullptr - }; - for (const char** p = wyvernSkins; *p; ++p) { - rendering::VkTexture* texId = charRenderer->loadTexture(*p); - if (texId) { - charRenderer->setModelTexture(modelId, 0, texId); - LOG_INFO(" Forced wyvern skin fallback: ", *p); - replaced++; - break; - } - } - } - } - LOG_INFO("Mount texture setup: ", replaced, " textures applied"); - } - } - - mountModelId_ = modelId; - - // Create mount instance at player position - glm::vec3 mountPos = renderer->getCharacterPosition(); - float yawRad = glm::radians(renderer->getCharacterYaw()); - uint32_t instanceId = charRenderer->createInstance(modelId, mountPos, - glm::vec3(0.0f, 0.0f, yawRad), 1.0f); - - if (instanceId == 0) { - LOG_WARNING("Failed to create mount instance"); - return; - } - - mountInstanceId_ = instanceId; - - // Compute height offset — place player above mount's back - // Use tight bounds from actual vertices (M2 header bounds can be inaccurate) - const auto* modelData = charRenderer->getModelData(modelId); - float heightOffset = 1.8f; - if (modelData && !modelData->vertices.empty()) { - float minZ = std::numeric_limits::max(); - float maxZ = -std::numeric_limits::max(); - for (const auto& v : modelData->vertices) { - if (v.position.z < minZ) minZ = v.position.z; - if (v.position.z > maxZ) maxZ = v.position.z; - } - float extentZ = maxZ - minZ; - LOG_INFO("Mount tight bounds: minZ=", minZ, " maxZ=", maxZ, " extentZ=", extentZ); - if (extentZ > 0.5f) { - // Saddle point is roughly 75% up the model, measured from model origin - heightOffset = maxZ * 0.8f; - if (heightOffset < 1.0f) heightOffset = extentZ * 0.75f; - if (heightOffset < 1.0f) heightOffset = 1.8f; - } - } - - renderer->setMounted(instanceId, mountDisplayId, heightOffset, m2Path); - - // For taxi mounts, start with flying animation; for ground mounts, start with stand - bool isTaxi = gameHandler && gameHandler->isOnTaxiFlight(); - uint32_t startAnim = 0; // ANIM_STAND - if (isTaxi) { - // Try WotLK fly anims first, then Vanilla-friendly fallbacks - uint32_t taxiCandidates[] = {159, 158, 234, 229, 233, 141, 369, 6, 5}; // FlyForward, FlyIdle, FlyRun(234), FlyStand(229), FlyWalk(233), FlyMounted, FlyRun, Fly, Run - for (uint32_t anim : taxiCandidates) { - if (charRenderer->hasAnimation(instanceId, anim)) { - startAnim = anim; - break; - } - } - // If none found, startAnim stays 0 (Stand/hover) which is fine for flying creatures - } - charRenderer->playAnimation(instanceId, startAnim, true); - - LOG_INFO("processPendingMount: DONE displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset); -} - -void Application::despawnOnlineCreature(uint64_t guid) { - // If this guid is a PLAYER, it will be tracked in playerInstances_. - // Route to the correct despawn path so we don't leak instances. - if (playerInstances_.count(guid)) { - despawnOnlinePlayer(guid); - return; - } - - pendingCreatureSpawnGuids_.erase(guid); - creatureSpawnRetryCounts_.erase(guid); - creaturePermanentFailureGuids_.erase(guid); - deadCreatureGuids_.erase(guid); - - auto it = creatureInstances_.find(guid); - if (it == creatureInstances_.end()) return; - - if (renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->removeInstance(it->second); - } - - creatureInstances_.erase(it); - creatureModelIds_.erase(guid); - creatureRenderPosCache_.erase(guid); - creatureWeaponsAttached_.erase(guid); - creatureWeaponAttachAttempts_.erase(guid); - creatureWasMoving_.erase(guid); - creatureWasSwimming_.erase(guid); - creatureWasFlying_.erase(guid); - creatureWasWalking_.erase(guid); - creatureSwimmingState_.erase(guid); - creatureWalkingState_.erase(guid); - creatureFlyingState_.erase(guid); - - LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); -} - -void Application::despawnOnlineGameObject(uint64_t guid) { - pendingTransportDoodadBatches_.erase( - std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(), - [guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }), - pendingTransportDoodadBatches_.end()); - - auto it = gameObjectInstances_.find(guid); - if (it == gameObjectInstances_.end()) return; - - if (renderer) { - if (it->second.isWmo) { - if (auto* wmoRenderer = renderer->getWMORenderer()) { - wmoRenderer->removeInstance(it->second.instanceId); - } - } else { - if (auto* m2Renderer = renderer->getM2Renderer()) { - m2Renderer->removeInstance(it->second.instanceId); - } - } - } - - gameObjectInstances_.erase(it); - - LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec); + if (entitySpawner_) return entitySpawner_->getRenderPositionForGuid(guid, outPos); + return false; } void Application::loadQuestMarkerModels() { @@ -10125,7 +5288,7 @@ void Application::updateQuestMarkers() { } void Application::setupTestTransport() { - if (testTransportSetup_) return; + if (entitySpawner_->isTestTransportSetup()) return; if (!gameHandler || !renderer || !assetManager) return; auto* transportManager = gameHandler->getTransportManager(); @@ -10252,7 +5415,7 @@ void Application::setupTestTransport() { glm::vec3(-15.0f, -30.0f, 0.0f), glm::vec3(15.0f, 30.0f, 10.0f)); - testTransportSetup_ = true; + entitySpawner_->setTestTransportSetup(true); LOG_INFO("========================================"); LOG_INFO("Test transport registered:"); LOG_INFO(" GUID: 0x", std::hex, transportGuid, std::dec); diff --git a/src/core/entity_spawner.cpp b/src/core/entity_spawner.cpp new file mode 100644 index 00000000..58d34402 --- /dev/null +++ b/src/core/entity_spawner.cpp @@ -0,0 +1,4979 @@ +#include "core/entity_spawner.hpp" +#include "core/coordinates.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.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 "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_WARNING("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"); + } + + 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; + const uint32_t texRegionFields[8] = { + idiL ? (*idiL)["TextureArmUpper"] : 14u, + idiL ? (*idiL)["TextureArmLower"] : 15u, + idiL ? (*idiL)["TextureHand"] : 16u, + idiL ? (*idiL)["TextureTorsoUpper"]: 17u, + idiL ? (*idiL)["TextureTorsoLower"]: 18u, + idiL ? (*idiL)["TextureLegUpper"] : 19u, + idiL ? (*idiL)["TextureLegLower"] : 20u, + idiL ? (*idiL)["TextureFoot"] : 21u, + }; + 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, 1, false); // Death + } else { + charRenderer->playAnimation(instanceId, 0, true); // Idle + } + 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, ")"); +} + +void EntitySpawner::spawnOnlinePlayer(uint64_t guid, + uint8_t raceId, + uint8_t genderId, + uint32_t appearanceBytes, + uint8_t facialFeatures, + float x, float y, float z, float orientation) { + if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; + if (playerInstances_.count(guid)) return; + + // Skip local player — already spawned as the main character + if (gameHandler_) { + uint64_t localGuid = gameHandler_->getPlayerGuid(); + uint64_t activeGuid = gameHandler_->getActiveCharacterGuid(); + if ((localGuid != 0 && guid == localGuid) || + (activeGuid != 0 && guid == activeGuid) || + (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { + return; + } + } + auto* charRenderer = renderer_->getCharacterRenderer(); + + // Base geometry model: cache by (race, gender) + uint32_t cacheKey = (static_cast(raceId) << 8) | static_cast(genderId & 0xFF); + uint32_t modelId = 0; + auto itCache = playerModelCache_.find(cacheKey); + if (itCache != playerModelCache_.end()) { + modelId = itCache->second; + } else { + game::Race race = static_cast(raceId); + game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE; + std::string m2Path = game::getPlayerModelPath(race, gender); + if (m2Path.empty()) { + LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec, + " race=", static_cast(raceId), " gender=", static_cast(genderId)); + return; + } + + // Parse modelDir/baseName for skin/anim loading + std::string modelDir; + std::string baseName; + { + size_t slash = m2Path.rfind('\\'); + if (slash != std::string::npos) { + modelDir = m2Path.substr(0, slash + 1); + baseName = m2Path.substr(slash + 1); + } else { + baseName = m2Path; + } + size_t dot = baseName.rfind('.'); + if (dot != std::string::npos) baseName = baseName.substr(0, dot); + } + + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path); + return; + } + + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty()) { + LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); + return; + } + + // Skin file (only for WotLK M2s - vanilla has embedded skin) + std::string skinPath = modelDir + baseName + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, model); + } + + // After skin loading, full model must be valid (vertices + indices) + if (!model.isValid()) { + LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); + return; + } + + // Load only core external animations (stand/walk/run) to avoid stalls + for (uint32_t si = 0; si < model.sequences.size(); si++) { + if (!(model.sequences[si].flags & 0x20)) { + uint32_t animId = model.sequences[si].id; + if (animId != 0 && animId != 4 && animId != 5) continue; + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), + "%s%s%04u-%02u.anim", + modelDir.c_str(), + baseName.c_str(), + animId, + model.sequences[si].variationIndex); + auto animData = assetManager_->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + } + } + } + + modelId = nextPlayerModelId_++; + if (!charRenderer->loadModel(model, modelId)) { + LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path); + return; + } + + playerModelCache_[cacheKey] = modelId; + } + + // Determine texture slots once per model + { + auto [slotIt, inserted] = playerTextureSlotsByModelId_.try_emplace(modelId); + if (inserted) { + PlayerTextureSlots slots; + if (const auto* md = charRenderer->getModelData(modelId)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + uint32_t t = md->textures[ti].type; + if (t == 1 && slots.skin < 0) slots.skin = static_cast(ti); + else if (t == 6 && slots.hair < 0) slots.hair = static_cast(ti); + else if (t == 8 && slots.underwear < 0) slots.underwear = static_cast(ti); + } + } + slotIt->second = slots; + } + } + + // Create instance at server position + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + float renderYaw = orientation + glm::radians(90.0f); + uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); + if (instanceId == 0) return; + + // Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides + const char* raceFolderName = "Human"; + switch (static_cast(raceId)) { + case game::Race::HUMAN: raceFolderName = "Human"; break; + case game::Race::ORC: raceFolderName = "Orc"; break; + case game::Race::DWARF: raceFolderName = "Dwarf"; break; + case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break; + case game::Race::UNDEAD: raceFolderName = "Scourge"; break; + case game::Race::TAUREN: raceFolderName = "Tauren"; break; + case game::Race::GNOME: raceFolderName = "Gnome"; break; + case game::Race::TROLL: raceFolderName = "Troll"; break; + case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break; + case game::Race::DRAENEI: raceFolderName = "Draenei"; break; + default: break; + } + const char* genderFolder = (genderId == 1) ? "Female" : "Male"; + std::string raceGender = std::string(raceFolderName) + genderFolder; + std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp"; + std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp"; + std::vector underwearPaths; + std::string hairTexturePath; + std::string faceLowerPath; + std::string faceUpperPath; + + uint8_t skinId = appearanceBytes & 0xFF; + uint8_t faceId = (appearanceBytes >> 8) & 0xFF; + uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF; + uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF; + + if (auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) { + const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; + auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL); + uint32_t targetRaceId = raceId; + uint32_t targetSexId = genderId; + + bool foundSkin = false; + bool foundUnderwear = false; + bool foundHair = false; + bool foundFaceLower = false; + + for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { + uint32_t rRace = charSectionsDbc->getUInt32(r, csF.raceId); + uint32_t rSex = charSectionsDbc->getUInt32(r, csF.sexId); + uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection); + uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex); + uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex); + + if (rRace != targetRaceId || rSex != targetSexId) continue; + + if (baseSection == 0 && !foundSkin && colorIndex == skinId) { + std::string tex1 = charSectionsDbc->getString(r, csF.texture1); + if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; } + } else if (baseSection == 3 && !foundHair && + variationIndex == hairStyleId && colorIndex == hairColorId) { + hairTexturePath = charSectionsDbc->getString(r, csF.texture1); + if (!hairTexturePath.empty()) foundHair = true; + } else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) { + for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { + std::string tex = charSectionsDbc->getString(r, f); + if (!tex.empty()) underwearPaths.push_back(tex); + } + foundUnderwear = true; + } else if (baseSection == 1 && !foundFaceLower && + variationIndex == faceId && colorIndex == skinId) { + std::string tex1 = charSectionsDbc->getString(r, csF.texture1); + std::string tex2 = charSectionsDbc->getString(r, csF.texture2); + if (!tex1.empty()) faceLowerPath = tex1; + if (!tex2.empty()) faceUpperPath = tex2; + foundFaceLower = true; + } + + if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break; + } + } + + // Composite base skin + face + underwear overlays + rendering::VkTexture* compositeTex = nullptr; + { + std::vector layers; + layers.push_back(bodySkinPath); + if (!faceLowerPath.empty()) layers.push_back(faceLowerPath); + if (!faceUpperPath.empty()) layers.push_back(faceUpperPath); + for (const auto& up : underwearPaths) layers.push_back(up); + if (layers.size() > 1) { + compositeTex = charRenderer->compositeTextures(layers); + } else { + compositeTex = charRenderer->loadTexture(bodySkinPath); + } + } + + rendering::VkTexture* hairTex = nullptr; + if (!hairTexturePath.empty()) { + hairTex = charRenderer->loadTexture(hairTexturePath); + } + rendering::VkTexture* underwearTex = nullptr; + if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); + else underwearTex = charRenderer->loadTexture(pelvisPath); + + const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; + if (slots.skin >= 0 && compositeTex) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); + } + if (slots.hair >= 0 && hairTex) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); + } + if (slots.underwear >= 0 && underwearTex) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); + } + + // Geosets: body + hair/facial hair selections + std::unordered_set activeGeosets; + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); + activeGeosets.insert(static_cast(100 + hairStyleId + 1)); + activeGeosets.insert(static_cast(200 + facialFeatures + 1)); + activeGeosets.insert(kGeosetBareForearms); + activeGeosets.insert(kGeosetBareShins); + activeGeosets.insert(kGeosetDefaultEars); + activeGeosets.insert(kGeosetBareSleeves); + activeGeosets.insert(kGeosetDefaultKneepads); + activeGeosets.insert(kGeosetBarePants); + activeGeosets.insert(kGeosetWithCape); + activeGeosets.insert(kGeosetBareFeet); + charRenderer->setActiveGeosets(instanceId, activeGeosets); + + charRenderer->playAnimation(instanceId, 0, true); + playerInstances_[guid] = instanceId; + + OnlinePlayerAppearanceState st; + st.instanceId = instanceId; + st.modelId = modelId; + st.raceId = raceId; + st.genderId = genderId; + st.appearanceBytes = appearanceBytes; + st.facialFeatures = facialFeatures; + st.bodySkinPath = bodySkinPath; + // Include face textures so compositeWithRegions can rebuild the full base + if (!faceLowerPath.empty()) st.underwearPaths.push_back(faceLowerPath); + if (!faceUpperPath.empty()) st.underwearPaths.push_back(faceUpperPath); + for (const auto& up : underwearPaths) st.underwearPaths.push_back(up); + onlinePlayerAppearance_[guid] = std::move(st); +} + +void EntitySpawner::setOnlinePlayerEquipment(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes) { + if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; + + // Skip local player — equipment handled by GameScreen::updateCharacterGeosets/Textures + // via consumeOnlineEquipmentDirty(), which fires on the same server update. + if (gameHandler_) { + uint64_t localGuid = gameHandler_->getPlayerGuid(); + if (localGuid != 0 && guid == localGuid) return; + } + + // If the player isn't spawned yet, store equipment until spawn. + auto appIt = onlinePlayerAppearance_.find(guid); + if (!playerInstances_.count(guid) || appIt == onlinePlayerAppearance_.end()) { + pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; + return; + } + + const OnlinePlayerAppearanceState& st = appIt->second; + + auto* charRenderer = renderer_->getCharacterRenderer(); + if (!charRenderer) return; + if (st.instanceId == 0 || st.modelId == 0) return; + + if (st.bodySkinPath.empty()) { + LOG_WARNING("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_WARNING("setOnlinePlayerEquipment: guid=0x", std::hex, guid, std::dec, + " instanceId=", st.instanceId, " nonZeroDisplayIds=", nonZeroDisplay, + " head=", displayInfoIds[0], " chest=", displayInfoIds[4], + " legs=", displayInfoIds[6], " mainhand=", displayInfoIds[15]); + + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return; + const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + + auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t { + if (displayInfoId == 0) return 0; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return 0; + return displayInfoDbc->getUInt32(static_cast(recIdx), fieldIdx); + }; + + auto findDisplayIdByInvType = [&](std::initializer_list types) -> uint32_t { + for (int s = 0; s < 19; s++) { + uint8_t inv = inventoryTypes[s]; + if (inv == 0 || displayInfoIds[s] == 0) continue; + for (uint8_t t : types) { + if (inv == t) return displayInfoIds[s]; + } + } + return 0; + }; + + auto hasInvType = [&](std::initializer_list types) -> bool { + for (int s = 0; s < 19; s++) { + uint8_t inv = inventoryTypes[s]; + if (inv == 0) continue; + for (uint8_t t : types) { + if (inv == t) return true; + } + } + return false; + }; + + // --- Geosets --- + // Mirror the same group-range logic as CharacterPreview::applyEquipment to + // keep other-player rendering consistent with the local character preview. + // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, + // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). + std::unordered_set geosets; + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); + + uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); + geosets.insert(static_cast(100 + hairStyleId + 1)); + geosets.insert(static_cast(200 + st.facialFeatures + 1)); + geosets.insert(701); // Ears + geosets.insert(kGeosetDefaultKneepads); // Kneepads + geosets.insert(kGeosetBareFeet); // Bare feet mesh + + const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; + const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; + + // Per-group defaults — overridden below when equipment provides a geoset value. + uint16_t geosetGloves = kGeosetBareForearms; + uint16_t geosetBoots = kGeosetBareShins; + uint16_t geosetSleeves = kGeosetBareSleeves; + uint16_t geosetPants = kGeosetBarePants; + + // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 + { + uint32_t did = findDisplayIdByInvType({4, 5, 20}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); + // Robe kilt → leg group 13 + uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); + if (gg3 > 0) geosetPants = static_cast(kGeosetBarePants + gg3); + } + + // Legs (invType 7) → leg group 13 + { + uint32_t did = findDisplayIdByInvType({7}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetPants = static_cast(kGeosetBarePants + gg1); + } + + // Feet/Boots (invType 8) → shin group 5 + { + uint32_t did = findDisplayIdByInvType({8}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetBoots = static_cast(501 + gg1); + } + + // Hands/Gloves (invType 10) → forearm group 4 + { + uint32_t did = findDisplayIdByInvType({10}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetGloves = static_cast(kGeosetBareForearms + gg1); + } + + // Wrists/Bracers (invType 9) → sleeve group 8 (only if chest/shirt didn't set it) + { + uint32_t did = findDisplayIdByInvType({9}); + if (did != 0 && geosetSleeves == kGeosetBareSleeves) { + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); + } + } + + // Waist/Belt (invType 6) → buckle group 18 + uint16_t geosetBelt = 0; + { + uint32_t did = findDisplayIdByInvType({6}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetBelt = static_cast(1801 + gg1); + } + + geosets.insert(geosetGloves); + geosets.insert(geosetBoots); + geosets.insert(geosetSleeves); + geosets.insert(geosetPants); + if (geosetBelt != 0) geosets.insert(geosetBelt); + // Back/Cloak (invType 16) + geosets.insert(hasInvType({16}) ? kGeosetWithCape : kGeosetNoCape); + // Tabard (invType 19) + if (hasInvType({19})) geosets.insert(kGeosetDefaultTabard); + + // Hide hair under helmets: replace style-specific scalp with bald scalp + // HEAD slot is index 0 in the 19-element equipment array + if (displayInfoIds[0] != 0 && hairStyleId > 0) { + uint16_t hairGeoset = static_cast(hairStyleId + 1); + geosets.erase(static_cast(100 + hairGeoset)); // Remove style group 1 + geosets.insert(kGeosetDefaultConnector); // Default group 1 connector + } + + charRenderer->setActiveGeosets(st.instanceId, geosets); + + // --- Helmet model attachment --- + // HEAD slot is index 0 in the 19-element equipment array. + // Helmet M2s are race/gender-specific (e.g. Helm_Plate_B_01_HuM.m2 for Human Male). + if (displayInfoIds[0] != 0) { + // Detach any previously attached helmet before attaching a new one + charRenderer->detachWeapon(st.instanceId, 0); + charRenderer->detachWeapon(st.instanceId, 11); + + int32_t helmIdx = displayInfoDbc->findRecordById(displayInfoIds[0]); + if (helmIdx >= 0) { + const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; + std::string helmModelName = displayInfoDbc->getString(static_cast(helmIdx), leftModelField); + if (!helmModelName.empty()) { + // Strip .mdx/.m2 extension + size_t dotPos = helmModelName.rfind('.'); + if (dotPos != std::string::npos) helmModelName = helmModelName.substr(0, dotPos); + + // Race/gender suffix for helmet variants + static const std::unordered_map racePrefix = { + {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, + {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} + }; + std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; + std::string raceSuffix; + auto itRace = racePrefix.find(st.raceId); + if (itRace != racePrefix.end()) { + raceSuffix = "_" + itRace->second + genderSuffix; + } + + // Try race/gender-specific variant first, then base name + std::string helmPath; + pipeline::M2Model helmModel; + if (!raceSuffix.empty()) { + helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; + if (!loadWeaponM2(helmPath, helmModel)) helmModel = {}; + } + if (!helmModel.isValid()) { + helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; + loadWeaponM2(helmPath, helmModel); + } + + if (helmModel.isValid()) { + uint32_t helmModelId = nextWeaponModelId_++; + // Get texture from ItemDisplayInfo (LeftModelTexture) + const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; + std::string helmTexName = displayInfoDbc->getString(static_cast(helmIdx), leftTexField); + std::string helmTexPath; + if (!helmTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; + if (assetManager_->fileExists(suffixedTex)) helmTexPath = suffixedTex; + } + if (helmTexPath.empty()) { + helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; + } + } + // Attachment point 0 (head bone), fallback to 11 (explicit head attachment) + bool attached = charRenderer->attachWeapon(st.instanceId, 0, helmModel, helmModelId, helmTexPath); + if (!attached) { + attached = charRenderer->attachWeapon(st.instanceId, 11, helmModel, helmModelId, helmTexPath); + } + if (attached) { + LOG_DEBUG("Attached player helmet: ", helmPath, " tex: ", helmTexPath); + } + } + } + } + } else { + // No helmet equipped — detach any existing helmet model + charRenderer->detachWeapon(st.instanceId, 0); + charRenderer->detachWeapon(st.instanceId, 11); + } + + // --- Shoulder model attachment --- + // SHOULDERS slot is index 2 in the 19-element equipment array. + // Shoulders have TWO M2 models (left + right) attached at points 5 and 6. + // ItemDisplayInfo.dbc: LeftModel → left shoulder, RightModel → right shoulder. + if (displayInfoIds[2] != 0) { + // Detach any previously attached shoulder models + charRenderer->detachWeapon(st.instanceId, 5); + charRenderer->detachWeapon(st.instanceId, 6); + + int32_t shoulderIdx = displayInfoDbc->findRecordById(displayInfoIds[2]); + if (shoulderIdx >= 0) { + const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; + const uint32_t rightModelField = idiL ? (*idiL)["RightModel"] : 2u; + const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; + const uint32_t rightTexField = idiL ? (*idiL)["RightModelTexture"] : 4u; + + // Race/gender suffix for shoulder variants (same as helmets) + static const std::unordered_map shoulderRacePrefix = { + {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, + {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} + }; + std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; + std::string raceSuffix; + auto itRace = shoulderRacePrefix.find(st.raceId); + if (itRace != shoulderRacePrefix.end()) { + raceSuffix = "_" + itRace->second + genderSuffix; + } + + // Attach left shoulder (attachment point 5) using LeftModel + std::string leftModelName = displayInfoDbc->getString(static_cast(shoulderIdx), leftModelField); + if (!leftModelName.empty()) { + size_t dotPos = leftModelName.rfind('.'); + if (dotPos != std::string::npos) leftModelName = leftModelName.substr(0, dotPos); + + std::string leftPath; + pipeline::M2Model leftModel; + if (!raceSuffix.empty()) { + leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + raceSuffix + ".m2"; + if (!loadWeaponM2(leftPath, leftModel)) leftModel = {}; + } + if (!leftModel.isValid()) { + leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + ".m2"; + loadWeaponM2(leftPath, leftModel); + } + + if (leftModel.isValid()) { + uint32_t leftModelId = nextWeaponModelId_++; + std::string leftTexName = displayInfoDbc->getString(static_cast(shoulderIdx), leftTexField); + std::string leftTexPath; + if (!leftTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + raceSuffix + ".blp"; + if (assetManager_->fileExists(suffixedTex)) leftTexPath = suffixedTex; + } + if (leftTexPath.empty()) { + leftTexPath = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + ".blp"; + } + } + bool attached = charRenderer->attachWeapon(st.instanceId, 5, leftModel, leftModelId, leftTexPath); + if (attached) { + LOG_DEBUG("Attached left shoulder: ", leftPath, " tex: ", leftTexPath); + } + } + } + + // Attach right shoulder (attachment point 6) using RightModel + std::string rightModelName = displayInfoDbc->getString(static_cast(shoulderIdx), rightModelField); + if (!rightModelName.empty()) { + size_t dotPos = rightModelName.rfind('.'); + if (dotPos != std::string::npos) rightModelName = rightModelName.substr(0, dotPos); + + std::string rightPath; + pipeline::M2Model rightModel; + if (!raceSuffix.empty()) { + rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + raceSuffix + ".m2"; + if (!loadWeaponM2(rightPath, rightModel)) rightModel = {}; + } + if (!rightModel.isValid()) { + rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + ".m2"; + loadWeaponM2(rightPath, rightModel); + } + + if (rightModel.isValid()) { + uint32_t rightModelId = nextWeaponModelId_++; + std::string rightTexName = displayInfoDbc->getString(static_cast(shoulderIdx), rightTexField); + std::string rightTexPath; + if (!rightTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + raceSuffix + ".blp"; + if (assetManager_->fileExists(suffixedTex)) rightTexPath = suffixedTex; + } + if (rightTexPath.empty()) { + rightTexPath = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + ".blp"; + } + } + bool attached = charRenderer->attachWeapon(st.instanceId, 6, rightModel, rightModelId, rightTexPath); + if (attached) { + LOG_DEBUG("Attached right shoulder: ", rightPath, " tex: ", rightTexPath); + } + } + } + } + } else { + // No shoulders equipped — detach any existing shoulder models + charRenderer->detachWeapon(st.instanceId, 5); + charRenderer->detachWeapon(st.instanceId, 6); + } + + // --- Cape texture (group 15 / texture type 2) --- + // The geoset above enables the cape mesh, but without a texture it renders blank. + if (hasInvType({16})) { + // Back/cloak is WoW equipment slot 14 (BACK) in the 19-element array. + uint32_t capeDid = displayInfoIds[14]; + if (capeDid != 0) { + int32_t capeRecIdx = displayInfoDbc->findRecordById(capeDid); + if (capeRecIdx >= 0) { + const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; + std::string capeName = displayInfoDbc->getString( + static_cast(capeRecIdx), leftTexField); + + if (!capeName.empty()) { + std::replace(capeName.begin(), capeName.end(), '/', '\\'); + + auto hasBlpExt = [](const std::string& p) { + if (p.size() < 4) return false; + std::string ext = p.substr(p.size() - 4); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return ext == ".blp"; + }; + + const bool hasDir = (capeName.find('\\') != std::string::npos); + const bool hasExt = hasBlpExt(capeName); + + std::vector capeCandidates; + auto addCapeCandidate = [&](const std::string& p) { + if (p.empty()) return; + if (std::find(capeCandidates.begin(), capeCandidates.end(), p) == capeCandidates.end()) { + capeCandidates.push_back(p); + } + }; + + if (hasDir) { + addCapeCandidate(capeName); + if (!hasExt) addCapeCandidate(capeName + ".blp"); + } else { + std::string baseObj = "Item\\ObjectComponents\\Cape\\" + capeName; + std::string baseTex = "Item\\TextureComponents\\Cape\\" + capeName; + addCapeCandidate(baseObj); + addCapeCandidate(baseTex); + if (!hasExt) { + addCapeCandidate(baseObj + ".blp"); + addCapeCandidate(baseTex + ".blp"); + } + addCapeCandidate(baseObj + (st.genderId == 1 ? "_F.blp" : "_M.blp")); + addCapeCandidate(baseObj + "_U.blp"); + addCapeCandidate(baseTex + (st.genderId == 1 ? "_F.blp" : "_M.blp")); + addCapeCandidate(baseTex + "_U.blp"); + } + + const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); + rendering::VkTexture* capeTexture = nullptr; + for (const auto& candidate : capeCandidates) { + rendering::VkTexture* tex = charRenderer->loadTexture(candidate); + if (tex && tex != whiteTex) { + capeTexture = tex; + break; + } + } + + if (capeTexture) { + charRenderer->setGroupTextureOverride(st.instanceId, 15, capeTexture); + if (const auto* md = charRenderer->getModelData(st.modelId)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + if (md->textures[ti].type == 2) { + charRenderer->setTextureSlotOverride( + st.instanceId, static_cast(ti), capeTexture); + } + } + } + } + } + } + } + } + + // --- Textures (skin atlas compositing) --- + static constexpr const char* componentDirs[] = { + "ArmUpperTexture", + "ArmLowerTexture", + "HandTexture", + "TorsoUpperTexture", + "TorsoLowerTexture", + "LegUpperTexture", + "LegLowerTexture", + "FootTexture", + }; + + // Texture component region fields from DBC layout + // Binary DBC (23 fields) has textures at 14+ + const uint32_t texRegionFields[8] = { + idiL ? (*idiL)["TextureArmUpper"] : 14u, + idiL ? (*idiL)["TextureArmLower"] : 15u, + idiL ? (*idiL)["TextureHand"] : 16u, + idiL ? (*idiL)["TextureTorsoUpper"]: 17u, + idiL ? (*idiL)["TextureTorsoLower"]: 18u, + idiL ? (*idiL)["TextureLegUpper"] : 19u, + idiL ? (*idiL)["TextureLegLower"] : 20u, + idiL ? (*idiL)["TextureFoot"] : 21u, + }; + + std::vector> regionLayers; + const bool isFemale = (st.genderId == 1); + + for (int s = 0; s < 19; s++) { + uint32_t did = displayInfoIds[s]; + if (did == 0) continue; + int32_t recIdx = displayInfoDbc->findRecordById(did); + if (recIdx < 0) continue; + + for (int region = 0; region < 8; region++) { + std::string texName = displayInfoDbc->getString( + static_cast(recIdx), texRegionFields[region]); + if (texName.empty()) continue; + + std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; + std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); + std::string unisexPath = base + "_U.blp"; + std::string fullPath; + if (assetManager_->fileExists(genderPath)) fullPath = genderPath; + else if (assetManager_->fileExists(unisexPath)) fullPath = unisexPath; + else fullPath = base + ".blp"; + + regionLayers.emplace_back(region, fullPath); + } + } + + const auto slotsIt = playerTextureSlotsByModelId_.find(st.modelId); + if (slotsIt == playerTextureSlotsByModelId_.end()) return; + const PlayerTextureSlots& slots = slotsIt->second; + if (slots.skin < 0) return; + + rendering::VkTexture* newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); + if (newTex) { + charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); + } + + // --- Weapon model attachment --- + // Slot indices in the 19-element EquipSlot array: + // 15 = MAIN_HAND → attachment 1 (right hand) + // 16 = OFF_HAND → attachment 2 (left hand) + struct OnlineWeaponSlot { + int slotIndex; + uint32_t attachmentId; + }; + static constexpr OnlineWeaponSlot weaponSlots[] = { + { 15, 1 }, // MAIN_HAND → right hand + { 16, 2 }, // OFF_HAND → left hand + }; + + const uint32_t modelFieldL = idiL ? (*idiL)["LeftModel"] : 1u; + const uint32_t modelFieldR = idiL ? (*idiL)["RightModel"] : 2u; + const uint32_t texFieldL = idiL ? (*idiL)["LeftModelTexture"] : 3u; + const uint32_t texFieldR = idiL ? (*idiL)["RightModelTexture"] : 4u; + + for (const auto& ws : weaponSlots) { + uint32_t weapDisplayId = displayInfoIds[ws.slotIndex]; + if (weapDisplayId == 0) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + + int32_t recIdx = displayInfoDbc->findRecordById(weapDisplayId); + if (recIdx < 0) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + + // Prefer LeftModel (full weapon), fall back to RightModel (hilt variants) + std::string modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldL); + std::string textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldL); + if (modelName.empty()) { + modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldR); + textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldR); + } + if (modelName.empty()) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + + // Convert .mdx → .m2 + std::string modelFile = modelName; + { + size_t dotPos = modelFile.rfind('.'); + if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); + modelFile += ".m2"; + } + + // Try Weapon directory first, then Shield + std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; + pipeline::M2Model weaponModel; + if (!loadWeaponM2(m2Path, weaponModel)) { + m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; + if (!loadWeaponM2(m2Path, weaponModel)) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + } + + // Build texture path + std::string texturePath; + if (!textureName.empty()) { + texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; + if (!assetManager_->fileExists(texturePath)) { + texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; + if (!assetManager_->fileExists(texturePath)) texturePath.clear(); + } + } + + uint32_t weaponModelId = nextWeaponModelId_++; + charRenderer->attachWeapon(st.instanceId, ws.attachmentId, + weaponModel, weaponModelId, texturePath); + } +} + +void EntitySpawner::despawnPlayer(uint64_t guid) { + if (!renderer_ || !renderer_->getCharacterRenderer()) return; + auto it = playerInstances_.find(guid); + if (it == playerInstances_.end()) return; + renderer_->getCharacterRenderer()->removeInstance(it->second); + playerInstances_.erase(it); + onlinePlayerAppearance_.erase(guid); + pendingOnlinePlayerEquipment_.erase(guid); + creatureRenderPosCache_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); +} + +void EntitySpawner::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + if (!renderer_ || !assetManager_) return; + + if (!gameObjectLookupsBuilt_) { + buildGameObjectDisplayLookups(); + } + if (!gameObjectLookupsBuilt_) return; + + auto goIt = gameObjectInstances_.find(guid); + if (goIt != gameObjectInstances_.end()) { + // Already have a render instance — update its position (e.g. transport re-creation) + auto& info = goIt->second; + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + LOG_DEBUG("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, + " pos=(", x, ", ", y, ", ", z, ")"); + if (renderer_) { + if (info.isWmo) { + if (auto* wr = renderer_->getWMORenderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + wr->setInstanceTransform(info.instanceId, transform); + } + } else { + if (auto* mr = renderer_->getM2Renderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + mr->setInstanceTransform(info.instanceId, transform); + } + } + } + return; + } + + std::string modelPath; + + // Override model path for transports with wrong displayIds (preloaded transports) + // Check if this GUID is a known transport + bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(guid); + if (isTransport) { + // Map common transport displayIds to correct WMO paths + // NOTE: displayIds 455/462 are elevators in Thunder Bluff and should NOT be forced to ships. + // Keep ship/zeppelin overrides entry-driven where possible. + // DisplayIds 807, 808 = Zeppelins + // DisplayIds 2454, 1587 = Special ships/icebreakers + if (entry == 20808 || entry == 176231 || entry == 176310) { + modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; + LOG_INFO("Overriding transport entry/display ", entry, "/", displayId, " → transportship.wmo"); + } else if (displayId == 807 || displayId == 808 || displayId == 175080 || displayId == 176495 || displayId == 164871) { + modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; + LOG_INFO("Overriding transport displayId ", displayId, " → transport_zeppelin.wmo"); + } else if (displayId == 1587) { + modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; + LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Horde_Zeppelin.wmo"); + } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { + modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; + LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); + } else if (displayId == 3831) { + // Deeprun Tram car + modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; + LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); + } + } + + // Fallback to normal displayId lookup if not a transport or no override matched + if (modelPath.empty()) { + modelPath = getGameObjectModelPathForDisplayId(displayId); + } + + if (modelPath.empty()) { + LOG_WARNING("No model path for gameobject displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")"); + return; + } + + // Log spawns to help debug duplicate objects (e.g., cathedral issue) + LOG_DEBUG("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, + " model=", modelPath, " pos=(", x, ", ", y, ", ", z, ")"); + + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; + + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + const float renderYawWmo = orientation; + // M2 game objects: model default faces +renderX. renderYaw = canonical + 90° = server_yaw + // (same offset as creature/character renderer_ so all M2 models face consistently) + const float renderYawM2go = orientation + glm::radians(90.0f); + + bool loadedAsWmo = false; + if (isWmo) { + auto* wmoRenderer = renderer_->getWMORenderer(); + if (!wmoRenderer) return; + + uint32_t modelId = 0; + auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); + if (itCache != gameObjectDisplayIdWmoCache_.end()) { + modelId = itCache->second; + // Only use cached entry if the model is still resident in the renderer_ + if (wmoRenderer->isModelLoaded(modelId)) { + loadedAsWmo = true; + } else { + gameObjectDisplayIdWmoCache_.erase(itCache); + modelId = 0; + } + } + if (!loadedAsWmo && modelId == 0) { + auto wmoData = assetManager_->readFile(modelPath); + if (!wmoData.empty()) { + pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); + LOG_DEBUG("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); + int loadedGroups = 0; + if (wmoModel.nGroups > 0) { + std::string basePath = modelPath; + std::string extension; + if (basePath.size() > 4) { + extension = basePath.substr(basePath.size() - 4); + std::string extLower = extension; + for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); + if (extLower == ".wmo") { + basePath = basePath.substr(0, basePath.size() - 4); + } + } + + for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { + char groupSuffix[16]; + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); + std::string groupPath = basePath + groupSuffix; + std::vector groupData = assetManager_->readFile(groupPath); + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); + groupData = assetManager_->readFile(basePath + groupSuffix); + } + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); + groupData = assetManager_->readFile(basePath + groupSuffix); + } + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); + loadedGroups++; + } else { + LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); + } + } + } + + if (loadedGroups > 0 || wmoModel.nGroups == 0) { + modelId = nextGameObjectWmoModelId_++; + if (wmoRenderer->loadModel(wmoModel, modelId)) { + gameObjectDisplayIdWmoCache_[displayId] = modelId; + loadedAsWmo = true; + } else { + LOG_WARNING("Failed to load gameobject WMO model: ", modelPath); + } + } else { + LOG_WARNING("No WMO groups loaded for gameobject: ", modelPath, + " — falling back to M2"); + } + } else { + LOG_WARNING("Failed to read gameobject WMO: ", modelPath, " — falling back to M2"); + } + } + + if (loadedAsWmo) { + uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, + glm::vec3(0.0f, 0.0f, renderYawWmo), scale); + if (instanceId == 0) { + LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); + return; + } + + gameObjectInstances_[guid] = {modelId, instanceId, true}; + LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, + " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); + + // Spawn transport WMO doodads (chairs, furniture, etc.) as child M2 instances + bool isTransport = false; + if (gameHandler_) { + std::string lowerModelPath = modelPath; + std::transform(lowerModelPath.begin(), lowerModelPath.end(), lowerModelPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + isTransport = (lowerModelPath.find("transport") != std::string::npos); + } + + auto* m2Renderer = renderer_->getM2Renderer(); + if (m2Renderer && isTransport) { + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); + if (doodadTemplates && !doodadTemplates->empty()) { + constexpr size_t kMaxTransportDoodads = 192; + const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads); + LOG_DEBUG("Queueing ", doodadBudget, "/", doodadTemplates->size(), + " transport doodads for WMO instance ", instanceId); + pendingTransportDoodadBatches_.push_back(PendingTransportDoodadBatch{ + guid, + modelId, + instanceId, + 0, + doodadBudget, + 0, + x, y, z, + orientation + }); + } else { + LOG_DEBUG("Transport WMO has no doodads or templates not available"); + } + } + + // Transport GameObjects are not always named "transport" in their WMO path + // (e.g. elevators/lifts). If the server marks it as a transport, always + // notify so TransportManager can animate/carry passengers. + bool isTG = gameHandler_ && gameHandler_->isTransportGuid(guid); + LOG_WARNING("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); + } + + if (!m2Renderer->loadModel(model, modelId)) { + LOG_WARNING("Failed to load gameobject model: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); + return; + } + + gameObjectDisplayIdModelCache_[displayId] = modelId; + } + + uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, + glm::vec3(0.0f, 0.0f, renderYawM2go), scale); + if (instanceId == 0) { + LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); + return; + } + + // Freeze animation for static gameobjects, but let portals/effects/transports animate + bool isTransportGO = gameHandler_ && gameHandler_->isTransportGuid(guid); + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || + lowerPath.find("instancenewportal") != std::string::npos || + lowerPath.find("portalfx") != std::string::npos || + lowerPath.find("spellportal") != std::string::npos); + if (!isAnimatedEffect && !isTransportGO) { + 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_WARNING("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, ")"); +} + +void EntitySpawner::processAsyncCreatureResults(bool unlimited) { + // Check completed async model loads and finalize on main thread (GPU upload + instance creation). + // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. + // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. + static constexpr int kMaxModelUploadsPerTick = 1; + static constexpr int kMaxModelUploadsPerTickWarmup = 1; + static constexpr float kFinalizeBudgetMs = 2.0f; + static constexpr float kFinalizeBudgetWarmupMs = 2.0f; + const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; + const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; + const auto tickStart = std::chrono::steady_clock::now(); + int modelUploads = 0; + + for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { + if (std::chrono::duration( + std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { + break; + } + + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + + auto result = it->future.get(); + it = asyncCreatureLoads_.erase(it); + asyncCreatureDisplayLoads_.erase(result.displayId); + + // Failures and cache hits need no GPU work — process them even when the + // upload budget is exhausted. Previously the budget check was above this + // point, blocking ALL ready futures (including zero-cost ones) after a + // single upload, which throttled creature spawn throughput during world load. + if (result.permanent_failure) { + nonRenderableCreatureDisplayIds_.insert(result.displayId); + creaturePermanentFailureGuids_.insert(result.guid); + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + continue; + } + if (!result.valid || !result.model) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + continue; + } + + // Another async result may have already uploaded this displayId while this + // task was still running; in that case, skip duplicate GPU upload. + if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + continue; + } + + // Only actual GPU uploads count toward the per-tick budget. + if (modelUploads >= maxUploadsThisTick) { + // Re-queue this result — it needs a GPU upload but we're at budget. + // Push a new pending spawn so it's retried next frame. + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; s.y = result.y; s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + continue; + } + + // Model parsed on background thread — upload to GPU on main thread. + auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; + if (!charRenderer) { + pendingCreatureSpawnGuids_.erase(result.guid); + continue; + } + + // Count upload attempts toward the frame budget even if upload fails. + // Otherwise repeated failures can consume an unbounded amount of frame time. + modelUploads++; + + // Upload model to GPU (must happen on main thread) + // Use pre-decoded BLP cache to skip main-thread texture decode + auto uploadStart = std::chrono::steady_clock::now(); + charRenderer->setPredecodedBLPCache(&result.predecodedTextures); + if (!charRenderer->loadModel(*result.model, result.modelId)) { + charRenderer->setPredecodedBLPCache(nullptr); + nonRenderableCreatureDisplayIds_.insert(result.displayId); + creaturePermanentFailureGuids_.insert(result.guid); + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + continue; + } + charRenderer->setPredecodedBLPCache(nullptr); + { + auto uploadEnd = std::chrono::steady_clock::now(); + float uploadMs = std::chrono::duration(uploadEnd - uploadStart).count(); + if (uploadMs > 100.0f) { + LOG_WARNING("charRenderer->loadModel took ", uploadMs, "ms displayId=", result.displayId, + " preDecoded=", result.predecodedTextures.size()); + } + } + // Save remaining pre-decoded textures (display skins) for spawnOnlineCreature + if (!result.predecodedTextures.empty()) { + displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); + } + displayIdModelCache_[result.displayId] = result.modelId; + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + + // Re-queue as a normal pending spawn — model is now cached, so sync spawn is fast + // (only creates instance + applies textures, no file I/O). + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + } +} + +void EntitySpawner::processAsyncNpcCompositeResults(bool unlimited) { + auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; + if (!charRenderer) return; + + // Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing + // simultaneously. In unlimited mode (load screen), process everything without cap. + static constexpr float kCompositeBudgetMs = 2.0f; + auto startTime = std::chrono::steady_clock::now(); + + for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) { + if (!unlimited) { + float elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsed >= kCompositeBudgetMs) break; + } + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + auto result = it->future.get(); + it = asyncNpcCompositeLoads_.erase(it); + + const auto& info = result.info; + + // Set pre-decoded cache so texture loads skip synchronous BLP decode + charRenderer->setPredecodedBLPCache(&result.predecodedTextures); + + // --- Apply skin to type-1 slots --- + rendering::VkTexture* skinTex = nullptr; + + if (info.hasBakedSkin) { + // Baked skin: load from pre-decoded cache + skinTex = charRenderer->loadTexture(info.bakedSkinPath); + } + + if (info.hasComposite) { + // Composite with face/underwear/equipment regions on top of base skin + rendering::VkTexture* compositeTex = nullptr; + if (!info.regionLayers.empty()) { + compositeTex = charRenderer->compositeWithRegions(info.basePath, + info.overlayPaths, info.regionLayers); + } else if (!info.overlayPaths.empty()) { + std::vector skinLayers; + skinLayers.push_back(info.basePath); + for (const auto& op : info.overlayPaths) skinLayers.push_back(op); + compositeTex = charRenderer->compositeTextures(skinLayers); + } + if (compositeTex) skinTex = compositeTex; + } else if (info.hasSimpleSkin) { + // Simple skin: just base texture, no compositing + auto* baseTex = charRenderer->loadTexture(info.basePath); + if (baseTex) skinTex = baseTex; + } + + if (skinTex) { + for (uint32_t slot : info.skinTextureSlots) { + charRenderer->setModelTexture(info.modelId, slot, skinTex); + } + } + + // --- Apply hair texture to type-6 slots --- + if (!info.hairTexturePath.empty()) { + rendering::VkTexture* hairTex = charRenderer->loadTexture(info.hairTexturePath); + rendering::VkTexture* whTex = charRenderer->loadTexture(""); + if (hairTex && hairTex != whTex) { + for (uint32_t slot : info.hairTextureSlots) { + charRenderer->setModelTexture(info.modelId, slot, hairTex); + } + } + } else if (info.useBakedForHair && skinTex) { + // Bald NPC: use skin/baked texture for scalp cap + for (uint32_t slot : info.hairTextureSlots) { + charRenderer->setModelTexture(info.modelId, slot, skinTex); + } + } + + charRenderer->setPredecodedBLPCache(nullptr); + } +} + +void EntitySpawner::processCreatureSpawnQueue(bool unlimited) { + auto startTime = std::chrono::steady_clock::now(); + // Budget: max 2ms per frame for creature spawning to prevent stutter. + // In unlimited mode (load screen), process everything without budget cap. + static constexpr float kSpawnBudgetMs = 2.0f; + + // First, finalize any async model loads that completed on background threads. + processAsyncCreatureResults(unlimited); + { + auto now = std::chrono::steady_clock::now(); + float asyncMs = std::chrono::duration(now - startTime).count(); + if (asyncMs > 100.0f) { + LOG_WARNING("processAsyncCreatureResults took ", asyncMs, "ms"); + } + } + + if (pendingCreatureSpawns_.empty()) return; + if (!creatureLookupsBuilt_) { + buildCreatureDisplayLookups(); + if (!creatureLookupsBuilt_) return; + } + + int processed = 0; + int asyncLaunched = 0; + size_t rotationsLeft = pendingCreatureSpawns_.size(); + while (!pendingCreatureSpawns_.empty() && + (unlimited || processed < MAX_SPAWNS_PER_FRAME) && + rotationsLeft > 0) { + // Check time budget every iteration (including first — async results may + // have already consumed the budget via GPU model uploads). + if (!unlimited) { + auto now = std::chrono::steady_clock::now(); + float elapsedMs = std::chrono::duration(now - startTime).count(); + if (elapsedMs >= kSpawnBudgetMs) break; + } + + PendingCreatureSpawn s = pendingCreatureSpawns_.front(); + pendingCreatureSpawns_.pop_front(); + + if (nonRenderableCreatureDisplayIds_.count(s.displayId)) { + pendingCreatureSpawnGuids_.erase(s.guid); + creatureSpawnRetryCounts_.erase(s.guid); + processed++; + rotationsLeft = pendingCreatureSpawns_.size(); + continue; + } + + const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end()); + + // For new models: launch async load on background thread instead of blocking. + if (needsNewModel) { + // Keep exactly one background load per displayId. Additional spawns for + // the same displayId stay queued and will spawn once cache is populated. + if (asyncCreatureDisplayLoads_.count(s.displayId)) { + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + + const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; + if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { + // Too many in-flight — defer to next frame + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + + std::string m2Path = getModelPathForDisplayId(s.displayId); + if (m2Path.empty()) { + nonRenderableCreatureDisplayIds_.insert(s.displayId); + creaturePermanentFailureGuids_.insert(s.guid); + pendingCreatureSpawnGuids_.erase(s.guid); + creatureSpawnRetryCounts_.erase(s.guid); + processed++; + rotationsLeft = pendingCreatureSpawns_.size(); + continue; + } + + // Check for invisible stalkers + { + 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(s.displayId); + creaturePermanentFailureGuids_.insert(s.guid); + pendingCreatureSpawnGuids_.erase(s.guid); + processed++; + rotationsLeft = pendingCreatureSpawns_.size(); + continue; + } + } + + // Launch async M2 load — file I/O and parsing happen off the main thread. + uint32_t modelId = nextCreatureModelId_++; + auto* am = assetManager_; + + // Collect display skin texture paths for background pre-decode + std::vector displaySkinPaths; + { + auto itDD = displayDataMap_.find(s.displayId); + if (itDD != displayDataMap_.end()) { + std::string modelDir; + size_t lastSlash = m2Path.find_last_of("\\/"); + if (lastSlash != std::string::npos) modelDir = m2Path.substr(0, lastSlash + 1); + + auto resolveForAsync = [&](const std::string& skinField) { + if (skinField.empty()) return; + std::string raw = skinField; + std::replace(raw.begin(), raw.end(), '/', '\\'); + while (!raw.empty() && std::isspace(static_cast(raw.front()))) raw.erase(raw.begin()); + while (!raw.empty() && std::isspace(static_cast(raw.back()))) raw.pop_back(); + if (raw.empty()) return; + bool hasExt = raw.size() >= 4 && raw.substr(raw.size()-4) == ".blp"; + bool hasDir = raw.find('\\') != std::string::npos; + std::vector candidates; + if (hasDir) { + candidates.push_back(raw); + if (!hasExt) candidates.push_back(raw + ".blp"); + } else { + candidates.push_back(modelDir + raw); + if (!hasExt) candidates.push_back(modelDir + raw + ".blp"); + candidates.push_back(raw); + if (!hasExt) candidates.push_back(raw + ".blp"); + } + for (const auto& c : candidates) { + if (am->fileExists(c)) { displaySkinPaths.push_back(c); return; } + } + }; + resolveForAsync(itDD->second.skin1); + resolveForAsync(itDD->second.skin2); + resolveForAsync(itDD->second.skin3); + + // Pre-decode humanoid NPC textures (bake, skin, face, underwear, hair, equipment) + if (itDD->second.extraDisplayId != 0) { + auto itHE = humanoidExtraMap_.find(itDD->second.extraDisplayId); + if (itHE != humanoidExtraMap_.end()) { + const auto& he = itHE->second; + // Baked texture + if (!he.bakeName.empty()) { + displaySkinPaths.push_back("Textures\\BakedNpcTextures\\" + he.bakeName); + } + // CharSections: skin, face, underwear + 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 nRace = static_cast(he.raceId); + uint32_t nSex = static_cast(he.sexId); + uint32_t nSkin = static_cast(he.skinId); + uint32_t nFace = static_cast(he.faceId); + 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 != nRace || sId != nSex) 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 && color == nSkin) { + std::string t = csDbc->getString(r, csF.texture1); + if (!t.empty()) displaySkinPaths.push_back(t); + } else if (section == 1 && variation == nFace && color == nSkin) { + std::string t1 = csDbc->getString(r, csF.texture1); + std::string t2 = csDbc->getString(r, csF.texture2); + if (!t1.empty()) displaySkinPaths.push_back(t1); + if (!t2.empty()) displaySkinPaths.push_back(t2); + } else if (section == 3 && variation == static_cast(he.hairStyleId) + && color == static_cast(he.hairColorId)) { + std::string t = csDbc->getString(r, csF.texture1); + if (!t.empty()) displaySkinPaths.push_back(t); + } else if (section == 4 && color == nSkin) { + for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { + std::string t = csDbc->getString(r, f); + if (!t.empty()) displaySkinPaths.push_back(t); + } + } + } + } + // Equipment region textures + auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc"); + if (idiDbc) { + static constexpr const char* compDirs[] = { + "ArmUpperTexture", "ArmLowerTexture", "HandTexture", + "TorsoUpperTexture", "TorsoLowerTexture", + "LegUpperTexture", "LegLowerTexture", "FootTexture", + }; + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + const uint32_t trf[8] = { + idiL ? (*idiL)["TextureArmUpper"] : 14u, + idiL ? (*idiL)["TextureArmLower"] : 15u, + idiL ? (*idiL)["TextureHand"] : 16u, + idiL ? (*idiL)["TextureTorsoUpper"]: 17u, + idiL ? (*idiL)["TextureTorsoLower"]: 18u, + idiL ? (*idiL)["TextureLegUpper"] : 19u, + idiL ? (*idiL)["TextureLegLower"] : 20u, + idiL ? (*idiL)["TextureFoot"] : 21u, + }; + const bool isFem = (he.sexId == 1); + for (int eq = 0; eq < 11; eq++) { + uint32_t did = he.equipDisplayId[eq]; + if (did == 0) continue; + int32_t recIdx = idiDbc->findRecordById(did); + if (recIdx < 0) continue; + for (int region = 0; region < 8; region++) { + std::string texName = idiDbc->getString(static_cast(recIdx), trf[region]); + if (texName.empty()) continue; + std::string base = "Item\\TextureComponents\\" + + std::string(compDirs[region]) + "\\" + texName; + std::string gp = base + (isFem ? "_F.blp" : "_M.blp"); + std::string up = base + "_U.blp"; + if (am->fileExists(gp)) displaySkinPaths.push_back(gp); + else if (am->fileExists(up)) displaySkinPaths.push_back(up); + else displaySkinPaths.push_back(base + ".blp"); + } + } + } + } + } + } + } + + AsyncCreatureLoad load; + load.future = std::async(std::launch::async, + [am, m2Path, modelId, s, skinPaths = std::move(displaySkinPaths)]() -> PreparedCreatureModel { + PreparedCreatureModel result; + result.guid = s.guid; + result.displayId = s.displayId; + result.modelId = modelId; + result.x = s.x; + result.y = s.y; + result.z = s.z; + result.orientation = s.orientation; + result.scale = s.scale; + + auto m2Data = am->readFile(m2Path); + if (m2Data.empty()) { + result.permanent_failure = true; + return result; + } + + auto model = std::make_shared(pipeline::M2Loader::load(m2Data)); + if (model->vertices.empty()) { + result.permanent_failure = true; + return result; + } + + // Load skin file + if (model->version >= 264) { + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + auto skinData = am->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, *model); + } + } + + // Load external .anim files + 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 = am->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, *model); + } + } + } + + // Pre-decode model textures on background thread + for (const auto& tex : model->textures) { + if (tex.filename.empty()) continue; + std::string texKey = tex.filename; + std::replace(texKey.begin(), texKey.end(), '/', '\\'); + std::transform(texKey.begin(), texKey.end(), texKey.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; + auto blp = am->loadTexture(texKey); + if (blp.isValid()) { + result.predecodedTextures[texKey] = std::move(blp); + } + } + + // Pre-decode display skin textures (skin1/skin2/skin3 from CreatureDisplayInfo) + for (const auto& sp : skinPaths) { + std::string key = sp; + 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); + } + } + + result.model = std::move(model); + result.valid = true; + return result; + }); + asyncCreatureLoads_.push_back(std::move(load)); + asyncCreatureDisplayLoads_.insert(s.displayId); + asyncLaunched++; + // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it + rotationsLeft = pendingCreatureSpawns_.size(); + processed++; + continue; + } + + // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) + { + auto spawnStart = std::chrono::steady_clock::now(); + spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); + auto spawnEnd = std::chrono::steady_clock::now(); + float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); + if (spawnMs > 100.0f) { + LOG_WARNING("spawnOnlineCreature took ", spawnMs, "ms displayId=", s.displayId); + } + } + pendingCreatureSpawnGuids_.erase(s.guid); + + // If spawn still failed, retry for a limited number of frames. + if (!creatureInstances_.count(s.guid)) { + if (creaturePermanentFailureGuids_.erase(s.guid) > 0) { + creatureSpawnRetryCounts_.erase(s.guid); + processed++; + continue; + } + uint16_t retries = 0; + auto it = creatureSpawnRetryCounts_.find(s.guid); + if (it != creatureSpawnRetryCounts_.end()) { + retries = it->second; + } + if (retries < MAX_CREATURE_SPAWN_RETRIES) { + creatureSpawnRetryCounts_[s.guid] = static_cast(retries + 1); + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(s.guid); + } else { + creatureSpawnRetryCounts_.erase(s.guid); + LOG_WARNING("Dropping creature spawn after retries: guid=0x", std::hex, s.guid, std::dec, + " displayId=", s.displayId); + } + } else { + creatureSpawnRetryCounts_.erase(s.guid); + } + rotationsLeft = pendingCreatureSpawns_.size(); + processed++; + } +} + +void EntitySpawner::processPlayerSpawnQueue() { + if (pendingPlayerSpawns_.empty()) return; + if (!assetManager_ || !assetManager_->isInitialized()) return; + + int processed = 0; + while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) { + PendingPlayerSpawn s = pendingPlayerSpawns_.front(); + pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin()); + pendingPlayerSpawnGuids_.erase(s.guid); + + // Skip if already spawned (could have been spawned by a previous update this frame) + if (playerInstances_.count(s.guid)) { + processed++; + continue; + } + + spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); + // Apply any equipment updates that arrived before the player was spawned. + auto pit = pendingOnlinePlayerEquipment_.find(s.guid); + if (pit != pendingOnlinePlayerEquipment_.end()) { + deferredEquipmentQueue_.push_back({s.guid, pit->second}); + pendingOnlinePlayerEquipment_.erase(pit); + } + processed++; + } +} + +std::vector EntitySpawner::resolveEquipmentTexturePaths(uint64_t guid, + const std::array& displayInfoIds, + const std::array& /*inventoryTypes*/) const { + std::vector paths; + + auto it = onlinePlayerAppearance_.find(guid); + if (it == onlinePlayerAppearance_.end()) return paths; + const OnlinePlayerAppearanceState& st = it->second; + + // Add base skin + underwear paths + if (!st.bodySkinPath.empty()) paths.push_back(st.bodySkinPath); + for (const auto& up : st.underwearPaths) { + if (!up.empty()) paths.push_back(up); + } + + // Resolve equipment region texture paths (same logic as setOnlinePlayerEquipment) + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return paths; + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + + static constexpr const char* componentDirs[] = { + "ArmUpperTexture", "ArmLowerTexture", "HandTexture", + "TorsoUpperTexture", "TorsoLowerTexture", + "LegUpperTexture", "LegLowerTexture", "FootTexture", + }; + const uint32_t texRegionFields[8] = { + idiL ? (*idiL)["TextureArmUpper"] : 14u, + idiL ? (*idiL)["TextureArmLower"] : 15u, + idiL ? (*idiL)["TextureHand"] : 16u, + idiL ? (*idiL)["TextureTorsoUpper"]: 17u, + idiL ? (*idiL)["TextureTorsoLower"]: 18u, + idiL ? (*idiL)["TextureLegUpper"] : 19u, + idiL ? (*idiL)["TextureLegLower"] : 20u, + idiL ? (*idiL)["TextureFoot"] : 21u, + }; + const bool isFemale = (st.genderId == 1); + + for (int s = 0; s < 19; s++) { + uint32_t did = displayInfoIds[s]; + if (did == 0) continue; + int32_t recIdx = displayInfoDbc->findRecordById(did); + if (recIdx < 0) continue; + for (int region = 0; region < 8; region++) { + std::string texName = displayInfoDbc->getString( + static_cast(recIdx), texRegionFields[region]); + if (texName.empty()) continue; + std::string base = "Item\\TextureComponents\\" + + std::string(componentDirs[region]) + "\\" + texName; + std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); + std::string unisexPath = base + "_U.blp"; + if (assetManager_->fileExists(genderPath)) paths.push_back(genderPath); + else if (assetManager_->fileExists(unisexPath)) paths.push_back(unisexPath); + else paths.push_back(base + ".blp"); + } + } + return paths; +} + +void EntitySpawner::processAsyncEquipmentResults() { + for (auto it = asyncEquipmentLoads_.begin(); it != asyncEquipmentLoads_.end(); ) { + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + auto result = it->future.get(); + it = asyncEquipmentLoads_.erase(it); + + auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; + if (!charRenderer) continue; + + // Set pre-decoded cache so compositeWithRegions skips synchronous BLP decode + charRenderer->setPredecodedBLPCache(&result.predecodedTextures); + setOnlinePlayerEquipment(result.guid, result.displayInfoIds, result.inventoryTypes); + charRenderer->setPredecodedBLPCache(nullptr); + } +} + +void EntitySpawner::processDeferredEquipmentQueue() { + // First, finalize any completed async pre-decodes + processAsyncEquipmentResults(); + + if (deferredEquipmentQueue_.empty()) return; + // Limit in-flight async equipment loads + if (asyncEquipmentLoads_.size() >= 2) return; + + auto [guid, equipData] = deferredEquipmentQueue_.front(); + deferredEquipmentQueue_.erase(deferredEquipmentQueue_.begin()); + + // Resolve all texture paths that compositeWithRegions will need + auto texturePaths = resolveEquipmentTexturePaths(guid, equipData.first, equipData.second); + + if (texturePaths.empty()) { + // No textures to pre-decode — just apply directly (fast path) + LOG_WARNING("Equipment fast path for guid=0x", std::hex, guid, std::dec, + " (no textures to pre-decode)"); + setOnlinePlayerEquipment(guid, equipData.first, equipData.second); + return; + } + LOG_WARNING("Equipment async pre-decode for guid=0x", std::hex, guid, std::dec, + " textures=", texturePaths.size()); + + // Launch background BLP pre-decode + auto* am = assetManager_; + auto displayInfoIds = equipData.first; + auto inventoryTypes = equipData.second; + AsyncEquipmentLoad load; + load.future = std::async(std::launch::async, + [am, guid, displayInfoIds, inventoryTypes, paths = std::move(texturePaths)]() -> PreparedEquipmentUpdate { + PreparedEquipmentUpdate result; + result.guid = guid; + result.displayInfoIds = displayInfoIds; + result.inventoryTypes = inventoryTypes; + for (const auto& path : paths) { + 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; + }); + asyncEquipmentLoads_.push_back(std::move(load)); +} + +void EntitySpawner::processAsyncGameObjectResults() { + for (auto it = asyncGameObjectLoads_.begin(); it != asyncGameObjectLoads_.end(); ) { + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + + auto result = it->future.get(); + it = asyncGameObjectLoads_.erase(it); + + if (!result.valid || !result.isWmo || !result.wmoModel) { + // Fallback: spawn via sync path (likely an M2 or failed WMO) + spawnOnlineGameObject(result.guid, result.entry, result.displayId, + result.x, result.y, result.z, result.orientation, result.scale); + continue; + } + + // WMO parsed on background thread — do GPU upload + instance creation on main thread + auto* wmoRenderer = renderer_ ? renderer_->getWMORenderer() : nullptr; + if (!wmoRenderer) continue; + + uint32_t modelId = 0; + auto itCache = gameObjectDisplayIdWmoCache_.find(result.displayId); + if (itCache != gameObjectDisplayIdWmoCache_.end()) { + modelId = itCache->second; + } else { + modelId = nextGameObjectWmoModelId_++; + wmoRenderer->setPredecodedBLPCache(&result.predecodedTextures); + if (!wmoRenderer->loadModel(*result.wmoModel, modelId)) { + wmoRenderer->setPredecodedBLPCache(nullptr); + LOG_WARNING("Failed to load async gameobject WMO: ", result.modelPath); + continue; + } + wmoRenderer->setPredecodedBLPCache(nullptr); + gameObjectDisplayIdWmoCache_[result.displayId] = modelId; + } + + glm::vec3 renderPos = core::coords::canonicalToRender( + glm::vec3(result.x, result.y, result.z)); + uint32_t instanceId = wmoRenderer->createInstance( + modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); + if (instanceId == 0) continue; + + gameObjectInstances_[result.guid] = {modelId, instanceId, true}; + + // Queue transport doodad loading if applicable + std::string lowerPath = result.modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerPath.find("transport") != std::string::npos) { + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); + if (doodadTemplates && !doodadTemplates->empty()) { + PendingTransportDoodadBatch batch; + batch.guid = result.guid; + batch.modelId = modelId; + batch.instanceId = instanceId; + batch.x = result.x; + batch.y = result.y; + batch.z = result.z; + batch.orientation = result.orientation; + batch.doodadBudget = doodadTemplates->size(); + pendingTransportDoodadBatches_.push_back(batch); + } + } + } +} + +void EntitySpawner::processGameObjectSpawnQueue() { + // Finalize any completed async WMO loads first + processAsyncGameObjectResults(); + + if (pendingGameObjectSpawns_.empty()) return; + + // Process spawns: cached WMOs and M2s go sync (cheap), uncached WMOs go async + auto startTime = std::chrono::steady_clock::now(); + static constexpr float kBudgetMs = 2.0f; + static constexpr int kMaxAsyncLoads = 2; + + while (!pendingGameObjectSpawns_.empty()) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kBudgetMs) break; + + auto& s = pendingGameObjectSpawns_.front(); + + // Check if this is an uncached WMO that needs async loading + std::string modelPath; + if (gameObjectLookupsBuilt_) { + // Check transport overrides first + bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(s.guid); + if (isTransport) { + if (s.entry == 20808 || s.entry == 176231 || s.entry == 176310) + modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; + else if (s.displayId == 807 || s.displayId == 808 || s.displayId == 175080 || s.displayId == 176495 || s.displayId == 164871) + modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; + else if (s.displayId == 1587) + modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; + else if (s.displayId == 2454 || s.displayId == 181688 || s.displayId == 190536) + modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; + } + if (modelPath.empty()) + modelPath = getGameObjectModelPathForDisplayId(s.displayId); + } + + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; + bool isCached = isWmo && gameObjectDisplayIdWmoCache_.count(s.displayId); + + if (isWmo && !isCached && !modelPath.empty() && + static_cast(asyncGameObjectLoads_.size()) < kMaxAsyncLoads) { + // Launch async WMO load — file I/O + parse on background thread + auto* am = assetManager_; + PendingGameObjectSpawn capture = s; + std::string capturePath = modelPath; + AsyncGameObjectLoad load; + load.future = std::async(std::launch::async, + [am, capture, capturePath]() -> PreparedGameObjectWMO { + PreparedGameObjectWMO result; + result.guid = capture.guid; + result.entry = capture.entry; + result.displayId = capture.displayId; + result.x = capture.x; + result.y = capture.y; + result.z = capture.z; + result.orientation = capture.orientation; + result.scale = capture.scale; + result.modelPath = capturePath; + result.isWmo = true; + + auto wmoData = am->readFile(capturePath); + if (wmoData.empty()) return result; + + auto wmo = std::make_shared( + pipeline::WMOLoader::load(wmoData)); + + // Load groups + if (wmo->nGroups > 0) { + std::string basePath = capturePath; + std::string ext; + if (basePath.size() > 4) { + ext = basePath.substr(basePath.size() - 4); + basePath = basePath.substr(0, basePath.size() - 4); + } + for (uint32_t gi = 0; gi < wmo->nGroups; gi++) { + char suffix[16]; + snprintf(suffix, sizeof(suffix), "_%03u%s", gi, ext.c_str()); + auto groupData = am->readFile(basePath + suffix); + if (groupData.empty()) { + snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); + groupData = am->readFile(basePath + suffix); + } + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, *wmo, gi); + } + } + } + + // Pre-decode WMO textures on background thread + for (const auto& texPath : wmo->textures) { + if (texPath.empty()) continue; + std::string texKey = texPath; + size_t nul = texKey.find('\0'); + if (nul != std::string::npos) texKey.resize(nul); + std::replace(texKey.begin(), texKey.end(), '/', '\\'); + std::transform(texKey.begin(), texKey.end(), texKey.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (texKey.empty()) continue; + // Convert to .blp extension + if (texKey.size() >= 4) { + std::string ext = texKey.substr(texKey.size() - 4); + if (ext == ".tga" || ext == ".dds") { + texKey = texKey.substr(0, texKey.size() - 4) + ".blp"; + } + } + if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; + auto blp = am->loadTexture(texKey); + if (blp.isValid()) { + result.predecodedTextures[texKey] = std::move(blp); + } + } + + result.wmoModel = wmo; + result.valid = true; + return result; + }); + asyncGameObjectLoads_.push_back(std::move(load)); + pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); + continue; + } + + // Cached WMO or M2 — spawn synchronously (cheap) + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); + pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); + } +} + +void EntitySpawner::processPendingTransportRegistrations() { + if (pendingTransportRegistrations_.empty()) return; + if (!gameHandler_ || !renderer_) return; + + auto* transportManager = gameHandler_->getTransportManager(); + if (!transportManager) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr int kMaxRegistrationsPerFrame = 2; + static constexpr float kRegistrationBudgetMs = 2.0f; + int processed = 0; + + for (auto it = pendingTransportRegistrations_.begin(); + it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kRegistrationBudgetMs) break; + + const PendingTransportRegistration pending = *it; + auto goIt = gameObjectInstances_.find(pending.guid); + if (goIt == gameObjectInstances_.end()) { + it = pendingTransportRegistrations_.erase(it); + continue; + } + + if (transportManager->getTransport(pending.guid)) { + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + it = pendingTransportRegistrations_.erase(it); + continue; + } + + const uint32_t wmoInstanceId = goIt->second.instanceId; + LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, + " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); + + // TransportAnimation.dbc is indexed by GameObject entry. + uint32_t pathId = pending.entry; + const bool preferServerData = gameHandler_->hasServerTransportUpdate(pending.guid); + + bool clientAnim = transportManager->isClientSideAnimation(); + LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, + " guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " pathId=", pathId, + " preferServer=", preferServerData); + + glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); + const bool shipOrZeppelinDisplay = + (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || + pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || + pending.displayId == 807 || pending.displayId == 808); + bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); + if (shipOrZeppelinDisplay) { + hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); + } + + LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + + if (preferServerData) { + if (!hasUsablePath) { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", + std::hex, pending.guid, std::dec, " entry=", pending.entry); + } else { + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); + } + } else if (!hasUsablePath) { + bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); + uint32_t inferredPath = transportManager->inferDbcPathForSpawn( + canonicalSpawnPos, 1200.0f, allowZOnly); + if (inferredPath != 0) { + pathId = inferredPath; + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); + } else { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_WARNING("Using remapped fallback transport path ", pathId, + " for entry ", pending.entry, " displayId=", pending.displayId, + " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); + } else { + LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, + " - transport will be stationary"); + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + } + } + } else { + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); + } + + transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); + + if (!goIt->second.isWmo) { + if (auto* tr = transportManager->getTransport(pending.guid)) { + tr->isM2 = true; + } + } + + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + + auto moveIt = pendingTransportMoves_.find(pending.guid); + if (moveIt != pendingTransportMoves_.end()) { + const PendingTransportMove latestMove = moveIt->second; + transportManager->updateServerTransport( + pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); + LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, + " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, + ") orientation=", latestMove.orientation); + pendingTransportMoves_.erase(moveIt); + } + + if (glm::dot(canonicalSpawnPos, canonicalSpawnPos) < 1.0f) { + auto goData = gameHandler_->getCachedGameObjectInfo(pending.entry); + if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { + uint32_t taxiPathId = goData->data[0]; + if (transportManager->hasTaxiPath(taxiPathId)) { + transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); + LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, + " taxiPathId=", taxiPathId); + } + } + } + + if (auto* tr = transportManager->getTransport(pending.guid); tr) { + LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " pathId=", tr->pathId, + " mode=", (tr->useClientAnimation ? "client" : "server"), + " serverUpdates=", tr->serverUpdateCount); + } else { + LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " (TransportManager instance missing)"); + } + + ++processed; + it = pendingTransportRegistrations_.erase(it); + } +} + +void EntitySpawner::processPendingTransportDoodads() { + if (pendingTransportDoodadBatches_.empty()) return; + if (!renderer_ || !assetManager_) return; + + auto* wmoRenderer = renderer_->getWMORenderer(); + auto* m2Renderer = renderer_->getM2Renderer(); + if (!wmoRenderer || !m2Renderer) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr float kDoodadBudgetMs = 4.0f; + + // Batch all GPU uploads into a single async command buffer submission so that + // N doodads with multiple textures each don't each block on vkQueueSubmit + + // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds + // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. + auto* vkCtx = renderer_->getVkContext(); + if (vkCtx) vkCtx->beginUploadBatch(); + + size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; + for (auto it = pendingTransportDoodadBatches_.begin(); + it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { + // Time budget check + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kDoodadBudgetMs) break; + auto goIt = gameObjectInstances_.find(it->guid); + if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo || + goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) { + it = pendingTransportDoodadBatches_.erase(it); + continue; + } + + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId); + if (!doodadTemplates || doodadTemplates->empty()) { + it = pendingTransportDoodadBatches_.erase(it); + continue; + } + + const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size()); + while (it->nextIndex < maxIndex && budgetLeft > 0) { + // Per-doodad time budget (each does synchronous file I/O + parse + GPU upload) + float innerMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (innerMs >= kDoodadBudgetMs) { budgetLeft = 0; break; } + + const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex]; + it->nextIndex++; + budgetLeft--; + + uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); + auto m2Data = assetManager_->readFile(doodadTemplate.m2Path); + if (m2Data.empty()) continue; + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && m2Model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } + if (!m2Model.isValid()) continue; + + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; + uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); + if (m2InstanceId == 0) continue; + m2Renderer->setSkipCollision(m2InstanceId, true); + + wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform); + it->spawnedDoodads++; + } + + if (it->nextIndex >= maxIndex) { + if (it->spawnedDoodads > 0) { + LOG_DEBUG("Spawned ", it->spawnedDoodads, + " transport doodads for WMO instance ", it->instanceId); + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z)); + glm::mat4 wmoTransform(1.0f); + wmoTransform = glm::translate(wmoTransform, renderPos); + wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1)); + wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform); + } + it = pendingTransportDoodadBatches_.erase(it); + } else { + ++it; + } + } + + // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). + if (vkCtx) vkCtx->endUploadBatch(); +} + +void EntitySpawner::processPendingMount() { + if (pendingMountDisplayId_ == 0) return; + uint32_t mountDisplayId = pendingMountDisplayId_; + pendingMountDisplayId_ = 0; + LOG_INFO("processPendingMount: loading displayId ", mountDisplayId); + + if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_) return; + auto* charRenderer = renderer_->getCharacterRenderer(); + + std::string m2Path = getModelPathForDisplayId(mountDisplayId); + if (m2Path.empty()) { + LOG_WARNING("No model path for mount displayId ", mountDisplayId); + return; + } + + // Check model cache + uint32_t modelId = 0; + auto cacheIt = displayIdModelCache_.find(mountDisplayId); + if (cacheIt != displayIdModelCache_.end()) { + modelId = cacheIt->second; + } else { + modelId = nextCreatureModelId_++; + + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("Failed to read mount M2: ", m2Path); + return; + } + + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty()) { + LOG_WARNING("Failed to parse mount 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 mount M2: ", skinPath); + } + } + + // Load external .anim files (only idle + run needed for mounts) + 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)) { + uint32_t animId = model.sequences[si].id; + // Only load stand(0), walk(4), run(5) anims to avoid hang + if (animId != 0 && animId != 4 && animId != 5) continue; + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", + basePath.c_str(), animId, model.sequences[si].variationIndex); + auto animData = assetManager_->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + } + } + } + + if (!charRenderer->loadModel(model, modelId)) { + LOG_WARNING("Failed to load mount model: ", m2Path); + return; + } + + displayIdModelCache_[mountDisplayId] = modelId; + } + + // Apply creature skin textures from CreatureDisplayInfo.dbc. + // Re-apply even for cached models so transient failures can self-heal. + std::string modelDir; + size_t lastSlash = m2Path.find_last_of("\\/"); + if (lastSlash != std::string::npos) { + modelDir = m2Path.substr(0, lastSlash + 1); + } + + auto itDisplayData = displayDataMap_.find(mountDisplayId); + bool haveDisplayData = false; + CreatureDisplayData dispData{}; + if (itDisplayData != displayDataMap_.end()) { + dispData = itDisplayData->second; + haveDisplayData = true; + } else { + // Some taxi mount display IDs are sparse; recover skins by matching model path. + std::string lowerMountPath = m2Path; + std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + int bestScore = -1; + for (const auto& [dispId, data] : displayDataMap_) { + auto pit = modelIdToPath_.find(data.modelId); + if (pit == modelIdToPath_.end()) continue; + std::string p = pit->second; + std::transform(p.begin(), p.end(), p.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (p != lowerMountPath) continue; + int score = 0; + if (!data.skin1.empty()) { + std::string p1 = modelDir + data.skin1 + ".blp"; + score += assetManager_->fileExists(p1) ? 30 : 3; + } + if (!data.skin2.empty()) { + std::string p2 = modelDir + data.skin2 + ".blp"; + score += assetManager_->fileExists(p2) ? 20 : 2; + } + if (!data.skin3.empty()) { + std::string p3 = modelDir + data.skin3 + ".blp"; + score += assetManager_->fileExists(p3) ? 10 : 1; + } + if (score > bestScore) { + bestScore = score; + dispData = data; + haveDisplayData = true; + } + } + if (haveDisplayData) { + LOG_INFO("Recovered mount display data by model path for displayId=", mountDisplayId, + " skin1='", dispData.skin1, "' skin2='", dispData.skin2, + "' skin3='", dispData.skin3, "'"); + } + } + if (haveDisplayData) { + // If this displayId has no skins, try to find another displayId for the same model with skins. + if (dispData.skin1.empty() && dispData.skin2.empty() && dispData.skin3.empty()) { + uint32_t sourceModelId = dispData.modelId; + int bestScore = -1; + for (const auto& [dispId, data] : displayDataMap_) { + if (data.modelId != sourceModelId) continue; + int score = 0; + if (!data.skin1.empty()) { + std::string p = modelDir + data.skin1 + ".blp"; + score += assetManager_->fileExists(p) ? 30 : 3; + } + if (!data.skin2.empty()) { + std::string p = modelDir + data.skin2 + ".blp"; + score += assetManager_->fileExists(p) ? 20 : 2; + } + if (!data.skin3.empty()) { + std::string p = modelDir + data.skin3 + ".blp"; + score += assetManager_->fileExists(p) ? 10 : 1; + } + if (score > bestScore) { + bestScore = score; + dispData = data; + } + } + LOG_INFO("Mount skin fallback for displayId=", mountDisplayId, + " modelId=", sourceModelId, " skin1='", dispData.skin1, + "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); + } + const auto* md = charRenderer->getModelData(modelId); + if (md) { + LOG_INFO("Mount model textures: ", md->textures.size(), " slots, skin1='", dispData.skin1, + "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); + for (size_t ti = 0; ti < md->textures.size(); ti++) { + LOG_INFO(" tex[", ti, "] type=", md->textures[ti].type, + " filename='", md->textures[ti].filename, "'"); + } + + int replaced = 0; + for (size_t ti = 0; ti < md->textures.size(); ti++) { + const auto& tex = md->textures[ti]; + std::string texPath; + if (tex.type == 11 && !dispData.skin1.empty()) { + texPath = modelDir + dispData.skin1 + ".blp"; + } else if (tex.type == 12 && !dispData.skin2.empty()) { + texPath = modelDir + dispData.skin2 + ".blp"; + } else if (tex.type == 13 && !dispData.skin3.empty()) { + texPath = modelDir + dispData.skin3 + ".blp"; + } + if (!texPath.empty()) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); + if (skinTex) { + charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); + LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath); + replaced++; + } else { + LOG_WARNING(" Failed to load skin texture slot ", ti, ": ", texPath); + } + } + } + + // Force skin textures onto type-0 (hardcoded) slots that have no filename + if (replaced == 0) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + const auto& tex = md->textures[ti]; + if (tex.type == 0 && tex.filename.empty()) { + // Empty hardcoded slot — try skin1 then skin2 + std::string texPath; + if (!dispData.skin1.empty() && replaced == 0) { + texPath = modelDir + dispData.skin1 + ".blp"; + } else if (!dispData.skin2.empty()) { + texPath = modelDir + dispData.skin2 + ".blp"; + } + if (!texPath.empty()) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); + if (skinTex) { + charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); + LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath); + replaced++; + } + } + } + } + } + + // If still no textures, try hardcoded model texture filenames + if (replaced == 0) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + if (!md->textures[ti].filename.empty()) { + rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename); + if (texId) { + charRenderer->setModelTexture(modelId, static_cast(ti), texId); + LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename); + replaced++; + } + } + } + } + + // Final fallback for gryphon/wyvern: try well-known skin texture names + if (replaced == 0 && !md->textures.empty()) { + std::string lowerMountPath = m2Path; + std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerMountPath.find("gryphon") != std::string::npos) { + const char* gryphonSkins[] = { + "Creature\\Gryphon\\Gryphon_Skin.blp", + "Creature\\Gryphon\\Gryphon_Skin01.blp", + "Creature\\Gryphon\\GRYPHON_SKIN01.BLP", + nullptr + }; + for (const char** p = gryphonSkins; *p; ++p) { + rendering::VkTexture* texId = charRenderer->loadTexture(*p); + if (texId) { + charRenderer->setModelTexture(modelId, 0, texId); + LOG_INFO(" Forced gryphon skin fallback: ", *p); + replaced++; + break; + } + } + } else if (lowerMountPath.find("wyvern") != std::string::npos) { + const char* wyvernSkins[] = { + "Creature\\Wyvern\\Wyvern_Skin.blp", + "Creature\\Wyvern\\Wyvern_Skin01.blp", + nullptr + }; + for (const char** p = wyvernSkins; *p; ++p) { + rendering::VkTexture* texId = charRenderer->loadTexture(*p); + if (texId) { + charRenderer->setModelTexture(modelId, 0, texId); + LOG_INFO(" Forced wyvern skin fallback: ", *p); + replaced++; + break; + } + } + } + } + LOG_INFO("Mount texture setup: ", replaced, " textures applied"); + } + } + + mountModelId_ = modelId; + + // Create mount instance at player position + glm::vec3 mountPos = renderer_->getCharacterPosition(); + float yawRad = glm::radians(renderer_->getCharacterYaw()); + uint32_t instanceId = charRenderer->createInstance(modelId, mountPos, + glm::vec3(0.0f, 0.0f, yawRad), 1.0f); + + if (instanceId == 0) { + LOG_WARNING("Failed to create mount instance"); + return; + } + + mountInstanceId_ = instanceId; + + // Compute height offset — place player above mount's back + // Use tight bounds from actual vertices (M2 header bounds can be inaccurate) + const auto* modelData = charRenderer->getModelData(modelId); + float heightOffset = 1.8f; + if (modelData && !modelData->vertices.empty()) { + float minZ = std::numeric_limits::max(); + float maxZ = -std::numeric_limits::max(); + for (const auto& v : modelData->vertices) { + if (v.position.z < minZ) minZ = v.position.z; + if (v.position.z > maxZ) maxZ = v.position.z; + } + float extentZ = maxZ - minZ; + LOG_INFO("Mount tight bounds: minZ=", minZ, " maxZ=", maxZ, " extentZ=", extentZ); + if (extentZ > 0.5f) { + // Saddle point is roughly 75% up the model, measured from model origin + heightOffset = maxZ * 0.8f; + if (heightOffset < 1.0f) heightOffset = extentZ * 0.75f; + if (heightOffset < 1.0f) heightOffset = 1.8f; + } + } + + renderer_->setMounted(instanceId, mountDisplayId, heightOffset, m2Path); + + // For taxi mounts, start with flying animation; for ground mounts, start with stand + bool isTaxi = gameHandler_ && gameHandler_->isOnTaxiFlight(); + uint32_t startAnim = 0; // ANIM_STAND + if (isTaxi) { + // Try WotLK fly anims first, then Vanilla-friendly fallbacks + uint32_t taxiCandidates[] = {159, 158, 234, 229, 233, 141, 369, 6, 5}; // FlyForward, FlyIdle, FlyRun(234), FlyStand(229), FlyWalk(233), FlyMounted, FlyRun, Fly, Run + for (uint32_t anim : taxiCandidates) { + if (charRenderer->hasAnimation(instanceId, anim)) { + startAnim = anim; + break; + } + } + // If none found, startAnim stays 0 (Stand/hover) which is fine for flying creatures + } + charRenderer->playAnimation(instanceId, startAnim, true); + + LOG_INFO("processPendingMount: DONE displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset); +} + +void EntitySpawner::despawnCreature(uint64_t guid) { + // If this guid is a PLAYER, it will be tracked in playerInstances_. + // Route to the correct despawn path so we don't leak instances. + if (playerInstances_.count(guid)) { + despawnPlayer(guid); + return; + } + + pendingCreatureSpawnGuids_.erase(guid); + creatureSpawnRetryCounts_.erase(guid); + creaturePermanentFailureGuids_.erase(guid); + deadCreatureGuids_.erase(guid); + + auto it = creatureInstances_.find(guid); + if (it == creatureInstances_.end()) return; + + if (renderer_ && renderer_->getCharacterRenderer()) { + renderer_->getCharacterRenderer()->removeInstance(it->second); + } + + creatureInstances_.erase(it); + creatureModelIds_.erase(guid); + creatureRenderPosCache_.erase(guid); + creatureWeaponsAttached_.erase(guid); + creatureWeaponAttachAttempts_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + + LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); +} + +void EntitySpawner::despawnGameObject(uint64_t guid) { + pendingTransportDoodadBatches_.erase( + std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(), + [guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }), + pendingTransportDoodadBatches_.end()); + + auto it = gameObjectInstances_.find(guid); + if (it == gameObjectInstances_.end()) return; + + if (renderer_) { + if (it->second.isWmo) { + if (auto* wmoRenderer = renderer_->getWMORenderer()) { + wmoRenderer->removeInstance(it->second.instanceId); + } + } else { + if (auto* m2Renderer = renderer_->getM2Renderer()) { + m2Renderer->removeInstance(it->second.instanceId); + } + } + } + + gameObjectInstances_.erase(it); + + LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec); +} + +bool EntitySpawner::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) { + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) return false; + outModel = pipeline::M2Loader::load(m2Data); + // Load skin (WotLK+ M2 format): strip .m2, append 00.skin + std::string skinPath = m2Path; + size_t dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos); + skinPath += "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && outModel.version >= 264) + pipeline::M2Loader::loadSkin(skinData, outModel); + return outModel.isValid(); +} + + +} // namespace core +} // namespace wowee