diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 459c9046..d95e561a 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -180,6 +180,7 @@ "RangeIndex": 33, "Rank": 129, "SchoolEnum": 1, + "SpellVisualID": 115, "Tooltip": 147 }, "SpellIcon": { @@ -193,7 +194,8 @@ "CastKit": 2, "ID": 0, "ImpactKit": 3, - "MissileModel": 8 + "MissileModel": 8, + "PrecastKit": 1 }, "SpellVisualEffectName": { "FilePath": 2, @@ -201,7 +203,12 @@ }, "SpellVisualKit": { "BaseEffect": 5, + "BreathEffect": 8, + "ChestEffect": 4, + "HeadEffect": 3, "ID": 0, + "LeftHandEffect": 6, + "RightHandEffect": 7, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index e11682cf..7899f634 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -219,6 +219,7 @@ "RangeIndex": 40, "Rank": 136, "SchoolMask": 215, + "SpellVisualID": 122, "Tooltip": 154 }, "SpellIcon": { @@ -236,7 +237,8 @@ "CastKit": 2, "ID": 0, "ImpactKit": 3, - "MissileModel": 8 + "MissileModel": 8, + "PrecastKit": 1 }, "SpellVisualEffectName": { "FilePath": 2, @@ -244,7 +246,12 @@ }, "SpellVisualKit": { "BaseEffect": 5, + "BreathEffect": 8, + "ChestEffect": 4, + "HeadEffect": 3, "ID": 0, + "LeftHandEffect": 6, + "RightHandEffect": 7, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 2f580109..1218449b 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -213,6 +213,7 @@ "RangeIndex": 33, "Rank": 129, "SchoolEnum": 1, + "SpellVisualID": 115, "Tooltip": 147 }, "SpellIcon": { @@ -230,7 +231,8 @@ "CastKit": 2, "ID": 0, "ImpactKit": 3, - "MissileModel": 8 + "MissileModel": 8, + "PrecastKit": 1 }, "SpellVisualEffectName": { "FilePath": 2, @@ -238,7 +240,12 @@ }, "SpellVisualKit": { "BaseEffect": 5, + "BreathEffect": 8, + "ChestEffect": 4, + "HeadEffect": 3, "ID": 0, + "LeftHandEffect": 6, + "RightHandEffect": 7, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index e563d2c9..f070509d 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -235,6 +235,7 @@ "RangeIndex": 49, "Rank": 153, "SchoolMask": 225, + "SpellVisualID": 131, "Tooltip": 139 }, "SpellIcon": { @@ -252,7 +253,8 @@ "CastKit": 2, "ID": 0, "ImpactKit": 3, - "MissileModel": 8 + "MissileModel": 8, + "PrecastKit": 1 }, "SpellVisualEffectName": { "FilePath": 2, @@ -260,7 +262,12 @@ }, "SpellVisualKit": { "BaseEffect": 5, + "BreathEffect": 8, + "ChestEffect": 4, + "HeadEffect": 3, "ID": 0, + "LeftHandEffect": 6, + "RightHandEffect": 7, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13 diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9e6ada12..cac41b7c 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -2493,6 +2493,7 @@ public: uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; int32_t effectBasePoints[3] = {0, 0, 0}; float durationSec = 0.0f; + uint32_t spellVisualId = 0; }; static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128; std::string getAreaName(uint32_t areaId) const; diff --git a/include/game/spell_handler.hpp b/include/game/spell_handler.hpp index 6b1100ff..2d5c30ff 100644 --- a/include/game/spell_handler.hpp +++ b/include/game/spell_handler.hpp @@ -6,6 +6,7 @@ #include "game/handler_types.hpp" #include "audio/spell_sound_manager.hpp" #include "network/packet.hpp" +#include #include #include #include @@ -283,6 +284,15 @@ private: void playSpellCastSound(uint32_t spellId); void playSpellImpactSound(uint32_t spellId); + // Resolve SpellVisualID from Spell.dbc cache for a given spellId. + uint32_t resolveSpellVisualId(uint32_t spellId); + // Resolve render-space position for a unit GUID (player or entity). + bool resolveUnitPosition(uint64_t guid, glm::vec3& outPos); + // Play the cast/precast visual effect at the caster's position. + void triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint32_t castTimeMs = 0); + // Play the impact visual effect at the target's position. + void triggerImpactVisual(uint32_t spellId, uint64_t targetGuid); + // --- handleSpellLogExecute per-effect parsers (extracted to reduce nesting) --- void parseEffectPowerDrain(network::Packet& packet, uint32_t effectLogCount, uint64_t caster, uint32_t spellId, bool isPlayerCaster, diff --git a/include/rendering/m2_model_classifier.hpp b/include/rendering/m2_model_classifier.hpp index 8ef09aab..6d6faeb5 100644 --- a/include/rendering/m2_model_classifier.hpp +++ b/include/rendering/m2_model_classifier.hpp @@ -7,6 +7,17 @@ namespace wowee { namespace rendering { +/// Ambient sound emitter type for doodad models (fire, water, etc.). +enum class AmbientEmitterType : uint8_t { + None = 0, + FireplaceSmall = 1, ///< Small fire / campfire + FireplaceLarge = 2, ///< Large brazier / bonfire + Torch = 3, ///< Wall torch / standing torch + Fountain = 4, ///< Fountain water loop + Waterfall = 5, ///< Waterfall ambient + Forge = 6, ///< Forge / anvil fire +}; + /** * Output of classifyM2Model(): all name/geometry-based flags for an M2 model. * Pure data — no Vulkan, GPU, or asset-manager dependencies. @@ -25,6 +36,7 @@ struct M2ClassificationResult { // --- Rendering / effect classification --- bool isFoliageLike = false; ///< Foliage or tree (wind sway, disabled animation) + bool isSmallFoliage = false; ///< Small bush/grass/plant (skip during taxi/flight) bool isSpellEffect = false; ///< Spell effect / particle-dominated visual bool isLavaModel = false; ///< Lava surface (UV scroll animation) bool isInstancePortal = false; ///< Instance portal (additive, spin, no collision) @@ -36,6 +48,12 @@ struct M2ClassificationResult { bool isGroundDetail = false; ///< Ground-clutter detail doodad (always non-blocking) bool isInvisibleTrap = false; ///< Event-object invisible trap (no render, no collision) bool isSmoke = false; ///< Smoke model (UV scroll animation) + bool isWaterfall = false; ///< Waterfall model (ambient sound + splash particles) + bool isBrazierOrFire = false; ///< Brazier / campfire / bonfire model + bool isTorch = false; ///< Wall-mounted or standing torch + + // --- Ambient emitter type (for sound system) --- + AmbientEmitterType ambientEmitterType = AmbientEmitterType::None; // --- Animation flags --- bool disableAnimation = false; ///< Keep visually stable (foliage, chest lids, etc.) @@ -89,5 +107,18 @@ struct M2BatchTexClassification { */ M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey); +// --------------------------------------------------------------------------- +// Lightweight ambient emitter classification (name-only, no geometry needed) +// --------------------------------------------------------------------------- + +/** + * Classify an M2 model path for ambient sound emitter type. + * Faster than the full classifyM2Model() when only the emitter type is needed. + * + * @param lowerName Lowercased model path/name + * @return AmbientEmitterType::None if the model is not an ambient emitter source + */ +AmbientEmitterType classifyAmbientEmitter(const std::string& lowerName); + } // namespace rendering } // namespace wowee diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 5db00014..c788886f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -2,6 +2,7 @@ #include "pipeline/m2_loader.hpp" #include "pipeline/blp_loader.hpp" +#include "rendering/m2_model_classifier.hpp" #include #include #include @@ -78,11 +79,15 @@ struct M2ModelGPU { bool collisionTreeTrunk = false; bool collisionNoBlock = false; bool collisionStatue = false; - bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi - bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision) - bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path) + bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi + bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision) + bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path) bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning) bool isFireflyEffect = false; // Firefly/fireflies M2 (exempt from particle dampeners) + bool isWaterfall = false; // Waterfall model (ambient sound + splash particles) + bool isBrazierOrFire = false; // Brazier / campfire / bonfire model + bool isTorch = false; // Wall-mounted or standing torch + AmbientEmitterType ambientEmitterType = AmbientEmitterType::None; // Collision mesh with spatial grid (from M2 bounding geometry) struct CollisionMesh { @@ -282,6 +287,8 @@ public: bool hasModel(uint32_t modelId) const; bool loadModel(const pipeline::M2Model& model, uint32_t modelId); + /** Mark a loaded model as a spell effect (full-brightness particles, no collision). */ + void markModelAsSpellEffect(uint32_t modelId); uint32_t createInstance(uint32_t modelId, const glm::vec3& position, const glm::vec3& rotation = glm::vec3(0.0f), diff --git a/include/rendering/spell_visual_system.hpp b/include/rendering/spell_visual_system.hpp index 38d75c0d..631de56f 100644 --- a/include/rendering/spell_visual_system.hpp +++ b/include/rendering/spell_visual_system.hpp @@ -12,14 +12,16 @@ namespace pipeline { class AssetManager; } namespace rendering { class M2Renderer; +class Renderer; +class CharacterRenderer; class SpellVisualSystem { public: SpellVisualSystem() = default; ~SpellVisualSystem() = default; - // Initialize with references to the M2 renderer (for model loading/instance spawning) - void initialize(M2Renderer* m2Renderer); + // Initialize with references to the M2 renderer and parent renderer + void initialize(M2Renderer* m2Renderer, Renderer* renderer); void shutdown(); // Spawn a spell visual at a world position. @@ -27,9 +29,17 @@ public: void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, bool useImpactKit = false); + // Spawn a precast visual effect at a world position. + // castTimeMs: server cast time in milliseconds (0 = use anim duration). + void playSpellVisualPrecast(uint32_t visualId, const glm::vec3& worldPosition, + uint32_t castTimeMs = 0); + // Advance lifetime timers and remove expired instances. void update(float deltaTime); + // Remove all active precast visual instances (cast canceled/interrupted). + void cancelAllPrecastVisuals(); + // Remove all active spell visual instances and reset caches. // Called on map change / combat reset. void reset(); @@ -40,14 +50,18 @@ private: uint32_t instanceId; float elapsed; float duration; // per-instance lifetime in seconds (from M2 anim or default) + bool isPrecast; // true for precast effects (removed on cancel/interrupt) + uint32_t attachmentId; // character attachment point to track (0=none/static) }; void loadSpellVisualDbc(); M2Renderer* m2Renderer_ = nullptr; + Renderer* renderer_ = nullptr; pipeline::AssetManager* cachedAssetManager_ = nullptr; std::vector activeSpellVisuals_; + std::unordered_map spellVisualPrecastPath_; // visualId → precast M2 path std::unordered_map spellVisualCastPath_; // visualId → cast M2 path std::unordered_map spellVisualImpactPath_; // visualId → impact M2 path std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId @@ -56,6 +70,12 @@ private: bool spellVisualDbcLoaded_ = false; static constexpr float SPELL_VISUAL_MAX_DURATION = 5.0f; static constexpr float SPELL_VISUAL_DEFAULT_DURATION = 2.0f; + + // Determine character attachment point from model path keywords + static uint32_t classifyAttachmentId(const std::string& modelPath); + + // Apply height offset based on model path keywords (Hand → hands, Chest → chest, Base → ground) + static glm::vec3 applyEffectHeightOffset(const glm::vec3& basePos, const std::string& modelPath); }; } // namespace rendering diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index 7ea03e0e..4ce04d9f 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -89,6 +89,56 @@ void SpellHandler::playSpellImpactSound(uint32_t spellId) { audio::SpellSoundManager::SpellPower::MEDIUM); } +// ---- Spell visual effect helpers ---- + +uint32_t SpellHandler::resolveSpellVisualId(uint32_t spellId) { + owner_.loadSpellNameCache(); + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.spellVisualId : 0; +} + +bool SpellHandler::resolveUnitPosition(uint64_t guid, glm::vec3& outPos) { + auto* renderer = owner_.services().renderer; + if (!renderer) return false; + if (guid == owner_.getPlayerGuid()) { + outPos = renderer->getCharacterPosition(); + return true; + } + auto entity = owner_.getEntityManager().getEntity(guid); + if (!entity) return false; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + outPos = core::coords::canonicalToRender(canonical); + return true; +} + +void SpellHandler::triggerCastVisual(uint32_t spellId, uint64_t casterGuid, uint32_t castTimeMs) { + LOG_INFO("SpellVisual: triggerCastVisual spellId=", spellId, " casterGuid=0x", std::hex, casterGuid, std::dec); + auto* renderer = owner_.services().renderer; + if (!renderer) { LOG_WARNING("SpellVisual: triggerCastVisual — no renderer"); return; } + auto* svs = renderer->getSpellVisualSystem(); + if (!svs) { LOG_WARNING("SpellVisual: triggerCastVisual — no SpellVisualSystem"); return; } + uint32_t visualId = resolveSpellVisualId(spellId); + if (visualId == 0) { LOG_WARNING("SpellVisual: triggerCastVisual — visualId=0 for spellId=", spellId); return; } + glm::vec3 casterPos; + if (!resolveUnitPosition(casterGuid, casterPos)) { LOG_WARNING("SpellVisual: triggerCastVisual — cannot resolve caster position"); return; } + LOG_INFO("SpellVisual: triggerCastVisual visualId=", visualId, " pos=(", casterPos.x, ",", casterPos.y, ",", casterPos.z, ") castTimeMs=", castTimeMs); + svs->playSpellVisualPrecast(visualId, casterPos, castTimeMs); +} + +void SpellHandler::triggerImpactVisual(uint32_t spellId, uint64_t targetGuid) { + LOG_INFO("SpellVisual: triggerImpactVisual spellId=", spellId, " targetGuid=0x", std::hex, targetGuid, std::dec); + auto* renderer = owner_.services().renderer; + if (!renderer) return; + auto* svs = renderer->getSpellVisualSystem(); + if (!svs) return; + uint32_t visualId = resolveSpellVisualId(spellId); + if (visualId == 0) { LOG_WARNING("SpellVisual: triggerImpactVisual — visualId=0 for spellId=", spellId); return; } + glm::vec3 targetPos; + if (!resolveUnitPosition(targetGuid, targetPos)) return; + LOG_INFO("SpellVisual: triggerImpactVisual visualId=", visualId, " pos=(", targetPos.x, ",", targetPos.y, ",", targetPos.z, ")"); + svs->playSpellVisual(visualId, targetPos, /*useImpactKit=*/true); +} + static std::string displaySpellName(GameHandler& handler, uint32_t spellId) { if (spellId == 0) return {}; @@ -387,6 +437,11 @@ void SpellHandler::cancelCast() { queuedSpellTarget_ = 0; if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("UNIT_SPELLCAST_STOP", {"player"}); + // Remove lingering precast visual effects + if (auto* renderer = owner_.services().renderer) { + if (auto* svs = renderer->getSpellVisualSystem()) + svs->cancelAllPrecastVisuals(); + } } void SpellHandler::startCraftQueue(uint32_t spellId, int count) { @@ -828,6 +883,11 @@ void SpellHandler::handleCastFailed(network::Packet& packet) { owner_.pendingGameObjectInteractGuidRef() = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; + // Remove lingering precast visual effects + if (auto* renderer = owner_.services().renderer) { + if (auto* svs = renderer->getSpellVisualSystem()) + svs->cancelAllPrecastVisuals(); + } queuedSpellId_ = 0; queuedSpellTarget_ = 0; @@ -948,6 +1008,12 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { if (!unitId.empty()) owner_.addonEventCallbackRef()("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)}); } + + // Trigger cast visual effect (precast/cast kit M2) at the caster's position. + // Skip profession spells (crafting has no flashy cast effects). + if (!owner_.isProfessionSpell(data.spellId)) { + triggerCastVisual(data.spellId, data.casterUnit, data.castTime); + } } void SpellHandler::handleSpellGo(network::Packet& packet) { @@ -1094,6 +1160,29 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { if (playerIsHit || playerHitEnemy) playSpellImpactSound(data.spellId); + + // Trigger spell visual effects: cast kit at caster + impact kit at each hit target. + // Skip profession spells and melee (schoolMask == 1) abilities. + if (!owner_.isProfessionSpell(data.spellId)) { + uint32_t visualId = resolveSpellVisualId(data.spellId); + if (visualId != 0) { + // Cast-complete visual at caster (for instant spells that skip SPELL_START) + glm::vec3 casterPos; + if (resolveUnitPosition(data.casterUnit, casterPos)) { + if (auto* renderer = owner_.services().renderer) { + if (auto* svs = renderer->getSpellVisualSystem()) { + svs->playSpellVisual(visualId, casterPos, /*useImpactKit=*/false); + } + } + } + // Impact visual at each hit target + for (const auto& tgt : data.hitTargets) { + if (tgt != 0) { + triggerImpactVisual(data.spellId, tgt); + } + } + } + } } void SpellHandler::handleSpellCooldown(network::Packet& packet) { @@ -1798,6 +1887,7 @@ void SpellHandler::loadSpellNameCache() const { const uint32_t ebp1Field = spellL ? spellL->field("EffectBasePoints1") : 0xFFFFFFFF; const uint32_t ebp2Field = spellL ? spellL->field("EffectBasePoints2") : 0xFFFFFFFF; const uint32_t durIdxField = spellL ? spellL->field("DurationIndex") : 0xFFFFFFFF; + const uint32_t spellVisualIdField = spellL ? spellL->field("SpellVisualID") : 0xFFFFFFFF; uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { @@ -1806,7 +1896,7 @@ void SpellHandler::loadSpellNameCache() const { std::string name = dbc->getString(i, nameField); std::string rank = dbc->getString(i, rankField); if (!name.empty()) { - GameHandler::SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0}; + GameHandler::SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0, {0, 0, 0}, 0.0f, 0}; if (tooltipField != 0xFFFFFFFF) { entry.description = dbc->getString(i, tooltipField); } @@ -1830,6 +1920,9 @@ void SpellHandler::loadSpellNameCache() const { // Duration: read DurationIndex and resolve via SpellDuration.dbc later if (durIdxField != 0xFFFFFFFF) entry.durationSec = static_cast(dbc->getUInt32(i, durIdxField)); // store index temporarily + // SpellVisualID: references SpellVisual.dbc for cast/impact M2 effects + if (spellVisualIdField != 0xFFFFFFFF && spellVisualIdField < dbc->getFieldCount()) + entry.spellVisualId = dbc->getUInt32(i, spellVisualIdField); owner_.spellNameCacheRef()[id] = std::move(entry); } } @@ -2417,6 +2510,11 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) { craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; + // Remove lingering precast visual effects + if (auto* renderer = owner_.services().renderer) { + if (auto* svs = renderer->getSpellVisualSystem()) + svs->cancelAllPrecastVisuals(); + } if (auto* ac = owner_.services().audioCoordinator) { if (auto* ssm = ac->getSpellSoundManager()) { ssm->stopPrecast(); diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 08014f75..041ef3e5 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1378,10 +1378,43 @@ M2Model M2Loader::load(const std::vector& m2Data) { if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f; if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f; - // visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1) + // visibilityTrack M2TrackDisk at 0x98 — keys are uint8 (0/1), NOT float. + // Must read as uint8 and convert to float, else 0x01 reads as + // float ~1.4e-45 which fails the visibility > 0.5 check. if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) { M2TrackDisk disk = readValue(m2Data, base + 0x98); - parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags); + auto& track = rib.visibilityTrack; + track.interpolationType = disk.interpolationType; + track.globalSequence = disk.globalSequence; + uint32_t nSeqs = disk.nTimestamps; + if (nSeqs > 0 && nSeqs <= 4096) { + track.sequences.resize(nSeqs); + for (uint32_t s = 0; s < nSeqs; s++) { + if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & kM2SeqFlagEmbeddedData)) continue; + uint32_t tsHdr = disk.ofsTimestamps + s * 8; + uint32_t keyHdr = disk.ofsKeys + s * 8; + if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue; + uint32_t tsCount = readValue(m2Data, tsHdr); + uint32_t tsOfs = readValue(m2Data, tsHdr + 4); + uint32_t kCount = readValue(m2Data, keyHdr); + uint32_t kOfs = readValue(m2Data, keyHdr + 4); + if (tsCount == 0 || kCount == 0) continue; + if (tsOfs + tsCount * 4 > m2Data.size()) continue; + if (kOfs + kCount * sizeof(uint8_t) > m2Data.size()) continue; + track.sequences[s].timestamps = readArray(m2Data, tsOfs, tsCount); + track.sequences[s].floatValues.reserve(kCount); + for (uint32_t k = 0; k < kCount; k++) { + uint8_t raw = readValue(m2Data, kOfs + k); + track.sequences[s].floatValues.push_back(raw != 0 ? 1.0f : 0.0f); + } + } + } + } + + // Skip garbage emitters (common M2 artifact: alternating emitters + // have bone=UINT_MAX or other invalid state) + if (rib.bone == 0xFFFFFFFF) { + continue; } model.ribbonEmitters.push_back(std::move(rib)); diff --git a/src/rendering/m2_model_classifier.cpp b/src/rendering/m2_model_classifier.cpp index 424bfc42..c750c689 100644 --- a/src/rendering/m2_model_classifier.cpp +++ b/src/rendering/m2_model_classifier.cpp @@ -56,19 +56,31 @@ M2ClassificationResult classifyM2Model( r.isInvisibleTrap = has(n, "invisibletrap"); r.isGroundDetail = has(n, "\\nodxt\\detail\\") || has(n, "\\detail\\"); r.isSmoke = has(n, "smoke"); - r.isLavaModel = has(n, "forgelava") || has(n, "lavapot") || has(n, "lavaflow"); + r.isLavaModel = has(n, "forgelava") || has(n, "lavapot") || has(n, "lavaflow") + || has(n, "lavapool"); r.isInstancePortal = has(n, "instanceportal") || has(n, "instancenewportal") || has(n, "portalfx") || has(n, "spellportal"); r.isWaterVegetation = has(n, "cattail") || has(n, "reed") || has(n, "bulrush") - || has(n, "seaweed") || has(n, "kelp") || has(n, "lilypad"); + || has(n, "seaweed") || has(n, "kelp") || has(n, "lilypad") + || has(n, "waterlily"); + + r.isWaterfall = has(n, "waterfall"); r.isElvenLike = has(n, "elf") || has(n, "elven") || has(n, "quel"); r.isLanternLike = has(n, "lantern") || has(n, "lamp") || has(n, "light"); r.isKoboldFlame = has(n, "kobold") && (has(n, "candle") || has(n, "torch") || has(n, "mine")); + // Fire / brazier / torch model detection (for ambient emitter + rendering) + const bool fireName = has(n, "fire") || has(n, "campfire") || has(n, "bonfire"); + const bool brazierName = has(n, "brazier") || has(n, "cauldronfire"); + const bool forgeName = has(n, "forge") && !has(n, "forgelava"); + const bool torchName = has(n, "torch") && !r.isKoboldFlame; + r.isBrazierOrFire = fireName || brazierName; + r.isTorch = torchName; + // --------------------------------------------------------------- // Collision: shape categories (mirrors original logic ordering) // --------------------------------------------------------------- @@ -83,7 +95,11 @@ M2ClassificationResult classifyM2Model( || has(n, "seat") || has(n, "throne"); const bool smallSolid = (statueName && !sittable) || has(n, "crate") || has(n, "box") - || has(n, "chest") || has(n, "barrel"); + || has(n, "chest") || has(n, "barrel") + || has(n, "anvil") || has(n, "mailbox") + || has(n, "cauldron") || has(n, "cannon") + || has(n, "wagon") || has(n, "cart") + || has(n, "table") || has(n, "desk"); const bool chestName = has(n, "chest"); r.collisionSteppedFountain = has(n, "fountain"); @@ -106,17 +122,22 @@ M2ClassificationResult classifyM2Model( // Foliage token table (sorted alphabetically) // --------------------------------------------------------------- static constexpr auto kFoliageTokens = std::to_array({ - "algae", "bamboo", "banana", "branch", "bush", - "cactus", "canopy", "cattail", "coconut", "coral", - "corn", "crop", "dead-grass", "dead_grass", "deadgrass", + "algae", "bamboo", "banana", "barley", "bracken", + "branch", "briars", "brush", "bush", + "cactus", "canopy", "cattail", "clover", "coconut", + "coral", "corn", "crop", + "dead-grass", "dead_grass", "deadgrass", "dry-grass", "dry_grass", "drygrass", - "fern", "fireflies", "firefly", "fireflys", - "flower", "frond", "fungus", "gourd", "grass", - "hay", "hedge", "ivy", "kelp", "leaf", - "leaves", "lily", "melon", "moss", "mushroom", - "palm", "pumpkin", "reed", "root", "seaweed", - "shrub", "squash", "stalk", "thorn", "toadstool", - "vine", "watermelon", "weed", "wheat", + "fern", "fernleaf", "fireflies", "firefly", "fireflys", + "flower", "frond", "fungus", "gourd", "grapes", + "grass", + "hay", "hedge", "hops", "ivy", + "kelp", "leaf", "leaves", "lichen", "lily", + "melon", "moss", "mushroom", "nettle", + "palm", "pinecone", "pumpkin", "reed", "root", + "sapling", "seaweed", "seedling", "shrub", "squash", + "stalk", "thorn", "thistle", "toadstool", + "underbrush", "vine", "watermelon", "weed", "wheat", }); // "plant" is foliage unless "planter" is also present (planters are solid curbs). @@ -173,20 +194,44 @@ M2ClassificationResult classifyM2Model( r.shadowWindFoliage = r.isFoliageLike; r.isFireflyEffect = ambientCreature; + // Small foliage: foliage-like models with a small bounding box. + // Used to skip rendering during taxi/flight for performance. + r.isSmallFoliage = r.isFoliageLike && !treeLike + && horiz < 3.0f && vert < 2.0f; + // --------------------------------------------------------------- // Spell effects (named tokens + particle-dominated geometry heuristic) // --------------------------------------------------------------- static constexpr auto kEffectTokens = std::to_array({ - "bubbles", "hazardlight", "instancenewportal", "instanceportal", + "bubbles", "dustcloud", "hazardlight", + "instancenewportal", "instanceportal", "lavabubble", "lavasplash", "lavasteam", "levelup", "lightshaft", "mageportal", "particleemitter", - "spotlight", "volumetriclight", "wisps", "worldtreeportal", + "smokepuff", "sparkle", "spotlight", + "steam", "volumetriclight", "wisps", "worldtreeportal", }); r.isSpellEffect = hasAny(n, kEffectTokens) || (emitterCount >= 3 && vertexCount <= 200); // Instance portals are spell effects too. if (r.isInstancePortal) r.isSpellEffect = true; + // --------------------------------------------------------------- + // Ambient emitter type (for sound system integration) + // --------------------------------------------------------------- + if (r.isBrazierOrFire) { + const bool isSmallFire = has(n, "small") || has(n, "campfire"); + r.ambientEmitterType = isSmallFire ? AmbientEmitterType::FireplaceSmall + : AmbientEmitterType::FireplaceLarge; + } else if (r.isTorch) { + r.ambientEmitterType = AmbientEmitterType::Torch; + } else if (forgeName) { + r.ambientEmitterType = AmbientEmitterType::Forge; + } else if (r.collisionSteppedFountain) { + r.ambientEmitterType = AmbientEmitterType::Fountain; + } else if (r.isWaterfall) { + r.ambientEmitterType = AmbientEmitterType::Waterfall; + } + return r; } @@ -244,5 +289,28 @@ M2BatchTexClassification classifyBatchTexture(const std::string& lowerTexKey) return r; } +// --------------------------------------------------------------------------- +// classifyAmbientEmitter — lightweight name-only emitter type detection +// --------------------------------------------------------------------------- + +AmbientEmitterType classifyAmbientEmitter(const std::string& lowerName) +{ + const bool fireName = has(lowerName, "fire") || has(lowerName, "campfire") + || has(lowerName, "bonfire"); + const bool brazierName = has(lowerName, "brazier") || has(lowerName, "cauldronfire"); + const bool forgeName = has(lowerName, "forge") && !has(lowerName, "forgelava"); + + if (fireName || brazierName) { + const bool isSmall = has(lowerName, "small") || has(lowerName, "campfire"); + return isSmall ? AmbientEmitterType::FireplaceSmall + : AmbientEmitterType::FireplaceLarge; + } + if (has(lowerName, "torch")) return AmbientEmitterType::Torch; + if (forgeName) return AmbientEmitterType::Forge; + if (has(lowerName, "fountain")) return AmbientEmitterType::Fountain; + if (has(lowerName, "waterfall")) return AmbientEmitterType::Waterfall; + return AmbientEmitterType::None; +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 5281aad6..6cc695e5 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1123,6 +1123,20 @@ bool M2Renderer::hasModel(uint32_t modelId) const { return models.find(modelId) != models.end(); } +void M2Renderer::markModelAsSpellEffect(uint32_t modelId) { + auto it = models.find(modelId); + if (it != models.end()) { + it->second.isSpellEffect = true; + // Spell effects MUST have bone animation for ribbons/particles to work. + // The classifier may have set disableAnimation=true based on name tokens + // (e.g. "chest" in HolySmite_Low_Chest.m2) — override that for spell effects. + if (it->second.disableAnimation && it->second.hasAnimation) { + it->second.disableAnimation = false; + LOG_INFO("SpellEffect: re-enabled animation for '", it->second.name, "'"); + } + } +} + bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { if (models.find(modelId) != models.end()) { // Already loaded @@ -1186,6 +1200,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { gpuModel.disableAnimation = cls.disableAnimation; gpuModel.shadowWindFoliage = cls.shadowWindFoliage; gpuModel.isFireflyEffect = cls.isFireflyEffect; + gpuModel.isSmallFoliage = cls.isSmallFoliage; gpuModel.isSmoke = cls.isSmoke; gpuModel.isSpellEffect = cls.isSpellEffect; gpuModel.isLavaModel = cls.isLavaModel; @@ -1194,6 +1209,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { gpuModel.isElvenLike = cls.isElvenLike; gpuModel.isLanternLike = cls.isLanternLike; gpuModel.isKoboldFlame = cls.isKoboldFlame; + gpuModel.isWaterfall = cls.isWaterfall; + gpuModel.isBrazierOrFire = cls.isBrazierOrFire; + gpuModel.isTorch = cls.isTorch; + gpuModel.ambientEmitterType = cls.ambientEmitterType; gpuModel.boundMin = tightMin; gpuModel.boundMax = tightMax; gpuModel.boundRadius = model.boundRadius; @@ -1402,17 +1421,25 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get()); gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE); for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) { - // Resolve texture via textureLookup table - uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex; - uint32_t texIdx = (texLookupIdx < model.textureLookup.size()) - ? model.textureLookup[texLookupIdx] : UINT32_MAX; - if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { - gpuModel.ribbonTextures[ri] = allTextures[texIdx]; + // Resolve texture: ribbon textureIndex is a direct index into the + // model's texture array (NOT through the textureLookup table). + uint16_t texDirect = model.ribbonEmitters[ri].textureIndex; + if (texDirect < allTextures.size() && allTextures[texDirect] != nullptr) { + gpuModel.ribbonTextures[ri] = allTextures[texDirect]; } else { - LOG_WARNING("M2 '", model.name, "' ribbon emitter[", ri, - "] texLookup=", texLookupIdx, " resolved texIdx=", texIdx, - " out of range (", allTextures.size(), - " textures) — using white fallback"); + // Fallback: try through textureLookup table + uint32_t texIdx = (texDirect < model.textureLookup.size()) + ? model.textureLookup[texDirect] : UINT32_MAX; + if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { + gpuModel.ribbonTextures[ri] = allTextures[texIdx]; + } else { + LOG_WARNING("M2 '", model.name, "' ribbon emitter[", ri, + "] texIndex=", texDirect, " lookup failed" + " (direct=", (texDirect < allTextures.size() ? "yes" : "OOB"), + " lookup=", texIdx, + " textures=", allTextures.size(), + ") — using white fallback"); + } } // Allocate descriptor set (reuse particleTexLayout_ = single sampler) if (particleTexLayout_ && materialDescPool_) { diff --git a/src/rendering/m2_renderer_particles.cpp b/src/rendering/m2_renderer_particles.cpp index 65c71424..3cfc282b 100644 --- a/src/rendering/m2_renderer_particles.cpp +++ b/src/rendering/m2_renderer_particles.cpp @@ -189,6 +189,15 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt } inst.particles.push_back(p); + + // Diagnostic: log first particle birth per spell effect instance + if (gpu.isSpellEffect && inst.particles.size() == 1) { + LOG_INFO("SpellEffect: first particle for '", gpu.name, + "' pos=(", p.position.x, ",", p.position.y, ",", p.position.z, + ") rate=", rate, " life=", life, + " bone=", em.bone, " boneCount=", inst.boneMatrices.size(), + " globalSeqs=", gpu.globalSequenceDurations.size()); + } } // Cap accumulator to avoid bursts after lag if (inst.emitterAccumulators[ei] > 2.0f) { @@ -258,14 +267,24 @@ void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt // Determine bone world position for spine glm::vec3 spineWorld = inst.position; - if (em.bone < inst.boneMatrices.size()) { + // Use referenced bone; fall back to bone 0 if out of range (common for spell effects + // where ribbon bone fields may be unset/garbage, e.g. bone=4294967295) + uint32_t boneIdx = em.bone; + if (boneIdx >= inst.boneMatrices.size() && !inst.boneMatrices.empty()) { + boneIdx = 0; + } + if (boneIdx < inst.boneMatrices.size()) { glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); - spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); + spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[boneIdx] * local); } else { glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); spineWorld = glm::vec3(inst.modelMatrix * local); } + // Skip emitters that produce NaN positions (garbage bone/position data) + if (std::isnan(spineWorld.x) || std::isnan(spineWorld.y) || std::isnan(spineWorld.z)) + continue; + // Evaluate animated tracks (use first available sequence key, or fallback value) auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { for (const auto& seq : track.sequences) { @@ -311,6 +330,16 @@ void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt e.heightBelow = heightBelow; e.age = 0.0f; edges.push_back(e); + + // Diagnostic: log first ribbon edge per spell effect instance+emitter + if (gpu.isSpellEffect && edges.size() == 1) { + LOG_INFO("SpellEffect: ribbon edge[0] for '", gpu.name, + "' emitter=", ri, " pos=(", spineWorld.x, ",", spineWorld.y, + ",", spineWorld.z, ") hA=", heightAbove, " hB=", heightBelow, + " vis=", visibility, " eps=", em.edgesPerSecond, + " edgeLife=", em.edgeLifetime, " bone=", em.bone); + } + // Cap trail length if (edges.size() > 128) edges.pop_front(); } @@ -359,7 +388,17 @@ void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSe // Descriptor set for texture VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; - if (!texSet) continue; + if (!texSet) { + if (gpu.isSpellEffect) { + static bool ribbonTexWarn = false; + if (!ribbonTexWarn) { + LOG_WARNING("SpellEffect: ribbon[", ri, "] for '", gpu.name, + "' has null texSet — descriptor pool may be exhausted"); + ribbonTexWarn = true; + } + } + continue; + } uint32_t firstVert = static_cast(written); @@ -409,6 +448,29 @@ void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSe } } + // Periodic diagnostic: spell ribbon draw count + { + static uint32_t ribbonDiagFrame_ = 0; + if (++ribbonDiagFrame_ % 300 == 1) { + size_t spellRibbonDraws = 0; + size_t spellRibbonVerts = 0; + for (const auto& inst : instances) { + if (!inst.cachedModel || !inst.cachedModel->isSpellEffect) continue; + for (size_t ri = 0; ri < inst.ribbonEdges.size(); ri++) { + if (inst.ribbonEdges[ri].size() >= 2) { + spellRibbonDraws++; + spellRibbonVerts += inst.ribbonEdges[ri].size() * 2; + } + } + } + if (spellRibbonDraws > 0 || !draws.empty()) { + LOG_INFO("SpellEffect: ", spellRibbonDraws, " spell ribbon strips (", + spellRibbonVerts, " verts), total draws=", draws.size(), + " written=", written); + } + } + } + if (draws.empty() || written == 0) return; VkExtent2D ext = vkCtx_->getSwapchainExtent(); @@ -471,7 +533,13 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame if (rawScale > 2.0f) alpha *= 0.02f; if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; } - float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f); + // Spell effect particles: mild boost so tiny M2 scales stay visible + float scale = rawScale; + if (gpu.isSpellEffect) { + scale = std::max(rawScale * 1.5f, 0.15f); + } else if (!gpu.isFireflyEffect) { + scale = std::min(rawScale, 1.5f); + } VkTexture* tex = whiteTexture_.get(); if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { @@ -517,6 +585,22 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame } } + // Periodic diagnostic: spell effect particle count + { + static uint32_t spellParticleDiagFrame_ = 0; + if (++spellParticleDiagFrame_ % 300 == 1) { + size_t spellPtc = 0; + for (const auto& inst : instances) { + if (inst.cachedModel && inst.cachedModel->isSpellEffect) + spellPtc += inst.particles.size(); + } + if (spellPtc > 0) { + LOG_INFO("SpellEffect: rendering ", spellPtc, " spell particles (", + totalParticles, " total)"); + } + } + } + if (totalParticles == 0) return; // Bind per-frame set (set 0) for particle pipeline diff --git a/src/rendering/m2_renderer_render.cpp b/src/rendering/m2_renderer_render.cpp index d013fb50..4910e182 100644 --- a/src/rendering/m2_renderer_render.cpp +++ b/src/rendering/m2_renderer_render.cpp @@ -46,7 +46,8 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, // Deduplicate: skip if same model already at nearly the same position. // Uses hash map for O(1) lookup instead of O(N) scan. - if (!mdlRef.isGroundDetail) { + // Spell effects are exempt — transient visuals must always create fresh instances. + if (!mdlRef.isGroundDetail && !mdlRef.isSpellEffect) { DedupKey dk{modelId, static_cast(std::round(position.x * 10.0f)), static_cast(std::round(position.y * 10.0f)), @@ -111,7 +112,8 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, } // Register in dedup map before pushing (uses original position, not ground-adjusted) - if (!mdlRef.isGroundDetail) { + // Spell effects are exempt from dedup tracking (transient, overlapping allowed). + if (!mdlRef.isGroundDetail && !mdlRef.isSpellEffect) { DedupKey dk{modelId, static_cast(std::round(position.x * 10.0f)), static_cast(std::round(position.y * 10.0f)), diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 472e5cf9..8749a94e 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -1936,7 +1936,7 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s // Initialize SpellVisualSystem once M2Renderer is available (§4.4) if (!spellVisualSystem_) { spellVisualSystem_ = std::make_unique(); - spellVisualSystem_->initialize(m2Renderer.get()); + spellVisualSystem_->initialize(m2Renderer.get(), this); } } diff --git a/src/rendering/spell_visual_system.cpp b/src/rendering/spell_visual_system.cpp index a60a3870..867919dc 100644 --- a/src/rendering/spell_visual_system.cpp +++ b/src/rendering/spell_visual_system.cpp @@ -1,5 +1,7 @@ #include "rendering/spell_visual_system.hpp" #include "rendering/m2_renderer.hpp" +#include "rendering/renderer.hpp" +#include "rendering/character_renderer.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "pipeline/dbc_layout.hpp" @@ -11,13 +13,15 @@ namespace wowee { namespace rendering { -void SpellVisualSystem::initialize(M2Renderer* m2Renderer) { +void SpellVisualSystem::initialize(M2Renderer* m2Renderer, Renderer* renderer) { m2Renderer_ = m2Renderer; + renderer_ = renderer; } void SpellVisualSystem::shutdown() { reset(); m2Renderer_ = nullptr; + renderer_ = nullptr; cachedAssetManager_ = nullptr; } @@ -38,13 +42,26 @@ void SpellVisualSystem::loadSpellVisualDbc() { const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr; uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2; + uint32_t svPrecastKitField = svLayout ? (*svLayout)["PrecastKit"] : 1; uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3; 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; - // Helper to look up effectName path from a kit ID + // Kit effect fields to probe, in priority order. + // SpecialEffect0 > BaseEffect > LeftHand > RightHand > Chest > Head > Breath + struct KitField { const char* name; uint32_t fallback; }; + static constexpr KitField kitFieldDefs[] = { + {"SpecialEffect0", 11}, {"BaseEffect", 5}, + {"LeftHandEffect", 6}, {"RightHandEffect", 7}, + {"ChestEffect", 4}, {"HeadEffect", 3}, + {"BreathEffect", 8}, {"SpecialEffect1", 12}, + {"SpecialEffect2", 13}, + }; + constexpr size_t numKitFields = sizeof(kitFieldDefs) / sizeof(kitFieldDefs[0]); + uint32_t kitFields[numKitFields]; + for (size_t k = 0; k < numKitFields; ++k) + kitFields[k] = kitLayout ? kitLayout->field(kitFieldDefs[k].name) : kitFieldDefs[k].fallback; + // Load SpellVisualEffectName.dbc — ID → M2 path auto fxDbc = cachedAssetManager_->loadDBC("SpellVisualEffectName.dbc"); if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) { @@ -56,10 +73,22 @@ void SpellVisualSystem::loadSpellVisualDbc() { 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; + if (id && !p.empty()) { + // DBC stores old-format extensions (.mdx, .mdl) but extracted assets are .m2 + if (p.size() > 4) { + std::string ext = p.substr(p.size() - 4); + // Case-insensitive extension check + for (auto& c : ext) c = static_cast(std::tolower(static_cast(c))); + if (ext == ".mdx" || ext == ".mdl") { + p = p.substr(0, p.size() - 4) + ".m2"; + } + } + effectPaths[id] = p; + } } // Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID + // Probes all effect slots in priority order and keeps the first valid hit. auto kitDbc = cachedAssetManager_->loadDBC("SpellVisualKit.dbc"); std::unordered_map kitToEffectName; // kitId → effectNameId if (kitDbc && kitDbc->isLoaded()) { @@ -67,10 +96,11 @@ void SpellVisualSystem::loadSpellVisualDbc() { 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); + for (size_t k = 0; k < numKitFields && !eff; ++k) { + if (kitFields[k] < fc) + eff = kitDbc->getUInt32(i, kitFields[k]); + } if (eff) kitToEffectName[kitId] = eff; } } @@ -96,11 +126,18 @@ void SpellVisualSystem::loadSpellVisualDbc() { return; } uint32_t svFc = svDbc->getFieldCount(); - uint32_t loadedCast = 0, loadedImpact = 0; + uint32_t loadedPrecast = 0, loadedCast = 0, loadedImpact = 0; for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) { uint32_t vid = svDbc->getUInt32(i, 0); if (!vid) continue; + // Precast path: PrecastKit → SpecialEffect0/BaseEffect + { + std::string path; + if (svPrecastKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svPrecastKitField)); + if (!path.empty()) { spellVisualPrecastPath_[vid] = path; ++loadedPrecast; } + } // Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel { std::string path; @@ -120,12 +157,211 @@ void SpellVisualSystem::loadSpellVisualDbc() { if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; } } } - LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact, - " visual→M2 mappings (of ", svDbc->getRecordCount(), " records)"); + LOG_INFO("SpellVisual: loaded precast=", loadedPrecast, " cast=", loadedCast, " impact=", loadedImpact, + " visual\u2192M2 mappings (of ", svDbc->getRecordCount(), " records)"); +} + +// --------------------------------------------------------------------------- +// Classify model path to a character attachment point for bone tracking +// --------------------------------------------------------------------------- +uint32_t SpellVisualSystem::classifyAttachmentId(const std::string& modelPath) { + std::string lower = modelPath; + for (auto& c : lower) c = static_cast(std::tolower(static_cast(c))); + + // "hand" effects track the right hand (attachment 1) + if (lower.find("_hand") != std::string::npos || lower.find("hand_") != std::string::npos) + return 1; // RightHand + // "chest" effects track chest/torso (attachment 5 in M2 spec) + if (lower.find("_chest") != std::string::npos || lower.find("chest_") != std::string::npos) + return 5; // Chest + // "head" effects track head (attachment 11) + if (lower.find("_head") != std::string::npos || lower.find("head_") != std::string::npos) + return 11; // Head + return 0; // No bone tracking (static position or base effect) +} + +// --------------------------------------------------------------------------- +// Height offset for spell effect placement (fallback when no bone tracking) +// --------------------------------------------------------------------------- +glm::vec3 SpellVisualSystem::applyEffectHeightOffset(const glm::vec3& basePos, const std::string& modelPath) { + // Lowercase the path for case-insensitive matching + std::string lower = modelPath; + for (auto& c : lower) c = static_cast(std::tolower(static_cast(c))); + + // "hand" effects go at hand height (~0.8m above feet) + if (lower.find("_hand") != std::string::npos || lower.find("hand_") != std::string::npos) { + return basePos + glm::vec3(0.0f, 0.0f, 0.8f); + } + // "chest" effects go at chest height (~1.0m above feet) + if (lower.find("_chest") != std::string::npos || lower.find("chest_") != std::string::npos) { + return basePos + glm::vec3(0.0f, 0.0f, 1.0f); + } + // "head" effects go at head height (~1.6m above feet) + if (lower.find("_head") != std::string::npos || lower.find("head_") != std::string::npos) { + return basePos + glm::vec3(0.0f, 0.0f, 1.6f); + } + // "base" / "feet" / ground effects stay at ground level + return basePos; +} + +void SpellVisualSystem::playSpellVisualPrecast(uint32_t visualId, const glm::vec3& worldPosition, + uint32_t castTimeMs) { + LOG_INFO("SpellVisual: playSpellVisualPrecast visualId=", visualId, + " pos=(", worldPosition.x, ",", worldPosition.y, ",", worldPosition.z, + ") castTimeMs=", castTimeMs); + if (!m2Renderer_ || visualId == 0) { + LOG_WARNING("SpellVisual: playSpellVisualPrecast early-out: m2Renderer_=", (m2Renderer_ ? "yes" : "null"), + " visualId=", visualId); + return; + } + + if (!cachedAssetManager_) + cachedAssetManager_ = core::Application::getInstance().getAssetManager(); + if (!cachedAssetManager_) { LOG_WARNING("SpellVisual: no AssetManager"); return; } + + if (!spellVisualDbcLoaded_) loadSpellVisualDbc(); + + // Try precast path first, fall back to cast path + auto pathIt = spellVisualPrecastPath_.find(visualId); + if (pathIt == spellVisualPrecastPath_.end()) { + // No precast kit — fall back to playing cast kit + playSpellVisual(visualId, worldPosition, false); + return; + } + + const std::string& modelPath = pathIt->second; + LOG_INFO("SpellVisual: precast path resolved to: ", modelPath); + + // 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; + } + + if (spellVisualFailedModels_.count(modelId)) { + LOG_WARNING("SpellVisual: precast model in failed-cache, skipping: ", modelPath); + return; + } + + if (!m2Renderer_->hasModel(modelId)) { + auto m2Data = cachedAssetManager_->readFile(modelPath); + if (m2Data.empty()) { + LOG_WARNING("SpellVisual: could not read precast model: ", modelPath); + spellVisualFailedModels_.insert(modelId); + // Fall back to cast kit + playSpellVisual(visualId, worldPosition, false); + return; + } + LOG_INFO("SpellVisual: precast M2 data read OK, size=", m2Data.size(), " bytes"); + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + LOG_INFO("SpellVisual: precast M2 parsed: verts=", model.vertices.size(), + " bones=", model.bones.size(), " particles=", model.particleEmitters.size(), + " ribbons=", model.ribbonEmitters.size(), + " globalSeqs=", model.globalSequenceDurations.size(), + " sequences=", model.sequences.size()); + if (model.vertices.empty() && model.particleEmitters.empty()) { + LOG_WARNING("SpellVisual: empty precast model: ", modelPath); + spellVisualFailedModels_.insert(modelId); + playSpellVisual(visualId, worldPosition, false); + return; + } + 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); + LOG_INFO("SpellVisual: loaded skin, indices=", model.indices.size()); + } + } + if (!m2Renderer_->loadModel(model, modelId)) { + LOG_WARNING("SpellVisual: failed to load precast model to GPU: ", modelPath); + spellVisualFailedModels_.insert(modelId); + playSpellVisual(visualId, worldPosition, false); + return; + } + m2Renderer_->markModelAsSpellEffect(modelId); + LOG_INFO("SpellVisual: loaded precast model id=", modelId, " path=", modelPath); + } + + // Determine attachment point for bone tracking (hand/chest/head → follow character bones) + uint32_t attachId = classifyAttachmentId(modelPath); + glm::vec3 spawnPos = worldPosition; + if (attachId != 0 && renderer_) { + auto* charRenderer = renderer_->getCharacterRenderer(); + uint32_t charInstId = renderer_->getCharacterInstanceId(); + if (charRenderer && charInstId != 0) { + glm::mat4 attachMat; + if (charRenderer->getAttachmentTransform(charInstId, attachId, attachMat)) { + spawnPos = glm::vec3(attachMat[3]); + } else { + spawnPos = applyEffectHeightOffset(worldPosition, modelPath); + attachId = 0; + } + } else { + spawnPos = applyEffectHeightOffset(worldPosition, modelPath); + attachId = 0; + } + } else { + spawnPos = applyEffectHeightOffset(worldPosition, modelPath); + } + + uint32_t instanceId = m2Renderer_->createInstance(modelId, + spawnPos, + glm::vec3(0.0f), 1.0f); + if (instanceId == 0) { + LOG_WARNING("SpellVisual: createInstance returned 0 for precast model=", modelPath); + return; + } + + // Duration: prefer server cast time if available (long casts like Hearthstone=10s), + // otherwise fall back to M2 animation duration, then default. + float duration; + if (castTimeMs >= 500) { + // Server cast time available — precast should last the full cast duration + duration = std::clamp(static_cast(castTimeMs) / 1000.0f, 0.5f, 30.0f); + } else { + float animDurMs = m2Renderer_->getInstanceAnimDuration(instanceId); + duration = (animDurMs > 100.0f) + ? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION) + : SPELL_VISUAL_DEFAULT_DURATION; + } + activeSpellVisuals_.push_back({instanceId, 0.0f, duration, true, attachId}); + LOG_INFO("SpellVisual: spawned precast visualId=", visualId, " instanceId=", instanceId, + " duration=", duration, "s castTimeMs=", castTimeMs, " attach=", attachId, + " model=", modelPath, + " active=", activeSpellVisuals_.size()); + + // Hand effects: spawn a mirror copy on the left hand (attachment 2) + if (attachId == 1 /* RightHand */) { + glm::vec3 leftPos = worldPosition; + if (renderer_) { + auto* cr = renderer_->getCharacterRenderer(); + uint32_t ci = renderer_->getCharacterInstanceId(); + if (cr && ci != 0) { + glm::mat4 lm; + if (cr->getAttachmentTransform(ci, 2, lm)) + leftPos = glm::vec3(lm[3]); + } + } + uint32_t leftId = m2Renderer_->createInstance(modelId, leftPos, glm::vec3(0.0f), 1.0f); + if (leftId != 0) { + activeSpellVisuals_.push_back({leftId, 0.0f, duration, true, 2 /* LeftHand */}); + } + } } void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, bool useImpactKit) { + LOG_INFO("SpellVisual: playSpellVisual visualId=", visualId, " impact=", useImpactKit, + " pos=(", worldPosition.x, ",", worldPosition.y, ",", worldPosition.z, ")"); if (!m2Renderer_ || visualId == 0) return; if (!cachedAssetManager_) @@ -137,9 +373,13 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl // Select cast or impact path map auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_; auto pathIt = pathMap.find(visualId); - if (pathIt == pathMap.end()) return; // No model for this visual + if (pathIt == pathMap.end()) { + LOG_WARNING("SpellVisual: no ", (useImpactKit ? "impact" : "cast"), " path for visualId=", visualId); + return; + } const std::string& modelPath = pathIt->second; + LOG_INFO("SpellVisual: ", (useImpactKit ? "impact" : "cast"), " path resolved to: ", modelPath); // Get or assign a model ID for this path auto midIt = spellVisualModelIds_.find(modelPath); @@ -156,19 +396,26 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl } // Skip models that have previously failed to load (avoid repeated I/O) - if (spellVisualFailedModels_.count(modelId)) return; + if (spellVisualFailedModels_.count(modelId)) { + LOG_WARNING("SpellVisual: model in failed-cache, skipping: ", modelPath); + return; + } // 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); + LOG_WARNING("SpellVisual: could not read model: ", modelPath); spellVisualFailedModels_.insert(modelId); return; } + LOG_INFO("SpellVisual: cast/impact M2 data read OK, size=", m2Data.size(), " bytes"); pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + LOG_INFO("SpellVisual: M2 parsed: verts=", model.vertices.size(), + " bones=", model.bones.size(), " particles=", model.particleEmitters.size(), + " ribbons=", model.ribbonEmitters.size()); if (model.vertices.empty() && model.particleEmitters.empty()) { - LOG_DEBUG("SpellVisual: empty model: ", modelPath); + LOG_WARNING("SpellVisual: empty model: ", modelPath); spellVisualFailedModels_.insert(modelId); return; } @@ -183,11 +430,38 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl spellVisualFailedModels_.insert(modelId); return; } - LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath); + m2Renderer_->markModelAsSpellEffect(modelId); + LOG_INFO("SpellVisual: loaded model id=", modelId, " path=", modelPath); + } + + // Determine attachment point for bone tracking on cast effects at caster + uint32_t attachId = 0; + if (!useImpactKit) { + attachId = classifyAttachmentId(modelPath); + } + glm::vec3 spawnPos = worldPosition; + if (attachId != 0 && renderer_) { + auto* charRenderer = renderer_->getCharacterRenderer(); + uint32_t charInstId = renderer_->getCharacterInstanceId(); + if (charRenderer && charInstId != 0) { + glm::mat4 attachMat; + if (charRenderer->getAttachmentTransform(charInstId, attachId, attachMat)) { + spawnPos = glm::vec3(attachMat[3]); + } else { + spawnPos = applyEffectHeightOffset(worldPosition, modelPath); + attachId = 0; + } + } else { + spawnPos = applyEffectHeightOffset(worldPosition, modelPath); + attachId = 0; + } + } else { + spawnPos = applyEffectHeightOffset(worldPosition, modelPath); } // Spawn instance at world position - uint32_t instanceId = m2Renderer_->createInstance(modelId, worldPosition, + uint32_t instanceId = m2Renderer_->createInstance(modelId, + spawnPos, glm::vec3(0.0f), 1.0f); if (instanceId == 0) { LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId); @@ -198,18 +472,62 @@ void SpellVisualSystem::playSpellVisual(uint32_t visualId, const glm::vec3& worl float duration = (animDurMs > 100.0f) ? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION) : SPELL_VISUAL_DEFAULT_DURATION; - activeSpellVisuals_.push_back({instanceId, 0.0f, duration}); - LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId, - " duration=", duration, "s model=", modelPath); + activeSpellVisuals_.push_back({instanceId, 0.0f, duration, false, attachId}); + LOG_INFO("SpellVisual: spawned ", (useImpactKit ? "impact" : "cast"), " visualId=", visualId, + " instanceId=", instanceId, " duration=", duration, "s animDurMs=", animDurMs, + " attach=", attachId, " model=", modelPath, " active=", activeSpellVisuals_.size()); + + // Hand effects: spawn a mirror copy on the left hand (attachment 2) + if (attachId == 1 /* RightHand */) { + glm::vec3 leftPos = worldPosition; + if (renderer_) { + auto* cr = renderer_->getCharacterRenderer(); + uint32_t ci = renderer_->getCharacterInstanceId(); + if (cr && ci != 0) { + glm::mat4 lm; + if (cr->getAttachmentTransform(ci, 2, lm)) + leftPos = glm::vec3(lm[3]); + } + } + uint32_t leftId = m2Renderer_->createInstance(modelId, leftPos, glm::vec3(0.0f), 1.0f); + if (leftId != 0) { + activeSpellVisuals_.push_back({leftId, 0.0f, duration, false, 2 /* LeftHand */}); + } + } } void SpellVisualSystem::update(float deltaTime) { if (activeSpellVisuals_.empty() || !m2Renderer_) return; + + // Get character bone tracking context (once per frame) + CharacterRenderer* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; + uint32_t charInstId = renderer_ ? renderer_->getCharacterInstanceId() : 0; + for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { it->elapsed += deltaTime; if (it->elapsed >= it->duration) { m2Renderer_->removeInstance(it->instanceId); it = activeSpellVisuals_.erase(it); + } else { + // Update position for bone-tracked effects (follow character hands/chest/head) + if (it->attachmentId != 0 && charRenderer && charInstId != 0) { + glm::mat4 attachMat; + if (charRenderer->getAttachmentTransform(charInstId, it->attachmentId, attachMat)) { + glm::vec3 bonePos = glm::vec3(attachMat[3]); + m2Renderer_->setInstancePosition(it->instanceId, bonePos); + } + } + ++it; + } + } +} + +void SpellVisualSystem::cancelAllPrecastVisuals() { + if (!m2Renderer_) return; + for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { + if (it->isPrecast) { + m2Renderer_->removeInstance(it->instanceId); + it = activeSpellVisuals_.erase(it); } else { ++it; } diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index a0e5aaef..d5c5a51d 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -3,6 +3,7 @@ #include "rendering/vk_context.hpp" #include "rendering/water_renderer.hpp" #include "rendering/m2_renderer.hpp" +#include "rendering/m2_model_classifier.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/camera.hpp" #include "audio/ambient_sound_manager.hpp" @@ -691,36 +692,21 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { doodadLogCount++; } - if (m2PathLower.find("fire") != std::string::npos || - m2PathLower.find("brazier") != std::string::npos || - m2PathLower.find("campfire") != std::string::npos) { - // Fireplace/brazier emitter + auto emitterType = rendering::classifyAmbientEmitter(m2PathLower); + if (emitterType != rendering::AmbientEmitterType::None) { PendingTile::AmbientEmitter emitter; emitter.position = worldPos; - if (m2PathLower.find("small") != std::string::npos || m2PathLower.find("campfire") != std::string::npos) { - emitter.type = 0; // FIREPLACE_SMALL - } else { - emitter.type = 1; // FIREPLACE_LARGE + // Map classifier enum to AmbientSoundManager type codes + switch (emitterType) { + case rendering::AmbientEmitterType::FireplaceSmall: emitter.type = 0; break; + case rendering::AmbientEmitterType::FireplaceLarge: emitter.type = 1; break; + case rendering::AmbientEmitterType::Torch: emitter.type = 2; break; + case rendering::AmbientEmitterType::Fountain: emitter.type = 3; break; + case rendering::AmbientEmitterType::Waterfall: emitter.type = 6; break; + case rendering::AmbientEmitterType::Forge: emitter.type = 1; break; // Forge → large fire + default: emitter.type = 0; break; } pending->ambientEmitters.push_back(emitter); - } else if (m2PathLower.find("torch") != std::string::npos) { - // Torch emitter - PendingTile::AmbientEmitter emitter; - emitter.position = worldPos; - emitter.type = 2; // TORCH - pending->ambientEmitters.push_back(emitter); - } else if (m2PathLower.find("fountain") != std::string::npos) { - // Fountain emitter - PendingTile::AmbientEmitter emitter; - emitter.position = worldPos; - emitter.type = 3; // FOUNTAIN - pending->ambientEmitters.push_back(emitter); - } else if (m2PathLower.find("waterfall") != std::string::npos) { - // Waterfall emitter - PendingTile::AmbientEmitter emitter; - emitter.position = worldPos; - emitter.type = 6; // WATERFALL - pending->ambientEmitters.push_back(emitter); } PendingTile::WMODoodadReady doodadReady;