diff --git a/README.md b/README.md index 222fda90..9d895a76 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,946 @@ This version includes significant performance improvements, DLL-enhanced feature > **Looking for TBC support?** Visit the original pfUI by Shagu: [https://github.com/shagu/pfUI](https://github.com/shagu/pfUI) +## 🎯 What's New in Version 7.6.0 (February 3, 2026) + +### 🚀 Centralized Cast-Bar Tracking System (libdebuff.lua + nameplates.lua) + +**Major architectural change: Cast tracking moved from nameplates to libdebuff for single source of truth!** + +Previously, both `nameplates.lua` and `libdebuff.lua` independently tracked cast events, creating code duplication and maintenance overhead. Now all cast tracking is centralized in `libdebuff.lua` with nameplates consuming shared data. + +**libdebuff.lua - NEW Cast Tracking:** +- ✅ `SPELL_START_SELF/OTHER` → Cast-Start Tracking +- ✅ `SPELL_GO_SELF/OTHER` → Cast-Completion Tracking +- ✅ `SPELL_FAILED_OTHER` → Cast-Cancel Detection (movement, interrupts, OOM) +- ✅ `pfUI.libdebuff_casts` → Shared cast data structure `[casterGuid] = {spellID, spellName, icon, startTime, duration, endTime, event}` +- ✅ `pfUI.libdebuff_GetSpellIcon()` → Icon cache export function + +**nameplates.lua - Simplified Cast Consumption:** +- ✅ `GetCastInfo(guid)` → Reads `pfUI.libdebuff_casts` +- ✅ `pfUI.libdebuff_GetSpellIcon` → Uses shared icon cache +- ❌ `UNIT_CASTEVENT` → **REMOVED** (replaced by Nampower SPELL_* events) +- ❌ Local cast tracking code → **REMOVED** (~56 lines saved) + +**Benefits:** +- **100% Nampower, 0% SuperWOW** - No longer depends on UNIT_CASTEVENT +- **Single Source of Truth** - Cast data only tracked once +- **Icon Cache 100-400x faster** - First lookup via Nampower's GetSpellIconTexture, then cached +- **Easier Maintenance** - Changes only in one place +- **Code Reduction** - 56 lines removed from nameplates.lua + +### ⚠️ Nampower Version Requirement Update (libdebuff.lua) + +**Now requires Nampower 2.27.2+ (SPELL_FAILED_OTHER bug fix):** + +Version 2.27.1 had a bug where `SPELL_FAILED_OTHER` didn't fire for movement-cancelled casts. This is now fixed in 2.27.2. + +**User Warnings:** +- **2.27.2+**: ✅ Success message + auto-enable CVars +- **2.27.1**: ⚠️ Yellow warning + popup (cast-bar cancel broken) +- **< 2.27.1**: ❌ Red error + popup (debuff tracking disabled) +- **No Nampower**: ❌ Red error + popup (addon disabled) + +**NEW StaticPopup Dialogs:** + +Popups appear center-screen on login to ensure users don't miss the version requirement! + +### 🌿 libpredict HoT Tracking Integration (libpredict.lua) + +**Major enhancement: libdebuff integration for server-accurate HoT tracking!** + +Previously, libpredict relied purely on prediction (UNIT_CASTEVENT + timing calculations). Now it uses libdebuff's AURA_CAST events for server-side accurate buff/debuff data when available. + +**NEW Hybrid System:** +``` +GetHotDuration(unit, spell): + 1. Try libdebuff first (Nampower AURA_CAST events) + ↓ + if available: return server-accurate data + + 2. Fallback to prediction (legacy system) + ↓ + Use hots[] table with UNIT_CASTEVENT prediction +``` + +**Benefits:** +- ✅ **Server-accurate durations** - No prediction needed with Nampower +- ✅ **Automatic rank protection** - Built into libdebuff's system +- ✅ **Multi-caster support** - Multiple druids = multiple rejuvs tracked separately +- ✅ **Zero overhead** - libdebuff already tracks all auras +- ✅ **Backwards compatible** - Falls back to prediction without Nampower + +**NEW Rank Support for HoTs:** + +Extended `Hot()` function signature to include rank parameter: +```lua +function libpredict:Hot(sender, target, spell, duration, startTime, source, rank) +``` + +**Rank Protection Logic:** +- Don't overwrite Rank 10 HoT with Rank 8! +- Active higher-rank HoTs block lower-rank applications +- Works with multiple casters simultaneously + +**HealComm Protocol Extended (Backwards Compatible):** +- OLD: `"Reju/TargetName/15/"` +- NEW: `"Reju/TargetName/15/10/"` (rank added) +- `"0"` = unknown rank (for non-rank-aware clients) + +**Example Scenario:** +``` +Druid A casts Rejuvenation Rank 10 (15s duration) +Druid B casts Rejuvenation Rank 8 (12s duration) + +With rank protection: +→ Rank 8 is BLOCKED while Rank 10 is active! +→ No more accidental overwrites of better HoTs! +``` + +### 📊 Code Statistics + +**libdebuff.lua:** +- Lines: 2743 → 2835 (+92 lines) +- Events: 12 → 15 (+3: SPELL_START_SELF/OTHER, SPELL_FAILED_OTHER) +- Exports: 14 → 16 (+2: pfUI.libdebuff_casts, pfUI.libdebuff_GetSpellIcon) + +**nameplates.lua:** +- Lines: 1826 → 1770 (-56 lines) +- Events: 7 → 6 (-1: UNIT_CASTEVENT removed) +- Code removed: ~74 lines (UNIT_CASTEVENT handler, local cast tracking) + +**libpredict.lua:** +- Lines: 935 → 1095 (+160 lines) +- New: libdebuff integration, rank support, rank protection logic +- Backwards compatible: Works with/without Nampower + +--- + +## 🎯 What's New in Version 7.5.1 (February 02, 2026) + +- Added icon cache system - Icons are now cached in pfUI.libdebuff_icon_cache for instant lookups after first access + +- Replaced SpellInfo texture lookups with GetSpellIconTexture - Direct DBC queries via Nampower (~100-400x faster than tooltip parsing) + +- Optimized UnitDebuff() function - Now uses GetUnitField("aura") to retrieve spell IDs directly from unit data, then fetches icons via GetSpellIconTexture instead of vanilla UnitDebuff API + +- Changed fallback icons - Unknown spell icons now display QuestionMark instead of class-specific icons + +- Performance impact - Icon lookups reduced from ~5-20ms to ~0.05ms (first) / ~0.001ms (cached) per debuff, resulting in 600-2600x speedup for full debuff bars + +- Replaced in libdebuff.lua the UNIT_CASTEVENT of Superwow with Nampowers SPELL_GO and SPELL_START events (slowly trying to get away from superwow, not maintained anymore and outdated) + +--- + +## 🎯 What's New in Version 7.5.0 (January 31, 2026) + +### 🔧 Player Buff Bar Timer Fix (buffwatch.lua) + +**Fixed buff timers resetting on Player Buff/Debuff Bars when other buffs expire:** + +Previously, buff bar timers would reset or jump when other buffs expired because the UUID (unique identifier) included the slot number. Since slots shift when buffs expire, the same buff would get a new UUID, causing the timer bar to think it's a new buff. + +**The Problem:** +- Old UUID: `texture + name + slot` (e.g., "PowerWordFortitude_tex_PWF_3") +- Buff in slot 3 expires → slots 4,5,6 shift down to 3,4,5 +- UUID changes from `..._4` to `..._3` → timer resets! + +**The Solution:** +- Player buffs now use: `texture + name` only (no slot) +- Target debuffs still use: `texture + name + slot` (needed for multi-caster scenarios) + +```lua +-- For player: no slot in uuid (slots shift when other buffs expire) +-- For target: include slot (multiple players can have same debuff) +local uuid +if frame.unit == "player" then + uuid = data[4] .. data[3] -- texture + name only +else + uuid = data[4] .. data[3] .. data[2] -- texture + name + slot +end +``` + +### 🛡️ Immunity Check for Debuff Timers (libdebuff.lua) + +**No more phantom timers for immune targets:** + +When a target is immune to your debuff (e.g., Rake bleed on a bleed-immune mob), the `AURA_CAST` event fires but `DEBUFF_ADDED` never comes. Previously this could create a timer with icon for a debuff that was never actually applied. + +**The Fix:** +- Debuff data now requires `slot` to be set (confirmed by `DEBUFF_ADDED_OTHER` event) +- If `AURA_CAST` fires but `DEBUFF_ADDED` never comes → `slot` stays `nil` → no timer/icon shown + +```lua +-- IMMUNITY CHECK: Only show if slot is set (confirmed by DEBUFF_ADDED_OTHER) +-- This prevents showing timers for spells like Rake where the bleed is immune +if data.slot and timeleft > -1 then + -- Show the debuff +end +``` + +### 🎯 UnitDebuff() Now Returns Caster Information (libdebuff.lua) + +**Enhanced UnitDebuff() API - 8th return value is now `caster`:** + +```lua +local name, rank, texture, stacks, dtype, duration, timeleft, caster = libdebuff:UnitDebuff(unit, id) +-- caster = "player" (your debuff), "other" (someone else's), or nil (unknown) +``` + +**Use Cases:** +- Buff bar tooltip can now find correct slot for "only own debuffs" mode +- UI can differentiate between your debuffs and others' debuffs +- Enables future features like "show only my debuffs" filters + +### 🔄 Buff Bar Tooltip Fix for "Only Own Debuffs" Mode (buffwatch.lua) + +**Fixed tooltip showing wrong debuff in "only own debuffs" mode:** + +When using the "Show only own debuffs" option on Target Debuff Bars, hovering over a debuff could show the wrong tooltip because the displayed slot didn't match the actual game slot. Now searches through all game slots to find the correct one by matching spell name AND caster. + +### 🔧 Lua 5.0 Local Variable Limit Workaround (libdebuff.lua) + +**Fixed addon failing to load due to Lua 5.0's 32 local variable limit:** + +Lua 5.0 (used by WoW 1.12) has a hard limit of 32 local variables per function scope. As libdebuff grew, it hit this limit and stopped loading entirely. + +**The Solution:** Moved 11 tables from local scope to `pfUI.` namespace: + +| Old (local) | New (pfUI. namespace) | +|-------------|----------------------| +| `ownDebuffs` | `pfUI.libdebuff_own` | +| `ownSlots` | `pfUI.libdebuff_own_slots` | +| `allSlots` | `pfUI.libdebuff_all_slots` | +| `allAuraCasts` | `pfUI.libdebuff_all_auras` | +| `pendingCasts` | `pfUI.libdebuff_pending` | +| `objectsByGuid` | `pfUI.libdebuff_objects_guid` | +| `debugStats` | `pfUI.libdebuff_debugstats` | +| `lastCastRanks` | `pfUI.libdebuff_lastranks` | +| `lastFailedSpells` | `pfUI.libdebuff_lastfailed` | +| `lastUnitDebuffLog` | `pfUI.libdebuff_lastlog` | +| `cache` | `pfUI.libdebuff_cache` | + +### 🛑 Crash 132 Fix (Credits: jrc13245) + +**Fixed client crash (Error 132) when logging out:** + +WoW crashes with Error 132 when addons make API calls like `UnitExists()` during shutdown, especially with UnitXP DLL installed. + +**The Fix:** Register `PLAYER_LOGOUT` event and immediately disable all event handling: + +```lua +frame:RegisterEvent("PLAYER_LOGOUT") +frame:SetScript("OnEvent", function() + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + -- ... rest of event handling +end) +``` + +**Applied to:** +- `libdebuff.lua` - All event frames +- `nameplates.lua` - nameplates + nameplates.combat frames +- `nampower.lua` - Spell queue indicator frame +- `superwow.lua` - Secondary mana bar frames +- `actionbar.lua` - Page switch frame + +### ⚡ Performance Micro-Optimizations + +**Various small performance improvements across the codebase:** + +| Optimization | Location | Benefit | +|-------------|----------|---------| +| `childs` table reuse | nameplates.lua | Avoids creating new table every scan cycle | +| Indexed access instead of `pairs()` | nameplates.lua | Faster debuff timeout scanning | +| Quick exit if not in combat | nameplates.lua | Skips threatcolor calculation when unnecessary | +| Player GUID caching | libdebuff.lua | Avoids repeated `UnitExists("player")` calls | +| Consistent DoNothing() pattern | unitframes.lua, nameplates.lua | Lightweight frames when animation disabled | + +### 📋 Complete libdebuff.lua Feature Summary + +For reference, here's everything the enhanced libdebuff system now provides: + +**Debuff Detection:** +- ✅ Checks for dodges, misses, resists, parries, immunes, reflects, and evades +- ✅ Immunity check - no timer if debuff wasn't actually applied +- ✅ Tracks if debuff is from YOU or from OTHERS (including their GUID) + +**Rank & Duration:** +- ✅ Rank protection - lower rank spells can't refresh higher rank timers +- ✅ Shared debuff logic (`uniqueDebuffs` and `debuffOverwritePairs`) +- ✅ Faerie Fire ↔ Faerie Fire (Feral), Demo Shout ↔ Demo Roar overwrites (with rank check!) +- ✅ Combo point finisher duration (Rip, Rupture, Kidney Shot) +- ✅ Talent-based duration modifiers (Booming Voice, Improved SW:P, etc.) + +**Tracking:** +- ✅ Multi-target debuff tracking via GUID +- ✅ Debuff stack tracking for stackable debuffs +- ✅ Handles dispels and removals via events + +**API:** +- ✅ `UnitDebuff()` returns: name, rank, texture, stacks, dtype, duration, timeleft, caster +- ✅ `UnitOwnDebuff()` for filtering only your own debuffs +- ✅ Cleveroids API compatibility via `objectsByGuid` + +**Debug Commands:** +- `/shifttest start/stop/stats/slots` - Debug debuff slot tracking +- `/memcheck` - Show memory usage statistics + +--- + +## 🎯 What's New in Version 7.4.3 (January 29, 2026) + +### ⚡ Massive Performance Optimization - Cooldown Frame Overhaul + +**Revolutionary frame creation system that eliminates unnecessary Model frames:** + +Previously, pfUI created expensive Model frames for every single buff/debuff cooldown timer, even when the animation was disabled. This caused significant performance overhead, especially in 40-man raids where hundreds of frames were created but never actually used. + +**The Problem:** +- Old system: **ALWAYS** created Model frames with `CooldownFrameTemplate` +- When animation was disabled, frames were just hidden with `SetAlpha(0)` +- The frames still existed and consumed CPU resources in the background +- In raids: 40 players × 32 buffs/debuffs = **1,280 Model frames** running even when animations were off! + +**The Solution (Nameplates.lua + Unitframes.lua):** +- New system: Creates **Frame type based on config setting** +- Animation ON → Model frame with `CooldownFrameTemplate` (expensive but animated) +- Animation OFF → Regular Frame with dummy functions (lightweight, no animation) +- Dummy functions (`DoNothing()`) prevent crashes when `CooldownFrame_SetTimer()` is called + +**Performance Impact:** + +| Scenario | Before 7.4.3 | After 7.4.3 | Improvement | +|----------|--------------|-------------|-------------| +| Player frame (animation ON) | 32 Model frames | 32 Model frames | No change ✅ | +| 40 raid frames (animation OFF) | 1,280 Model frames | 1,280 Light frames | **100% lighter!** 🚀 | +| Mixed (player ON, raid OFF) | 1,312 Model frames | 32 Model + 1,280 Light | **98% less Model frames!** 🎯 | + +**Real-World Example:** +``` +Before: ALL frames = 1,312 expensive Model frames +After: 32 Model (player) + 1,280 Light (raid) = 98% reduction in overhead +``` + +**Technical Implementation:** + +```lua +-- Nameplates.lua & Unitframes.lua +if cooldown_anim == 1 then + -- Create expensive Model frame + cd = CreateFrame("Model", ...) +else + -- Create lightweight Frame + cd = CreateFrame("Frame", ...) + cd.AdvanceTime = DoNothing + cd.SetSequence = DoNothing + cd.SetSequenceTime = DoNothing +end +``` + +**User Experience:** +- ✅ **GUI Integration:** Toggling "Show Timer Animation" now prompts for `/reload` +- ✅ **Per-Frame Control:** Each unitframe type (player/target/raid/party) has independent settings +- ✅ **Immediate Effect:** Reload applies the correct frame type based on your config +- ✅ **No Visual Change:** When animation is ON, everything looks identical (just way more efficient!) + +**Why This Matters:** +- **40-man raids:** Dramatically reduced frame update overhead +- **Low-end PCs:** Smoother gameplay with animations disabled +- **Battery life:** Less CPU usage = longer laptop battery +- **Future-proof:** Foundation for more performance optimizations + +**Compatibility:** +- Works with existing timer text display (independent of animation) +- Fully backward compatible with all existing configs +- No changes needed to user settings (automatic on reload) + +--- + +## 🎯 What's New in Version 7.4.2 (January 28, 2026) + +### Major Performance Improvements +- **Much faster debuff tracking** - No more lag in 40-man raids +- **10x less memory usage** - Runs cleaner over long raid sessions +- **Instant cleanup** - Dead mobs cleaned up immediately (was: 2-5 minutes) + +### Better Debuff Tracking +- **Multi-player debuffs** - See debuffs from all raid members with accurate timers +- **Rank protection** - Lower rank spells can't overwrite higher ranks anymore +- **100% accurate positioning** - Debuff icons always in the correct slot +- **Better combo points** - Rip, Rupture, Kidney Shot show correct duration + +--- + +## 🎯 What's New in Version 7.4.1 (January 27, 2026) + +### 🎯 Nameplate Debuff Timer Improvements + +- ✅ **New Option: Enable Debuff Timers** - Toggle for debuff timer display on nameplates + - Moved from hidden location (Appearance → Cooldown → "Display Debuff Durations") to Nameplates → Debuffs + - All timer-related options are now grouped together for better discoverability +- ✅ **New Option: Show Timer Text** - Toggle the countdown text (e.g., "12s") on debuff icons + - Previously always shown, now configurable +- ✅ **Show Timer Animation** - Existing pie-chart animation option, now properly grouped with other timer options + +### 🖼️ Unitframe Timer Config Fix (unitframes.lua) + +- ✅ **Live Config Updates** - "Show Timer Animation" and "Show Timer Text" now update immediately + - Previously: Changes only applied after buffs/debuffs were refreshed + - Now: Toggling the option instantly shows/hides the animation and text on existing buffs/debuffs + +### 🔧 Slot Shifting Fix Attempt (libdebuff.lua) + +- ✅ **DEBUFF_REMOVED now uses slotData.spellName** - Previously used spellName from scan, which could be wrong after slot shifting + - When debuffs shift slots (e.g., slot 3 removed, slots 4+ shift down), the scan might read a different spell + - Now uses `removedSpellName = slotData.spellName` from stored slot data for consistency +- ✅ **Cleanup empty spell tables** - After removing a caster from allAuraCasts, checks if no other casters remain and removes the empty spell table +- ✅ **Defensive casterGuid validation** - Checks for empty string and "0x0000000000000000" before looking up timer data +- ✅ **Invalid timer detection** - Warns when remaining > duration (impossible state) +- ✅ **ValidateSlotConsistency function** - Debug function to verify allSlots and allAuraCasts consistency after shifting +- ✅ **Enhanced debug logging** - All debug messages now include target= for easier filtering + +--- + +## 🎯 What's New in Version 7.4.0 (January 26, 2026) + +### 🗡️ Rogue Combo Point Fix + +**PLAYER_COMBO_POINTS event now works for Rogues:** + +The combo point tracking was previously only enabled for Druids. Rogues were completely ignored, causing abilities like Kidney Shot to always show base duration (1 sec) instead of the correct CP-scaled duration. + +**Technical Details:** +- Nampower sends `durationMs=1000` (base duration) for Kidney Shot +- Code checked `if duration == 0` before calling `GetDuration()` +- Since duration was 1 (not 0), the CP calculation was skipped +- Fix: Always call `GetDuration()` for CP-based abilities, regardless of event duration + +### ⚙️ New Settings: Number & Timer Formatting + +**Abbreviate Numbers (Settings → General):** + +| Option | Example | +|--------|---------| +| Full Numbers | 4250 | +| 2 Decimals | 4.25k | +| 1 Decimal | 4.2k (always rounds DOWN) | + +**Castbar Timer Decimals (Settings → General):** + +| Option | Example | +|--------|---------| +| 1 Decimal | 2.1 | +| 2 Decimals | 2.14 | + +### 🎬 Nameplate Castbar Improvements + +**Smooth Castbar Animation:** +- Fixed stuttering castbar caused by incorrect throttle placement +- Scanner throttle (0.05s) now only affects nameplate detection +- Castbar updates run at full 50 FPS for smooth animation + +**Countdown Timer:** +- Castbar timer now counts DOWN (3.0 → 0.0) instead of up +- Shows remaining cast time, not elapsed time + +**Intelligent Throttling (unchanged):** +- Target OR casting nameplates: 0.02s (50 FPS) +- All other nameplates: 0.1s (10 FPS) +- Event updates bypass throttle entirely + +### 🧹 Memory Management + +**Cache cleanup for hidden nameplates:** +- `guidRegistry` cleared when plate hides +- `CastEvents` cleared when plate hides +- `debuffCache` cleared when plate hides +- `threatMemory` cleared when plate hides + +Prevents memory leaks when mobs die or go out of range. + +--- + +## 🎯 What's New in Version 7.3.0 (January 25, 2026) + +### ⚡ O(1) Performance Optimizations for Unitframes + +**Complete rewrite of health/mana lookups using Nampower's `GetUnitField` API:** + +The unitframes now use direct memory access via `GetUnitField(guid, "health")` instead of the slower `UnitHealth()` API calls. This provides significant performance improvements especially in raids. + +**Key Changes:** + +| Component | Before (7.2.0) | After (7.3.0) | +|-----------|----------------|---------------| +| HealPredict Health | `UnitHealth()` API calls | `GetUnitField(guid, "health")` O(1) | +| Health Bar Colors | 4x redundant API calls per update | Uses cached `hp_orig`/`hpmax_orig` values | +| GetColor Function | `UnitHealth()` API calls | `GetUnitField(guid, "health")` O(1) | + +**Fallback Support:** +- Automatic fallback to `UnitHealth()` when Nampower not available +- Automatic fallback for units >180 yards (out of Nampower range) +- Automatic fallback when GUID unavailable + +### 🚀 Smart Roster Updates (No More Freeze!) + +**GUID-based tracking eliminates screen freezes when swapping raid groups:** + +Previously, any raid roster change would trigger a full update of ALL 40 raid frames, causing noticeable freezes. Now, only frames where the actual player changed get updated. + +**How it works:** +```lua +-- OLD: RAID_ROSTER_UPDATE → ALL 40 frames update_full = true → FREEZE +-- NEW: RAID_ROSTER_UPDATE → Check GUID per frame → Only changed frames update +``` + +| Scenario | Before (7.2.0) | After (7.3.0) | +|----------|----------------|---------------| +| Swap 2 players | 40 frame updates | 2 frame updates | +| Player joins | 40 frame updates | 1 frame update | +| Player leaves | 40 frame updates | 1 frame update | +| No changes | 40 frame updates | 0 frame updates | + +**Technical Implementation:** +- `pfUI.uf.guidTracker` tracks GUID per frame +- On roster change, compares old GUID vs new GUID +- Only sets `update_full = true` if GUID actually changed +- Also forces `update_aura = true` to refresh buffs/debuffs + +### 🔧 libpredict.lua Optimizations + +**Eliminated redundant `UnitName()` calls:** +- `UnitGetIncomingHeals()`: Removed double `UnitName()` call +- `UnitHasIncomingResurrection()`: Removed double `UnitName()` call +- `UNIT_HEALTH` event handler: Reuses cached name variable + +--- + +## 🎯 What's New in Version 7.2.0 (January 24, 2026) + +### 🐱 Druid Secondary Mana Bar Overhaul + +**Complete rewrite using Nampower's `GetUnitField` API:** + +The Druid Mana Bar feature (showing base mana while in shapeshift form) has been completely rewritten to use Nampower's native `GetUnitField` instead of the deprecated `UnitMana()` extended return values. + +**Key Changes:** + +| Component | Before (7.1.0) | After (7.2.0) | +|-----------|----------------|---------------| +| Data Source | `UnitMana()` second return value | `GetUnitField(guid, "power1")` | +| Player Support | ✅ Druids only | ✅ Druids only | +| Target Support | ❌ Limited/broken | ✅ All classes can see Druid mana in all forms | +| Text Settings | Hardcoded format | Respects Power Bar text config | + +grafik + +**New Features:** +- ✅ **Target Secondary Mana:** See enemy/friendly Druid's base mana while they're in Cat/Bear form +- ✅ **Respects Power Text Settings:** Uses same format as your Power Bar configuration (`powerdyn`, `power`, `powerperc`, `none`, etc.) +- ✅ **Available for ALL Classes:** Any class can now see Druid mana bars (controlled by "Show Druid Mana Bar" setting) + +**Technical Implementation:** +```lua +-- OLD: Extended UnitMana (unreliable for other units) +local _, baseMana = UnitMana("target") -- Often returns nil for non-player + +-- NEW: Direct field access via Nampower +local _, guid = UnitExists("target") +local baseMana = GetUnitField(guid, "power1") -- Base mana +local baseMaxMana = GetUnitField(guid, "maxPower1") -- Max base mana +``` + +### 🧹 Major Code Cleanup + +**superwow.lua:** +- ❌ Removed legacy `pfDruidMana` bar (old SuperWoW-style implementation) +- ❌ Removed `UnitMana()` fallback code +- ✅ Unified all secondary mana bars to use `GetUnitField` +- ✅ Fixed text centering issue (was using `SetJustifyH("RIGHT")`) + +**nampower.lua - Massive Cleanup:** + +Removed significant amounts of dead/unused code: + +| Removed Feature | Reason | +|-----------------|--------| +| Buff tracking system | Data collected but never displayed | +| HoT Detection (AURA_CAST events) | `OnHotApplied` callback never implemented | +| Swing Timer (`GetSwingTimers()`) | Never called anywhere in codebase | +| UNIT_DIED buff/debuff cleanup | Now handled by libdebuff | + +**Result:** Cleaner, more maintainable code with reduced memory footprint. + +--- + +## 🎯 What's New in Version 7.1.0 (January 24, 2026) + +### ⚡ Cooldown Timer Animation Support + +**Nameplate Debuff Animations:** +- ✅ Added "Show Timer Animation" option for nameplate debuffs +- ✅ Uses proper `Model` frame with `CooldownFrameTemplate` for Vanilla client +- ✅ Pie/swipe animation now works on nameplate debuff icons +- ✅ Configurable via GUI: Nameplates → Show Timer Animation + +**Target Frame Debuff Animations:** +- ✅ Timer animations now properly visible on target/player frame debuffs +- ✅ Fixed CD frame scaling and positioning for correct display +- ✅ `SetScale(size/32)`, `SetAllPoints()`, `SetFrameLevel(14)` for proper rendering + +**cooldown.lua Fix:** +- ✅ Added `elseif pfCooldownStyleAnimation == 1 then SetAlpha(1)` to make animations visible +- ✅ Previously animations were created but never shown (alpha stayed 0) + +### 🧹 Memory Leak Fixes + +**libdebuff.lua:** +- ✅ `lastCastRanks` table now cleaned up (entries older than 3 seconds removed) +- ✅ `lastFailedSpells` table now cleaned up (entries older than 2 seconds removed) +- ✅ Previously these tables grew indefinitely over long play sessions + +**unitframes.lua:** +- ✅ Cache cleanup now uses in-place `= nil` instead of creating new table every 30 seconds +- ✅ Reduces garbage collector pressure + +**nameplates.lua:** +- ✅ Reusable `debuffSeen` table instead of creating `local seen = {}` on every DEBUFF_UPDATE event +- ✅ Significant reduction in table allocations during combat + +--- + +## 🎯 What's New in Version 7.0.0 (January 21, 2026) + +### 🔥 Complete libdebuff.lua Rewrite (464 → 1594 lines) + +**Event-Driven Architecture:** + +Replaced tooltip scanning with a pure event-based system using Nampower/SuperWoW: + +**OLD (Master):** +```lua +-- Every UI update: +for slot = 1, 16 do + scanner:SetUnitDebuff("target", slot) -- Tooltip scan + local name = scanner:Line(1) +end +``` + +**NEW (Experiment):** +```lua +-- Events fire when changes happen: +RegisterEvent("AURA_CAST_ON_SELF") -- You cast a debuff +RegisterEvent("DEBUFF_ADDED_OTHER") -- Debuff lands in slot +RegisterEvent("DEBUFF_REMOVED_OTHER") -- Debuff removed + +-- UI reads from pre-computed tables: +local data = ownDebuffs[guid][spell] -- Direct lookup +``` + +--- + +### 🐱 Combo Point Finisher Support + +**Dynamic Duration Calculation:** + +| Ability | Formula | Durations (1-5 CP) | +|---------|---------|-------------------| +| Rip | 8s + CP × 2s | 10s / 12s / 14s / 16s / 18s | +| Rupture | 10s + CP × 2s | 12s / 14s / 16s / 18s / 20s | +| Kidney Shot | 2s + CP × 1s | 3s / 4s / 5s / 6s / 7s | + +**Before:** All Rips showed 18s (wrong for 1-4 CP) +**After:** Shows actual duration based on combo points used + +--- + +### 🎭 Carnage Talent Detection + +**Ferocious Bite Refresh Mechanics:** +- Tracks Carnage talent (Rank 2) which makes Ferocious Bite refresh Rip & Rake +- Only refreshes when Ferocious Bite HITS (not on miss/dodge/parry) +- Preserves original duration (doesn't reset to new CP count) +- Uses `DidSpellFail()` API for miss detection + +--- + +### 🔄 Additional Features + +- **Debuff Overwrite Pairs:** Faerie Fire ↔ Faerie Fire (Feral), Demoralizing Shout ↔ Demoralizing Roar +- **Slot Shifting Algorithm:** Accurate icon placement when debuffs expire +- **Multi-Caster Tracking:** Multiple players' debuffs tracked separately +- **Rank Protection:** Lower rank can't overwrite higher rank timer +- **Unique Debuff System:** Hunter's Mark, Scorpid Sting, etc. handled correctly + +--- + +## 📊 Performance Comparison + +### The Core Difference: Data Access Architecture + +**Master uses Blizzard API + Tooltip Scanning:** +```lua +-- Every UnitDebuff call requires tooltip scan +function libdebuff:UnitDebuff(unit, id) + local texture, stacks, dtype = UnitDebuff(unit, id) + if texture then + scanner:SetUnitDebuff(unit, id) -- Tooltip scan to get spell name + effect = scanner:Line(1) + end + -- Duration comes from hardcoded lookup tables +end + +-- UnitOwnDebuff iterates all 16 slots +function libdebuff:UnitOwnDebuff(unit, id) + for i = 1, 16 do + local effect = libdebuff:UnitDebuff(unit, i) -- 16 tooltip scans! + if caster == "player" then ... + end +end +``` + +**Experiment uses Nampower Events + GetUnitField:** +```lua +-- Single call returns ALL 48 aura slots (32 buffs + 16 debuffs) +local auras = GetUnitField(guid, "aura") -- Returns array[48] of spell IDs +local stacks = GetUnitField(guid, "auraApplications") -- Returns array[48] of stack counts + +-- Events fire with full data including duration +-- AURA_CAST_ON_OTHER: spellId, casterGuid, targetGuid, effect, effectAuraName, +-- effectAmplitude, effectMiscValue, durationMs, auraCapStatus +-- BUFF_REMOVED_OTHER: guid, slot, spellId, stackCount, auraLevel + +-- UnitOwnDebuff is just a table lookup +function libdebuff:UnitOwnDebuff(unit, id) + local _, guid = UnitExists(unit) + local data = ownDebuffs[guid][spellName] -- Pre-computed by events + return data.duration, data.timeleft, ... +end +``` + +### Nampower Features Used (Experiment Only) + +| Feature | Purpose | Data Provided | +|---------|---------|---------------| +| `GetUnitField(guid, "aura")` | Single call returns all 48 aura spell IDs | `array[48]` of spell IDs | +| `GetUnitField(guid, "auraApplications")` | Stack counts for all auras | `array[48]` of stack counts | +| `GetUnitField(guid, "power1")` | Base mana for shapeshifted Druids | Mana value (7.2.0) | +| `GetUnitField(guid, "maxPower1")` | Max base mana | Max mana value (7.2.0) | +| `AURA_CAST_ON_OTHER` | Instant debuff cast detection | spellId, casterGuid, targetGuid, **durationMs** | +| `AURA_CAST_ON_SELF` | Instant self-buff detection | Same as above | +| `BUFF_REMOVED_OTHER` | Instant aura removal detection | guid, **slot**, spellId, stackCount | +| `DEBUFF_ADDED_OTHER` | Debuff slot assignment | guid, slot, spellId, stacks | +| `DEBUFF_REMOVED_OTHER` | Debuff removal with slot info | guid, slot, spellId | + +Master uses **none** of these - it relies on: +- `UnitDebuff()` API (no caster info, no duration) +- Tooltip scanning via `GameTooltip:SetUnitDebuff()` to get spell names +- Chat message parsing (`CHAT_MSG_SPELL_PERIODIC_*`) for duration detection +- Hardcoded duration lookup tables + +### Performance Comparison + +| Operation | Master | Experiment | Improvement | +|-----------|--------|------------|-------------| +| Initial target scan | 16 tooltip scans | 1 GetUnitField call (48 slots) | **16x fewer calls** | +| Get YOUR debuffs | Loop 16 slots + tooltip each | Direct table lookup | **~50-100x faster** | +| Debuff duration | Hardcoded tables / chat parsing | Event provides `durationMs` | **Accurate to ms** | +| Detect debuff removal | Polling / timeout | `BUFF_REMOVED_OTHER` event | **Instant** | +| Detect new debuff | Chat message delay | `AURA_CAST_ON_OTHER` event | **Instant** | +| Caster identification | Not available | Event provides `casterGuid` | **New capability** | +| Druid mana (other units) | Not available | `GetUnitField(guid, "power1")` | **New in 7.2.0** | +| Memory usage | ~50KB | ~200KB | 4x more (negligible) | + +### Memory Management (7.1.0+ Fixes) + +| Table | Before 7.1.0 | After 7.1.0 | +|-------|--------------|-------------| +| `lastCastRanks` | Grew indefinitely | Cleaned every 30s (>3s old) | +| `lastFailedSpells` | Grew indefinitely | Cleaned every 30s (>2s old) | +| `debuffSeen` (nameplates) | New table per DEBUFF_UPDATE | Reused single table | +| `cleanedCache` (unitframes) | New table every 30s | In-place cleanup | + +--- + +## 📋 File Changes Summary + +### Version 7.5.0 + +| File | Location | Changes | +|------|----------|---------| +| `buffwatch.lua` | `modules/` | Player buff bar timer fix (UUID without slot), tooltip fix for "only own debuffs" mode | +| `libdebuff.lua` | `libs/` | Immunity check, UnitDebuff() 8th return value `caster`, Lua 5.0 table limit workaround (11 tables to pfUI. namespace), Crash 132 fix, Player GUID caching | +| `unitframes.lua` | `api/` | Consistent DoNothing() pattern for lightweight cooldown frames | +| `nameplates.lua` | `modules/` | Consistent DoNothing() pattern, `childs` table reuse, indexed debuff timeout scan, quick exit optimization, Crash 132 fix | +| `nampower.lua` | `modules/` | Crash 132 fix | +| `superwow.lua` | `modules/` | Crash 132 fix | +| `actionbar.lua` | `modules/` | Crash 132 fix | + +### Version 7.4.3 (January 29, 2026) + +| File | Location | Changes | +|------|----------|---------| +| `libdebuff.lua` | `libs/` | Rogue PLAYER_COMBO_POINTS fix, always use GetDuration() for CP-abilities | +| `api.lua` | `api/` | Abbreviate() now supports 3 modes (off/2dec/1dec), 1dec always floors | +| `config.lua` | `api/` | Added `castbardecimals` option | +| `gui.lua` | `modules/` | Abbreviate Numbers dropdown, Castbar Timer Decimals dropdown | +| `nameplates.lua` | `modules/` | Smooth castbar (throttle fix), countdown timer, cache cleanup | +| `castbar.lua` | `modules/` | FormatCastbarTime() helper, respects castbardecimals config | + +### Version 7.2.0 + +| File | Location | Changes | +|------|----------|---------| +| `superwow.lua` | `modules/` | Removed legacy pfDruidMana, added Target/ToT secondary mana bars, GetUnitField for all mana queries, respect Power Bar text settings | +| `nampower.lua` | `modules/` | Major cleanup: removed dead buff tracking, HoT detection, swing timer code | + +### Version 7.1.0 + +| File | Location | Changes | +|------|----------|---------| +| `libdebuff.lua` | `libs/` | Memory leak fixes for lastCastRanks, lastFailedSpells | +| `unitframes.lua` | `api/` | In-place cache cleanup, CD frame scaling/positioning | +| `nameplates.lua` | `modules/` | Reusable debuffSeen table, Model+CooldownFrameTemplate | +| `cooldown.lua` | `modules/` | SetAlpha(1) for pfCooldownStyleAnimation == 1 | +| `config.lua` | `api/` | Added nameplates.debuffanim option | +| `gui.lua` | `modules/` | Added "Show Timer Animation" checkbox for nameplates | + +--- + +## 📋 Installation + +### Requirements + +**REQUIRED:** +- SuperWoW DLL +- Nampower DLL + +**Optional but Recommended:** +- UnitXP_SP3 DLL (for accurate XP tracking) + +### Steps + +1. Install SuperWoW + Nampower +2. Download pfUI Experiment build +3. Extract to `Interface/AddOns/pfUI` +4. `/reload` +5. Check for errors in console + +### Verification + +Type `/run print(GetNampowerVersion())` - should show version number. + +If `nil`, Nampower is not installed correctly! + +--- + +## 🐛 Known Issues + +### Untested Scenarios + +- ❌ 40-man raids with 5+ druids (slot shifting stress test) +- ❌ Rapid target swapping with Ferocious Bite spam +- ⚠️ Multi-caster tracking in AQ40/Naxx + +### Edge Cases + +1. **DEBUFF_ADDED race condition:** Sometimes fires before AURA_CAST_ON_SELF processes +2. **Slot shifting bugs:** Complex logic for removing/adding debuffs +3. **Combo point detection:** Relies on PLAYER_COMBO_POINTS event timing + +--- + +## 📜 Changelog + +### 7.5.0 (January 31, 2026) + +**Added:** +- ✅ UnitDebuff() 8th return value: `caster` ("player"/"other"/nil) +- ✅ Immunity check - no timer/icon shown if debuff wasn't actually applied +- ✅ Buff bar tooltip correctly identifies debuff slot in "only own debuffs" mode +- ✅ `/shifttest` and `/memcheck` debug commands for libdebuff troubleshooting + +**Fixed:** +- 🔧 Player Buff Bar timer reset bug (UUID no longer includes slot for player buffs) +- 🔧 Lua 5.0 local variable limit (moved 11 tables to pfUI. namespace) +- 🔧 Crash 132 on logout (Credits: jrc13245) - affects libdebuff, nameplates, nampower, superwow, actionbar +- 🔧 Consistent DoNothing() pattern across all cooldown frame creation + +**Performance:** +- ⚡ `childs` table reuse in nameplate scanner (avoids GC churn) +- ⚡ Indexed access instead of `pairs()` for debuff timeout scanning +- ⚡ Quick exit if not in combat for threatcolor calculation +- ⚡ Player GUID caching in libdebuff + +### 7.4.3 (January 29, 2026) + +**Added:** +- ✅ Castbar Timer Decimals setting (1 or 2 decimals) +- ✅ Abbreviate Numbers dropdown (Full / 2 Decimals / 1 Decimal) +- ✅ Nameplate castbar countdown (shows remaining time) +- ✅ Cache cleanup for hidden nameplates (prevents memory leaks) + +**Fixed:** +- 🔧 Rogue combo point tracking (PLAYER_COMBO_POINTS was Druid-only) +- 🔧 Kidney Shot/Rupture duration (now always uses GetDuration() for CP-abilities) +- 🔧 Nameplate castbar stuttering (throttle only affects scanner, not updates) + +**Changed:** +- 🔧 Abbreviate Numbers: 1 Decimal mode always rounds DOWN (4180 → 4.1k) +- 🔧 Nameplate castbar: counts down instead of up + +### 7.2.0 (January 24, 2026) + +**Added:** +- ✅ Target Secondary Mana Bar (see Druid mana while in shapeshift form) +- ✅ Target-of-Target Secondary Mana Bar +- ✅ Secondary Mana Bars now respect Power Bar text settings + +**Changed:** +- 🔧 Secondary Mana Bars now use `GetUnitField(guid, "power1")` instead of `UnitMana()` +- 🔧 "Show Druid Mana Bar" setting now available for ALL classes (not just Druids) + +**Removed:** +- ❌ Legacy `pfDruidMana` bar (replaced by `pfPlayerSecondaryMana`) +- ❌ `UnitMana()` extended return value fallback +- ❌ Dead code in nampower.lua: buff tracking, HoT detection, swing timer + +### 7.1.0 (January 24, 2026) + +**Added:** +- ✅ Nameplate debuff timer animation support (pie/swipe effect) +- ✅ Target frame debuff animation improvements +- ✅ GUI option: Nameplates → Show Timer Animation + +**Fixed:** +- 🔧 Memory leak: `lastCastRanks` now cleaned up (>3s old entries) +- 🔧 Memory leak: `lastFailedSpells` now cleaned up (>2s old entries) +- 🔧 Memory churn: Reusable `debuffSeen` table in nameplates +- 🔧 Memory churn: In-place cache cleanup in unitframes +- 🔧 cooldown.lua: Animation now visible when pfCooldownStyleAnimation == 1 + +### 7.0.0 (January 21, 2026) + +**Added:** +- ✅ Event-driven debuff tracking (AURA_CAST, DEBUFF_ADDED, etc.) +- ✅ Combo point finisher support (Rip, Rupture, Kidney Shot) +- ✅ Carnage talent detection (Ferocious Bite refresh) +- ✅ Debuff overwrite pairs (Faerie Fire ↔ Faerie Fire Feral) +- ✅ Slot shifting algorithm (accurate icon placement) +- ✅ Multi-caster tracking (multiple Moonfires) +- ✅ Rank protection (Rank 1 can't overwrite Rank 10) +- ✅ Unique debuff system (Hunter's Mark, Scorpid Sting) +- ✅ Nampower GetUnitField() initial scan +- ✅ Combat indicator fix (works on player frame now) + +**Changed:** +- 🔧 libdebuff.lua completely rewritten (464 → 1594 lines) +- 🔧 UnitOwnDebuff() uses table lookup instead of tooltip scan + --- ## What's New in Version 6.2.6 (January 27, 2026) @@ -476,7 +1416,7 @@ Same as original pfUI - free to use and modify. --- -**Version:** 6.2.6 -**Release Date:** January 27, 2026 +**Version:** 7.6.0 +**Release Date:** February 3, 2026 **Compatibility:** Turtle WoW 1.18.0 **Status:** Stable \ No newline at end of file diff --git a/api/api.lua b/api/api.lua index 030a1afd..d86733a5 100644 --- a/api/api.lua +++ b/api/api.lua @@ -445,18 +445,33 @@ end -- 'number' [number] the number that should be abbreviated -- 'returns: [string] the abbreviated value function pfUI.api.Abbreviate(number) - if pfUI_config.unitframes.abbrevnum == "1" then + local mode = pfUI_config.unitframes.abbrevnum + -- mode "0" = disabled (full numbers) + -- mode "1" = 2 decimals (4250 -> 4.25k) [legacy/default] + -- mode "2" = 1 decimal (4250 -> 4.2k) - always rounds DOWN + + if mode == "1" or mode == "2" then local sign = number < 0 and -1 or 1 number = math.abs(number) if number > 1000000 then - return pfUI.api.round(number/1000000*sign,2) .. "m" + if mode == "2" then + -- 1 decimal, round DOWN: 4.18m -> 4.1m + return (floor(number/100000) / 10 * sign) .. "m" + else + return pfUI.api.round(number/1000000*sign, 2) .. "m" + end elseif number > 1000 then - return pfUI.api.round(number/1000*sign,2) .. "k" + if mode == "2" then + -- 1 decimal, round DOWN: 4180 -> 4.1k (not 4.2k) + return (floor(number/100) / 10 * sign) .. "k" + else + return pfUI.api.round(number/1000*sign, 2) .. "k" + end end end - return number + return math.floor(number) end -- [ SendChatMessageWide ] diff --git a/api/config.lua b/api/config.lua index 7bfe23e5..8bbf7549 100644 --- a/api/config.lua +++ b/api/config.lua @@ -224,6 +224,7 @@ function pfUI:LoadConfig() pfUI:UpdateConfig("unitframes", nil, "combowidth", "6") pfUI:UpdateConfig("unitframes", nil, "comboheight", "6") pfUI:UpdateConfig("unitframes", nil, "abbrevnum", "1") + pfUI:UpdateConfig("unitframes", nil, "castbardecimals", "2") pfUI:UpdateConfig("unitframes", nil, "abbrevname", "1") -- Nampower Settings @@ -231,7 +232,6 @@ function pfUI:LoadConfig() pfUI:UpdateConfig("unitframes", nil, "spellqueuesize", "24") pfUI:UpdateConfig("unitframes", nil, "gcd_indicator", "0") pfUI:UpdateConfig("unitframes", nil, "gcd_size", "4") - pfUI:UpdateConfig("unitframes", nil, "nampower_buffs", "1") pfUI:UpdateConfig("unitframes", nil, "reactive_indicator", "0") pfUI:UpdateConfig("unitframes", nil, "reactive_size", "28") pfUI:UpdateConfig("unitframes", nil, "damage_tracking", "0") diff --git a/api/unitframes.lua b/api/unitframes.lua index d089b7a3..4912e67c 100644 --- a/api/unitframes.lua +++ b/api/unitframes.lua @@ -14,6 +14,20 @@ end) pfUI.uf.frames = {} pfUI.uf.delayed = {} +-- ============================================================================ +-- GUID-based Roster Tracking for Smart Updates +-- Only updates frames where the unit actually changed, not ALL 40 frames +-- ============================================================================ +pfUI.uf.guidTracker = { + -- Maps frame to its last known GUID: frame -> guid + frameToGuid = {}, +} + +-- Clear all GUID tracking (forces full update next time) +function pfUI.uf.ClearGuidTracking() + pfUI.uf.guidTracker.frameToGuid = {} +end + -- slash command to toggle unitframe test mode _G.SLASH_PFTEST1, _G.SLASH_PFTEST2 = "/pftest", "/pfuftest" _G.SlashCmdList.PFTEST = function() @@ -31,23 +45,45 @@ local glow2 = { insets = {left = 0, right = 0, top = 0, bottom = 0}, } +local function DoNothing() + return +end + local maxdurations = {} local function BuffOnUpdate() if ( this.tick or 1) > GetTime() then return else this.tick = GetTime() + .2 end - local timeleft = GetPlayerBuffTimeLeft(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HELPFUL")) - local texture = GetPlayerBuffTexture(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HELPFUL")) + local bid = GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HELPFUL") + local timeleft = GetPlayerBuffTimeLeft(bid) + local texture = GetPlayerBuffTexture(bid) local start = 0 - if timeleft > 0 then - if not maxdurations[texture] then - maxdurations[texture] = timeleft - elseif maxdurations[texture] and maxdurations[texture] < timeleft then - maxdurations[texture] = timeleft - end - start = GetTime() + timeleft - maxdurations[texture] + -- slot is empty (buff expired or doesn't exist), clear timer and bail + if not texture then + CooldownFrame_SetTimer(this.cd, 0, 0, 0) + return end - CooldownFrame_SetTimer(this.cd, start, maxdurations[texture], timeleft > 0 and 1 or 0) + -- Get buff name for unique key (two buffs could share same texture) + local name = "" + if libtipscan then + scanner = scanner or libtipscan:GetScanner("unitframes") + if scanner then + scanner:SetPlayerBuff(bid) + name = scanner:Line(1) or "" + end + end + local key = texture .. name + + if timeleft > 0 then + if not maxdurations[key] then + maxdurations[key] = timeleft + elseif maxdurations[key] and maxdurations[key] < timeleft then + maxdurations[key] = timeleft + end + start = GetTime() + timeleft - maxdurations[key] + end + + CooldownFrame_SetTimer(this.cd, start, maxdurations[key], timeleft > 0 and 1 or 0) end local function TargetBuffOnUpdate() @@ -73,6 +109,9 @@ local function BuffOnEnter() if IsShiftKeyDown() then local texture = parent.label == "player" and GetPlayerBuffTexture(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HELPFUL")) or UnitBuff(parent.label .. parent.id, this.id) + -- slot is empty, nothing to compare against + if not texture then return end + local playerlist = "" local first = true @@ -120,20 +159,38 @@ end local function DebuffOnUpdate() if ( this.tick or 1) > GetTime() then return else this.tick = GetTime() + .2 end - local timeleft = GetPlayerBuffTimeLeft(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HARMFUL")) - local texture = GetPlayerBuffTexture(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HARMFUL")) + local bid = GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HARMFUL") + local timeleft = GetPlayerBuffTimeLeft(bid) + local texture = GetPlayerBuffTexture(bid) local start = 0 - if timeleft > 0 then - if not maxdurations[texture] then - maxdurations[texture] = timeleft - elseif maxdurations[texture] and maxdurations[texture] < timeleft then - maxdurations[texture] = timeleft - end - start = GetTime() + timeleft - maxdurations[texture] + -- slot is empty (debuff expired or doesn't exist), clear timer and bail + if not texture then + CooldownFrame_SetTimer(this.cd, 0, 0, 0) + return end - CooldownFrame_SetTimer(this.cd, start, maxdurations[texture], timeleft > 0 and 1 or 0) + -- Get debuff name for unique key (two debuffs could share same texture) + local name = "" + if libtipscan then + scanner = scanner or libtipscan:GetScanner("unitframes") + if scanner then + scanner:SetPlayerBuff(bid) + name = scanner:Line(1) or "" + end + end + local key = texture .. name + + if timeleft > 0 then + if not maxdurations[key] then + maxdurations[key] = timeleft + elseif maxdurations[key] and maxdurations[key] < timeleft then + maxdurations[key] = timeleft + end + start = GetTime() + timeleft - maxdurations[key] + end + + CooldownFrame_SetTimer(this.cd, start, maxdurations[key], timeleft > 0 and 1 or 0) end local function DebuffOnEnter() @@ -143,7 +200,29 @@ local function DebuffOnEnter() if this:GetParent().label == "player" then GameTooltip:SetPlayerBuff(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,"HARMFUL")) else - GameTooltip:SetUnitDebuff(this:GetParent().label .. this:GetParent().id, this.id) + local unitstr = this:GetParent().label .. this:GetParent().id + local parent = this:GetParent() + + -- For "only own debuffs" mode: find the REAL slot by matching spell name AND caster + if parent.config and parent.config.selfdebuff == "1" and libdebuff then + -- Get the spell name from our filtered list + local ownDebuffName = libdebuff:UnitOwnDebuff(unitstr, this.id) + + if ownDebuffName then + -- Search through all game slots to find OUR debuff with matching name + for gameSlot = 1, 16 do + local gameName, _, _, _, _, _, _, gameCaster = libdebuff:UnitDebuff(unitstr, gameSlot) + -- Match both name AND caster (must be ours) + if gameName == ownDebuffName and gameCaster == "player" then + GameTooltip:SetUnitDebuff(unitstr, gameSlot) + return + end + end + end + end + + -- Normal mode: use visual id directly + GameTooltip:SetUnitDebuff(unitstr, this.id) end end @@ -164,9 +243,113 @@ visibilityscan:SetScript("OnUpdate", function() for frame in pairs(this.frames) do frame:UpdateVisibility() end end) +-- ============================================================================ +-- GetUnitStats - Nampower Integration for Health + Power +-- Returns: hp, maxHp, power, maxPower, powerType +-- IMPORTANT: Uses _G.UnitExists directly to avoid conflicts with Nampower's +-- extended UnitExists that returns (exists, guid) +-- ============================================================================ + +-- Cache für Stats-Tracking (nur Änderungen zählen) +pfUI.api.lastUnitStats = pfUI.api.lastUnitStats or {} + +function pfUI.api.GetUnitStats(unitstr, trackStats) + local hp, maxHp, power, maxPower, powerType + local usedNampower = false + + -- Try Nampower first if available + if GetUnitField then + -- Use the standard check first, then get guid separately + local exists = _G.UnitExists(unitstr) + if exists then + -- Get guid via the extended UnitExists for Nampower + local _, guid = _G.UnitExists(unitstr) + if guid then + hp = GetUnitField(guid, "health") + maxHp = GetUnitField(guid, "maxHealth") + + -- Get power type from bytes0 + local bytes0 = GetUnitField(guid, "bytes0") + if bytes0 then + local temp = math.floor(bytes0 / 16777216) + powerType = temp - math.floor(temp / 256) * 256 + else + powerType = UnitPowerType(unitstr) or 0 + end + + -- Get power values based on type + if powerType == 1 then + -- Rage (Nampower stores rage * 10) + local rage = GetUnitField(guid, "power2") + power = rage and math.floor(rage / 10) or UnitMana(unitstr) + maxPower = 100 + elseif powerType == 3 then + -- Energy + power = GetUnitField(guid, "power4") or UnitMana(unitstr) + if power then power = math.floor(power) end + maxPower = GetUnitField(guid, "maxPower4") or UnitManaMax(unitstr) + else + -- Mana (default) + power = GetUnitField(guid, "power1") or UnitMana(unitstr) + maxPower = GetUnitField(guid, "maxPower1") or UnitManaMax(unitstr) + end + + -- Check if Nampower gave valid health data + if hp and hp > 0 and maxHp and maxHp > 0 then + usedNampower = true + + -- Track Nampower success - NUR bei echten Änderungen + if trackStats and pfUI.uf and pfUI.uf.stats and pfUI.uf.stats.enabled then + local lastStats = pfUI.api.lastUnitStats[unitstr] + if not lastStats or lastStats.hp ~= hp or lastStats.maxHp ~= maxHp or + lastStats.power ~= power or lastStats.maxPower ~= maxPower then + pfUI.uf.stats.nampowerUsed = (pfUI.uf.stats.nampowerUsed or 0) + 1 + pfUI.api.lastUnitStats[unitstr] = { + hp = hp, + maxHp = maxHp, + power = power, + maxPower = maxPower + } + end + end + + return hp, maxHp, power or 0, maxPower or 1, powerType + end + end + end + end + + -- Fallback to standard API + hp = UnitHealth(unitstr) or 0 + maxHp = UnitHealthMax(unitstr) or 1 + powerType = UnitPowerType(unitstr) or 0 + power = UnitMana(unitstr) or 0 + maxPower = UnitManaMax(unitstr) or 1 + + -- Track Fallback usage - NUR bei echten Änderungen + if trackStats and not usedNampower then + if pfUI.uf and pfUI.uf.stats and pfUI.uf.stats.enabled then + local lastStats = pfUI.api.lastUnitStats[unitstr] + if not lastStats or lastStats.hp ~= hp or lastStats.maxHp ~= maxHp or + lastStats.power ~= power or lastStats.maxPower ~= maxPower then + pfUI.uf.stats.fallbackUsed = (pfUI.uf.stats.fallbackUsed or 0) + 1 + pfUI.api.lastUnitStats[unitstr] = { + hp = hp, + maxHp = maxHp, + power = power, + maxPower = maxPower + } + end + end + end + + return hp, maxHp, power, maxPower, powerType +end + local aggrodata = { } function pfUI.api.UnitHasAggro(unit) - if aggrodata[unit] and GetTime() < aggrodata[unit].check + 1 then + -- Only cache positive results to allow instant detection when aggro changes + if aggrodata[unit] and aggrodata[unit].state > 0 and GetTime() < aggrodata[unit].check + 1 then return aggrodata[unit].state end @@ -192,7 +375,7 @@ function pfUI.api.UnitHasAggro(unit) return aggrodata[unit].state end -pfUI.uf.glow = CreateFrame("Frame") +pfUI.uf.glow = CreateFrame("Frame", nil, UIParent) pfUI.uf.glow:SetScript("OnUpdate", function() local fpsmod = GetFramerate() / 30 if not this.val or this.val >= .8 then @@ -204,13 +387,17 @@ pfUI.uf.glow:SetScript("OnUpdate", function() end) pfUI.uf.glow.mod = 0 -pfUI.uf.glow.val = 0 +pfUI.uf.glow.val = 0.6 function pfUI.uf.glow.UpdateGlowAnimation() - this:SetAlpha(pfUI.uf.glow.val) + local val = pfUI.uf.glow.val or 0.6 + if val < 0.4 then val = 0.4 end + if val > 0.8 then val = 0.8 end + this:SetAlpha(val) end local detect_icon, detect_name +local buff_icons_seeded = false function pfUI.uf:DetectBuff(name, id) if not name or not id then return end @@ -228,10 +415,21 @@ function pfUI.uf:DetectBuff(name, id) -- make sure the icon cache exists pfUI_cache.buff_icons = pfUI_cache.buff_icons or {} + -- seed cache from static locale data once per login + -- reverses L["icons"] (name→icon) into buff_icons (icon→name) + -- so that pfUI_cache.buff_icons[detect_icon] hits for all known buffs immediately + if not buff_icons_seeded then + for name, icon in pairs(L["icons"]) do + local path = "Interface\\Icons\\" .. icon + pfUI_cache.buff_icons[path] = pfUI_cache.buff_icons[path] or name + end + buff_icons_seeded = true + end + -- check the regular way detect_icon = UnitBuff(name, id) if detect_icon then - if not L["icons"][detect_name] and not pfUI_cache.buff_icons[detect_icon] then + if not pfUI_cache.buff_icons[detect_icon] then -- read buff name and cache it scanner:SetUnitBuff(name, id) detect_name = scanner:Line(1) @@ -744,37 +942,7 @@ function pfUI.uf:UpdateConfig() f.buffs[i].stacks:SetShadowOffset(0.8, -0.8) f.buffs[i].stacks:SetTextColor(1,1,.5) - -- Create CD frame if it doesn't exist - if not f.buffs[i].cd then - f.buffs[i].cd = CreateFrame(COOLDOWN_FRAME_TYPE, f.buffs[i]:GetName() .. "Cooldown", f.buffs[i], "CooldownFrameTemplate") - end - - -- Always update CD properties (in case size changed) - local cdScale = f.config.buffsize / 32 - f.buffs[i].cd:ClearAllPoints() - f.buffs[i].cd:SetScale(cdScale) - f.buffs[i].cd:SetAllPoints(f.buffs[i]) - f.buffs[i].cd:SetFrameLevel(14) - f.buffs[i].cd.pfCooldownType = "ALL" - f.buffs[i].cd.pfCooldownStyleText = cooldown_text - f.buffs[i].cd.pfCooldownStyleAnimation = cooldown_anim - f.buffs[i].cd:SetAlpha(cooldown_anim == 1 and 1 or 0) - - -- immediately show/hide existing cooldown text - if f.buffs[i].cd.pfCooldownText then - if cooldown_text == 1 then - f.buffs[i].cd.pfCooldownText:Show() - else - f.buffs[i].cd.pfCooldownText:Hide() - end - end - - f.buffs[i].id = i - f.buffs[i]:Hide() - f.buffs[i]:SetFrameLevel(12) - CreateBackdrop(f.buffs[i], default_border) - f.buffs[i]:RegisterForClicks("RightButtonUp") f.buffs[i]:ClearAllPoints() @@ -808,6 +976,45 @@ function pfUI.uf:UpdateConfig() f.buffs[i]:SetWidth(f.config.buffsize) f.buffs[i]:SetHeight(f.config.buffsize) + + -- Create CD frame if it doesn't exist + if not f.buffs[i].cd then + if cooldown_anim == 1 then + -- Animation enabled: Use Model frame with CooldownFrameTemplate + f.buffs[i].cd = CreateFrame(COOLDOWN_FRAME_TYPE, f.buffs[i]:GetName() .. "Cooldown", f.buffs[i], "CooldownFrameTemplate") + else + -- Animation disabled: Use regular Frame with dummy functions + f.buffs[i].cd = CreateFrame("Frame", f.buffs[i]:GetName() .. "Cooldown", f.buffs[i]) + f.buffs[i].cd.AdvanceTime = DoNothing + f.buffs[i].cd.SetSequence = DoNothing + f.buffs[i].cd.SetSequenceTime = DoNothing + end + end + + -- Always update CD properties (in case size changed) + local cdScale = f.config.buffsize / 32 + f.buffs[i].cd:ClearAllPoints() + f.buffs[i].cd:SetScale(cdScale) + f.buffs[i].cd:SetAllPoints(f.buffs[i]) + f.buffs[i].cd:SetFrameLevel(14) + f.buffs[i].cd.pfCooldownType = "ALL" + f.buffs[i].cd.pfCooldownStyleText = cooldown_text + f.buffs[i].cd.pfCooldownStyleAnimation = cooldown_anim + f.buffs[i].cd:SetAlpha(cooldown_anim == 1 and 1 or 0) + + -- immediately show/hide existing cooldown text + if f.buffs[i].cd.pfCooldownText then + if cooldown_text == 1 then + f.buffs[i].cd.pfCooldownText:Show() + else + f.buffs[i].cd.pfCooldownText:Hide() + end + end + + f.buffs[i].id = i + f.buffs[i]:Hide() + + CreateBackdrop(f.buffs[i], default_border) if f:GetName() == "pfPlayer" then f.buffs[i]:SetScript("OnUpdate", BuffOnUpdate) @@ -846,10 +1053,26 @@ function pfUI.uf:UpdateConfig() f.debuffs[i].stacks:SetShadowColor(0, 0, 0) f.debuffs[i].stacks:SetShadowOffset(0.8, -0.8) f.debuffs[i].stacks:SetTextColor(1,1,.5) + + f.debuffs[i]:SetFrameLevel(12) + f.debuffs[i]:RegisterForClicks("RightButtonUp") + f.debuffs[i]:ClearAllPoints() + f.debuffs[i]:SetWidth(f.config.debuffsize) + f.debuffs[i]:SetHeight(f.config.debuffsize) + f.debuffs[i]:SetNormalTexture(nil) -- Create CD frame if it doesn't exist if not f.debuffs[i].cd then - f.debuffs[i].cd = CreateFrame(COOLDOWN_FRAME_TYPE, f.debuffs[i]:GetName() .. "Cooldown", f.debuffs[i], "CooldownFrameTemplate") + if cooldown_anim == 1 then + -- Animation enabled: Use Model frame with CooldownFrameTemplate + f.debuffs[i].cd = CreateFrame(COOLDOWN_FRAME_TYPE, f.debuffs[i]:GetName() .. "Cooldown", f.debuffs[i], "CooldownFrameTemplate") + else + -- Animation disabled: Use regular Frame with dummy functions + f.debuffs[i].cd = CreateFrame("Frame", f.debuffs[i]:GetName() .. "Cooldown", f.debuffs[i]) + f.debuffs[i].cd.AdvanceTime = DoNothing + f.debuffs[i].cd.SetSequence = DoNothing + f.debuffs[i].cd.SetSequenceTime = DoNothing + end end -- Always update CD properties (in case size changed) @@ -875,15 +1098,8 @@ function pfUI.uf:UpdateConfig() f.debuffs[i].id = i f.debuffs[i]:Hide() - f.debuffs[i]:SetFrameLevel(12) CreateBackdrop(f.debuffs[i], default_border) - f.debuffs[i]:RegisterForClicks("RightButtonUp") - f.debuffs[i]:ClearAllPoints() - f.debuffs[i]:SetWidth(f.config.debuffsize) - f.debuffs[i]:SetHeight(f.config.debuffsize) - f.debuffs[i]:SetNormalTexture(nil) - if f:GetName() == "pfPlayer" then f.debuffs[i]:SetScript("OnUpdate", DebuffOnUpdate) end @@ -911,6 +1127,14 @@ function pfUI.uf.OnShow() end function pfUI.uf.OnEvent() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + this:SetScript("OnUpdate", nil) + return + end + -- update indicators if event == "PARTY_LEADER_CHANGED" or event == "PARTY_LOOT_METHOD_CHANGED" or @@ -928,16 +1152,36 @@ function pfUI.uf.OnEvent() -- update regular frames if event == "PLAYER_ENTERING_WORLD" then this.update_full = true + -- Clear GUID tracking on zone change for full rebuild + if pfUI.uf.ClearGuidTracking then pfUI.uf.ClearGuidTracking() end elseif this.label == "target" and event == "PLAYER_TARGET_CHANGED" and not pfScanActive == true then this.update_full = true elseif ( this.label == "raid" or this.label == "party" or this.label == "player" ) and event == "PARTY_MEMBERS_CHANGED" then - this.update_full = true + -- Smart update: check if THIS frame's unit actually changed + if pfUI.uf.guidTracker and this.id then + local unit = this.label == "player" and "player" or (this.label .. this.id) + local _, newGuid = UnitExists(unit) + local oldGuid = pfUI.uf.guidTracker.frameToGuid[this] + if newGuid ~= oldGuid then + pfUI.uf.guidTracker.frameToGuid[this] = newGuid + this.update_full = true + end + else + this.update_full = true + end elseif ( this.label == "raid" or this.label == "party" ) and event == "PARTY_MEMBER_ENABLE" then this.update_full = true elseif ( this.label == "raid" or this.label == "party" ) and event == "PARTY_MEMBER_DISABLE" then this.update_full = true elseif ( this.label == "raid" or this.label == "party" ) and event == "RAID_ROSTER_UPDATE" then - this.update_full = true + -- Note: Smart GUID-based updates are handled in raid.lua OnUpdate + -- after frame IDs are reassigned. We don't set update_full here anymore + -- for raid frames to avoid the freeze. + if this.label == "party" then + -- Party frames still need the old logic (no smart tracking yet) + this.update_full = true + end + -- Raid frames: update_full is set by raid.lua GUID tracker elseif this.label == "pet" and event == "UNIT_PET" then this.update_full = true elseif this.label == "player" and (event == "PLAYER_AURAS_CHANGED" or event == "UNIT_INVENTORY_CHANGED") then @@ -946,6 +1190,8 @@ function pfUI.uf.OnEvent() this.update_full = true -- UNIT_XXX Events elseif arg1 and arg1 == this.label .. this.id then + this.lastEventUpdate = GetTime() + if event == "UNIT_PORTRAIT_UPDATE" or event == "UNIT_MODEL_CHANGED" then this.update_portrait = true elseif event == "UNIT_AURA" then @@ -955,7 +1201,7 @@ function pfUI.uf.OnEvent() elseif event == "UNIT_COMBAT" then CombatFeedback_OnCombatEvent(arg2, arg3, arg4, arg5) else - this.update_full = true + this.update_base = true end end end @@ -966,20 +1212,388 @@ local _GetTime = GetTime -- Global cached time for libpredict and other functions pfUI.uf.now = 0 +-- ============================================================================ +-- GLOBAL FALLBACK THROTTLE - Limits total fallback updates across ALL frames +-- ============================================================================ +pfUI.uf.fallbackThrottle = { + lastUpdate = 0, + interval = 0.1, -- 10 updates per second total (not per frame!) + updatesThisInterval = 0, + maxUpdatesPerInterval = 5 -- Max 5 frames can update per interval +} + +-- ============================================================================ +-- STATS SYSTEM - Performance tracking for Nampower vs Fallback +-- ============================================================================ +pfUI.uf.stats = { + eventUpdates = 0, + heartbeatUpdates = 0, + earlyReturns = 0, + nampowerUsed = 0, + fallbackUsed = 0, + throttledSkips = 0, + startTime = 0, + enabled = true +} + +-- Stats Frame (Live Display) +pfUI.uf.statsFrame = CreateFrame("Frame", "pfUIStatsFrame", UIParent) +pfUI.uf.statsFrame:SetWidth(200) +pfUI.uf.statsFrame:SetHeight(220) +pfUI.uf.statsFrame:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -10, -200) +pfUI.uf.statsFrame:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 8, + insets = { left = 3, right = 3, top = 3, bottom = 3 } +}) +pfUI.uf.statsFrame:SetBackdropColor(0, 0, 0, 0.8) +pfUI.uf.statsFrame:EnableMouse(true) +pfUI.uf.statsFrame:SetMovable(true) +pfUI.uf.statsFrame:SetClampedToScreen(true) +pfUI.uf.statsFrame:RegisterForDrag("LeftButton") +pfUI.uf.statsFrame:SetScript("OnDragStart", function() this:StartMoving() end) +pfUI.uf.statsFrame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) +pfUI.uf.statsFrame:Hide() + +-- Stats Title +pfUI.uf.statsFrame.title = pfUI.uf.statsFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") +pfUI.uf.statsFrame.title:SetPoint("TOP", pfUI.uf.statsFrame, "TOP", 0, -8) +pfUI.uf.statsFrame.title:SetText("Performance") + +-- Stats Text (multi-line) +pfUI.uf.statsFrame.text = pfUI.uf.statsFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") +pfUI.uf.statsFrame.text:SetPoint("TOPLEFT", pfUI.uf.statsFrame, "TOPLEFT", 10, -30) +pfUI.uf.statsFrame.text:SetWidth(180) +pfUI.uf.statsFrame.text:SetHeight(180) +pfUI.uf.statsFrame.text:SetJustifyH("LEFT") +pfUI.uf.statsFrame.text:SetJustifyV("TOP") +pfUI.uf.statsFrame.text:SetText("Initializing...") + +-- Update function for stats display +pfUI.uf.UpdateStatsDisplay = function() + local elapsed = GetTime() - pfUI.uf.stats.startTime + if elapsed < 0.1 then return end + + local eventRate = pfUI.uf.stats.eventUpdates / elapsed + local heartbeatRate = pfUI.uf.stats.heartbeatUpdates / elapsed + local totalFrameUpdates = eventRate + heartbeatRate + + -- Calculate Nampower vs Fallback percentages (ONLY counts actual data changes!) + local totalDataChanges = pfUI.uf.stats.nampowerUsed + pfUI.uf.stats.fallbackUsed + local nampowerPct = totalDataChanges > 0 and math.floor((pfUI.uf.stats.nampowerUsed / totalDataChanges) * 100) or 0 + local fallbackPct = totalDataChanges > 0 and math.floor((pfUI.uf.stats.fallbackUsed / totalDataChanges) * 100) or 0 + + -- Calculate data change rate (how often HP/Mana actually changes) + local dataChangeRate = totalDataChanges / elapsed + + local statsText = string.format( + "Time: %.1fs\n" .. + "|cffaaaaaa--- Frame Updates ---|r\n" .. + "Event: %.1f/s (%d)\n" .. + "Heartbeat: %.1f/s (%d)\n" .. + "Total: %.1f/s\n" .. + "\n" .. + "|cffaaaaaa--- Data Changes ---|r\n" .. + "Rate: %.1f/s (%d)\n" .. + "|cff00ff00NP: %d%% (%d)|r\n" .. + "|cffff8800FB: %d%% (%d)|r", + elapsed, + eventRate, + pfUI.uf.stats.eventUpdates, + heartbeatRate, + pfUI.uf.stats.heartbeatUpdates, + totalFrameUpdates, + dataChangeRate, + totalDataChanges, + nampowerPct, + pfUI.uf.stats.nampowerUsed, + fallbackPct, + pfUI.uf.stats.fallbackUsed + ) + + pfUI.uf.statsFrame.text:SetText(statsText) +end + +-- Stats update timer +pfUI.uf.statsUpdateTimer = 0 + +-- Cache cleanup timer (clean lastUnitStats every 30s to prevent memory leak) +pfUI.uf.cacheCleanupTimer = 0 + +-- ============================================================================ +-- OnUpdate with Heartbeat Polling and Fallback +-- ============================================================================ function pfUI.uf.OnUpdate() local now = _GetTime() - pfUI.uf.now = now -- Cache for libpredict to use + pfUI.uf.now = now + + -- Update stats display (throttled to 0.2s) + if pfUI.uf.statsFrame and pfUI.uf.statsFrame:IsShown() then + if (pfUI.uf.statsUpdateTimer or 0) <= now then + pfUI.uf.statsUpdateTimer = now + 0.2 + if pfUI.uf.stats.startTime > 0 then + pfUI.uf.UpdateStatsDisplay() + end + end + end + + -- Cleanup lastUnitStats cache every 30 seconds to prevent memory leak + if (pfUI.uf.cacheCleanupTimer or 0) <= now then + pfUI.uf.cacheCleanupTimer = now + 30 + + -- Only keep cache for units that currently exist + if pfUI.api.lastUnitStats then + for unitstr in pairs(pfUI.api.lastUnitStats) do + if not _G.UnitExists(unitstr) then + pfUI.api.lastUnitStats[unitstr] = nil + end + end + end + end -- update combat feedback (no throttle - needs immediate feedback) if this.feedbackText then CombatFeedback_OnUpdate(arg1) end -- Throttle raid/party frames for performance (10 updates/sec) - -- Other frames run at full speed for responsive castbars etc. if this.label == "raid" or this.label == "party" then - if (this.throttleTick or 0) > now then return end + if (this.throttleTick or 0) > now then + if pfUI.uf.stats and pfUI.uf.stats.enabled then + pfUI.uf.stats.throttledSkips = pfUI.uf.stats.throttledSkips + 1 + end + return + end this.throttleTick = now + 0.1 end + -- ============================================================================ + -- EVENTLESS ACTIONS (Range Check, Online/Offline, Aggro) - MUST RUN ALWAYS + -- These run on their own timer, independent of event-based updates + -- ============================================================================ + if this.label then + -- Combat/Aggro Indicators (throttled to 0.2s) + if not this.lastCombatCheck then this.lastCombatCheck = now + 0.2 end + if this.lastCombatCheck < now then + this.lastCombatCheck = now + 0.2 + + if this.config and this.config.squareaggro == "1" and pfUI.api.UnitHasAggro(this.label .. this.id) > 0 then + this.combat.tex:SetTexture(1,.2,0) + this.combat:Show() + elseif this.config and this.config.squarecombat == "1" and UnitAffectingCombat(this.label .. this.id) then + this.combat.tex:SetTexture(1,1,.2) + this.combat:Show() + elseif this.combat then + this.combat:Hide() + end + end + + -- Range Check / Online-Offline State (throttled) + -- Validate tick value - it should be an interval (e.g. 0.5), not a timestamp + local tickInterval = this.tick + if tickInterval and tickInterval > 10 then + -- tick is a timestamp, not an interval - ignore it + tickInterval = nil + end + -- Reset lastTick if it's invalid (much larger than now, e.g. from corrupted state) + if this.lastTick and this.lastTick > now + 10 then + this.lastTick = nil + end + if not this.lastTick then this.lastTick = now + (tickInterval or .5) end + if this.lastTick < now then + local unitstr = this.label .. this.id + this.lastTick = now + (tickInterval or .5) + + -- target target has a huge delay, make sure to not tick during range checks + if this.label == "targettarget" or this.label == "targettargettarget" then + local name = UnitName(this.label) + if name ~= this.namebuf1 then + this.namebuf1 = name + elseif name ~= this.namebuf2 then + this.namebuf2 = name + else + pfUI.uf:RefreshUnitState(this) + pfUI.uf:RefreshIndicators(this) + end + else + pfUI.uf:RefreshUnitState(this) + pfUI.uf:RefreshIndicators(this) + end + + if this.config and this.config.glowaggro == "1" and pfUI.api.UnitHasAggro(unitstr) > 0 then + this.glow:SetBackdropBorderColor(1,.2,0) + this.glow:Show() + elseif this.config and this.config.glowcombat == "1" and UnitAffectingCombat(unitstr) then + this.glow:SetBackdropBorderColor(1,1,.2) + this.glow:Show() + elseif this.glow then + this.glow:Hide() + end + + -- update everything on eventless frames (targettarget, etc) + if this.tick then + pfUI.uf:RefreshUnit(this, "all") + end + end + + -- Heal Prediction (throttled to 0.1s for responsiveness) + if libpredict and this.incHeal then + if not this.lastHealTick then this.lastHealTick = now end + if this.lastHealTick < now then + this.lastHealTick = now + 0.1 + + local unit = this.label .. this.id + local heal = libpredict:UnitGetIncomingHeals(unit) + + -- O(1) Nampower lookup via GUID (same pattern as nameplates.lua) + local health, maxHealth + if GetUnitField then + local _, guid = UnitExists(unit) + if guid then + health = GetUnitField(guid, "health") + maxHealth = GetUnitField(guid, "maxHealth") + end + end + -- Fallback to standard API + if not health or not maxHealth or maxHealth == 0 then + health, maxHealth = UnitHealth(unit), UnitHealthMax(unit) + end + + if heal - health - maxHealth ~= this.predictstate then + local overhealperc = tonumber(this.config.overhealperc) + this.predictstate = heal - health - maxHealth + + if heal > 0 and (health < maxHealth or overhealperc > 0 ) then + local width = this.config.width + local height = this.config.height + + if this.config.verticalbar == "0" then + local healthWidth = width * (health / maxHealth) + local incWidth = width * heal / maxHealth + if healthWidth + incWidth > width * (1+(overhealperc/100)) then + incWidth = width * (1+overhealperc/100) - healthWidth + end + + if this.config.invert_healthbar == "1" then + this.incHeal:SetWidth(incWidth) + else + this.incHeal:SetWidth(incWidth + healthWidth) + end + else + local healthHeight = height * (health / maxHealth) + local incHeight = height * heal / maxHealth + if healthHeight + incHeight > height * (1+(overhealperc/100)) then + incHeight = height * (1+overhealperc/100) - healthHeight + end + + if this.config.invert_healthbar == "1" then + this.incHeal:SetHeight(incHeight) + else + this.incHeal:SetHeight(incHeight + healthHeight) + end + end + + this.incHeal:Show() + else + this.incHeal:Hide() + end + end + + -- update ressurections + local ress = libpredict:UnitHasIncomingResurrection(unit) + if ress and UnitIsDeadOrGhost(unit) then + this.ressIcon:Show() + else + this.ressIcon:Hide() + end + end + end + end + + -- ============================================================================ + -- EVENT-BASED UPDATES (Health, Mana, Auras, etc.) + -- ============================================================================ + + -- Check if we have pending updates from events + local hasUpdates = this.update_full or this.update_base or + this.update_aura or this.update_portrait or + this.update_pvp or this.update_indicators + + -- Track event-triggered updates (not API calls, just frame updates) + if hasUpdates and pfUI.uf.stats and pfUI.uf.stats.enabled then + pfUI.uf.stats.eventUpdates = pfUI.uf.stats.eventUpdates + 1 + end + + -- Heartbeat Polling: If no events pending, check if we need fallback + if not hasUpdates then + local timeSinceEvent = this.lastEventUpdate and (now - this.lastEventUpdate) or 999 + + -- If >0.5s since last event and unit exists, try heartbeat + if timeSinceEvent > 0.5 and this.label and _G.UnitExists(this.label .. this.id) then + local needsFallback = false + + -- Check if Nampower can provide data + if GetUnitField then + -- Use _G.UnitExists to avoid conflicts with range checking + local unitstr = this.label .. this.id + local exists = _G.UnitExists(unitstr) + if exists then + local _, guid = _G.UnitExists(unitstr) + if guid then + local hp = GetUnitField(guid, "health") + if not hp or hp == 0 then + needsFallback = true + end + else + needsFallback = true + end + else + needsFallback = true + end + else + needsFallback = true + end + + if needsFallback then + -- GLOBAL Throttle: Limit fallback updates across ALL frames + local throttle = pfUI.uf.fallbackThrottle + + -- Reset counter each interval + if now - throttle.lastUpdate > throttle.interval then + throttle.lastUpdate = now + throttle.updatesThisInterval = 0 + end + + -- Check if we've exceeded max updates this interval + if throttle.updatesThisInterval >= throttle.maxUpdatesPerInterval then + if pfUI.uf.stats and pfUI.uf.stats.enabled then + pfUI.uf.stats.earlyReturns = pfUI.uf.stats.earlyReturns + 1 + end + return + end + + throttle.updatesThisInterval = throttle.updatesThisInterval + 1 + + -- Nampower not available or no data - trigger fallback update + this.update_base = true + if pfUI.uf.stats and pfUI.uf.stats.enabled then + pfUI.uf.stats.heartbeatUpdates = pfUI.uf.stats.heartbeatUpdates + 1 + end + else + -- Nampower working fine, no update needed + if pfUI.uf.stats and pfUI.uf.stats.enabled then + pfUI.uf.stats.earlyReturns = pfUI.uf.stats.earlyReturns + 1 + end + return + end + else + -- Too soon or unit doesn't exist + if pfUI.uf.stats and pfUI.uf.stats.enabled then + pfUI.uf.stats.earlyReturns = pfUI.uf.stats.earlyReturns + 1 + end + return + end + end + -- process indicator update events if this.update_indicators then pfUI.uf:RefreshIndicators(this) @@ -1014,7 +1628,6 @@ function pfUI.uf.OnUpdate() if this.update_pvp then pfUI.uf:RefreshUnit(this, "pvp") this.update_pvp = nil - this.update_base = true end if this.update_base then @@ -1077,126 +1690,6 @@ function pfUI.uf.OnUpdate() this.portrait.model:SetCamera(0) this.portrait.model.update = nil end - - -- get incoming heals and resurections - if libpredict then - local unit = this.label .. this.id - local heal = libpredict:UnitGetIncomingHeals(unit) - local ress = libpredict:UnitHasIncomingResurrection(unit) - local health, maxHealth = UnitHealth(unit), UnitHealthMax(unit) - - if heal - health - maxHealth ~= this.predictstate then - local overhealperc = tonumber(this.config.overhealperc) - this.predictstate = heal - health - maxHealth - - if heal > 0 and (health < maxHealth or overhealperc > 0 ) then - local width = this.config.width - local height = this.config.height - - if this.config.verticalbar == "0" then - local healthWidth = width * (health / maxHealth) - local incWidth = width * heal / maxHealth - if healthWidth + incWidth > width * (1+(overhealperc/100)) then - incWidth = width * (1+overhealperc/100) - healthWidth - end - - if this.config.invert_healthbar == "1" then - this.incHeal:SetWidth(incWidth) - else - this.incHeal:SetWidth(incWidth + healthWidth) - end - else - local healthHeight = height * (health / maxHealth) - local incHeight = height * heal / maxHealth - if healthHeight + incHeight > height * (1+(overhealperc/100)) then - incHeight = height * (1+overhealperc/100) - healthHeight - end - - if this.config.invert_healthbar == "1" then - this.incHeal:SetHeight(incHeight) - else - this.incHeal:SetHeight(incHeight + healthHeight) - end - end - - this.incHeal:Show() - else - this.incHeal:Hide() - end - end - - -- update ressurections - if ress and UnitIsDeadOrGhost(unit) then - this.ressIcon:Show() - else - this.ressIcon:Hide() - end - end - - -- Combat/Aggro Indicators (separate throttle, works for all frames including player) - if not this.lastCombatCheck then this.lastCombatCheck = GetTime() + 0.2 end - if this.lastCombatCheck < GetTime() then - this.lastCombatCheck = GetTime() + 0.2 - - if this.config.squareaggro == "1" and pfUI.api.UnitHasAggro(this.label .. this.id) > 0 then - this.combat.tex:SetTexture(1,.2,0) - this.combat:Show() - elseif this.config.squarecombat == "1" and UnitAffectingCombat(this.label .. this.id) then - this.combat.tex:SetTexture(1,1,.2) - this.combat:Show() - else - this.combat:Hide() - end - - if this.config.glowaggro == "1" and pfUI.api.UnitHasAggro(this.label .. this.id) > 0 then - this.glow:SetBackdropBorderColor(1,.2,0) - this.glow:Show() - elseif this.config.glowcombat == "1" and UnitAffectingCombat(this.label .. this.id) then - this.glow:SetBackdropBorderColor(1,1,.2) - this.glow:Show() - else - this.glow:Hide() - end - end - - -- trigger eventless actions (online/offline/range) - -- Validate tick value - it should be an interval (e.g. 0.5), not a timestamp - local tickInterval = this.tick - if tickInterval and tickInterval > 10 then - tickInterval = nil - end - -- Reset lastTick if it's invalid (much larger than now) - local now = GetTime() - if this.lastTick and this.lastTick > now + 10 then - this.lastTick = nil - end - if not this.lastTick then this.lastTick = now + (tickInterval or .5) end - if this.lastTick and this.lastTick < now then - local unitstr = this.label .. this.id - - this.lastTick = now + (tickInterval or .5) - - -- target target has a huge delay, make sure to not tick during range checks - -- by waiting for a stable name over three ticks otherwise aborting the update. - if this.label == "targettarget" or this.label == "targettargettarget" then - local name = UnitName(this.label) - if name ~= this.namebuf1 then - this.namebuf1 = name - return - elseif name ~= this.namebuf2 then - this.namebuf2 = name - return - end - end - - pfUI.uf:RefreshUnitState(this) - pfUI.uf:RefreshIndicators(this) - - -- update everything on eventless frames (targettarget, etc) - if this.tick then - pfUI.uf:RefreshUnit(this, "all") - end - end end function pfUI.uf.OnEnter() @@ -1241,6 +1734,7 @@ function pfUI.uf:EnableEvents() local f = self f:RegisterEvent("PLAYER_ENTERING_WORLD") + f:RegisterEvent("PLAYER_LOGOUT") f:RegisterEvent("UNIT_DISPLAYPOWER") f:RegisterEvent("UNIT_HEALTH") f:RegisterEvent("UNIT_MAXHEALTH") @@ -1328,14 +1822,14 @@ function pfUI.uf:CreateUnitFrame(unit, id, config, tick) f.GetColor = pfUI.uf.GetColor -- cache values to the frame - f.label = unit + f.label = strlower(unit) f.fname = fname f.id = id f.config = config or pfUI_config.unitframes.fallback f.tick = tick -- disable events for unknown unitstrings - if not pfValidUnits[unit .. id] then + if not pfValidUnits[strlower(unit) .. id] then f.unitname = unit f.label, f.id = "", "" f.RegisterEvent = function() return end @@ -1916,18 +2410,25 @@ function pfUI.uf:RefreshUnit(unit, component) if texture then local timeleft, name, _ if libdebuff then - name, _, texture, _, _, _, timeleft = libdebuff:UnitDebuff(unitstr, i) + -- Use UnitOwnDebuff if "show only own debuffs" is enabled + if unit.config.selfdebuff == "1" then + name, _, texture, _, _, _, timeleft = libdebuff:UnitOwnDebuff(unitstr, i) + else + name, _, texture, _, _, _, timeleft = libdebuff:UnitDebuff(unitstr, i) + end else scanner:SetUnitDebuff(unitstr, i) name = scanner:Line(1) or "" end -- match filter - for _, filter in pairs(unit.indicator_custom) do - if filter == string.lower(name) then - pfUI.uf:AddIcon(unit, pos, texture, timeleft, count) - pos = pos + 1 - break + if name then + for _, filter in pairs(unit.indicator_custom) do + if filter == string.lower(name) then + pfUI.uf:AddIcon(unit, pos, texture, timeleft, count) + pos = pos + 1 + break + end end end end @@ -1985,9 +2486,11 @@ function pfUI.uf:RefreshUnit(unit, component) -- base frame if component == "all" or component == "base" then - -- Unit HP/MP - local hp, hpmax = UnitHealth(unitstr), UnitHealthMax(unitstr) - local power, powermax = UnitMana(unitstr), UnitManaMax(unitstr) + -- Unit HP/MP with Nampower Integration + local hp, hpmax, power, powermax, powerType = pfUI.api.GetUnitStats(unitstr, true) + + -- Store original values for color calculations (before invert_healthbar modifies hp) + local hp_orig, hpmax_orig = hp, hpmax if unit.config.invert_healthbar == "1" then hp = hpmax - hp @@ -2007,7 +2510,8 @@ function pfUI.uf:RefreshUnit(unit, component) local custom = unit.config.defcolor == "0" and unit.config.custom or C.unitframes.custom local r, g, b, a = .2, .2, .2, 1 - if customfullhp == "1" and UnitHealth(unitstr) == UnitHealthMax(unitstr) then + -- O(1) optimization: use cached hp/hpmax instead of UnitHealth()/UnitHealthMax() API calls + if customfullhp == "1" and hp_orig == hpmax_orig then r, g, b, a = GetStringColor(customcolor) custom_active = true elseif custom == "0" then @@ -2032,8 +2536,9 @@ function pfUI.uf:RefreshUnit(unit, component) r, g, b, a = GetStringColor(customcolor) custom_active = true elseif custom == "2" then - if UnitHealthMax(unitstr) > 0 then - r, g, b = GetColorGradient(UnitHealth(unitstr) / UnitHealthMax(unitstr)) + -- O(1) optimization: use cached hp/hpmax instead of UnitHealth()/UnitHealthMax() API calls + if hpmax_orig > 0 then + r, g, b = GetColorGradient(hp_orig / hpmax_orig) else r, g, b = 0, 0, 0 end @@ -2045,7 +2550,8 @@ function pfUI.uf:RefreshUnit(unit, component) if customfade == "1" then -- fade custom color into default color - local perc = UnitHealth(unitstr) / UnitHealthMax(unitstr) + -- O(1) optimization: use cached hp/hpmax instead of UnitHealth()/UnitHealthMax() API calls + local perc = hpmax_orig > 0 and (hp_orig / hpmax_orig) or 0 local cr, cg, cb, ca = GetStringColor(customcolor) r = (cr*perc) + (r*(1-perc)) @@ -2241,7 +2747,18 @@ function pfUI.uf:AddIcon(frame, pos, icon, timeleft, stacks, start, duration) frame.icon[pos].stacks:SetPoint("BOTTOMRIGHT", 0, 0) frame.icon[pos].stacks:SetJustifyH("RIGHT") frame.icon[pos].stacks:SetJustifyV("BOTTOM") - frame.icon[pos].cd = CreateFrame(COOLDOWN_FRAME_TYPE, nil, frame.icon[pos]) + + -- Check if parent frame has cooldown animation enabled + local parent_cooldown_anim = frame.config and tonumber(frame.config.cooldown_anim) or 1 + if parent_cooldown_anim == 1 then + frame.icon[pos].cd = CreateFrame(COOLDOWN_FRAME_TYPE, nil, frame.icon[pos]) + else + frame.icon[pos].cd = CreateFrame("Frame", nil, frame.icon[pos]) + frame.icon[pos].cd.AdvanceTime = DoNothing + frame.icon[pos].cd.SetSequence = DoNothing + frame.icon[pos].cd.SetSequenceTime = DoNothing + end + frame.icon[pos].cd.pfCooldownStyleAnimation = 0 frame.icon[pos].cd.pfCooldownType = "ALL" frame.icon[pos].cd:SetFrameLevel(48) @@ -2552,11 +3069,11 @@ function pfUI.uf:GetStatusValue(unit, pos) config = "unit" end - - local mp, mpmax = UnitMana(unitstr), UnitManaMax(unitstr) - local hp, hpmax = UnitHealth(unitstr), UnitHealthMax(unitstr) + -- Get stats with Nampower Integration + local hp, hpmax, mp, mpmax, powerType = pfUI.api.GetUnitStats(unitstr, true) local rhp, rhpmax = hp, hpmax + -- Use libhealth for mob health estimation (overrides Nampower/Standard) if pfUI.libhealth and pfUI.libhealth.enabled then rhp, rhpmax = pfUI.libhealth:GetUnitHealth(unitstr) elseif unit.label == "target" and (MobHealth3 or MobHealthFrame) and MobHealth_GetTargetCurHP() then @@ -2705,8 +3222,21 @@ function pfUI.uf.GetColor(self, preset) b = UnitReactionColor[UnitReaction(unitstr, "player")].b elseif preset == "health" and config["healthcolor"] == "1" then - if UnitHealthMax(unitstr) > 0 then - r, g, b = GetColorGradient(UnitHealth(unitstr) / UnitHealthMax(unitstr)) + -- O(1) Nampower lookup for health gradient color + local hp, hpmax + if GetUnitField then + local _, guid = UnitExists(unitstr) + if guid then + hp = GetUnitField(guid, "health") + hpmax = GetUnitField(guid, "maxHealth") + end + end + -- Fallback to standard API + if not hp or not hpmax then + hp, hpmax = UnitHealth(unitstr), UnitHealthMax(unitstr) + end + if hpmax and hpmax > 0 then + r, g, b = GetColorGradient(hp / hpmax) else r, g, b = 0, 0, 0 end @@ -2727,3 +3257,62 @@ function pfUI.uf.GetColor(self, preset) return rgbhex(r,g,b) end + +-- ============================================================================ +-- Slash Commands for Stats Frame +-- ============================================================================ +_G.SLASH_PFUISTATS1 = "/pfuistats" +_G.SLASH_PFUISTATS2 = "/ufstats" +_G.SlashCmdList["PFUISTATS"] = function(msg) + msg = string.lower(msg or "") + + if not pfUI.uf.stats then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ERROR:|r Stats not initialized!") + return + end + + -- Initialize startTime on first use + if pfUI.uf.stats.startTime == 0 then + pfUI.uf.stats.startTime = GetTime() + end + + if msg == "reset" then + pfUI.uf.stats.eventUpdates = 0 + pfUI.uf.stats.heartbeatUpdates = 0 + pfUI.uf.stats.earlyReturns = 0 + pfUI.uf.stats.nampowerUsed = 0 + pfUI.uf.stats.fallbackUsed = 0 + pfUI.uf.stats.throttledSkips = 0 + pfUI.uf.stats.startTime = GetTime() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00pfUI Stats:|r Reset!") + + elseif msg == "toggle" then + pfUI.uf.stats.enabled = not pfUI.uf.stats.enabled + local status = pfUI.uf.stats.enabled and "|cff00ff00ON|r" or "|cffff0000OFF|r" + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00pfUI Stats:|r Tracking: " .. status) + + elseif msg == "show" then + if pfUI.uf.statsFrame then + pfUI.uf.statsFrame:Show() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00pfUI Stats:|r Frame shown") + end + + elseif msg == "hide" then + if pfUI.uf.statsFrame then + pfUI.uf.statsFrame:Hide() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00pfUI Stats:|r Frame hidden") + end + + else + -- Toggle frame (default action) + if pfUI.uf.statsFrame then + if pfUI.uf.statsFrame:IsShown() then + pfUI.uf.statsFrame:Hide() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00pfUI Stats:|r Frame hidden") + else + pfUI.uf.statsFrame:Show() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00pfUI Stats:|r Frame shown") + end + end + end +end \ No newline at end of file diff --git a/env/translations_zhCN.lua b/env/translations_zhCN.lua index 4dfaf4bc..b41108db 100644 --- a/env/translations_zhCN.lua +++ b/env/translations_zhCN.lua @@ -700,6 +700,7 @@ pfUI_translation["zhCN"] = { ["Show Empty Buttons"] = "显示空按钮", ["Show FPS and Latency Colors"] = "显示帧数以及延迟颜色", ["Show Guild Name"] = "显示公会名称", + ["Show Player Levels"] ="显示玩家等级", ["Show Happiness Icon"] = "显示高兴值图标", ["Show Health Points"] = "显示生命值", ["Show/Hide TimeManager"] = "显示/隐藏时间管理器", diff --git a/libs/libdebuff.lua b/libs/libdebuff.lua index 4f217703..b3ce9761 100644 --- a/libs/libdebuff.lua +++ b/libs/libdebuff.lua @@ -1,14 +1,19 @@ -- load pfUI environment setfenv(1, pfUI:GetEnvironment()) ---[[ libdebuff ]]-- +--[[ libdebuff - GetUnitField Edition ]]-- -- A pfUI library that detects and saves all ongoing debuffs of players, NPCs and enemies. --- The functions UnitDebuff is exposed to the modules which allows to query debuffs like you --- would on later expansions. +-- +-- MAJOR REWRITE: Now uses GetUnitField for slot mapping instead of manual shifting. +-- Key insight: GetUnitField returns STABLE aura slots (33-48) that DON'T shift when +-- debuffs expire. Only the display slots (UnitDebuff returns 1,2,3...) are compacted. +-- +-- This eliminates ~400 lines of error-prone shift logic while maintaining full +-- multi-caster tracking support. -- -- libdebuff:UnitDebuff(unit, id) -- Returns debuff informations on the given effect of the specified unit. --- name, rank, texture, stacks, dtype, duration, timeleft +-- name, rank, texture, stacks, dtype, duration, timeleft, caster -- return instantly if we're not on a vanilla client if pfUI.client > 11200 then return end @@ -26,18 +31,334 @@ local scanner = libtipscan:GetScanner("libdebuff") local _, class = UnitClass("player") local lastspell --- Speichert die Ranks der zuletzt gecasteten Spells (bleibt länger als pending) -local lastCastRanks = {} +-- Nampower Support +local hasNampower = false --- Speichert Spells die gefailed sind (miss/dodge/parry/etc.) für 1 Sekunde -local lastFailedSpells = {} +-- Set hasNampower immediately for functionality +if GetNampowerVersion then + local major, minor, patch = GetNampowerVersion() + patch = patch or 0 + -- Minimum required version: 2.27.2 (SPELL_FAILED_OTHER fix) + if major > 2 or (major == 2 and minor > 27) or (major == 2 and minor == 27 and patch >= 2) then + hasNampower = true + end +end + +-- Delayed Nampower version check (5 seconds after PLAYER_ENTERING_WORLD) +local nampowerCheckFrame = CreateFrame("Frame") +local nampowerCheckTimer = 0 +local nampowerCheckDone = false +nampowerCheckFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +nampowerCheckFrame:RegisterEvent("PLAYER_LOGOUT") +nampowerCheckFrame:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + this:SetScript("OnUpdate", nil) + return + end + + nampowerCheckFrame:SetScript("OnUpdate", function() + nampowerCheckTimer = nampowerCheckTimer + arg1 + if nampowerCheckTimer >= 5 and not nampowerCheckDone then + nampowerCheckDone = true + + if GetNampowerVersion then + local major, minor, patch = GetNampowerVersion() + patch = patch or 0 + local versionString = major .. "." .. minor .. "." .. patch + + if major > 2 or (major == 2 and minor > 27) or (major == 2 and minor == 27 and patch >= 2) then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r Nampower v" .. versionString .. " detected - GetUnitField mode enabled!") + + -- Enable required Nampower CVars + if SetCVar and GetCVar then + local cvarsToEnable = { + "NP_EnableSpellStartEvents", + "NP_EnableSpellGoEvents", + "NP_EnableAuraCastEvents", + "NP_EnableAutoAttackEvents" + } + + local totalCvars = table.getn(cvarsToEnable) + local enabledCount = 0 + local alreadyEnabledCount = 0 + local failedCount = 0 + + for _, cvar in ipairs(cvarsToEnable) do + local success, currentValue = pcall(GetCVar, cvar) + if success and currentValue then + if currentValue == "1" then + alreadyEnabledCount = alreadyEnabledCount + 1 + else + local setSuccess = pcall(SetCVar, cvar, "1") + if setSuccess then + enabledCount = enabledCount + 1 + else + failedCount = failedCount + 1 + end + end + else + failedCount = failedCount + 1 + end + end + + if enabledCount > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r Enabled " .. enabledCount .. " Nampower CVars") + end + + if alreadyEnabledCount == totalCvars then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r All required Nampower CVars already enabled") + elseif alreadyEnabledCount > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r " .. alreadyEnabledCount .. " CVars were already enabled") + end + + if failedCount > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00[libdebuff]|r Warning: Could not check/set " .. failedCount .. " CVars") + end + end + + elseif major == 2 and minor == 27 and patch == 1 then + DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00[libdebuff] WARNING: Nampower v2.27.1 detected!|r") + DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00[libdebuff] Please update to v2.27.2 or higher!|r") + StaticPopup_Show("LIBDEBUFF_NAMPOWER_UPDATE", versionString) + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff] Debuff tracking disabled! Please update Nampower to v2.27.2 or higher.|r") + StaticPopup_Show("LIBDEBUFF_NAMPOWER_UPDATE", versionString) + end + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff] Nampower not found! Debuff tracking disabled.|r") + StaticPopup_Show("LIBDEBUFF_NAMPOWER_MISSING") + end + + nampowerCheckFrame:SetScript("OnUpdate", nil) + end + end) +end) + +-- ============================================================================ +-- DATA STRUCTURES (Simplified - no more manual slot tracking!) +-- ============================================================================ + +-- ownDebuffs: [targetGUID][spellName] = {startTime, duration, texture, rank} +-- Timer data for OUR debuffs only +pfUI.libdebuff_own = pfUI.libdebuff_own or {} +local ownDebuffs = pfUI.libdebuff_own + +-- allAuraCasts: [targetGUID][spellName][casterGuid] = {startTime, duration, rank} +-- Timer data for ALL debuffs (multi-caster support) +pfUI.libdebuff_all_auras = pfUI.libdebuff_all_auras or {} +local allAuraCasts = pfUI.libdebuff_all_auras + +-- slotOwnership: [targetGUID][auraSlot] = {casterGuid, spellName, spellId} +-- Maps REAL aura slots (33-48) to caster info - NO SHIFTING NEEDED! +pfUI.libdebuff_slot_ownership = pfUI.libdebuff_slot_ownership or {} +local slotOwnership = pfUI.libdebuff_slot_ownership + +-- displayToAura: [targetGUID][displaySlot] = auraSlot +-- Maps DISPLAY slots (1-16) to REAL aura slots (33-48) for DEBUFF_REMOVED correlation +pfUI.libdebuff_display_to_aura = pfUI.libdebuff_display_to_aura or {} +local displayToAura = pfUI.libdebuff_display_to_aura + +-- pendingCasts: [targetGUID][spellName] = {casterGuid, rank, time} +-- Temporary storage from SPELL_GO to correlate with DEBUFF_ADDED +pfUI.libdebuff_pending = pfUI.libdebuff_pending or {} +local pendingCasts = pfUI.libdebuff_pending + +-- Spell Icon Cache: [spellId] = texture +pfUI.libdebuff_icon_cache = pfUI.libdebuff_icon_cache or {} +local iconCache = pfUI.libdebuff_icon_cache + +-- Cast Tracking: [casterGuid] = {spellID, spellName, icon, startTime, duration, endTime} +-- Shared with nameplates for cast-bar display +pfUI.libdebuff_casts = pfUI.libdebuff_casts or {} + +-- Cleveroids API: [targetGUID][spellID] = {start, duration, caster, stacks} +pfUI.libdebuff_objects_guid = pfUI.libdebuff_objects_guid or {} +local objectsByGuid = pfUI.libdebuff_objects_guid + +-- LEGACY: Keep these for backwards compatibility (external modules might check them) +pfUI.libdebuff_own_slots = pfUI.libdebuff_own_slots or {} +pfUI.libdebuff_all_slots = pfUI.libdebuff_all_slots or {} + +-- Deduplication: Track recent AURA_CAST events to ignore duplicates +-- [targetGuid][spellName][casterGuid] = timestamp +pfUI.libdebuff_recent_casts = pfUI.libdebuff_recent_casts or {} +local recentCasts = pfUI.libdebuff_recent_casts +local AURA_CAST_DEDUPE_WINDOW = 0.1 -- Ignore duplicates within 100ms + +-- ============================================================================ +-- STATIC POPUP DIALOGS +-- ============================================================================ + +StaticPopupDialogs["LIBDEBUFF_NAMPOWER_UPDATE"] = { + text = "Nampower Update Required!\n\nYour current version: %s\nRequired version: 2.27.2+\n\nPlease update Nampower!", + button1 = "OK", + timeout = 0, + whileDead = 1, + hideOnEscape = 1, + preferredIndex = 3, + OnAccept = function() + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r Download: https://gitea.com/avitasia/nampower/releases/tag/v2.27.2") + end, +} + +StaticPopupDialogs["LIBDEBUFF_NAMPOWER_MISSING"] = { + text = "Nampower Not Found!\n\nNampower 2.27.2+ is required for pfUI Enhanced debuff tracking.\n\nPlease install Nampower.", + button1 = "OK", + timeout = 0, + whileDead = 1, + hideOnEscape = 1, + preferredIndex = 3, +} + +-- ============================================================================ +-- SPELL DATA TABLES +-- ============================================================================ + +-- Debuffs that only ONE player can have on target (overwrites other casters) +local selfOverwriteDebuffs = { + ["Faerie Fire"] = true, + ["Faerie Fire (Feral)"] = true, + ["Demoralizing Shout"] = true, + ["Demoralizing Roar"] = true, + ["Hunter's Mark"] = true, + ["Sunder Armor"] = true, + ["Thunder Clap"] = true, + ["Expose Armor"] = true, + ["Curse of Weakness"] = true, + ["Curse of Recklessness"] = true, + ["Curse of the Elements"] = true, + ["Curse of Shadow"] = true, + ["Curse of Tongues"] = true, + ["Curse of Exhaustion"] = true, + ["Judgement of Wisdom"] = true, + ["Judgement of Light"] = true, + ["Judgement of the Crusader"] = true, + ["Judgement of Justice"] = true, + ["Shadow Weaving"] = true, + ["Winter's Chill"] = true, +} + +-- Debuff pairs that overwrite each other +local debuffOverwritePairs = { + ["Faerie Fire"] = "Faerie Fire (Feral)", + ["Faerie Fire (Feral)"] = "Faerie Fire", + ["Demoralizing Shout"] = "Demoralizing Roar", + ["Demoralizing Roar"] = "Demoralizing Shout", +} + +-- Combopoint-based abilities: Only show timers for OUR casts +local combopointAbilities = { + ["Rip"] = true, + ["Rupture"] = true, + ["Kidney Shot"] = true, + ["Slice and Dice"] = true, + ["Expose Armor"] = true, +} + +-- ============================================================================ +-- HELPER FUNCTIONS +-- ============================================================================ -- Combo Points Tracking local currentComboPoints = 0 local lastSpentComboPoints = 0 local lastSpentTime = 0 --- Prüft ob ein Spell kürzlich gefailed ist (öffentliche Funktion für andere Module) +local function GetStoredComboPoints() + if lastSpentComboPoints > 0 and (GetTime() - lastSpentTime) < 1 then + return lastSpentComboPoints + end + return 0 +end + +-- Player GUID Cache +local playerGUID = nil +local function GetPlayerGUID() + if not playerGUID and UnitExists then + local _, guid = UnitExists("player") + playerGUID = guid + end + return playerGUID +end + +-- Debug Stats +pfUI.libdebuff_debugstats = pfUI.libdebuff_debugstats or { + enabled = false, + trackAllUnits = false, + aura_cast = 0, + debuff_added = 0, + debuff_removed = 0, + getunitfield_calls = 0, +} +local debugStats = pfUI.libdebuff_debugstats + +local function DebugGuid(guid) + if not guid then return "nil" end + local str = tostring(guid) + if string.len(str) > 4 then + return string.sub(str, -4) + end + return str +end + +local function IsCurrentTarget(guid) + if debugStats.trackAllUnits then return true end + if not guid or not UnitExists then return false end + local _, targetGuid = UnitExists("target") + return targetGuid == guid +end + +local function GetDebugTimestamp() + return string.format("[%.3f]", GetTime()) +end + +-- Speichert die Ranks der zuletzt gecasteten Spells +pfUI.libdebuff_lastranks = pfUI.libdebuff_lastranks or {} +local lastCastRanks = pfUI.libdebuff_lastranks + +-- Speichert Spells die gefailed sind +pfUI.libdebuff_lastfailed = pfUI.libdebuff_lastfailed or {} +local lastFailedSpells = pfUI.libdebuff_lastfailed + +-- Get spell icon texture (with caching) +function libdebuff:GetSpellIcon(spellId) + if not spellId or type(spellId) ~= "number" or spellId <= 0 then + return "Interface\\Icons\\INV_Misc_QuestionMark" + end + + if iconCache[spellId] then + return iconCache[spellId] + end + + local texture = nil + + if GetSpellRecField and GetSpellIconTexture then + local spellIconId = GetSpellRecField(spellId, "spellIconID") + if spellIconId and type(spellIconId) == "number" and spellIconId > 0 then + texture = GetSpellIconTexture(spellIconId) + end + end + + if not texture and SpellInfo then + local _, _, spellTexture = SpellInfo(spellId) + texture = spellTexture + end + + if not texture then + texture = "Interface\\Icons\\INV_Misc_QuestionMark" + end + + iconCache[spellId] = texture + return texture +end + +pfUI.libdebuff_GetSpellIcon = function(spellId) + return libdebuff:GetSpellIcon(spellId) +end + function libdebuff:DidSpellFail(spell) if not spell then return false end local data = lastFailedSpells[spell] @@ -47,53 +368,235 @@ function libdebuff:DidSpellFail(spell) return false end --- Gibt die zuletzt ausgegebenen Combo Points zurück (innerhalb 1 Sekunde) -local function GetStoredComboPoints() - if lastSpentComboPoints > 0 and (GetTime() - lastSpentTime) < 1 then - return lastSpentComboPoints +-- ============================================================================ +-- CORE: GetUnitField-based Slot Mapping (THE KEY INNOVATION!) +-- ============================================================================ + +-- Cache for GetDebuffSlotMap to reduce GetUnitField calls +-- [guid] = {map, timestamp} +local slotMapCache = {} +local SLOT_MAP_CACHE_DURATION = 0.05 -- 50ms cache (1-2 frames) + +-- Get current debuff state directly from WoW via GetUnitField +-- Returns: { [displaySlot] = {auraSlot, spellId, spellName} } +local function GetDebuffSlotMap(guid) + if not guid or not GetUnitField or not SpellInfo then + return nil end - return 0 + + -- Check cache first + local now = GetTime() + local cached = slotMapCache[guid] + if cached and (now - cached.timestamp) < SLOT_MAP_CACHE_DURATION then + return cached.map + end + + local auras = GetUnitField(guid, "aura") + if not auras then return nil end + + if debugStats.enabled then + debugStats.getunitfield_calls = debugStats.getunitfield_calls + 1 + end + + local map = {} + local displaySlot = 0 + + -- Debuff aura slots are 33-48 + for auraSlot = 33, 48 do + local spellId = auras[auraSlot] + if spellId and spellId > 0 then + displaySlot = displaySlot + 1 + local spellName = SpellInfo(spellId) + + map[displaySlot] = { + auraSlot = auraSlot, + spellId = spellId, + spellName = spellName or "Unknown" + } + end + end + + -- Cache the result + slotMapCache[guid] = { + map = map, + timestamp = now + } + + return map end --- Shared Debuffs: Diese werden von allen Spielern geteilt (nur einer kann drauf sein) --- Timer darf von anderen Spielern aktualisiert werden -local sharedDebuffs = { - -- Warrior - ["Sunder Armor"] = true, - ["Demoralizing Shout"] = true, - ["Thunder Clap"] = true, +-- Get caster info for a specific aura slot +local function GetSlotCaster(guid, auraSlot, spellName) + -- First check our ownership tracking + if slotOwnership[guid] and slotOwnership[guid][auraSlot] then + local ownership = slotOwnership[guid][auraSlot] + -- Verify spell name matches (slot might have been reused) + if ownership.spellName == spellName then + return ownership.casterGuid, ownership.isOurs + end + end - -- Rogue - ["Expose Armor"] = true, + -- Fallback: Check ownDebuffs + local myGuid = GetPlayerGUID() + if ownDebuffs[guid] and ownDebuffs[guid][spellName] then + return myGuid, true + end - -- Druid - ["Faerie Fire"] = true, - ["Faerie Fire (Feral)"] = true, + -- Fallback: Check allAuraCasts for any caster + if allAuraCasts[guid] and allAuraCasts[guid][spellName] then + for casterGuid, data in pairs(allAuraCasts[guid][spellName]) do + local timeleft = (data.startTime + data.duration) - GetTime() + if timeleft > 0 then + return casterGuid, (casterGuid == myGuid) + end + end + end - -- Hunter - ["Hunter's Mark"] = true, + return nil, false +end + +-- ============================================================================ +-- CLEANUP FUNCTIONS +-- ============================================================================ + +local lastRangeCheck = 0 + +local function CleanupUnit(guid) + if not guid then return false end - -- Warlock Curses (nur eine pro Typ kann auf Target sein) - ["Curse of Weakness"] = true, - ["Curse of Recklessness"] = true, - ["Curse of the Elements"] = true, - ["Curse of Shadow"] = true, - ["Curse of Tongues"] = true, - ["Curse of Exhaustion"] = true, - -- NICHT: Curse of Agony, Curse of Doom (jeder Warlock hat seinen eigenen!) + local cleaned = false - -- Priest - ["Shadow Weaving"] = true, + if ownDebuffs[guid] then + ownDebuffs[guid] = nil + cleaned = true + end - -- Mage - ["Winter's Chill"] = true, + if slotOwnership[guid] then + slotOwnership[guid] = nil + cleaned = true + end - -- Paladin Judgements - ["Judgement of Wisdom"] = true, - ["Judgement of Light"] = true, - ["Judgement of the Crusader"] = true, - ["Judgement of Justice"] = true, -} + if allAuraCasts[guid] then + allAuraCasts[guid] = nil + cleaned = true + end + + if objectsByGuid[guid] then + objectsByGuid[guid] = nil + cleaned = true + end + + if pendingCasts[guid] then + pendingCasts[guid] = nil + cleaned = true + end + + if debugStats.enabled and cleaned and IsCurrentTarget(guid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[CLEANUP]|r GUID %s", DebugGuid(guid))) + end + + return cleaned +end + +local function CleanupExpiredTimers(guid) + local now = GetTime() + + -- Cleanup ownDebuffs + if ownDebuffs[guid] then + local toDelete = {} + for spellName, data in pairs(ownDebuffs[guid]) do + local timeleft = (data.startTime + data.duration) - now + if timeleft < -2 then -- Grace period + table.insert(toDelete, spellName) + end + end + for _, spellName in ipairs(toDelete) do + ownDebuffs[guid][spellName] = nil + end + end + + -- Cleanup allAuraCasts + if allAuraCasts[guid] then + for spellName, casterTable in pairs(allAuraCasts[guid]) do + local castersToDelete = {} + for casterGuid, data in pairs(casterTable) do + local timeleft = (data.startTime + data.duration) - now + if timeleft < -2 then + table.insert(castersToDelete, casterGuid) + end + end + for _, casterGuid in ipairs(castersToDelete) do + allAuraCasts[guid][spellName][casterGuid] = nil + end + -- Remove empty spell tables + local hasCasters = false + for _ in pairs(allAuraCasts[guid][spellName]) do + hasCasters = true + break + end + if not hasCasters then + allAuraCasts[guid][spellName] = nil + end + end + end +end + +local function CleanupOutOfRangeUnits() + local now = GetTime() + if now - lastRangeCheck < 10 then return end + lastRangeCheck = now + + local allGuids = {} + for guid in pairs(ownDebuffs) do allGuids[guid] = true end + for guid in pairs(slotOwnership) do allGuids[guid] = true end + for guid in pairs(allAuraCasts) do allGuids[guid] = true end + for guid in pairs(objectsByGuid) do allGuids[guid] = true end + for guid in pairs(pendingCasts) do allGuids[guid] = true end + + for guid in pairs(allGuids) do + local exists = UnitExists and UnitExists(guid) + local isDead = UnitIsDead and UnitIsDead(guid) + + if not exists or isDead then + CleanupUnit(guid) + end + end + + -- Cleanup old lastCastRanks + for spell, data in pairs(lastCastRanks) do + if now - data.time > 3 then + lastCastRanks[spell] = nil + end + end + + -- Cleanup old lastFailedSpells + for spell, data in pairs(lastFailedSpells) do + if now - data.time > 2 then + lastFailedSpells[spell] = nil + end + end + + -- Cleanup old pendingCasts + for guid, spells in pairs(pendingCasts) do + for spell, data in pairs(spells) do + if now - data.time > 1 then + pendingCasts[guid][spell] = nil + end + end + local isEmpty = true + for _ in pairs(pendingCasts[guid]) do + isEmpty = false + break + end + if isEmpty then + pendingCasts[guid] = nil + end + end +end + +-- ============================================================================ +-- DURATION FUNCTIONS +-- ============================================================================ function libdebuff:GetDuration(effect, rank) if L["debuffs"][effect] then @@ -102,35 +605,27 @@ function libdebuff:GetDuration(effect, rank) local duration = L["debuffs"][effect][rank] if effect == L["dyndebuffs"]["Rupture"] then - -- Rupture: +2 sec per combo point local cp = GetComboPoints() or 0 if cp == 0 then cp = GetStoredComboPoints() end duration = duration + cp*2 elseif effect == L["dyndebuffs"]["Kidney Shot"] then - -- Kidney Shot: +1 sec per combo point local cp = GetComboPoints() or 0 if cp == 0 then cp = GetStoredComboPoints() end duration = duration + cp*1 elseif effect == "Rip" or effect == L["dyndebuffs"]["Rip"] then - -- Rip (Turtle WoW): 10s base + 2s per additional combo point - -- Base in table is 8, so: 8 + CP*2 = 10/12/14/16/18 local cp = GetComboPoints() or 0 if cp == 0 then cp = GetStoredComboPoints() end duration = 8 + cp*2 elseif effect == L["dyndebuffs"]["Demoralizing Shout"] then - -- Booming Voice: 10% per talent local _,_,_,_,count = GetTalentInfo(2,1) if count and count > 0 then duration = duration + ( duration / 100 * (count*10)) end elseif effect == L["dyndebuffs"]["Shadow Word: Pain"] then - -- Improved Shadow Word: Pain: +3s per talent local _,_,_,_,count = GetTalentInfo(3,4) if count and count > 0 then duration = duration + count * 3 end elseif effect == L["dyndebuffs"]["Frostbolt"] then - -- Permafrost: +1s per talent local _,_,_,_,count = GetTalentInfo(3,7) if count and count > 0 then duration = duration + count end elseif effect == L["dyndebuffs"]["Gouge"] then - -- Improved Gouge: +.5s per talent local _,_,_,_,count = GetTalentInfo(3,3) if count and count > 0 then duration = duration + (count*.5) end end @@ -140,15 +635,6 @@ function libdebuff:GetDuration(effect, rank) end end -function libdebuff:UpdateDuration(unit, unitlevel, effect, duration) - if not unit or not effect or not duration then return end - unitlevel = unitlevel or 0 - - if libdebuff.objects[unit] and libdebuff.objects[unit][unitlevel] and libdebuff.objects[unit][unitlevel][effect] then - libdebuff.objects[unit][unitlevel][effect].duration = duration - end -end - function libdebuff:GetMaxRank(effect) local max = 0 for id in pairs(L["debuffs"][effect]) do @@ -157,11 +643,27 @@ function libdebuff:GetMaxRank(effect) return max end +function libdebuff:UpdateDuration(unit, unitlevel, effect, duration) + if not unit or not effect or not duration then return end + unitlevel = unitlevel or 0 + + if libdebuff.objects[unit] and libdebuff.objects[unit][unitlevel] and libdebuff.objects[unit][unitlevel][effect] then + libdebuff.objects[unit][unitlevel][effect].duration = duration + end +end + function libdebuff:UpdateUnits() if not pfUI.uf or not pfUI.uf.target then return end pfUI.uf:RefreshUnit(pfUI.uf.target, "aura") end +-- ============================================================================ +-- LEGACY API (for turtle-wow.lua compatibility) +-- ============================================================================ + +libdebuff.pending = {} +libdebuff.objects = {} + function libdebuff:AddPending(unit, unitlevel, effect, duration, caster, rank) if not unit or duration <= 0 then return end if not L["debuffs"][effect] then return end @@ -197,295 +699,222 @@ function libdebuff:PersistPending(effect) libdebuff:RemovePending() end -function libdebuff:RevertLastAction() - if lastspell and lastspell.effect then - end - lastspell.start = lastspell.start_old - lastspell.start_old = nil - libdebuff:UpdateUnits() -end - function libdebuff:AddEffect(unit, unitlevel, effect, duration, caster, rank) - -- WORKAROUND: Wenn rank nil ist und wir einen eigenen Cast haben, hole rank aus lastCastRanks if not rank and caster == "player" and effect then - -- Erst aus pending versuchen if libdebuff.pending[3] == effect and libdebuff.pending[6] then rank = libdebuff.pending[6] - -- Dann aus lastCastRanks (bleibt länger) elseif lastCastRanks[effect] and (GetTime() - lastCastRanks[effect].time) < 2 then rank = lastCastRanks[effect].rank end end - if not unit or not effect then return end - - -- SCHUTZ: Wenn der Spell gerade gefailed ist (miss/dodge/parry/etc.), nicht anwenden - -- Nur für eigene Spells prüfen, nicht für andere Spieler - if caster == "player" and libdebuff:DidSpellFail(effect) then - return -- Spell hat nicht getroffen, keinen Timer setzen - end - + if not unit then return end unitlevel = unitlevel or 0 - if not libdebuff.objects[unit] then libdebuff.objects[unit] = {} end - if not libdebuff.objects[unit][unitlevel] then libdebuff.objects[unit][unitlevel] = {} end - if not libdebuff.objects[unit][unitlevel][effect] then libdebuff.objects[unit][unitlevel][effect] = {} end - - local existing = libdebuff.objects[unit][unitlevel][effect] + + -- Create tables if needed + libdebuff.objects[unit] = libdebuff.objects[unit] or {} + libdebuff.objects[unit][unitlevel] = libdebuff.objects[unit][unitlevel] or {} + + -- Get duration from spell database if not provided + if not duration or duration == 0 then + duration = libdebuff:GetDuration(effect, rank) + end + + -- Store/update effect local now = GetTime() + local existing = libdebuff.objects[unit][unitlevel][effect] - -- Wenn kein Caster übergeben wurde, behalte den existierenden (wichtig für Refresh-Mechaniken wie Ferocious Bite) - if not caster and existing.caster then - caster = existing.caster + if existing then + existing.start = now + existing.duration = duration + existing.caster = caster + existing.rank = rank + else + libdebuff.objects[unit][unitlevel][effect] = { + start = now, + duration = duration, + caster = caster, + rank = rank + } end - -- Wenn kein Rank übergeben wurde, behalte den existierenden - if not rank and existing.rank then - rank = existing.rank + lastspell = libdebuff.objects[unit][unitlevel][effect] +end + +-- ============================================================================ +-- MAIN API: UnitDebuff (GetUnitField-based) +-- ============================================================================ + +local cache = {} + +function libdebuff:UnitDebuff(unit, displaySlot) + local unitname = UnitName(unit) + local unitlevel = UnitLevel(unit) + local texture, stacks, dtype = UnitDebuff(unit, displaySlot) -- Blizzard API for texture/stacks + local duration, timeleft = nil, -1 + local rank = nil + local caster = nil + local effect = nil + + -- Get effect name from tooltip + if texture then + scanner:SetUnitDebuff(unit, displaySlot) + effect = scanner:Line(1) or "" end - - -- Prüfe ob ein existierender Debuff noch aktiv ist - local existingIsActive = existing.start and existing.duration and (existing.start + existing.duration) > now - - -- SCHUTZ: Wenn MEIN Debuff aktiv ist, darf ein anderer Spieler ihn NICHT überschreiben - -- AUSNAHME: Shared Debuffs (Sunder Armor, Curses, etc.) dürfen aktualisiert werden - if existingIsActive and existing.caster == "player" and caster ~= "player" then - if not sharedDebuffs[effect] then - return -- Blockiere das Update + + -- Nampower: Use GetUnitField for accurate slot mapping + if hasNampower and UnitExists and effect then + local _, guid = UnitExists(unit) + if not guid then + -- Fallback to legacy + return effect, rank, texture, stacks, dtype, duration, timeleft, caster end - end - - -- Rank-Prüfung wenn beide vom Player sind und beide Ranks bekannt sind - if existingIsActive and existing.rank and rank and existing.caster == "player" and caster == "player" then - -- Niedrigerer Rank darf höheren NICHT überschreiben - if rank < existing.rank then - return -- Blockiere das Update + + -- Get current slot map from GetUnitField + local slotMap = GetDebuffSlotMap(guid) + if not slotMap or not slotMap[displaySlot] then + -- Slot doesn't exist in GetUnitField - might be Blizzard displaying stale data + return nil end - -- Gleicher oder höherer Rank darf überschreiben (Timer erneuern) - end - - -- save current effect as lastspell - lastspell = existing - - existing.effect = effect - existing.start_old = existing.start - existing.start = now - existing.duration = duration or libdebuff:GetDuration(effect) - existing.caster = caster - existing.rank = rank - - libdebuff:UpdateUnits() -end - --- scan for debuff application -libdebuff:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE") -libdebuff:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE") -libdebuff:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER") -libdebuff:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") -libdebuff:RegisterEvent("PLAYER_TARGET_CHANGED") -libdebuff:RegisterEvent("SPELLCAST_STOP") -libdebuff:RegisterEvent("UNIT_AURA") - --- register combo points tracking for Druids and Rogues -if class == "DRUID" or class == "ROGUE" then - libdebuff:RegisterEvent("PLAYER_COMBO_POINTS") -end - --- register seal handler -if class == "PALADIN" then - libdebuff:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") -end - --- Remove Pending -libdebuff.rp = { SPELLIMMUNESELFOTHER, IMMUNEDAMAGECLASSSELFOTHER, - SPELLMISSSELFOTHER, SPELLRESISTSELFOTHER, SPELLEVADEDSELFOTHER, - SPELLDODGEDSELFOTHER, SPELLDEFLECTEDSELFOTHER, SPELLREFLECTSELFOTHER, - SPELLPARRIEDSELFOTHER, SPELLLOGABSORBSELFOTHER, SPELLFAILCASTSELF } - -libdebuff.objects = {} -libdebuff.pending = {} - --- Gather Data by Events -libdebuff:SetScript("OnEvent", function() - -- paladin seal refresh - if event == "CHAT_MSG_COMBAT_SELF_HITS" then - local hit = cmatch(arg1, COMBATHITSELFOTHER) - local crit = cmatch(arg1, COMBATHITCRITSELFOTHER) - if hit or crit then - for seal in L["judgements"] do - local name = UnitName("target") - local level = UnitLevel("target") - if name and libdebuff.objects[name] then - if level and libdebuff.objects[name][level] and libdebuff.objects[name][level][seal] then - libdebuff:AddEffect(name, level, seal) - elseif libdebuff.objects[name][0] and libdebuff.objects[name][0][seal] then - libdebuff:AddEffect(name, 0, seal) + + local slotData = slotMap[displaySlot] + local gufSpellName = slotData.spellName + local auraSlot = slotData.auraSlot + + -- Verify spell names match (sanity check) + if gufSpellName ~= effect then + -- Mismatch! GetUnitField and UnitDebuff disagree. + -- This can happen during the brief moment when debuffs change. + -- Trust GetUnitField (it's more accurate). + effect = gufSpellName + texture = libdebuff:GetSpellIcon(slotData.spellId) + end + + -- Get caster info for this slot + local slotCasterGuid, isOurs = GetSlotCaster(guid, auraSlot, effect) + + if isOurs then + -- OUR debuff - get timer from ownDebuffs + if ownDebuffs[guid] and ownDebuffs[guid][effect] then + local data = ownDebuffs[guid][effect] + local remaining = (data.startTime + data.duration) - GetTime() + if remaining > 0 then + duration = data.duration + timeleft = remaining + caster = "player" + rank = data.rank + elseif remaining > -1 then + -- Grace period - show 0 timeleft + duration = data.duration + timeleft = 0 + caster = "player" + rank = data.rank + end + end + else + -- OTHER player's debuff - get timer from allAuraCasts + if slotCasterGuid and allAuraCasts[guid] and allAuraCasts[guid][effect] then + local data = allAuraCasts[guid][effect][slotCasterGuid] + if data then + local remaining = (data.startTime + data.duration) - GetTime() + if remaining > 0 and data.duration > 0 then + duration = data.duration + timeleft = remaining + caster = "other" + rank = data.rank + end + end + end + + -- Fallback: Search all casters if specific one not found + if not duration and allAuraCasts[guid] and allAuraCasts[guid][effect] then + for anyCasterGuid, data in pairs(allAuraCasts[guid][effect]) do + local remaining = (data.startTime + data.duration) - GetTime() + if remaining > 0 and data.duration > 0 then + duration = data.duration + timeleft = remaining + caster = "other" + rank = data.rank + break end end end end + + return effect, rank, texture, stacks, dtype, duration, timeleft, caster + end - -- Add Combat Log - elseif event == "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE" or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE" then - local unit, effect = cmatch(arg1, AURAADDEDOTHERHARMFUL) - if unit and effect then - local unitlevel = UnitName("target") == unit and UnitLevel("target") or 0 - if not libdebuff.objects[unit] or not libdebuff.objects[unit][unitlevel] or not libdebuff.objects[unit][unitlevel][effect] then - libdebuff:AddEffect(unit, unitlevel, effect, nil, nil, nil) -- Explizit nil für rank - end - end + -- ============================================================================ + -- FALLBACK: Legacy (non-Nampower) system + -- ============================================================================ + + if effect and libdebuff.objects[unitname] then + for level, effects in pairs(libdebuff.objects[unitname]) do + if effects[effect] and effects[effect].duration then + local timeleft = effects[effect].start and + effects[effect].start + effects[effect].duration - GetTime() - -- Add Missing Buffs by Iteration - elseif ( event == "UNIT_AURA" and arg1 == "target" ) or event == "PLAYER_TARGET_CHANGED" then - for i=1, 16 do - local effect, rank, texture, stacks, dtype, duration, timeleft = libdebuff:UnitDebuff("target", i) - - -- abort when no further debuff was found - if not texture then return end - - if texture and effect and effect ~= "" then - -- don't overwrite existing timers - local unitlevel = UnitLevel("target") or 0 - local unit = UnitName("target") - if not libdebuff.objects[unit] or not libdebuff.objects[unit][unitlevel] or not libdebuff.objects[unit][unitlevel][effect] then - libdebuff:AddEffect(unit, unitlevel, effect, nil, nil, nil) -- Explizit nil für rank + if timeleft and timeleft > 0 then + return effect, effects[effect].rank, texture, stacks, dtype, + effects[effect].duration, timeleft, effects[effect].caster end end end - - -- Update Pending Spells und tracke failed spells - elseif event == "CHAT_MSG_SPELL_FAILED_LOCALPLAYER" or event == "CHAT_MSG_SPELL_SELF_DAMAGE" then - -- Prüfe ob ein Spell gefailed ist und speichere ihn - for _, msg in pairs(libdebuff.rp) do - local effect = cmatch(arg1, msg) - if effect then - -- Speichere den failed spell für 1 Sekunde - lastFailedSpells[effect] = { time = GetTime() } - - -- Bestehende Logik: Remove pending spell - if libdebuff.pending[3] == effect then - libdebuff:RemovePending() - return - elseif lastspell and lastspell.start_old and lastspell.effect == effect then - -- late removal of debuffs (e.g hunter arrows as they hit late) - libdebuff:RevertLastAction() - return - end - end - end - elseif event == "SPELLCAST_STOP" then - libdebuff:PersistPending() - elseif event == "PLAYER_COMBO_POINTS" then - -- Track combo points for Druid AND Rogue (both use CP-based abilities) - if class ~= "DRUID" and class ~= "ROGUE" then return end - local current = GetComboPoints("player", "target") or 0 - if current < currentComboPoints then - -- Combo points were spent! - lastSpentComboPoints = currentComboPoints - lastSpentTime = GetTime() - end - currentComboPoints = current - end -end) - --- Gather Data by User Actions -hooksecurefunc("CastSpell", function(id, bookType) - local rawEffect, rank = libspell.GetSpellInfo(id, bookType) - local duration = libdebuff:GetDuration(rawEffect, rank) - local rankNum = 0 - if rank then - local _, _, num = string.find(rank, "(%d+)") - rankNum = num and tonumber(num) or 0 - end - - -- Speichere rank für später (bleibt 2 Sekunden) - if rawEffect and rankNum > 0 then - lastCastRanks[rawEffect] = { rank = rankNum, time = GetTime() } - end - - libdebuff:AddPending(UnitName("target"), UnitLevel("target"), rawEffect, duration, "player", rankNum) -end) - -hooksecurefunc("CastSpellByName", function(effect, target) - local rawEffect, rank = libspell.GetSpellInfo(effect) - local duration = libdebuff:GetDuration(rawEffect, rank) - local rankNum = 0 - if rank then - local _, _, num = string.find(rank, "(%d+)") - rankNum = num and tonumber(num) or 0 - end - - -- Speichere rank für später (bleibt 2 Sekunden) - if rawEffect and rankNum > 0 then - lastCastRanks[rawEffect] = { rank = rankNum, time = GetTime() } - end - - libdebuff:AddPending(UnitName("target"), UnitLevel("target"), rawEffect, duration, "player", rankNum) -end) - -hooksecurefunc("UseAction", function(slot, target, button) - if GetActionText(slot) or not IsCurrentAction(slot) then return end - scanner:SetAction(slot) - local rawEffect, rank = scanner:Line(1) - local duration = libdebuff:GetDuration(rawEffect, rank) - local rankNum = 0 - if rank then - local _, _, num = string.find(rank, "(%d+)") - rankNum = num and tonumber(num) or 0 - end - - -- Speichere rank für später (bleibt 2 Sekunden) - if rawEffect and rankNum > 0 then - lastCastRanks[rawEffect] = { rank = rankNum, time = GetTime() } - end - - libdebuff:AddPending(UnitName("target"), UnitLevel("target"), rawEffect, duration, "player", rankNum) -end) - -function libdebuff:UnitDebuff(unit, id) - local unitname = UnitName(unit) - local unitlevel = UnitLevel(unit) - local texture, stacks, dtype = UnitDebuff(unit, id) - local duration, timeleft = nil, -1 - local rank = nil -- no backport - local caster = nil -- experimental - local effect - - if texture then - scanner:SetUnitDebuff(unit, id) - effect = scanner:Line(1) or "" - end - - -- read level based debuff table - local data = libdebuff.objects[unitname] and libdebuff.objects[unitname][unitlevel] - data = data or libdebuff.objects[unitname] and libdebuff.objects[unitname][0] - - if data and data[effect] then - if data[effect].duration and data[effect].start and data[effect].duration + data[effect].start > GetTime() then - -- read valid debuff data - duration = data[effect].duration - timeleft = duration + data[effect].start - GetTime() - caster = data[effect].caster - else - -- clean up invalid values - data[effect] = nil - end end return effect, rank, texture, stacks, dtype, duration, timeleft, caster end -local cache = {} -function libdebuff:UnitOwnDebuff(unit, id) - -- clean cache - for k, v in pairs(cache) do cache[k] = nil end +-- ============================================================================ +-- API: UnitOwnDebuff (only OUR debuffs) +-- ============================================================================ - -- detect own debuffs +function libdebuff:UnitOwnDebuff(unit, id) + if hasNampower and UnitExists then + local _, guid = UnitExists(unit) + if guid and ownDebuffs[guid] then + -- Build sorted list of our active debuffs + local sortedDebuffs = {} + local now = GetTime() + + for spellName, data in pairs(ownDebuffs[guid]) do + local timeleft = (data.startTime + data.duration) - now + if timeleft > -1 then -- Grace period + table.insert(sortedDebuffs, { + spellName = spellName, + data = data, + timeleft = timeleft + }) + end + end + + -- Sort by startTime (oldest first = lowest display slot) + -- If startTime is equal (e.g. after Carnage refresh), use spellName for stable sorting + table.sort(sortedDebuffs, function(a, b) + if a.data.startTime == b.data.startTime then + return a.spellName < b.spellName + end + return a.data.startTime < b.data.startTime + end) + + -- Return debuff at position 'id' + if sortedDebuffs[id] then + local entry = sortedDebuffs[id] + local texture = entry.data.texture or "Interface\\Icons\\INV_Misc_QuestionMark" + local displayTimeleft = entry.timeleft > 0 and entry.timeleft or 0 + return entry.spellName, entry.data.rank, texture, 1, nil, entry.data.duration, displayTimeleft, "player" + end + end + return nil + end + + -- Fallback: Iterate through all debuffs and filter + for k in pairs(cache) do cache[k] = nil end local count = 1 for i=1,16 do local effect, rank, texture, stacks, dtype, duration, timeleft, caster = libdebuff:UnitDebuff(unit, i) if effect and not cache[effect] and caster and caster == "player" then cache[effect] = true - if count == id then return effect, rank, texture, stacks, dtype, duration, timeleft, caster else @@ -495,5 +924,805 @@ function libdebuff:UnitOwnDebuff(unit, id) end end +-- ============================================================================ +-- API: GetBestAuraCast (for libpredict HoT tracking) +-- ============================================================================ + +function libdebuff:GetBestAuraCast(guid, spellName) + if not guid or not spellName then return nil end + + -- Check ownDebuffs first (for our casts) + if ownDebuffs[guid] and ownDebuffs[guid][spellName] then + local data = ownDebuffs[guid][spellName] + local timeleft = (data.startTime + data.duration) - GetTime() + if timeleft > 0 then + return data.startTime, data.duration, timeleft, data.rank, GetPlayerGUID() + end + end + + -- Check allAuraCasts (for any caster) + if allAuraCasts[guid] and allAuraCasts[guid][spellName] then + local bestData = nil + local bestCaster = nil + local bestTimeleft = 0 + + for casterGuid, data in pairs(allAuraCasts[guid][spellName]) do + local timeleft = (data.startTime + data.duration) - GetTime() + if timeleft > bestTimeleft then + bestTimeleft = timeleft + bestData = data + bestCaster = casterGuid + end + end + + if bestData and bestTimeleft > 0 then + return bestData.startTime, bestData.duration, bestTimeleft, bestData.rank, bestCaster + end + end + + return nil +end + +-- ============================================================================ +-- API: GetEnhancedDebuffs (for external modules) +-- ============================================================================ + +function libdebuff:GetEnhancedDebuffs(targetGUID) + if not targetGUID then return nil end + local result = {} + + if ownDebuffs[targetGUID] then + local myGuid = GetPlayerGUID() + for spellName, data in pairs(ownDebuffs[targetGUID]) do + local timeleft = (data.startTime + data.duration) - GetTime() + if timeleft > 0 then + result[spellName] = result[spellName] or {} + result[spellName][myGuid] = { + startTime = data.startTime, + duration = data.duration, + texture = data.texture, + rank = data.rank + } + end + end + end + + return result +end + +-- ============================================================================ +-- NAMPOWER EVENT HANDLING +-- ============================================================================ + +if hasNampower then + -- Carnage Talent Rank + local carnageRank = 0 + local function UpdateCarnageRank() + if class ~= "DRUID" then return end + local _, _, _, _, rank = GetTalentInfo(2, 17) + carnageRank = rank or 0 + end + + local frame = CreateFrame("Frame") + frame:RegisterEvent("PLAYER_ENTERING_WORLD") + frame:RegisterEvent("PLAYER_COMBO_POINTS") + frame:RegisterEvent("PLAYER_TALENT_UPDATE") + frame:RegisterEvent("PLAYER_LOGOUT") + frame:RegisterEvent("SPELL_START_SELF") + frame:RegisterEvent("SPELL_START_OTHER") + frame:RegisterEvent("SPELL_GO_SELF") + frame:RegisterEvent("SPELL_GO_OTHER") + frame:RegisterEvent("SPELL_FAILED_OTHER") + frame:RegisterEvent("AURA_CAST_ON_SELF") + frame:RegisterEvent("AURA_CAST_ON_OTHER") + frame:RegisterEvent("DEBUFF_ADDED_OTHER") + frame:RegisterEvent("DEBUFF_REMOVED_OTHER") + frame:RegisterEvent("PLAYER_TARGET_CHANGED") + frame:RegisterEvent("UNIT_HEALTH") + + frame:SetScript("OnEvent", function() + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + + elseif event == "PLAYER_ENTERING_WORLD" then + GetPlayerGUID() + UpdateCarnageRank() + + elseif event == "PLAYER_TALENT_UPDATE" then + UpdateCarnageRank() + + elseif event == "PLAYER_COMBO_POINTS" then + if class ~= "DRUID" and class ~= "ROGUE" then return end + local current = GetComboPoints("player", "target") or 0 + if current < currentComboPoints then + lastSpentComboPoints = currentComboPoints + lastSpentTime = GetTime() + end + currentComboPoints = current + + elseif event == "UNIT_HEALTH" then + local guid = arg1 + if guid and UnitIsDead and UnitIsDead(guid) then + CleanupUnit(guid) + end + + elseif event == "SPELL_START_SELF" or event == "SPELL_START_OTHER" then + local spellId = arg2 + local casterGuid = arg3 + local castTime = arg6 + + if not casterGuid or not spellId then return end + + local spellName = SpellInfo and SpellInfo(spellId) or nil + local icon = libdebuff:GetSpellIcon(spellId) + + pfUI.libdebuff_casts[casterGuid] = { + spellID = spellId, + spellName = spellName, + icon = icon, + startTime = GetTime(), + duration = castTime and castTime / 1000 or 0, + endTime = castTime and (GetTime() + castTime / 1000) or nil, + event = "START" + } + + elseif event == "SPELL_GO_SELF" or event == "SPELL_GO_OTHER" then + local spellId = arg2 + local casterGuid = arg3 + local targetGuid = arg4 + local numHit = arg6 or 0 + local numMissed = arg7 or 0 + + -- Clear cast bar + if casterGuid and pfUI.libdebuff_casts[casterGuid] then + pfUI.libdebuff_casts[casterGuid] = nil + end + + if numMissed > 0 or numHit == 0 then return end + if not SpellInfo then return end + + local spellName, spellRankString = SpellInfo(spellId) + if not spellName then return end + + local castRank = 0 + if spellRankString and spellRankString ~= "" then + castRank = tonumber((string.gsub(spellRankString, "Rank ", ""))) or 0 + end + + -- Store in pendingCasts for DEBUFF_ADDED correlation + if targetGuid then + pendingCasts[targetGuid] = pendingCasts[targetGuid] or {} + pendingCasts[targetGuid][spellName] = { + casterGuid = casterGuid, + rank = castRank, + time = GetTime() + } + end + + -- Store rank for our casts + local myGuid = GetPlayerGUID() + if casterGuid == myGuid then + lastCastRanks[spellName] = { + rank = castRank, + time = GetTime() + } + end + + -- CARNAGE TALENT: Ferocious Bite refreshes Rip & Rake (only on HIT, only for us, only with 5 CP) + -- This must happen AFTER the spell hits (SPELL_GO), not on AURA_CAST + if class == "DRUID" and carnageRank == 2 and spellName == "Ferocious Bite" and casterGuid == myGuid then + local cp = lastSpentComboPoints or 0 + if targetGuid and numHit > 0 and cp == 5 then + -- Refresh in ownDebuffs + if ownDebuffs[targetGuid] then + if ownDebuffs[targetGuid]["Rip"] then + ownDebuffs[targetGuid]["Rip"].startTime = GetTime() + if debugStats.enabled then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[CARNAGE]|r Rip refreshed (5 CP)") + end + end + if ownDebuffs[targetGuid]["Rake"] then + ownDebuffs[targetGuid]["Rake"].startTime = GetTime() + if debugStats.enabled then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[CARNAGE]|r Rake refreshed (5 CP)") + end + end + end + + -- Refresh in allAuraCasts + if allAuraCasts[targetGuid] then + if allAuraCasts[targetGuid]["Rip"] and allAuraCasts[targetGuid]["Rip"][myGuid] then + allAuraCasts[targetGuid]["Rip"][myGuid].startTime = GetTime() + end + if allAuraCasts[targetGuid]["Rake"] and allAuraCasts[targetGuid]["Rake"][myGuid] then + allAuraCasts[targetGuid]["Rake"][myGuid].startTime = GetTime() + end + end + + -- Trigger UI updates + if pfTarget and UnitExists("target") then + local _, currentTargetGuid = UnitExists("target") + if currentTargetGuid == targetGuid then + pfTarget.update_aura = true + end + end + + if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then + pfUI.nameplates:OnAuraUpdate(targetGuid) + end + end + end + + elseif event == "SPELL_FAILED_OTHER" then + local casterGuid = arg1 + + if casterGuid and pfUI.libdebuff_casts[casterGuid] then + pfUI.libdebuff_casts[casterGuid] = nil + end + + elseif event == "AURA_CAST_ON_SELF" or event == "AURA_CAST_ON_OTHER" then + local spellId = arg1 + local casterGuid = arg2 + local targetGuid = arg3 + local effect = arg4 + local effectAuraName = arg5 + local effectAmplitude = arg6 + local effectMiscValue = arg7 + local durationMs = arg8 + local auraCapStatus = arg9 + + if not SpellInfo or not spellId then return end + if not targetGuid or targetGuid == "" or targetGuid == "0x0000000000000000" then return end + + local spellName = SpellInfo(spellId) + if not spellName then return end + + -- Deduplicate: Ignore if we processed this exact cast recently (within 100ms) + -- Nampower fires multiple AURA_CAST events for multi-effect spells (e.g. Faerie Fire has 3 effects) + recentCasts[targetGuid] = recentCasts[targetGuid] or {} + recentCasts[targetGuid][spellName] = recentCasts[targetGuid][spellName] or {} + + local now = GetTime() + local lastCastTime = recentCasts[targetGuid][spellName][casterGuid] + + if lastCastTime and (now - lastCastTime) < AURA_CAST_DEDUPE_WINDOW then + return -- Duplicate event, ignore + end + + recentCasts[targetGuid][spellName][casterGuid] = now + + -- Rank aus spellId ermitteln + local rankNum = 0 + local rankString = GetSpellRecField(spellId, "rank") + if rankString and rankString ~= "" then + rankNum = tonumber((string.gsub(rankString, "Rank ", ""))) or 0 + end + + local duration = durationMs and (durationMs / 1000) or 0 + local startTime = GetTime() + local myGuid = GetPlayerGUID() + local isOurs = (myGuid and casterGuid == myGuid) + + if debugStats.enabled and isOurs then + debugStats.aura_cast = debugStats.aura_cast + 1 + end + + -- CP-based spells: Use GetDuration for our casts + if isOurs and combopointAbilities[spellName] then + duration = libdebuff:GetDuration(spellName, rankNum) + end + + -- CP-based spells: Force duration=0 for others (unknown!) + if not isOurs and combopointAbilities[spellName] then + if spellName == "Expose Armor" then + duration = 30 -- Fixed duration for Expose Armor + else + duration = 0 + end + end + + -- Store in allAuraCasts + if targetGuid and targetGuid ~= "" and targetGuid ~= "0x0000000000000000" then + allAuraCasts[targetGuid] = allAuraCasts[targetGuid] or {} + allAuraCasts[targetGuid][spellName] = allAuraCasts[targetGuid][spellName] or {} + + -- Downrank Protection: Check BEFORE clearing old casters! + -- For selfOverwrite debuffs, check ALL existing casters + if selfOverwriteDebuffs[spellName] then + for otherCaster, existingData in pairs(allAuraCasts[targetGuid][spellName]) do + if existingData.rank and rankNum and rankNum > 0 then + local existingTimeleft = (existingData.startTime + existingData.duration) - GetTime() + if existingTimeleft > 0 and rankNum < existingData.rank then + -- Lower rank cannot overwrite higher rank - block the update + if debugStats.enabled then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[DOWNRANK BLOCKED]|r %s: Rank %d from %s cannot overwrite Rank %d from %s (%.1fs left)", + spellName, rankNum, DebugGuid(casterGuid), existingData.rank, DebugGuid(otherCaster), existingTimeleft)) + end + return + end + end + end + else + -- For non-selfOverwrite: Check only same caster + local existingData = allAuraCasts[targetGuid][spellName][casterGuid] + if existingData and existingData.rank and rankNum and rankNum > 0 then + local existingTimeleft = (existingData.startTime + existingData.duration) - GetTime() + if existingTimeleft > 0 and rankNum < existingData.rank then + if debugStats.enabled and isOurs then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[DOWNRANK BLOCKED]|r %s: Rank %d cannot overwrite Rank %d (%.1fs left)", + spellName, rankNum, existingData.rank, existingTimeleft)) + end + return + end + end + end + + -- Handle self-overwrite debuffs (clear other casters) + if selfOverwriteDebuffs[spellName] then + local oldCasters = {} + for otherCaster in pairs(allAuraCasts[targetGuid][spellName]) do + if otherCaster ~= casterGuid then + table.insert(oldCasters, otherCaster) + end + end + for _, otherCaster in ipairs(oldCasters) do + allAuraCasts[targetGuid][spellName][otherCaster] = nil + end + + -- Clear from ownDebuffs if we're being overwritten + if not isOurs and ownDebuffs[targetGuid] and ownDebuffs[targetGuid][spellName] then + ownDebuffs[targetGuid][spellName] = nil + end + end + + -- Handle variant pairs (Faerie Fire <-> Faerie Fire (Feral)) + if debuffOverwritePairs[spellName] then + local otherVariant = debuffOverwritePairs[spellName] + if allAuraCasts[targetGuid][otherVariant] and allAuraCasts[targetGuid][otherVariant][casterGuid] then + allAuraCasts[targetGuid][otherVariant][casterGuid] = nil + end + end + + -- Store timer data + allAuraCasts[targetGuid][spellName][casterGuid] = { + startTime = startTime, + duration = duration, + rank = rankNum + } + + -- UPDATE slotOwnership for selfOverwrite refreshes + -- (DEBUFF_ADDED doesn't fire on refresh, so we must update here!) + if selfOverwriteDebuffs[spellName] and slotOwnership[targetGuid] then + for auraSlot, ownership in pairs(slotOwnership[targetGuid]) do + if ownership.spellName == spellName then + -- Update the casterGuid and isOurs for this slot + ownership.casterGuid = casterGuid + ownership.isOurs = isOurs + + if debugStats.enabled and IsCurrentTarget(targetGuid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[SLOT UPDATED]|r aura=%d %s newCaster=%s isOurs=%s", + auraSlot, spellName, DebugGuid(casterGuid), tostring(isOurs))) + end + break + end + end + end + + if debugStats.enabled and IsCurrentTarget(targetGuid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cff00ffff[AURA_CAST]|r %s target=%s caster=%s isOurs=%s dur=%.1fs", + GetDebugTimestamp(), spellName, DebugGuid(targetGuid), DebugGuid(casterGuid), tostring(isOurs), duration)) + end + end + + -- Notify nameplates + if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then + pfUI.nameplates:OnAuraUpdate(targetGuid) + end + + -- Notify unitframes of debuff updates (UNIT_AURA doesn't fire on refreshes!) + -- Check player + if UnitExists("player") then + local _, playerGuid = UnitExists("player") + if playerGuid == targetGuid and pfPlayer then + pfPlayer.update_aura = true + end + end + + -- Check target + if UnitExists("target") then + local _, targetUnitGuid = UnitExists("target") + if targetUnitGuid == targetGuid and pfTarget then + pfTarget.update_aura = true + end + end + + -- Only track in ownDebuffs if it's OUR debuff + if not isOurs then return end + if targetGuid == myGuid then return end -- Skip self-buffs + if not targetGuid or targetGuid == "" or targetGuid == "0x0000000000000000" then return end + + -- Get texture + local texture = libdebuff:GetSpellIcon(spellId) + + -- Store in ownDebuffs + ownDebuffs[targetGuid] = ownDebuffs[targetGuid] or {} + + if not ownDebuffs[targetGuid][spellName] then + ownDebuffs[targetGuid][spellName] = {} + end + + local data = ownDebuffs[targetGuid][spellName] + + -- Downrank Protection: Check if existing debuff is still active and has higher rank + if data.startTime and data.duration and data.rank and rankNum > 0 then + local existingTimeleft = (data.startTime + data.duration) - GetTime() + if existingTimeleft > 0 then + -- Existing debuff is still active + if rankNum < data.rank then + -- Lower rank cannot overwrite higher rank - block the update + if debugStats.enabled then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[DOWNRANK BLOCKED]|r %s: Rank %d cannot overwrite Rank %d (%.1fs left)", + spellName, rankNum, data.rank, existingTimeleft)) + end + return + end + end + end + + data.startTime = startTime + data.duration = duration + data.texture = texture + data.rank = rankNum + + -- Handle variant pairs for ownDebuffs + if debuffOverwritePairs[spellName] then + local otherVariant = debuffOverwritePairs[spellName] + if ownDebuffs[targetGuid][otherVariant] then + ownDebuffs[targetGuid][otherVariant] = nil + end + end + + -- Store for Cleveroids API + objectsByGuid[targetGuid] = objectsByGuid[targetGuid] or {} + objectsByGuid[targetGuid][spellId] = { + start = startTime, + duration = duration, + caster = "player", + stacks = 1 + } + + elseif event == "DEBUFF_ADDED_OTHER" then + local guid = arg1 + local displaySlot = arg2 -- This is DISPLAY slot (1-16), NOT aura slot! + local spellId = arg3 + local stacks = arg4 + + -- Invalidate slot map cache for this GUID + slotMapCache[guid] = nil + + local spellName = SpellInfo and SpellInfo(spellId) + if not spellName then return end + + if debugStats.enabled then + debugStats.debuff_added = debugStats.debuff_added + 1 + end + + -- If unit is dead, cleanup and skip + if UnitIsDead and UnitIsDead(guid) then + CleanupUnit(guid) + return + end + + -- Find the REAL aura slot (33-48) via GetUnitField + local auraSlot = nil + local slotMap = GetDebuffSlotMap(guid) + if slotMap and slotMap[displaySlot] then + auraSlot = slotMap[displaySlot].auraSlot + end + + -- Fallback: Calculate aura slot if GetUnitField didn't work + -- (This assumes no gaps, which isn't always true, but better than nothing) + if not auraSlot then + auraSlot = 32 + displaySlot + end + + -- Get caster from pendingCasts (SPELL_GO correlation) + local casterGuid = nil + if pendingCasts[guid] and pendingCasts[guid][spellName] then + local pending = pendingCasts[guid][spellName] + if GetTime() - pending.time < 0.5 then + casterGuid = pending.casterGuid + pendingCasts[guid][spellName] = nil + end + end + + -- Fallback: Check allAuraCasts for most recent caster + if not casterGuid and allAuraCasts[guid] and allAuraCasts[guid][spellName] then + local mostRecent = nil + local mostRecentTime = 0 + for casterId, data in pairs(allAuraCasts[guid][spellName]) do + if data.startTime > mostRecentTime then + mostRecentTime = data.startTime + mostRecent = casterId + end + end + if mostRecent then + casterGuid = mostRecent + end + end + + local myGuid = GetPlayerGUID() + local isOurs = (myGuid and casterGuid == myGuid) + + -- Fallback: Check ownDebuffs timing + if not isOurs and not casterGuid then + if ownDebuffs[guid] and ownDebuffs[guid][spellName] then + local age = GetTime() - ownDebuffs[guid][spellName].startTime + if age < 0.5 then + isOurs = true + casterGuid = myGuid + end + end + end + + -- Store slot ownership (KEY: auraSlot is STABLE, no shifting needed!) + slotOwnership[guid] = slotOwnership[guid] or {} + slotOwnership[guid][auraSlot] = { + casterGuid = casterGuid, + spellName = spellName, + spellId = spellId, + isOurs = isOurs + } + + -- Store displaySlot → auraSlot mapping for DEBUFF_REMOVED + displayToAura[guid] = displayToAura[guid] or {} + displayToAura[guid][displaySlot] = auraSlot + + if debugStats.enabled and IsCurrentTarget(guid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cff00ff00[DEBUFF_ADDED]|r display=%d aura=%d %s caster=%s isOurs=%s", + GetDebugTimestamp(), displaySlot, auraSlot, spellName, DebugGuid(casterGuid), tostring(isOurs))) + end + + -- CRITICAL FIX: Update ownDebuffs here too for refresh timing! + -- This prevents the gap between DEBUFF_REMOVED and AURA_CAST where buffwatch shows nothing + if isOurs and casterGuid then + local myGuid = GetPlayerGUID() + if myGuid and casterGuid == myGuid then + -- Check if we have timer data from allAuraCasts + if allAuraCasts[guid] and allAuraCasts[guid][spellName] and allAuraCasts[guid][spellName][casterGuid] then + local auraData = allAuraCasts[guid][spellName][casterGuid] + local texture = libdebuff:GetSpellIcon(spellId) + + ownDebuffs[guid] = ownDebuffs[guid] or {} + ownDebuffs[guid][spellName] = { + startTime = auraData.startTime, + duration = auraData.duration, + texture = texture, + rank = auraData.rank or 0 + } + + if debugStats.enabled and IsCurrentTarget(guid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff00ff[OWNDEBUFF SYNC]|r %s from DEBUFF_ADDED", spellName)) + end + end + end + end + + -- Cleanup expired timers + CleanupExpiredTimers(guid) + + -- Notify nameplates + if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then + pfUI.nameplates:OnAuraUpdate(guid) + end + + elseif event == "DEBUFF_REMOVED_OTHER" then + local guid = arg1 + local displaySlot = arg2 -- This is DISPLAY slot (1-16), NOT aura slot! + local spellId = arg3 + + -- Invalidate slot map cache for this GUID + slotMapCache[guid] = nil + + local spellName = SpellInfo and SpellInfo(spellId) or "?" + + if debugStats.enabled then + debugStats.debuff_removed = debugStats.debuff_removed + 1 + if IsCurrentTarget(guid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cffff9900[DEBUFF_REMOVED]|r display=%d %s", + GetDebugTimestamp(), displaySlot, spellName)) + end + end + + -- If unit is dead, cleanup all + if UnitIsDead and UnitIsDead(guid) then + CleanupUnit(guid) + return + end + + -- Find the auraSlot using displaySlot mapping + local wasOurs = false + local removedCasterGuid = nil + local foundAuraSlot = nil + + -- Use displayToAura mapping to find the correct auraSlot + if displayToAura[guid] and displayToAura[guid][displaySlot] then + foundAuraSlot = displayToAura[guid][displaySlot] + + -- Get ownership info for this specific slot + if slotOwnership[guid] and slotOwnership[guid][foundAuraSlot] then + local ownership = slotOwnership[guid][foundAuraSlot] + wasOurs = ownership.isOurs + removedCasterGuid = ownership.casterGuid + end + + -- Clear both mappings + slotOwnership[guid][foundAuraSlot] = nil + displayToAura[guid][displaySlot] = nil + + if debugStats.enabled and IsCurrentTarget(guid) then + DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cffff9900[SLOT CLEARED]|r aura=%d %s wasOurs=%s caster=%s", + GetDebugTimestamp(), foundAuraSlot, spellName, tostring(wasOurs), DebugGuid(removedCasterGuid))) + end + end + + -- Remove from ownDebuffs if it was ours + if wasOurs and ownDebuffs[guid] and ownDebuffs[guid][spellName] then + local age = GetTime() - ownDebuffs[guid][spellName].startTime + -- Only delete if not recently renewed + if age > 1 then + ownDebuffs[guid][spellName] = nil + end + end + + -- Remove from allAuraCasts + if removedCasterGuid and allAuraCasts[guid] and allAuraCasts[guid][spellName] then + if allAuraCasts[guid][spellName][removedCasterGuid] then + local auraData = allAuraCasts[guid][spellName][removedCasterGuid] + local age = GetTime() - auraData.startTime + -- Only delete if not recently refreshed + if age > 1 then + allAuraCasts[guid][spellName][removedCasterGuid] = nil + end + end + end + + -- Cleanup expired timers + CleanupExpiredTimers(guid) + + -- Notify nameplates + if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then + pfUI.nameplates:OnAuraUpdate(guid) + end + + elseif event == "PLAYER_TARGET_CHANGED" then + -- Nothing special needed - GetUnitField will get fresh data on next query + if not UnitExists then return end + local _, targetGuid = UnitExists("target") + + if targetGuid and targetGuid ~= "" then + -- Cleanup expired timers for new target + CleanupExpiredTimers(targetGuid) + end + end + + -- Periodic cleanup + CleanupOutOfRangeUnits() + end) + + -- Cleveroids API + if CleveRoids then + CleveRoids.libdebuff = libdebuff + libdebuff.objects = objectsByGuid + end +end + -- add libdebuff to pfUI API -pfUI.api.libdebuff = libdebuff \ No newline at end of file +pfUI.api.libdebuff = libdebuff + +-- Expose debugStats for external access +libdebuff.debugStats = debugStats + +-- ============================================================================ +-- DEBUG COMMANDS +-- ============================================================================ + +_G.SLASH_LIBDEBUGSTATS1 = "/libdebugstats" +_G.SlashCmdList["LIBDEBUGSTATS"] = function(msg) + msg = string.lower(msg or "") + + if msg == "start" then + debugStats.enabled = true + debugStats.trackAllUnits = false + debugStats.aura_cast = 0 + debugStats.debuff_added = 0 + debugStats.debuff_removed = 0 + debugStats.getunitfield_calls = 0 + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[libdebuff]|r Debug tracking STARTED") + + elseif msg == "stop" then + debugStats.enabled = false + DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[libdebuff]|r Debug tracking STOPPED") + + elseif msg == "stats" then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff=== LIBDEBUFF STATS (GetUnitField Edition) ===|r") + DEFAULT_CHAT_FRAME:AddMessage(string.format("AURA_CAST events: %d", debugStats.aura_cast)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("DEBUFF_ADDED events: %d", debugStats.debuff_added)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("DEBUFF_REMOVED events: %d", debugStats.debuff_removed)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("GetUnitField calls: %d", debugStats.getunitfield_calls)) + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00No manual slot shifting needed!|r") + + elseif msg == "target" then + if not UnitExists("target") then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff]|r No target!") + return + end + + local _, guid = UnitExists("target") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff=== TARGET DEBUFF STATE ===|r") + DEFAULT_CHAT_FRAME:AddMessage(string.format("GUID: %s", tostring(guid))) + + -- Show GetUnitField slot map + local slotMap = GetDebuffSlotMap(guid) + if slotMap then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00GetUnitField Slots:|r") + for displaySlot, data in pairs(slotMap) do + local casterGuid, isOurs = GetSlotCaster(guid, data.auraSlot, data.spellName) + DEFAULT_CHAT_FRAME:AddMessage(string.format(" Display %d (aura %d): %s [caster=%s, ours=%s]", + displaySlot, data.auraSlot, data.spellName, DebugGuid(casterGuid), tostring(isOurs))) + end + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff9900No debuffs via GetUnitField|r") + end + + -- Show ownDebuffs + if ownDebuffs[guid] then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ownDebuffs:|r") + for spell, data in pairs(ownDebuffs[guid]) do + local timeleft = (data.startTime + data.duration) - GetTime() + DEFAULT_CHAT_FRAME:AddMessage(string.format(" %s: dur=%.1f left=%.1f", spell, data.duration, timeleft)) + end + end + + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[libdebuff] GetUnitField Edition - Commands:|r") + DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats start - Start debug tracking") + DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats stop - Stop debug tracking") + DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats stats - Show statistics") + DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats target - Show target debuff state") + end +end + +_G.SLASH_MEMCHECK1 = "/memcheck" +_G.SlashCmdList["MEMCHECK"] = function() + local function countTable(t) + local count = 0 + if not t then return 0 end + for _ in pairs(t) do count = count + 1 end + return count + end + + local function countNestedEntries(t) + local total = 0 + if not t then return 0 end + for _, nested in pairs(t) do + if type(nested) == "table" then + total = total + countTable(nested) + end + end + return total + end + + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========== LIBDEBUFF MEMORY (GetUnitField Edition) ==========|r") + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00Primary Tables:|r")) + DEFAULT_CHAT_FRAME:AddMessage(string.format(" ownDebuffs: %d GUIDs, %d debuffs", countTable(ownDebuffs), countNestedEntries(ownDebuffs))) + DEFAULT_CHAT_FRAME:AddMessage(string.format(" slotOwnership: %d GUIDs, %d slots", countTable(slotOwnership), countNestedEntries(slotOwnership))) + DEFAULT_CHAT_FRAME:AddMessage(string.format(" allAuraCasts: %d GUIDs", countTable(allAuraCasts))) + DEFAULT_CHAT_FRAME:AddMessage(string.format(" pendingCasts: %d GUIDs", countTable(pendingCasts))) + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00No ownSlots/allSlots (eliminated by GetUnitField approach!)|r") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff============================================================|r") +end + +DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r GetUnitField Edition loaded!") \ No newline at end of file diff --git a/libs/libpredict.lua b/libs/libpredict.lua index e9fd1887..4cc83672 100644 --- a/libs/libpredict.lua +++ b/libs/libpredict.lua @@ -12,10 +12,27 @@ setfenv(1, pfUI:GetEnvironment()) -- The library is able to receive and send compatible messages to HealComm -- including resurrections. It has an option to disable the sending of those -- messages in case HealComm is already active. +-- +-- HOT TRACKING INTEGRATION (NEW): +-- With Nampower enabled, HoT tracking now primarily uses libdebuff's AURA_CAST +-- event system for accurate server-side buff/debuff tracking with full rank +-- protection. GetHotDuration() first checks libdebuff, then falls back to the +-- legacy prediction system for backwards compatibility with non-Nampower clients. +-- This provides: +-- - Accurate duration from server (no prediction needed) +-- - Automatic rank protection (lower ranks won't overwrite higher ranks) +-- - Support for multiple casters of same HoT on one target +-- - Zero event overhead (libdebuff already tracks all auras) -- return instantly when another libpredict is already active if pfUI.api.libpredict then return end +-- Check if libdebuff integration is available +local libdebuff_available = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false + +-- Check if Nampower is available for SPELL_FAILED events +local hasNampower = GetNampowerVersion ~= nil + local senttarget local heals, ress, events, hots = {}, {}, {}, {} @@ -98,6 +115,7 @@ local libpredict = CreateFrame("Frame") libpredict:RegisterEvent("UNIT_HEALTH") libpredict:RegisterEvent("CHAT_MSG_ADDON") libpredict:RegisterEvent("PLAYER_TARGET_CHANGED") +libpredict:RegisterEvent("PLAYER_LOGOUT") -- SuperWoW: Registriere UNIT_CASTEVENT für akkurate Instant-HoT Detection if superwow_active then @@ -105,12 +123,23 @@ if superwow_active then end libpredict:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + if event == "CHAT_MSG_ADDON" and (arg1 == "HealComm" or arg1 == "CTRA") then + -- Ignore own messages (sender receives own addon messages) + local playerName = UnitName("player") + if arg4 == playerName then return end + this:ParseChatMessage(arg4, arg2, arg1) elseif event == "UNIT_HEALTH" then local name = UnitName(arg1) - if ress[name] and not UnitIsDeadOrGhost(arg1) then - ress[UnitName(arg1)] = nil + if name and ress[name] and not UnitIsDeadOrGhost(arg1) then + ress[name] = nil -- Reuse 'name' variable instead of calling UnitName again end elseif event == "UNIT_CASTEVENT" and superwow_active then -- arg1 = casterGUID, arg2 = targetGUID, arg3 = event type, arg4 = spellId, arg5 = castTime @@ -174,23 +203,38 @@ libpredict:SetScript("OnEvent", function() duration = renewDuration or 15 end - if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ffff[UNIT_CASTEVENT]|r spell=%s target=%s dur=%s", hotType, targetName, tostring(duration))) + -- Extract rank from spellId (if SpellInfo available) + local rank = nil + if SpellInfo then + local _, rankString = SpellInfo(spellId) + if rankString and rankString ~= "" then + rank = tonumber((string.gsub(rankString, "Rank ", ""))) or nil + end end - -- Sende HoT - local playerName = UnitName("player") - libpredict:Hot(playerName, targetName, hotType, duration, nil, "UNIT_CASTEVENT") + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ffff[UNIT_CASTEVENT]|r spell=%s target=%s dur=%s rank=%s", + hotType, targetName, tostring(duration), tostring(rank or "?"))) + end - -- Sende HealComm Nachricht (sender könnte noch nicht existieren bei sehr frühem Event) + -- Sende HoT mit Rank + local playerName = UnitName("player") + libpredict:Hot(playerName, targetName, hotType, duration, nil, "UNIT_CASTEVENT", rank) + + -- Sende HealComm Nachricht mit Rank (backwards compatible: rank optional) + -- Use "0" for unknown rank instead of empty string to avoid parsing issues + local rankStr = rank and tostring(rank) or "0" if libpredict.sender and libpredict.sender.SendHealCommMsg then - libpredict.sender:SendHealCommMsg(hotType .. "/" .. targetName .. "/" .. duration .. "/") + libpredict.sender:SendHealCommMsg(hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/") else - -- Fallback: direkt senden - local msg = hotType .. "/" .. targetName .. "/" .. duration .. "/" - SendAddonMessage("HealComm", msg, "PARTY") - SendAddonMessage("HealComm", msg, "RAID") - SendAddonMessage("HealComm", msg, "BATTLEGROUND") + -- Fallback: direkt senden (smart channel selection) + local msg = hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/" + if GetNumRaidMembers() > 0 then + SendAddonMessage("HealComm", msg, "RAID") + elseif GetNumPartyMembers() > 0 then + SendAddonMessage("HealComm", msg, "PARTY") + end + -- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla) end end end) @@ -210,10 +254,14 @@ libpredict:SetScript("OnUpdate", function() end) function libpredict:ParseComm(sender, msg) - local msgtype, target, heal, time + local msgtype, target, heal, time, rank - if msg == "Healstop" or msg == "GrpHealstop" then + if msg == "HealStop" or msg == "GrpHealstop" then msgtype = "Stop" + -- DEBUG: Log when HealStop received + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[libpredict RX]|r HealStop from " .. tostring(sender)) + end elseif msg == "Resurrection/stop/" then msgtype = "RessStop" elseif msg then @@ -242,6 +290,13 @@ function libpredict:ParseComm(sender, msg) if msgobj[1] == "Reju" or msgobj[1] == "Renew" or msgobj[1] == "Regr" then --hots msgtype, target, heal, time = "Hot", msgobj[2], msgobj[1], msgobj[3] + -- NEW: Parse rank (optional, backwards compatible) + -- Format: "Reju/Target/12/10/" where msgobj[3]=duration, msgobj[4]=rank + -- "0" = unknown rank (for clients without rank extraction) + local rankStr = msgobj[4] + if rankStr and rankStr ~= "" and rankStr ~= "/" and rankStr ~= "0" then + rank = tonumber(rankStr) + end end elseif select and UnitCastingInfo then -- latest healcomm @@ -271,7 +326,7 @@ function libpredict:ParseComm(sender, msg) end end - return msgtype, target, heal, time + return msgtype, target, heal, time, rank end -- Duplikat-Erkennung für HoT Nachrichten @@ -279,10 +334,10 @@ local recentHots = {} local DUPLICATE_WINDOW = 0.5 -- Ignoriere gleiche Nachricht innerhalb 0.5s function libpredict:ParseChatMessage(sender, msg, comm) - local msgtype, target, heal, time + local msgtype, target, heal, time, rank if comm == "HealComm" then - msgtype, target, heal, time = libpredict:ParseComm(sender, msg) + msgtype, target, heal, time, rank = libpredict:ParseComm(sender, msg) elseif comm == "CTRA" then local _, _, cmd, ctratarget = string.find(msg, "(%a+)%s?([^#]*)") if cmd and ctratarget and cmd == "RES" and ctratarget ~= "" and ctratarget ~= UNKNOWN then @@ -344,10 +399,10 @@ function libpredict:ParseChatMessage(sender, msg, comm) local delay = (heal == "Regr") and 0.3 or 0 local correctedStart = now - delay - libpredict:Hot(sender, target, heal, time, correctedStart, "ParseComm-Self") + libpredict:Hot(sender, target, heal, time, correctedStart, "ParseComm-Self", rank) return end - libpredict:Hot(sender, target, heal, time, nil, "ParseComm") + libpredict:Hot(sender, target, heal, time, nil, "ParseComm", rank) end end @@ -371,7 +426,7 @@ end -- Debug flag libpredict.debug = false -function libpredict:Hot(sender, target, spell, duration, startTime, source) +function libpredict:Hot(sender, target, spell, duration, startTime, source, rank) hots[target] = hots[target] or {} hots[target][spell] = hots[target][spell] or {} @@ -382,10 +437,30 @@ function libpredict:Hot(sender, target, spell, duration, startTime, source) -- Sicherstellen dass duration eine Zahl ist duration = tonumber(duration) or duration + + -- Rank protection: Don't overwrite higher rank HoT with lower rank + local existing = hots[target][spell] + if existing and existing.rank and rank then + local existingRank = tonumber(existing.rank) or 0 + local newRank = tonumber(rank) or 0 + + local now = pfUI.uf.now or GetTime() + local existingTimeleft = (existing.start + existing.duration) - now + + -- If existing HoT is still active and has higher rank, don't overwrite + if existingTimeleft > 0 and newRank > 0 and newRank < existingRank then + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[Hot RANK BLOCK]|r %s Rank %d cannot overwrite Rank %d on %s", + spell, newRank, existingRank, target)) + end + return -- Don't overwrite! + end + end local now = pfUI.uf.now or GetTime() hots[target][spell].duration = duration hots[target][spell].start = startTime or now + hots[target][spell].rank = rank -- Store rank for protection -- Debug if libpredict.debug then @@ -393,7 +468,8 @@ function libpredict:Hot(sender, target, spell, duration, startTime, source) " | sender=" .. (sender or "nil") .. " | target=" .. (target or "nil") .. " | spell=" .. (spell or "nil") .. - " | dur=" .. tostring(duration) .. " (" .. type(duration) .. ")") + " | dur=" .. tostring(duration) .. " (" .. type(duration) .. ")" .. + " | rank=" .. tostring(rank or "?")) end -- update aura events of relevant unitframes @@ -447,9 +523,10 @@ function libpredict:RessStop(sender) end function libpredict:UnitGetIncomingHeals(unit) - if not unit or not UnitName(unit) then return 0 end - if UnitIsDeadOrGhost(unit) then return 0 end + if not unit then return 0 end local name = UnitName(unit) + if not name then return 0 end + if UnitIsDeadOrGhost(unit) then return 0 end local sumheal = 0 if not heals[name] then @@ -468,8 +545,9 @@ function libpredict:UnitGetIncomingHeals(unit) end function libpredict:UnitHasIncomingResurrection(unit) - if not unit or not UnitName(unit) then return nil end + if not unit then return nil end local name = UnitName(unit) + if not name then return nil end if not ress[name] then return nil @@ -587,6 +665,12 @@ hooksecurefunc("CastSpell", function(id, bookType) spell_queue[2] = effect.. ( rank or "" ) spell_queue[3] = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player") + -- Extract rank number + local rankNum = nil + if rank and rank ~= "" then + rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil + end + -- Instant-HoTs: Mit SuperWoW nutzen wir UNIT_CASTEVENT (akkurater) -- Ohne SuperWoW: Fallback auf Hook-Methode mit Cooldown if superwow_active then return end @@ -603,10 +687,11 @@ hooksecurefunc("CastSpell", function(id, bookType) instantHotCooldown[key] = now if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell REJU INSTANT]|r target=%s (Fallback)", target)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell REJU INSTANT]|r target=%s rank=%s (Fallback)", target, tostring(rankNum or "?"))) end - libpredict:Hot(player, target, "Reju", rejuvDuration, nil, "CastSpell-Instant") - libpredict.sender:SendHealCommMsg("Reju/"..target.."/"..rejuvDuration.."/") + libpredict:Hot(player, target, "Reju", rejuvDuration, nil, "CastSpell-Instant", rankNum) + local rankStr = rankNum and tostring(rankNum) or "0" + libpredict.sender:SendHealCommMsg("Reju/"..target.."/"..rejuvDuration.."/"..rankStr.."/") elseif effect == RENEW then local target = spell_queue[3] local now = pfUI.uf.now or GetTime() @@ -619,10 +704,11 @@ hooksecurefunc("CastSpell", function(id, bookType) instantHotCooldown[key] = now if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell RENEW INSTANT]|r target=%s (Fallback)", target)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell RENEW INSTANT]|r target=%s rank=%s (Fallback)", target, tostring(rankNum or "?"))) end - libpredict:Hot(player, target, "Renew", renewDuration, nil, "CastSpell-Instant") - libpredict.sender:SendHealCommMsg("Renew/"..target.."/"..renewDuration.."/") + libpredict:Hot(player, target, "Renew", renewDuration, nil, "CastSpell-Instant", rankNum) + local rankStr = rankNum and tostring(rankNum) or "0" + libpredict.sender:SendHealCommMsg("Renew/"..target.."/"..renewDuration.."/"..rankStr.."/") end end) @@ -639,6 +725,12 @@ hooksecurefunc("CastSpellByName", function(effect, target) target = target and target == true and UnitName("player") or target target = target and target == 1 and UnitName("player") or target + -- Extract rank number + local rankNum = nil + if rank and rank ~= "" then + rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil + end + -- Nur spell_queue überschreiben wenn kein Cast läuft -- (verhindert dass Instant-Spam während Regrowth-Cast die Queue zerstört) if not libpredict.sender.current_cast then @@ -663,10 +755,11 @@ hooksecurefunc("CastSpellByName", function(effect, target) instantHotCooldown[key] = now if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName REJU INSTANT]|r target=%s (Fallback)", hotTarget)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName REJU INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?"))) end - libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "CastSpellByName-Instant") - libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/") + libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "CastSpellByName-Instant", rankNum) + local rankStr = rankNum and tostring(rankNum) or "0" + libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/"..rankStr.."/") elseif effect == RENEW then local hotTarget = target or mouseover or default local now = pfUI.uf.now or GetTime() @@ -679,10 +772,11 @@ hooksecurefunc("CastSpellByName", function(effect, target) instantHotCooldown[key] = now if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName RENEW INSTANT]|r target=%s (Fallback)", hotTarget)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName RENEW INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?"))) end - libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "CastSpellByName-Instant") - libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/") + libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "CastSpellByName-Instant", rankNum) + local rankStr = rankNum and tostring(rankNum) or "0" + libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/"..rankStr.."/") end end) @@ -697,6 +791,12 @@ hooksecurefunc("UseAction", function(slot, target, selfcast) spell_queue[2] = effect.. ( rank or "" ) spell_queue[3] = selfcast and UnitName("player") or UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player") + -- Extract rank number + local rankNum = nil + if rank and rank ~= "" then + rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil + end + -- Instant-HoTs: Mit SuperWoW nutzen wir UNIT_CASTEVENT (akkurater) -- Ohne SuperWoW: Fallback auf Hook-Methode mit Cooldown if superwow_active then return end @@ -713,10 +813,11 @@ hooksecurefunc("UseAction", function(slot, target, selfcast) instantHotCooldown[key] = now if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction REJU INSTANT]|r target=%s (Fallback)", hotTarget)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction REJU INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?"))) end - libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "UseAction-Instant") - libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/") + libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "UseAction-Instant", rankNum) + local rankStr = rankNum and tostring(rankNum) or "0" + libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/"..rankStr.."/") elseif effect == RENEW then local hotTarget = spell_queue[3] local now = pfUI.uf.now or GetTime() @@ -729,24 +830,39 @@ hooksecurefunc("UseAction", function(slot, target, selfcast) instantHotCooldown[key] = now if libpredict.debug then - DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction RENEW INSTANT]|r target=%s (Fallback)", hotTarget)) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction RENEW INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?"))) end - libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "UseAction-Instant") - libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/") + libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "UseAction-Instant", rankNum) + local rankStr = rankNum and tostring(rankNum) or "0" + libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/"..rankStr.."/") end end) libpredict.sender = CreateFrame("Frame", "pfPredictionSender", UIParent) libpredict.sender.enabled = true libpredict.sender.SendHealCommMsg = function(self, msg) - SendAddonMessage("HealComm", msg, "PARTY") - SendAddonMessage("HealComm", msg, "RAID") - SendAddonMessage("HealComm", msg, "BATTLEGROUND") + -- Smart channel selection: Only send to relevant channel to avoid duplicates + if GetNumRaidMembers() > 0 then + -- In raid: Only send to RAID (includes all raid members) + SendAddonMessage("HealComm", msg, "RAID") + elseif GetNumPartyMembers() > 0 then + -- In party: Only send to PARTY + SendAddonMessage("HealComm", msg, "PARTY") + end + -- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla) + -- BG groups are handled by RAID channel end libpredict.sender.SendResCommMsg = function(self, msg) - SendAddonMessage("CTRA", msg, "PARTY") - SendAddonMessage("CTRA", msg, "RAID") - SendAddonMessage("CTRA", msg, "BATTLEGROUND") + -- Smart channel selection: Only send to relevant channel to avoid duplicates + if GetNumRaidMembers() > 0 then + -- In raid: Only send to RAID (includes all raid members) + SendAddonMessage("CTRA", msg, "RAID") + elseif GetNumPartyMembers() > 0 then + -- In party: Only send to PARTY + SendAddonMessage("CTRA", msg, "PARTY") + end + -- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla) + -- BG groups are handled by RAID channel end libpredict.sender:SetScript("OnUpdate", function() @@ -756,15 +872,19 @@ libpredict.sender:SetScript("OnUpdate", function() local target = this.regrowth_target or player local duration = 20 local startTime = this.regrowth_start + local rank = this.regrowth_rank - libpredict:Hot(player, target, "Regr", duration, startTime, "OnUpdate") - libpredict.sender:SendHealCommMsg("Regr/"..target.."/"..duration.."/") + libpredict:Hot(player, target, "Regr", duration, startTime, "OnUpdate", rank) + local rankStr = rank and tostring(rank) or "0" + libpredict.sender:SendHealCommMsg("Regr/"..target.."/"..duration.."/"..rankStr.."/") -- Übernehme nächsten Regrowth falls vorhanden this.regrowth_target = this.regrowth_target_next this.regrowth_start = this.regrowth_start_next + this.regrowth_rank = this.regrowth_rank_next this.regrowth_target_next = nil this.regrowth_start_next = nil + this.regrowth_rank_next = nil this.regrowth_timer = nil end end) @@ -776,6 +896,18 @@ libpredict.sender:RegisterEvent("SPELLCAST_FAILED") libpredict.sender:RegisterEvent("SPELLCAST_INTERRUPTED") libpredict.sender:RegisterEvent("SPELLCAST_DELAYED") +-- Nampower: Register SPELL_FAILED_SELF for more reliable cast fail detection +if hasNampower then + libpredict.sender:RegisterEvent("SPELL_FAILED_SELF") + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[libpredict]|r Nampower detected - SPELL_FAILED_SELF registered") + end +else + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00[libpredict]|r Nampower NOT detected - using vanilla SPELLCAST_FAILED only") + end +end + -- force cache updates libpredict.sender:RegisterEvent("UNIT_INVENTORY_CHANGED") libpredict.sender:RegisterEvent("SKILL_LINES_CHANGED") @@ -819,10 +951,17 @@ libpredict.sender:SetScript("OnEvent", function() local casttime = time if spell == REGROWTH then + -- Extract rank from spell_queue[2] which contains "spell + rank" + local fullSpell = spell_queue[2] + local rankStr = fullSpell and string.match(fullSpell, "Rank (%d+)") or nil + local rankNum = rankStr and tonumber(rankStr) or nil + if this.regrowth_timer then this.regrowth_target_next = spell_queue[3] + this.regrowth_rank_next = rankNum else this.regrowth_target = spell_queue[3] + this.regrowth_rank = rankNum end end @@ -852,6 +991,11 @@ libpredict.sender:SetScript("OnEvent", function() elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then if libpredict.sender.healing then libpredict:HealStop(player) + + -- DEBUG: Log when sending HealStop + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffff00ff[libpredict TX]|r Sending HealStop (via " .. event .. ") to group") + end libpredict.sender:SendHealCommMsg("HealStop") libpredict.sender.healing = nil elseif libpredict.sender.resurrecting then @@ -871,6 +1015,35 @@ libpredict.sender:SetScript("OnEvent", function() -- Cleanup this.current_cast = nil this.current_cast_target = nil + elseif event == "SPELL_FAILED_SELF" then + -- Nampower SPELL_FAILED_SELF: More reliable than vanilla SPELLCAST_FAILED + -- Same cleanup as SPELLCAST_FAILED + if libpredict.sender.healing then + libpredict:HealStop(player) + + -- DEBUG: Log when sending HealStop + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffff00ff[libpredict TX]|r Sending HealStop to group (SPELL_FAILED_SELF)") + end + libpredict.sender:SendHealCommMsg("HealStop") + libpredict.sender.healing = nil + elseif libpredict.sender.resurrecting then + local target = senttarget or spell_queue[3] + libpredict:RessStop(player) + libpredict.sender:SendHealCommMsg("Resurrection/stop/") + libpredict.sender:SendResCommMsg("RESNO " .. target) + libpredict.sender.resurrecting = nil + end + -- Regrowth cleanup + if this.current_cast == REGROWTH then + this.regrowth_timer = nil + this.regrowth_start = nil + this.regrowth_target_next = nil + this.regrowth_start_next = nil + end + -- Cleanup + this.current_cast = nil + this.current_cast_target = nil elseif event == "SPELLCAST_DELAYED" then if libpredict.sender.healing then libpredict:HealDelay(player, arg1) @@ -900,7 +1073,37 @@ end) function libpredict:GetHotDuration(unit, spell) if unit == UNKNOWNOBJECT or unit == UNKOWNBEING then return end - + + -- NEW: Try libdebuff first (Nampower AURA_CAST events) + if pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast then + local _, guid = UnitExists(unit) -- FIX: Get GUID, not exists boolean! + if guid then + -- Get the best (highest rank) aura cast for this spell + local spellName = spell + + -- Map short spell codes to full names + if spell == "Reju" then + spellName = REJUVENATION + elseif spell == "Regr" then + spellName = REGROWTH + elseif spell == "Renew" then + spellName = RENEW + end + + local start, duration, timeleft, rank, casterGuid = pfUI.api.libdebuff:GetBestAuraCast(guid, spellName) + + if start and duration and timeleft then + -- SUCCESS: libdebuff has accurate server-side data! + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[GetHotDuration]|r %s on %s via libdebuff: dur=%.1fs timeleft=%.1fs rank=%d", + spell, unit, duration, timeleft, rank or 0)) + end + return start, duration, timeleft + end + end + end + + -- FALLBACK: Use old prediction system (for non-Nampower clients or no AURA_CAST data) local start, duration, timeleft local now = pfUI.uf.now or GetTime() @@ -915,6 +1118,11 @@ function libpredict:GetHotDuration(unit, spell) start = spellData.start duration = spellData.duration timeleft = endTime - now + + if libpredict.debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff9900[GetHotDuration]|r %s on %s via prediction: dur=%.1fs timeleft=%.1fs", + spell, unit, duration, timeleft)) + end end end end @@ -922,4 +1130,40 @@ function libpredict:GetHotDuration(unit, spell) return start, duration, timeleft end +-- Debug command: /hotdebug - Show HoT tracking status +_G.SLASH_HOTDEBUG1 = "/hotdebug" +_G.SlashCmdList.HOTDEBUG = function() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[HoT Tracking Debug]|r") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r") + + -- Check libdebuff availability + local libdebuff_now = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false + + if libdebuff_now then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[PRIMARY]|r libdebuff integration: ACTIVE") + DEFAULT_CHAT_FRAME:AddMessage(" Using AURA_CAST events for server-side tracking") + DEFAULT_CHAT_FRAME:AddMessage(" Rank protection: ENABLED") + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[PRIMARY]|r libdebuff integration: NOT AVAILABLE") + DEFAULT_CHAT_FRAME:AddMessage(" Reason: Nampower not enabled or libdebuff outdated") + end + + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[FALLBACK]|r Legacy prediction system: ACTIVE") + DEFAULT_CHAT_FRAME:AddMessage(" Using UNIT_CASTEVENT + HealComm messages") + + -- Show active HoTs in tracking + local hotCount = 0 + for target, spells in pairs(hots) do + for spell, data in pairs(spells) do + hotCount = hotCount + 1 + end + end + + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ffff[TRACKED]|r %d HoTs in legacy system", hotCount)) + + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r") + DEFAULT_CHAT_FRAME:AddMessage("Tip: /libpredict.debug = true for verbose logging") +end + pfUI.api.libpredict = libpredict diff --git a/modules/actionbar.lua b/modules/actionbar.lua index ab84ce70..b7577957 100644 --- a/modules/actionbar.lua +++ b/modules/actionbar.lua @@ -668,7 +668,10 @@ pfUI:RegisterModule("actionbar", "vanilla", function () start, duration, enable = GetActionCooldown(button.id) end - CooldownFrame_SetTimer(button.cd, start, duration, enable) + -- Nil-protect: GetActionCooldown can return nil during macro parsing/indexing + if start and duration then + CooldownFrame_SetTimer(button.cd, start, duration, enable or 1) + end end local _, active @@ -982,7 +985,16 @@ pfUI:RegisterModule("actionbar", "vanilla", function () pageswitch:RegisterEvent("PLAYER_AURAS_CHANGED") pageswitch:RegisterEvent("PLAYER_ENTERING_WORLD") pageswitch:RegisterEvent("UNIT_CASTEVENT") + pageswitch:RegisterEvent("PLAYER_LOGOUT") pageswitch:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + this:SetScript("OnUpdate", nil) + return + end + if class ~= "DRUID" then return end -- On login/reload: full scan @@ -1230,8 +1242,8 @@ pfUI:RegisterModule("actionbar", "vanilla", function () f.count:SetJustifyH("RIGHT") f.count:SetJustifyV("BOTTOM") - -- macro spell scan - if C.bars.macroscan == "0" then + -- macro spell scan (disabled when macro addons are loaded) + if C.bars.macroscan == "0" or pfUI:MacroAddonsLoaded() then f.scanmacro, f.spellslot, f.booktype = nil, nil, nil else f.scanmacro = true diff --git a/modules/buff.lua b/modules/buff.lua index f7114416..85d820e6 100644 --- a/modules/buff.lua +++ b/modules/buff.lua @@ -76,8 +76,55 @@ pfUI:RegisterModule("buff", "vanilla:tbc", function () buff.backdrop:SetBackdropBorderColor(br,bg,bb,ba) end else - buff:Hide() - return + -- Fallback: try UnitBuff/UnitDebuff API which may be more reliable in some cases + local fallbackTexture, fallbackStacks, fallbackDispelType, fallbackSpellId + local maxSlots = buff.btype == "HELPFUL" and 32 or 16 + + if buff.id >= 1 and buff.id <= maxSlots then + if buff.btype == "HELPFUL" and C.buffs.buffs == "1" then + for i = 1, maxSlots do + local tex, stacks, dtype, spellId = UnitBuff("player", i) + if tex and i == buff.id then + fallbackTexture, fallbackStacks, fallbackDispelType, fallbackSpellId = tex, stacks, dtype, spellId + break + end + if not tex then break end + end + elseif buff.btype == "HARMFUL" and C.buffs.debuffs == "1" then + for i = 1, maxSlots do + local tex, stacks, dtype, spellId = UnitDebuff("player", i) + if tex and i == buff.id then + fallbackTexture, fallbackStacks, fallbackDispelType, fallbackSpellId = tex, stacks, dtype, spellId + break + end + if not tex then break end + end + end + end + + if fallbackTexture then + buff.mode = buff.btype + buff.fallbackSpellId = fallbackSpellId + buff.texture:SetTexture(fallbackTexture) + if buff.btype == "HARMFUL" then + if fallbackDispelType == "Magic" then + buff.backdrop:SetBackdropBorderColor(0,1,1,1) + elseif fallbackDispelType == "Poison" then + buff.backdrop:SetBackdropBorderColor(0,1,0,1) + elseif fallbackDispelType == "Curse" then + buff.backdrop:SetBackdropBorderColor(1,0,1,1) + elseif fallbackDispelType == "Disease" then + buff.backdrop:SetBackdropBorderColor(1,1,0,1) + else + buff.backdrop:SetBackdropBorderColor(1,0,0,1) + end + else + buff.backdrop:SetBackdropBorderColor(br,bg,bb,ba) + end + else + buff:Hide() + return + end end buff:Show() @@ -119,30 +166,8 @@ pfUI:RegisterModule("buff", "vanilla:tbc", function () buff.btype = btype buff.gid = i - buff:SetScript("OnUpdate", function() - if not this.next then this.next = GetTime() + .1 end - if this.next > GetTime() then return end - this.next = GetTime() + .1 - - local timeleft = 0 - local stacks = 0 - - if this.mode == this.btype then - timeleft = GetPlayerBuffTimeLeft(this.bid, this.btype) - stacks = GetPlayerBuffApplications(this.bid, this.btype) - elseif this.mode == "MAINHAND" then - local _, mhtime, mhcharge = GetWeaponEnchantInfo() - timeleft = mhtime/1000 - stacks = mhcharge - elseif this.mode == "OFFHAND" then - local _, _, _, _, ohtime, ohcharge = GetWeaponEnchantInfo() - timeleft = ohtime/1000 - stacks = ohcharge - end - - this.timer:SetText(timeleft > 0 and GetColoredTimeString(timeleft) or "") - this.stacks:SetText(stacks > 1 and stacks or "") - end) + -- PERF: OnUpdate moved to consolidated parent frame handler (see pfUI.buff:SetScript("OnUpdate")) + -- Individual buff frames no longer have their own OnUpdate buff:SetScript("OnEnter", function() GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") @@ -251,6 +276,71 @@ pfUI:RegisterModule("buff", "vanilla:tbc", function () end end) + -- PERF: Consolidated OnUpdate handler for all buff timers + -- This replaces 50 individual OnUpdate handlers with a single one + pfUI.buff:SetScript("OnUpdate", function() + local now = GetTime() + if not this.nextUpdate then this.nextUpdate = now + 0.1 end + if this.nextUpdate > now then return end + this.nextUpdate = now + 0.1 + + -- Cache weapon enchant info once per update cycle + local mh, mhtime, mhcharge, oh, ohtime, ohcharge = GetWeaponEnchantInfo() + + -- Update all visible buff buttons + local buttons = pfUI.buff.buffs.buttons + for i = 1, 32 do + local buff = buttons[i] + if buff:IsShown() then + local timeleft, stacks = 0, 0 + if buff.mode == buff.btype then + timeleft = GetPlayerBuffTimeLeft(buff.bid, buff.btype) + stacks = GetPlayerBuffApplications(buff.bid, buff.btype) + elseif buff.mode == "MAINHAND" then + timeleft = mhtime and mhtime / 1000 or 0 + stacks = mhcharge or 0 + elseif buff.mode == "OFFHAND" then + timeleft = ohtime and ohtime / 1000 or 0 + stacks = ohcharge or 0 + end + buff.timer:SetText(timeleft > 0 and GetColoredTimeString(timeleft) or "") + buff.stacks:SetText(stacks > 1 and stacks or "") + end + end + + -- Update all visible debuff buttons + buttons = pfUI.buff.debuffs.buttons + for i = 1, 16 do + local buff = buttons[i] + if buff:IsShown() then + local timeleft = GetPlayerBuffTimeLeft(buff.bid, buff.btype) + local stacks = GetPlayerBuffApplications(buff.bid, buff.btype) + buff.timer:SetText(timeleft > 0 and GetColoredTimeString(timeleft) or "") + buff.stacks:SetText(stacks > 1 and stacks or "") + end + end + + -- Update weapon buff buttons if separate + if C.buffs.separateweapons == "1" then + buttons = pfUI.buff.wepbuffs.buttons + for i = 1, 2 do + local buff = buttons[i] + if buff:IsShown() then + local timeleft, stacks = 0, 0 + if buff.mode == "MAINHAND" then + timeleft = mhtime and mhtime / 1000 or 0 + stacks = mhcharge or 0 + elseif buff.mode == "OFFHAND" then + timeleft = ohtime and ohtime / 1000 or 0 + stacks = ohcharge or 0 + end + buff.timer:SetText(timeleft > 0 and GetColoredTimeString(timeleft) or "") + buff.stacks:SetText(stacks > 1 and stacks or "") + end + end + end + end) + -- Weapon Buffs pfUI.buff.wepbuffs = CreateFrame("Frame", "pfWepBuffFrame", UIParent) pfUI.buff.wepbuffs.count = 0 diff --git a/modules/buffwatch.lua b/modules/buffwatch.lua index e9109b2a..8d83e1c1 100644 --- a/modules/buffwatch.lua +++ b/modules/buffwatch.lua @@ -125,7 +125,23 @@ pfUI:RegisterModule("buffwatch", "vanilla:tbc", function () if this.unit == "player" then GameTooltip:SetPlayerBuff(GetPlayerBuff(PLAYER_BUFF_START_ID+this.id,this.type)) elseif this.type == "HARMFUL" then - GameTooltip:SetUnitDebuff(this.unit, this.id) + -- For "only own debuffs" mode: find the REAL slot by matching spell name AND caster + local config = this.parent and this.parent.config + if config and config.selfdebuff == "1" and libdebuff then + local ownDebuffName = libdebuff:UnitOwnDebuff(this.unit, this.id) + if ownDebuffName then + -- Search through all game slots to find OUR debuff with matching name + for gameSlot = 1, 16 do + local gameName, _, _, _, _, _, _, gameCaster = libdebuff:UnitDebuff(this.unit, gameSlot) + if gameName == ownDebuffName and gameCaster == "player" then + GameTooltip:SetUnitDebuff(this.unit, gameSlot) + break + end + end + end + else + GameTooltip:SetUnitDebuff(this.unit, this.id) + end elseif this.type == "HELPFUL" then GameTooltip:SetUnitBuff(this.unit, this.id) end @@ -261,7 +277,14 @@ pfUI:RegisterModule("buffwatch", "vanilla:tbc", function () and data[3] and data[3] ~= "" -- buff has a name and data[4] and data[4] ~= "" -- buff has a texture then - local uuid = data[4] .. data[3] -- we use that to cache some values for buffs + -- For player: no slot in uuid (slots shift when other buffs expire) + -- For target: include slot (multiple players can have same debuff, slot identifies who) + local uuid + if frame.unit == "player" then + uuid = data[4] .. data[3] -- texture + name only + else + uuid = data[4] .. data[3] .. data[2] -- texture + name + slot + end -- update bar data frame.bars[bar] = frame.bars[bar] or CreateStatusBar(bar, frame) diff --git a/modules/castbar.lua b/modules/castbar.lua index 8f1e5950..ed235a02 100644 --- a/modules/castbar.lua +++ b/modules/castbar.lua @@ -6,6 +6,17 @@ pfUI:RegisterModule("castbar", "vanilla", function () local rawborder, default_border = GetBorderSize("unitframes") local cbtexture = pfUI.media[C.appearance.castbar.texture] + -- Helper function for castbar timer formatting + local function FormatCastbarTime(value) + if C.unitframes.castbardecimals == "1" then + -- 1 decimal, always floor + return string.format("%.1f", floor(value * 10) / 10) + else + -- 2 decimals (default) + return string.format("%.2f", value) + end + end + local function CreateCastbar(name, parent, unitstr, unitname) local cb = CreateFrame("Frame", name, parent or UIParent) @@ -165,10 +176,10 @@ pfUI:RegisterModule("castbar", "vanilla", function () if this.showtimer then if this.delay and this.delay > 0 then - local delay = "|cffffaaaa" .. (channel and "-" or "+") .. round(this.delay,2) .. " |r " - this.bar.right:SetText(delay .. string.format("%.2f",cur) .. " / " .. round(max,2)) + local delay = "|cffffaaaa" .. (channel and "-" or "+") .. FormatCastbarTime(this.delay) .. " |r " + this.bar.right:SetText(delay .. FormatCastbarTime(cur) .. " / " .. FormatCastbarTime(max)) else - this.bar.right:SetText(string.format("%.2f",cur) .. " / " .. round(max,2)) + this.bar.right:SetText(FormatCastbarTime(cur) .. " / " .. FormatCastbarTime(max)) end end diff --git a/modules/gui.lua b/modules/gui.lua index 4f04247d..b0d9b52e 100644 --- a/modules/gui.lua +++ b/modules/gui.lua @@ -891,6 +891,15 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () "7:" .. T["Small"], "8:" .. T["Tiny (PixelPerfect)"], }, + ["abbrevnum"] = { + "0:" .. T["Full Numbers (4250)"], + "1:" .. T["Abbreviate 2 Decimals (4.25k)"], + "2:" .. T["Abbreviate 1 Decimal (4.2k)"], + }, + ["castbardecimals"] = { + "1:" .. T["1 Decimal (2.1)"], + "2:" .. T["2 Decimals (2.14)"], + }, ["orientation"] = { "HORIZONTAL:" .. T["Horizontal"], "VERTICAL:" .. T["Vertical"], @@ -1378,7 +1387,7 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () donate:SetHeight(20) donate:SetText(T["Donate"]) donate:SetScript("OnClick", function() - pfUI.chat.urlcopy.CopyText("https://ko-fi.com/shagu") + pfUI.chat.urlcopy.CopyText("https://github.com/me0wg4ming/pfUI") end) SkinButton(donate) @@ -1388,7 +1397,7 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () github:SetHeight(20) github:SetText(T["GitHub"]) github:SetScript("OnClick", function() - pfUI.chat.urlcopy.CopyText("https://github.com/shagu/pfUI") + pfUI.chat.urlcopy.CopyText("https://github.com/me0wg4ming/pfUI") end) SkinButton(github) @@ -1398,7 +1407,7 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () website:SetHeight(20) website:SetText(T["Website"]) website:SetScript("OnClick", function() - pfUI.chat.urlcopy.CopyText("https://shagu.org/pfUI") + pfUI.chat.urlcopy.CopyText("https://github.com/me0wg4ming/pfUI") end) SkinButton(website) end) @@ -1496,7 +1505,8 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () CreateConfig(nil, T["Disable Errors in UIErrors Frame"], C.global, "errors_hide", "checkbox") CreateConfig(nil, T["Highlight Settings That Require Reload"], C.gui, "reloadmarker", "checkbox") CreateConfig(nil, T["Show Incompatible Config Entries"], C.gui, "showdisabled", "checkbox") - CreateConfig(nil, T["Abbreviate Numbers (4200 -> 4.2k)"], C.unitframes, "abbrevnum", "checkbox") + CreateConfig(nil, T["Abbreviate Numbers"], C.unitframes, "abbrevnum", "dropdown", pfUI.gui.dropdowns.abbrevnum) + CreateConfig(nil, T["Castbar Timer Decimals"], C.unitframes, "castbardecimals", "dropdown", pfUI.gui.dropdowns.castbardecimals) CreateConfig(nil, T["Abbreviate Unit Names"], C.unitframes, "abbrevname", "checkbox") CreateConfig(nil, T["Health Point Estimation"], nil, nil, "header") CreateConfig(nil, T["Estimate Enemy Health Points"], C.global, "libhealth", "checkbox") @@ -1669,7 +1679,6 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () CreateConfig(nil, T["Spell Queue Icon Size"], C.unitframes, "spellqueuesize", nil, nil, nil, nil, nil, "vanilla" ) CreateConfig(nil, T["Show Reactive Spell Indicator"], C.unitframes, "reactive_indicator", "checkbox", nil, nil, nil, nil, "vanilla" ) CreateConfig(nil, T["Reactive Indicator Size"], C.unitframes, "reactive_size", nil, nil, nil, nil, nil, "vanilla" ) - CreateConfig(nil, T["Enhanced Buff Tracking"], C.unitframes, "nampower_buffs", "checkbox", nil, nil, nil, nil, "vanilla" ) CreateConfig(nil, T["UnitXP Settings"], nil, nil, "header") CreateConfig(nil, T["Show Line of Sight Indicator"], C.unitframes, "los_indicator", "checkbox", nil, nil, nil, nil, "vanilla" ) @@ -1831,7 +1840,7 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () CreateConfig(U[c], T["Timer"], nil, nil, "header") CreateConfig(U[c], T["Show Timer Text"], C.unitframes[c], "cooldown_text", "checkbox") - CreateConfig(U[c], T["Show Timer Animation"], C.unitframes[c], "cooldown_anim", "checkbox") + CreateConfig(Reload, T["Show Timer Animation"], C.unitframes[c], "cooldown_anim", "checkbox") CreateConfig(U[c], T["Buffs"], nil, nil, "header") CreateConfig(U[c], T["Buff Position"], C.unitframes[c], "buffs", "dropdown", pfUI.gui.dropdowns.uf_buff_position) @@ -2032,7 +2041,9 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () CreateConfig(U["bars"], T["Button Animation"], C.bars, "animation", "dropdown", pfUI.gui.dropdowns.actionbuttonanimations) CreateConfig(U["bars"], T["Button Animation Trigger"], C.bars, "animmode", "dropdown", pfUI.gui.dropdowns.animationmode) CreateConfig(U["bars"], T["Show Animation On Hidden Bars"], C.bars, "animalways", "checkbox") - CreateConfig(U["bars"], T["Scan Macros For Spells"], C.bars, "macroscan", "checkbox", nil, nil, nil, nil, "vanilla") + if not pfUI:MacroAddonsLoaded() then + CreateConfig(U["bars"], T["Scan Macros For Spells"], C.bars, "macroscan", "checkbox", nil, nil, nil, nil, "vanilla") + end CreateConfig(U["bars"], T["Show Reagent Count"], C.bars, "reagents", "checkbox") CreateConfig(U["bars"], T["Highlight Equipped Items"], C.bars, "showequipped", "checkbox") CreateConfig(U["bars"], T["Equipped Item Color"], C.bars, "eqcolor", "color") @@ -2357,7 +2368,8 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () CreateConfig(U["nameplates"], T["Show Debuff Stacks"], C.nameplates.debuffs, "showstacks", "checkbox") CreateConfig(U["nameplates"], T["Enable Debuff Timers"], C.nameplates, "debufftimers", "checkbox") CreateConfig(U["nameplates"], T["Show Timer Text"], C.nameplates, "debufftext", "checkbox") - CreateConfig(nil, T["Show Timer Animation"], C.nameplates, "debuffanim", "checkbox") + CreateConfig(Reload, T["Show Timer Animation"], C.nameplates, "debuffanim", "checkbox") + CreateConfig(U["nameplates"], T["Only Show Own Debuffs (|cffffaaaaExperimental|r)"], C.nameplates, "selfdebuff", "checkbox") CreateConfig(U["nameplates"], T["Filter Mode"], C.nameplates.debuffs, "filter", "dropdown", pfUI.gui.dropdowns.buffbarfilter) CreateConfig(U["nameplates"], T["Blacklist"], C.nameplates.debuffs, "blacklist", "list") @@ -2448,7 +2460,8 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () CreateGUIEntry(T["Components"], T["Modules"], function() table.sort(pfUI.modules) for i,m in pairs(pfUI.modules) do - if m ~= "gui" then + -- skip gui and macrotweak when macro addons are loaded + if m ~= "gui" and not (m == "macrotweak" and pfUI:MacroAddonsLoaded()) then -- create disabled entry if not existing and display pfUI:UpdateConfig("disabled", nil, m, "0") CreateConfig(nil, T["Disable Module"] .. " " .. m, C.disabled, m, "checkbox") @@ -2465,4 +2478,4 @@ pfUI:RegisterModule("gui", "vanilla:tbc", function () end end) end -end) +end) \ No newline at end of file diff --git a/modules/macrotweak.lua b/modules/macrotweak.lua index f6c4cd50..3d45d862 100644 --- a/modules/macrotweak.lua +++ b/modules/macrotweak.lua @@ -1,6 +1,6 @@ pfUI:RegisterModule("macrotweak", "vanilla", function () - -- disable macrotweak when SuperCleveRoidMacros is loaded - if IsAddOnLoaded("SuperCleveRoidMacros") then return end + -- disable macrotweak when macro addons are loaded + if IsAddOnLoaded("Supermacro") or IsAddOnLoaded("SuperCleveRoidMacros") or IsAddOnLoaded("UltimaMacros") then return end -- do not write macro calls into chat input history if ChatFrameEditBox._AddHistoryLine then diff --git a/modules/nameplates.lua b/modules/nameplates.lua index 918f3dc9..7b4926a2 100644 --- a/modules/nameplates.lua +++ b/modules/nameplates.lua @@ -4,7 +4,6 @@ pfUI:RegisterModule("nameplates", "vanilla", function () -- check for SuperWoW support (use SUPERWOW_VERSION global) local superwow_active = SUPERWOW_VERSION ~= nil - --print("pfUI Nameplates: SuperWoW " .. (superwow_active and "ACTIVE" or "INACTIVE")) -- Local function references for performance local GetTime = GetTime @@ -19,6 +18,10 @@ pfUI:RegisterModule("nameplates", "vanilla", function () local UnitCanAssist = UnitCanAssist local UnitCastingInfo = UnitCastingInfo local UnitChannelInfo = UnitChannelInfo + local UnitHealth = UnitHealth + local UnitHealthMax = UnitHealthMax + local UnitMana = UnitMana + local UnitManaMax = UnitManaMax local pairs = pairs local tonumber = tonumber local strlower = strlower @@ -27,6 +30,7 @@ pfUI:RegisterModule("nameplates", "vanilla", function () local floor = floor local ceil = ceil local abs = abs + local mathmod = math.mod local unitcolors = { ["ENEMY_NPC"] = { .9, .2, .3, .8 }, @@ -56,7 +60,8 @@ pfUI:RegisterModule("nameplates", "vanilla", function () } -- catch all nameplates - local childs, regions, plate + local childs = {} -- PERF: Reuse table instead of creating new one each scan + local regions, plate local initialized = 0 -- Friendly zone nameplate disable state @@ -66,31 +71,111 @@ pfUI:RegisterModule("nameplates", "vanilla", function () local parentcount = 0 local platecount = 0 local registry = {} - -- NEW: Cast event cache like Overhead.lua - local CastEvents = {} + + -- ============================================================================ + -- OPTIMIZATION: GUID-based registries for O(1) lookups + -- ============================================================================ + local guidRegistry = {} -- guid -> plate (for direct event routing) + + -- Helper function to safely access libdebuff cast data + local function GetCastInfo(guid) + return pfUI.libdebuff_casts and pfUI.libdebuff_casts[guid] + end + + local debuffCache = {} -- guid -> { [spellID] = { start, duration } } + local threatMemory = {} -- guid -> true if mob had player targeted + local debuffSeen = {} -- reusable table for debuff tracking (avoid GC churn) + + -- PERF: Track visible plate count for adaptive throttling + local visiblePlateCount = 0 + local lastVisibleCheck = 0 + + -- wipe polyfill + local wipe = wipe or function(t) for k in pairs(t) do t[k] = nil end end + + -- Player GUID for filtering local _, PlayerGUID = UnitExists("player") + -- ============================================================================ + -- OPTIMIZATION: Config caching + -- ============================================================================ + local cfg = {} + local function CacheConfig() + cfg.showcastbar = C.nameplates["showcastbar"] == "1" + cfg.targetcastbar = C.nameplates["targetcastbar"] == "1" + cfg.notargalpha = tonumber(C.nameplates.notargalpha) or 0.5 + if cfg.notargalpha > 1 then cfg.notargalpha = cfg.notargalpha / 100 end + cfg.namefightcolor = C.nameplates.namefightcolor == "1" + cfg.spellname = C.nameplates.spellname == "1" + cfg.showhp = C.nameplates.showhp == "1" + cfg.showdebuffs = C.nameplates["showdebuffs"] == "1" + cfg.targetzoom = C.nameplates.targetzoom == "1" + cfg.zoomval = (tonumber(C.nameplates.targetzoomval) or 0.4) + 1 + cfg.width = tonumber(C.nameplates.width) or 120 + cfg.heighthealth = tonumber(C.nameplates.heighthealth) or 8 + cfg.targetglow = C.nameplates.targetglow == "1" + cfg.targethighlight = C.nameplates.targethighlight == "1" + cfg.outcombatstate = C.nameplates.outcombatstate == "1" + cfg.barcombatstate = C.nameplates.barcombatstate == "1" + cfg.ccombatcasting = C.nameplates.ccombatcasting == "1" + cfg.ccombatthreat = C.nameplates.ccombatthreat == "1" + cfg.ccombatnothreat = C.nameplates.ccombatnothreat == "1" + cfg.ccombatstun = C.nameplates.ccombatstun == "1" + cfg.ccombatofftank = C.nameplates.ccombatofftank == "1" + cfg.use_unitfonts = C.nameplates.use_unitfonts == "1" + cfg.font_size = cfg.use_unitfonts and C.global.font_unit_size or C.global.font_size + cfg.hptextformat = C.nameplates.hptextformat + -- NEW: Cache debuff config + cfg.debufftimers = C.nameplates.debufftimers == "1" + cfg.debuffanim = tonumber(C.nameplates.debuffanim) or 0 + cfg.debufftext = tonumber(C.nameplates.debufftext) or 1 + end + + -- ============================================================================ + -- OPTIMIZATION: Frame state cache + -- ============================================================================ + local frameState = { + now = 0, + hasTarget = false, + targetGuid = nil, + hasMouseover = false, + } + -- cache default border color local er, eg, eb, ea = GetStringColor(pfUI_config.appearance.border.color) local function GetCombatStateColor(guid) + -- PERF: Quick exit if not in combat + if not UnitAffectingCombat("player") then return false end + if not UnitAffectingCombat(guid) then return false end + if UnitCanAssist("player", guid) then return false end + local target = guid.."target" local color = false - if UnitAffectingCombat("player") and UnitAffectingCombat(guid) and not UnitCanAssist("player", guid) then - if C.nameplates.ccombatcasting == "1" and (UnitCastingInfo(guid) or UnitChannelInfo(guid)) then - color = combatstate.CASTING - elseif C.nameplates.ccombatthreat == "1" and UnitIsUnit(target, "player") then - color = combatstate.THREAT - elseif C.nameplates.ccombatofftank == "1" and UnitName(target) and offtanks[strlower(UnitName(target))] then - color = combatstate.OFFTANK - elseif C.nameplates.ccombatofftank == "1" and pfUI.uf and pfUI.uf.raid and pfUI.uf.raid.tankrole[UnitName(target)] then - color = combatstate.OFFTANK - elseif C.nameplates.ccombatnothreat == "1" and UnitExists(target) then - color = combatstate.NOTHREAT - elseif C.nameplates.ccombatstun == "1" and not UnitExists(target) and not UnitIsPlayer(guid) then - color = combatstate.STUN - end + local castInfo = GetCastInfo(guid) + local isCasting = castInfo and castInfo.endTime and frameState.now < castInfo.endTime + local targetingPlayer = UnitIsUnit(target, "player") + + -- Remember if mob targets player, clear only when targeting someone else while NOT casting + if targetingPlayer then + threatMemory[guid] = true + elseif UnitExists(target) and not isCasting then + threatMemory[guid] = nil + end + + if cfg.ccombatcasting and isCasting then + color = combatstate.CASTING + elseif cfg.ccombatthreat and (targetingPlayer or threatMemory[guid]) then + color = combatstate.THREAT + elseif cfg.ccombatofftank and UnitName(target) and offtanks[strlower(UnitName(target))] then + color = combatstate.OFFTANK + elseif cfg.ccombatofftank and pfUI.uf and pfUI.uf.raid and pfUI.uf.raid.tankrole[UnitName(target)] then + color = combatstate.OFFTANK + elseif cfg.ccombatnothreat and UnitExists(target) then + color = combatstate.NOTHREAT + elseif cfg.ccombatstun and not UnitExists(target) and not UnitIsPlayer(guid) then + color = combatstate.STUN end return color @@ -298,31 +383,25 @@ pfUI:RegisterModule("nameplates", "vanilla", function () plate.debuffs[index].stacks:SetJustifyV("BOTTOM") plate.debuffs[index].stacks:SetTextColor(1,1,0) - -- Read config for cooldown animation and text - local cooldown_anim = tonumber(C.nameplates.debuffanim) or 0 - local cooldown_text = tonumber(C.nameplates.debufftext) or 1 - - if pfUI.client <= 11200 then - -- Animation enabled: Use Model frame with CooldownFrameTemplate - plate.debuffs[index].cd = CreateFrame(COOLDOWN_FRAME_TYPE, plate.platename.."Debuff"..index.."Cooldown", plate.debuffs[index], "CooldownFrameTemplate") - plate.debuffs[index].cd:SetAllPoints(plate.debuffs[index]) - plate.debuffs[index].cd.pfCooldownStyleAnimation = 1 - plate.debuffs[index].cd.pfCooldownStyleText = cooldown_text - plate.debuffs[index].cd.pfCooldownType = "ALL" - else - -- Animation disabled: Create fake cooldown frame for performance (like ShaguPlates) + -- PERF: Use lightweight fake cooldown frame when animation disabled + -- The Model-based CooldownFrameTemplate causes major lag with many nameplates + if pfUI.client <= 11200 and cfg.debuffanim ~= 1 then plate.debuffs[index].cd = CreateFrame("Frame", plate.platename.."Debuff"..index.."Cooldown", plate.debuffs[index]) plate.debuffs[index].cd:SetAllPoints(plate.debuffs[index]) - - -- Add dummy functions so CooldownFrame_SetTimer doesn't crash + plate.debuffs[index].cd:SetScript("OnUpdate", CooldownFrame_OnUpdateModel) plate.debuffs[index].cd.AdvanceTime = DoNothing plate.debuffs[index].cd.SetSequence = DoNothing plate.debuffs[index].cd.SetSequenceTime = DoNothing - - plate.debuffs[index].cd.pfCooldownStyleAnimation = 0 - plate.debuffs[index].cd.pfCooldownStyleText = cooldown_text - plate.debuffs[index].cd.pfCooldownType = "ALL" + else + -- Use CooldownFrameTemplate for animation or TBC+ + plate.debuffs[index].cd = CreateFrame(COOLDOWN_FRAME_TYPE, plate.platename.."Debuff"..index.."Cooldown", plate.debuffs[index], "CooldownFrameTemplate") + plate.debuffs[index].cd:SetAllPoints(plate.debuffs[index]) end + + -- Set initial config flags (will be cached per-cooldown later) + plate.debuffs[index].cd.pfCooldownStyleAnimation = cfg.debuffanim + plate.debuffs[index].cd.pfCooldownStyleText = cfg.debufftext + plate.debuffs[index].cd.pfCooldownType = "ALL" end local function UpdateDebuffConfig(nameplate, i) @@ -356,25 +435,62 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplate.debuffs[i]:SetWidth(tonumber(C.nameplates.debuffsize)) nameplate.debuffs[i]:SetHeight(tonumber(C.nameplates.debuffsize)) + + -- Update cooldown display settings + if nameplate.debuffs[i].cd then + local cooldown_text = tonumber(C.nameplates.debufftext) or 1 + local cooldown_anim = tonumber(C.nameplates.debuffanim) or 0 + nameplate.debuffs[i].cd.pfCooldownStyleText = cooldown_text + nameplate.debuffs[i].cd.pfCooldownStyleAnimation = cooldown_anim + + -- Update scale for TBC+ + if pfUI.client > 11200 then + local debuffsize = tonumber(C.nameplates.debuffsize) + local cdScale = debuffsize / 32 + nameplate.debuffs[i].cd:SetScale(cdScale) + end + end end -- create nameplate core - local nameplates = CreateFrame("Frame", "pfNameplates", UIParent) - nameplates:RegisterEvent("PLAYER_ENTERING_WORLD") - nameplates:RegisterEvent("PLAYER_TARGET_CHANGED") - nameplates:RegisterEvent("UNIT_COMBO_POINTS") - nameplates:RegisterEvent("PLAYER_COMBO_POINTS") - nameplates:RegisterEvent("UNIT_AURA") - nameplates:RegisterEvent("ZONE_CHANGED_NEW_AREA") +local nameplates = CreateFrame("Frame", "pfNameplates", UIParent) +nameplates:RegisterEvent("PLAYER_ENTERING_WORLD") +nameplates:RegisterEvent("PLAYER_TARGET_CHANGED") +nameplates:RegisterEvent("PLAYER_LOGOUT") +nameplates:RegisterEvent("UNIT_COMBO_POINTS") +nameplates:RegisterEvent("PLAYER_COMBO_POINTS") +nameplates:RegisterEvent("ZONE_CHANGED_NEW_AREA") - -- NEW: Register cast events like Overhead.lua - if superwow_active then - nameplates:RegisterEvent("UNIT_CASTEVENT") + -- Cast tracking handled by libdebuff (SPELL_START/GO/FAILED events) + -- No local event registration needed + + -- Callback from libdebuff when auras change (GUID-based, event-driven) + nameplates.OnAuraUpdate = function(self, guid) + if not guid then return end + + -- GUID is actual GUID (0xF13000...) from SuperWoW/Nampower events + local plate = guidRegistry[guid] + if plate and plate.nameplate then + -- Mark nameplate for aura update in next OnUpdate cycle + plate.nameplate.auraUpdate = true + end end nameplates:SetScript("OnEvent", function() - if event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then + -- Stop event handling during logout to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + this:SetScript("OnUpdate", nil) + if nameplates.mouselook then + nameplates.mouselook:SetScript("OnUpdate", nil) + end + return + + elseif event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then if event == "PLAYER_ENTERING_WORLD" then + _, PlayerGUID = UnitExists("player") + CacheConfig() this:SetGameVariables() end @@ -419,47 +535,27 @@ pfUI:RegisterModule("nameplates", "vanilla", function () savedFriendlyState = nil end end - elseif event == "UNIT_CASTEVENT" then - -- NEW: Event-based cast handling from Overhead.lua - local casterGUID = arg1 - if casterGUID == PlayerGUID then return end - - local eventType = arg3 -- "START", "CAST", "FAIL", "CHANNEL" - local spellID = arg4 - local castDuration = arg5 - local spellName, _, icon = SpellInfo(spellID) - - if eventType == "MAINHAND" or eventType == "OFFHAND" then - return - end - - if eventType == "CAST" then - if not CastEvents[casterGUID] or spellID ~= CastEvents[casterGUID].spell then - return + + elseif event == "PLAYER_TARGET_CHANGED" then + -- Flag target plate for update via GUID registry + local _, targetGuid = UnitExists("target") + if targetGuid then + local plate = guidRegistry[targetGuid] + if plate and plate.nameplate then + plate.nameplate.targetUpdate = true end end - - if eventType == "START" or eventType == "CHANNEL" then - if not CastEvents[casterGUID] then - CastEvents[casterGUID] = {} + -- Also propagate to all plates for alpha/strata updates + this.eventcache = true + + elseif event == "PLAYER_COMBO_POINTS" or event == "UNIT_COMBO_POINTS" then + -- Only flag the target plate for combo point update + local _, targetGuid = UnitExists("target") + if targetGuid then + local plate = guidRegistry[targetGuid] + if plate and plate.nameplate then + plate.nameplate.comboUpdate = true end - wipe(CastEvents[casterGUID]) - CastEvents[casterGUID].event = eventType - CastEvents[casterGUID].spellID = spellID - CastEvents[casterGUID].spellName = spellName - CastEvents[casterGUID].icon = icon - CastEvents[casterGUID].startTime = GetTime() - CastEvents[casterGUID].endTime = castDuration and GetTime() + castDuration / 1000 - CastEvents[casterGUID].duration = castDuration and castDuration / 1000 or nil - elseif eventType == "FAIL" then - if CastEvents[casterGUID] then - wipe(CastEvents[casterGUID]) - end - end - - -- Propagate cast event to all nameplates - for plate in pairs(registry) do - plate.eventcache = true end else this.eventcache = true @@ -467,8 +563,10 @@ pfUI:RegisterModule("nameplates", "vanilla", function () end) nameplates:SetScript("OnUpdate", function() - -- Throttle main scanner to reasonable rate - if (this.tick or 0) > GetTime() then return else this.tick = GetTime() + 0.05 end + -- PERF: Cache GetTime() once per frame + frameState.now = GetTime() + frameState.hasTarget, frameState.targetGuid = UnitExists("target") + frameState.hasMouseover = UnitExists("mouseover") -- propagate events to all nameplates if this.eventcache then @@ -478,19 +576,76 @@ pfUI:RegisterModule("nameplates", "vanilla", function () end end - -- detect new nameplates - parentcount = WorldFrame:GetNumChildren() - if initialized < parentcount then - childs = { WorldFrame:GetChildren() } - for i = initialized + 1, parentcount do - plate = childs[i] - if IsNamePlate(plate) and not registry[plate] then - nameplates.OnCreate(plate) - registry[plate] = plate + -- PERF: Update visible plate count periodically for adaptive throttling + if frameState.now - lastVisibleCheck > 0.5 then + lastVisibleCheck = frameState.now + local count = 0 + for plate in pairs(registry) do + if plate:IsVisible() then count = count + 1 end + end + visiblePlateCount = count + end + + -- Throttle ONLY the nameplate scanner + local scanThrottle = nameplates.combat and nameplates.combat.inCombat and 0.1 or 0.05 + local shouldScan = (this.tick or 0) <= frameState.now + if shouldScan then + this.tick = frameState.now + scanThrottle + + -- detect new nameplates + parentcount = WorldFrame:GetNumChildren() + if initialized < parentcount then + -- PERF: Reuse childs table instead of creating new one + local newchilds = { WorldFrame:GetChildren() } + for i = 1, parentcount do + childs[i] = newchilds[i] + end + + for i = initialized + 1, parentcount do + plate = childs[i] + if IsNamePlate(plate) and not registry[plate] then + nameplates.OnCreate(plate) + registry[plate] = plate + end + end + + initialized = parentcount + end + end + + -- Central OnUpdate for all visible plates + for plate in pairs(registry) do + if plate:IsVisible() then + nameplates.OnUpdate(plate, frameState) + else + -- PERF: Clean up ALL caches for hidden plates to prevent memory leak + local guid = plate.nameplate and plate.nameplate.cachedGuid + if guid then + -- Remove from guidRegistry + if guidRegistry[guid] == plate then + guidRegistry[guid] = nil + end + + -- Clean cast cache ONLY if cast has expired + -- (Don't delete active casts just because plate was hidden briefly) + local castInfo = GetCastInfo(guid) + if castInfo and castInfo.endTime and castInfo.endTime < frameState.now then + if pfUI.libdebuff_casts then + pfUI.libdebuff_casts[guid] = nil + end + end + + -- Clean debuffCache + if debuffCache[guid] then + debuffCache[guid] = nil + end + + -- Clean threatMemory + if threatMemory[guid] then + threatMemory[guid] = nil + end end end - - initialized = parentcount end end) @@ -498,13 +653,22 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplates.combat = CreateFrame("Frame") nameplates.combat:RegisterEvent("PLAYER_ENTER_COMBAT") nameplates.combat:RegisterEvent("PLAYER_LEAVE_COMBAT") + nameplates.combat:RegisterEvent("PLAYER_LOGOUT") nameplates.combat:SetScript("OnEvent", function() - if event == "PLAYER_ENTER_COMBAT" then + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + elseif event == "PLAYER_ENTER_COMBAT" then this.inCombat = 1 if PlayerFrame then PlayerFrame.inCombat = 1 end elseif event == "PLAYER_LEAVE_COMBAT" then this.inCombat = nil if PlayerFrame then PlayerFrame.inCombat = nil end + -- Clear threat memory when leaving combat + for k in pairs(threatMemory) do + threatMemory[k] = nil + end end end) @@ -635,9 +799,13 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplate.castbar = castbar end + -- Stagger tick to spread updates across frames (0.05s apart per plate) + nameplate.tick = GetTime() + mathmod(platecount, 10) * 0.05 + parent.nameplate = nameplate HookScript(parent, "OnShow", nameplates.OnShow) - HookScript(parent, "OnUpdate", nameplates.OnUpdate) + -- NOTE: OnUpdate is now handled centrally, not per-plate/ + parent:SetScript("OnUpdate", nil) -- Disable Blizzard's OnUpdate nameplates.OnConfigChange(parent) nameplates.OnShow(parent) @@ -808,12 +976,12 @@ pfUI:RegisterModule("nameplates", "vanilla", function () -- always make sure to keep plate visible plate:Show() - if target and C.nameplates.targetglow == "1" then + if target and cfg.targetglow then plate.glow:Show() else plate.glow:Hide() end -- target indicator - if superwow_active and C.nameplates.outcombatstate == "1" then + if superwow_active and cfg.outcombatstate then local guid = plate.parent:GetName(1) or "" -- determine color based on combat state @@ -822,7 +990,7 @@ pfUI:RegisterModule("nameplates", "vanilla", function () -- set border color plate.health.backdrop:SetBackdropBorderColor(color.r, color.g, color.b, color.a) - elseif target and C.nameplates.targethighlight == "1" then + elseif target and cfg.targethighlight then plate.health.backdrop:SetBackdropBorderColor(plate.health.hlr, plate.health.hlg, plate.health.hlb, plate.health.hla) elseif C.nameplates.outfriendlynpc == "1" and unittype == "FRIENDLY_NPC" then plate.health.backdrop:SetBackdropBorderColor(unpack(unitcolors[unittype])) @@ -899,30 +1067,44 @@ pfUI:RegisterModule("nameplates", "vanilla", function () plate.health:SetMinMaxValues(hpmin, hpmax) plate.health:SetValue(hp) - if C.nameplates.showhp == "1" then + if cfg.showhp then local rhp, rhpmax, estimated - if hpmax > 100 or (round(hpmax/100*hp) ~= hp) then - rhp, rhpmax = hp, hpmax - elseif pfUI.libhealth and pfUI.libhealth.enabled then - rhp, rhpmax, estimated = pfUI.libhealth:GetUnitHealthByName(name,level,tonumber(hp),tonumber(hpmax)) + + -- Try Nampower first for real HP values via GUID + local guid = superwow_active and plate.parent:GetName(1) or nil + if guid and GetUnitField then + local npHp = GetUnitField(guid, "health") + local npMaxHp = GetUnitField(guid, "maxHealth") + if npHp and npHp > 0 and npMaxHp and npMaxHp > 0 then + rhp, rhpmax = npHp, npMaxHp + end + end + + -- Fallback to existing methods + if not rhp then + if hpmax > 100 or (round(hpmax/100*hp) ~= hp) then + rhp, rhpmax = hp, hpmax + elseif pfUI.libhealth and pfUI.libhealth.enabled then + rhp, rhpmax, estimated = pfUI.libhealth:GetUnitHealthByName(name,level,tonumber(hp),tonumber(hpmax)) + end end - local setting = C.nameplates.hptextformat - local hasdata = ( estimated or hpmax > 100 or (round(hpmax/100*hp) ~= hp) ) + local setting = cfg.hptextformat + local hasdata = ( rhp and rhpmax ) or estimated or hpmax > 100 or (round(hpmax/100*hp) ~= hp) - if setting == "curperc" and hasdata then + if setting == "curperc" and hasdata and rhp then plate.health.text:SetText(string.format("%s | %s%%", Abbreviate(rhp), ceil(hp/hpmax*100))) - elseif setting == "cur" and hasdata then + elseif setting == "cur" and hasdata and rhp then plate.health.text:SetText(string.format("%s", Abbreviate(rhp))) - elseif setting == "curmax" and hasdata then + elseif setting == "curmax" and hasdata and rhp then plate.health.text:SetText(string.format("%s - %s", Abbreviate(rhp), Abbreviate(rhpmax))) - elseif setting == "curmaxs" and hasdata then + elseif setting == "curmaxs" and hasdata and rhp then plate.health.text:SetText(string.format("%s / %s", Abbreviate(rhp), Abbreviate(rhpmax))) - elseif setting == "curmaxperc" and hasdata then + elseif setting == "curmaxperc" and hasdata and rhp then plate.health.text:SetText(string.format("%s - %s | %s%%", Abbreviate(rhp), Abbreviate(rhpmax), ceil(hp/hpmax*100))) - elseif setting == "curmaxpercs" and hasdata then + elseif setting == "curmaxpercs" and hasdata and rhp then plate.health.text:SetText(string.format("%s / %s | %s%%", Abbreviate(rhp), Abbreviate(rhpmax), ceil(hp/hpmax*100))) - elseif setting == "deficit" then + elseif setting == "deficit" and rhp then plate.health.text:SetText(string.format("-%s" .. (hasdata and "" or "%%"), Abbreviate(rhpmax - rhp))) else -- "percent" as fallback plate.health.text:SetText(string.format("%s%%", ceil(hp/hpmax*100))) @@ -943,7 +1125,7 @@ pfUI:RegisterModule("nameplates", "vanilla", function () r, g, b, a = .5, .5, .5, .8 end - if superwow_active and C.nameplates.barcombatstate == "1" then + if superwow_active and cfg.barcombatstate then local guid = plate.parent:GetName(1) or "" local color = GetCombatStateColor(guid) @@ -971,7 +1153,7 @@ pfUI:RegisterModule("nameplates", "vanilla", function () -- update debuffs local index = 1 - if C.nameplates["showdebuffs"] == "1" then + if cfg.showdebuffs then local verify = string.format("%s:%s", (name or ""), (level or "")) -- update cached debuffs @@ -1008,18 +1190,26 @@ pfUI:RegisterModule("nameplates", "vanilla", function () plate.debuffs[index].stacks:Hide() end - if duration and timeleft and C.nameplates.debufftimers == "1" then - -- Read config values for cooldown display - local cooldown_anim = tonumber(C.nameplates.debuffanim) or 0 - local cooldown_text = tonumber(C.nameplates.debufftext) or 1 + if duration and timeleft and cfg.debufftimers then + -- PERF: Only update cooldown if start time changed significantly + local cd = plate.debuffs[index].cd + local newStart = GetTime() + timeleft - duration - -- Ensure cooldown flags are set - plate.debuffs[index].cd.pfCooldownStyleText = cooldown_text - plate.debuffs[index].cd.pfCooldownStyleAnimation = cooldown_anim - plate.debuffs[index].cd.pfCooldownType = "ALL" - - plate.debuffs[index].cd:Show() - CooldownFrame_SetTimer(plate.debuffs[index].cd, GetTime() + timeleft - duration, duration, 1) + if not cd.cachedStart or abs(cd.cachedStart - newStart) > 0.5 then + -- Update config flags only on first run or config change + if not cd.configCached or cd.cachedAnim ~= cfg.debuffanim or cd.cachedText ~= cfg.debufftext then + cd.pfCooldownStyleAnimation = cfg.debuffanim + cd.pfCooldownStyleText = cfg.debufftext + cd:SetAlpha(cfg.debuffanim == 1 and 1 or 0) + cd.cachedAnim = cfg.debuffanim + cd.cachedText = cfg.debufftext + cd.configCached = true + end + + cd:Show() + CooldownFrame_SetTimer(cd, newStart, duration, 1) + cd.cachedStart = newStart + end end index = index + 1 @@ -1039,45 +1229,111 @@ pfUI:RegisterModule("nameplates", "vanilla", function () local frame = frame or this local nameplate = frame.nameplate + -- Register GUID when plate becomes visible + if superwow_active then + local guid = frame:GetName(1) + if guid then + nameplate.cachedGuid = guid + guidRegistry[guid] = frame + end + end + nameplates:OnDataChanged(nameplate) end - nameplates.OnUpdate = function(frame) - local frame = frame or this + nameplates.OnUpdate = function(frame, state) local nameplate = frame.nameplate - local now = GetTime() + local now = state and state.now or GetTime() - -- Performance: Skip invisible frames immediately - local isVisible = frame:IsVisible() - if not isVisible then return end + -- Update GUID registry (lightweight, needed for event routing) + if superwow_active then + local guid = frame:GetName(1) + if guid and guid ~= nameplate.cachedGuid then + if nameplate.cachedGuid and guidRegistry[nameplate.cachedGuid] == frame then + guidRegistry[nameplate.cachedGuid] = nil + end + nameplate.cachedGuid = guid + guidRegistry[guid] = frame + end + end - -- Intelligent throttling based on target/castbar status - local target = UnitExists("target") and frame:GetAlpha() >= 0.99 or nil + -- PERF: Intelligent throttling based on target/castbar status and plate count + local target = state and state.hasTarget and frame:GetAlpha() >= 0.99 or nil local isCasting = nameplate.castbar and nameplate.castbar:IsShown() local throttle if target or isCasting then throttle = 0.02 -- 50 FPS for target OR active castbar + elseif visiblePlateCount > 20 then + throttle = 0.15 -- ~7 FPS for mass pulls (20+ plates) else throttle = 0.1 -- 10 FPS for others (healthbar updates) end - if (nameplate.lasttick or 0) + throttle > now then return end + -- Check for pending event updates (these bypass throttle for immediate response) + local hasEventUpdate = nameplate.eventcache or nameplate.auraUpdate or nameplate.castUpdate or nameplate.targetUpdate or nameplate.comboUpdate + + -- Event updates bypass throttle + if not hasEventUpdate and (nameplate.lasttick or 0) + throttle > now then return end nameplate.lasttick = now + -- ========================================================================= + -- EVERYTHING BELOW RUNS AT THROTTLED RATE (50 FPS target, 10 FPS others) + -- ========================================================================= + local update local original = nameplate.original local name = original.name:GetText() - local mouseover = UnitExists("mouseover") and original.glow:IsShown() or nil - local namefightcolor = C.nameplates.namefightcolor == "1" + local mouseover = state and state.hasMouseover and original.glow:IsShown() or nil -- trigger queued event update - if nameplate.eventcache then + if hasEventUpdate then nameplates:OnDataChanged(nameplate) nameplate.eventcache = nil + nameplate.auraUpdate = nil + nameplate.castUpdate = nil + nameplate.targetUpdate = nil + nameplate.comboUpdate = nil end - -- OPTIMIZED: Cache strata changes + -- ========================================================================= + -- VANILLA OVERLAP/CLICKTHROUGH HANDLING + -- ========================================================================= + if pfUI.client <= 11200 then + local useOverlap = C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0" + local clickable = C.nameplates["clickthrough"] ~= "1" + + if not clickable then + frame:EnableMouse(false) + nameplate:EnableMouse(false) + else + local plate = useOverlap and nameplate or frame + plate:EnableMouse(clickable) + end + + if C.nameplates["overlap"] == "1" then + if frame:GetWidth() > 1 then + frame:SetWidth(1) + frame:SetHeight(1) + end + else + if not nameplate.dwidth then + nameplate.dwidth = floor(nameplate:GetWidth() * UIParent:GetScale()) + end + + if floor(frame:GetWidth()) ~= nameplate.dwidth then + frame:SetWidth(nameplate:GetWidth() * UIParent:GetScale()) + frame:SetHeight(nameplate:GetHeight() * UIParent:GetScale()) + end + end + + local mouseEnabled = nameplate:IsMouseEnabled() + if C.nameplates["clickthrough"] == "0" and C.nameplates["overlap"] == "1" and SpellIsTargeting() == mouseEnabled then + nameplate:EnableMouse(not mouseEnabled) + end + end + + -- Cache strata changes if nameplate.istarget ~= target then nameplate.target_strata = nil end @@ -1092,21 +1348,11 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplate.istarget = target - -- set non-target plate alpha (cached to prevent flicker) - local configAlpha = tonumber(C.nameplates.notargalpha) - if not configAlpha or configAlpha < 0 then - configAlpha = 100 - end - - -- Ensure we're working with 0-1 range - if configAlpha > 1 then - configAlpha = configAlpha / 100 - end - - local desiredAlpha = (target or not UnitExists("target")) and 1 or configAlpha + -- Set non-target plate alpha + local configAlpha = cfg.notargalpha or 0.5 + local desiredAlpha = (target or not state.hasTarget) and 1 or configAlpha if nameplate.cachedAlpha ~= desiredAlpha then - -- Setze nur die nameplate Alpha, nicht den parent frame nameplate:SetAlpha(desiredAlpha) nameplate.cachedAlpha = desiredAlpha end @@ -1129,13 +1375,22 @@ pfUI:RegisterModule("nameplates", "vanilla", function () update = true end - -- trigger update when name color changed + -- trigger update when name color changed (includes combat state check) local r, g, b = original.name:GetTextColor() - if r + g + b ~= nameplate.cache.namecolor then + local inCombatWithPlayer = false + if superwow_active and cfg.namefightcolor then + local guid = nameplate.cachedGuid + if guid then + inCombatWithPlayer = UnitAffectingCombat(guid) and UnitAffectingCombat("player") + end + end + + if r + g + b ~= nameplate.cache.namecolor or (cfg.namefightcolor and nameplate.cache.inCombat ~= inCombatWithPlayer) then nameplate.cache.namecolor = r + g + b + nameplate.cache.inCombat = inCombatWithPlayer - if namefightcolor then - if r > .9 and g < .2 and b < .2 then + if cfg.namefightcolor then + if (r > .9 and g < .2 and b < .2) or inCombatWithPlayer then nameplate.name:SetTextColor(1,0.4,0.2,1) else nameplate.name:SetTextColor(r,g,b,1) @@ -1155,10 +1410,11 @@ pfUI:RegisterModule("nameplates", "vanilla", function () update = true end - -- scan for debuff timeouts + -- PERF: scan for debuff timeouts using indexed access instead of pairs() if nameplate.debuffcache then - for id, data in pairs(nameplate.debuffcache) do - if ( not data.stop or data.stop < now ) and not data.empty then + for id = 1, 16 do + local data = nameplate.debuffcache[id] + if data and ( not data.stop or data.stop < now ) and not data.empty then data.empty = true update = true end @@ -1176,12 +1432,12 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplate.tick = now + .5 end - -- OPTIMIZED: Cache zoom dimensions - if target and C.nameplates.targetzoom == "1" then + -- Zoom animation + if target and cfg.targetzoom then if not nameplate.health.zoomed then - local zoomval = tonumber(C.nameplates.targetzoomval)+1 - local wc = tonumber(C.nameplates.width)*zoomval - local hc = tonumber(C.nameplates.heighthealth)*(zoomval*.9) + local zoomval = cfg.zoomval + local wc = cfg.width * zoomval + local hc = cfg.heighthealth * (zoomval * .9) nameplate.health.targetWidth = wc nameplate.health.targetHeight = hc end @@ -1189,27 +1445,27 @@ pfUI:RegisterModule("nameplates", "vanilla", function () local w, h = nameplate.health:GetWidth(), nameplate.health:GetHeight() local wc, hc = nameplate.health.targetWidth, nameplate.health.targetHeight - -- Nutze kleine Toleranz um Fließkomma-Schwankungen zu vermeiden - if wc > w + 0.5 then - nameplate.health:SetWidth(w*1.05) - nameplate.health.zoomTransition = true - elseif hc > h + 0.5 then - nameplate.health:SetHeight(h*1.05) - nameplate.health.zoomTransition = true - else - if nameplate.health.zoomTransition then - nameplate.health:SetWidth(wc) - nameplate.health:SetHeight(hc) - nameplate.health.zoomTransition = nil + if wc and hc then + if wc > w + 0.5 then + nameplate.health:SetWidth(w*1.05) + nameplate.health.zoomTransition = true + elseif hc > h + 0.5 then + nameplate.health:SetHeight(h*1.05) + nameplate.health.zoomTransition = true + else + if nameplate.health.zoomTransition then + nameplate.health:SetWidth(wc) + nameplate.health:SetHeight(hc) + nameplate.health.zoomTransition = nil + end + nameplate.health.zoomed = true end - nameplate.health.zoomed = true end elseif nameplate.health.zoomed or nameplate.health.zoomTransition then local w, h = nameplate.health:GetWidth(), nameplate.health:GetHeight() - local wc = tonumber(C.nameplates.width) - local hc = tonumber(C.nameplates.heighthealth) + local wc = cfg.width + local hc = cfg.heighthealth - -- Nutze kleine Toleranz um Fließkomma-Schwankungen zu vermeiden if w > wc + 0.5 then nameplate.health:SetWidth(w*.95) elseif h > hc + 0.5 then @@ -1224,27 +1480,25 @@ pfUI:RegisterModule("nameplates", "vanilla", function () end end - -- OPTIMIZED: UNIT_CASTEVENT implementation + -- OPTIMIZED: 100% Nampower - libdebuff handles all cast events (SPELL_START/GO/FAILED) -- Use multiple checks for target detection (target variable, istarget flag, or zoomed state) local isTargetPlate = target or nameplate.istarget or (nameplate.health and nameplate.health.zoomed) - if C.nameplates["showcastbar"] == "1" and ( C.nameplates["targetcastbar"] == "0" or isTargetPlate ) then + if cfg.showcastbar and ( not cfg.targetcastbar or isTargetPlate ) then local unitstr = nil local targetGUID = nil - -- Get GUID for CastEvents lookup - if isTargetPlate and superwow_active then - -- Get target's GUID for event cache lookup - local _, guid = UnitExists("target") - targetGUID = guid + -- Get GUID for CastEvents lookup - use cached GUID when available + if isTargetPlate then + targetGUID = state and state.targetGuid end - -- Use SuperWoW GUID for non-target plates + -- Use cached GUID for non-target plates if superwow_active and not isTargetPlate then - unitstr = nameplate.parent:GetName(1) + unitstr = nameplate.cachedGuid end -- Check event-based cast cache first (use GUID) - local castInfo = (targetGUID and CastEvents[targetGUID]) or (unitstr and CastEvents[unitstr]) + local castInfo = GetCastInfo(targetGUID) or (unitstr and GetCastInfo(unitstr)) if castInfo and castInfo.spellID then -- Check if cast is still valid @@ -1266,9 +1520,15 @@ pfUI:RegisterModule("nameplates", "vanilla", function () end nameplate.castbar:SetValue(barValue) - nameplate.castbar.text:SetText(round(now - castInfo.startTime, 1)) + -- Show remaining time (countdown), not elapsed time + local remaining = castInfo.endTime - now + if C.unitframes.castbardecimals == "1" then + nameplate.castbar.text:SetText(floor(remaining * 10) / 10) + else + nameplate.castbar.text:SetText(string.format("%.2f", remaining)) + end - if C.nameplates.spellname == "1" then + if cfg.spellname then nameplate.castbar.spell:SetText(castInfo.spellName) else nameplate.castbar.spell:SetText("") @@ -1293,6 +1553,7 @@ pfUI:RegisterModule("nameplates", "vanilla", function () end if not cast and not channel then + -- No cast data = hide castbar (fixes stuck castbar on FAIL for non-target plates) nameplate.castbar:Hide() else local effect = cast or channel @@ -1304,7 +1565,14 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplate.castbar:SetMinMaxValues(0, duration/1000) nameplate.castbar:SetValue(cur) - nameplate.castbar.text:SetText(round(cur,1)) + -- Show remaining time (countdown), not elapsed time + local remaining = max - cur + if channel then remaining = cur end -- Channel already counts down + if C.unitframes.castbardecimals == "1" then + nameplate.castbar.text:SetText(floor(remaining * 10) / 10) + else + nameplate.castbar.text:SetText(string.format("%.2f", remaining)) + end if C.nameplates.spellname == "1" then nameplate.castbar.spell:SetText(effect) @@ -1349,6 +1617,9 @@ pfUI:RegisterModule("nameplates", "vanilla", function () nameplates:SetGameVariables() nameplates.UpdateConfig = function() + -- Refresh config cache for all cfg.* values + CacheConfig() + -- update debuff filters DebuffFilterPopulate() @@ -1458,48 +1729,6 @@ pfUI:RegisterModule("nameplates", "vanilla", function () end end - local hookOnUpdate = nameplates.OnUpdate - nameplates.OnUpdate = function(self) - -- initialize shortcut variables - local plate = (C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0") and this.nameplate or this - local clickable = C.nameplates["clickthrough"] ~= "1" and true or false - - -- disable all click events - if not clickable then - this:EnableMouse(false) - this.nameplate:EnableMouse(false) - else - plate:EnableMouse(clickable) - end - - if C.nameplates["overlap"] == "1" then - if this:GetWidth() > 1 then - -- set parent to 1 pixel to have them overlap each other - this:SetWidth(1) - this:SetHeight(1) - end - else - if not this.nameplate.dwidth then - -- cache initial sizing value for comparison - this.nameplate.dwidth = floor(this.nameplate:GetWidth() * UIParent:GetScale()) - end - - if floor(this:GetWidth()) ~= this.nameplate.dwidth then - -- align parent plate to the actual size - this:SetWidth(this.nameplate:GetWidth() * UIParent:GetScale()) - this:SetHeight(this.nameplate:GetHeight() * UIParent:GetScale()) - end - end - - -- disable click events while spell is targeting - local mouseEnabled = this.nameplate:IsMouseEnabled() - if C.nameplates["clickthrough"] == "0" and C.nameplates["overlap"] == "1" and SpellIsTargeting() == mouseEnabled then - this.nameplate:EnableMouse(not mouseEnabled) - end - - hookOnUpdate(self) - end - -- enable mouselook on rightbutton down nameplates.mouselook = CreateFrame("Frame", nil, UIParent) nameplates.mouselook.time = nil diff --git a/modules/nampower.lua b/modules/nampower.lua index 1c884077..a3b171df 100644 --- a/modules/nampower.lua +++ b/modules/nampower.lua @@ -54,7 +54,15 @@ pfUI:RegisterModule("nampower", "vanilla", function () local queue = CreateFrame("Frame") queue:RegisterEvent("SPELL_QUEUE_EVENT") + queue:RegisterEvent("PLAYER_LOGOUT") queue:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + local eventCode = arg1 local spellId = arg2 @@ -76,189 +84,7 @@ pfUI:RegisterModule("nampower", "vanilla", function () end) end - -- Enhanced Debuff Tracking using Nampower events - -- DEBUFF_ADDED_OTHER/DEBUFF_REMOVED_OTHER provide accurate debuff tracking with spellId - if libdebuff then - -- Storage for GUID-based debuff tracking - pfUI.nampower_debuffs = pfUI.nampower_debuffs or {} - local debuffdb = pfUI.nampower_debuffs - - -- Get player GUID for tracking own debuffs - local playerGuid - - local debuffTracker = CreateFrame("Frame") - debuffTracker:RegisterEvent("PLAYER_ENTERING_WORLD") - debuffTracker:RegisterEvent("DEBUFF_ADDED_OTHER") - debuffTracker:RegisterEvent("DEBUFF_REMOVED_OTHER") - debuffTracker:RegisterEvent("DEBUFF_ADDED_SELF") - debuffTracker:RegisterEvent("DEBUFF_REMOVED_SELF") - - debuffTracker:SetScript("OnEvent", function() - if event == "PLAYER_ENTERING_WORLD" then - -- Cache player GUID - if UnitExists then - local _, guid = UnitExists("player") - playerGuid = guid - end - return - end - - -- DEBUFF events: arg1=guid, arg2=slot, arg3=spellId, arg4=stackCount, arg5=auraLevel - local guid = arg1 - local slot = arg2 - local spellId = arg3 - local stackCount = arg4 - local auraLevel = arg5 - - if not guid or not spellId then return end - - if event == "DEBUFF_ADDED_OTHER" or event == "DEBUFF_ADDED_SELF" then - -- Initialize storage for this GUID - if not debuffdb[guid] then debuffdb[guid] = {} end - - -- Get spell info - local spellName, spellRank, texture - if SpellInfo then - spellName, spellRank, texture = SpellInfo(spellId) - end - if not spellName then - spellName, spellRank = SafeGetSpellNameAndRank(spellId) - end - - if spellName then - -- Get duration from libdebuff's duration table or GetSpellRec - local duration = 0 - if libdebuff.GetDuration then - duration = libdebuff:GetDuration(spellName, spellRank) - end - - -- Try GetSpellRec for duration if libdebuff doesn't have it - if duration == 0 and GetSpellRec and spellId then - local success, spellRec = pcall(GetSpellRec, spellId) - if success and spellRec and spellRec.durationIndex and spellRec.durationIndex > 0 then - -- Duration index maps to spell duration - common values: - -- This is a rough approximation since we don't have the duration table - duration = 30 -- Default fallback - end - end - - -- Store debuff data - debuffdb[guid][spellId] = { - spellId = spellId, - name = spellName, - rank = spellRank, - texture = texture, - stacks = stackCount or 1, - start = GetTime(), - duration = duration, - slot = slot, - auraLevel = auraLevel, - caster = (event == "DEBUFF_ADDED_SELF" or guid == playerGuid) and "player" or nil - } - - -- Also update libdebuff's internal tracking if we have unit info - local unitName = UnitName and guid and UnitName(guid) - local unitLevel = UnitLevel and guid and UnitLevel(guid) - if unitName and duration > 0 then - libdebuff:AddEffect(unitName, unitLevel or 0, spellName, duration, "player") - end - end - - elseif event == "DEBUFF_REMOVED_OTHER" or event == "DEBUFF_REMOVED_SELF" then - -- Remove debuff from tracking - if debuffdb[guid] and debuffdb[guid][spellId] then - debuffdb[guid][spellId] = nil - end - end - end) - - -- Enhanced UnitDebuff function that uses Nampower data - -- This provides more accurate debuff information when available - local originalUnitDebuff = libdebuff.UnitDebuff - function libdebuff:UnitDebuffNampower(unit, id) - -- First try the original method - local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitDebuff(self, unit, id) - - -- If we have Nampower data for this unit, try to enhance it - if not UnitExists then return effect, rank, texture, stacks, dtype, duration, timeleft, caster end - - local exists, guid = UnitExists(unit) - if not exists or not guid or not debuffdb[guid] then - return effect, rank, texture, stacks, dtype, duration, timeleft, caster - end - - -- Find the debuff by slot - for spellId, data in pairs(debuffdb[guid]) do - if data.slot == id then - -- Use Nampower data for more accurate timing - if data.duration and data.duration > 0 and data.start then - duration = data.duration - timeleft = data.duration + data.start - GetTime() - if timeleft < 0 then timeleft = 0 end - caster = data.caster - stacks = data.stacks or stacks - end - break - end - end - - return effect, rank, texture, stacks, dtype, duration, timeleft, caster - end - - -- Expose enhanced function - pfUI.api.libdebuff_nampower = libdebuff.UnitDebuffNampower - end - - -- Enhanced buff tracking using BUFF events - if C.unitframes.nampower_buffs == "1" then - pfUI.nampower_buffs = pfUI.nampower_buffs or {} - local buffdb = pfUI.nampower_buffs - - local buffTracker = CreateFrame("Frame") - buffTracker:RegisterEvent("BUFF_ADDED_OTHER") - buffTracker:RegisterEvent("BUFF_REMOVED_OTHER") - buffTracker:RegisterEvent("BUFF_ADDED_SELF") - buffTracker:RegisterEvent("BUFF_REMOVED_SELF") - - buffTracker:SetScript("OnEvent", function() - local guid = arg1 - local slot = arg2 - local spellId = arg3 - local stackCount = arg4 - local auraLevel = arg5 - - if not guid or not spellId then return end - - if event == "BUFF_ADDED_OTHER" or event == "BUFF_ADDED_SELF" then - if not buffdb[guid] then buffdb[guid] = {} end - - local spellName, spellRank, texture - if SpellInfo then - spellName, spellRank, texture = SpellInfo(spellId) - end - if not spellName then - spellName, spellRank = SafeGetSpellNameAndRank(spellId) - end - - if spellName then - buffdb[guid][spellId] = { - spellId = spellId, - name = spellName, - rank = spellRank, - texture = texture, - stacks = stackCount or 1, - start = GetTime(), - slot = slot, - auraLevel = auraLevel - } - end - elseif event == "BUFF_REMOVED_OTHER" or event == "BUFF_REMOVED_SELF" then - if buffdb[guid] and buffdb[guid][spellId] then - buffdb[guid][spellId] = nil - end - end - end) - end + -- NOTE: Buff tracking removed - was dead code (data collected but never used for display) -- Direct Aura Access API using GetUnitField -- Much faster than tooltip scanning - reads aura arrays directly from unit fields @@ -429,22 +255,8 @@ pfUI:RegisterModule("nampower", "vanilla", function () end end - -- UNIT_DIED event handling - -- Can be used to clear tracking data or trigger effects on unit death - local deathTracker = CreateFrame("Frame") - deathTracker:RegisterEvent("UNIT_DIED") - deathTracker:SetScript("OnEvent", function() - local guid = arg1 - if not guid then return end - - -- Clean up debuff tracking for dead units - if pfUI.nampower_debuffs and pfUI.nampower_debuffs[guid] then - pfUI.nampower_debuffs[guid] = nil - end - if pfUI.nampower_buffs and pfUI.nampower_buffs[guid] then - pfUI.nampower_buffs[guid] = nil - end - end) + -- UNIT_DIED event handling - placeholder for future use + -- (Debuff/buff cleanup removed as tracking is now handled by libdebuff) -- Trinket Management API if GetTrinkets then @@ -667,96 +479,6 @@ pfUI:RegisterModule("nampower", "vanilla", function () end end - -- Enhanced Heal Prediction with AURA_CAST events - -- This helps libpredict detect HoT applications more accurately - if libpredict then - local auraCastFrame = CreateFrame("Frame") - auraCastFrame:RegisterEvent("AURA_CAST_ON_SELF") - auraCastFrame:RegisterEvent("AURA_CAST_ON_OTHER") - - auraCastFrame:SetScript("OnEvent", function() - local casterGuid = arg1 - local targetGuid = arg2 - local spellId = arg3 - - if not spellId or not targetGuid then return end - - -- Get spell name - local spellName - if SpellInfo then - spellName = SpellInfo(spellId) - end - if not spellName then - spellName = SafeGetSpellNameAndRank(spellId) - end - - if not spellName then return end - - -- Check if this is a HoT spell we care about - local hotSpells = { - ["Rejuvenation"] = true, - ["Renew"] = true, - ["Regrowth"] = true, - ["Verjüngung"] = true, -- German - ["Erneuerung"] = true, - ["Nachwachsen"] = true, - } - - if hotSpells[spellName] then - -- Signal to libpredict that a HoT was applied - -- This can be used to update heal predictions - if pfUI.api.libpredict and pfUI.api.libpredict.OnHotApplied then - pfUI.api.libpredict:OnHotApplied(targetGuid, spellName, spellId) - end - end - end) - end - - -- Swing Timer Integration - -- Track auto-attack swing timers for melee classes - local swingFrame = CreateFrame("Frame") - swingFrame.mainHand = { start = 0, speed = 0 } - swingFrame.offHand = { start = 0, speed = 0 } - swingFrame.ranged = { start = 0, speed = 0 } - - swingFrame:RegisterEvent("PLAYER_ENTER_COMBAT") - swingFrame:RegisterEvent("PLAYER_LEAVE_COMBAT") - swingFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") - swingFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_MISSES") - - swingFrame:SetScript("OnEvent", function() - if event == "PLAYER_ENTER_COMBAT" then - local mainSpeed, offSpeed = UnitAttackSpeed("player") - this.mainHand.speed = mainSpeed or 2 - this.offHand.speed = offSpeed or 0 - this.mainHand.start = GetTime() - if this.offHand.speed > 0 then - this.offHand.start = GetTime() - end - elseif event == "CHAT_MSG_COMBAT_SELF_HITS" or event == "CHAT_MSG_COMBAT_SELF_MISSES" then - -- Reset swing timer on hit/miss - local mainSpeed, offSpeed = UnitAttackSpeed("player") - this.mainHand.speed = mainSpeed or 2 - this.mainHand.start = GetTime() - end - end) - - pfUI.api.GetSwingTimers = function() - local now = GetTime() - local mainRemaining = swingFrame.mainHand.speed - (now - swingFrame.mainHand.start) - local offRemaining = swingFrame.offHand.speed > 0 and (swingFrame.offHand.speed - (now - swingFrame.offHand.start)) or 0 - - return { - mainHand = { - remaining = math.max(0, mainRemaining), - speed = swingFrame.mainHand.speed, - progress = swingFrame.mainHand.speed > 0 and (1 - math.max(0, mainRemaining) / swingFrame.mainHand.speed) or 0, - }, - offHand = { - remaining = math.max(0, offRemaining), - speed = swingFrame.offHand.speed, - progress = swingFrame.offHand.speed > 0 and (1 - math.max(0, offRemaining) / swingFrame.offHand.speed) or 0, - }, - } - end + -- NOTE: HoT Detection (AURA_CAST events) removed - OnHotApplied callback was never implemented in libpredict + -- NOTE: Swing Timer removed - GetSwingTimers() was never called anywhere end) \ No newline at end of file diff --git a/modules/player.lua b/modules/player.lua index 0c1480b5..6e366d4a 100644 --- a/modules/player.lua +++ b/modules/player.lua @@ -15,8 +15,8 @@ pfUI:RegisterModule("player", "vanilla:tbc", function () if pfUI.uf.player:GetScript("OnUpdate") then local originalOnUpdate = pfUI.uf.player:GetScript("OnUpdate") pfUI.uf.player:SetScript("OnUpdate", function() - if (this.tick or 0) > GetTime() then return end - this.tick = GetTime() + 0.1 + if (this.throttleTick or 0) > GetTime() then return end + this.throttleTick = GetTime() + 0.1 originalOnUpdate() end) end diff --git a/modules/raid.lua b/modules/raid.lua index 5c244990..af45fe12 100644 --- a/modules/raid.lua +++ b/modules/raid.lua @@ -113,6 +113,27 @@ pfUI:RegisterModule("raid", "vanilla:tbc", function () end end + -- Smart GUID-based updates: only refresh frames where unit changed + if pfUI.uf.guidTracker then + local tracker = pfUI.uf.guidTracker + + for i = 1, maxraid do + local frame = pfUI.uf.raid[i] + if frame and frame.id and frame.id > 0 then + local unit = "raid" .. frame.id + local _, newGuid = UnitExists(unit) + local oldGuid = tracker.frameToGuid[frame] + + if newGuid ~= oldGuid then + -- GUID changed = different player = need full update + tracker.frameToGuid[frame] = newGuid + frame.update_full = true + frame.update_aura = true -- Force aura refresh! + end + end + end + end + -- rebuild unitmap after frame IDs are assigned if pfUI.uf.RebuildUnitmap then pfUI.uf.RebuildUnitmap() diff --git a/modules/superwow.lua b/modules/superwow.lua index 24d67a90..0d2569e1 100644 --- a/modules/superwow.lua +++ b/modules/superwow.lua @@ -161,15 +161,42 @@ pfUI:RegisterModule("superwow", "vanilla", function () end -- Add support for druid mana bars - if SUPERWOW_VERSION and pfUI.uf and pfUI.uf.player and pfUI_config.unitframes.druidmanabar == "1" then - local parent = pfUI.uf.player.power.bar - local config = pfUI.uf.player.config - local mana = config.defcolor == "0" and config.manacolor or pfUI_config.unitframes.manacolor - local r, g, b, a = pfUI.api.strsplit(",", mana) - local rawborder, default_border = GetBorderSize("unitframes") - local _, class = UnitClass("player") - local width = config.pwidth ~= "-1" and config.pwidth or config.width + -- Uses Nampower's GetUnitField to get base mana when in shapeshift form + local hasNampower = (GetUnitField ~= nil) + -- Add support for player secondary power bar (shows base mana when in shapeshift form) + -- Works with SuperWoW OR Nampower - both extend UnitMana() to return base mana as second value + -- Only for Druids (only class that can shapeshift in Vanilla) + -- Controlled by "Show Druid Mana Bar" setting + local _, playerClass = UnitClass("player") + if hasNampower and pfUI.uf and pfUI.uf.player and playerClass == "DRUID" and pfUI_config.unitframes.druidmanabar == "1" then + local rawborder, default_border = GetBorderSize("unitframes") + local config = pfUI.uf.player.config + + -- Create secondary mana bar below the power bar + local playerMana = CreateFrame("StatusBar", "pfPlayerSecondaryMana", pfUI.uf.player) + playerMana:SetFrameStrata(pfUI.uf.player:GetFrameStrata()) + playerMana:SetFrameLevel(pfUI.uf.player:GetFrameLevel() + 5) + playerMana:SetStatusBarTexture(pfUI.media[config.pbartexture]) + + -- Mana color + local manacolor = config.defcolor == "0" and config.manacolor or C.unitframes.manacolor + local r, g, b, a = pfUI.api.strsplit(",", manacolor) + playerMana:SetStatusBarColor(r, g, b, a) + + -- Use SAME size as normal power bar (pwidth/pheight from config) + local width = config.pwidth ~= "-1" and config.pwidth or config.width + local height = config.pheight + playerMana:SetWidth(width) + playerMana:SetHeight(height) + playerMana:SetPoint("TOPLEFT", pfUI.uf.player.power, "BOTTOMLEFT", 0, -2*default_border - (config.pspace or 0)) + playerMana:SetPoint("TOPRIGHT", pfUI.uf.player.power, "BOTTOMRIGHT", 0, -2*default_border - (config.pspace or 0)) + playerMana:Hide() + + CreateBackdrop(playerMana) + CreateBackdropShadow(playerMana) + + -- Text overlay - same font settings as power bar local fontname = pfUI.font_unit local fontsize = tonumber(pfUI_config.global.font_unit_size) local fontstyle = pfUI_config.global.font_unit_style @@ -180,75 +207,285 @@ pfUI:RegisterModule("superwow", "vanilla", function () fontstyle = config.customfont_style end - local druidmana = CreateFrame("StatusBar", "pfDruidMana", UIParent) - druidmana:SetFrameStrata(parent:GetFrameStrata()) - druidmana:SetFrameLevel(parent:GetFrameLevel() + 16) - druidmana:SetStatusBarTexture(pfUI.media[config.pbartexture]) - druidmana:SetStatusBarColor(r, g, b, a) - druidmana:SetPoint("TOPLEFT", parent, "BOTTOMLEFT", 0, -2*default_border - config.pspace) - druidmana:SetPoint("TOPRIGHT", parent, "BOTTOMRIGHT", 0, -2*default_border - config.pspace) - druidmana:SetWidth(width) - druidmana:SetHeight(tonumber(pfUI_config.unitframes.druidmanaheight) or 6) - druidmana:EnableMouse(true) - druidmana:Hide() + playerMana.text = playerMana:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + playerMana.text:SetFontObject(GameFontWhite) + playerMana.text:SetFont(fontname, fontsize, fontstyle) + playerMana.text:SetPoint("CENTER", playerMana, "CENTER", 0, 0) + + -- Set text color like normal power bar (mana = type 0) + local tr, tg, tb = 1, 1, 1 + if config["powercolor"] == "1" then + tr = ManaBarColor[0].r + tg = ManaBarColor[0].g + tb = ManaBarColor[0].b + end + if C.unitframes.pastel == "1" then + tr, tg, tb = (tr + .75) * .5, (tg + .75) * .5, (tb + .75) * .5 + end + playerMana.text:SetTextColor(tr, tg, tb, 1) - UpdateMovable(druidmana) - CreateBackdrop(druidmana) - CreateBackdropShadow(druidmana) - - druidmana:RegisterEvent("UNIT_MANA") - druidmana:RegisterEvent("UNIT_MAXMANA") - druidmana:RegisterEvent("UNIT_DISPLAYPOWER") - druidmana:SetScript("OnEvent", function() - if UnitPowerType("player") == 0 then - this:Hide() + -- Update function + local function UpdatePlayerSecondaryMana() + local powerType = UnitPowerType("player") + + -- Only show when NOT using mana (i.e., in Bear/Cat form) + if powerType == 0 then + playerMana:Hide() return end - local _, mana = UnitMana("player") - local _, max = UnitManaMax("player") - local perc = math.ceil(mana / max * 100) - if perc == 100 then - this.text:SetText(string.format("%s", Abbreviate(mana))) - else - this.text:SetText(string.format("%s - %s%%", Abbreviate(mana), perc)) + -- Get base mana using Nampower's GetUnitField + local baseMana, baseMaxMana + + if GetUnitField then + local _, guid = UnitExists("player") + if guid then + baseMana = GetUnitField(guid, "power1") + baseMaxMana = GetUnitField(guid, "maxPower1") + end + end + + -- Round down power values (Nampower can return decimals, especially for rage) + if baseMana then baseMana = math.floor(baseMana) end + if baseMaxMana then baseMaxMana = math.floor(baseMaxMana) end + + if type(baseMana) ~= "number" or type(baseMaxMana) ~= "number" or baseMaxMana == 0 then + playerMana:Hide() + return + end + + -- Update bar + playerMana:SetMinMaxValues(0, baseMaxMana) + playerMana:SetValue(baseMana) + + -- Update text based on power text config (uses same setting as normal power bar) + -- Check txtpowercenter first (centered text), then txtpowerright, then txtpowerleft + local textConfig = config.txtpowercenter or config.txtpowerright or config.txtpowerleft + + if not textConfig or textConfig == "" or textConfig == "none" then + -- No text configured - hide text + playerMana.text:SetText("") + elseif textConfig == "power" then + playerMana.text:SetText(Abbreviate(baseMana)) + elseif textConfig == "powermax" then + playerMana.text:SetText(Abbreviate(baseMaxMana)) + elseif textConfig == "powerperc" then + local perc = math.ceil(baseMana / baseMaxMana * 100) + playerMana.text:SetText(perc) + elseif textConfig == "powermiss" then + local miss = math.ceil(baseMana - baseMaxMana) + playerMana.text:SetText(miss == 0 and "0" or Abbreviate(miss)) + elseif textConfig == "powerdyn" then + local perc = math.ceil(baseMana / baseMaxMana * 100) + if perc == 100 then + playerMana.text:SetText(Abbreviate(baseMana)) + else + playerMana.text:SetText(string.format("%s - %s%%", Abbreviate(baseMana), perc)) + end + elseif textConfig == "powerminmax" then + playerMana.text:SetText(string.format("%s/%s", Abbreviate(baseMana), Abbreviate(baseMaxMana))) + else + -- Default: show dynamic (value + percentage if not full) + local perc = math.ceil(baseMana / baseMaxMana * 100) + if perc == 100 then + playerMana.text:SetText(Abbreviate(baseMana)) + else + playerMana.text:SetText(string.format("%s - %s%%", Abbreviate(baseMana), perc)) + end + end + + playerMana:Show() + end + + -- Register events + playerMana:RegisterEvent("UNIT_MANA") + playerMana:RegisterEvent("UNIT_MAXMANA") + playerMana:RegisterEvent("UNIT_DISPLAYPOWER") + playerMana:RegisterEvent("UPDATE_SHAPESHIFT_FORM") + playerMana:RegisterEvent("PLAYER_LOGOUT") + playerMana:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + if arg1 == nil or arg1 == "player" then + UpdatePlayerSecondaryMana() end - this:SetMinMaxValues(0, max) - this:SetValue(mana) - this:Show() end) - druidmana.text = druidmana:CreateFontString("Status", "OVERLAY", "GameFontNormalSmall") - druidmana.text:SetFontObject(GameFontWhite) - druidmana.text:SetFont(fontname, fontsize, fontstyle) - druidmana.text:SetPoint("RIGHT", -2*(default_border + config.txtpowerrightoffx), 0) - druidmana.text:SetPoint("LEFT", 2*(default_border + config.txtpowerrightoffx), 0) - druidmana.text:SetJustifyH("RIGHT") + -- Initial update + UpdatePlayerSecondaryMana() - if config["powercolor"] == "1" then - local r = ManaBarColor[0].r - local g = ManaBarColor[0].g - local b = ManaBarColor[0].b - - if pfUI_config.unitframes.pastel == "1" then - druidmana.text:SetTextColor((r+.75)*.5, (g+.75)*.5, (b+.75)*.5, 1) - else - druidmana.text:SetTextColor(r, g, b, a) - end - end - - if pfUI_config.unitframes.druidmanatext == "1" then - druidmana.text:Show() - else - druidmana.text:Hide() - end - - if class ~= "DRUID" then - druidmana:UnregisterAllEvents() - druidmana:Hide() - end + -- Store reference + pfUI.uf.player.secondaryMana = playerMana end + -- Add support for target secondary power bar (shows base mana when target is in shapeshift form) + -- Works with SuperWoW OR Nampower - both extend UnitMana() to return base mana as second value + -- Controlled by "Show Druid Mana Bar" setting - available for ALL classes + if hasNampower and pfUI.uf and pfUI.uf.target and pfUI_config.unitframes.druidmanabar == "1" then + local rawborder, default_border = GetBorderSize("unitframes") + local config = pfUI.uf.target.config + + -- Create secondary mana bar below the power bar + local targetMana = CreateFrame("StatusBar", "pfTargetSecondaryMana", pfUI.uf.target) + targetMana:SetFrameStrata(pfUI.uf.target:GetFrameStrata()) + targetMana:SetFrameLevel(pfUI.uf.target:GetFrameLevel() + 5) + targetMana:SetStatusBarTexture(pfUI.media[config.pbartexture]) + + -- Mana color + local manacolor = config.defcolor == "0" and config.manacolor or C.unitframes.manacolor + local r, g, b, a = pfUI.api.strsplit(",", manacolor) + targetMana:SetStatusBarColor(r, g, b, a) + + -- Use SAME size as normal power bar (pwidth/pheight from config) + local width = config.pwidth ~= "-1" and config.pwidth or config.width + local height = config.pheight -- Same height as power bar! + targetMana:SetWidth(width) + targetMana:SetHeight(height) + targetMana:SetPoint("TOPLEFT", pfUI.uf.target.power, "BOTTOMLEFT", 0, -2*default_border - (config.pspace or 0)) + targetMana:SetPoint("TOPRIGHT", pfUI.uf.target.power, "BOTTOMRIGHT", 0, -2*default_border - (config.pspace or 0)) + targetMana:Hide() + + CreateBackdrop(targetMana) + CreateBackdropShadow(targetMana) + + -- Text overlay - same font settings as power bar + local fontname = pfUI.font_unit + local fontsize = tonumber(pfUI_config.global.font_unit_size) + local fontstyle = pfUI_config.global.font_unit_style + + if config.customfont == "1" then + fontname = pfUI.media[config.customfont_name] + fontsize = tonumber(config.customfont_size) + fontstyle = config.customfont_style + end + + targetMana.text = targetMana:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + targetMana.text:SetFontObject(GameFontWhite) + targetMana.text:SetFont(fontname, fontsize, fontstyle) + targetMana.text:SetPoint("CENTER", targetMana, "CENTER", 0, 0) + + -- Set text color like normal power bar (mana = type 0) + -- Uses powercolor setting and pastel effect + local function UpdateTextColor() + local tr, tg, tb = 1, 1, 1 + if config["powercolor"] == "1" then + tr = ManaBarColor[0].r + tg = ManaBarColor[0].g + tb = ManaBarColor[0].b + end + if C.unitframes.pastel == "1" then + tr, tg, tb = (tr + .75) * .5, (tg + .75) * .5, (tb + .75) * .5 + end + targetMana.text:SetTextColor(tr, tg, tb, 1) + end + UpdateTextColor() + + -- Update function + local function UpdateTargetSecondaryMana() + if not UnitExists("target") then + targetMana:Hide() + return + end + + local powerType = UnitPowerType("target") + + -- Only show if target is NOT using mana (i.e., in shapeshift form with energy/rage) + if powerType == 0 then + -- Target is using mana as primary power - no need for secondary bar + targetMana:Hide() + return + end + + -- Get base mana using Nampower's GetUnitField + local baseMana, baseMaxMana + + if GetUnitField then + local _, guid = UnitExists("target") + if guid then + baseMana = GetUnitField(guid, "power1") + baseMaxMana = GetUnitField(guid, "maxPower1") + end + end + + -- Round down power values (Nampower can return decimals, especially for rage) + if baseMana then baseMana = math.floor(baseMana) end + if baseMaxMana then baseMaxMana = math.floor(baseMaxMana) end + + -- Check if we got valid mana values + if type(baseMana) ~= "number" or type(baseMaxMana) ~= "number" or baseMaxMana == 0 then + targetMana:Hide() + return + end + + -- Update bar + targetMana:SetMinMaxValues(0, baseMaxMana) + targetMana:SetValue(baseMana) + + -- Update text based on power text config (uses same setting as normal power bar) + local textConfig = config.txtpowercenter or config.txtpowerright or config.txtpowerleft + + if not textConfig or textConfig == "" or textConfig == "none" then + targetMana.text:SetText("") + elseif textConfig == "power" then + targetMana.text:SetText(Abbreviate(baseMana)) + elseif textConfig == "powermax" then + targetMana.text:SetText(Abbreviate(baseMaxMana)) + elseif textConfig == "powerperc" then + local perc = math.ceil(baseMana / baseMaxMana * 100) + targetMana.text:SetText(perc) + elseif textConfig == "powermiss" then + local miss = math.ceil(baseMana - baseMaxMana) + targetMana.text:SetText(miss == 0 and "0" or Abbreviate(miss)) + elseif textConfig == "powerdyn" then + local perc = math.ceil(baseMana / baseMaxMana * 100) + if perc == 100 then + targetMana.text:SetText(Abbreviate(baseMana)) + else + targetMana.text:SetText(string.format("%s - %s%%", Abbreviate(baseMana), perc)) + end + elseif textConfig == "powerminmax" then + targetMana.text:SetText(string.format("%s/%s", Abbreviate(baseMana), Abbreviate(baseMaxMana))) + else + local perc = math.ceil(baseMana / baseMaxMana * 100) + if perc == 100 then + targetMana.text:SetText(Abbreviate(baseMana)) + else + targetMana.text:SetText(string.format("%s - %s%%", Abbreviate(baseMana), perc)) + end + end + + targetMana:Show() + end + + -- Register events + targetMana:RegisterEvent("PLAYER_TARGET_CHANGED") + targetMana:RegisterEvent("UNIT_MANA") + targetMana:RegisterEvent("UNIT_MAXMANA") + targetMana:RegisterEvent("UNIT_DISPLAYPOWER") + targetMana:RegisterEvent("PLAYER_LOGOUT") + targetMana:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + if event == "PLAYER_TARGET_CHANGED" then + UpdateTargetSecondaryMana() + elseif arg1 == "target" then + UpdateTargetSecondaryMana() + end + end) + + -- Store reference + pfUI.uf.target.secondaryMana = targetMana + end + + -- Add support for guid based focus frame if SUPERWOW_VERSION and pfUI.uf and pfUI.uf.focus then local focus = function(unitstr) @@ -312,8 +549,16 @@ pfUI:RegisterModule("superwow", "vanilla", function () trackFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") trackFrame:RegisterEvent("RAID_ROSTER_UPDATE") trackFrame:RegisterEvent("PLAYER_ENTERING_WORLD") + trackFrame:RegisterEvent("PLAYER_LOGOUT") trackFrame:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + -- Track party members for i = 1, 4 do local unit = "party" .. i @@ -483,7 +728,15 @@ pfUI:RegisterModule("superwow", "vanilla", function () supercast:RegisterEvent("PLAYER_ENTERING_WORLD") supercast:RegisterEvent("UNIT_CASTEVENT") + supercast:RegisterEvent("PLAYER_LOGOUT") supercast:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + if event == "PLAYER_ENTERING_WORLD" then -- Cache player GUID if UnitExists then diff --git a/modules/unitxp.lua b/modules/unitxp.lua index bc9b1833..1a4bae04 100644 --- a/modules/unitxp.lua +++ b/modules/unitxp.lua @@ -90,7 +90,24 @@ pfUI:RegisterModule("unitxp", "vanilla", function () -- Also try on PLAYER_ENTERING_WORLD in case target frame wasn't ready local initFrame = CreateFrame("Frame") initFrame:RegisterEvent("PLAYER_ENTERING_WORLD") + initFrame:RegisterEvent("PLAYER_LOGOUT") initFrame:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + -- Stop indicator OnUpdate scripts + if pfUI.uf and pfUI.uf.target then + if pfUI.uf.target.behindIndicator then + pfUI.uf.target.behindIndicator:SetScript("OnUpdate", nil) + end + if pfUI.uf.target.losIndicator then + pfUI.uf.target.losIndicator:SetScript("OnUpdate", nil) + end + end + return + end + CreateTargetIndicators() this:UnregisterAllEvents() end) @@ -102,8 +119,16 @@ pfUI:RegisterModule("unitxp", "vanilla", function () notifyFrame:RegisterEvent("CHAT_MSG_BN_WHISPER") notifyFrame:RegisterEvent("READY_CHECK") notifyFrame:RegisterEvent("RAID_INSTANCE_WELCOME") + notifyFrame:RegisterEvent("PLAYER_LOGOUT") notifyFrame:SetScript("OnEvent", function() + -- Handle shutdown to prevent crash 132 + if event == "PLAYER_LOGOUT" then + this:UnregisterAllEvents() + this:SetScript("OnEvent", nil) + return + end + pcall(UnitXP, "notify", "taskbarIcon") pcall(UnitXP, "notify", "systemSound") end) diff --git a/pfUI-tbc.toc b/pfUI-tbc.toc index 692b94af..f475c9ae 100644 --- a/pfUI-tbc.toc +++ b/pfUI-tbc.toc @@ -1,9 +1,9 @@ ## Interface: 20400 ## Title: |cff33ffccpf|cffffffffUI -## Author: Shagu +## Author: Shagu - modfied by me0wg4ming ## Notes: A complete user interface replacement. ## Notes-ruRU: Полная замена пользовательского интерфейса. -## Version: 6.2.6 +## Version: 7.6.0 (experiment version) ## SavedVariables: pfUI_profiles, pfUI_addon_profiles, pfUI_cache ## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB diff --git a/pfUI.lua b/pfUI.lua index 606919f8..8d2791b3 100644 --- a/pfUI.lua +++ b/pfUI.lua @@ -47,6 +47,11 @@ pfUI.version = {} pfUI.hooks = {} pfUI.env = {} +-- check if macro addons are loaded (disables macrotweak/macroscan) +function pfUI:MacroAddonsLoaded() + return IsAddOnLoaded("Supermacro") or IsAddOnLoaded("SuperCleveRoidMacros") or IsAddOnLoaded("UltimaMacros") +end + -- detect current addon path local tocs = { "", "-master", "-tbc", "-wotlk" } for _, name in pairs(tocs) do diff --git a/pfUI.toc b/pfUI.toc index 1a7148fe..62459348 100644 --- a/pfUI.toc +++ b/pfUI.toc @@ -1,9 +1,9 @@ ## Interface: 11200 ## Title: |cff33ffccpf|cffffffffUI -## Author: Shagu +## Author: Shagu - modfied by me0wg4ming ## Notes: A complete user interface replacement. ## Notes-ruRU: Полная замена пользовательского интерфейса. -## Version: 6.2.6 +## Version: 7.6.0 (experiment version) ## SavedVariables: pfUI_profiles, pfUI_addon_profiles, pfUI_cache ## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB