Release 3.8.6: module fixes and native timer optimizations

This commit is contained in:
2026-11-06 17:20:18 -04:00
parent b9912256bf
commit e43d93a524
48 changed files with 1966 additions and 2731 deletions
+13
View File
@@ -4,6 +4,19 @@ Changes in OEVG/ShaguTweaks-ClassicAPI, newest first. Entries summarize released
Based on [Dusk/ShaguTweaks-ClassicAPI](https://octowow.st/git/Dusk/ShaguTweaks-ClassicAPI) at `4391564` (2026-09-08). Earlier history is available upstream.
## 3.8.6 — 2026-11-06 — Module fixes and native timers
- Prevent recursive merchant-click hooks when Buy Em All runs alongside another merchant addon.
- Keep the viewed world map unchanged when hovering the Turtle clock.
- Refresh reagent counts when conditional macros change spells, and apply Minimap Clean's hidden state to late addon buttons.
- Keep the dungeon-finder minimap button's size and position stable when dragging it with Enlarged Minimap enabled.
- Stop Sell Junk's completion check when sales stall, and refresh rarity borders/glows when previously uncached item data arrives.
- Use native ClassicAPI cooldown numbers when native countdowns are enabled, retaining the existing appearance and legacy fallback; remove unused static spell/debuff locale data.
- Use native target cast-bar animation and player countdown text where supported, and limit power/portrait styling updates to affected unit frames.
- Use native totem/control-alert timers where supported, and reduce repeated resource/map styling writes and chat-cache allocations.
- Keep swing-timer and cast-bar preview borders above their fills and match them to the selected UI theme.
- Remove AoE Loot and retire its saved setting. See the [migration guide](Docs/EXTRAS_MERGE.md#removed-later) for AutoAreaLoot.
## 3.8.5 — 2026-11-06 — UI polish and initialization cleanup
- Replace the theme checkboxes with one **UI:** dropdown below Search: Default, Darkened or Golden, preserving existing choices.
+15 -1
View File
@@ -6,6 +6,19 @@ Copied from Extras commit `8e14bb4` (2026-09-18). Credits and licenses are in `T
## What to do when updating
From 3.8.6, Cooldown Numbers uses ClassicAPI's native countdown text when
`countdownForCooldowns` is enabled. It preserves ShaguTweaks' colors, rounding
and duration threshold. The addon leaves that global CVar unchanged; when it is
off, or the required native APIs are unavailable, the existing Lua renderer
continues to work. Legacy Model indicators retain their sweep and use a native
child for numbers when supported.
For custom-module authors: 3.8.6 removes unused `ShaguTweaks.L` data sections
`bagtypes`, `itemtypes`, `hunterpaging`, `critters`, `dyndebuffs`, `judgements`,
`interrupts`, `resurrections`, `spells`, `debuffs` and `totems`. The `class` and
`customcast` sections remain. Custom modules using the removed tables must
supply their own data or use client APIs.
1. Install this addon (3.7 or newer).
2. Disable or delete the `ShaguTweaks-extras` folder in `Interface\AddOns`. While it stays enabled, its older copies replace the built-in ones, and a message in chat reminds you at login.
3. Your on/off choices carry over, because the modules kept their names.
@@ -53,6 +66,7 @@ The textures these modules use (the gryphons and the XP bar) moved to this addon
| **Key-Down Casting** and **Alt Self-Cast** | 3.7.1 | ClassicAPI now has the game's own `ActionButtonUseKeyDown` setting. Type `/console ActionButtonUseKeyDown 1` once instead. Details in the [changelog](../CHANGELOG.md). |
| **Debuff Timer** | 3.8 | [TargetFrameBuff](https://octowow.st/git/OEVG/TargetFrameBuff) already provides target buff/debuff countdowns with ClassicAPI. Use its timer settings instead of running overlapping timers in ShaguTweaks. |
| **Auto Dismount** | 3.8.1 | ClassicAPI provides Auto Dismount, Auto Stand and Auto Cancel Form. |
| **AoE Loot** | 3.8.6 | Removed in favor of a dedicated area-looting addon. Use [AutoAreaLoot](https://github.com/rotaruandrei93/AutoAreaLoot) for this functionality; check its README for required dependencies. The old ShaguTweaks setting is retired automatically. |
| **Nameplate Castbar**, **Nameplate Scale** and **Nameplate Class Colors** | 3.8 | Enabling these together with another nameplate addon can cause conflicts because both addons modify the same nameplates. Use one dedicated nameplate addon instead; alternatives are listed below. |
### Nameplate alternatives
@@ -61,4 +75,4 @@ Choose one to handle your nameplates:
- [BlizzNameplatesPlus](https://github.com/Wurmschwanz/BlizzNameplatesPlus) for a similar Blizzard-like appearance.
- [UnitPlates](https://github.com/Redbu11dev/UnitPlates) for more detailed nameplates.
- [ShaguPlates](https://github.com/Dinkleberrrg/ShaguPlates) for extensive customization.
- [ShaguPlates](https://github.com/Dinkleberrrg/ShaguPlates) for extensive customization.
+2 -4
View File
@@ -7,7 +7,7 @@ English-only fork of [Dusk/ShaguTweaks-ClassicAPI](https://octowow.st/git/Dusk/S
Focused on **stability, compatibility, customization, and performance** while keeping the original ShaguTweaks experience.
**Version 3.8.5.** What changed in each update: see the [changelog](CHANGELOG.md).
**Version 3.8.6.** What changed in each update: see the [changelog](CHANGELOG.md).
> **Upgrading to 3.7:** the modules of the separate *ShaguTweaks Extras ClassicAPI* addon are now built into ShaguTweaks. **Disable or delete `ShaguTweaks-extras`** after updating, otherwise its older copies replace the built-in ones. Your on/off settings carry over. Raid Frames, Bag Item Click, Bag Search Bar and Auction Alt-Buy were not merged. See [Docs/EXTRAS_MERGE.md](Docs/EXTRAS_MERGE.md) for where each module went and why some were left out. See the [migration guide](Docs/EXTRAS_MERGE.md#removed-later) for removed modules.
@@ -119,7 +119,6 @@ ClassicAPI backs casts, auras, items and unit data: native cooldown support, fil
Modules that use ClassicAPI directly:
- Range Color
- AoE Loot
- Chat Tweaks
- Equip Compare
- Free Slot Count
@@ -236,7 +235,6 @@ Nameplate Scale, Class Colors and Castbar were removed in 3.8 because enabling t
### Loot
- AoE Loot *(off)*
- Improved Roll Frames *(off)*
- Loot Cursor
- Loot History (`/loothistory`)
@@ -303,7 +301,7 @@ Additional maintenance and Turtle WoW work by **paokkerkir**.
ClassicAPI compatibility fork maintained by **Dusk-92**.
Special thanks to **Sandrea** for providing **AoELoot**, adapted here as the **AoE Loot** module.
The former **AoE Loot** module was removed in 3.8.6. See the [migration guide](Docs/EXTRAS_MERGE.md#removed-later) for the recommended replacement.
Special thanks to **Jesper_92** for reporting issues that helped improve the addon.
+1 -2
View File
@@ -2,7 +2,7 @@
## Title: |cffffcc00Shagu|cffffffffTweaks |cffcccccc[ClassicAPI]
## Author: Shagu
## Notes: A collection of tweaks and enhancements to the Default UI
## Version: 3.8.5
## Version: 3.8.6
## Dependencies: !!!ClassicAPI
## SavedVariables: ShaguTweaks_player_cache, ShaguTweaks_social_cache
## SavedVariablesPerCharacter: ShaguTweaks_config, ShaguTweaks_cache
@@ -78,7 +78,6 @@ mods\reduced-actionbar.lua
mods\actionbar-move.lua
mods\actionbar-grid.lua
mods\no-toggle.lua
mods\aoe-loot.lua
mods\loot-cursor.lua
mods\loot-history.lua
mods\loss-of-control.lua
+4 -3
View File
@@ -117,12 +117,13 @@ ClassicAPI binaries or source are distributed as part of this addon.
## AoELoot
`mods/aoe-loot.lua` is adapted from the standalone AoELoot addon provided to
the project by Sandrea:
Before 3.8.6, `mods/aoe-loot.lua` was adapted from the standalone AoELoot
addon provided to the project by Sandrea. The module has since been removed;
this historical attribution remains for earlier releases:
- Original addon title: `AoELoot`
- Original metadata credit: Sandrea / ChatGPT
- Adapted module: `mods/aoe-loot.lua`
- Former adapted module: `mods/aoe-loot.lua`
The supplied package did not include an upstream URL or a separate license
file. This notice records the known provenance and preserves attribution; it
+18 -32
View File
@@ -60,9 +60,6 @@ API.containeritems = type(_G.C_Container) == "table"
API.containeriteminfo = type(_G.C_Container) == "table"
and type(_G.C_Container.GetContainerItemInfo) == "function"
API.containeropenable = type(_G.C_Container) == "table"
and type(_G.C_Container.IsContainerItemOpenable) == "function"
API.items = type(_G.C_Item) == "table"
API.iteminfo = API.items and type(_G.C_Item.GetItemInfo) == "function"
API.iteminfoinstant = API.items
@@ -145,11 +142,6 @@ API.loothistoryevents = API.loothistory and API.eventutils
and _G.C_EventUtils.IsEventValid("LOOT_HISTORY_FULL_UPDATE")
API.lootrollitemid = type(_G.GetLootRollItemID) == "function"
API.aoeloot = type(_G.C_Loot) == "table"
and type(_G.C_Loot.GetNearbyLootableUnits) == "function"
and type(_G.C_Loot.IsScanInProgress) == "function"
and type(_G.C_Loot.LootAllCorpses) == "function"
API.overridebindings = type(_G.SetOverrideBindingClick) == "function"
and type(_G.ClearOverrideBindings) == "function"
@@ -247,6 +239,24 @@ API.GetChannelInfo = function(unit)
isTradeSkill, notInterruptible, spellID, true
end
-- Native cast durations are snapshots: fetch again after pushback/channel
-- updates. Synthetic/SuperWoW timing uses a separate reusable object, never
-- mutating a duration returned by the engine.
API.GetCastDuration = function(unit, channel, startTime, endTime, fallback)
if not API.durationtimers then return end
local query = channel and _G.UnitChannelDuration or _G.UnitCastingDuration
if type(query) == "function" then
local duration = query(unit)
if duration and math.abs(duration:GetStartTime() - startTime) < .001
and math.abs(duration:GetEndTime() - endTime) < .001 then
return duration, fallback
end
end
fallback = fallback or _G.C_DurationUtil.CreateDuration()
fallback:SetTimeSpan(startTime, endTime)
return fallback, fallback
end
API.IsSpellInRange = function(spell, unit)
if API.spellrange then
return _G.C_Spell.IsSpellInRange(spell, unit)
@@ -442,30 +452,6 @@ API.GetContainerNumFreeSlots = function(bag)
return 0, 0
end
API.IsContainerItemOpenable = function(bag, slot)
if API.containeropenable then
return _G.C_Container.IsContainerItemOpenable(bag, slot)
end
end
API.GetNearbyLootableUnits = function()
if API.aoeloot then
return _G.C_Loot.GetNearbyLootableUnits()
end
end
API.IsLootScanInProgress = function()
return API.aoeloot and _G.C_Loot.IsScanInProgress() or false
end
API.LootAllCorpses = function(maxCount)
if API.aoeloot then
if maxCount then return _G.C_Loot.LootAllCorpses(maxCount) end
return _G.C_Loot.LootAllCorpses()
end
return false
end
-- ClassicAPI updates its own left/right modifier bitmap before firing
-- MODIFIER_STATE_CHANGED. Prefer that state over vanilla's merged Win32 key
-- state when the richer ClassicAPI functions are available.
File diff suppressed because it is too large Load Diff
+2
View File
@@ -74,6 +74,8 @@ ShaguTweaks:SetScript("OnEvent", function()
-- Retire the removed duplicate toggle; ClassicAPI owns these preferences.
ShaguTweaks_config[ShaguTweaks.T["Auto Dismount"]] = nil
-- Area looting is delegated to a dedicated addon from 3.8.6 onward.
ShaguTweaks_config[ShaguTweaks.T["AoE Loot"]] = nil
-- Gold numbers belong to Real Health Numbers; retire the duplicate toggle.
ShaguTweaks_config["Unit Frame Health Colors"] = nil
+45 -5
View File
@@ -12,24 +12,54 @@ local module = ShaguTweaks:register({
})
module.enable = function(self)
if self.counter then return end
local reagent_slots = { }
local reagent_counts = { }
local macro_slots = { }
local bars = { "Action", "BonusAction", "MultiBarBottomLeft", "MultiBarBottomRight", "MultiBarLeft", "MultiBarRight" }
local reagentcounter = CreateFrame("Frame", "ShaguTweaksReagentCount", UIParent)
self.counter = reagentcounter
reagentcounter:RegisterEvent("PLAYER_ENTERING_WORLD")
reagentcounter:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
reagentcounter:RegisterEvent("ACTIONBAR_PAGE_CHANGED")
reagentcounter:RegisterEvent("UPDATE_BONUS_ACTIONBAR")
reagentcounter:RegisterEvent("UPDATE_MACROS")
reagentcounter:RegisterEvent("SPELLS_CHANGED")
reagentcounter:RegisterEvent("BAG_UPDATE")
local macroEvents = { "MODIFIER_STATE_CHANGED", "PLAYER_TARGET_CHANGED",
"UPDATE_MOUSEOVER_UNIT", "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED",
"UPDATE_SHAPESHIFT_FORM", "UPDATE_SHAPESHIFT_FORMS", "SPELL_UPDATE_COOLDOWN",
"UNIT_AURA", "UNIT_DISPLAYPOWER" }
local macroEventSet = {}
local watchingMacros = false
local function WatchMacros()
local needed = next(macro_slots) ~= nil
if needed == watchingMacros then return end
watchingMacros = needed
for _, name in ipairs(macroEvents) do
if needed and API.eventutils and _G.C_EventUtils.IsEventValid(name) then
macroEventSet[name] = true
if name == "UNIT_AURA" or name == "UNIT_DISPLAYPOWER" then
API.RegisterUnitEvent(reagentcounter, name, "player")
else reagentcounter:RegisterEvent(name) end
elseif not needed then
reagentcounter:UnregisterEvent(name)
macroEventSet[name] = nil
end
end
end
-- Hidden frames still receive events, but do not execute OnUpdate.
-- Wake for one frame only when work is pending.
reagentcounter:Hide()
reagentcounter:SetScript("OnEvent", function()
if (event == "UNIT_AURA" or event == "UNIT_DISPLAYPOWER") and arg1 ~= "player" then return end
this.pending = true
if event ~= "BAG_UPDATE" then
if macroEventSet[event] then
this.macros = true
elseif event ~= "BAG_UPDATE" then
this.rescan = true
end
this:Show()
@@ -45,16 +75,24 @@ module.enable = function(self)
-- changes. ClassicAPI resolves the spell/macro directly, so no tooltip
-- scan or localized reagent-name parsing is required.
if this.rescan then
reagent_counts = {}
for slot = 1, 120 do
reagentcounter.ScanSlot(slot)
end
WatchMacros()
elseif this.macros then
-- A native macro can resolve to another spell without being edited.
-- Re-evaluate only known macro actions, not all 120 action slots.
for slot in pairs(macro_slots) do reagentcounter.ScanSlot(slot) end
WatchMacros()
end
-- Count only reagents referenced by active actions. ClassicAPI reads the
-- inventory directly, avoiding a Lua bag scan for every reagent.
for itemID in pairs(reagent_counts) do
reagent_counts[itemID] = API.GetItemCount(itemID, false, false) or 0
for itemID in pairs(reagent_counts) do reagent_counts[itemID] = nil end
for _, itemID in pairs(reagent_slots) do
if reagent_counts[itemID] == nil then
reagent_counts[itemID] = API.GetItemCount(itemID, false, false) or 0
end
end
-- update all actionbar buttons
@@ -79,11 +117,13 @@ module.enable = function(self)
this.pending = nil
this.rescan = nil
this.macros = nil
this:Hide()
end)
reagentcounter.ScanSlot = function(slot)
reagent_slots[slot] = nil
macro_slots[slot] = nil
if not HasAction(slot) then return end
local actionType, actionID = API.GetActionInfo(slot)
@@ -92,6 +132,7 @@ module.enable = function(self)
if actionType == "spell" then
spellID = actionID
elseif actionType == "macro" and actionID then
macro_slots[slot] = actionID
local _, _, macroSpellID = API.GetMacroSpell(actionID)
spellID = macroSpellID
end
@@ -105,7 +146,6 @@ module.enable = function(self)
-- The action button only has one count field. Preserve the historical
-- behavior by showing the first reagent's owned stack count.
reagent_slots[slot] = reagent.itemID
reagent_counts[reagent.itemID] = reagent_counts[reagent.itemID] or 0
end
-- The stock UI clears the Count fontstring on non-consumable actions whenever
-121
View File
@@ -1,121 +0,0 @@
-- Adapted from AoELoot by Sandrea / ChatGPT for ShaguTweaks-ClassicAPI.
local T = ShaguTweaks.T
local API = ShaguTweaks.API
local module = ShaguTweaks:register({
title = T["AoE Loot"],
description = T["Automatically loots all nearby corpses using ClassicAPI without confirmation prompts. Leaves Master Loot untouched."],
expansions = { ["vanilla"] = true },
category = T["Loot"],
enabled = nil,
})
module.enable = function(self)
-- AoE Loot relies entirely on ClassicAPI's native corpse walker,
-- container classification and timer. Do not keep partial legacy behavior
-- when those capabilities are unavailable.
if not API or not API.aoeloot or not API.containeropenable or not API.timer then return end
local frame = CreateFrame("Frame", "ShaguTweaksAoELoot")
local pending = false
local containerLootDeadline = 0
local takeoverGeneration = 0
local TAKEOVER_DELAY = 0.3
local function CancelTakeover()
takeoverGeneration = takeoverGeneration + 1
pending = false
end
local function TrackContainerUse(bag, slot)
local isOpenable, canOpen = API.IsContainerItemOpenable(bag, slot)
if isOpenable and canOpen then
-- LOOT_OPENED is also fired by clams, lockboxes and similar bag items.
-- Mark that session before UseContainerItem runs so it remains under the
-- normal client handling and the consumed container can leave the bag.
containerLootDeadline = GetTime() + 3
end
end
-- Use the ShaguTweaks hook explicitly because its prepend mode records the
-- bag item before UseContainerItem can synchronously fire LOOT_OPENED.
ShaguTweaks.hooksecurefunc("UseContainerItem", TrackContainerUse, true)
local function IsMasterLootActive()
if not GetLootMethod then return false end
return GetLootMethod() == "master"
end
local function CanStartAoELoot()
if IsMasterLootActive() then return false end
if API.IsLootScanInProgress() then return false end
return true
end
local function HasNearbyLootableCorpse()
local units = API.GetNearbyLootableUnits()
return type(units) == "table" and table.getn(units) > 0
end
local function StartAoELoot(generation)
if generation ~= takeoverGeneration or not pending then return end
pending = false
if CanStartAoELoot() then
API.LootAllCorpses()
end
end
local function TakeOverLootSession()
-- AoE Loot owns corpse sessions while enabled. Close the normal client
-- window immediately so Vanilla autoloot, SuperAPI SetAutoloot modes and
-- quickloot-patched clients cannot keep control of the session.
takeoverGeneration = takeoverGeneration + 1
local generation = takeoverGeneration
pending = true
CloseLoot()
-- Native autoloot may already have emitted loot packets before LOOT_OPENED
-- reaches Lua. ClassicAPI's own loot test uses the same 0.3s settling
-- window between loot operations; after it expires the native corpse walker
-- becomes the sole owner of all remaining nearby corpse loot.
C_Timer.After(TAKEOVER_DELAY, function()
StartAoELoot(generation)
end)
end
frame:RegisterEvent("LOOT_OPENED")
frame:SetScript("OnEvent", function()
if event ~= "LOOT_OPENED" then return end
-- Inventory containers use the same loot event as corpses. Let the normal
-- client finish them instead of treating them as an AoE-loot trigger.
if containerLootDeadline > 0 then
local isContainerLoot = GetTime() <= containerLootDeadline
containerLootDeadline = 0
if isContainerLoot then
CancelTakeover()
return
end
end
-- The master looter must keep the normal window to inspect and assign loot.
if IsMasterLootActive() then
CancelTakeover()
return
end
-- Chests, fishing nodes and other non-corpse sources also emit LOOT_OPENED.
-- Only take over when ClassicAPI can see at least one nearby lootable unit.
if not HasNearbyLootableCorpse() then return end
-- A queued takeover already owns this interaction. ClassicAPI suppresses
-- its own LOOT_OPENED/LOOT_CLOSED events while walking corpses, so no
-- additional session needs to be stacked here.
if pending then return end
TakeOverLootSession()
end)
end
+20 -13
View File
@@ -484,10 +484,8 @@ function Buy:HandleMerchantClick(anchor, button, ignoreModifiers)
if self.max <= 0 then return true end
if self.max == 1 then
if self.originalMerchantItemButton_OnClick then
self.originalMerchantItemButton_OnClick("LeftButton", 1)
end
return true
-- Let this invocation's predecessor buy one without opening StackSplit.
return false, true
end
self.defaultStack = quantity
@@ -636,21 +634,30 @@ module.enable = function(self)
Buy:StopPurchaseQueue()
end)
local function MerchantItemButton_OnClick_Wrapper(button, ignoreModifiers)
local anchor = this
if Buy:HandleMerchantClick(anchor, button, ignoreModifiers) then return end
if Buy.originalMerchantItemButton_OnClick then
return Buy.originalMerchantItemButton_OnClick(button, ignoreModifiers)
end
local installed, dispatching
local function DispatchClick(original, button, ignoreModifiers)
local handled, single = Buy:HandleMerchantClick(this, button, ignoreModifiers)
if handled then return end
return original(single and "LeftButton" or button, single and 1 or ignoreModifiers)
end
local function InstallHook()
local current = _G.MerchantItemButton_OnClick
if type(current) ~= "function" then return end
if current == MerchantItemButton_OnClick_Wrapper then return end
if current == installed then return end
Buy.originalMerchantItemButton_OnClick = current
_G.MerchantItemButton_OnClick = MerchantItemButton_OnClick_Wrapper
-- A later addon may wrap our previous hook. Each installation must retain
-- its own predecessor; a mutable shared fallback would form a cycle.
local original = current
installed = function(button, ignoreModifiers)
if dispatching then return original(button, ignoreModifiers) end
dispatching = true
local ok, r1, r2, r3, r4 = pcall(DispatchClick, original, button, ignoreModifiers)
dispatching = nil
if not ok then error(r1) end
return r1, r2, r3, r4
end
_G.MerchantItemButton_OnClick = installed
end
InstallHook()
+5 -1
View File
@@ -41,6 +41,9 @@ local function SetPreview(on)
if S.preview == on then return end
S.preview = on
if on then
local tint = ShaguTweaks.UITint
S.mover.border:SetVertexColor(tint and tint.r or 1, tint and tint.g or 1,
tint and tint.b or 1, tint and tint.a or 1)
S.mover:Show()
else
CastingBarFrame:StopMovingOrSizing()
@@ -65,7 +68,8 @@ local function CreateMover()
m.bg:SetAllPoints(m)
m.bg:SetTexture(0, 0, 0, .5)
m.border = m:CreateTexture(nil, "ARTWORK")
-- Keep the rim above the StatusBar's ARTWORK fill during preview/dragging.
m.border = m:CreateTexture(nil, "OVERLAY")
m.border:SetTexture("Interface\\CastingBar\\UI-CastingBar-Border")
m.border:SetWidth(256)
m.border:SetHeight(64)
+7 -17
View File
@@ -16,11 +16,11 @@ local COOLDOWN = 70
-- how often (in seconds) stale entries may be pruned when chat traffic arrives
local CLEAN_INTERVAL = 30
-- double-buffered cache of [frame] = {message = last_seen_time}, keyed per
-- Cache of [frame] = {message = last_seen_time}, keyed per
-- chat frame so that a message shown in one tab (e.g. "General") doesn't
-- get treated as a duplicate when it also needs to show in another tab
-- (e.g. a custom "World" tab)
local cache = { {}, {}, INDEX = 1 }
local cache = {}
local last_cleanup = 0
local canFilterEmotes = API and API.guildmembership and API.friendmembership
@@ -37,21 +37,12 @@ local function PruneCache(now)
if now - last_cleanup < CLEAN_INTERVAL then return end
last_cleanup = now
local index = cache.INDEX
local newindex = (index == 1) and 2 or 1
cache[newindex] = {}
for frame, frameCache in pairs(cache[index]) do
for frame, frameCache in pairs(cache) do
for msg, seen_at in pairs(frameCache) do
if (seen_at + COOLDOWN) > now then
cache[newindex][frame] = cache[newindex][frame] or {}
cache[newindex][frame][msg] = seen_at
end
if (seen_at + COOLDOWN) <= now then frameCache[msg] = nil end
end
if not next(frameCache) then cache[frame] = nil end
end
cache[index] = {}
cache.INDEX = newindex
end
-- returns true (and records the message) if this exact text was already seen
@@ -60,11 +51,10 @@ local function IsDuplicate(frame, msg)
local now = GetTime()
PruneCache(now)
local index = cache.INDEX
local frameCache = cache[index][frame]
local frameCache = cache[frame]
if not frameCache then
frameCache = {}
cache[index][frame] = frameCache
cache[frame] = frameCache
end
local seen_at = frameCache[msg]
+127 -6
View File
@@ -18,6 +18,90 @@ local module = ShaguTweaks:register({
-- by one complete clock cycle.
local TIMER_WRAP_SECONDS = (2 ^ 32) / 1000
local formatter
local nativeCooldowns = setmetatable({}, { __mode = "k" })
local cvarWatcher
local SetCooldown
local function FontSize(parent)
local size = parent:GetHeight() or 0
size = size > 0 and size * .64 or 12
return size > 14 and 14 or size
end
local function NativeNumbersEnabled()
return type(_G.GetCVar) == "function" and _G.GetCVar("countdownForCooldowns") == "1"
end
local function CanUseNative(cooldown)
return type(cooldown.GetCountdownFontString) == "function"
and type(cooldown.SetCountdownFormatter) == "function"
and type(cooldown.SetMinimumCountdownDuration) == "function"
and type(cooldown.SetHideCountdownNumbers) == "function"
and _G.C_StringUtil and type(_G.C_StringUtil.CreateNumericRuleFormatter) == "function"
and _G.Enum and _G.Enum.NumericRuleFormatRounding
end
local function NativeTarget(cooldown, parent)
if CanUseNative(cooldown) then return cooldown end
if cooldown.stCountdownNativeFrame ~= nil then return cooldown.stCountdownNativeFrame or nil end
if not ShaguTweaks.API.nativecooldowns or not _G.C_StringUtil
or type(_G.C_StringUtil.CreateNumericRuleFormatter) ~= "function" then return end
-- Stock Vanilla indicators can still be Models. Keep their sweep untouched
-- and use an engine-driven child solely for numbers, as the old overlay did.
local ok, child = pcall(CreateFrame, "Cooldown", nil, cooldown)
if not ok or not child then
cooldown.stCountdownNativeFrame = false
return
end
if not CanUseNative(child) then
child:Hide()
cooldown.stCountdownNativeFrame = false
return
end
child.stCountdownHelper = true
child:SetAllPoints(cooldown)
child:SetFrameLevel(parent:GetFrameLevel() + 1)
child:SetDrawSwipe(false)
child:SetDrawEdge(false)
child:SetDrawBling(false)
cooldown.stCountdownNativeFrame = child
return child
end
local function GetFormatter()
if formatter then return formatter end
local up = _G.Enum.NumericRuleFormatRounding.Up
formatter = _G.C_StringUtil.CreateNumericRuleFormatter()
-- Match TimeConvert, including its colors before rounding at 5/10 seconds.
formatter:SetBreakpoints({
{ threshold = 0, step = 1, rounding = up, format = "|cffff5555%d" },
{ threshold = 5, step = 1, rounding = up, format = "|cffffff55%d" },
{ threshold = 10, step = 1, rounding = up, format = "|cffffffff%d" },
{ threshold = 60, format = "|cffffffff%dm", components = { { div = 60, step = 1, rounding = up } } },
{ threshold = 3600, format = "|cffffffff%dh", components = { { div = 3600, step = 1, rounding = up } } },
{ threshold = 86400, format = "|cffffffff%dd", components = { { div = 86400, step = 1, rounding = up } } },
})
return formatter
end
local function RememberNative(cooldown, start, duration, enable)
nativeCooldowns[cooldown] = true
cooldown.stCountdownStart, cooldown.stCountdownDuration, cooldown.stCountdownEnable = start, duration, enable
if cvarWatcher then return end
cvarWatcher = CreateFrame("Frame")
cvarWatcher.enabled = NativeNumbersEnabled()
cvarWatcher:RegisterEvent("CVAR_UPDATE")
cvarWatcher:SetScript("OnEvent", function()
local enabled = NativeNumbersEnabled()
if enabled == cvarWatcher.enabled then return end
cvarWatcher.enabled = enabled
for frame in pairs(nativeCooldowns) do
SetCooldown(frame, frame.stCountdownStart, frame.stCountdownDuration, frame.stCountdownEnable)
end
end)
end
local function CooldownOnUpdate()
-- hide frames without parent
local parent = this:GetParent()
@@ -73,9 +157,7 @@ local function CreateCoolDown(cooldown)
cooldown.cooldowntext.text = cooldown.cooldowntext:CreateFontString(nil, "OVERLAY")
-- detect dynamic font size
local size = parent:GetHeight() or 0
size = size > 0 and size * .64 or 12
size = size > 14 and 14 or size
local size = FontSize(parent)
-- set fonts
cooldown.cooldowntext.text:SetFont(STANDARD_TEXT_FONT, size, "OUTLINE")
@@ -83,16 +165,55 @@ local function CreateCoolDown(cooldown)
cooldown.cooldowntext:SetScript("OnUpdate", CooldownOnUpdate)
end
local function SetCooldown(this, start, duration, enable)
SetCooldown = function(this, start, duration, enable)
if this.stCountdownHelper then return end
-- Let OmniCC and ShaguPlates own their cooldown text. Also hide an overlay
-- that may have been created before either compatibility flag was applied.
if this.noCooldownCount or this.pfCooldownType then
if this.noCooldownCount or this.pfCooldownType or this.readable then
if this.cooldowntext then
this.cooldowntext:Hide()
end
if this.stCountdownNativeFrame then this.stCountdownNativeFrame:Hide() end
if (this.stNativeCountdown or this.noCooldownCount)
and type(this.SetHideCountdownNumbers) == "function" then
this:SetHideCountdownNumbers(true)
end
return
end
local parent = this:GetParent()
if parent and (CanUseNative(this) or (ShaguTweaks.API.nativecooldowns
and _G.C_StringUtil and type(_G.C_StringUtil.CreateNumericRuleFormatter) == "function")) then
RememberNative(this, start, duration, enable)
if NativeNumbersEnabled() then
local target = NativeTarget(this, parent)
local text = target and target:GetCountdownFontString()
if text then
if this.cooldowntext then this.cooldowntext:Hide() end
if target == this then this.stNativeCountdown = true end
target:SetCountdownFormatter(GetFormatter())
-- Native threshold is strictly greater-than; retain duration >= 2s.
target:SetMinimumCountdownDuration(1999)
local active = start and start > 0 and duration
and duration >= 2 and (not enable or enable > 0)
target:SetHideCountdownNumbers(not active)
if target ~= this then
if active then target:SetCooldown(start, duration) else target:Hide() end
end
text:SetFont(STANDARD_TEXT_FONT, FontSize(parent), "OUTLINE")
text:ClearAllPoints()
text:SetPoint("CENTER", target, "CENTER", 0, 0)
text:SetTextColor(1, 1, 1, 1)
return
end
end
end
-- Never switch on the global CVar: it also controls unrelated addons.
-- Older builds and CVar-off clients keep the Lua renderer.
if this.stCountdownNativeFrame then this.stCountdownNativeFrame:Hide() end
if type(this.SetHideCountdownNumbers) == "function" then this:SetHideCountdownNumbers(true) end
-- don't draw global cooldowns
if not duration or duration < 2 then
-- hide if already existing
@@ -108,7 +229,7 @@ local function SetCooldown(this, start, duration, enable)
end
if this.cooldowntext then
if start > 0 and duration > 0 and (not enable or enable > 0) then
if start and start > 0 and duration > 0 and (not enable or enable > 0) then
this.cooldowntext.elapsed = 0
this.cooldowntext:Show()
else
+1 -1
View File
@@ -1,5 +1,5 @@
local _G = ShaguTweaks.GetGlobalEnv()
local L, T = ShaguTweaks.L, ShaguTweaks.T
local T = ShaguTweaks.T
local API = ShaguTweaks.API
local module = ShaguTweaks:register({
+30 -5
View File
@@ -84,6 +84,19 @@ module.enable = function(self)
end
local timerFormat = "%.1f"..T["s"]
local nativeText = API.durationtimers and type(API.GetCastDuration) == "function"
if nativeText then
local formatter = _G.C_StringUtil.CreateNumericRuleFormatter()
formatter:AddBreakpoint({ threshold = 0, format = timerFormat })
castbar.timerBinding = _G.C_DurationUtil.CreateDurationTextBinding()
castbar.timerBinding:SetFontString(castbar.timerText)
castbar.timerBinding:SetFormatter(formatter)
castbar.timerBinding:SetExpiredText(string.format(timerFormat, 0))
ShaguTweaks.HookScript(CastingBarFrame, "OnHide", function()
castbar.timerBinding:Disable()
castbar:Hide()
end)
end
local function UpdateTimer()
local alpha = CastingBarFrame:GetAlpha()
@@ -136,6 +149,7 @@ module.enable = function(self)
castbar.startTime = nil
castbar.endTime = nil
castbar.isChannel = nil
if castbar.timerBinding then castbar.timerBinding:Disable() end
if CastingBarFrame:GetAlpha() == 0 then
castbar:Hide()
end
@@ -157,24 +171,35 @@ module.enable = function(self)
castbar.elapsed = 0
castbar:Show()
UpdateTimer()
if nativeText then
local duration
duration, castbar.fallbackDuration = API.GetCastDuration("player", isChannel,
castbar.startTime, castbar.endTime, castbar.fallbackDuration)
castbar.timerBinding:SetDuration(duration)
castbar.timerBinding:Enable()
else
UpdateTimer()
end
end
-- The cast/channel query runs only on ClassicAPI state-change events.
-- While visible, OnUpdate only animates the remaining-time text and alpha.
-- Native text inherits the player's normal fade. Only older builds need
-- an OnUpdate to animate the remaining-time text and alpha.
castbar.elapsed = 0
castbar:SetScript("OnUpdate", function()
if not nativeText then castbar:SetScript("OnUpdate", function()
this.elapsed = (this.elapsed or 0) + arg1
if this.elapsed < .05 then return end
this.elapsed = 0
UpdateTimer()
end)
end) end
if nativeText then ShaguTweaks.HookScript(CastingBarFrame, "OnShow", RefreshCast) end
local events = CreateFrame("Frame", nil, UIParent)
events:RegisterEvent("PLAYER_ENTERING_WORLD")
for index = 1, table.getn(classicEvents) do
events:RegisterEvent(classicEvents[index])
API.RegisterUnitEvent(events, classicEvents[index], "player")
end
events:SetScript("OnEvent", function()
+64
View File
@@ -80,6 +80,68 @@ local function Quality(itemID)
return API.GetItemQualityByID(itemID)
end
-- Cold metadata needs one retry when ClassicAPI's cache reply arrives. Store
-- identities only: the refresh rereads live slots instead of repainting a
-- recycled button with an old item's reply. No polling while waiting.
local pendingItems = {}
local itemEvents, itemRefreshQueued
local function StopItemEventsIfIdle()
if itemEvents and not next(pendingItems) then
itemEvents:UnregisterEvent("GET_ITEM_INFO_RECEIVED")
end
end
local function RetryItemColors()
itemRefreshQueued = nil
if Engine.borders or Engine.glows then
if Engine.RefreshAll then Engine:RefreshAll() end
end
end
local function TrackColdItem(button, itemID, quality)
if not itemID or quality ~= nil or not (Engine.borders or Engine.glows)
or not button.IsVisible or not button:IsVisible() then
if pendingItems[button] then
pendingItems[button] = nil
StopItemEventsIfIdle()
end
return
end
if not API.eventutils or not _G.C_EventUtils
or not _G.C_EventUtils.IsEventValid("GET_ITEM_INFO_RECEIVED") then return end
if not itemEvents then
itemEvents = CreateFrame("Frame")
itemEvents:SetScript("OnEvent", function()
local refresh
for slot, id in pairs(pendingItems) do
if not slot:IsVisible() then
pendingItems[slot] = nil
elseif id == arg1 then
pendingItems[slot] = nil
if arg2 and arg2 ~= 0 then refresh = true end
end
end
StopItemEventsIfIdle()
if refresh and not itemRefreshQueued then
itemRefreshQueued = true
if API.timer then API.Defer(RetryItemColors)
else ShaguTweaks.QueueFunction(RetryItemColors) end
end
end)
end
if not button.stItemCacheHideHook then
button.stItemCacheHideHook = true
HookScript(button, "OnHide", function()
pendingItems[button] = nil
StopItemEventsIfIdle()
end)
end
pendingItems[button] = itemID
itemEvents:RegisterEvent("GET_ITEM_INFO_RECEIVED")
end
local function IsQuestItem(itemID)
if not itemID then return false end
local _, _, _, _, _, classID = API.GetItemInfoInstant(itemID)
@@ -120,6 +182,7 @@ local function SetVisualColor(button, itemID, opts, knownQuality)
local default = opts.default or defaultColor
local quality = knownQuality
if quality == nil then quality = Quality(itemID) end
TrackColdItem(button, itemID, quality)
local quest = opts.quest ~= false and IsQuestItem(itemID)
local r, g, b
local hasRarity = false
@@ -522,6 +585,7 @@ function Engine:Install()
self.RefreshAll = function()
RefreshPaperdoll()
if InspectFrame and InspectFrame:IsShown() then RefreshInspect() end
RefreshWeapons()
RefreshBags()
if BankFrame and BankFrame:IsShown() then RefreshBank() end
+100 -18
View File
@@ -8,6 +8,7 @@
local _G = ShaguTweaks.GetGlobalEnv()
local T = ShaguTweaks.T
local API = ShaguTweaks.API
local module = ShaguTweaks:register({
title = T["Loss of Control Alert"],
@@ -53,6 +54,13 @@ local DEFAULT_COLOR = COLORS.STUN
-- One table for state: Lua 5.0 allows only 32 upvalues per function.
local S = { unlocked = false, elapsed = 0 }
local Refresh
local function StopTiming()
if S.expiry then S.expiry:Cancel(); S.expiry = nil end
if S.schoolWake then S.schoolWake:Cancel(); S.schoolWake = nil end
if S.binding then S.binding:Disable(); S.frame.bar:SetMinMaxValues(0, 1) end
end
local function Supported()
local c = _G.C_LossOfControl
@@ -73,7 +81,8 @@ local function PickEffect()
local count = _G.C_LossOfControl.GetActiveLossOfControlDataCount() or 0
for i = 1, count do
local data = _G.C_LossOfControl.GetActiveLossOfControlData(i)
if data then
if data and not (data.duration and data.duration > 0
and data.timeRemaining and data.timeRemaining <= 0) then
local rank = RANK[data.locType] or (table.getn(PRIORITY) + 1)
local left = data.timeRemaining or 0
if not best or rank < bestRank or (rank == bestRank and left > bestLeft) then
@@ -97,24 +106,35 @@ local function ApplyTheme()
local color = ShaguTweaks.UIBorder
local r, g, b = color and color.r or .6, color and color.g or .6, color and color.b or .6
local a = color and color.a or 1
f:SetBackdropBorderColor(r, g, b, a)
f.border:SetBackdropBorderColor(r, g, b, a)
f.icon:SetVertexColor(1, 1, 1, 1)
if S.themeR ~= r or S.themeG ~= g or S.themeB ~= b or S.themeA ~= a then
S.themeR, S.themeG, S.themeB, S.themeA = r, g, b, a
f:SetBackdropBorderColor(r, g, b, a)
f.border:SetBackdropBorderColor(r, g, b, a)
f.icon:SetVertexColor(1, 1, 1, 1)
end
end
local function SetBarColor(r, g, b)
if S.native then S.frame.bar:SetStatusBarColor(r, g, b)
else S.frame.bar:SetVertexColor(r, g, b) end
end
local function Apply(data)
local f = S.frame
local color = COLORS[data.locType] or DEFAULT_COLOR
f.icon:SetTexture((data.iconTexture and data.iconTexture ~= "") and data.iconTexture or FALLBACK_ICON)
local icon = (data.iconTexture and data.iconTexture ~= "") and data.iconTexture or FALLBACK_ICON
if S.icon ~= icon then S.icon = icon; f.icon:SetTexture(icon) end
ApplyTheme()
f.bar:SetVertexColor(color[1], color[2], color[3])
f.title:SetText(LABELS[data.locType] or data.displayText or "")
if S.effectColor ~= color then S.effectColor = color; SetBarColor(color[1], color[2], color[3]) end
local title = LABELS[data.locType] or data.displayText or ""
if S.title ~= title then S.title = title; f.title:SetText(title) end
-- spell name under the label; school lockouts name the locked school
local detail = SchoolName(data)
if not detail and data.locType ~= "SCHOOL_INTERRUPT" then detail = data.displayText end
f.detail:SetText(detail or "")
detail = detail or ""
if S.detail ~= detail then S.detail = detail; f.detail:SetText(detail) end
f.data = data
end
@@ -140,18 +160,43 @@ local function UpdateTime()
end
end
-- Pick again and redraw. Called on the events and while the alert is up,
-- because re-locking a school extends its lockout without firing an event.
local function Refresh()
-- Re-select on lifecycle events and timed expiry. Native school lockouts also
-- check scalar state for silent extensions; older builds retain their poller.
Refresh = function()
local f = S.frame
if S.unlocked then return end
local data = PickEffect()
if data then
Apply(data)
UpdateTime()
if S.native then
StopTiming()
local left, total = data.timeRemaining, data.duration
if left and left > 0 then
S.duration:SetTimeFromEnd(GetTime() + left, total and total > 0 and total or left)
S.binding:SetDuration(S.duration)
S.binding:Enable()
S.expiry = _G.C_Timer.NewTimer(left, S.expire)
if total and total > 0 then
f.bar:SetTimerDuration(S.duration, _G.Enum.StatusBarInterpolation.Immediate,
_G.Enum.StatusBarTimerDirection.RemainingTime)
f.bar:Show()
else f.bar:Hide() end
else
f.time:SetText("")
f.bar:Hide()
end
if data.locType == "SCHOOL_INTERRUPT" then
if type(_G.C_LossOfControl.GetSchoolLockout) == "function" then
local mask, remaining = _G.C_LossOfControl.GetSchoolLockout()
S.schoolMask, S.schoolEnd = mask, GetTime() + (remaining or 0)
end
S.schoolWake = _G.C_Timer.NewTimer(.2, S.checkSchool)
end
else UpdateTime() end
if not f:IsShown() then f:Show() end
elseif f:IsShown() then
else
StopTiming()
f.data = nil
f:Hide()
end
@@ -184,10 +229,13 @@ local function SetUnlocked(on)
f:EnableMouse(on)
if on then
StopTiming()
S.icon, S.effectColor, S.title, S.detail = nil, nil, nil, nil
f.data = nil
f.icon:SetTexture(FALLBACK_ICON)
ApplyTheme()
f.bar:SetVertexColor(1, .2, .2)
SetBarColor(1, .2, .2)
if S.native then f.bar:SetValue(1) end
f.bar:SetWidth(BAR_WIDTH)
f.bar:Show()
f.title:SetText(T["Stunned"])
@@ -252,8 +300,41 @@ local function CreateAlert()
f.time:SetTextColor(1, 1, 1)
-- remaining time as a bar under the text
f.bar = f:CreateTexture(nil, "ARTWORK")
f.bar:SetTexture("Interface\\TargetingFrame\\UI-StatusBar")
S.native = API.durationtimers and _G.Enum.StatusBarTimerDirection.RemainingTime ~= nil
if S.native then
f.bar = CreateFrame("StatusBar", nil, f)
if type(f.bar.SetTimerDuration) ~= "function" then S.native = false; f.bar:Hide() end
end
if S.native then
f.bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
f.bar:SetWidth(BAR_WIDTH)
S.duration = _G.C_DurationUtil.CreateDuration()
local formatter = _G.C_StringUtil.CreateNumericRuleFormatter()
formatter:SetBreakpoints({{ threshold = 0, format = "%.1f" },
{ threshold = 10, step = 1, format = "%d" }})
S.binding = _G.C_DurationUtil.CreateDurationTextBinding()
S.binding:SetFontString(f.time)
S.binding:SetFormatter(formatter)
S.binding:SetExpiredText("")
S.binding:SetUpdateInterval(.05)
S.expire = function() S.expiry = nil; Refresh() end
-- Re-locking an active school can extend it without any native event.
-- Check cheap scalar state only while a school lockout is displayed.
S.checkSchool = function()
S.schoolWake = nil
if S.unlocked or not f.data then return end
local query = _G.C_LossOfControl.GetSchoolLockout
if type(query) ~= "function" then Refresh(); return end
local mask, remaining = query()
local finish = GetTime() + (remaining or 0)
if mask ~= S.schoolMask or math.abs(finish - S.schoolEnd) > .05 then
Refresh()
else S.schoolWake = _G.C_Timer.NewTimer(.2, S.checkSchool) end
end
else
f.bar = f:CreateTexture(nil, "ARTWORK")
f.bar:SetTexture("Interface\\TargetingFrame\\UI-StatusBar")
end
f.bar:SetHeight(4)
f.bar:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", ICON + 20, 10)
@@ -271,8 +352,8 @@ local function CreateAlert()
RestorePosition()
end)
-- Runs only while the alert is shown (a hidden frame gets no OnUpdate).
f:SetScript("OnUpdate", OnUpdate)
-- Only older builds need a Lua animation/selection loop while shown.
if not S.native then f:SetScript("OnUpdate", OnUpdate) end
S.events = CreateFrame("Frame")
S.events:SetScript("OnEvent", function()
@@ -314,5 +395,6 @@ module.disable = function(self)
if not S.frame then return end
if S.unlocked then SetUnlocked(false) end
S.events:UnregisterAllEvents()
StopTiming()
S.frame:Hide()
end
+12 -4
View File
@@ -35,7 +35,8 @@ local ignoreList = {
"GameTimeFrame",
"MinimapToggleButton",
"TWMinimapShopFrame",
"LFT_Minimap"
"LFT_Minimap",
"LFTMinimapButton",
}
local function ShouldIgnore(name)
@@ -99,6 +100,7 @@ local function RefreshButtonCache()
buttonCache[table.getn(buttonCache) + 1] = child
end
end
return true
end
local function SetButtonsAlpha(alpha)
@@ -128,15 +130,20 @@ module.enable = function(self)
self.frame = self.frame or CreateFrame("Frame")
-- Prime the cache once. Future scans only happen when the minimap child count
-- changes, e.g. when an addon creates a button after login.
RefreshButtonCache()
-- Addons can replace a child without changing the final child count.
self.frame:RegisterEvent("ADDON_LOADED")
self.frame:SetScript("OnEvent", function() cachedChildCount = -1 end)
SetButtonsAlpha(1)
self.frame:SetScript("OnUpdate", function()
self.elapsed = self.elapsed + arg1
if self.elapsed < THROTTLE then return end
self.elapsed = 0
-- Only the native child count is queried while the cache is unchanged.
-- Newly discovered buttons inherit the current state even while hidden.
if RefreshButtonCache() then SetButtonsAlpha(self.isShown and 1 or 0) end
local isOver = API.IsMouseOver(Minimap, MARGIN, -MARGIN, -MARGIN, MARGIN)
if isOver then
@@ -161,6 +168,7 @@ end
module.disable = function(self)
if self.frame then
self.frame:SetScript("OnUpdate", nil)
self.frame:UnregisterEvent("ADDON_LOADED")
end
-- Restore all detected addon buttons when the module is disabled.
+27
View File
@@ -1,4 +1,30 @@
local T = ShaguTweaks.T
local _G = ShaguTweaks.GetGlobalEnv()
local function FixLFTButtonDrag()
local button = _G.LFTMinimapButton
if not button or button.stEnlargedDragFix then return end
local stop = button:GetScript("OnDragStop")
if type(stop) ~= "function" then return end
button.stEnlargedDragFix = true
button:SetScript("OnDragStop", function(...)
local parent = button:GetParent()
local left, top = button:GetLeft(), button:GetTop()
local scale = button:GetScale()
stop(unpack(arg))
-- Turtle's handler detaches to UIParent without converting coordinates or
-- inherited scale. Keep the native parent/local scale (also across reloads)
-- and preserve the dropped screen position. Native reset remains untouched.
if parent == Minimap and button:GetParent() == UIParent then
button:SetParent(parent)
button:SetScale(scale)
if left and top then
button:ClearAllPoints()
button:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", left, top)
end
end
end)
end
local module = ShaguTweaks:register({
title = T["Enlarged Minimap"],
@@ -121,6 +147,7 @@ module.enable = function(self)
end
MinimapCluster:SetScale(scale)
FixLFTButtonDrag()
ShaguTweaks.ScheduleMinimapClamp()
-- BuffFrame is anchored to UIParent TOPRIGHT/TOPRIGHT x=-205 y=-13 by vanilla.
+17 -6
View File
@@ -35,7 +35,10 @@ module.enable = function(self)
autovendor:SetScript("OnShow", function()
this.startMoney = GetMoney()
this.started = HasGreyItems()
this.lastCount = API.GetNumJunkItems()
this.started = this.lastCount > 0
this.elapsed = 0
this.progressUntil = GetTime() + 5
if not this.started then
this:Hide()
@@ -45,6 +48,7 @@ module.enable = function(self)
-- ClassicAPI performs a real merchant sale and drains the queue one item
-- per frame, without abusing UseContainerItem or a manual throttle.
API.SellAllJunkItems()
this.button:Update()
end)
autovendor:SetScript("OnHide", function()
@@ -57,15 +61,22 @@ module.enable = function(self)
this.started = nil
this.startMoney = nil
this.lastCount, this.elapsed, this.progressUntil = nil, nil, nil
if this.button then this.button:Update() end
end)
-- This frame is visible only while ClassicAPI's short sell queue is active.
-- Polling a single engine count here replaces repeated full bag scans.
-- Native code owns sale pacing. Check completion ten times per second and
-- stop waiting after five seconds without progress; never resubmit the sale.
autovendor:SetScript("OnUpdate", function()
if this.started and API.GetNumJunkItems() == 0 then
this:Hide()
end
if not this.started then return end
this.elapsed = this.elapsed + arg1
if this.elapsed < .1 then return end
this.elapsed = 0
local count = API.GetNumJunkItems()
local now = GetTime()
if count < this.lastCount then this.progressUntil = now + 5 end
this.lastCount = count
if count == 0 or now >= this.progressUntil then this:Hide() end
end)
autovendor:RegisterEvent("MERCHANT_SHOW")
+14 -4
View File
@@ -340,6 +340,14 @@ end
-- Classic cast bar look: dark background, UI-StatusBar fill, the cast bar
-- border and spark, and an icon frame like Improved Castbar's.
local function ApplyBarTheme(bar)
local tint, edge = ShaguTweaks.UITint, ShaguTweaks.UIBorder
bar.border:SetVertexColor(tint and tint.r or 1, tint and tint.g or 1,
tint and tint.b or 1, tint and tint.a or 1)
bar.icon:SetBackdropBorderColor(edge and edge.r or 1, edge and edge.g or 1,
edge and edge.b or 1, edge and edge.a or 1)
end
local function CreateBar(key)
local bar = CreateFrame("StatusBar", "ShaguTweaksSwingTimer_" .. key, S.container)
bar:SetWidth(WIDTH)
@@ -353,7 +361,8 @@ local function CreateBar(key)
bar.bg:SetAllPoints(bar)
bar.bg:SetTexture(0, 0, 0, .5)
bar.border = bar:CreateTexture(nil, "ARTWORK")
-- The StatusBar fill uses ARTWORK; keep the rim above it at every progress.
bar.border = bar:CreateTexture(nil, "OVERLAY")
bar.border:SetTexture("Interface\\CastingBar\\UI-CastingBar-Border")
bar.border:SetWidth(256)
bar.border:SetHeight(64)
@@ -411,15 +420,16 @@ local function CreateBar(key)
tile = true, tileSize = 8, edgeSize = 12,
insets = { left = 2, right = 2, top = 2, bottom = 2 }
})
if ShaguTweaks.DarkMode then
bar.icon:SetBackdropBorderColor(.3, .3, .3, .9)
end
bar.icon.texture = bar.icon:CreateTexture(nil, "BACKGROUND")
bar.icon.texture:SetPoint("CENTER", 0, 0)
bar.icon.texture:SetWidth(24)
bar.icon.texture:SetHeight(24)
bar.icon.texture:SetTexCoord(.08, .92, .08, .92)
ApplyBarTheme(bar)
-- Modules initialize in arbitrary order. Read the final palette when a real
-- timer or mover preview appears, without an idle update or recolor scan.
bar:SetScript("OnShow", function() ApplyBarTheme(this) end)
-- Each bar is dragged on its own while the preview is up.
bar:SetMovable(true)
+56 -9
View File
@@ -159,12 +159,19 @@ local function UpdatePosition()
end
local function HideCast()
if castbar.expiry then castbar.expiry:Cancel(); castbar.expiry = nil end
if castbar.nativeProgress then castbar:SetMinMaxValues(0, 1) end
castbar.startTime = nil
castbar.endTime = nil
castbar.isChannel = nil
castbar:Hide()
end
local function ExpireCast()
castbar.expiry = nil
HideCast()
end
local function UpdateProgress()
local startTime = castbar.startTime
local endTime = castbar.endTime
@@ -216,6 +223,7 @@ local function RefreshCast()
castbar.startTime = startTime / 1000
castbar.endTime = endTime / 1000
castbar.isChannel = isChannel
if castbar.endTime <= GetTime() then HideCast(); return end
local max = castbar.endTime - castbar.startTime
castbar:SetMinMaxValues(0, max)
@@ -230,7 +238,17 @@ local function RefreshCast()
UpdatePosition()
castbar:Show()
UpdateProgress()
if castbar.nativeProgress then
if castbar.expiry then castbar.expiry:Cancel() end
local duration
duration, castbar.fallbackDuration = API.GetCastDuration("target", isChannel,
castbar.startTime, castbar.endTime, castbar.fallbackDuration)
castbar:SetTimerDuration(duration, _G.Enum.StatusBarInterpolation.Immediate,
isChannel and _G.Enum.StatusBarTimerDirection.RemainingTime or _G.Enum.StatusBarTimerDirection.ElapsedTime)
castbar.expiry = _G.C_Timer.NewTimer(math.max(0, castbar.endTime - GetTime()), ExpireCast)
else
UpdateProgress()
end
end
-- Hold Ctrl+Shift+Alt to show a marker where the bar goes and drag it; the
@@ -280,7 +298,7 @@ local function CreateMover()
mover.bg:SetAllPoints(mover)
mover.bg:SetTexture(0, 0, 0, .5)
mover.border = mover:CreateTexture(nil, "ARTWORK")
mover.border = mover:CreateTexture(nil, "OVERLAY")
mover.border:SetTexture("Interface\\CastingBar\\UI-CastingBar-Border")
mover.border:SetWidth(256 * f)
mover.border:SetHeight(64 * f)
@@ -308,9 +326,6 @@ local function CreateMover()
tile = true, tileSize = 8, edgeSize = 12,
insets = { left = 2, right = 2, top = 2, bottom = 2 }
})
if ShaguTweaks.DarkMode then
mover.icon:SetBackdropBorderColor(.3, .3, .3, .9)
end
mover.icon.texture = mover.icon:CreateTexture(nil, "BACKGROUND")
mover.icon.texture:SetPoint("CENTER", 0, 0)
mover.icon.texture:SetWidth(16)
@@ -338,6 +353,11 @@ local function SetPreview(on)
if (mover and mover:IsShown() and true or false) == on then return end
if on then
if not mover then CreateMover() end
local tint, edge = ShaguTweaks.UITint, ShaguTweaks.UIBorder
mover.border:SetVertexColor(tint and tint.r or 1, tint and tint.g or 1,
tint and tint.b or 1, tint and tint.a or 1)
mover.icon:SetBackdropBorderColor(edge and edge.r or 1, edge and edge.g or 1,
edge and edge.b or 1, edge and edge.a or 1)
PlaceMover()
mover:Show()
elseif mover then
@@ -374,15 +394,41 @@ module.enable = function(self)
end
end
-- Animation-only OnUpdate; hidden frames do not receive OnUpdate ticks.
castbar:SetScript("OnUpdate", UpdateProgress)
castbar.nativeProgress = API.durationtimers and type(API.GetCastDuration) == "function"
and type(castbar.SetTimerDuration) == "function"
and _G.Enum.StatusBarTimerDirection.RemainingTime ~= nil
if castbar.nativeProgress then
castbar.spark:ClearAllPoints()
castbar.spark:SetPoint("CENTER", castbar:GetStatusBarTexture(), "RIGHT", 0, 0)
else
-- Older ClassicAPI builds retain the animation-only fallback.
castbar:SetScript("OnUpdate", UpdateProgress)
end
local listener = CreateFrame("Frame")
listener:RegisterEvent("PLAYER_TARGET_CHANGED")
listener:RegisterEvent("MODIFIER_STATE_CHANGED")
for index = 1, table.getn(castEvents) do
API.RegisterUnitEvent(listener, castEvents[index], "target")
API.RegisterUnitEvent(listener, castEvents[index], "target", "player")
end
-- Layout changes are event-driven on the native animation path. Defer the
-- aura pass so native/third-party buff rows finish laying themselves out.
local positionQueued
local function RunPositionUpdate() positionQueued = nil; UpdatePosition() end
local function QueuePositionUpdate()
if positionQueued or not castbar:IsShown() then return end
positionQueued = true
if API.timer then API.Defer(RunPositionUpdate)
else ShaguTweaks.QueueFunction(RunPositionUpdate) end
end
if castbar.nativeProgress then
API.RegisterUnitEvent(listener, "UNIT_AURA", "target")
if TargetofTargetFrame then
ShaguTweaks.HookScript(TargetofTargetFrame, "OnShow", QueuePositionUpdate)
ShaguTweaks.HookScript(TargetofTargetFrame, "OnHide", QueuePositionUpdate)
end
end
listener:SetScript("OnEvent", function()
@@ -395,10 +441,11 @@ module.enable = function(self)
RefreshCast()
return
end
if event == "UNIT_AURA" then QueuePositionUpdate(); return end
-- ClassicAPI fans remote cast events out to every unit token currently
-- resolving to the caster, including "target" when appropriate.
if arg1 == "target" then
if arg1 == "target" or (arg1 == "player" and UnitIsUnit("target", "player")) then
RefreshCast()
end
end)
+62 -9
View File
@@ -2,11 +2,12 @@
--
-- One icon per active totem (Fire, Earth, Water, Air) with the time left.
-- Left-click targets the totem, hovering shows its tooltip. The data comes
-- from ClassicAPI's GetTotemInfo / GetTotemTimeLeft, and the display is
-- redrawn on PLAYER_TOTEM_UPDATE, with a light timer only while a totem is up.
-- from ClassicAPI's totem APIs. Native duration timers animate where available;
-- older builds use a light update only while a totem is up.
local _G = ShaguTweaks.GetGlobalEnv()
local T = ShaguTweaks.T
local API = ShaguTweaks.API
local module = ShaguTweaks:register({
title = T["Totem Timers"],
@@ -50,6 +51,12 @@ local function FormatTime(seconds)
return string.format("%d", seconds)
end
local function StopButton(b)
if b.expiry then b.expiry:Cancel(); b.expiry = nil end
if b.binding then b.binding:Disable(); b.bar:SetMinMaxValues(0, 1); b.bar:SetValue(1) end
b.active = nil
end
local function Layout()
local shown = 0
for slot = 1, 4 do
@@ -102,12 +109,24 @@ local function Refresh()
for slot = 1, 4 do
local b = S.buttons[slot]
local have, name, start, duration, icon = _G.GetTotemInfo(slot)
if start and start > 0 then
local nativeDuration = S.native and _G.GetTotemDuration(slot)
if start and start > 0 and (not nativeDuration or nativeDuration:GetRemainingDuration() > 0) then
any = true
b.duration = duration
if icon and icon ~= "" then b.icon:SetTexture(icon) end
b:Show()
if S.native then
StopButton(b)
b.active = true
b.bar:SetWidth(SIZE)
b.bar:SetTimerDuration(nativeDuration, _G.Enum.StatusBarInterpolation.Immediate,
_G.Enum.StatusBarTimerDirection.RemainingTime)
b.binding:SetDuration(nativeDuration)
b.binding:Enable()
b.expiry = _G.C_Timer.NewTimer(nativeDuration:GetRemainingDuration(), b.expire)
end
else
StopButton(b)
b.duration = nil
b:Hide()
end
@@ -115,7 +134,7 @@ local function Refresh()
if any then
Layout()
UpdateTimes()
if not S.native then UpdateTimes() end
if not S.frame:IsShown() then S.frame:Show() end
else
S.frame:Hide()
@@ -150,6 +169,7 @@ local function SetUnlocked(on)
if on then
for slot = 1, 4 do
local b = S.buttons[slot]
StopButton(b)
b:EnableMouse(false) -- let the drag reach the container
b.icon:SetTexture(PREVIEW_ICONS[slot])
b.time:SetText(FormatTime(60 - slot * 10))
@@ -189,11 +209,29 @@ local function CreateButton(slot)
b.time:SetTextColor(1, 1, 1)
-- remaining time as a thin bar under the icon
b.bar = b:CreateTexture(nil, "OVERLAY")
b.bar:SetTexture("Interface\\Buttons\\WHITE8X8")
b.bar = CreateFrame("StatusBar", nil, b)
b.bar:SetStatusBarTexture("Interface\\Buttons\\WHITE8X8")
b.bar:SetMinMaxValues(0, 1)
b.bar:SetValue(1)
b.bar:SetWidth(SIZE)
b.bar:SetHeight(3)
b.bar:SetPoint("TOPLEFT", b, "BOTTOMLEFT", 0, -2)
b.bar:SetVertexColor(SLOT_COLORS[slot][1], SLOT_COLORS[slot][2], SLOT_COLORS[slot][3])
b.bar:SetStatusBarColor(SLOT_COLORS[slot][1], SLOT_COLORS[slot][2], SLOT_COLORS[slot][3])
if S.native and type(b.bar.SetTimerDuration) ~= "function" then S.native = false end
if S.native then
b.binding = _G.C_DurationUtil.CreateDurationTextBinding()
b.binding:SetFontString(b.time)
b.binding:SetFormatter(S.formatter)
b.binding:SetExpiredText("")
b.binding:SetUpdateInterval(.1)
b.expire = function()
b.expiry = nil
StopButton(b)
b:Hide()
for i = 1, 4 do if S.buttons[i].active then return end end
if not S.unlocked then S.frame:Hide() end
end
end
b:RegisterForClicks("LeftButtonUp")
b:SetScript("OnClick", function()
@@ -226,6 +264,20 @@ local function CreateTimers()
f:Hide()
S.frame = f
S.native = API.durationtimers and type(_G.GetTotemDuration) == "function"
and _G.Enum.StatusBarTimerDirection.RemainingTime ~= nil
if S.native then
local down = _G.Enum.NumericRuleFormatRounding.Down
S.formatter = _G.C_StringUtil.CreateNumericRuleFormatter()
S.formatter:SetBreakpoints({
{ threshold = 0, step = 1, rounding = down, format = "|cffff3333%d" },
{ threshold = 5.000001, step = 1, rounding = down, format = "|cffffe633%d" },
{ threshold = 10.000001, step = 1, rounding = down, format = "|cffffffff%d" },
{ threshold = 60, format = "|cffffffff%d:%02d", components = {
{ div = 60, step = 1, rounding = down }, { mod = 60, step = 1, rounding = down } } },
})
end
for slot = 1, 4 do CreateButton(slot) end
f:SetScript("OnMouseDown", function()
@@ -242,8 +294,8 @@ local function CreateTimers()
RestorePosition()
end)
-- Runs only while a totem is out (a hidden frame gets no OnUpdate).
f:SetScript("OnUpdate", OnUpdate)
-- Only older builds need a Lua animation loop while a totem is out.
if not S.native then f:SetScript("OnUpdate", OnUpdate) end
S.events = CreateFrame("Frame")
S.events:SetScript("OnEvent", function()
@@ -284,5 +336,6 @@ module.disable = function(self)
if not S.frame then return end
if S.unlocked then SetUnlocked(false) end
S.events:UnregisterAllEvents()
for slot = 1, 4 do StopButton(S.buttons[slot]) end
S.frame:Hide()
end
+27 -8
View File
@@ -5,7 +5,32 @@
if not TargetHPText or not TargetHPPercText then return end
local T = ShaguTweaks.T
local L = ShaguTweaks.L
local _G = ShaguTweaks.GetGlobalEnv()
local function IsFirstContinent()
local maps = _G.C_Map
local first = GetMapContinents()
if first and maps and type(maps.GetBestMapForUnit) == "function"
and type(maps.GetMapInfo) == "function" then
local id = maps.GetBestMapForUnit("player")
for depth = 1, 8 do
if not id or id == 0 then return false end
local info = maps.GetMapInfo(id)
if not info then break end
if info.name == first then return true end
id = info.parentMapID
end
end
-- Older ClassicAPI builds lack the map hierarchy. Restore the viewed map
-- immediately instead of leaving it on the player's zone after clock hover.
local continent, zone = GetCurrentMapContinent(), GetCurrentMapZone()
if continent == nil or zone == nil then return end
SetMapToCurrentZone()
local result = GetCurrentMapContinent() == 1
if continent == 0 then SetMapZoom(0) else SetMapZoom(continent, zone) end
return result
end
local module = ShaguTweaks:register({
title = T["Turtle WoW Compatibility"],
@@ -21,8 +46,7 @@ module.enable = function(self)
local zh, zm = GetGameTime()
local sh, sm = zh, zm
SetMapToCurrentZone()
if GetCurrentMapContinent() == 1 then
if IsFirstContinent() then
sh = sh + 12
sh = sh >= 24 and sh - 24 or sh
end
@@ -79,9 +103,4 @@ module.enable = function(self)
-- Vendor Values exposes a lazy ClassicAPI-backed SellValueDB compatibility
-- table, so do not allocate a second Turtle-specific static price database.
-- update debuff durations
L["debuffs"]["Hand of Reckoning"] = {[0]=3.0}
L["debuffs"]['Insect Swarm'] = {[0]=18.0}
L["debuffs"]['Moonfire'] = {[1]=9.0,[2]=18.0,[3]=18.0,[4]=18.0,[5]=18.0,[6]=18.0,[7]=18.0,[8]=18.0,[9]=18.0,[10]=18.0,[0]=18.0}
end
+33 -10
View File
@@ -121,7 +121,7 @@ local function SetupModernPowerBars()
if not state then return end
local unit = bar.unit or state.unit
if not ShaguTweaks.ModernClassColors or not UnitExists(unit) then
state.background:Hide()
if state.background:IsShown() then state.background:Hide() end
return
end
-- The druid's secondary bar stays mana while the primary bar is rage/energy.
@@ -132,24 +132,37 @@ local function SetupModernPowerBars()
local suffix = state.family
if suffix == "Player" then
suffix = ""
local _, class = UnitClass(unit)
-- Keep the retail energy variant for combo-point users.
if powerType == 3 and (class == "ROGUE" or class == "DRUID") then
suffix = "Class"
if powerType == 3 then
local _, class = UnitClass(unit)
if class == "ROGUE" or class == "DRUID" then suffix = "Class" end
end
end
-- Vanilla mana slots are rectangular. Retail target/small-frame fills
-- have cut-out corners that leave a visible wedge inside those slots.
-- Use the native rectangular mana artwork for every mana frame.
if powerType == 0 then suffix = "Class" end
bar:SetStatusBarTexture(texture .. suffix)
state.background:Show()
if state.styleTexture ~= texture or state.styleSuffix ~= suffix then
state.styleTexture, state.styleSuffix = texture, suffix
state.wantedTexture = texture .. suffix
end
local wanted = state.wantedTexture
local current = bar:GetStatusBarTexture()
if not current or current:GetTexture() ~= wanted then bar:SetStatusBarTexture(wanted) end
if not state.background:IsShown() then state.background:Show() end
-- Call the method captured before our hook to avoid recursive updates.
state.setColor(bar, 1, 1, 1, 1)
local r, g, b, a = bar:GetStatusBarColor()
if not r or math.abs(r - 1) > .0001 or math.abs(g - 1) > .0001
or math.abs(b - 1) > .0001 or not a or math.abs(a - 1) > .0001 then
state.setColor(bar, 1, 1, 1, 1)
end
else
-- Unknown/custom power types retain their original texture and tint.
bar:SetStatusBarTexture(state.originalTexture)
state.background:Hide()
local current = bar:GetStatusBarTexture()
if not current or current:GetTexture() ~= state.originalTexture then
bar:SetStatusBarTexture(state.originalTexture)
end
if state.background:IsShown() then state.background:Hide() end
end
end
@@ -190,7 +203,17 @@ local function SetupModernPowerBars()
end
-- Vanilla refreshes power type here. Appending preserves the normal frame
-- updates, including shapeshifts and targets with a different power type.
hooksecurefunc("UnitFrame_UpdateManaType", RefreshPowerBars)
hooksecurefunc("UnitFrame_UpdateManaType", function(frame)
-- Clients use either an explicit unit frame or the legacy `this` context.
if type(frame) ~= "table" then frame = this end
if not frame then return end
if bars[frame] then ApplyPowerStyle(frame); return end
if frame.manabar and bars[frame.manabar] then ApplyPowerStyle(frame.manabar); return end
if not frame.unit then return end
for bar, state in pairs(bars) do
if (bar.unit or state.unit) == frame.unit then ApplyPowerStyle(bar) end
end
end)
RefreshPowerBars()
return RefreshPowerBars
end
+28 -14
View File
@@ -13,6 +13,8 @@ for _, name in pairs(tocs) do
end
end
local CLASS_PORTRAIT_TEXTURE = (addonpath or "Interface\\AddOns\\ShaguTweaks") .. "\\img\\UI-Classes-Circles"
local CLASS_ICON_TCOORDS = {
["WARRIOR"] = { 0, 0.25, 0, 0.25 },
["MAGE"] = { 0.25, 0.49609375, 0, 0.25 },
@@ -46,33 +48,45 @@ local function UpdatePortraits(frame)
if class then
local iconCoords = CLASS_ICON_TCOORDS[class]
if iconCoords then
frame.portrait:SetTexture(addonpath .. "\\img\\UI-Classes-Circles")
frame.portrait:SetTexCoord(unpack(iconCoords))
local wanted = CLASS_PORTRAIT_TEXTURE
if frame.portrait:GetTexture() ~= wanted then frame.portrait:SetTexture(wanted) end
local l, t, l2, b, r, t2, r2, b2 = frame.portrait:GetTexCoord()
if l ~= iconCoords[1] or t ~= iconCoords[3] or l2 ~= iconCoords[1]
or b ~= iconCoords[4] or r ~= iconCoords[2] or t2 ~= iconCoords[3]
or r2 ~= iconCoords[2] or b2 ~= iconCoords[4] then
frame.portrait:SetTexCoord(unpack(iconCoords))
end
end
else
frame.portrait:SetTexCoord(0, 1, 0, 1)
local l, t, l2, b, r, t2, r2, b2 = frame.portrait:GetTexCoord()
if l ~= 0 or t ~= 0 or l2 ~= 0 or b ~= 1 or r ~= 1 or t2 ~= 0 or r2 ~= 1 or b2 ~= 1 then
frame.portrait:SetTexCoord(0, 1, 0, 1)
end
end
end
module.enable = function(self)
-- overwrite portraits set by the game
local original = UnitFrame_Update
UnitFrame_Update = function(arg1, arg2, arg3)
original(arg1, arg2, arg3)
UpdatePortraits(this)
end
ShaguTweaks.hooksecurefunc("UnitFrame_Update", function(frame)
UpdatePortraits(type(frame) == "table" and frame.unit and frame or this)
end)
-- handle portrait update events
local events = CreateFrame("Frame")
events:RegisterEvent("PLAYER_ENTERING_WORLD")
events:RegisterEvent("UNIT_PORTRAIT_UPDATE")
local function RefreshFrame(frame)
if not frame or not frame.unit then return end
if event == "PLAYER_ENTERING_WORLD" or frame.unit == arg1
or (arg1 and UnitIsUnit(frame.unit, arg1)) then UpdatePortraits(frame) end
end
events:SetScript("OnEvent", function()
UpdatePortraits(PlayerFrame)
UpdatePortraits(TargetFrame)
UpdatePortraits(PartyMemberFrame1)
UpdatePortraits(PartyMemberFrame2)
UpdatePortraits(PartyMemberFrame3)
UpdatePortraits(PartyMemberFrame4)
RefreshFrame(PlayerFrame)
RefreshFrame(TargetFrame)
RefreshFrame(PartyMemberFrame1)
RefreshFrame(PartyMemberFrame2)
RefreshFrame(PartyMemberFrame3)
RefreshFrame(PartyMemberFrame4)
end)
-- Turtle/Vanilla already runs TargetofTarget_OnUpdate for the target-of-target
+24 -8
View File
@@ -95,18 +95,32 @@ module.enable = function(self)
percent = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
percent:SetPoint("LEFT", bar, "LEFT", 4, 0)
end
local function Appearance()
local function TextureAppearance()
local texture = source:GetStatusBarTexture()
bar:SetStatusBarTexture(texture and texture:GetTexture() or "Interface\\TargetingFrame\\UI-StatusBar")
local path = texture and texture:GetTexture() or "Interface\\TargetingFrame\\UI-StatusBar"
if state.texturePath ~= path then
state.texturePath = path
bar:SetStatusBarTexture(path)
end
end
local function ColorAppearance()
local r, g, b = source:GetStatusBarColor()
bar:SetStatusBarColor(r, g, b, 1)
if state.r ~= r or state.g ~= g or state.b ~= b then
state.r, state.g, state.b = r, g, b
bar:SetStatusBarColor(r, g, b, 1)
end
end
local function Values()
local minimum, maximum = source:GetMinMaxValues()
local value = source:GetValue() or 0
maximum = maximum or 0
bar:SetMinMaxValues(minimum or 0, maximum > 0 and maximum or 1)
bar:SetValue(value)
minimum = minimum or 0
local rangeChanged = state.minimum ~= minimum or state.maximum ~= maximum
if rangeChanged then
state.minimum = minimum
bar:SetMinMaxValues(minimum, maximum > 0 and maximum or 1)
end
if rangeChanged or state.value ~= value then bar:SetValue(value) end
if value ~= state.value or maximum ~= state.maximum then
state.value, state.maximum = value, maximum
text:SetText(ShaguTweaks.Abbreviate(value))
@@ -115,9 +129,11 @@ module.enable = function(self)
end
ShaguTweaks.hooksecurefunc(source, "SetValue", Values)
ShaguTweaks.hooksecurefunc(source, "SetMinMaxValues", Values)
ShaguTweaks.hooksecurefunc(source, "SetStatusBarColor", Appearance)
ShaguTweaks.hooksecurefunc(source, "SetStatusBarTexture", Appearance)
Appearance(); Values()
ShaguTweaks.hooksecurefunc(source, "SetStatusBarColor", ColorAppearance)
ShaguTweaks.hooksecurefunc(source, "SetStatusBarTexture", function()
TextureAppearance(); ColorAppearance()
end)
TextureAppearance(); ColorAppearance(); Values()
if attachedTo then
frame:SetParent(attachedTo)
frame:SetScale(1)
+17 -12
View File
@@ -22,6 +22,15 @@ end
local partyUnits = { "party1", "party2", "party3", "party4" }
local UPDATE_INTERVAL = .1
local partyNames = {}
local function RefreshPartyNames()
for name in pairs(partyNames) do partyNames[name] = nil end
for i = 1, 4 do
local name = UnitName(partyUnits[i])
if name then partyNames[name] = true end
end
end
local function SetAllPointsOffset(frame, parent, offset)
frame:SetPoint("TOPLEFT", parent, "TOPLEFT", offset, -offset)
@@ -61,7 +70,7 @@ local function UpdateIcon(frame, fallback)
if not unit and not name then return end
local icon = _G[frame:GetName() .. "Icon"]
if icon then icon:SetTexture() end
if icon and icon:GetTexture() then icon:SetTexture() end
-- create icon if not yet existing
if not frame.texture then
@@ -70,13 +79,7 @@ local function UpdateIcon(frame, fallback)
end
-- check if unit is in same party group
local ingroup
for i = 1, 4 do
if UnitName(partyUnits[i]) == name then
ingroup = true
break
end
end
local ingroup = name and partyNames[name]
-- update texture according to raid/group state
if ingroup and frame.texture.ingroup ~= "PARTY" then
@@ -97,10 +100,10 @@ local function UpdateIcon(frame, fallback)
end
local color = class and RAID_CLASS_COLORS[class]
if color then
frame.texture:SetVertexColor(color.r, color.g, color.b)
else
frame.texture:SetVertexColor(.5,1,.5)
local r, g, b = color and color.r or .5, color and color.g or 1, color and color.b or .5
if frame.texture.classR ~= r or frame.texture.classG ~= g or frame.texture.classB ~= b then
frame.texture.classR, frame.texture.classG, frame.texture.classB = r, g, b
frame.texture:SetVertexColor(r, g, b)
end
end
@@ -108,11 +111,13 @@ local function UpdateMapColors()
this.elapsed = (this.elapsed or UPDATE_INTERVAL) + arg1
if this.elapsed < UPDATE_INTERVAL then return end
this.elapsed = 0
local partyRefreshed = false
local buttons = this.buttons
for i = 1, table.getn(buttons) do
local frame = _G[buttons[i][1]]
if frame and frame:IsVisible() then
if not partyRefreshed then RefreshPartyNames(); partyRefreshed = true end
UpdateIcon(frame, buttons[i][2])
end
end
+30 -1
View File
@@ -9,7 +9,7 @@ sh tests/run_all.sh ../tools/lua5.0/lua5.0
The interpreter defaults to `lua5.0` if omitted. The runner discovers all
`tests/test_*.lua` files, reports every failure, prints a final count, and exits
unsuccessfully if any suite fails. It is developer tooling, is not loaded by the
addon, and has no in-game overhead. The current suite contains 31 test files.
addon, and has no in-game overhead. The current suite contains 45 test files.
Individual checks can also be run from the addon root:
@@ -119,3 +119,32 @@ Quest-log checks use the inspected Turtle native 512/256-pixel tiles, preserve l
Recolor scheduling checks verify immediate/trailing behavior, one pending callback per frame, callback reuse, late children and FIFO cache limits with dynamic name/path changes.
Recolor profiling tests verify opt-in traversal counts, recursive root attribution, unique/repeat visits and that stopped captures no longer receive updates.
Module lifecycle checks cover foreign merchant hook chains and error recovery,
conditional-macro reagent changes, late minimap buttons, Turtle clock map
selection, scaled LFT button dragging, and AoE Loot removal/migration.
Sell Junk checks cover throttled completion, stalled progress, extended active
sales, merchant close, profit reporting and fresh attempts. Item-cache checks
cover cold/warm metadata, native failure replies, hidden/removed/reused slots,
coalesced refreshes, disabled visuals and native/fallback deferred scheduling.
Cooldown checks compare native formatting against TimeConvert at color/unit
boundaries, verify no native Lua update driver, shared formatter reuse, CVar
changes without global writes, ownership flags, native number-only children for
legacy indicators, and fallback timer/clock-wrap behavior. Locale checks retain the class mappings and Hunter fallback names
while rejecting obsolete sections and Turtle debuff writes.
Cast-bar tests cover native duration snapshots and separate reusable fallback
durations, cast/channel direction, pushback, interruption, expiry cancellation,
self-targets, native player fade, target aura layout, mover previews, SuperWoW
timing and older API animation. Unit-frame styling tests cover explicit/legacy
hook contexts, per-unit routing, aliases, startup passes, Druid fixed mana,
packed native colors and corrective updates after artwork overwrites.
Control-timer checks cover native totem fill/text, replacement/expiry, preview
and disable cleanup, timed/untimed control effects, priority changes, silent
school-lockout extensions and older API fallback. Cosmetic checks count skipped
resource-copy writes, party-name lookups and map tint/icon writes, and retain
per-tab spam suppression while pruning the cache in place. These operation
counts do not measure in-game CPU or FPS gains.
+50
View File
@@ -0,0 +1,50 @@
-- Minimal vanilla frame environment for module lifecycle regression tests.
return function()
local env=setmetatable({},{__index=getfenv(0)});env._G=env
env.ShaguTweaks={T=setmetatable({},{__index=function(_,key)return key end}),API={}}
env.ShaguTweaks.GetGlobalEnv=function()return env end
env.ShaguTweaks.register=function(_,module)env.module=module;return module end
env.frames,env.hooks={},{}
local methods={}
function methods:SetScript(key,value)self.scripts[key]=value end
function methods:GetScript(key)return self.scripts[key] end
function methods:RegisterEvent(key)self.events[key]=true end
function methods:UnregisterEvent(key)self.events[key]=nil end
function methods:UnregisterAllEvents()self.events={}end
function methods:Hide()self.visible=false end
function methods:Show()
local was=self.visible;self.visible=true
if not was and self.scripts.OnShow then
local before=env.this;env.this=self;self.scripts.OnShow();env.this=before
end
end
function methods:IsVisible()return self.visible end
function methods:GetNormalTexture()return self end
function methods:SetText(value)self.text=value end
function methods:GetText()return self.text end
function methods:SetAlpha(value)self.alpha=value end
function methods:GetName()return self.name end
function methods:GetID()return self.id end
env.CreateFrame=function(_,name,parent)
local frame=setmetatable({scripts={},events={},visible=true,alpha=1,name=name,parent=parent},
{__index=function(_,key)
if methods[key] then return methods[key] end
if string.find(key,'^[A-Z]') then return function()end end
end})
table.insert(env.frames,frame);if name then env[name]=frame end;return frame
end
env.ShaguTweaks.hooksecurefunc=function(name,callback)env.hooks[name]=callback end
env.ShaguTweaks.HookScript=function(frame,script,callback)frame:SetScript(script,callback)end
env.DEFAULT_CHAT_FRAME={AddMessage=function()end}
env.Invoke=function(frame,script,event,payload)
local oldThis,oldEvent,oldArg=env.this,env.event,env.arg1
env.this,env.event,env.arg1=frame,event,payload
frame.scripts[script]()
env.this,env.event,env.arg1=oldThis,oldEvent,oldArg
end
env.Load=function(path)
local f=assert(io.open(path));local source=f:read('*a');f:close()
local chunk=assert(loadstring(source,'@'..path));setfenv(chunk,env);chunk()
end
return env
end
+54
View File
@@ -0,0 +1,54 @@
local root=(arg[1] or '.')..'/'
local env=dofile(root..'tests/fixtures/module_mock.lua')()
local api=env.ShaguTweaks.API
for _,key in ipairs({'GetItemCount','GetItemMaxStackSizeByID','GetItemFamily',
'GetContainerNumFreeSlots','GetContainerItemID','GetContainerItemStackCount',
'GetMerchantItemInfo','GetMerchantItemID','IsShiftKeyDown','IsControlKeyDown'}) do api[key]=function()end end
env.IsAddOnLoaded=function()return false end
env.GetLocale=function()return 'enUS'end
env.StaticPopupDialogs={}
env.MerchantFrame=env.CreateFrame();env.MerchantFrame.selectedTab=1
local native,handled,foreign=0,0,0
local lastButton,lastIgnore
env.MerchantItemButton_OnClick=function(button,ignore)
native=native+1;lastButton,lastIgnore=button,ignore;return 'native',nil,3
end
env.Load(root..'mods/buy-em-all.lua')
local buy=env.STBuyEmAll
local realHandle=buy.HandleMerchantClick
buy.CreateUI=function()env.STBuyEmAllFrame=env.CreateFrame()end
buy.HandleMerchantClick=function()handled=handled+1;return false end
env.module:enable()
local installer=env.frames[table.getn(env.frames)]
local first=env.MerchantItemButton_OnClick
for i=1,10 do env.Invoke(installer,'OnEvent','MERCHANT_SHOW')end
assert(env.MerchantItemButton_OnClick==first)
local saved=first
env.MerchantItemButton_OnClick=function(button,ignore)
foreign=foreign+1;return saved(button,ignore)
end
env.Invoke(installer,'OnEvent','MERCHANT_SHOW')
local a,b,c=env.MerchantItemButton_OnClick('RightButton',nil)
assert(native==1 and handled==1 and foreign==1 and a=='native' and b==nil and c==3)
assert(lastButton=='RightButton' and lastIgnore==nil)
-- Error paths must release the guard so a following click is handled normally.
buy.HandleMerchantClick=function()error('expected click failure')end
assert(not pcall(env.MerchantItemButton_OnClick,'LeftButton'))
buy.HandleMerchantClick=function()handled=handled+1;return true end
env.MerchantItemButton_OnClick('LeftButton');assert(handled==2 and native==1)
-- The single-item route must reach the predecessor with modifiers bypassed.
buy.HandleMerchantClick=realHandle
buy.GetMerchantData=function()return 'Item',123,1,1,1 end
buy.FreeBagSpace=function()return 10,20 end
api.IsShiftKeyDown=function()return true end;api.IsControlKeyDown=function()return false end
env.GetMoney=function()return 100 end
env.ChatFrameEditBox={IsVisible=function()return false end}
env.this={GetID=function()return 1 end}
env.MerchantItemButton_OnClick('RightButton')
assert(native==2 and lastButton=='LeftButton' and lastIgnore==1)
-- A foreign handler that replaces the chain entirely remains reachable too.
env.MerchantItemButton_OnClick=function()return 'replacement'end
env.Invoke(installer,'OnEvent','MERCHANT_SHOW')
buy.HandleMerchantClick=function()return false end
assert(env.MerchantItemButton_OnClick('LeftButton')=='replacement')
print('Buy Em All: immutable hook predecessors, foreign wrapper/replacement, one dispatch, error recovery, native return values and single-item routing passed')
+14 -1
View File
@@ -1,17 +1,22 @@
local root=(arg[1] or '.')..'/'
local methods={}
local frames={}
local function object()
return setmetatable({scripts={}}, {__index=function(t,k) return methods[k] or function() end end})
local f=setmetatable({scripts={}}, {__index=function(t,k) return methods[k] or function() end end})
table.insert(frames,f);return f
end
function methods:SetScript(k,v) self.scripts[k]=v end
function methods:CreateTexture() return object() end
function methods:CreateFontString() return object() end
function methods:SetPoint(...) self.point=arg end
function methods:ClearAllPoints() self.point=nil end
function methods:SetVertexColor(...)self.tint=arg end
UIParent=object();CastingBarFrame=object()
CreateFrame=function(_,name) local f=object();if name then _G[name]=f end;return f end
ShaguTweaks={T=setmetatable({},{__index=function(t,k) return k end}),GetGlobalEnv=function() return _G end}
dofile(root..'api.lua')
local keys=false
ShaguTweaks.API.AreMoveModifiersDown=function()return keys end
function ShaguTweaks:register() error('Cast-bar checkbox must not be registered') end
ShaguTweaks_config={['Movable Cast Bar']=0,CastBarPosition={123,234}}
local f=assert(io.open(root..'main.lua'));local source=f:read('*a');f:close();assert(loadstring(source))
@@ -25,6 +30,14 @@ assert(CastingBarFrame.point[4]==123 and CastingBarFrame.point[5]==234)
CastingBarFrame:SetPoint('BOTTOM',UIParent,'BOTTOM',1,2)
assert(CastingBarFrame.point[4]==123)
local mover=ShaguTweaksCastBarMover
local listener
for _,frame in ipairs(frames)do if frame~=mover and frame.scripts.OnEvent then listener=frame end end
for _,palette in ipairs({{r=.3,g=.3,b=.3,a=.9},{r=1,g=.78,b=.4,a=1},{r=1,g=1,b=1,a=1}})do
ShaguTweaks.UITint=palette
event='MODIFIER_STATE_CHANGED';keys=true;listener.scripts.OnEvent()
assert(mover.border.tint[1]==palette.r and mover.border.tint[2]==palette.g and mover.border.tint[4]==palette.a)
keys=false;listener.scripts.OnEvent()
end
initialize();assert(ShaguTweaksCastBarMover==mover)
arg1='RightButton';mover.scripts.OnMouseUp()
assert(not ShaguTweaks_config.CastBarPosition and CastingBarFrame.point[5]==60)
+155
View File
@@ -0,0 +1,155 @@
local root=(arg[1] or '.')..'/'
local function read(path)local f=assert(io.open(root..path));local s=f:read('*a');f:close();return s end
local function Check(native)
local env=setmetatable({},{__index=getfenv(0)});env._G=env
local frames,modules,timers,queue={},{},{},{}
local now,cast,channel,selfTarget,queries=10,nil,nil,false,0
local keys,remote=false,nil
local methods={}
local function object(kind,name,parent)
local f=setmetatable({kind=kind,name=name,parent=parent,scripts={},events={},shown=true,width=140,alpha=1},
{__index=function(_,key)return methods[key] or (string.find(key,'^[A-Z]') and function()end or nil)end})
table.insert(frames,f);if name then env[name]=f end;return f
end
function methods:SetScript(k,v)self.scripts[k]=v end
function methods:GetScript(k)return self.scripts[k]end
function methods:RegisterEvent(k)self.events[k]=true end
function methods:Hide()self.shown=false end
function methods:Show()self.shown=true end
function methods:IsShown()return self.shown end
function methods:SetPoint(...)self.point=arg end
function methods:ClearAllPoints()self.point=nil end
function methods:GetFont()return 'font',12,'' end
function methods:GetAlpha()return self.alpha end
function methods:SetAlpha(a)self.alpha=a end
function methods:SetWidth(w)self.width=w end
function methods:GetWidth()return self.width end
function methods:GetHeight()return 10 end
function methods:GetEffectiveScale()return 1 end
function methods:SetTexture(t)self.path=t end
function methods:SetText(t)self.text=t end
function methods:SetVertexColor(...)self.tint=arg end
function methods:SetBackdropBorderColor(...)self.rim=arg end
function methods:SetMinMaxValues(a,b)self.low,self.high=a,b;self.timer=nil end
function methods:SetValue(v)self.value=v;self.writes=(self.writes or 0)+1 end
function methods:SetTimerDuration(d,_,direction)self.timer,self.direction=d,direction end
function methods:GetStatusBarTexture()if not self.fill then self.fill=object('Texture')end;return self.fill end
function methods:CreateTexture()return object('Texture')end
function methods:CreateFontString()return object('FontString')end
env.CreateFrame=object
env.UIParent=object('Frame');env.TargetFrame=object('Frame');env.TargetofTargetFrame=object('Frame')
env.TargetFrameBuff1=object('Texture');env.TargetFrameBuff1:Hide()
env.TargetofTargetFrame:Hide()
env.CastingBarFrame=object('StatusBar');env.CastingBarText=object('FontString')
env.GetTime=function()return now end
env.UnitExists=function()return true end
env.UnitIsUnit=function(a,b)return a==b or selfTarget and ((a=='target' and b=='player') or (b=='target' and a=='player'))end
env.Enum={StatusBarInterpolation={Immediate=0},StatusBarTimerDirection={ElapsedTime=0,RemainingTime=1}}
env.C_EventUtils={IsEventValid=function()return true end}
env.C_Timer={NewTimer=function(delay,callback)
local t={at=now+delay,callback=callback};function t:Cancel()self.cancelled=true end
table.insert(timers,t);return t
end}
env.C_DurationUtil={CreateDuration=function()
local d={}
function d:SetTimeSpan(a,b)self.start,self.finish=a,b end
function d:GetStartTime()return self.start end
function d:GetEndTime()return self.finish end
return d
end,CreateDurationTextBinding=function()
local b={};function b:SetFontString(v)self.text=v end
function b:SetFormatter(v)self.formatter=v end
function b:SetExpiredText(v)self.expired=v end
function b:SetDuration(v)self.duration=v end
function b:Enable()self.enabled=true end
function b:Disable()self.enabled=false end
return b
end}
env.C_StringUtil={CreateNumericRuleFormatter=function()return {AddBreakpoint=function(self,v)self.rule=v end}end}
local suppliedDuration
env.UnitCastingDuration=function()return suppliedDuration end
env.UnitChannelDuration=env.UnitCastingDuration
env.ShaguTweaks={T=setmetatable({},{__index=function(_,k)return k end}),API={durationtimers=native,casts=true,eventutils=true,timer=true},
libcast={db={}},GetGlobalEnv=function()return env end}
env.ShaguTweaks.UnitCastingInfo=function()
if remote then return 'Remote','','','remote',remote.start*1000,remote.finish*1000 end
end
function env.ShaguTweaks:register(m)modules[m.title]=m;return m end
env.ShaguTweaks.HookScript=function(frame,name,callback)
local original=frame.scripts[name];frame.scripts[name]=function()if original then original()end;callback()end
end
env.ShaguTweaks.QueueFunction=function(callback)table.insert(queue,callback)end
local API=env.ShaguTweaks.API
API.Defer=env.ShaguTweaks.QueueFunction
API.RegisterUnitEvent=function(f,e,a,b)f.events[e]={a,b}end
API.AreMoveModifiersDown=function()return keys end
API.UnitGUID=function()return 'guid' end
API.GetCastInfo=function()queries=queries+1;if cast then return cast.name or 'Cast','','','icon',cast.start*1000,cast.finish*1000 end end
API.GetChannelInfo=function()if channel then return 'Channel','','','icon',channel.start*1000,channel.finish*1000 end end
local src=read('api.lua');local a=assert(string.find(src,'API.GetCastDuration = function',1,true));local b=assert(string.find(src,'API.IsSpellInRange =',a,true))
local helper=assert(loadstring('local API=ShaguTweaks.API;local _G=ShaguTweaks.GetGlobalEnv();'..string.sub(src,a,b-1)));setfenv(helper,env);helper()
local function load(path)local fn=assert(loadfile(root..path));setfenv(fn,env);fn()end
load('mods/target-castbar.lua');modules['Enemy Castbars']:enable()
load('mods/improved-castbar.lua');modules['Improved Castbar']:enable()
local target,player=env.ShaguTargetCastbar,env.STImprovedCastbar
local function emit(name,unit)
env.event,env.arg1=name,unit
for _,frame in ipairs(frames)do if frame.events[name] and frame.scripts.OnEvent then env.this=frame;frame.scripts.OnEvent()end end
env.this=nil
end
local function runQueue()local work=queue;queue={};for _,fn in ipairs(work)do fn()end end
cast={start=9,finish=15}
suppliedDuration=env.C_DurationUtil.CreateDuration();suppliedDuration:SetTimeSpan(9,15)
emit('UNIT_SPELLCAST_START','target');emit('UNIT_SPELLCAST_START','player')
assert(target.shown and player.shown)
if native then
assert(target.timer==suppliedDuration and target.direction==0 and not target.scripts.OnUpdate)
assert(target.spark.point[2]==target:GetStatusBarTexture())
assert(player.timerBinding.enabled and player.timerBinding.duration==suppliedDuration and not player.scripts.OnUpdate)
assert(player.timerBinding.formatter.rule.format=='%.1fs')
assert(env.CastingBarFrame.timer==nil,'player native fill/fade must remain untouched')
local old=target.expiry
-- Pushback replaces the snapshot and cancels the previous expiry.
cast.finish=17;emit('UNIT_SPELLCAST_DELAYED','target');emit('UNIT_SPELLCAST_DELAYED','player')
assert(old.cancelled and target.timer.finish==17 and player.timerBinding.duration.finish==17)
assert(suppliedDuration.finish==15,'a mismatched engine snapshot must not be mutated')
local fallback=target.fallbackDuration
cast=nil;suppliedDuration=nil;channel={start=10,finish=18};emit('UNIT_SPELLCAST_CHANNEL_START','target');emit('UNIT_SPELLCAST_CHANNEL_START','player')
assert(target.direction==1 and target.fallbackDuration==fallback and player.timerBinding.enabled)
channel.finish=16;emit('UNIT_SPELLCAST_CHANNEL_UPDATE','target');assert(target.timer.finish==16)
-- Aura layout work coalesces; animation does not query casts or write values.
emit('UNIT_AURA','target');emit('UNIT_AURA','target');assert(table.getn(queue)==1);runQueue()
env.TargetofTargetFrame:Show();env.TargetofTargetFrame.scripts.OnShow();runQueue();assert(target.positionY==-24)
local activeTimer=target.timer
env.ShaguTweaks.UITint={r=1,g=.78,b=.4,a=1};env.ShaguTweaks.UIBorder={r=.8,g=.62,b=.32,a=1}
keys=true;emit('MODIFIER_STATE_CHANGED');assert(env.ShaguTweaksTargetCastBarMover.shown and target.timer==activeTimer)
assert(env.ShaguTweaksTargetCastBarMover.border.tint[2]==.78 and env.ShaguTweaksTargetCastBarMover.icon.rim[1]==.8)
keys=false;emit('MODIFIER_STATE_CHANGED');assert(not env.ShaguTweaksTargetCastBarMover.shown)
local before=queries;now=11;assert(queries==before and not target.writes)
-- Cancel/stop clears target immediately, retaining player text for native fade.
channel=nil;emit('UNIT_SPELLCAST_INTERRUPTED','target');emit('UNIT_SPELLCAST_STOP','player')
assert(not target.shown and not target.timer and not player.timerBinding.enabled and player.shown)
env.CastingBarFrame.scripts.OnHide();assert(not player.shown)
-- Missing STOP still expires; replacement casts cannot be hidden by old timers.
cast={start=11,finish=15};emit('UNIT_SPELLCAST_START','target');local expiry=target.expiry
cast.finish=19;emit('UNIT_SPELLCAST_DELAYED','target');assert(expiry.cancelled)
now=19;target.expiry.callback();assert(not target.shown and not target.timer)
-- Player-only events refresh a self-target; unrelated player casts do not.
now=20;cast={start=20,finish=25};selfTarget=true;emit('UNIT_SPELLCAST_START','player');assert(target.shown)
cast=nil;emit('UNIT_SPELLCAST_STOP','player');assert(not target.shown)
selfTarget=false;cast={start=20,finish=25};emit('UNIT_SPELLCAST_START','player');assert(not target.shown)
cast=nil;emit('PLAYER_TARGET_CHANGED');assert(not target.shown)
-- Complete remote timing still uses the optional GUID cache complement.
env.ShaguTweaks.superwow_active=true;env.ShaguTweaks.libcast.db.guid={cast='Remote'}
remote={start=20,finish=26};env.ShaguTweaks.libcast.OnRemoteChanged('guid')
assert(target.shown and target.timer.finish==26 and target.text.text=='Remote')
remote=nil;env.ShaguTweaks.libcast.db.guid=nil;env.ShaguTweaks.libcast.OnRemoteChanged('guid')
assert(not target.shown)
else
assert(target.scripts.OnUpdate and player.scripts.OnUpdate and not player.timerBinding)
env.this=target;target.scripts.OnUpdate();assert(target.value==1)
env.this=player;env.arg1=.1;player.scripts.OnUpdate();assert(player.timerText.text=='5.0s')
end
end
Check(true);Check(false)
print('Castbars: native snapshot/fallback durations, fill/spark/text ownership, delay/channel replacement, cancellation/expiry, player fade, self-target routing and older API animation passed')
+187
View File
@@ -0,0 +1,187 @@
local root=(arg[1] or '.')..'/'
local function Check(native)
local env=setmetatable({},{__index=getfenv(0)});env._G=env
local modules,frames,timers,slots,effects={},{},{},{},{}
local now,keys,totemReads,effectReads,schoolReads=10,false,0,0,0
local methods={}
local function object(kind,name,parent)
local f=setmetatable({kind=kind,parent=parent,scripts={},events={},shown=true,writes=0},
{__index=function(_,key)return methods[key] or (string.find(key,'^[A-Z]') and function()end or nil)end})
table.insert(frames,f);if name then env[name]=f end;return f
end
function methods:RegisterEvent(name)self.events[name]=true end
function methods:UnregisterAllEvents()self.events={}end
function methods:SetScript(k,v)self.scripts[k]=v end
function methods:Show()self.shown=true end
function methods:Hide()self.shown=false end
function methods:IsShown()return self.shown end
function methods:CreateTexture()return object('Texture',nil,self)end
function methods:CreateFontString()return object('FontString',nil,self)end
function methods:SetTexture(path)self.path=path;self.writes=self.writes+1 end
function methods:SetText(text)self.text=text;self.writes=self.writes+1 end
function methods:SetTextColor(...)self.textColor=arg end
function methods:SetStatusBarColor(...)self.color=arg end
function methods:SetVertexColor(...)self.color=arg end
function methods:SetWidth(n)self.width=n end
function methods:SetStatusBarTexture(p)self.path=p end
function methods:SetMinMaxValues()self.timer=nil end
function methods:SetValue(v)self.value=v end
function methods:SetTimerDuration(d,_,direction)self.timer,self.direction=d,direction end
env.CreateFrame=object;env.UIParent=object('Frame')
env.SlashCmdList={};env.ShaguTweaks_config={}
env.GetTime=function()return now end
env.UnitClass=function()return 'Shaman','SHAMAN' end
env.GameTooltip={Hide=function()end,SetOwner=function()end,SetTotem=function()end}
env.Enum={StatusBarInterpolation={Immediate=0},StatusBarTimerDirection={RemainingTime=1},NumericRuleFormatRounding={Down=2}}
env.C_Timer={NewTimer=function(delay,fn)
local t={at=now+delay,callback=fn};function t:Cancel()self.cancelled=true end
table.insert(timers,t);return t
end}
env.C_DurationUtil={CreateDuration=function()
local d={finish=0,total=0}
function d:SetTimeFromEnd(finish,total)self.finish,self.total=finish,total end
function d:GetRemainingDuration()return math.max(0,self.finish-now)end
return d
end,CreateDurationTextBinding=function()
local b={}
function b:SetFontString(text)self.text=text end
function b:SetDuration(d)self.duration=d end
function b:SetFormatter(f)self.formatter=f end
function b:SetExpiredText(t)self.expired=t end
function b:SetUpdateInterval(t)self.interval=t end
function b:Enable()self.enabled=true end
function b:Disable()self.enabled=false end
return b
end}
env.C_StringUtil={CreateNumericRuleFormatter=function()return {SetBreakpoints=function(self,rules)self.rules=rules end}end}
env.GetTotemInfo=function(slot)
local data=slots[slot]
if data then return true,'Totem',data.start,data.total,'icon' end
return false,'',0,0,''
end
env.GetTotemTimeLeft=function(slot)totemReads=totemReads+1;local s=slots[slot];return s and math.max(0,s.start+s.total-now)or 0 end
env.GetTotemDuration=function(slot)
local d=env.C_DurationUtil.CreateDuration();local s=slots[slot]
if s then d:SetTimeFromEnd(s.start+s.total,s.total)end
return d
end
env.C_LossOfControl={GetActiveLossOfControlDataCount=function()effectReads=effectReads+1;return table.getn(effects)end,
GetActiveLossOfControlData=function(i)
local e=effects[i]
return {locType=e.kind,timeRemaining=e.finish and math.max(0,e.finish-now),duration=e.total,
displayText=e.label or e.kind,lockoutSchool=e.mask or 0,iconTexture='effect'}
end,GetSchoolLockout=function()
schoolReads=schoolReads+1;local mask,left=0,0
for _,e in ipairs(effects)do if e.kind=='SCHOOL_INTERRUPT' and e.finish>now then mask=e.mask;left=math.max(left,e.finish-now)end end
return mask,mask~=0 and left or nil
end}
env.C_Spell={GetSchoolString=function()return 'Nature' end}
env.ShaguTweaks={T=setmetatable({},{__index=function(_,k)return k end}),
API={durationtimers=native,AreMoveModifiersDown=function()return keys end},GetGlobalEnv=function()return env end}
function env.ShaguTweaks:register(m)modules[m.title]=m;return m end
local function load(path)local f=assert(loadfile(root..path));setfenv(f,env);f()end
local function emit(name)
env.event=name
for _,f in ipairs(frames)do if f.events[name]then env.this=f;f.scripts.OnEvent()end end
env.this=nil
end
local function advance(delta)
local finish=now+delta
for iteration=1,1000 do
local nextTimer
for _,t in ipairs(timers)do
if not t.cancelled and not t.fired and t.at<=finish and (not nextTimer or t.at<nextTimer.at)then nextTimer=t end
end
if not nextTimer then now=finish;return end
now=nextTimer.at;nextTimer.fired=true;nextTimer.callback()
end
error('timer loop')
end
slots[1]={start=9,total=30};slots[3]={start=9,total=60}
load('mods/totem-timers.lua');modules['Totem Timers']:enable()
local totem=env.ShaguTweaksTotem1;local holder=env.ShaguTweaksTotemTimers
local function formatted(formatter,n)
local rule
for _,candidate in ipairs(formatter.rules)do if n>=candidate.threshold then rule=candidate end end
local function rounded(v,step,mode)
if not step then return v end
return (mode==2 and math.floor(v/step) or math.floor(v/step+.5))*step
end
n=rounded(n,rule.step,rule.rounding)
if rule.components then
local values={}
for _,part in ipairs(rule.components)do
local v=n;if part.div then v=v/part.div end;if part.mod then v=math.mod(v,part.mod)end
table.insert(values,rounded(v,part.step,part.rounding))
end
return string.format(rule.format,unpack(values))
end
return string.format(rule.format,n)
end
if native then
assert(not holder.scripts.OnUpdate and totem.binding.enabled and totem.bar.timer.total==30 and totem.bar.direction==1)
assert(totem.binding.formatter==env.ShaguTweaksTotem3.binding.formatter)
assert(totem.binding.formatter.rules[4].format=='|cffffffff%d:%02d')
assert(formatted(totem.binding.formatter,5)=='|cffff33335')
assert(formatted(totem.binding.formatter,5.001)=='|cffffe6335')
assert(formatted(totem.binding.formatter,10)=='|cffffe63310')
assert(formatted(totem.binding.formatter,10.001)=='|cffffffff10')
assert(formatted(totem.binding.formatter,61.9)=='|cffffffff1:01')
local original=totem.expiry;slots[1]={start=10,total=60};emit('PLAYER_TOTEM_UPDATE')
assert(original.cancelled and totem.bar.timer.finish==70)
local oldTimer=totem.expiry;keys=true;emit('MODIFIER_STATE_CHANGED')
assert(oldTimer.cancelled and not totem.binding.enabled and totem.bar.value==1)
keys=false;emit('MODIFIER_STATE_CHANGED');assert(totem.binding.enabled)
local before=totemReads;advance(61);assert(totemReads==before and not holder.shown)
assert(not totem.binding.enabled and not totem.shown)
slots[2]={start=now,total=30};emit('PLAYER_TOTEM_UPDATE')
local earth=env.ShaguTweaksTotem2;local pending=earth.expiry
modules['Totem Timers']:disable();assert(pending.cancelled and not earth.binding.enabled)
modules['Totem Timers']:enable();assert(earth.binding.enabled)
else
assert(holder.scripts.OnUpdate and not totem.binding)
assert(totem.time.text=='29')
now=36;env.arg1=.1;holder.scripts.OnUpdate();assert(totem.time.text=='3' and totem.time.textColor[2]==.2)
end
modules['Totem Timers']:disable()
effects={{kind='ROOT',finish=now+20,total=20},{kind='STUN',finish=now+4,total=4}}
load('mods/loss-of-control.lua');modules['Loss of Control Alert']:enable()
local alert=env.ShaguTweaksLossOfControl
assert(alert.title.text=='Stunned')
if native then
assert(not alert.scripts.OnUpdate and alert.bar.timer.total==4)
local before=effectReads;local writes=alert.icon.writes
advance(2);assert(effectReads==before and alert.icon.writes==writes,'native animation must not rescan/repaint')
advance(2.1);assert(alert.title.text=='Rooted' and alert.shown)
effects={};emit('LOSS_OF_CONTROL_UPDATE');assert(not alert.shown)
effects={{kind='STUN'}};emit('LOSS_OF_CONTROL_UPDATE')
assert(alert.shown and alert.time.text=='' and not alert.bar.shown)
keys=true;emit('MODIFIER_STATE_CHANGED');assert(alert.time.text=='8.0' and alert.bar.value==1)
keys=false;emit('MODIFIER_STATE_CHANGED');assert(alert.time.text=='' and alert.shown)
effects={{kind='SCHOOL_INTERRUPT',mask=8,finish=now+6,total=6}}
emit('LOSS_OF_CONTROL_UPDATE');local listReads=effectReads;advance(.4)
assert(effectReads==listReads and schoolReads>0,'steady lockouts use scalar queries only')
local oldEnd=alert.bar.timer.finish
effects[1].finish=now+10;effects[1].total=10
advance(.3);assert(alert.bar.timer.finish>oldEnd and effectReads>listReads)
local scalarQuery=env.C_LossOfControl.GetSchoolLockout
env.C_LossOfControl.GetSchoolLockout=nil
listReads=effectReads;advance(.3);assert(effectReads>listReads,'older scalar-less API must still detect extensions')
env.C_LossOfControl.GetSchoolLockout=scalarQuery
advance(6);assert(alert.shown,'old expiry must not hide the extended lockout')
local beforeDisable=effectReads
modules['Loss of Control Alert']:disable();advance(20)
assert(not alert.shown and effectReads==beforeDisable)
modules['Loss of Control Alert']:enable();assert(not alert.shown)
-- A higher-priority newly applied effect replaces the lockout immediately.
effects={{kind='ROOT',finish=now+10,total=10}};emit('LOSS_OF_CONTROL_UPDATE')
table.insert(effects,{kind='FEAR',finish=now+3,total=3});emit('LOSS_OF_CONTROL_ADDED')
assert(alert.title.text=='Feared');advance(3.1);assert(alert.title.text=='Rooted')
else
assert(alert.scripts.OnUpdate and alert.time.text=='4.0')
env.arg1=.05;alert.scripts.OnUpdate();assert(alert.title.text=='Stunned')
end
modules['Loss of Control Alert']:disable()
end
Check(true);Check(false)
print('Control timers: native totem fill/text/expiry, replacements, preview/disable cleanup, loss-of-control priority/untimed effects, school extension scalar checks and older API fallback passed')
+141
View File
@@ -0,0 +1,141 @@
local root=(arg[1] or '.')..'/'
assert(loadfile(root..'mods/cooldown-numbers.lua'))
local function read(path)local f=assert(io.open(root..path));local s=f:read('*a');f:close();return s end
local helpers=read('helpers.lua')
local a=assert(string.find(helpers,'ShaguTweaks.TimeConvert = function',1,true))
local b=assert(string.find(helpers,'\nend',a,true))
local reference={ShaguTweaks={},ceil=math.ceil}
setmetatable(reference,{__index=getfenv(0)})
local chunk=assert(loadstring(string.sub(helpers,a,b+3)));setfenv(chunk,reference);chunk()
local env=setmetatable({},{__index=getfenv(0)});env._G=env
local frames,formatted,cvar,now={},0,'1',101
local nativeFactory
local function newFrame(parent)
local f={parent=parent,scripts={},visible=true,alpha=1}
function f:GetParent()return self.parent end
function f:GetHeight()return self.height or 36 end
function f:GetFrameLevel()return 4 end
function f:GetAlpha()return self.alpha end
function f:SetAlpha(n)self.alpha=n end
function f:SetAllPoints()end
function f:SetFrameLevel()end
function f:SetScript(k,v)self.scripts[k]=v end
function f:RegisterEvent(k)self.event=k end
function f:Hide()self.visible=false end
function f:Show()self.visible=true end
function f:CreateFontString()
local text={}
function text:SetFont(font,size,flags)self.font,self.size,self.flags=font,size,flags end
function text:ClearAllPoints()end
function text:SetPoint()end
function text:SetTextColor()end
function text:SetText(value)self.value=value end
return text
end
return f
end
env.CreateFrame=function(kind,_,parent)
if kind=='Cooldown' and env.createNative then return env.createNative(parent) end
local f=newFrame(parent);table.insert(frames,f);return f
end
env.GetTime=function()return now end
env.GetCVar=function()return cvar end
env.SetCVar=function()error('must not change global cooldown policy')end
env.STANDARD_TEXT_FONT='font'
env.Enum={NumericRuleFormatRounding={Up=1}}
env.C_StringUtil={CreateNumericRuleFormatter=function()
formatted=formatted+1
local formatter={}
function formatter:SetBreakpoints(rules)self.rules=rules end
function formatter:FormatNumber(n)
local rule
for _,candidate in ipairs(self.rules)do if n>=candidate.threshold then rule=candidate end end
local function round(value,step)if step then return math.ceil(value/step)*step end;return value end
local value=round(n,rule.step)
if rule.components then value=round(value/rule.components[1].div,rule.components[1].step)end
return string.format(rule.format,value)
end
return formatter
end}
nativeFactory=env.C_StringUtil.CreateNumericRuleFormatter
local set
env.hooksecurefunc=function(name,callback)assert(name=='CooldownFrame_SetTimer');set=callback end
env.ShaguTweaks={T={},API={nativecooldowns=false},GetGlobalEnv=function()return env end,GetExpansion=function()return 'vanilla' end,
TimeConvert=reference.ShaguTweaks.TimeConvert,register=function(_,module)env.module=module;return module end}
local load=assert(loadfile(root..'mods/cooldown-numbers.lua'));setfenv(load,env);load();env.module:enable()
local parent=newFrame();parent.height=18
local function native(owner)
local f=newFrame(owner or parent);f.nativeText=f:CreateFontString()
function f:GetCountdownFontString()return self.nativeText end
function f:SetCountdownFormatter(value)self.formatter=value end
function f:SetMinimumCountdownDuration(value)self.minimum=value end
function f:SetHideCountdownNumbers(value)self.hidden=value end
function f:SetDrawSwipe(value)self.swipe=value end
function f:SetDrawEdge(value)self.edge=value end
function f:SetDrawBling(value)self.bling=value end
function f:SetCooldown(start,duration)self.start,self.duration=start,duration;self.visible=true;self.starts=(self.starts or 0)+1 end
return f
end
local cd=native();set(cd,100,20,1)
assert(not cd.cooldowntext and not cd.scripts.OnUpdate and not cd.hidden)
assert(cd.minimum==1999 and cd.nativeText.size==18*.64)
assert(table.getn(frames)==1 and frames[1].event=='CVAR_UPDATE')
local second=native();set(second,100,20,1)
assert(formatted==1 and second.formatter==cd.formatter,'one shared native formatter')
for _,n in ipairs({.01,.99,1,4.01,4.99,5,5.01,9.99,10,59,59.99,60,60.01,3599.99,3600,86399.99,86400,172801})do
assert(cd.formatter:FormatNumber(n)==reference.ShaguTweaks.TimeConvert(n),'TimeConvert mismatch at '..n)
end
set(cd,100,1.5,1);assert(cd.hidden and not cd.cooldowntext)
set(cd,100,2,1);assert(not cd.hidden)
set(cd,0,20,1);assert(cd.hidden)
set(cd,100,20,0);assert(cd.hidden)
set(cd,100,20,1);cd.noCooldownCount=true;set(cd,100,20,1);assert(cd.hidden)
cd.noCooldownCount=nil;cd.pfCooldownType='spell';set(cd,100,20,1);assert(cd.hidden)
cd.pfCooldownType=nil;cd.readable=true;set(cd,100,20,1);assert(cd.hidden)
cd.readable=nil
local foreign=native();foreign.pfCooldownType='spell';set(foreign,100,20,1)
assert(foreign.hidden==nil and foreign.formatter==nil,'must leave foreign-owned native setup alone')
local excluded=native();excluded.noCooldownCount=true;set(excluded,100,20,1)
assert(excluded.hidden and not excluded.cooldowntext and not excluded.formatter)
-- CVar changes switch existing native indicators without setting the CVar.
local watcher=frames[1]
cvar='0';watcher.scripts.OnEvent()
assert(cd.cooldowntext and cd.cooldowntext.visible and cd.hidden)
local function tick(frame,dt)env.this,env.arg1=frame,dt;frame.scripts.OnUpdate();env.this,env.arg1=nil,nil end
tick(cd.cooldowntext,.1);assert(cd.cooldowntext.text.value==reference.ShaguTweaks.TimeConvert(19))
local frameCount=table.getn(frames);watcher.scripts.OnEvent();assert(table.getn(frames)==frameCount)
cvar='1';watcher.scripts.OnEvent();assert(not cd.cooldowntext.visible and not cd.hidden)
-- Legacy Models keep the old renderer, including alpha, expiry and clock wrap.
local model=newFrame(parent);set(model,100,20,1);assert(model.cooldowntext.visible)
model.alpha=.4;tick(model.cooldowntext,.1);assert(model.cooldowntext.alpha==.4)
now=121;tick(model.cooldowntext,.1);assert(not model.cooldowntext.visible)
local cycle=2^32/1000;now=1;set(model,cycle-2,10,1);tick(model.cooldowntext,.1)
assert(model.cooldowntext.text.value==reference.ShaguTweaks.TimeConvert(7))
-- Native numeric children optimize legacy Models without changing their sweep.
env.ShaguTweaks.API.nativecooldowns=true;env.createNative=native
local legacy=newFrame(parent);set(legacy,1,20,1)
local numbers=legacy.stCountdownNativeFrame
assert(numbers and not legacy.cooldowntext and not numbers.scripts.OnUpdate)
assert(numbers.parent==legacy and numbers.start==1 and numbers.duration==20)
assert(numbers.swipe==false and numbers.edge==false and numbers.bling==false)
local starts=numbers.starts;set(numbers,1,20,1)
assert(numbers.starts==starts,'native helper must not recursively decorate itself')
set(legacy,1,1,1);assert(not numbers.visible)
set(legacy,1,20,1);assert(numbers.visible)
cvar='0';watcher.scripts.OnEvent();assert(not numbers.visible and legacy.cooldowntext.visible)
cvar='1';watcher.scripts.OnEvent();assert(numbers.visible and not legacy.cooldowntext.visible)
assert(legacy.stCountdownNativeFrame==numbers)
legacy.noCooldownCount=true;set(legacy,1,20,1);assert(not numbers.visible)
-- A rejected native frame creation safely falls back and is not retried.
local attempts=0;env.createNative=function()attempts=attempts+1;error('unsupported frame')end
local unsupported=newFrame(parent);set(unsupported,1,20,1);set(unsupported,1,20,1)
assert(unsupported.cooldowntext.visible and attempts==1)
env.ShaguTweaks.API.nativecooldowns=false
-- Missing formatter APIs retain one renderer, suppressing native duplicates.
env.C_StringUtil.CreateNumericRuleFormatter=nil
local older=native();set(older,1,20,1)
assert(older.hidden and older.cooldowntext.visible)
env.C_StringUtil.CreateNumericRuleFormatter=nativeFactory
set(model,1,1,1);assert(not model.cooldowntext.visible)
model.noCooldownCount=true;set(model,1,20,1);assert(not model.cooldowntext.visible)
print('Cooldown numbers: shared native formatter, TimeConvert boundaries, global-CVar ownership/live changes, foreign flags, GCDs, legacy/older API fallback, alpha/expiry and clock wrap passed')
+72
View File
@@ -0,0 +1,72 @@
local root=(arg[1] or '.')..'/'
local env=setmetatable({},{__index=getfenv(0)});env._G=env
local partyCalls,frames,partyNames=0,{}, {'Alice','Bob'}
local function texture(path)
local t={path=path,writes=0,tints=0}
function t:GetTexture()return self.path end
function t:SetTexture(p)self.path=p;self.writes=self.writes+1 end
function t:SetVertexColor(r,g,b)self.r,self.g,self.b=r,g,b;self.tints=self.tints+1 end
function t:SetPoint()end
return t
end
env.GetAddOnInfo=function()return nil,'ShaguTweaks' end
env.UnitExists=function()return true end
env.UnitName=function(unit)
local _,_,id=string.find(unit,'party(%d)')
if id then partyCalls=partyCalls+1;return partyNames[tonumber(id)]end
return 'Remote'
end
env.UnitClass=function()return 'Mage','MAGE' end
env.RAID_CLASS_COLORS={WARLOCK={r=.5,g=.5,b=.9},MAGE={r=.2,g=.8,b=.9}}
env.WorldMapFrame={}
env.CreateFrame=function()
local f={scripts={}}
function f:SetScript(k,v)self.scripts[k]=v end
function f:RegisterEvent()end
function f:UnregisterEvent()self.unregistered=true end
table.insert(frames,f);return f
end
env.ShaguTweaks={T={},GetGlobalEnv=function()return env end,GetUnitData=function()return 'WARLOCK' end}
function env.ShaguTweaks:register(m)env.module=m;return m end
for i,name in ipairs({'Alice','Other','Third'})do
local f={name=name,frameName='WorldMapRaid'..i}
function f:GetName()return self.frameName end
function f:IsVisible()return self.visible~=false end
function f:CreateTexture()return texture()end
env[f.frameName]=f;env[f.frameName..'Icon']=texture('native')
end
local chunk=assert(loadfile(root..'mods/worldmap-colors.lua'));setfenv(chunk,env);chunk();env.module:enable()
local updater=frames[1]
local function tick()env.this,env.arg1=updater,.1;updater.scripts.OnUpdate()end
tick();assert(partyCalls==4,'party roster should be read once, not once per visible icon')
assert(env.WorldMapRaid1.texture.ingroup=='PARTY' and env.WorldMapRaid2.texture.ingroup=='RAID')
for i=1,3 do assert(env['WorldMapRaid'..i].texture.tints==1 and env['WorldMapRaid'..i..'Icon'].writes==1)end
tick();assert(partyCalls==8)
for i=1,3 do assert(env['WorldMapRaid'..i].texture.tints==1 and env['WorldMapRaid'..i..'Icon'].writes==1)end
partyNames={};tick();assert(env.WorldMapRaid1.texture.ingroup=='RAID')
env.WorldMapRaid2.name=nil;env.WorldMapRaid2.unit='raid10';tick()
assert(env.WorldMapRaid2.texture.r==.2,'packed map icons must use their actual unit')
env.WorldMapRaid2Icon.path='native again';tick();assert(env.WorldMapRaid2Icon.writes==2)
for i=1,3 do env['WorldMapRaid'..i].visible=false end
local before=partyCalls;tick();assert(partyCalls==before,'solo/empty maps should not query party names')
env.BattlefieldMinimap={};env.this=frames[2];frames[2].scripts.OnEvent();assert(frames[2].unregistered)
-- Exercise the actual spam cache helpers while retaining the table identities.
local f=assert(io.open(root..'mods/chat-spam-filter.lua'));local source=f:read('*a');f:close()
local a=assert(string.find(source,'local COOLDOWN =',1,true))
local b=assert(string.find(source,'-- SuperWoW exposes',a,true))
local now=0
env.GetTime=function()return now end
env.API={}
chunk=assert(loadstring(string.sub(source,a,b-1)..'return IsDuplicate, cache'))
setfenv(chunk,env);local duplicate,cache=chunk()
local tab1,tab2={},{}
assert(not duplicate(tab1,'repeat') and duplicate(tab1,'repeat'))
assert(not duplicate(tab2,'repeat'),'each chat tab has an independent cache')
local original=cache[tab1]
now=31;assert(not duplicate(tab1,'new') and cache[tab1]==original)
now=61;assert(duplicate(tab1,'repeat') and cache[tab1]==original)
now=70;assert(not duplicate(tab1,'repeat'),'duplicates must not extend their original 70s window')
now=101;assert(not duplicate(tab1,'later') and not original.new and not cache[tab2])
assert(cache[tab1]==original,'pruning must retain the active per-tab table')
print('Cosmetic/cache writes: four party-name lookups per map pass, stable tint/icon writes skipped, live roster/unit/native changes retained, in-place spam pruning and per-tab cooldowns passed')
+105
View File
@@ -0,0 +1,105 @@
local root=(arg[1] or '.')..'/'
assert(loadfile(root..'mods/item-colors.lua'))
local file=assert(io.open(root..'mods/item-colors.lua'));local source=file:read('*a');file:close()
local cut=assert(string.find(source,'function Engine:Install()',1,true))
local function Check(nativeTimer)
local env=assert(loadfile(root..'tests/fixtures/module_mock.lua'))()()
local qualities,live,queued={},{},{}
local refreshes,hideHooks=0,0
env.ShaguTweaks.API.eventutils=true
env.ShaguTweaks.API.timer=nativeTimer
env.C_EventUtils={IsEventValid=function()return true end}
env.ShaguTweaks.API.GetItemQualityByID=function(id)return qualities[id]end
env.ShaguTweaks.API.GetItemInfoInstant=function()return nil,nil,nil,nil,nil,0 end
env.GetItemQualityColor=function(q)return q/10,.5,1 end
env.ShaguTweaks.AddBorder=function(button)
if not rawget(button,'ShaguTweaks_border') then
button.ShaguTweaks_border={SetBackdropBorderColor=function(self,r,g,b,a)self.r,self.a=r,a end}
end
return button.ShaguTweaks_border
end
env.ShaguTweaks.HookScript=function(button,script,callback)
hideHooks=hideHooks+1;assert(script=='OnHide');button.scripts[script]=callback
end
local function defer(callback)table.insert(queued,callback)end
env.ShaguTweaks.API.Defer=defer;env.ShaguTweaks.QueueFunction=defer
local chunk=assert(loadstring(string.sub(source,1,cut-1)..'return SetVisualColor, Engine, mailTradeOptions'))
setfenv(chunk,env);local paint,engine,opts=chunk()
engine.borders=true
engine.RefreshAll=function()
refreshes=refreshes+1
for slot,id in pairs(live) do if slot:IsVisible() then paint(slot,id,opts) end end
end
local function button(id)
local slot=env.CreateFrame('Button');live[slot]=id;paint(slot,id,opts);return slot
end
local a=button(100);local events=env.frames[2]
local b=button(100)
assert(events.events.GET_ITEM_INFO_RECEIVED and a.ShaguTweaks_border.a==0)
paint(a,100,opts);assert(hideHooks==2,'one hide hook per cold button')
local function reply(id,success)
env.arg2=success;env.Invoke(events,'OnEvent','GET_ITEM_INFO_RECEIVED',id);env.arg2=nil
end
local function flush()
local work=queued;queued={}
for _,callback in ipairs(work)do callback()end
end
reply(999,true);assert(table.getn(queued)==0)
qualities[100]=3;reply(100,true);reply(100,true)
assert(table.getn(queued)==1 and not events.events.GET_ITEM_INFO_RECEIVED)
flush();assert(refreshes==1 and a.ShaguTweaks_border.r==.3 and b.ShaguTweaks_border.r==.3)
-- Warm items need neither an event listener nor a new hide hook.
button(100);assert(not events.events.GET_ITEM_INFO_RECEIVED and hideHooks==2)
-- Slot reused before the old reply: refresh reads the new identity.
live[a]=200;paint(a,200,opts)
live[a]=100 -- native slot changed, its ordinary paint has not run yet
qualities[200]=4;reply(200,1);flush()
assert(a.ShaguTweaks_border.r==.3,'old reply must not paint old quality')
-- Removed and hidden slots clear pending state and never resurrect a border.
live[a]=300;paint(a,300,opts);live[a]=nil;paint(a,nil,opts)
reply(300,true);assert(table.getn(queued)==0 and a.ShaguTweaks_border.a==0)
live[a]=400;paint(a,400,opts);a.visible=false;env.Invoke(a,'OnHide')
assert(not events.events.GET_ITEM_INFO_RECEIVED)
reply(400,true);assert(table.getn(queued)==0)
a.visible=true;live[a]=500;paint(a,500,opts);reply(500,false)
assert(not events.events.GET_ITEM_INFO_RECEIVED and table.getn(queued)==0)
paint(a,500,opts);reply(500,0)
assert(not events.events.GET_ITEM_INFO_RECEIVED and table.getn(queued)==0)
paint(a,500,opts);reply(500,nil) -- native ClassicAPI failure encoding
assert(not events.events.GET_ITEM_INFO_RECEIVED and table.getn(queued)==0)
-- Distinct replies in the same frame share one live refresh.
live[a]=600;paint(a,600,opts);live[b]=700;paint(b,700,opts)
qualities[600],qualities[700]=2,4;reply(600,true);reply(700,true)
assert(table.getn(queued)==1);flush()
assert(a.ShaguTweaks_border.r==.2 and b.ShaguTweaks_border.r==.4)
assert(not events.events.GET_ITEM_INFO_RECEIVED)
-- Disabling visuals before a deferred reply prevents repainting.
live[a]=800;paint(a,800,opts);qualities[800]=2;reply(800,true)
local before=refreshes;engine.borders=false;flush();assert(refreshes==before)
-- Older clients retain their ordinary refresh behavior without this event.
env.C_EventUtils.IsEventValid=function()return false end
engine.borders=true;paint(a,900,opts);assert(not events.events.GET_ITEM_INFO_RECEIVED)
-- The same retry works when only rarity glows are enabled.
env.C_EventUtils.IsEventValid=function()return true end
engine.borders=false;engine.glows=true
function a:CreateTexture()
local texture={}
function texture:SetTexture()end
function texture:SetBlendMode()end
function texture:SetPoint()end
function texture:SetVertexColor(r)self.r=r end
function texture:Hide()self.visible=false end
function texture:Show()self.visible=true end
return texture
end
-- Keep this scenario to one visible slot.
live[b]=nil
for slot in pairs(live)do if slot~=a then live[slot]=nil end end
a.ShaguTweaks_itemRarityGlow=false
live[a]=1000;paint(a,1000,opts)
assert(not a.ShaguTweaks_itemRarityGlow.visible)
qualities[1000]=4;reply(1000,1);flush()
assert(a.ShaguTweaks_itemRarityGlow.visible and a.ShaguTweaks_itemRarityGlow.r==.4)
end
Check(true);Check(false)
print('Item cache retry: cold/warm data, shared replies, live slot identities, removed/hidden items, failed replies, coalescing, disabled visuals and timer fallback passed')
+50
View File
@@ -0,0 +1,50 @@
local root=(arg[1] or '.')..'/'
local f=assert(io.open(root..'mods/minimap-zoom.lua'));local source=f:read('*a');f:close()
assert(loadstring(source))
local a=assert(string.find(source,'local function FixLFTButtonDrag()',1,true))
local b=assert(string.find(source,'local module =',a,true))
local function Check(uiScale,mapScale,buttonScale)
local env=dofile(root..'tests/fixtures/module_mock.lua')()
env.UIParent={effective=uiScale};env.Minimap={effective=uiScale*mapScale}
local calls=0
local button={parent=env.Minimap,scale=buttonScale,scripts={}}
function button:GetParent()return self.parent end
function button:GetScale()return self.scale end
function button:GetEffectiveScale()return self.parent.effective*self.scale end
function button:SetParent(parent)self.parent=parent end
function button:SetScale(scale)self.scale=scale end
function button:GetLeft()return self.left end
function button:GetTop()return self.top end
function button:ClearAllPoints()self.cleared=true end
function button:SetPoint(point,parent,relative,x,y)
assert(point=='TOPLEFT' and parent==env.UIParent and relative=='BOTTOMLEFT')
self.left,self.top=x,y
end
function button:GetScript(script)return self.scripts[script]end
function button:SetScript(script,callback)self.scripts[script]=callback end
local reset=function()button:SetParent(env.Minimap);button.left,button.top=-22,-14 end
button.scripts.OnMouseUp=reset
button.scripts.OnDragStop=function(mouse)
assert(mouse=='LeftButton');calls=calls+1
-- Exact native LFT XML behavior: StopMoving, then SetParent('UIParent').
button:SetParent(env.UIParent)
end
button.left,button.top=1000/button:GetEffectiveScale(),650/button:GetEffectiveScale()
env.LFTMinimapButton=button
local chunk=assert(loadstring('local _G=ShaguTweaks.GetGlobalEnv();\n'..string.sub(source,a,b-1)..'return FixLFTButtonDrag'))
setfenv(chunk,env);local install=chunk()
install();local hook=button.scripts.OnDragStop;install();assert(button.scripts.OnDragStop==hook)
local before=button:GetEffectiveScale()
hook('LeftButton')
assert(calls==1 and button:GetParent()==env.Minimap and button.scale==buttonScale)
assert(math.abs(button:GetEffectiveScale()-before)<.000001)
assert(math.abs(button:GetLeft()*before-1000)<.000001 and math.abs(button:GetTop()*before-650)<.000001)
assert(button.scripts.OnMouseUp==reset)
reset();assert(button.parent==env.Minimap and button.scale==buttonScale and button.left==-22)
-- A foreign parent remains owned by its existing native/other-addon handler.
button.parent={effective=uiScale*2};hook('LeftButton');assert(button.parent==env.UIParent)
env.LFTMinimapButton=nil;install() -- non-Turtle clients
env.LFTMinimapButton={GetScript=function()end};install()
end
Check(.7,1.2,1);Check(1,1.5,.8);Check(.65,2,1)
print('LFT minimap drag: native reparenting preserves inherited size and screen position, custom/UI scales, unchanged reset, foreign parent and idempotent/non-Turtle setup passed')
+16
View File
@@ -0,0 +1,16 @@
local root=(arg[1] or '.')..'/'
local env={};setmetatable(env,{__index=getfenv(0)})
local chunk=assert(loadfile(root..'locales/locales_enUS.lua'));setfenv(chunk,env);chunk()
local locale=env.ShaguTweaks_locale.enUS
local sections=0
for key in pairs(locale)do
assert(key=='class' or key=='customcast','unused locale section still loaded: '..key)
sections=sections+1
end
assert(sections==2)
local count=0;for name,token in pairs(locale.class)do assert(type(name)=='string' and type(token)=='string');count=count+1 end
assert(count==9 and locale.class.Warlock=='WARLOCK' and locale.class.Druid=='DRUID')
assert(locale.customcast.AIMEDSHOT=='Aimed Shot' and locale.customcast.MULTISHOT=='Multi-Shot')
local f=assert(io.open(root..'mods/turtle-wow.lua'));local turtle=f:read('*a');f:close()
assert(not string.find(turtle,'L["debuffs"]',1,true),'obsolete Turtle writes remain')
print('Locale payload: only the nine class mappings and two live Hunter cast names remain; obsolete Turtle debuff writes removed')
+34
View File
@@ -0,0 +1,34 @@
local root=(arg[1] or '.')..'/'
local env=dofile(root..'tests/fixtures/module_mock.lua')()
local function button(name,clickable)
return {alpha=1,GetName=function()return name end,
GetScript=function(self,k)if k=='OnClick' and self.clickable then return function()end end end,
SetAlpha=function(self,a)self.alpha=a end,clickable=clickable}
end
local first=button('FirstAddon',true)
local ignored=button('LFT_Minimap',true)
local children={first,ignored}
local scans,now,over=0,0,false
env.Minimap={GetNumChildren=function()return table.getn(children)end,
GetChildren=function()scans=scans+1;return unpack(children)end}
env.GetTime=function()return now end
env.ShaguTweaks.API={regionmouseover=true,IsMouseOver=function()return over end}
env.Load(root..'mods/minimap-clean.lua');env.module:enable()
local frame=env.module.frame
local function tick(time)now=time;env.Invoke(frame,'OnUpdate',nil,.1)end
tick(0);tick(4);assert(first.alpha==0 and ignored.alpha==1)
local before=scans
for i=1,50 do tick(4+i*.1)end
assert(scans==before) -- no subtree rebuild while the count is unchanged
local late=button('LateAddon',true);table.insert(children,late)
tick(10);assert(late.alpha==0 and first.alpha==0 and ignored.alpha==1)
-- Replaced children at the same count are invalidated when an addon loads.
local replacement=button('ReplacementAddon',true);children[3]=replacement
env.Invoke(frame,'OnEvent','ADDON_LOADED');tick(11);assert(replacement.alpha==0)
over=true;tick(12);assert(first.alpha==1 and replacement.alpha==1)
over=false;tick(13);tick(17);assert(replacement.alpha==0)
env.module:disable();assert(not frame.scripts.OnUpdate and not frame.events.ADDON_LOADED)
assert(first.alpha==1 and replacement.alpha==1)
env.module:enable();assert(frame.events.ADDON_LOADED and frame.scripts.OnUpdate)
assert(env.module.frame==frame and replacement.alpha==1)
print('Minimap Clean: late/replaced buttons inherit state, unchanged cache avoids traversal, LFT exclusion, hover delay and disable/re-enable passed')
+14 -2
View File
@@ -33,7 +33,8 @@ local function Check(big,timer,hideResources,druid)
function methods:SetTexture(path) self.path=path end
function methods:SetBackdrop(backdrop) self.backdrop=backdrop end
function methods:GetTexture() return self.path end
function methods:GetStatusBarTexture() return {GetTexture=function() return 'NativeHealthTexture' end} end
function methods:GetStatusBarTexture() local path=self.barPath or 'NativeHealthTexture';return {GetTexture=function() return path end} end
function methods:SetStatusBarTexture(path) self.barPath=path;self.textureWrites=(self.textureWrites or 0)+1 end
function methods:GetStatusBarColor() return unpack(self.barColor or {.53,.53,.93,1}) end
function methods:SetText(text) self.text=text end
function methods:SetTexCoord(a,b,c,d) self.uv={a,b,c,d} end
@@ -107,7 +108,7 @@ local function Check(big,timer,hideResources,druid)
function methods:GetMinMaxValues() return self.minimum or 0,self.maximum or 100 end
function methods:SetValue(v) self.value=v end
function methods:GetValue() return self.value or 75 end
function methods:SetStatusBarColor(r,g,b,a) self.barColor={r,g,b,a} end
function methods:SetStatusBarColor(r,g,b,a) self.barColor={r,g,b,a};self.colorWrites=(self.colorWrites or 0)+1 end
function methods:StartSizing(direction) self.sizing=direction end
ShaguTweaks.Abbreviate=function(v) return tostring(v) end
ShaguTweaks_config={['Hide Player Frame Resources']=hideResources and 1 or 0,
@@ -155,6 +156,17 @@ local function Check(big,timer,hideResources,druid)
assert(display.health.width==180 and display.health.height==40 and display.health.left==420)
assert(display.power.height==(big and 36 or 18))
assert(display.health.bar.value==75 and display.health.bar.maximum==100)
local copy=display.health.bar
local textureWrites,colorWrites=copy.textureWrites,copy.colorWrites
local r,g,b=PlayerFrameHealthBar:GetStatusBarColor()
for i=1,100 do PlayerFrameHealthBar:SetStatusBarColor(r,g,b,1) end
assert(copy.textureWrites==textureWrites and copy.colorWrites==colorWrites,'unchanged native colors must not rewrite copies')
PlayerFrameHealthBar:SetStatusBarColor(.1,.2,.3,1)
assert(copy.textureWrites==textureWrites and copy.colorWrites==colorWrites+1,'color-only update must not refresh prediction texture')
PlayerFrameHealthBar:SetStatusBarTexture('ChangedHealthTexture')
assert(copy.textureWrites==textureWrites+1 and copy.colorWrites==colorWrites+1)
PlayerFrameHealthBar:SetStatusBarTexture('ChangedHealthTexture')
assert(copy.textureWrites==textureWrites+1)
PlayerFrameHealthBar:SetMinMaxValues(0,1000);PlayerFrameHealthBar:SetValue(400)
assert(display.health.bar.value==400 and display.health.bar.maximum==1000)
PlayerFrameHealthBar:SetStatusBarColor(.2,.3,.4,0)
+46
View File
@@ -0,0 +1,46 @@
local root=(arg[1] or '.')..'/'
local env=dofile(root..'tests/fixtures/module_mock.lua')()
local selected,scans=11,0
local actions={[1]='macro',[2]='spell'}
env.NUM_ACTIONBAR_BUTTONS=12
for i=1,2 do
env['ActionButton'..i]=env.CreateFrame('Button','ActionButton'..i);env['ActionButton'..i].id=i
env['ActionButton'..i..'Count']=env.CreateFrame()
end
env.ActionButton_GetPagedID=function(button)return button.id end
env.HasAction=function(slot)scans=scans+1;return actions[slot]~=nil end
env.IsConsumableAction=function()return false end
env.C_EventUtils={IsEventValid=function(name)return name~='UPDATE_SHAPESHIFT_FORM'end}
local api=env.ShaguTweaks.API
api.eventutils=true
api.RegisterUnitEvent=function(frame,event,unit)assert(unit=='player');frame:RegisterEvent(event)end
api.GetActionInfo=function(slot)return actions[slot],slot end
api.GetMacroSpell=function()return 'Conditional','',selected end
api.GetSpellReagents=function(id)if id~=33 then return {{itemID=id}}end end
api.GetItemCount=function(id)return id*10 end
env.Load(root..'mods/actionbar-reagents.lua');env.module:enable()
local frame=env.module.counter
local function flush(event,unit)
env.Invoke(frame,'OnEvent',event,unit)
if frame.visible then env.Invoke(frame,'OnUpdate')end
end
flush('PLAYER_ENTERING_WORLD');assert(env.ActionButton1Count.text==110 and env.ActionButton2Count.text==20)
assert(frame.events.MODIFIER_STATE_CHANGED and not frame.events.UPDATE_SHAPESHIFT_FORM)
assert(frame.events.UPDATE_SHAPESHIFT_FORMS and frame.events.UNIT_AURA)
local initial=scans
selected=22;flush('MODIFIER_STATE_CHANGED');assert(env.ActionButton1Count.text==220 and scans==initial+1)
selected=33;flush('PLAYER_TARGET_CHANGED');assert(env.ActionButton1Count.text==nil)
selected=44;flush('UNIT_AURA','target');assert(env.ActionButton1Count.text==nil and not frame.pending)
flush('UNIT_AURA','player');assert(env.ActionButton1Count.text==440)
selected=nil;flush('UPDATE_SHAPESHIFT_FORMS');assert(env.ActionButton1Count.text==nil)
selected=55;flush('SPELL_UPDATE_COOLDOWN');assert(env.ActionButton1Count.text==550)
initial=scans;flush('BAG_UPDATE');assert(scans==initial and env.ActionButton1Count.text==550)
actions[1]=nil;flush('ACTIONBAR_SLOT_CHANGED')
assert(not frame.events.MODIFIER_STATE_CHANGED and not frame.events.UNIT_AURA)
assert(env.ActionButton1Count.text==nil)
actions[1]='macro';selected=66;flush('UPDATE_MACROS')
assert(frame.events.MODIFIER_STATE_CHANGED and env.ActionButton1Count.text==660)
env.ActionButton1Count.text='native cleared';env.this=env.ActionButton1
env.hooks.ActionButton_UpdateCount();assert(env.ActionButton1Count.text==660)
local count=table.getn(env.frames);env.module:enable();assert(table.getn(env.frames)==count)
print('Reagents: conditional spell/reagent-free/unresolved transitions, macro-only scans, native event guards, unit filtering, count ownership and listener teardown/rearming passed')
+17
View File
@@ -0,0 +1,17 @@
local root=(arg[1] or '.')..'/'
local function read(path)local f=assert(io.open(root..path));local s=f:read('*a');f:close();return s end
local toc=read('ShaguTweaks.toc');assert(not string.find(toc,'mods\\aoe-loot.lua',1,true))
local file=io.open(root..'mods/aoe-loot.lua');assert(not file)
local api=read('api.lua');assert(loadstring(api))
for _,name in ipairs({'API.aoeloot','API.containeropenable','API.IsContainerItemOpenable',
'API.GetNearbyLootableUnits','API.IsLootScanInProgress','API.LootAllCorpses'}) do
assert(not string.find(api,name,1,true),name)
end
local main=read('main.lua');assert(loadstring(main))
local retired='ShaguTweaks_config[ShaguTweaks.T["AoE Loot"]] = nil'
assert(string.find(main,retired,1,true))
local env={ShaguTweaks_config={['AoE Loot']=1,['Loot Quality']=1},ShaguTweaks={T=setmetatable({},{__index=function(_,k)return k end})}}
local chunk=assert(loadstring(retired));setfenv(chunk,env);chunk()
assert(env.ShaguTweaks_config['AoE Loot']==nil and env.ShaguTweaks_config['Loot Quality']==1)
assert(string.find(read('Docs/EXTRAS_MERGE.md'),'https://github.com/rotaruandrei93/AutoAreaLoot',1,true))
print('Removed AoE Loot: source/manifest/API cleanup, retired saved setting and migration replacement link passed')
+54
View File
@@ -0,0 +1,54 @@
local root=(arg[1] or '.')..'/'
assert(loadfile(root..'mods/sell-junk.lua'))
local env=assert(loadfile(root..'tests/fixtures/module_mock.lua'))()()
local now,count,money,sales,queries,enabled,messages=0,3,100,0,0,true,{}
env.GetTime=function()return now end
env.GetMoney=function()return money end
env.floor,env.mod=math.floor,math.mod
env.ShaguTweaks.API.GetNumJunkItems=function()queries=queries+1;return count end
env.ShaguTweaks.API.SellAllJunkItems=function()sales=sales+1 end
env.MerchantFrame={selectedTab=1}
env.MerchantFrame_Update=function()end
env.MerchantRepairText={SetText=function()end}
env.MerchantRepairItemButton={IsShown=function()return false end}
env.DEFAULT_CHAT_FRAME.AddMessage=function(_,text)table.insert(messages,text)end
env.Load(root..'mods/sell-junk.lua');env.module:enable()
local driver=env.frames[1];local button=driver.button
-- Model native OnHide delivery without changing the shared fixture.
function driver:Hide()
local visible=self.visible;self.visible=false
if visible and self.scripts.OnHide then env.Invoke(self,'OnHide') end
end
function button:Enable()enabled=true end
function button:Disable()enabled=false end
local function tick(dt)
now=now+dt
if driver:IsVisible() then env.Invoke(driver,'OnUpdate',nil,dt) end
end
local function start()
env.Invoke(driver,'OnEvent','MERCHANT_SHOW')
env.Invoke(button,'OnClick')
assert(driver:IsVisible() and not enabled)
end
start();local before=queries
for i=1,9 do tick(.01) end
assert(queries==before,'must not scan every frame')
tick(.02);assert(queries==before+1)
tick(4);assert(driver:IsVisible())
tick(1);assert(not driver:IsVisible() and enabled and sales==1)
assert(not driver.started and not driver.progressUntil and table.getn(messages)==0)
-- Progress extends the deadline, including queues taking over five seconds.
start();tick(4);count=2;money=110;tick(.2)
tick(4);assert(driver:IsVisible())
count=0;money=140;tick(.2)
assert(not driver:IsVisible() and not enabled and sales==2)
assert(table.getn(messages)==1)
-- Merchant close cancels waiting; later attempts start a fresh deadline.
count=2;start();tick(1)
env.Invoke(driver,'OnEvent','MERCHANT_CLOSED')
assert(not driver:IsVisible() and not driver.started)
now=now+20;start();tick(.2);assert(driver:IsVisible())
tick(5);assert(not driver:IsVisible() and enabled and sales==4)
count=0;env.Invoke(button,'OnClick')
assert(not driver:IsVisible() and sales==4)
print('Sell Junk: throttled completion, stalled-sale timeout, progress renewal, profit once, merchant close and clean retry passed')
+21 -1
View File
@@ -18,10 +18,12 @@ local function Check(native,methodAvailable,class)
function methods:Show() self.shown=true end
function methods:Hide() self.shown=false end
function methods:IsShown() return self.shown end
function methods:CreateTexture() return object('Texture',nil,self) end
function methods:CreateTexture(_,layer)local t=object('Texture',nil,self);t.layer=layer;return t end
function methods:CreateFontString() return object('FontString',nil,self) end
function methods:SetText(value) self.text=value end
function methods:SetStatusBarColor(...) self.color=arg end
function methods:SetVertexColor(...) self.tint=arg end
function methods:SetBackdropBorderColor(...) self.borderColor=arg end
function methods:SetPoint(...) self.point=arg end
function methods:GetStatusBarTexture() self.fill=self.fill or object('Texture');return self.fill end
function methods:SetMinMaxValues(low,high) self.low,self.high=low,high;self.nativeTimer=nil end
@@ -79,6 +81,24 @@ local function Check(native,methodAvailable,class)
dofile(root..'mods/swing-timer.lua')
modules['Swing Timer']:enable();modules['Wand Timer']:enable()
local mh,oh,ra=ShaguTweaksSwingTimer_mainhand,ShaguTweaksSwingTimer_offhand,ShaguTweaksSwingTimer_ranged
-- All timer variants, including previews created before the palette, use
-- the same theme colors without changing the progress fill or weapon icon.
for _,palette in ipairs({{r=.3,g=.3,b=.3,a=.9},{r=1,g=.78,b=.4,a=1},{r=1,g=1,b=1,a=1}})do
ShaguTweaks.UITint=palette
ShaguTweaks.UIBorder={r=palette.r*.8,g=palette.g*.8,b=palette.b*.8,a=palette.a}
for _,bar in ipairs({mh,oh,ra})do
local fill=bar.color
this=bar;bar.scripts.OnShow();this=nil
assert(bar.border.layer=='OVERLAY' and bar.border.tint[1]==palette.r and bar.border.tint[4]==palette.a)
assert(bar.icon.borderColor[1]==palette.r*.8 and bar.icon.borderColor[4]==palette.a)
assert(bar.color==fill and not bar.icon.texture.tint)
end
end
ShaguTweaks.UITint,ShaguTweaks.UIBorder=nil,nil
for _,bar in ipairs({mh,oh,ra})do
this=bar;bar.scripts.OnShow();this=nil
assert(bar.border.tint[1]==1 and bar.icon.borderColor[1]==1)
end
local listener,driver
for _,frame in ipairs(frames) do
if frame.events.PLAYER_SWING then listener=frame end
+29
View File
@@ -0,0 +1,29 @@
local root=(arg[1] or '.')..'/'
local env=dofile(root..'tests/fixtures/module_mock.lua')()
env.TargetHPText,env.TargetHPPercText,env.Cartographer=true,true,true
env.ShaguTweaks.L={debuffs={}}
env.GetGameTime=function()return 8,30 end
env.GetMapContinents=function()return 'Kalimdor','Eastern Kingdoms'end
local continent,zone,mutations=2,7,0
env.GetCurrentMapContinent=function()return continent end
env.GetCurrentMapZone=function()return zone end
env.SetMapToCurrentZone=function()mutations=mutations+1;continent,zone=1,3 end
env.SetMapZoom=function(c,z)mutations=mutations+1;continent,zone=c,z or 0 end
local maps={
[11]={name='Durotar',parentMapID=-13},[-13]={name='Kalimdor',parentMapID=-694},
[12]={name='Elwynn Forest',parentMapID=-14},[-14]={name='Eastern Kingdoms',parentMapID=-694},
[-694]={name='World',parentMapID=0},
}
local current=11
env.C_Map={GetBestMapForUnit=function(unit)assert(unit=='player');return current end,
GetMapInfo=function(id)return maps[id]end}
env.Load(root..'mods/turtle-wow.lua');env.module:enable()
local clock=env.ShaguTweaks.GetTurtleClockTimes
local server,localTime=clock();assert(server=='20:30' and localTime=='08:30')
assert(continent==2 and zone==7 and mutations==0)
current=12;assert(clock()=='08:30' and mutations==0)
-- Missing hierarchy data and old API versions restore the previous view.
current=99;assert(clock()=='20:30' and continent==2 and zone==7 and mutations==2)
env.C_Map=nil;assert(clock()=='20:30' and continent==2 and zone==7 and mutations==4)
continent,zone=0,0;clock();assert(continent==0 and zone==0)
print('Turtle clock: native map hierarchy without view mutation, both continents, missing data, older API fallback and world-map restoration passed')
+106
View File
@@ -0,0 +1,106 @@
local root=(arg[1] or '.')..'/'
local env=setmetatable({},{__index=getfenv(0)});env._G=env
local modules,hooks,frames,power,queries={},{},{},{player=0,target=0,party1=0},{}
local function texture(path)
local t={path=path,shown=true,coords={0,0,0,1,1,0,1,1},writes=0,crops=0}
function t:GetTexture()return self.path end
function t:SetTexture(path)self.path=path;self.writes=self.writes+1 end
function t:GetTexCoord()return unpack(self.coords)end
function t:SetTexCoord(l,r,top,bottom)self.coords={l,top,l,bottom,r,top,r,bottom};self.crops=self.crops+1 end
function t:SetAllPoints()end
function t:IsShown()return self.shown end
function t:Show()self.shown=true end
function t:Hide()self.shown=false end
return t
end
local function bar(unit)
local f={unit=unit,fill=texture('original'),color={.2,.3,.4,1},writes=0}
function f:GetStatusBarTexture()return self.fill end
function f:SetStatusBarTexture(path)self.fill:SetTexture(path)end
function f:GetStatusBarColor()return unpack(self.color)end
function f:SetStatusBarColor(r,g,b,a)self.color={r,g,b,a};self.writes=self.writes+1 end
function f:CreateTexture()return texture()end
return f
end
env.PlayerFrameManaBar=bar('player');env.PlayerFrameAlternatePowerBar=bar('player')
env.TargetFrameManaBar=bar('target');env.PartyMemberFrame1ManaBar=bar('party1')
env.MAX_PARTY_MEMBERS=1
env.UnitExists=function(unit)queries[unit]=(queries[unit] or 0)+1;return true end
env.UnitPowerType=function(unit)return power[unit] or 0 end
env.UnitClass=function(unit)return 'Druid','DRUID' end
env.CreateFrame=function()
local f={scripts={}}
function f:SetScript(k,v)self.scripts[k]=v end
function f:RegisterEvent()end
function f:Hide()end
table.insert(frames,f);return f
end
env.ShaguTweaks={T=setmetatable({},{__index=function(_,k)return k end}),API={},GetGlobalEnv=function()return env end}
function env.ShaguTweaks:register(m)modules[m.title]=m;return m end
env.ShaguTweaks.hooksecurefunc=function(obj,name,callback)
if type(obj)=='string' then hooks[obj]=name;return end
local old=obj[name];obj[name]=function(self,r,g,b,a)old(self,r,g,b,a);callback(self,r,g,b,a)end
end
local function load(path)local f=assert(loadfile(root..path));setfenv(f,env);f()end
load('mods/unitframes-classcolor.lua');modules['Modern Class Colors']:enable()
local player,alt,target,party=env.PlayerFrameManaBar,env.PlayerFrameAlternatePowerBar,env.TargetFrameManaBar,env.PartyMemberFrame1ManaBar
local function reset()
queries={}
for _,f in ipairs({player,alt,target,party})do f.writes=0;f.fill.writes=0 end
end
reset();hooks.UnitFrame_UpdateManaType({unit='target',manabar=target})
assert(queries.target==1 and not queries.player and not queries.party1)
assert(target.fill.writes==0 and target.writes==0,'unchanged styles must not repeat texture/tint writes')
-- Native overwrites are corrected even though the cached style key is stable.
target.fill.path='native';target.color={0,0,1,1};hooks.UnitFrame_UpdateManaType({manabar=target})
assert(target.fill.writes==1 and target.writes==1 and target.color[1]==1)
reset();target:SetStatusBarColor(.5,.5,.5,1)
assert(target.writes==2 and not queries.player and not queries.party1)
reset();target.color={.999997,.999997,.999997,.999997};hooks.UnitFrame_UpdateManaType(target)
assert(target.writes==0,'packed native white should not trigger repeated corrective writes')
-- A player form change updates the primary and fixed-mana secondary bar only.
reset();power.player=3;hooks.UnitFrame_UpdateManaType({unit='player'})
assert(queries.player==2 and not queries.target and not queries.party1)
assert(string.find(player.fill.path,'ModernPowerEnergyClass',1,true))
assert(string.find(alt.fill.path,'ModernPowerManaClass',1,true))
reset();hooks.UnitFrame_UpdateManaType({unit='raid40'});assert(not next(queries))
env.this={manabar=party};hooks.UnitFrame_UpdateManaType('party1');env.this=nil
assert(queries.party1==1 and not queries.player)
power.target=7;reset();hooks.UnitFrame_UpdateManaType(target);assert(target.fill.path=='original')
hooks.UnitFrame_UpdateManaType(target);assert(target.fill.writes==1)
-- Keep the explicit full startup refresh after other modules initialize.
reset();env.this=frames[1];frames[1].scripts.OnUpdate();env.this=nil
assert(queries.player==2 and queries.target==1 and queries.party1==1)
local classQueries,players,selfTarget={}, {},false
env.GetAddOnInfo=function()return 'ShaguTweaks','ShaguTweaks' end
env.UnitClass=function(unit)classQueries[unit]=(classQueries[unit] or 0)+1;return 'Druid','DRUID' end
env.UnitIsPlayer=function(unit)return players[unit]~=false end
env.UnitIsUnit=function(a,b)return a==b or selfTarget and ((a=='player' and b=='target')or(a=='target' and b=='player'))end
env.TargetofTarget_OnUpdate=function()end
env.ShaguTweaks.API.UnitGUID=function(unit)return unit..'guid' end
env.PlayerFrame={unit='player',portrait=texture('portrait')}
env.TargetFrame={unit='target',portrait=texture('portrait')}
env.TargetofTargetFrame={unit='targettarget',portrait=texture('portrait')}
for i=1,4 do env['PartyMemberFrame'..i]={unit='party'..i,portrait=texture('portrait')}end
load('mods/unitframes-classportrait.lua');modules['Unit Frame Class Portraits']:enable()
local events=frames[table.getn(frames)]
local function emit(name,unit)
classQueries={};env.event,env.arg1=name,unit;events.scripts.OnEvent()
end
emit('PLAYER_ENTERING_WORLD')
assert(classQueries.player==1 and classQueries.target==1 and classQueries.party4==1)
emit('UNIT_PORTRAIT_UPDATE','party2');assert(classQueries.party2==1 and not classQueries.player and not classQueries.target)
local portrait=env.PartyMemberFrame2.portrait;local writes,crops=portrait.writes,portrait.crops
emit('UNIT_PORTRAIT_UPDATE','party2');assert(portrait.writes==writes and portrait.crops==crops)
emit('UNIT_PORTRAIT_UPDATE','raid40');assert(not next(classQueries))
selfTarget=true;emit('UNIT_PORTRAIT_UPDATE','player');assert(classQueries.player==1 and classQueries.target==1)
local tf=env.TargetFrame;tf.portrait.path='native';tf.portrait.coords={0,0,0,1,1,0,1,1}
hooks.UnitFrame_Update(tf);assert(string.find(tf.portrait.path,'UI-Classes-Circles',1,true))
tf.portrait.path='native';env.this=tf;hooks.UnitFrame_Update('target');env.this=nil
assert(string.find(tf.portrait.path,'UI-Classes-Circles',1,true))
players.target=false;tf.portrait.path='npc';hooks.UnitFrame_Update(tf)
assert(tf.portrait.path=='npc' and tf.portrait.coords[1]==0 and tf.portrait.coords[5]==1)
env.TargetofTarget_OnUpdate(.3)
assert(string.find(env.TargetofTargetFrame.portrait.path,'UI-Classes-Circles',1,true))
print('Unit-frame styling: affected power/portrait routing, startup refreshes, fixed druid mana, stable keys/live overwrite correction, packed-white tolerance, aliases and native hook contexts passed')