mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 04:47:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2576eb424e | ||
|
|
d80d87e28b | ||
|
|
a0ebfe99b4 | ||
|
|
2bc624c271 | ||
|
|
39608efe75 | ||
|
|
af1d6d25a4 | ||
|
|
5efe47cc34 | ||
|
|
aacbc0d92c | ||
|
|
241a5befd2 | ||
|
|
edab1d1e2a | ||
|
|
b55424bab6 | ||
|
|
11191e8d4c | ||
|
|
4263bae064 | ||
|
|
6f928575fa | ||
|
|
1aeafd41a7 | ||
|
|
3003b1129e | ||
|
|
bf9250141a | ||
|
|
3df21247a2 | ||
|
|
3b33a926ed | ||
|
|
dfddf1698d | ||
|
|
6308429b26 | ||
|
|
37e8b3242c | ||
|
|
bdc574d6ce | ||
|
|
85e3f3c051 | ||
|
|
ee2b4d22ac | ||
|
|
59a603f9b2 |
@@ -8,14 +8,17 @@ locales\locales.xml
|
||||
|
||||
Compat.lua
|
||||
Util\Print.lua
|
||||
Util\Mixin.lua
|
||||
Util\Color.lua
|
||||
Util\Constants.lua
|
||||
Util\String.lua
|
||||
Util\Mixin.lua
|
||||
Util\TableUtil.lua
|
||||
Util\MathUtil.lua
|
||||
Util\FrameUtil.lua
|
||||
Util\FormattingUtil.lua
|
||||
Util\TimeUtil.lua
|
||||
Util\CallbackRegistry.lua
|
||||
Util\GlobalCallbackRegistry.lua
|
||||
Util\Color.lua
|
||||
Util\ColorUtil.lua
|
||||
Util\ItemLocation.lua
|
||||
Util\EventUtil.lua
|
||||
|
||||
@@ -10,11 +10,14 @@ function ColorMixin:OnLoad(r, g, b, a)
|
||||
self:SetRGBA(r, g, b, a)
|
||||
end
|
||||
|
||||
function ColorMixin:IsEqualTo(otherColor)
|
||||
function ColorMixin:IsRGBEqualTo(otherColor)
|
||||
return self.r == otherColor.r
|
||||
and self.g == otherColor.g
|
||||
and self.b == otherColor.b
|
||||
and self.a == otherColor.a
|
||||
end
|
||||
|
||||
function ColorMixin:IsEqualTo(otherColor)
|
||||
return self:IsRGBEqualTo(otherColor) and self.a == otherColor.a
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGB()
|
||||
@@ -52,6 +55,39 @@ function ColorMixin:GenerateHexColorMarkup()
|
||||
return "|c"..self:GenerateHexColor()
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorNoAlpha()
|
||||
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
|
||||
end
|
||||
|
||||
function ColorMixin:GetHSL()
|
||||
local r, g, b, a = self.r, self.g, self.b, self.a
|
||||
local max, min = math.max(r, g, b), math.min(r, g, b)
|
||||
local h, s, l
|
||||
l = (max + min) / 2
|
||||
if max == min then
|
||||
h, s = 0, 0 -- achromatic
|
||||
else
|
||||
local d = max - min
|
||||
if l > 0.5 then
|
||||
s = d / (2 - max - min)
|
||||
else
|
||||
s = d / (max + min)
|
||||
end
|
||||
if max == r then
|
||||
h = (g - b) / d
|
||||
if g < b then
|
||||
h = h + 6
|
||||
end
|
||||
elseif max == g then
|
||||
h = (b - r) / d + 2
|
||||
elseif max == b then
|
||||
h = (r - g) / d + 4
|
||||
end
|
||||
h = h / 6
|
||||
end
|
||||
return h, s, l, a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:WrapTextInColorCode(text)
|
||||
return WrapTextInColorCode(text, self:GenerateHexColor())
|
||||
end
|
||||
@@ -60,6 +96,10 @@ function WrapTextInColorCode(text, colorHexString)
|
||||
return string.format("|c%s%s|r", colorHexString, text)
|
||||
end
|
||||
|
||||
function WrapTextInColor(text, color)
|
||||
return WrapTextInColorCode(text, color:GenerateHexColor())
|
||||
end
|
||||
|
||||
function ColorMixin:WrapTextInColorTableCode(text)
|
||||
return WrapTextInColorCode(text, self:GenerateHexColor())
|
||||
end
|
||||
|
||||
@@ -20,6 +20,24 @@ function CreateColorFromHexString(hexColor)
|
||||
end
|
||||
end
|
||||
|
||||
function CreateColorFromRGBAHexString(hexColor)
|
||||
if string.len(hexColor) == 8 then
|
||||
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
|
||||
return CreateColor(r, g, b, a);
|
||||
else
|
||||
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
|
||||
end
|
||||
end
|
||||
|
||||
function CreateColorFromRGBHexString(hexColor)
|
||||
if string.len(hexColor) == 6 then
|
||||
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
|
||||
return CreateColor(r, g, b, 1);
|
||||
else
|
||||
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
|
||||
end
|
||||
end
|
||||
|
||||
function CreateColorFromBytes(r, g, b, a)
|
||||
return CreateColor(r / 255, g / 255, b / 255, a / 255);
|
||||
end
|
||||
@@ -31,6 +49,10 @@ function AreColorsEqual(left, right)
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function IsRGBAEqualToColor(r, g, b, a, color)
|
||||
return (color.r == r) and (color.g == g) and (color.b == b) and (color.a == a);
|
||||
end
|
||||
|
||||
function GetClassColor(classFilename)
|
||||
local color = RAID_CLASS_COLORS[classFilename];
|
||||
if color then
|
||||
@@ -46,7 +68,23 @@ end
|
||||
function GetClassColoredTextForUnit(unit, text)
|
||||
local _, classFilename = UnitClass(unit);
|
||||
local color = GetClassColorObj(classFilename);
|
||||
if (color) then
|
||||
if (color) then
|
||||
return color:WrapTextInColorCode(text);
|
||||
end
|
||||
end
|
||||
|
||||
function GetFactionColor(factionGroupTag)
|
||||
-- Both PLAYER_FACTION_GROUP (tag -> index) and PLAYER_FACTION_COLORS
|
||||
-- (index -> color) live in our Constants.lua. Guarded defensively in
|
||||
-- case a client variant leaves one unset.
|
||||
local group = PLAYER_FACTION_GROUP[factionGroupTag];
|
||||
return PLAYER_FACTION_COLORS and PLAYER_FACTION_COLORS[group];
|
||||
end
|
||||
|
||||
function RGBToColorCode(r, g, b)
|
||||
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
|
||||
end
|
||||
|
||||
function RGBTableToColorCode(rgbTable)
|
||||
return RGBToColorCode(rgbTable.r, rgbTable.g, rgbTable.b);
|
||||
end
|
||||
|
||||
@@ -106,8 +106,20 @@ BAG_ITEM_QUALITY_COLORS =
|
||||
};
|
||||
|
||||
-- faction
|
||||
PLAYER_FACTION_GROUP = { [0] = "Horde", [1] = "Alliance", Horde = 0, Alliance = 1 };
|
||||
|
||||
FACTION_LABELS = {
|
||||
[0] = FACTION_HORDE,
|
||||
[1] = FACTION_ALLIANCE,
|
||||
};
|
||||
|
||||
FACTION_LABELS_FROM_STRING = {
|
||||
["Horde"] = FACTION_HORDE,
|
||||
["Alliance"] = FACTION_ALLIANCE,
|
||||
}
|
||||
|
||||
PLAYER_FACTION_COLORS =
|
||||
{
|
||||
{
|
||||
[0] = PLAYER_FACTION_COLOR_HORDE,
|
||||
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
-- Backport of FrameXML/FormattingUtil.lua (feasible subset) to vanilla
|
||||
-- 1.12 / Lua 5.0.
|
||||
--
|
||||
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
|
||||
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
|
||||
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
|
||||
--
|
||||
-- The FrameXML format-string globals these functions consume
|
||||
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
|
||||
-- defined in the locale layer (locales/enUS.lua), which loads first.
|
||||
|
||||
COPPER_PER_SILVER = 100;
|
||||
SILVER_PER_GOLD = 100;
|
||||
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD;
|
||||
|
||||
function FormatLargeNumber(amount)
|
||||
amount = tostring(amount);
|
||||
local newDisplay = "";
|
||||
local strlen = string.len(amount);
|
||||
-- Insert a separator before each group of three trailing digits.
|
||||
for i = 4, strlen, 3 do
|
||||
newDisplay = LARGE_NUMBER_SEPERATOR ..
|
||||
string.sub(amount, -(i - 1), -(i - 3)) .. newDisplay;
|
||||
end
|
||||
-- Prepend everything before the first separator.
|
||||
local lead = (math.mod(strlen, 3) == 0) and 3 or math.mod(strlen, 3);
|
||||
newDisplay = string.sub(amount, 1, lead) .. newDisplay;
|
||||
return newDisplay;
|
||||
end
|
||||
|
||||
function FormatPercentage(percentage, roundToNearestInteger)
|
||||
if roundToNearestInteger then
|
||||
percentage = Round(percentage * 100);
|
||||
else
|
||||
percentage = percentage * 100;
|
||||
end
|
||||
return string.format(PERCENTAGE_STRING, percentage);
|
||||
end
|
||||
|
||||
function FormatFraction(numerator, denominator)
|
||||
return string.format(GENERIC_FRACTION_STRING, numerator, denominator);
|
||||
end
|
||||
|
||||
function SplitTextIntoLines(text, delimiter)
|
||||
local lines = {};
|
||||
local startIndex = 1;
|
||||
local foundIndex = string.find(text, delimiter);
|
||||
while foundIndex do
|
||||
table.insert(lines, string.sub(text, startIndex, foundIndex - 1));
|
||||
startIndex = foundIndex + 2;
|
||||
foundIndex = string.find(text, delimiter, startIndex);
|
||||
end
|
||||
if startIndex <= string.len(text) then
|
||||
table.insert(lines, string.sub(text, startIndex));
|
||||
end
|
||||
return lines;
|
||||
end
|
||||
|
||||
function SplitTextIntoHeaderAndNonHeader(text)
|
||||
local foundIndex = string.find(text, "|n");
|
||||
local len = string.len(text);
|
||||
if not foundIndex then
|
||||
return text; -- no newline: whole thing is a header
|
||||
elseif len == 2 then
|
||||
return nil; -- the string was just a newline
|
||||
elseif foundIndex == 1 then
|
||||
return string.sub(text, 3); -- leading newline: rest is a header
|
||||
elseif foundIndex == len - 1 then
|
||||
return string.sub(text, 1, foundIndex - 1); -- trailing newline
|
||||
else
|
||||
return string.sub(text, 1, foundIndex - 1), string.sub(text, foundIndex + 2);
|
||||
end
|
||||
end
|
||||
|
||||
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
|
||||
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
|
||||
-- reference is dropped since texture markup doesn't render in 1.12. The
|
||||
-- checkGoldThreshold arg is accepted for signature compatibility but not
|
||||
-- applied (it relied on modern Constants tables).
|
||||
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
|
||||
local gold = math.floor(money / COPPER_PER_GOLD);
|
||||
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
|
||||
local copper = math.mod(money, COPPER_PER_SILVER);
|
||||
|
||||
local goldString = (separateThousands and FormatLargeNumber(gold) or tostring(gold)) ..
|
||||
GOLD_AMOUNT_SYMBOL;
|
||||
local silverString = silver .. SILVER_AMOUNT_SYMBOL;
|
||||
local copperString = copper .. COPPER_AMOUNT_SYMBOL;
|
||||
|
||||
if showZeroAsGold and money == 0 then
|
||||
return goldString;
|
||||
end
|
||||
|
||||
local moneyString = "";
|
||||
local separator = "";
|
||||
if gold > 0 then
|
||||
moneyString = goldString;
|
||||
separator = " ";
|
||||
end
|
||||
if silver > 0 then
|
||||
moneyString = moneyString .. separator .. silverString;
|
||||
separator = " ";
|
||||
end
|
||||
if copper > 0 or moneyString == "" then
|
||||
moneyString = moneyString .. separator .. copperString;
|
||||
end
|
||||
return moneyString;
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Backport of FrameXML/FrameUtil.lua (core subset) to vanilla 1.12.
|
||||
--
|
||||
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
|
||||
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
|
||||
-- native in 1.12.
|
||||
-- - RegisterFrameForUnitEvents (no RegisterUnitEvent in 1.12), and the
|
||||
-- scale/layout/secure/EventRegistry helpers (rely on APIs absent here).
|
||||
|
||||
FrameUtil = FrameUtil or {};
|
||||
|
||||
function FrameUtil.RegisterFrameForEvents(frame, events)
|
||||
for i, event in ipairs(events) do
|
||||
frame:RegisterEvent(event);
|
||||
end
|
||||
end
|
||||
|
||||
function FrameUtil.UnregisterFrameForEvents(frame, events)
|
||||
for i, event in ipairs(events) do
|
||||
frame:UnregisterEvent(event);
|
||||
end
|
||||
end
|
||||
|
||||
function FrameUtil.GetRootParent(frame)
|
||||
local parent = frame:GetParent();
|
||||
while parent do
|
||||
local nextParent = parent:GetParent();
|
||||
if not nextParent then
|
||||
break;
|
||||
end
|
||||
parent = nextParent;
|
||||
end
|
||||
return parent;
|
||||
end
|
||||
|
||||
-- Reparents while preserving the frame's render layering.
|
||||
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
|
||||
local origStrata = frame:GetFrameStrata();
|
||||
local origFrameLevel = frame:GetFrameLevel();
|
||||
frame:SetParent(parent);
|
||||
frame:SetFrameStrata(origStrata);
|
||||
frame:SetFrameLevel(origFrameLevel);
|
||||
end
|
||||
|
||||
function DoesAncestryInclude(ancestry, frame)
|
||||
if ancestry then
|
||||
local currentFrame = frame;
|
||||
while currentFrame do
|
||||
if currentFrame == ancestry then
|
||||
return true;
|
||||
end
|
||||
currentFrame = currentFrame:GetParent();
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
function DoesAncestryIncludeAny(ancestry, frames)
|
||||
for _, frame in ipairs(frames) do
|
||||
if DoesAncestryInclude(ancestry, frame) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
@@ -4,4 +4,22 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
|
||||
_G["SLASH_"..name.."1"] = "/"..command;
|
||||
_G["SLASH_"..name.."2"] = "/"..commandAlias;
|
||||
SlashCmdList[name] = callback;
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["FOCUS"] = function(msg)
|
||||
if msg == "" then
|
||||
FocusUnit();
|
||||
else
|
||||
FocusUnit(msg);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLEARFOCUS"] = function(msg)
|
||||
ClearFocus()
|
||||
end
|
||||
|
||||
SlashCmdList["EQUIP_SET"] = function(msg)
|
||||
if msg ~= "" then
|
||||
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,18 @@ function CreateTableReverseEnumerator(tbl, minIndex, maxIndex)
|
||||
return Enumerator, tbl, maxIndex
|
||||
end
|
||||
|
||||
function tDeleteItem(tbl, item)
|
||||
local size = tGetN(tbl);
|
||||
local index = size;
|
||||
while index > 0 do
|
||||
if item == tbl[index] then
|
||||
tRemove(tbl, index);
|
||||
end
|
||||
index = index - 1;
|
||||
end
|
||||
return size - tGetN(tbl);
|
||||
end
|
||||
|
||||
function tIndexOf(tbl, item)
|
||||
for i, v in ipairs(tbl) do
|
||||
if item == v then
|
||||
@@ -73,6 +85,15 @@ function tIndexOf(tbl, item)
|
||||
end
|
||||
end
|
||||
|
||||
function tContains(tbl, item)
|
||||
for k, v in pairs(tbl) do
|
||||
if item == v then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
function TableUtil.ContainsAllKeys(lhsTable, rhsTable)
|
||||
for key, _ in pairs(lhsTable) do
|
||||
if rhsTable[key] == nil then
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Backport of FrameXML/TimeUtil.lua (feasible subset) to vanilla 1.12 /
|
||||
-- Lua 5.0.
|
||||
--
|
||||
-- Ports the deprecated-but-widely-used duration formatters (SecondsToTime,
|
||||
-- SecondsToClock, SecondsToTimeAbbrev) plus the unit constants/helpers. The
|
||||
-- SecondsFormatterMixin (the modern replacement) is omitted — it's a heavy
|
||||
-- mixin class little addon code targets on classic.
|
||||
--
|
||||
-- 5.0 notes: `%` -> math.mod, `s:format()` -> string.format. The FrameXML
|
||||
-- format-string globals these functions consume (DAYS_ABBR, D_HOURS,
|
||||
-- HOURS_MINUTES_SECONDS, the *_ONELETTER_ABBR set, …) are defined in the
|
||||
-- locale layer (locales/enUS.lua), which loads first.
|
||||
|
||||
SECONDS_PER_MIN = 60;
|
||||
SECONDS_PER_HOUR = 60 * SECONDS_PER_MIN;
|
||||
SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
|
||||
SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY;
|
||||
SECONDS_PER_YEAR = 12 * SECONDS_PER_MONTH;
|
||||
|
||||
function SecondsToMinutes(seconds)
|
||||
return seconds / SECONDS_PER_MIN;
|
||||
end
|
||||
|
||||
function MinutesToSeconds(minutes)
|
||||
return minutes * SECONDS_PER_MIN;
|
||||
end
|
||||
|
||||
function ConvertSecondsToUnits(timestamp)
|
||||
timestamp = math.max(timestamp, 0);
|
||||
local days = math.floor(timestamp / SECONDS_PER_DAY);
|
||||
timestamp = timestamp - (days * SECONDS_PER_DAY);
|
||||
local hours = math.floor(timestamp / SECONDS_PER_HOUR);
|
||||
timestamp = timestamp - (hours * SECONDS_PER_HOUR);
|
||||
local minutes = math.floor(timestamp / SECONDS_PER_MIN);
|
||||
timestamp = timestamp - (minutes * SECONDS_PER_MIN);
|
||||
local seconds = math.floor(timestamp);
|
||||
local milliseconds = timestamp - seconds;
|
||||
return {
|
||||
days = days,
|
||||
hours = hours,
|
||||
minutes = minutes,
|
||||
seconds = seconds,
|
||||
milliseconds = milliseconds,
|
||||
};
|
||||
end
|
||||
|
||||
function SecondsToClock(seconds, displayZeroHours)
|
||||
local units = ConvertSecondsToUnits(seconds);
|
||||
if units.hours > 0 or displayZeroHours then
|
||||
return string.format(HOURS_MINUTES_SECONDS, units.hours, units.minutes, units.seconds);
|
||||
else
|
||||
return string.format(MINUTES_SECONDS, units.minutes, units.seconds);
|
||||
end
|
||||
end
|
||||
|
||||
-- 1.12's native unit strings are bare labels ("Hr", "Min") with no `%d`,
|
||||
-- unlike modern's "%d Hr"; insert the count correctly for either shape.
|
||||
local function FormatUnit(fmt, count)
|
||||
if string.find(fmt, "%%d") then
|
||||
return string.format(fmt, count);
|
||||
end
|
||||
return count .. " " .. fmt;
|
||||
end
|
||||
|
||||
function SecondsToTime(seconds, noSeconds, notAbbreviated, maxCount, roundUp)
|
||||
local time = "";
|
||||
local count = 0;
|
||||
local tempTime;
|
||||
seconds = roundUp and math.ceil(seconds) or math.floor(seconds);
|
||||
maxCount = maxCount or 2;
|
||||
|
||||
-- With a single term, use a higher 1.5x threshold; with 2+, 1.0x.
|
||||
local threshold = (maxCount > 1) and 1.0 or 1.5;
|
||||
|
||||
if seconds >= SECONDS_PER_DAY * threshold then
|
||||
count = count + 1;
|
||||
tempTime = (count == maxCount and roundUp) and math.ceil(seconds / SECONDS_PER_DAY)
|
||||
or math.floor(seconds / SECONDS_PER_DAY);
|
||||
time = FormatUnit(notAbbreviated and D_DAYS or DAYS_ABBR, tempTime);
|
||||
seconds = math.mod(seconds, SECONDS_PER_DAY);
|
||||
end
|
||||
if count < maxCount and seconds >= SECONDS_PER_HOUR * threshold then
|
||||
count = count + 1;
|
||||
if time ~= "" then time = time .. TIME_UNIT_DELIMITER; end
|
||||
tempTime = (count == maxCount and roundUp) and math.ceil(seconds / SECONDS_PER_HOUR)
|
||||
or math.floor(seconds / SECONDS_PER_HOUR);
|
||||
time = time .. FormatUnit(notAbbreviated and D_HOURS or HOURS_ABBR, tempTime);
|
||||
seconds = math.mod(seconds, SECONDS_PER_HOUR);
|
||||
end
|
||||
if count < maxCount and seconds >= SECONDS_PER_MIN * threshold then
|
||||
count = count + 1;
|
||||
if time ~= "" then time = time .. TIME_UNIT_DELIMITER; end
|
||||
tempTime = (count == maxCount and roundUp) and math.ceil(seconds / SECONDS_PER_MIN)
|
||||
or math.floor(seconds / SECONDS_PER_MIN);
|
||||
time = time .. FormatUnit(notAbbreviated and D_MINUTES or MINUTES_ABBR, tempTime);
|
||||
seconds = math.mod(seconds, SECONDS_PER_MIN);
|
||||
end
|
||||
if count < maxCount and seconds > 0 and not noSeconds then
|
||||
if time ~= "" then time = time .. TIME_UNIT_DELIMITER; end
|
||||
time = time .. FormatUnit(notAbbreviated and D_SECONDS or SECONDS_ABBR, seconds);
|
||||
end
|
||||
return time;
|
||||
end
|
||||
|
||||
-- Returns (formatString, value) for the single largest applicable unit.
|
||||
function SecondsToTimeAbbrev(seconds, thresholdOverride)
|
||||
local threshold = thresholdOverride or 1.5;
|
||||
if seconds >= SECONDS_PER_DAY * threshold then
|
||||
return DAY_ONELETTER_ABBR, math.ceil(seconds / SECONDS_PER_DAY);
|
||||
end
|
||||
if seconds >= SECONDS_PER_HOUR * threshold then
|
||||
return HOUR_ONELETTER_ABBR, math.ceil(seconds / SECONDS_PER_HOUR);
|
||||
end
|
||||
if seconds >= SECONDS_PER_MIN * threshold then
|
||||
return MINUTE_ONELETTER_ABBR, math.ceil(seconds / SECONDS_PER_MIN);
|
||||
end
|
||||
return SECOND_ONELETTER_ABBR, seconds;
|
||||
end
|
||||
@@ -1,3 +1,4 @@
|
||||
BINDING_NAME_AOELOOT = "AoE Loot"
|
||||
BINDING_NAME_FOCUSTARGET = "Focus Target";
|
||||
BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
FOCUSTARGET = "Focus Target";
|
||||
@@ -7,4 +8,32 @@ ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
|
||||
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
|
||||
ITEM_MISSING = "%s missing";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
TARGETFOCUS = "Target Focus";
|
||||
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
SILVER_AMOUNT_SYMBOL = "s";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
TIME_UNIT_DELIMITER = " ";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
DAYS_ABBR = "%d |4Day:Days;";
|
||||
HOURS_ABBR = "%d |4Hr:Hr;";
|
||||
MINUTES_ABBR = "%d |4Min:Min;";
|
||||
SECONDS_ABBR = "%d |4Sec:Sec;";
|
||||
D_DAYS = "%d |4Day:Days;";
|
||||
D_HOURS = "%d |4Hour:Hours;";
|
||||
D_MINUTES = "%d |4Minute:Minutes;";
|
||||
D_SECONDS = "%d |4Second:Seconds;";
|
||||
DAY_ONELETTER_ABBR = "%d d";
|
||||
HOUR_ONELETTER_ABBR = "%d h";
|
||||
MINUTE_ONELETTER_ABBR = "%d m";
|
||||
SECOND_ONELETTER_ABBR = "%d s";
|
||||
|
||||
@@ -47,7 +47,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastingInfo`, `CastSpellNoToggle`, `ChannelInfo`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange`, `UnitCastingInfo`, `UnitChannelInfo` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [Table](docs/API.md#table) | `table.wipe` |
|
||||
|
||||
@@ -782,24 +782,28 @@ The probe helpers (`_classicapi_ProbeSpellRecord`,
|
||||
`_classicapi_FindSpellField`) were temporarily exposed to derive the
|
||||
offset and removed in the same commit that shipped `GetSpellSchool`.
|
||||
|
||||
## 38. `GetSpellRadius(spellID)` — easy
|
||||
## ~~38. `GetSpellRadius(spellID)`~~ — DONE
|
||||
|
||||
Returns the AOE radius in yards, or `nil` for non-AOE spells.
|
||||
`Spell.dbc` has a `SpellRadiusIndex` field that points into
|
||||
`SpellRadius.dbc` (instance at `0x00C0D7A8` per `docs/DBCs.md`,
|
||||
already wired up via `VAR_SPELL_RANGE_RECORDS`-style helpers in
|
||||
`src/spell/Info.cpp`).
|
||||
Shipped in [src/spell/Radius.cpp](src/spell/Radius.cpp) as
|
||||
`C_Spell.GetSpellRadius(spellID)` plus the vanilla-positional
|
||||
`GetSpellRadius(slot, bookType)`. Returns `(baseRadius, modifiedRadius)`,
|
||||
or `nil` for non-AOE spells. `modifiedRadius` applies the local player's
|
||||
talent/item radius mods (e.g. Arctic Reach) via the shared `Spell::Mod`
|
||||
helper — verified in-game: Frost Nova (10230) → `10, 10` without Arctic
|
||||
Reach, `10, 12` with rank 2 (+20%).
|
||||
|
||||
Stride and field layout for `SpellRadius.dbc`: needs derivation
|
||||
(should be `{id, radius_min, radius, radius_max}` per the public
|
||||
schema, but verify). Sub-DBC lookup pattern matches what
|
||||
`LookupSubRecord` already does in `Info.cpp` for icon / cast time /
|
||||
range.
|
||||
The radius is **per-effect**, not a single spell-level field: `Spell.dbc`
|
||||
has `EffectRadiusIndex[3]` at record `+0x160` (verified by decompiling
|
||||
`FUN_006e6350`, which reads `spellRec[+0x160]`/`[+0x164]` for effects
|
||||
0/1). Each non-zero index resolves into `SpellRadius.dbc` (records
|
||||
`0x00C0D7B0`, count `0x00C0D7B4`), record shape
|
||||
`{ id@+0, radius@+4 (f32), radiusPerLevel@+8 (f32), ... }`. We return the
|
||||
largest base radius across the three effects. Base radius only — the
|
||||
engine adds `radiusPerLevel * casterLevel`, but that needs a unit context
|
||||
and modern `GetSpellRadius` reports the base value.
|
||||
|
||||
Useful for AOE damage trackers, ground-effect addons, and any aura
|
||||
library that wants to render an indicator radius. Modern WoW exposes
|
||||
this via `GetSpellRadius` (deprecated) or returns from
|
||||
`C_Spell.GetSpellInfo`.
|
||||
See `OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX`, `VAR_SPELL_RADIUS_RECORDS`,
|
||||
`OFF_SPELL_RADIUS_VALUE` in [src/Offsets.h](src/Offsets.h).
|
||||
|
||||
## ~~39. `GetFactionParentID(factionID)`~~ — DONE
|
||||
|
||||
@@ -2309,45 +2313,55 @@ that want this behavior on Octo-style multi-DLL clients can
|
||||
fall back to debouncing `BAG_UPDATE` themselves via an
|
||||
`OnUpdate` timer.
|
||||
|
||||
## 68. `UnitChannelInfo(unit)` / `UnitCastingInfo(unit)` — medium
|
||||
## ~~68. `UnitChannelInfo(unit)` / `UnitCastingInfo(unit)`~~ — DONE
|
||||
|
||||
Modern signatures return `(name, _, texture, startMs, endMs, _, _,
|
||||
spellID)` for the unit's currently channeled / cast spell. 1.12
|
||||
exposes nothing equivalent — the closest is `SPELLCAST_CHANNEL_*`
|
||||
events, which only fire for the local player and don't carry
|
||||
spellID payloads.
|
||||
Shipped in [src/spell/Cast.cpp](src/spell/Cast.cpp): `UnitCastingInfo`,
|
||||
`CastingInfo`, `UnitChannelInfo`, `ChannelInfo`. Verified in-game —
|
||||
Blizzard channel `(…, 907894314, 907902314, …, 10187)` (8s), Conjure Food
|
||||
cast `(…, 907928268, 907931268, …, 28612)` (3s), `GetTime()*1000` falls
|
||||
between start/end.
|
||||
|
||||
The data is sitting in the broadcast descriptor:
|
||||
**The start/end-time hunt resolved with a firm negative**, confirmed via
|
||||
the cross-binary technique. 3.3.5's `Script_UnitCastingInfo`
|
||||
(`0x00611DF0`) / `Script_UnitChannelInfo` (`0x00612090`) read cast times
|
||||
from **per-unit CGUnit fields** (cast `+0xa6c/+0xa78/+0xa7c`, channel
|
||||
`+0xa80/+0xa84/+0xa88`) — TBC/WotLK additions that don't exist in the
|
||||
1.12 CGUnit (the tell: 1.12's *player* cast spellID is a global,
|
||||
`VAR_CURRENT_CAST_SPELL`, not a unit field). 1.12 stores no cast times
|
||||
anywhere readable; the cast bar is Lua-driven off `SPELLCAST_START`, and
|
||||
remote units' casts are transient spell-visual objects
|
||||
(`FUN_0060d7c0`), not a queryable record. The cast-timer globals at
|
||||
`0x00cee8d4..e4` are a shared visual-system slot (written for *any*
|
||||
channeling unit via `FUN_00612a30 → FUN_0060d7c0`), so they're not
|
||||
player-safe.
|
||||
|
||||
- `UNIT_FIELD_CHANNEL_SPELL` at descriptor `+0x228` (already in
|
||||
`Offsets.h` as `OFF_UNIT_FIELD_CHANNEL_SPELL`). Verified via the
|
||||
warlock/clicker/fishing diffs in the `IsAssistingRitual` session
|
||||
— non-zero = currently channeling that spell.
|
||||
- `UNIT_FIELD_CHANNEL_OBJECT` at descriptor `+0x38/+0x3C`
|
||||
(`OFF_UNIT_FIELD_CHANNEL_OBJECT`). 64-bit GUID of the channel
|
||||
target — bobber for fishing, lock for lockpicking, etc. Zero for
|
||||
channels that don't bind to a GameObject (warlock spells, Ritual
|
||||
of Summoning).
|
||||
So timing is **self-tracked for the local player** on `WorldTick`:
|
||||
- cast: `VAR_CURRENT_CAST_SPELL` 0→nonzero → `startMs = FUN_OS_TICKCOUNT_MS`,
|
||||
`endMs = start + SpellCastTimes base ms`. Instant casts (cast time 0)
|
||||
and channels are filtered out of the cast path.
|
||||
- channel: player's broadcast `UNIT_FIELD_CHANNEL_SPELL` (`+0x228`),
|
||||
`endMs = start + SpellDuration base ms`.
|
||||
|
||||
That gives us spellID immediately, and `name` + `texture` via the
|
||||
existing `GetSpellInfo(spellID)` chain. Worth shipping a partial
|
||||
function that omits the `startMs`/`endMs` returns and let callers
|
||||
do their own duration estimation off `SPELLCAST_CHANNEL_START` —
|
||||
or hunt for the per-unit cast-time fields the engine uses for the
|
||||
local castbar.
|
||||
`FUN_OS_TICKCOUNT_MS` shares `GetTime()`'s epoch (×0.001), so the ms
|
||||
values feed addon progress math directly. Other units: channel
|
||||
spellID/name/texture from the broadcast `+0x228` (no times); regular
|
||||
casts return nil (no vanilla data).
|
||||
|
||||
What's missing for the full modern signature: the start/end-time
|
||||
fields. The local player has `Script_SpellStopCasting`-adjacent
|
||||
state at fixed VAs (`m_castingSpellId`, `m_castStartMs`,
|
||||
`m_castEndMs`) — but for *other* units we'd need to either find
|
||||
broadcast UpdateFields holding the same, or compute end-time on
|
||||
the client from `Spell.dbc`'s cast time when we see a fresh
|
||||
UNIT_FIELD_CHANNEL_SPELL value. The 5.4.8/3.3.5 binaries are the
|
||||
right next stop for this.
|
||||
`isTradeskill` is real — `SPELL_ATTR_TRADESPELL` (Attributes bit 0x20,
|
||||
the version-stable bit 3.3.5's Script_UnitCastingInfo reads). Verified:
|
||||
Rough Blasting Powder (Engineering) → true, Conjure Food → false.
|
||||
|
||||
Companion field to investigate while we're in here: `desc 0x1320`
|
||||
captures the *prior* channeled spellID after the channel ends —
|
||||
see entry #71 for what that's about.
|
||||
Cast time is the **effective** time, not base: we call the engine's
|
||||
cast-time helper `FUN_006e3340` (base + level scaling + cast-time SpellMod
|
||||
op 10 + the caster's haste/cast-speed multiplier at descriptor +0x22c) —
|
||||
the same helper the engine's own cast-start uses, so our end time matches
|
||||
the cast bar. Verified: talented Frostbolt (Improved Frostbolt -0.5s)
|
||||
reads 2.5s, not the 3.0s base. (Calling the helper beats replicating op 10
|
||||
via `Spell::Mod`, which would miss the cast-speed multiplier.)
|
||||
|
||||
**Follow-up:** `delayTimeMs` pushback tracking (currently 0).
|
||||
|
||||
Companion field (`desc 0x1320`, prior channeled spell) — see entry #71.
|
||||
|
||||
## 69. `IsGathering()` — easy (local player only)
|
||||
|
||||
|
||||
+201
@@ -279,6 +279,9 @@ build instructions.
|
||||
- [`GetSpellLink(spellID)` / `GetSpellLink(slot, bookType)`](#getspelllinkspellid--getspelllinkslot-booktype)
|
||||
- [`C_Spell.GetSpellLink(spellID)`](#c_spellgetspelllinkspellid)
|
||||
- [`C_Spell.GetSpellDescription(spellID)`](#c_spellgetspelldescriptionspellid)
|
||||
- [`C_Spell.GetSpellMechanicByID(spellID)`](#c_spellgetspellmechanicbyidspellid)
|
||||
- [`C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype)
|
||||
- [`C_Spell.GetSpellPowerCost(spellIdentifier)`](#c_spellgetspellpowercostspellidentifier)
|
||||
- [`C_Spell.GetSpellReagents(spellID)`](#c_spellgetspellreagentsspellid)
|
||||
- [`C_Spell.GetSpellSubtext(spellIdentifier)`](#c_spellgetspellsubtextspellidentifier)
|
||||
- [`IsPassiveSpell(spellID)` / `IsPassiveSpell(slot, bookType)`](#ispassivespellspellid--ispassivespellslot-booktype)
|
||||
@@ -299,6 +302,8 @@ build instructions.
|
||||
- [`CastSpellNoToggle(name | spellID)`](#castspellnotogglename--spellid)
|
||||
- [`C_Spell.CastAtCursor(spellID)`](#c_spellcastatcursorspellid)
|
||||
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
|
||||
- [`UnitCastingInfo(unit)` / `CastingInfo()`](#unitcastinginfounit--castinginfo)
|
||||
- [`UnitChannelInfo(unit)` / `ChannelInfo()`](#unitchannelinfounit--channelinfo)
|
||||
|
||||
- [SpellBook](#spellbook)
|
||||
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
|
||||
@@ -6833,6 +6838,134 @@ local desc = C_Spell.GetSpellDescription(133) -- Fireball Rank 1
|
||||
> "currently displayed" tooltip text with caster scaling, use
|
||||
> `GameTooltip:SetSpellByID` and read line strings from there.
|
||||
|
||||
### `C_Spell.GetSpellMechanicByID(spellID)`
|
||||
|
||||
Returns the spell's mechanic as `(mechanicID, name)` — the standard WoW
|
||||
`SpellMechanic` ID and its English name. Works for **any** `spellID`, not
|
||||
just spells the player has learned.
|
||||
|
||||
Returns:
|
||||
- nothing (`nil`) if the spell ID is invalid / out of range
|
||||
- `(0, nil)` for a known spell that carries no mechanic
|
||||
- `(mechanicID, name)` otherwise
|
||||
|
||||
The `name` is always the **enUS** string (locale-independent), because the
|
||||
mechanic name is a stable token consumers string-match against
|
||||
(`"rooted"`, `"stunned"`, `"polymorphed"`, …) — returning a localized
|
||||
value would break that matching across clients. It is `nil` only when the
|
||||
mechanic has no `SpellMechanic.dbc` record or no English name; the numeric
|
||||
ID is still returned in that case.
|
||||
|
||||
```lua
|
||||
C_Spell.GetSpellMechanicByID(118) -- Polymorph → 17, "polymorphed"
|
||||
C_Spell.GetSpellMechanicByID(339) -- Entangling Roots → 7, "rooted"
|
||||
C_Spell.GetSpellMechanicByID(133) -- Fireball → 0, nil
|
||||
```
|
||||
|
||||
This reads `Spell.dbc`'s `Mechanic` field and resolves the name from
|
||||
`SpellMechanic.dbc` directly — both DBCs are resident from boot, so the
|
||||
call is synchronous with no caching or network round-trip (unlike item
|
||||
data). It replaces hand-maintained `spellID → mechanic` lookup tables that
|
||||
addons otherwise keep because vanilla exposes no Lua reader for the field
|
||||
— e.g. crowd-control macro conditionals (`[cc:stun]`, `[cc:fear]`) that
|
||||
need to know which mechanic a debuff applies.
|
||||
|
||||
The complete `SpellMechanic.dbc` set on 1.12 — all 27 rows (the `name`
|
||||
column is exactly what this function returns):
|
||||
|
||||
| ID | name | ID | name | ID | name |
|
||||
|----|------|----|------|----|------|
|
||||
| 1 | `charmed` | 10 | `asleep` | 19 | `shielded` |
|
||||
| 2 | `disoriented` | 11 | `ensnared` | 20 | `shackled` |
|
||||
| 3 | `disarmed` | 12 | `stunned` | 21 | `mounted` |
|
||||
| 4 | `distracted` | 13 | `frozen` | 22 | `seduced` |
|
||||
| 5 | `fleeing` | 14 | `incapacitated` | 23 | `turned` |
|
||||
| 6 | `clumsy` | 15 | `bleeding` | 24 | `horrified` |
|
||||
| 7 | `rooted` | 16 | `healing` | 25 | `invulnerable` |
|
||||
| 8 | `pacified` | 17 | `polymorphed` | 26 | `interrupted` |
|
||||
| 9 | `silenced` | 18 | `banished` | 27 | `dazed` |
|
||||
|
||||
> **No mechanic `30`.** The table tops out at `27`. Sap and Gouge report
|
||||
> `14` (incapacitated) in 1.12 — the `30` ("sapped") value used by some
|
||||
> addon tables is a later-expansion addition and has no row here.
|
||||
|
||||
### `C_Spell.GetSpellRadius(spellID)` / `GetSpellRadius(slot, bookType)`
|
||||
|
||||
Returns `(baseRadius, modifiedRadius)` — the spell's AOE radius in yards,
|
||||
or `nil` for a non-AOE spell (no effect carries a radius). Works for any
|
||||
spell, not just ones the player has learned.
|
||||
|
||||
- **`baseRadius`** — the raw `SpellRadius.dbc` value; caster-independent,
|
||||
what an AOE tracker watching other units wants.
|
||||
- **`modifiedRadius`** — `baseRadius` with the **local player's** talent /
|
||||
item modifiers applied (e.g. a Mage's Arctic Reach). Equal to
|
||||
`baseRadius` when the player has no matching modifier.
|
||||
|
||||
```lua
|
||||
C_Spell.GetSpellRadius(122) -- Frost Nova → 10, 10 (no Arctic Reach)
|
||||
C_Spell.GetSpellRadius(122) -- Frost Nova → 10, 12 (with Arctic Reach r2)
|
||||
C_Spell.GetSpellRadius(133) -- Fireball → nil (single-target)
|
||||
GetSpellRadius(10, "spell") -- 10th player-book slot, by spellbook position
|
||||
```
|
||||
|
||||
Two signatures matching the dual-signature shape used elsewhere in this
|
||||
backport (`GetSpellInfo`, `SpellHasRange`, …): the namespaced form takes
|
||||
a modern spell *identifier* (numeric ID or name); the bare global takes
|
||||
the vanilla `(slot, bookType)` spellbook position, where `bookType`
|
||||
follows the `GetSpellName` convention — `"pet"` → pet book, `"spell"`
|
||||
(or any non-pet / omitted value) → player book.
|
||||
|
||||
`baseRadius` reads `Spell.dbc`'s per-effect `EffectRadiusIndex[3]`
|
||||
(`+0x160`) and resolves each into `SpellRadius.dbc`; since a spell's
|
||||
effects can each carry their own radius, the **largest** base radius
|
||||
across the three effects is returned.
|
||||
|
||||
> **No caster-level scaling.** The engine's internal radius helper also
|
||||
> adds `radiusPerLevel * casterLevel`, but that needs a unit context;
|
||||
> like modern `GetSpellRadius` this reports the base value. Talent/item
|
||||
> modifiers (the `modifiedRadius` return) **are** applied, via the
|
||||
> engine's SpellMod system — but only the **local player's**, since the
|
||||
> client tracks spell mods for the player alone (there's no way to know
|
||||
> another caster's talents). All DBCs / mod tables are resident from
|
||||
> boot, so the call is synchronous with no caching.
|
||||
|
||||
### `C_Spell.GetSpellPowerCost(spellIdentifier)`
|
||||
|
||||
Returns an array of `SpellPowerCostInfo` tables, or `nil` if the spell
|
||||
isn't found or has no resource cost. Vanilla spells have exactly one
|
||||
power cost, so the array holds at most one entry.
|
||||
|
||||
```lua
|
||||
C_Spell.GetSpellPowerCost(10187) -- Blizzard, Mage with 3/3 Frost Channeling
|
||||
-- { [1] = { type = 0, name = "MANA", cost = 1190, minCost = 1190,
|
||||
-- costPercent = 0, costPerSec = 0, requiredAuraID = 0,
|
||||
-- hasRequiredAura = false } }
|
||||
-- base cost 1400, 15% reduction applied -> 1190
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `type` | `Enum.PowerType` — `0` mana, `1` rage, `2` focus, `3` energy, `4` happiness (the spell's `PowerType`) |
|
||||
| `name` | power token (`"MANA"`, `"RAGE"`, …) |
|
||||
| `cost` | **effective** cost for the local player |
|
||||
| `minCost` | `== cost` (vanilla has no optional-cost component) |
|
||||
| `costPercent` | the spell's `%`-of-base-resource cost, or `0` for a flat cost |
|
||||
| `costPerSec` | `0` (vanilla doesn't expose a per-second channel cost in these terms) |
|
||||
| `requiredAuraID` | `0` (no form/aura-conditional costs in 1.12) |
|
||||
| `hasRequiredAura` | `false` |
|
||||
|
||||
`cost` is the value the engine actually charges — base + level scaling +
|
||||
`ManaCostPercent`-of-resource + descriptor power-cost mods + the cost
|
||||
SpellMod (talents like Frost Channeling). It comes from the engine's own
|
||||
cost helper (`FUN_006e31b0`), so it stays in lockstep with what casting
|
||||
the spell deducts.
|
||||
|
||||
> **Effective vs. base.** This returns the player-modified cost.
|
||||
> [`C_Spell.GetSpellInfo`](#c_spellgetspellinfospellid)'s `cost` field is
|
||||
> the **base** `ManaCost` (intrinsic spell data, caster-independent) —
|
||||
> same base/modified split as
|
||||
> [`GetSpellRadius`](#c_spellgetspellradiusspellid--getspellradiusslot-booktype).
|
||||
|
||||
### `C_Spell.GetSpellReagents(spellID)`
|
||||
|
||||
Returns the spell's reagent list as an array of
|
||||
@@ -7422,6 +7555,74 @@ pass the plain name. Multi-rank buffs match on first-found; if you
|
||||
have multiple ranks of the same buff active (unusual but possible with
|
||||
e.g. paladin blessings before talent merge), the first slot wins.
|
||||
|
||||
### `UnitCastingInfo(unit)` / `CastingInfo()`
|
||||
|
||||
Returns the unit's in-progress **regular cast** (not a channel), or
|
||||
`nil` if it isn't casting. `CastingInfo()` is `UnitCastingInfo("player")`
|
||||
without the unit-token lookup.
|
||||
|
||||
```
|
||||
name, displayName, textureID, startTimeMs, endTimeMs, isTradeskill,
|
||||
castID, notInterruptible, castingSpellID, castBarID, delayTimeMs
|
||||
= UnitCastingInfo(unit)
|
||||
```
|
||||
|
||||
```lua
|
||||
-- mid-cast:
|
||||
UnitCastingInfo("player")
|
||||
-- "Conjure Food", "Conjure Food", "Interface\\Icons\\INV_Misc_Food_...",
|
||||
-- 907928268, 907931268, false, nil, false, 28612, nil, 0
|
||||
```
|
||||
|
||||
`startTimeMs`/`endTimeMs` share `GetTime()`'s epoch (`GetTime()*1000`),
|
||||
so progress is `(GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs)`.
|
||||
|
||||
> **Local player only.** Vanilla 1.12 stores no cast times anywhere
|
||||
> readable (the cast bar is Lua-driven off `SPELLCAST_START`), and it
|
||||
> tracks regular casts for nobody but you — other units' casts exist
|
||||
> only as transient animation visuals. So `UnitCastingInfo(otherUnit)`
|
||||
> returns `nil`. We self-track the player's cast on a per-frame tick
|
||||
> (`VAR_CURRENT_CAST_SPELL`), stamping `startTimeMs = now` and
|
||||
> `endTimeMs = now + base cast time`. Instant casts return `nil` (no
|
||||
> cast bar). See [`UnitChannelInfo`](#unitchannelinfounit--channelinfo)
|
||||
> for channels, which *are* visible on other units.
|
||||
>
|
||||
> `isTradeskill` is real — the spell's `SPELL_ATTR_TRADESPELL` flag, so
|
||||
> profession recipe casts (smelting, cooking/enchanting recipes, …)
|
||||
> return `true` while regular spells return `false`. `endTimeMs` reflects
|
||||
> the **effective** cast time (the engine's own cast-time helper: base +
|
||||
> level scaling + cast-time talents like Improved Frostbolt + the
|
||||
> haste/cast-speed multiplier), so it matches the in-game cast bar — e.g.
|
||||
> a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. Fields
|
||||
> vanilla can't fill are structurally-correct placeholders:
|
||||
> `castID`/`castBarID` = `nil`, `notInterruptible` = `false`,
|
||||
> `delayTimeMs` = `0`.
|
||||
|
||||
### `UnitChannelInfo(unit)` / `ChannelInfo()`
|
||||
|
||||
Returns the unit's in-progress **channel**, or `nil` if it isn't
|
||||
channeling. `ChannelInfo()` is `UnitChannelInfo("player")` without the
|
||||
token lookup.
|
||||
|
||||
```
|
||||
name, displayName, textureID, startTimeMs, endTimeMs, isTradeskill,
|
||||
notInterruptible, spellID = UnitChannelInfo(unit)
|
||||
```
|
||||
|
||||
```lua
|
||||
ChannelInfo()
|
||||
-- "Blizzard", "Blizzard", "Interface\\Icons\\Spell_Frost_IceStorm",
|
||||
-- 907894314, 907902314, false, false, 10187
|
||||
```
|
||||
|
||||
Unlike casts, the channeled spellID **is** broadcast for every unit
|
||||
(`UNIT_FIELD_CHANNEL_SPELL`), so `UnitChannelInfo("target")` works on
|
||||
other units — returning `name`/`displayName`/`textureID`/`spellID` with
|
||||
**`nil` start/end times** (we only track the local player's channel
|
||||
start). For the player, the full timing is present
|
||||
(`endTimeMs - startTimeMs` = channel duration). Same placeholder fields
|
||||
as `UnitCastingInfo`.
|
||||
|
||||
## SpellBook
|
||||
|
||||
Functions keyed on the player or pet **spellbook** (slot indices,
|
||||
|
||||
+101
@@ -627,6 +627,37 @@ enum Offsets {
|
||||
// tick count in EAX).
|
||||
FUN_OS_TICKCOUNT_MS = 0x0042B790,
|
||||
|
||||
// Effective cast time (ms) for a spell, fully resolved:
|
||||
// `__fastcall uint32_t(int spellID, int unit /*0 = local player*/,
|
||||
// int flag /*0 = clamp negatives to 0*/)`. Computes
|
||||
// SpellCastTimes base + level scaling, applies the cast-time
|
||||
// SpellMod (op 10, via FUN_006e6af0), then multiplies by the
|
||||
// caster's cast-speed/haste mod (descriptor +0x22c). This is the
|
||||
// exact helper the engine's own cast-start path (FUN_006ec910)
|
||||
// uses for the cast-bar end time, so reading it keeps our
|
||||
// UnitCastingInfo end time in lockstep with the engine. 0 for
|
||||
// instant / no-cast-time spells.
|
||||
FUN_GET_CAST_TIME = 0x006E3340,
|
||||
|
||||
// Effective power cost (mana/rage/energy/…) for a spell:
|
||||
// `__fastcall uint32_t(int spellID, int unit /*0 = local player*/)`.
|
||||
// Computes ManaCost base + per-level + ManaCostPercent-of-resource,
|
||||
// applies the descriptor power-cost modifiers, then the cost
|
||||
// SpellMod (op 0xE / 14). 0xFFFFFFFF on invalid input / no player.
|
||||
// Read by C_Spell.GetSpellPowerCost (the full helper, like the cast
|
||||
// time one — beats replicating op 14 via Spell::Mod since it also
|
||||
// folds in the descriptor cost mods).
|
||||
FUN_GET_SPELL_COST = 0x006E31B0,
|
||||
|
||||
// Effective spell/channel duration (ms):
|
||||
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
|
||||
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
|
||||
// base + level scaling (capped at the row's max), then the duration
|
||||
// SpellMod (op 1). Same `unit` selector as the cast-time/cost helpers.
|
||||
// Used for UnitChannelInfo's channel end time. Note: takes the spell
|
||||
// RECORD pointer, not a spellID.
|
||||
FUN_GET_SPELL_DURATION = 0x006EA000,
|
||||
|
||||
// Spell.dbc `m_durationIndex` field — pointer into SpellDuration.dbc.
|
||||
// Verified via `FUN_004E44B0` (`0x004e44b0`) and `FUN_006EA000`
|
||||
// (`0x006ea000`), both of which read `[spellRec + 0x78]` and use
|
||||
@@ -1417,6 +1448,10 @@ enum Offsets {
|
||||
VAR_CHRCLASSES_COUNT = 0x00C0DEF8,
|
||||
OFF_CHRCLASSES_NAMES = 0x14,
|
||||
OFF_CHRCLASSES_FILENAME = 0x38,
|
||||
// SpellFamilyName for the class — the gate the SpellMod system
|
||||
// matches a spell's OFF_SPELL_RECORD_FAMILY_NAME against. Read by
|
||||
// FUN_006e6ca0 (`DAT_00cecaac = ChrClasses[classID] + 0x3c`).
|
||||
OFF_CHRCLASSES_SPELL_FAMILY = 0x3C,
|
||||
|
||||
// ChrRaces.dbc — standard 5-DWORD class shape at 0x00C0DED8,
|
||||
// records-pointer at +0x08, count at +0x0C. 29 columns,
|
||||
@@ -2381,6 +2416,18 @@ enum Offsets {
|
||||
VAR_SPELL_RECORDS = 0x00C0D788, // SpellRecord *records[spellID]
|
||||
VAR_SPELL_RECORD_COUNT = 0x00C0D78C, // max spellID
|
||||
|
||||
// SpellMechanic.dbc — maps a SpellMechanic ID to its localized
|
||||
// name. Standard 5-DWORD DBC instance at 0x00C0D7BC; records ptr at
|
||||
// +0x08, count at +0x0C (see docs/DBCs.md). Records are 1-based
|
||||
// (records[mechanicID]); the engine only ever uses the table for
|
||||
// existence/bounds checks (FUN_006e9ca0, FUN_00612df0, FUN_006e8eb0
|
||||
// case 0x8d all do `records[id] != 0`), never reading the name — so
|
||||
// the name offset below follows the canonical 2-column
|
||||
// (ID + localized Name) DBC schema rather than an engine-reader anchor.
|
||||
VAR_SPELLMECHANIC_RECORDS = 0x00C0D7C4, // SpellMechanicRecord *records[mechanicID]
|
||||
VAR_SPELLMECHANIC_COUNT = 0x00C0D7C8, // max mechanic ID
|
||||
OFF_SPELLMECHANIC_NAME = 0x04, // char *name[9], locale-indexed
|
||||
|
||||
// Spell.dbc School field — 0-based integer at record +0x04.
|
||||
// Verified empirically against Fireball (133) → School=2 (Fire)
|
||||
// and Frostbolt (116) → School=4 (Frost) on Octo 1.12.1. Vanilla
|
||||
@@ -2406,6 +2453,35 @@ enum Offsets {
|
||||
VAR_SPELL_CAST_TIMES_RECORDS = 0x00C0D878, // SpellCastTimes.dbc
|
||||
VAR_SPELL_CAST_TIMES_COUNT = 0x00C0D87C,
|
||||
|
||||
// SpellRadius.dbc — AOE radius records, indexed by a spell's
|
||||
// EffectRadiusIndex (see OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX). Record
|
||||
// shape: { id@+0, radius@+4 (f32), radiusPerLevel@+8 (f32), ... }.
|
||||
// Verified by decompiling FUN_006e6350 (the engine's effective-radius
|
||||
// helper), which reads `radiusRec[+4]` as the base radius and
|
||||
// `radiusRec[+8] * casterLevel` as the per-level term. Read by
|
||||
// GetSpellRadius (base radius only — no level scaling).
|
||||
VAR_SPELL_RADIUS_RECORDS = 0x00C0D7B0,
|
||||
VAR_SPELL_RADIUS_COUNT = 0x00C0D7B4,
|
||||
OFF_SPELL_RADIUS_VALUE = 0x04, // base radius, f32
|
||||
|
||||
// SpellMod (talent / item) accumulators for the LOCAL PLAYER. Two
|
||||
// parallel tables of int32, indexed [familyFlagBit][op]: flat adds at
|
||||
// VAR_SPELLMOD_FLAT_TABLE, percent adds at VAR_SPELLMOD_PCT_TABLE.
|
||||
// Per-bit stride is 0x74 (29 ops); the op is the dword index (op*4
|
||||
// within a slot). A spell's per-effect value is modified by summing,
|
||||
// over the bits set in its SpellFamilyFlags, flat[bit][op] and
|
||||
// pct[bit][op], then `value = (value + flat) * (100 + pct) * 0.01`.
|
||||
// Gated on the spell's SpellFamilyName matching the player's class
|
||||
// family. Decompiled from FUN_006e6b30 / FUN_006e6bf0 (the engine's
|
||||
// ApplySpellMod), which the radius helper FUN_006e6350 invokes with
|
||||
// op 6. These are single global instances = the local player's mods
|
||||
// (vanilla only tracks spell mods for the local player).
|
||||
VAR_SPELLMOD_FLAT_TABLE = 0x00CEAD60,
|
||||
VAR_SPELLMOD_PCT_TABLE = 0x00CECB30,
|
||||
SPELLMOD_SLOT_STRIDE = 0x74, // bytes per familyFlagBit row
|
||||
SPELLMOD_SLOT_COUNT = 64, // SpellFamilyFlags is 64-bit
|
||||
SPELLMOD_OP_RADIUS = 6, // op index for spell radius
|
||||
|
||||
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
|
||||
// names like "Magic", "Curse", "Disease", "Poison". Used by
|
||||
// `Script_UnitDebuff` to fill its third return value. Standard
|
||||
@@ -3710,6 +3786,31 @@ enum Offsets {
|
||||
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
|
||||
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
|
||||
SPELL_RECORD_EFFECT_COUNT = 3,
|
||||
|
||||
// Per-effect EffectRadiusIndex[3] → SpellRadius.dbc. Verified by
|
||||
// FUN_006e6350, which reads spellRec[+0x160] (effect 0) and
|
||||
// spellRec[+0x164] (effect 1) as radius indices. 0 = effect has no
|
||||
// radius. Read by GetSpellRadius.
|
||||
OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX = 0x160, // int32[3]
|
||||
|
||||
// SpellFamily classification, used by the SpellMod system (see
|
||||
// VAR_SPELLMOD_*). Verified from FUN_006e6b30: SpellFamilyName at
|
||||
// +0x280 (gates whether the player's class mods apply), 64-bit
|
||||
// SpellFamilyFlags at +0x284 (selects which familyFlagBit rows to
|
||||
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
|
||||
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
|
||||
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
|
||||
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
|
||||
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
|
||||
|
||||
// Spell.dbc Mechanic field — the spell-level SpellMechanic ID (→
|
||||
// SpellMechanic.dbc), 0 = none. Field 5 of the record
|
||||
// (ID, School, Category, castUI, Dispel, Mechanic), which lands at
|
||||
// +0x14 given Attributes (field 6) is verified at +0x18. Cross-
|
||||
// confirmed by FUN_006e9ca0 (the mechanic-immunity check), which
|
||||
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
|
||||
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
|
||||
OFF_SPELL_RECORD_MECHANIC = 0x14,
|
||||
SPELL_AURA_MOD_SHAPESHIFT = 36,
|
||||
SPELL_AURA_MOUNTED = 78,
|
||||
|
||||
|
||||
@@ -12,4 +12,9 @@
|
||||
<Binding name="TARGETFOCUS">
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
<Binding name="AOELOOT">
|
||||
if not C_Loot.IsScanInProgress() then
|
||||
C_Loot.ScanNearbyLoot()
|
||||
end
|
||||
</Binding>
|
||||
</Bindings>
|
||||
|
||||
+163
-56
@@ -147,41 +147,54 @@ static int __fastcall Script_IsItemDataCached(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pending explicit-request tracking. We deliberately do NOT eagerly
|
||||
// create cache entries when an addon calls `C_Item.RequestLoadItemData`
|
||||
// at addon-load / PLAYER_ENTERING_WORLD time — empirically (pfQuest),
|
||||
// pre-creating the entry blocks the engine's natural prefetch path
|
||||
// from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The entry stays in a
|
||||
// permanently-pending state because nothing ever sends the query.
|
||||
// Pending-request tracking. We deliberately do NOT eagerly create cache
|
||||
// entries during the startup window — empirically (pfQuest), pre-creating
|
||||
// the entry at addon-load / PLAYER_ENTERING_WORLD time, before the
|
||||
// engine's network/opcode dispatch has settled, blocks the engine's
|
||||
// natural prefetch from issuing a fresh `SMSG_ITEM_QUERY_SINGLE`. The
|
||||
// entry stays permanently pending because nothing ever sends the query.
|
||||
// This applies to BOTH paths into the cache:
|
||||
// - explicit `C_Item.RequestLoadItemData(ByID)`
|
||||
// - implicit `WarmCache` (the `GetItemInfo` hook + every `GetItem*`
|
||||
// getter's cache-miss warmup) — see `WarmCache`.
|
||||
//
|
||||
// Instead we hook the engine's item-cache response handler at
|
||||
// `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that fills cache
|
||||
// entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`). After the engine
|
||||
// finishes filling any entries and walking its own callback lists,
|
||||
// we sweep our pending set and fire `ITEM_DATA_LOAD_RESULT(itemID, 1)`
|
||||
// for any tracked items that now resolve. The engine's natural
|
||||
// prefetch (for player-inventory items) takes care of the actual
|
||||
// `SMSG_ITEM_QUERY_SINGLE` for us.
|
||||
// Both route through `Track`, which records the item without touching
|
||||
// the cache while `g_settled` is false. We hook the engine's item-cache
|
||||
// response handler at `FUN_ITEMSTATS_CACHE_RESPONSE` (the function that
|
||||
// fills cache entries on `SMSG_ITEM_QUERY_SINGLE_RESPONSE`); after the
|
||||
// engine finishes filling entries and walking its own callback lists, we
|
||||
// sweep our pending set and fire the per-item completion event for any
|
||||
// tracked item that now resolves. The engine's natural prefetch (for
|
||||
// player-inventory items) takes care of the actual query for us.
|
||||
//
|
||||
// For items the engine *doesn't* naturally prefetch (e.g. quest
|
||||
// reward IDs the player has never owned), we escalate after
|
||||
// `kEscalateTicks` by issuing our own `CacheFetch` with a no-op
|
||||
// For items the engine *doesn't* naturally prefetch (e.g. quest reward
|
||||
// IDs the player has never owned), `OnWorldTick` escalates once the
|
||||
// startup gate has settled by issuing our own `CacheFetch` with a no-op
|
||||
// callback — that creates the entry and queues the query, and the
|
||||
// response handler hook still fires our event when the data lands.
|
||||
// `kMaxWaitTicks` is the hard timeout that gives up and fires
|
||||
// `ITEM_DATA_LOAD_RESULT(itemID, nil)`.
|
||||
// `kMaxWaitTicks` is the hard timeout that gives up and fires the
|
||||
// completion event with failure.
|
||||
struct Pending {
|
||||
uint32_t itemID;
|
||||
int ticksWaiting;
|
||||
bool requestIssued;
|
||||
bool implicit; // true: fire GET_ITEM_INFO_RECEIVED; false: ITEM_DATA_LOAD_RESULT
|
||||
bool failed; // set by the escalation callback on a server "no such item" reply
|
||||
};
|
||||
|
||||
constexpr int kMaxPending = 64;
|
||||
constexpr int kEscalateTicks = 60;
|
||||
constexpr int kMaxPending = 512;
|
||||
constexpr int kMaxWaitTicks = 1200;
|
||||
// World ticks after which we assume the engine's startup
|
||||
// network/opcode-dispatch state has settled and eager queries are safe.
|
||||
// Also latched the moment the engine's own response handler fires (a
|
||||
// response proves dispatch is live) — see `ItemStatsCacheResponse_h`.
|
||||
// One-way latch; see the relog note in `WarmCache`.
|
||||
constexpr int kSettleTicks = 60;
|
||||
|
||||
static Pending g_pending[kMaxPending];
|
||||
static int g_pendingCount = 0;
|
||||
static bool g_settled = false;
|
||||
static int g_worldTicks = 0;
|
||||
|
||||
static int FindPending(uint32_t itemID) {
|
||||
for (int i = 0; i < g_pendingCount; ++i) {
|
||||
@@ -197,27 +210,92 @@ static void RemovePendingAt(int idx) {
|
||||
g_pending[idx] = g_pending[g_pendingCount];
|
||||
}
|
||||
|
||||
// No-op callback for the escalation path — we just need a non-null
|
||||
// callback pointer so the engine's `_GetRecord` queues the query.
|
||||
// The response handler hook will detect the fill and fire the event;
|
||||
// this callback doesn't need to do anything.
|
||||
static void __stdcall ItemLoadCallback_NoOp(void * /*userData*/, int /*success*/) {}
|
||||
// Callback for the escalation path. The engine queues the query when we
|
||||
// pass a non-null callback to `_GetRecord`; it then invokes this with the
|
||||
// itemID smuggled through `userData`. On a successful fill the engine has
|
||||
// already set the entry's loaded flag, so the response-handler sweep
|
||||
// reports success — we do nothing here. On a server failure (high-bit
|
||||
// itemID in `SMSG_ITEM_QUERY_SINGLE_RESPONSE`) the engine does NOT set the
|
||||
// loaded flag (verified in `FUN_0055BDB0`), so the sweep can't detect it;
|
||||
// we mark the pending entry failed so the sweep / tick fires failure
|
||||
// immediately instead of waiting out `kMaxWaitTicks`.
|
||||
static void __stdcall ItemLoadCallback_Escalated(void *userData, int success) {
|
||||
if (success)
|
||||
return;
|
||||
const auto itemID = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(userData));
|
||||
const int idx = FindPending(itemID);
|
||||
if (idx >= 0)
|
||||
g_pending[idx].failed = true;
|
||||
}
|
||||
|
||||
static void RequestAndMaybeNotify(uint32_t itemID) {
|
||||
// Already loaded — fire success synchronously.
|
||||
static void FireByTag(uint32_t itemID, bool success, bool implicit) {
|
||||
if (implicit)
|
||||
FireGetItemInfoReceived(static_cast<int>(itemID), success);
|
||||
else
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), success);
|
||||
}
|
||||
|
||||
// Records an item we owe a completion event for, WITHOUT eagerly
|
||||
// creating the cache entry. During the startup window (before
|
||||
// `g_settled`) this is the only safe way to "request" an item: calling
|
||||
// `CacheFetch` with a callback now would create an orphan entry the
|
||||
// engine's prefetch then skips, leaving it permanently pending (see the
|
||||
// block comment above). `OnWorldTick` issues the actual query once
|
||||
// settled, and the response-handler sweep fires the event — tagged
|
||||
// implicit (GET_ITEM_INFO_RECEIVED) or explicit (ITEM_DATA_LOAD_RESULT).
|
||||
// Returns false only when the request was dropped because the pending
|
||||
// set is full (the caller is still tracking nothing). All other outcomes
|
||||
// — already-loaded, already-tracking, newly-tracked — return true.
|
||||
static bool Track(uint32_t itemID, bool implicit) {
|
||||
if (CacheFetch(itemID, nullptr) != nullptr) {
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), true);
|
||||
return;
|
||||
// Already loaded. Explicit RequestLoadItemData fires its
|
||||
// completion event synchronously (modern API contract). Implicit
|
||||
// warmup mirrors the engine's already-loaded shortcut, which does
|
||||
// not re-invoke the callback — so a cache hit fires nothing.
|
||||
if (!implicit)
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), true);
|
||||
return true;
|
||||
}
|
||||
const int idx = FindPending(itemID);
|
||||
if (idx >= 0) {
|
||||
// Already tracking. An explicit request supersedes a pending
|
||||
// implicit one so the explicit caller's ITEM_DATA_LOAD_RESULT is
|
||||
// honored (the two events are mutually exclusive per fill).
|
||||
if (!implicit)
|
||||
g_pending[idx].implicit = false;
|
||||
return true;
|
||||
}
|
||||
// Already tracking — second request just dedups.
|
||||
if (FindPending(itemID) >= 0)
|
||||
return;
|
||||
if (g_pendingCount >= kMaxPending)
|
||||
return;
|
||||
return false;
|
||||
g_pending[g_pendingCount].itemID = itemID;
|
||||
g_pending[g_pendingCount].ticksWaiting = 0;
|
||||
g_pending[g_pendingCount].requestIssued = false;
|
||||
g_pending[g_pendingCount].implicit = implicit;
|
||||
g_pending[g_pendingCount].failed = false;
|
||||
++g_pendingCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool RequestAndMaybeNotify(uint32_t itemID) { return Track(itemID, /*implicit=*/false); }
|
||||
|
||||
// Fires + removes a pending entry if it has resolved either way: loaded
|
||||
// (engine set the flag) → success, or `failed` (escalation callback saw a
|
||||
// server rejection) → failure. Returns true if it fired and removed the
|
||||
// entry at `i`, so callers must NOT advance their index when it does.
|
||||
static bool TryCompletePending(int i) {
|
||||
const uint32_t itemID = g_pending[i].itemID;
|
||||
const bool implicit = g_pending[i].implicit;
|
||||
if (CacheFetch(itemID, nullptr) != nullptr) {
|
||||
RemovePendingAt(i);
|
||||
FireByTag(itemID, true, implicit);
|
||||
return true;
|
||||
}
|
||||
if (g_pending[i].failed) {
|
||||
RemovePendingAt(i);
|
||||
FireByTag(itemID, false, implicit);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Response handler hook. After the engine fills cache entries from
|
||||
@@ -237,16 +315,17 @@ static void __fastcall ItemStatsCacheResponse_h(void *cache, void * /*edx_unused
|
||||
void *packetReader, int flag) {
|
||||
using Trampoline_t = void(__fastcall *)(void *cache, void *edx, void *packetReader, int flag);
|
||||
reinterpret_cast<Trampoline_t>(ItemStatsCacheResponse_o)(cache, nullptr, packetReader, flag);
|
||||
// The engine just processed an item-query response, which proves its
|
||||
// network/opcode dispatch is live — eager queries are safe from here
|
||||
// on. Latch the startup gate so `WarmCache` stops deferring.
|
||||
g_settled = true;
|
||||
// Sweep pending — the engine just finished filling any matching
|
||||
// entries. Peek (no callback) returns non-null iff the entry's
|
||||
// loaded flag is set.
|
||||
// entries (and, on the failure branch, invoked our escalation
|
||||
// callback which marked any rejected items `failed`). Fire whatever
|
||||
// resolved this response, success or failure.
|
||||
for (int i = 0; i < g_pendingCount;) {
|
||||
const uint32_t itemID = g_pending[i].itemID;
|
||||
if (CacheFetch(itemID, nullptr) != nullptr) {
|
||||
RemovePendingAt(i);
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), true);
|
||||
if (TryCompletePending(i))
|
||||
continue;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
@@ -258,23 +337,34 @@ static const Game::HookAutoRegister _hookCacheResponse{
|
||||
|
||||
// Escalation + timeout. The hook covers the happy path (engine fills
|
||||
// cache → we fire). This tick is only responsible for:
|
||||
// 1. Issuing a query for items the engine doesn't naturally prefetch
|
||||
// (e.g. quest rewards) — wait `kEscalateTicks` so we don't race
|
||||
// the engine's startup-time state machine, then call CacheFetch
|
||||
// with a no-op callback. The engine sends the SMSG, the response
|
||||
// handler hook fires the event when the response lands.
|
||||
// 2. Giving up after `kMaxWaitTicks` for items that never resolve
|
||||
// (bad itemID, server doesn't respond, etc.) — fire failure.
|
||||
// 1. Latching the startup gate via a tick-count fallback, in case no
|
||||
// engine response arrives early enough to latch it first.
|
||||
// 2. Issuing a query for items the engine doesn't naturally prefetch
|
||||
// (e.g. quest rewards) — but only once `g_settled`, so we don't
|
||||
// race the engine's startup-time state machine. The engine sends
|
||||
// the SMSG, the response handler hook fires the event when the
|
||||
// response lands.
|
||||
// 3. Firing failure immediately for items the server rejected (the
|
||||
// escalation callback marked them `failed`), and as a last resort
|
||||
// giving up after `kMaxWaitTicks` for items that never resolve at
|
||||
// all (lost query, server silent) — fire failure.
|
||||
static void OnWorldTick() {
|
||||
if (!g_settled && ++g_worldTicks >= kSettleTicks)
|
||||
g_settled = true;
|
||||
for (int i = 0; i < g_pendingCount;) {
|
||||
if (!g_pending[i].requestIssued && g_pending[i].ticksWaiting >= kEscalateTicks) {
|
||||
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_NoOp);
|
||||
if (!g_pending[i].requestIssued && g_settled) {
|
||||
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_Escalated);
|
||||
g_pending[i].requestIssued = true;
|
||||
}
|
||||
// A server rejection (marked by the escalation callback) resolves
|
||||
// the item as failure without waiting out the timeout.
|
||||
if (TryCompletePending(i))
|
||||
continue;
|
||||
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
|
||||
const uint32_t itemID = g_pending[i].itemID;
|
||||
const bool implicit = g_pending[i].implicit;
|
||||
RemovePendingAt(i);
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), false);
|
||||
FireByTag(itemID, false, implicit);
|
||||
continue;
|
||||
}
|
||||
++g_pending[i].ticksWaiting;
|
||||
@@ -291,7 +381,24 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
// `ITEM_DATA_LOAD_RESULT`), matching modern semantics for ambient
|
||||
// cache fills.
|
||||
void WarmCache(uint32_t itemID) {
|
||||
CacheFetch(itemID, &ItemLoadCallback_Implicit);
|
||||
// Post-settle (steady state): query immediately for lowest latency.
|
||||
// The engine invokes our implicit callback when the data lands.
|
||||
if (g_settled) {
|
||||
CacheFetch(itemID, &ItemLoadCallback_Implicit);
|
||||
return;
|
||||
}
|
||||
// Startup window: do NOT eagerly create the cache entry — that
|
||||
// orphans it (the engine's prefetch skips already-existing entries,
|
||||
// and our SMSG goes into a hole while dispatch isn't ready), leaving
|
||||
// the item permanently nil for the session. This is the exact race
|
||||
// that broke pfQuest/Questie scanning at PLAYER_ENTERING_WORLD. Defer
|
||||
// through the pending set instead; OnWorldTick issues the query once
|
||||
// settled and the response-handler sweep fires GET_ITEM_INFO_RECEIVED.
|
||||
//
|
||||
// NOTE: g_settled is a one-way latch per process. The race this
|
||||
// guards is the initial-login cold-WDB window. On relog the WDB is
|
||||
// already warm so cold-fetch pressure is minimal; we don't re-arm it.
|
||||
Track(itemID, /*implicit=*/true);
|
||||
}
|
||||
|
||||
// Hook for `Script_GetItemInfo`. Pre-warms the cache from arg 1, then
|
||||
@@ -314,8 +421,8 @@ static int __fastcall Script_RequestLoadItemDataByID(void *L) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
|
||||
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -329,8 +436,8 @@ static int __fastcall Script_RequestLoadItemData(void *L) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
const bool accepted = RequestAndMaybeNotify(static_cast<uint32_t>(itemID));
|
||||
Game::Lua::PushBoolean(L, accepted ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Cast / channel info — `UnitCastingInfo`, `CastingInfo`,
|
||||
// `UnitChannelInfo`, `ChannelInfo`.
|
||||
//
|
||||
// Vanilla 1.12 stores NO cast start/end times anywhere readable (unlike
|
||||
// 3.3.5+, which keeps them per-unit on the CGUnit). The cast bar is
|
||||
// Lua-driven off `SPELLCAST_START(duration)` + `GetTime()`. So we
|
||||
// self-track the LOCAL PLAYER's cast/channel on `WorldTick`:
|
||||
// - cast: `VAR_CURRENT_CAST_SPELL` (the player cast-bar spellID
|
||||
// global). On a 0->nonzero transition we stamp startMs = now and
|
||||
// endMs = now + base cast time (SpellCastTimes.dbc). Instant casts
|
||||
// (cast time 0) and channels are filtered out here — a channel has
|
||||
// no cast time and surfaces through the channel field instead.
|
||||
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
|
||||
// (descriptor +0x228). endMs = now + base channel duration
|
||||
// (SpellDuration.dbc).
|
||||
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
|
||||
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
|
||||
// comparable to `GetTime()*1000` in addon progress math.
|
||||
//
|
||||
// Scope (vanilla ceiling, confirmed against the binary — see TODO #68):
|
||||
// - Player casts/channels: full timing.
|
||||
// - Other units' CHANNELS: spellID/name/texture from the broadcast
|
||||
// +0x228 field, but no times (we don't track per-remote-unit start).
|
||||
// - Other units' regular CASTS: nil. Vanilla doesn't store them — the
|
||||
// casting animation is driven by transient spell-visual objects, not
|
||||
// a queryable cast record.
|
||||
//
|
||||
// Modern-signature fields vanilla can't fill are structurally-correct
|
||||
// placeholders: castID / castBarID = nil, notInterruptible = false,
|
||||
// isTradeskill = false (no readable flag in 1.12), delayTimeMs = 0.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::Cast {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using TickMs_t = uint32_t(__fastcall *)();
|
||||
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
|
||||
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
|
||||
|
||||
// Spell.dbc record field offsets (mirrors Spell::Info's locals).
|
||||
constexpr int OFF_NAME = 0x1E0; // localized name[9]
|
||||
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
|
||||
constexpr int OFF_ATTRIBUTES = 0x18; // u32 Attributes flags
|
||||
constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
|
||||
|
||||
// SPELL_ATTR_TRADESPELL — set on profession recipe casts (enchant,
|
||||
// cooking with a cast, etc.). The version-stable `isTradeskill` source:
|
||||
// 3.3.5's Script_UnitCastingInfo reads bit 0x20 of Attributes (at +0x10
|
||||
// there, +0x18 here — the bit value is the same across builds).
|
||||
constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
|
||||
|
||||
struct TrackedSpell {
|
||||
int spellID; // 0 = not casting / channeling
|
||||
int startMs;
|
||||
int endMs;
|
||||
};
|
||||
|
||||
TrackedSpell g_cast{0, 0, 0};
|
||||
TrackedSpell g_channel{0, 0, 0};
|
||||
int g_lastCast = 0;
|
||||
int g_lastChannel = 0;
|
||||
|
||||
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
|
||||
|
||||
void *Resolve(const char *token) {
|
||||
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
|
||||
}
|
||||
|
||||
// Effective cast time (ms) for the local player, 0 if instant. Uses the
|
||||
// engine's own cast-time helper (base + level scaling + cast-time SpellMod
|
||||
// op 10 + the caster's haste/cast-speed multiplier), so our end time
|
||||
// matches the engine's cast bar exactly — including talents like a Mage's
|
||||
// faster casts. Unit arg 0 = local player; flag 0 clamps negatives to 0.
|
||||
int CastTimeMs(int spellID) {
|
||||
return static_cast<int>(
|
||||
reinterpret_cast<GetCastTime_t>(Offsets::FUN_GET_CAST_TIME)(spellID, 0, 0));
|
||||
}
|
||||
|
||||
// Effective channel duration (ms) for the local player, 0 if none. Uses
|
||||
// the engine's own duration helper (SpellDuration base + level scaling +
|
||||
// duration SpellMod op 1), mirroring how the cast time is sourced, so the
|
||||
// channel end time matches the engine. Unit arg 0 = local player.
|
||||
int ChannelDurationMs(int spellID) {
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const int ms = reinterpret_cast<GetDuration_t>(Offsets::FUN_GET_SPELL_DURATION)(rec, 0, 0);
|
||||
return ms > 0 ? ms : 0; // negative = "infinite"; channels are finite
|
||||
}
|
||||
|
||||
// The local player's currently-channeled spellID (broadcast +0x228), or 0.
|
||||
int PlayerChannelSpell() {
|
||||
void *p = Resolve("player");
|
||||
if (p == nullptr)
|
||||
return 0;
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(p) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
|
||||
}
|
||||
|
||||
const char *SpellName(const uint8_t *rec) {
|
||||
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
return *reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
|
||||
}
|
||||
|
||||
bool IsTradeskill(const uint8_t *rec) {
|
||||
return (*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES) & SPELL_ATTR_TRADESPELL) != 0;
|
||||
}
|
||||
|
||||
// Spell icon texture path, or "" if there's no icon record. SpellIcon.dbc
|
||||
// already stores the full "Interface\Icons\..." path (unlike item icons,
|
||||
// which are bare filenames) — so it's used verbatim, no prefix.
|
||||
const char *SpellIconPath(const uint8_t *rec) {
|
||||
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
|
||||
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
|
||||
Offsets::VAR_SPELL_ICON_COUNT,
|
||||
static_cast<uint32_t>(iconID));
|
||||
if (iconRec == nullptr)
|
||||
return "";
|
||||
const char *path = *reinterpret_cast<const char *const *>(iconRec + OFF_SUBRECORD_VALUE);
|
||||
return (path != nullptr) ? path : "";
|
||||
}
|
||||
|
||||
// Pushes UnitCastingInfo's 11-tuple from a tracked cast, or nothing (nil)
|
||||
// if there's no active cast.
|
||||
int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
if (c.spellID == 0)
|
||||
return 0;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(c.spellID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const char *name = SpellName(rec);
|
||||
Game::Lua::PushString(L, name); // 1 name
|
||||
Game::Lua::PushString(L, name); // 2 displayName
|
||||
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(c.startMs)); // 4 startTimeMs
|
||||
Game::Lua::PushNumber(L, static_cast<double>(c.endMs)); // 5 endTimeMs
|
||||
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
|
||||
Game::Lua::PushNil(L); // 7 castID
|
||||
Game::Lua::PushBool(L, false); // 8 notInterruptible
|
||||
Game::Lua::PushNumber(L, static_cast<double>(c.spellID)); // 9 castingSpellID
|
||||
Game::Lua::PushNil(L); // 10 castBarID
|
||||
Game::Lua::PushNumber(L, 0); // 11 delayTimeMs
|
||||
return 11;
|
||||
}
|
||||
|
||||
// Pushes UnitChannelInfo's 8-tuple. `haveTimes` is false for remote units
|
||||
// (we only track the local player's channel start), pushing nil times.
|
||||
int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes) {
|
||||
if (spellID == 0)
|
||||
return 0;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const char *name = SpellName(rec);
|
||||
Game::Lua::PushString(L, name); // 1 name
|
||||
Game::Lua::PushString(L, name); // 2 text / displayName
|
||||
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
|
||||
if (haveTimes) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(startMs)); // 4 startTimeMs
|
||||
Game::Lua::PushNumber(L, static_cast<double>(endMs)); // 5 endTimeMs
|
||||
} else {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
}
|
||||
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
|
||||
Game::Lua::PushBool(L, false); // 7 notInterruptible
|
||||
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 8 spellID
|
||||
return 8;
|
||||
}
|
||||
|
||||
// True if the unit at stack index 1 resolves to the local player.
|
||||
bool ArgIsPlayer(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return false;
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
if (token == nullptr)
|
||||
return false;
|
||||
void *u = Resolve(token);
|
||||
return u != nullptr && u == Resolve("player");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Per-frame: detect the player's cast/channel start, stamp times.
|
||||
void OnWorldTick() {
|
||||
const int now = NowMs();
|
||||
|
||||
const int cast = *reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL);
|
||||
if (cast != g_lastCast) {
|
||||
const int dur = (cast != 0) ? CastTimeMs(cast) : 0;
|
||||
if (cast != 0 && dur > 0) {
|
||||
g_cast = {cast, now, now + dur};
|
||||
} else {
|
||||
g_cast.spellID = 0; // 0, instant, or channel — no cast bar
|
||||
}
|
||||
g_lastCast = cast;
|
||||
}
|
||||
|
||||
const int chan = PlayerChannelSpell();
|
||||
if (chan != g_lastChannel) {
|
||||
if (chan != 0) {
|
||||
g_channel = {chan, now, now + ChannelDurationMs(chan)};
|
||||
} else {
|
||||
g_channel.spellID = 0;
|
||||
}
|
||||
g_lastChannel = chan;
|
||||
}
|
||||
}
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// `CastingInfo()` — local player's cast, no token lookup.
|
||||
static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cast); }
|
||||
|
||||
// `UnitCastingInfo(unit)` — only the local player has regular-cast data
|
||||
// in vanilla; any other unit returns nil.
|
||||
static int __fastcall Script_UnitCastingInfo(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitCastingInfo(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
if (ArgIsPlayer(L))
|
||||
return PushCastInfo(L, g_cast);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `ChannelInfo()` — local player's channel, no token lookup.
|
||||
static int __fastcall Script_ChannelInfo(void *L) {
|
||||
return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true);
|
||||
}
|
||||
|
||||
// `UnitChannelInfo(unit)` — full timing for the player; spellID/name/
|
||||
// texture (no times) for other units via the broadcast +0x228 field.
|
||||
static int __fastcall Script_UnitChannelInfo(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitChannelInfo(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
void *u = (token != nullptr) ? Resolve(token) : nullptr;
|
||||
if (u == nullptr)
|
||||
return 0;
|
||||
if (u == Resolve("player"))
|
||||
return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true);
|
||||
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(u) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
const int spellID = *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
|
||||
return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false);
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitCastingInfo", &Script_UnitCastingInfo);
|
||||
Game::Lua::RegisterGlobalFunction("CastingInfo", &Script_CastingInfo);
|
||||
Game::Lua::RegisterGlobalFunction("UnitChannelInfo", &Script_UnitChannelInfo);
|
||||
Game::Lua::RegisterGlobalFunction("ChannelInfo", &Script_ChannelInfo);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::Cast
|
||||
@@ -0,0 +1,90 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellMechanicByID(spellID)` -> (mechanicID, localizedName)
|
||||
//
|
||||
// Reads the spell-level Mechanic field straight out of Spell.dbc, so it
|
||||
// covers *every* spell the client knows — not just the player's
|
||||
// spellbook — with no caching or network round-trip (Spell.dbc is fully
|
||||
// resident from boot). This replaces hand-maintained spellID -> mechanic
|
||||
// tables (e.g. SuperCleveRoidMacros' `CCSpellMechanics`) that addons keep
|
||||
// solely because vanilla exposes no Lua reader for the field.
|
||||
//
|
||||
// Returns:
|
||||
// - nothing (nil) for an invalid / out-of-range spell ID
|
||||
// - (0, nil) for a known spell that carries no mechanic
|
||||
// - (mechanicID, name) otherwise. `name` is the enUS SpellMechanic.dbc
|
||||
// name (always English, locale-independent — it's a stable token
|
||||
// consumers match against). nil only if the mechanic has no record /
|
||||
// no English name; the numeric ID is still returned in that case.
|
||||
//
|
||||
// The mechanic numbering is WoW's standard SpellMechanic enum
|
||||
// (1 = Charm, 5 = Fear, 7 = Root, 12 = Stun, 17 = Polymorph, ...);
|
||||
// addons map those IDs to their own names as needed.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::Mechanic {
|
||||
|
||||
// SpellMechanic.dbc name — always the enUS (index 0) column, regardless
|
||||
// of client locale. The mechanic name is a stable token consumers key
|
||||
// off (`"rooted"`, `"stunned"`, …), so it must be identical on every
|
||||
// client; a localized value would break cross-locale string matching.
|
||||
// Vanilla only populates the enUS column of this table anyway. Returns
|
||||
// nullptr if the record is missing or the English slot is empty.
|
||||
static const char *MechanicName(uint32_t mechanicID) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELLMECHANIC_RECORDS,
|
||||
Offsets::VAR_SPELLMECHANIC_COUNT, mechanicID);
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLMECHANIC_NAME);
|
||||
const char *s = names[0]; // enUS
|
||||
return (s != nullptr && s[0] != '\0') ? s : nullptr;
|
||||
}
|
||||
|
||||
static int __fastcall Script_GetSpellMechanicByID(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return 0; // nil for invalid / out-of-range spell IDs
|
||||
|
||||
const uint32_t mechanicID =
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_MECHANIC);
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(mechanicID));
|
||||
|
||||
if (mechanicID == 0) {
|
||||
Game::Lua::PushNil(L); // known spell, no mechanic
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Locale name with enUS fallback (nullptr → nil if even English is
|
||||
// empty); the caller still gets the numeric ID either way.
|
||||
Game::Lua::PushString(L, MechanicName(mechanicID)); // pushstring(NULL) → pushnil
|
||||
return 2;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellMechanicByID",
|
||||
&Script_GetSpellMechanicByID);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::Mechanic
|
||||
@@ -0,0 +1,88 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "spell/Mod.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
namespace Spell::Mod {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// The local player's class SpellFamilyName, or 0 if it can't be resolved
|
||||
// (e.g. at the login screen). Gates the SpellMod family match — only
|
||||
// spells of the player's own class family receive the player's mods, so
|
||||
// a same-flag-bit spell of another family isn't falsely modified.
|
||||
uint32_t PlayerSpellFamily() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve("player"));
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
const uint8_t *cls = DBC::Record(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT, classByte);
|
||||
if (cls == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(cls + Offsets::OFF_CHRCLASSES_SPELL_FAMILY);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
float Apply(const uint8_t *spellRecord, int op, float base) {
|
||||
if (spellRecord == nullptr)
|
||||
return base;
|
||||
|
||||
const uint32_t familyName = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
|
||||
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
|
||||
if (familyName == 0 || (attrEx2 & Offsets::SPELL_ATTR_EX2_NO_SPELL_MODS) != 0)
|
||||
return base;
|
||||
if (familyName != PlayerSpellFamily())
|
||||
return base;
|
||||
|
||||
const uint64_t familyFlags = *reinterpret_cast<const uint64_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
|
||||
if (familyFlags == 0)
|
||||
return base;
|
||||
|
||||
const auto *flatTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_FLAT_TABLE);
|
||||
const auto *pctTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_PCT_TABLE);
|
||||
const int opByteOffset = op * 4;
|
||||
|
||||
int flat = 0;
|
||||
int pct = 0;
|
||||
for (int slot = 0; slot < Offsets::SPELLMOD_SLOT_COUNT; ++slot) {
|
||||
if (((familyFlags >> slot) & 1ull) == 0)
|
||||
continue;
|
||||
const int byteOff = slot * Offsets::SPELLMOD_SLOT_STRIDE + opByteOffset;
|
||||
flat += *reinterpret_cast<const int *>(flatTable + byteOff);
|
||||
pct += *reinterpret_cast<const int *>(pctTable + byteOff);
|
||||
}
|
||||
if (flat == 0 && pct == 0)
|
||||
return base;
|
||||
|
||||
int pctTotal = pct + 100;
|
||||
if (pctTotal < 0)
|
||||
pctTotal = 0;
|
||||
return (base + static_cast<float>(flat)) * static_cast<float>(pctTotal) * 0.01f;
|
||||
}
|
||||
|
||||
} // namespace Spell::Mod
|
||||
@@ -0,0 +1,41 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// `Spell::Mod` — the engine's SpellMod (talent / item) system, for the
|
||||
// LOCAL PLAYER. Replicates `FUN_006e6b30` / `FUN_006e6bf0`: the player's
|
||||
// flat and percent modifiers are accumulated into two tables indexed
|
||||
// `[familyFlagBit][op]` (see `VAR_SPELLMOD_*` in Offsets.h). A spell's
|
||||
// value for a given op is modified by summing, over the bits set in the
|
||||
// spell's SpellFamilyFlags, the flat and percent entries, then applying
|
||||
// `value = (value + flat) * (100 + pct) * 0.01`.
|
||||
//
|
||||
// This underlies many spell properties — radius, cast time, mana cost,
|
||||
// duration, range, cooldown — each a distinct `op` (e.g.
|
||||
// `Offsets::SPELLMOD_OP_RADIUS`). The op numbering is the engine's
|
||||
// internal one; verify each against the binary before relying on it.
|
||||
//
|
||||
// Inherently local-player-only: the client tracks spell mods for the
|
||||
// player alone, so there's no way to know another caster's talents.
|
||||
namespace Spell::Mod {
|
||||
|
||||
// Returns `base` with the local player's modifiers for `op` applied to
|
||||
// `spellRecord` (a `Spell.dbc` record pointer). Returns `base` unchanged
|
||||
// when no modifier matches — wrong spell family, mods disabled on the
|
||||
// spell, the player can't be resolved, or no relevant talent/item.
|
||||
float Apply(const uint8_t *spellRecord, int op, float base);
|
||||
|
||||
} // namespace Spell::Mod
|
||||
@@ -0,0 +1,98 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellPowerCost(spellIdentifier)` -> SpellPowerCostInfo[]
|
||||
//
|
||||
// Returns an array of power-cost tables, or nil if the spell isn't found
|
||||
// or has no resource cost. Vanilla spells have exactly one power cost, so
|
||||
// the array always holds at most one entry.
|
||||
//
|
||||
// `cost` is the **effective** cost for the local player — base + level
|
||||
// scaling + ManaCostPercent-of-resource + the descriptor power-cost
|
||||
// modifiers + the cost SpellMod (op 14) — via the engine's own cost
|
||||
// helper `FUN_006e31b0` (the same value the engine charges). So talents
|
||||
// like reduced-cost spells are reflected. Per-entry fields:
|
||||
//
|
||||
// type Enum.PowerType (0 mana, 1 rage, 2 focus, 3 energy,
|
||||
// 4 happiness) — the spell's PowerType (+0x7C)
|
||||
// name power token ("MANA", "RAGE", …)
|
||||
// cost effective cost (see above)
|
||||
// minCost == cost; vanilla has no "optional" cost component
|
||||
// costPercent ManaCostPercent (+0x270) — the spell's %-of-base-
|
||||
// resource cost, or 0 for a flat cost
|
||||
// costPerSec 0 — vanilla doesn't expose a per-second channel cost
|
||||
// in these terms
|
||||
// requiredAuraID 0 — vanilla has no form/aura-conditional costs here
|
||||
// hasRequiredAura false
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "unit/Power.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::PowerCost {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
|
||||
|
||||
constexpr int OFF_POWER_TYPE = 0x7C; // int PowerType
|
||||
constexpr int OFF_MANA_COST_PERCENT = 0x270; // int, % of base resource (0 = flat)
|
||||
|
||||
uint32_t EffectiveCost(int spellID) {
|
||||
return reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST)(spellID, 0);
|
||||
}
|
||||
|
||||
int __fastcall Script_GetSpellPowerCost(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return 0; // nil — spell not found
|
||||
|
||||
const uint32_t cost = EffectiveCost(spellID);
|
||||
if (cost == 0xFFFFFFFF)
|
||||
return 0; // engine error (no player context, etc.)
|
||||
|
||||
const int powerType = *reinterpret_cast<const int *>(rec + OFF_POWER_TYPE);
|
||||
const int costPercent = *reinterpret_cast<const int *>(rec + OFF_MANA_COST_PERCENT);
|
||||
if (cost == 0 && costPercent == 0)
|
||||
return 0; // nil — no resource cost
|
||||
|
||||
// SpellPowerCostInfo[] — a one-element array: outer[1] = entry.
|
||||
Game::Lua::NewTable(L); // outer
|
||||
Game::Lua::PushNumber(L, 1.0); // key 1
|
||||
Game::Lua::NewTable(L); // entry (top)
|
||||
Game::Lua::SetFieldNumber(L, "type", static_cast<double>(powerType));
|
||||
Game::Lua::SetFieldString(L, "name", Unit::Power::PowerTypeToken(powerType));
|
||||
Game::Lua::SetFieldNumber(L, "cost", static_cast<double>(cost));
|
||||
Game::Lua::SetFieldNumber(L, "minCost", static_cast<double>(cost));
|
||||
Game::Lua::SetFieldNumber(L, "costPercent", static_cast<double>(costPercent));
|
||||
Game::Lua::SetFieldNumber(L, "costPerSec", 0);
|
||||
Game::Lua::SetFieldNumber(L, "requiredAuraID", 0);
|
||||
Game::Lua::SetFieldBool(L, "hasRequiredAura", false);
|
||||
Game::Lua::SetTable(L, -3); // outer[1] = entry
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellPowerCost", &Script_GetSpellPowerCost);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::PowerCost
|
||||
@@ -0,0 +1,120 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellRadius(spellID)` and the parallel
|
||||
// `GetSpellRadius(slot, bookType)` -> (baseRadius, modifiedRadius)
|
||||
//
|
||||
// Returns the spell's AOE radius in yards, or nil for a non-AOE spell
|
||||
// (no effect carries a radius). Two values:
|
||||
// - baseRadius — the raw SpellRadius.dbc value (caster-independent,
|
||||
// what an AOE tracker watching other units wants)
|
||||
// - modifiedRadius — baseRadius with the LOCAL PLAYER's spell modifiers
|
||||
// (talents / items, e.g. a Mage's Arctic Reach)
|
||||
// applied. Equal to baseRadius when the player has
|
||||
// no matching modifier.
|
||||
//
|
||||
// Two signatures mirroring the other dual-signature spell globals
|
||||
// (`GetSpellInfo`, `SpellHasRange`, etc.): the namespaced form takes a
|
||||
// modern spell *identifier* (numeric ID or name), the bare global takes
|
||||
// the vanilla `(slot, bookType)` spellbook position. `bookType` is the
|
||||
// `GetSpellName` convention: `"pet"` → pet book, anything else (or
|
||||
// omitted) → player book.
|
||||
//
|
||||
// baseRadius reads `Spell.dbc`'s per-effect `EffectRadiusIndex[3]` and
|
||||
// resolves each into `SpellRadius.dbc`, taking the largest base radius
|
||||
// across the three effects. No caster-level scaling (the engine's
|
||||
// FUN_006e6350 also adds `radiusPerLevel * casterLevel`, which needs a
|
||||
// unit context; modern `GetSpellRadius` reports the base value).
|
||||
//
|
||||
// modifiedRadius applies the local player's SpellMods for op 6 (radius)
|
||||
// via `Spell::Mod::Apply`. It is inherently local-player-only — the
|
||||
// client tracks spell mods for the player alone, so there's no way to
|
||||
// know another caster's talents.
|
||||
//
|
||||
// All DBCs / mod tables are resident from boot, so the lookup is
|
||||
// synchronous with no caching.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "spell/Mod.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::Radius {
|
||||
|
||||
// Largest base radius across the spell's effects, or a negative sentinel
|
||||
// (< 0) if no effect has a radius.
|
||||
static float MaxBaseRadius(const uint8_t *record) {
|
||||
float best = -1.0f;
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
|
||||
const int radiusIndex = *reinterpret_cast<const int *>(
|
||||
record + Offsets::OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX + i * 4);
|
||||
const uint8_t *rad = DBC::Record(Offsets::VAR_SPELL_RADIUS_RECORDS,
|
||||
Offsets::VAR_SPELL_RADIUS_COUNT,
|
||||
static_cast<uint32_t>(radiusIndex));
|
||||
if (rad == nullptr)
|
||||
continue;
|
||||
const float r = *reinterpret_cast<const float *>(rad + Offsets::OFF_SPELL_RADIUS_VALUE);
|
||||
if (r > best)
|
||||
best = r;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Pushes (baseRadius, modifiedRadius) for `spellID`, or nil (0 returns)
|
||||
// for an invalid / non-AOE spell.
|
||||
static int PushRadiusForSpellID(void *L, int spellID) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return 0; // nil
|
||||
const float base = MaxBaseRadius(record);
|
||||
if (base < 0.0f)
|
||||
return 0; // nil — not an AOE spell
|
||||
Game::Lua::PushNumber(L, static_cast<double>(base));
|
||||
Game::Lua::PushNumber(L,
|
||||
static_cast<double>(Spell::Mod::Apply(record, Offsets::SPELLMOD_OP_RADIUS, base)));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Spell.GetSpellRadius(spellID)` — modern spell-identifier form.
|
||||
static int __fastcall Script_C_Spell_GetSpellRadius(void *L) {
|
||||
return PushRadiusForSpellID(L, Spell::Arg::ResolveSpellID(L, 1));
|
||||
}
|
||||
|
||||
// `GetSpellRadius(slot, bookType)` — vanilla-style positional shape.
|
||||
static int __fastcall Script_GetSpellRadius(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
int bookType = 0;
|
||||
if (Game::Lua::IsString(L, 2)) {
|
||||
const char *s = Game::Lua::ToString(L, 2);
|
||||
if (s != nullptr && _stricmp(s, "pet") == 0)
|
||||
bookType = 1;
|
||||
}
|
||||
return PushRadiusForSpellID(L, Spell::Lookup::SpellbookSlotToID(slot, bookType));
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRadius",
|
||||
&Script_C_Spell_GetSpellRadius);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellRadius", &Script_GetSpellRadius);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::Radius
|
||||
+17
-8
@@ -25,11 +25,12 @@ namespace Spell::Usable {
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
|
||||
|
||||
// Spell.dbc record field offsets we read for usability. Fully documented
|
||||
// in CLAUDE.md.
|
||||
constexpr int OFF_SPELL_POWER_TYPE = 0x7C; // int8: 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness
|
||||
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost
|
||||
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost (fallback only)
|
||||
|
||||
// Returns true if the player knows `spellID` per the engine's spell-
|
||||
// knowledge bitmap at `[VAR_PLAYER_SPELL_BITMAP]`. Same check
|
||||
@@ -135,8 +136,9 @@ bool HasReagents(void *L, const uint8_t *record) {
|
||||
// 3. Player descriptor is reachable (post-login).
|
||||
// 4. Player is alive (HEALTH > 0).
|
||||
// 5. Spell is not on cooldown (engine cooldown helper).
|
||||
// 6. Player has enough power for `ManaCost` of the spell's
|
||||
// `PowerType`. Only this failure flips `noMana=true`.
|
||||
// 6. Player has enough power for the spell's *effective* cost (talent
|
||||
// reductions applied) of its `PowerType`. Only this flips
|
||||
// `noMana=true`.
|
||||
// 7. Player has all required reagents in bags (Reagent[8] /
|
||||
// ReagentCount[8] from the spell record).
|
||||
//
|
||||
@@ -170,16 +172,23 @@ Usability ComputeUsability(void *L, int spellID) {
|
||||
if (IsOnCooldown(spellID))
|
||||
return r;
|
||||
|
||||
// Mana check is the only one that flips noMana.
|
||||
// Mana check is the only one that flips noMana. Use the engine's
|
||||
// effective-cost helper (op-14 SpellMod + descriptor power-cost mods +
|
||||
// ManaCostPercent), so talent reductions like Frost Channeling count —
|
||||
// not just the base ManaCost. Falls back to base if the engine can't
|
||||
// resolve a cost (shouldn't happen here: the player descriptor
|
||||
// resolved above, so it has a player context).
|
||||
const int powerType = static_cast<int>(*reinterpret_cast<const int8_t *>(
|
||||
record + OFF_SPELL_POWER_TYPE));
|
||||
if (powerType >= 0 && powerType <= 4) {
|
||||
const int manaCost = static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
record + OFF_SPELL_MANA_COST));
|
||||
if (manaCost > 0) {
|
||||
auto getCost = reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST);
|
||||
uint32_t cost = getCost(spellID, 0 /* local player */);
|
||||
if (cost == 0xFFFFFFFF)
|
||||
cost = *reinterpret_cast<const uint32_t *>(record + OFF_SPELL_MANA_COST);
|
||||
if (cost > 0) {
|
||||
const int currentPower = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_POWER1 + powerType * 4);
|
||||
if (currentPower < manaCost) {
|
||||
if (currentPower < static_cast<int>(cost)) {
|
||||
r.noMana = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
+18
-16
@@ -33,11 +33,29 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "unit/Power.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::Power {
|
||||
|
||||
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
|
||||
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
|
||||
// can't appear on a 1.12 unit but we map them anyway in case a
|
||||
// private server pushes one through.
|
||||
const char *PowerTypeToken(int type) {
|
||||
switch (type) {
|
||||
case 0: return "MANA";
|
||||
case 1: return "RAGE";
|
||||
case 2: return "FOCUS";
|
||||
case 3: return "ENERGY";
|
||||
case 4: return "HAPPINESS";
|
||||
case 5: return "RUNES";
|
||||
case 6: return "RUNIC_POWER";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
@@ -68,22 +86,6 @@ uint32_t PowerDivisor(int type) {
|
||||
return table[type];
|
||||
}
|
||||
|
||||
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
|
||||
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
|
||||
// can't appear on a 1.12 unit but we map them anyway in case a
|
||||
// private server pushes one through.
|
||||
const char *PowerTypeToken(int type) {
|
||||
switch (type) {
|
||||
case 0: return "MANA";
|
||||
case 1: return "RAGE";
|
||||
case 2: return "FOCUS";
|
||||
case 3: return "ENERGY";
|
||||
case 4: return "HAPPINESS";
|
||||
case 5: return "RUNES";
|
||||
case 6: return "RUNIC_POWER";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
// `Enum.PowerType` — modern Blizzard-style enum table. Slot 4 is
|
||||
// `Happiness` in 1.12 (pet happiness 0..3); modern retail relabels
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Unit::Power {
|
||||
|
||||
// Modern power-type token for a PowerType value: 0 MANA, 1 RAGE, 2 FOCUS,
|
||||
// 3 ENERGY, 4 HAPPINESS, 5 RUNES, 6 RUNIC_POWER, anything else "UNKNOWN".
|
||||
// Used by `UnitPowerType`'s second return and by spell power-cost APIs.
|
||||
const char *PowerTypeToken(int type);
|
||||
|
||||
} // namespace Unit::Power
|
||||
Reference in New Issue
Block a user