diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 5c550c81..4ee5ffb1 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -95,5 +95,14 @@ "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, "DisplayMapID": 8, "ParentWorldMapID": 10 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 26ac235e..d9ad4351 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -111,5 +111,14 @@ "Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41, "Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45, "Threshold8": 46, "Threshold9": 47 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index c5a3948e..a76d065c 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -108,5 +108,14 @@ "Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33, "Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37, "Threshold8": 38, "Threshold9": 39 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 73d50a87..a332f041 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -116,5 +116,14 @@ }, "LFGDungeons": { "ID": 0, "Name": 1 + }, + "SpellVisual": { + "ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, "FilePath": 2 } } diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 07c6ebd6..7c08a738 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -152,6 +153,9 @@ public: void playEmote(const std::string& emoteName); void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); + + // Spell visual effects (SMSG_PLAY_SPELL_VISUAL) + void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static uint32_t getEmoteDbcId(const std::string& emoteName); @@ -323,6 +327,18 @@ private: glm::mat4 computeLightSpaceMatrix(); pipeline::AssetManager* cachedAssetManager = nullptr; + + // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL + struct SpellVisualInstance { uint32_t instanceId; float elapsed; }; + std::vector activeSpellVisuals_; + std::unordered_map spellVisualModelPath_; // visualId → resolved M2 path + std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId + uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799 + bool spellVisualDbcLoaded_ = false; + void loadSpellVisualDbc(); + void updateSpellVisuals(float deltaTime); + static constexpr float SPELL_VISUAL_DURATION = 3.5f; + uint32_t currentZoneId = 0; std::string currentZoneName; bool inTavern_ = false; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index fe593980..6709e6c3 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3265,10 +3265,24 @@ void GameHandler::handlePacket(network::Packet& packet) { handleSpellDamageLog(packet); break; case Opcode::SMSG_PLAY_SPELL_VISUAL: { - // Minimal parse: uint64 casterGuid, uint32 visualId + // uint64 casterGuid + uint32 visualId if (packet.getSize() - packet.getReadPos() < 12) break; - packet.readUInt64(); - packet.readUInt32(); + uint64_t casterGuid = packet.readUInt64(); + uint32_t visualId = packet.readUInt32(); + if (visualId == 0) break; + // Resolve caster world position and spawn the effect + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (casterGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(casterGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(visualId, spawnPos); break; } case Opcode::SMSG_SPELLHEALLOG: diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 67a637bb..e04ca9ea 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -2627,6 +2627,180 @@ void Renderer::stopChargeEffect() { } } +// ─── Spell Visual Effects ──────────────────────────────────────────────────── + +void Renderer::loadSpellVisualDbc() { + if (spellVisualDbcLoaded_) return; + spellVisualDbcLoaded_ = true; // Set early to prevent re-entry on failure + + if (!cachedAssetManager) { + cachedAssetManager = core::Application::getInstance().getAssetManager(); + } + if (!cachedAssetManager) return; + + auto* layout = pipeline::getActiveDBCLayout(); + const pipeline::DBCFieldMap* svLayout = layout ? layout->getLayout("SpellVisual") : nullptr; + const pipeline::DBCFieldMap* kitLayout = layout ? layout->getLayout("SpellVisualKit") : nullptr; + const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr; + + uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2; + uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8; + uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11; + uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5; + uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2; + + // Load SpellVisualEffectName.dbc — ID → M2 path + auto fxDbc = cachedAssetManager->loadDBC("SpellVisualEffectName.dbc"); + if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) { + LOG_DEBUG("SpellVisual: SpellVisualEffectName.dbc unavailable (fc=", + fxDbc ? fxDbc->getFieldCount() : 0, ")"); + return; + } + std::unordered_map effectPaths; // effectNameId → path + for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) { + uint32_t id = fxDbc->getUInt32(i, 0); + std::string p = fxDbc->getString(i, fxFilePathField); + if (id && !p.empty()) effectPaths[id] = p; + } + + // Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID + auto kitDbc = cachedAssetManager->loadDBC("SpellVisualKit.dbc"); + std::unordered_map kitToEffectName; // kitId → effectNameId + if (kitDbc && kitDbc->isLoaded()) { + uint32_t fc = kitDbc->getFieldCount(); + for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) { + uint32_t kitId = kitDbc->getUInt32(i, 0); + if (!kitId) continue; + // Prefer SpecialEffect0, fall back to BaseEffect + uint32_t eff = 0; + if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field); + if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField); + if (eff) kitToEffectName[kitId] = eff; + } + } + + // Load SpellVisual.dbc — visualId → M2 path via kit chain + auto svDbc = cachedAssetManager->loadDBC("SpellVisual.dbc"); + if (!svDbc || !svDbc->isLoaded()) { + LOG_DEBUG("SpellVisual: SpellVisual.dbc unavailable"); + return; + } + uint32_t svFc = svDbc->getFieldCount(); + uint32_t loaded = 0; + for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) { + uint32_t vid = svDbc->getUInt32(i, 0); + if (!vid) continue; + + std::string path; + + // Try CastKit → SpellVisualKit → SpecialEffect0 path + if (svCastKitField < svFc) { + uint32_t kitId = svDbc->getUInt32(i, svCastKitField); + if (kitId) { + auto kitIt = kitToEffectName.find(kitId); + if (kitIt != kitToEffectName.end()) { + auto fxIt = effectPaths.find(kitIt->second); + if (fxIt != effectPaths.end()) path = fxIt->second; + } + } + } + // Fallback: MissileModel directly references SpellVisualEffectName + if (path.empty() && svMissileField < svFc) { + uint32_t missileEff = svDbc->getUInt32(i, svMissileField); + if (missileEff) { + auto fxIt = effectPaths.find(missileEff); + if (fxIt != effectPaths.end()) path = fxIt->second; + } + } + + if (!path.empty()) { + spellVisualModelPath_[vid] = path; + ++loaded; + } + } + LOG_INFO("SpellVisual: loaded ", loaded, " visual→M2 mappings (of ", + svDbc->getRecordCount(), " records)"); +} + +void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition) { + if (!m2Renderer || visualId == 0) return; + + if (!cachedAssetManager) + cachedAssetManager = core::Application::getInstance().getAssetManager(); + if (!cachedAssetManager) return; + + if (!spellVisualDbcLoaded_) loadSpellVisualDbc(); + + // Find the M2 path for this visual + auto pathIt = spellVisualModelPath_.find(visualId); + if (pathIt == spellVisualModelPath_.end()) return; // No model for this visual + + const std::string& modelPath = pathIt->second; + + // Get or assign a model ID for this path + auto midIt = spellVisualModelIds_.find(modelPath); + uint32_t modelId = 0; + if (midIt != spellVisualModelIds_.end()) { + modelId = midIt->second; + } else { + if (nextSpellVisualModelId_ >= 999800) { + LOG_WARNING("SpellVisual: model ID pool exhausted"); + return; + } + modelId = nextSpellVisualModelId_++; + spellVisualModelIds_[modelPath] = modelId; + } + + // Load the M2 model if not already loaded + if (!m2Renderer->hasModel(modelId)) { + auto m2Data = cachedAssetManager->readFile(modelPath); + if (m2Data.empty()) { + LOG_DEBUG("SpellVisual: could not read model: ", modelPath); + return; + } + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty() && model.particleEmitters.empty()) { + LOG_DEBUG("SpellVisual: empty model: ", modelPath); + return; + } + // Load skin file for WotLK-format M2s + if (model.version >= 264) { + std::string skinPath = modelPath.substr(0, modelPath.rfind('.')) + "00.skin"; + auto skinData = cachedAssetManager->readFile(skinPath); + if (!skinData.empty()) pipeline::M2Loader::loadSkin(skinData, model); + } + if (!m2Renderer->loadModel(model, modelId)) { + LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath); + return; + } + LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath); + } + + // Spawn instance at world position + uint32_t instanceId = m2Renderer->createInstance(modelId, worldPosition, + glm::vec3(0.0f), 1.0f); + if (instanceId == 0) { + LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId); + return; + } + activeSpellVisuals_.push_back({instanceId, 0.0f}); + LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId, + " model=", modelPath); +} + +void Renderer::updateSpellVisuals(float deltaTime) { + if (activeSpellVisuals_.empty() || !m2Renderer) return; + for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { + it->elapsed += deltaTime; + if (it->elapsed >= SPELL_VISUAL_DURATION) { + m2Renderer->removeInstance(it->instanceId); + it = activeSpellVisuals_.erase(it); + } else { + ++it; + } + } +} + void Renderer::triggerMeleeSwing() { if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown > 0.0f) return; @@ -3012,6 +3186,8 @@ void Renderer::update(float deltaTime) { if (chargeEffect) { chargeEffect->update(deltaTime); } + // Update transient spell visual instances + updateSpellVisuals(deltaTime); // Launch M2 doodad animation on background thread (overlaps with character animation + audio)