mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-11 12:27:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17f44f93f8 | ||
|
|
73d90107b0 | ||
|
|
ed389591ac | ||
|
|
1867262cd9 | ||
|
|
9b21f983b3 | ||
|
|
3b729f08cb | ||
|
|
cdb3faa982 | ||
|
|
bb79311556 | ||
|
|
e881e18720 | ||
|
|
ba4520493a | ||
|
|
dc80ade21b | ||
|
|
6addd6d5b1 | ||
|
|
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 | ||
|
|
0332e49ac2 | ||
|
|
b1b564a533 | ||
|
|
444875ae38 | ||
|
|
a7ffe1c704 | ||
|
|
9f75737882 | ||
|
|
10d5790975 | ||
|
|
b571483c5f | ||
|
|
0a79016b0f | ||
|
|
3c4f24192a |
@@ -1,9 +1,9 @@
|
||||
name: Release
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -16,12 +16,8 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# Stamp the addon's TOC version from the tag (drops the leading `v`)
|
||||
# BEFORE CMake configure, so the embed step picks up the stamped TOC.
|
||||
# The DLL bundles AddOns/!!!ClassicAPI/ at build time — see the
|
||||
# `embed_addon` custom command in CMakeLists.txt and the file-read
|
||||
# hook in src/addons/Embedded.cpp.
|
||||
- name: Stamp embedded TOC version
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = '${{ github.ref_name }}' -replace '^v',''
|
||||
@@ -29,13 +25,26 @@ jobs:
|
||||
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
|
||||
Set-Content $toc
|
||||
|
||||
- name: Configure
|
||||
- name: Configure (tag)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
|
||||
|
||||
- name: Configure (branch)
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
run: cmake -B build -A Win32
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build --config Release
|
||||
|
||||
- name: Upload artifact
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ClassicAPI-${{ github.sha }}
|
||||
path: build/Release/ClassicAPI.dll
|
||||
|
||||
- name: Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/Release/ClassicAPI.dll
|
||||
@@ -3,19 +3,24 @@
|
||||
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
|
||||
## Version: DEV
|
||||
## Author: Brues
|
||||
## X-Website: https://github.com/brues-code/ClassicAPI
|
||||
|
||||
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\PixelUtil.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
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Backport of FrameXML/PixelUtil.lua to vanilla 1.12 / Lua 5.0.
|
||||
--
|
||||
-- PixelUtil snaps frame sizes/points to whole physical pixels so
|
||||
-- borders and textures stay crisp at fractional UI scales. The table
|
||||
-- ports verbatim — its only math deps (Round, Lerp,
|
||||
-- ClampedPercentageBetween) all live in MathUtil, which loads first.
|
||||
--
|
||||
-- The one piece vanilla lacks is the native `GetPhysicalScreenSize()`
|
||||
-- (added in 7.0). The ClassicAPI DLL provides it (see
|
||||
-- src/screen/Info.cpp) by reading the engine's `gxResolution` CVar, so
|
||||
-- it's a real global registered at engine boot — no Lua polyfill
|
||||
-- needed here.
|
||||
|
||||
PixelUtil = {};
|
||||
|
||||
function PixelUtil.GetPixelToUIUnitFactor()
|
||||
local physicalWidth, physicalHeight = GetPhysicalScreenSize();
|
||||
return 768.0 / physicalHeight;
|
||||
end
|
||||
|
||||
function PixelUtil.GetNearestPixelSize(uiUnitSize, layoutScale, minPixels)
|
||||
if uiUnitSize == 0 and (not minPixels or minPixels == 0) then
|
||||
return 0;
|
||||
end
|
||||
|
||||
local uiUnitFactor = PixelUtil.GetPixelToUIUnitFactor();
|
||||
local numPixels = Round((uiUnitSize * layoutScale) / uiUnitFactor);
|
||||
if minPixels then
|
||||
if uiUnitSize < 0.0 then
|
||||
if numPixels > -minPixels then
|
||||
numPixels = -minPixels;
|
||||
end
|
||||
else
|
||||
if numPixels < minPixels then
|
||||
numPixels = minPixels;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return numPixels * uiUnitFactor / layoutScale;
|
||||
end
|
||||
|
||||
function PixelUtil.ConvertPixelsToUI(desiredPixels, layoutScale)
|
||||
return PixelUtil.GetNearestPixelSize(desiredPixels, layoutScale);
|
||||
end
|
||||
|
||||
function PixelUtil.ConvertPixelsToUIForRegion(desiredPixels, region)
|
||||
return PixelUtil.GetNearestPixelSize(desiredPixels, region:GetEffectiveScale());
|
||||
end
|
||||
|
||||
function PixelUtil.SetWidth(region, width, minPixels)
|
||||
region:SetWidth(PixelUtil.GetNearestPixelSize(width, region:GetEffectiveScale(), minPixels));
|
||||
end
|
||||
|
||||
function PixelUtil.SetHeight(region, height, minPixels)
|
||||
region:SetHeight(PixelUtil.GetNearestPixelSize(height, region:GetEffectiveScale(), minPixels));
|
||||
end
|
||||
|
||||
function PixelUtil.SetSize(region, width, height, minWidthPixels, minHeightPixels)
|
||||
PixelUtil.SetWidth(region, width, minWidthPixels);
|
||||
PixelUtil.SetHeight(region, height, minHeightPixels);
|
||||
end
|
||||
|
||||
function PixelUtil.SetPoint(region, point, relativeTo, relativePoint, offsetX, offsetY, minOffsetXPixels, minOffsetYPixels)
|
||||
region:SetPoint(point, relativeTo, relativePoint,
|
||||
PixelUtil.GetNearestPixelSize(offsetX, region:GetEffectiveScale(), minOffsetXPixels),
|
||||
PixelUtil.GetNearestPixelSize(offsetY, region:GetEffectiveScale(), minOffsetYPixels)
|
||||
);
|
||||
end
|
||||
|
||||
function PixelUtil.SetStatusBarValue(statusBar, value)
|
||||
local width = statusBar:GetWidth();
|
||||
if width and width > 0.0 then
|
||||
local min, max = statusBar:GetMinMaxValues();
|
||||
local percent = ClampedPercentageBetween(value, min, max);
|
||||
if percent == 0.0 or percent == 1.0 then
|
||||
statusBar:SetValue(value);
|
||||
else
|
||||
local numPixels = PixelUtil.GetNearestPixelSize(statusBar:GetWidth() * percent, statusBar:GetEffectiveScale());
|
||||
local roundedValue = Lerp(min, max, numPixels / width);
|
||||
statusBar:SetValue(roundedValue);
|
||||
end
|
||||
else
|
||||
statusBar:SetValue(value);
|
||||
end
|
||||
end
|
||||
@@ -32,8 +32,12 @@ function PlayerUtil.GetCurrentSpeed()
|
||||
return GetUnitSpeed("player") / 7 * 100
|
||||
end
|
||||
|
||||
local playerGuid
|
||||
function GetPlayerGuid()
|
||||
return UnitGUID("player");
|
||||
if not playerGuid then
|
||||
playerGuid = UnitGUID("player")
|
||||
end
|
||||
return playerGuid
|
||||
end
|
||||
|
||||
function IsPlayerGuid(guid)
|
||||
@@ -43,4 +47,4 @@ end
|
||||
function GetNameAndServerNameFromGUID(unitGUID)
|
||||
local _, _, _, _, _, name = GetPlayerInfoByGUID(unitGUID);
|
||||
return name, GetRealmName()
|
||||
end
|
||||
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,41 @@
|
||||
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 +1,25 @@
|
||||
if GetLocale() ~= "enUS" then return end
|
||||
|
||||
BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
BINDING_NAME_FOCUSTARGET = "Focus Target";
|
||||
BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
FOCUSTARGET = "Focus Target";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEMS_EQUIPPED = "%d |4item:items; equipped";
|
||||
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";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
|
||||
ITEM_MISSING = "%s missing";
|
||||
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "s";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
TARGETFOCUS = "Target Focus";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/..\FrameXML\UI.xsd">
|
||||
<Script file="deDE.lua"/>
|
||||
<Script file="enUS.lua"/>
|
||||
<Script file="enUS.lua"/>
|
||||
<Script file="deDE.lua"/>
|
||||
<Script file="esES.lua"/>
|
||||
<Script file="frFR.lua"/>
|
||||
<Script file="ruRU.lua"/>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
-- AoE Loot Test
|
||||
--
|
||||
-- /aoeloot opens a picker window listing every item in every lootable
|
||||
-- corpse within click range. Clicking a row hands the (guid, itemID)
|
||||
-- pair to C_Loot.LootUnitItem and re-scans so the picker refreshes
|
||||
-- with whatever's left.
|
||||
--
|
||||
-- Known visual: each scan opens / closes loot windows in rapid
|
||||
-- succession, so the Blizzard LootFrame flickers during the scan. This
|
||||
-- addon intentionally doesn't suppress that — the goal is to exercise
|
||||
-- the C++ surface, not to ship polished UI.
|
||||
|
||||
local MAX_ROWS = 24
|
||||
local RESCAN_DELAY = 0.3 -- seconds; lets LootUnitItem complete before re-scan
|
||||
|
||||
-- ---------- UI scaffolding ----------
|
||||
|
||||
-- Frame geometry. Height is recomputed dynamically per scan so the
|
||||
-- window only takes up as much screen real estate as the result count
|
||||
-- needs — see `ResizeForRows`.
|
||||
local FRAME_WIDTH = 320
|
||||
local HEADER_HEIGHT = 64 -- title + status + insets; minimum frame height
|
||||
local ROW_HEIGHT = 22
|
||||
|
||||
local frame = CreateFrame("Frame", "AoELootTestFrame", UIParent)
|
||||
frame:SetWidth(FRAME_WIDTH)
|
||||
frame:SetHeight(HEADER_HEIGHT)
|
||||
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
|
||||
frame:SetBackdrop({
|
||||
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
|
||||
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
|
||||
tile = true, tileSize = 32, edgeSize = 32,
|
||||
insets = { left = 11, right = 12, top = 12, bottom = 11 },
|
||||
})
|
||||
frame:SetMovable(true)
|
||||
frame:EnableMouse(true)
|
||||
frame:RegisterForDrag("LeftButton")
|
||||
-- Vanilla 1.12 fires script handlers with the frame set as the global
|
||||
-- `this`, not as a `self` arg to the function. Passing `frame.StartMoving`
|
||||
-- directly (the modern shortcut) breaks because the C method has no
|
||||
-- `self` to operate on. Wrap in closures so `this:method()` resolves
|
||||
-- correctly inside the handler.
|
||||
frame:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||
frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
||||
frame:Hide()
|
||||
|
||||
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
|
||||
title:SetPoint("TOP", frame, "TOP", 0, -16)
|
||||
title:SetText("AoE Loot")
|
||||
|
||||
local status = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
|
||||
status:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -36)
|
||||
status:SetText("")
|
||||
|
||||
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
|
||||
closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
|
||||
|
||||
-- ---------- Row pool ----------
|
||||
|
||||
local rows = {}
|
||||
|
||||
local function CreateRow(parent, idx)
|
||||
local row = CreateFrame("Button", nil, parent)
|
||||
row:SetWidth(290)
|
||||
row:SetHeight(20)
|
||||
row:SetPoint("TOPLEFT", parent, "TOPLEFT", 15,
|
||||
-54 - (idx - 1) * ROW_HEIGHT)
|
||||
|
||||
local icon = row:CreateTexture(nil, "ARTWORK")
|
||||
icon:SetWidth(18); icon:SetHeight(18)
|
||||
icon:SetPoint("LEFT", row, "LEFT", 0, 0)
|
||||
row.icon = icon
|
||||
|
||||
local text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
|
||||
text:SetPoint("LEFT", icon, "RIGHT", 6, 0)
|
||||
text:SetPoint("RIGHT", row, "RIGHT", 0, 0)
|
||||
text:SetJustifyH("LEFT")
|
||||
row.text = text
|
||||
|
||||
local hl = row:CreateTexture(nil, "HIGHLIGHT")
|
||||
hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
|
||||
hl:SetBlendMode("ADD")
|
||||
hl:SetAllPoints(row)
|
||||
|
||||
-- Tooltip on hover. The engine's native `SetHyperlink` does a
|
||||
-- literal `SStrCmpI` of the input's first 5 chars against the
|
||||
-- string `"item:"` at `.rdata` VA `0x00842D60` — anything that
|
||||
-- starts with `|c...` (full link with color) or `|Hitem` (full
|
||||
-- link without color) fails the prefix check with "Unknown link
|
||||
-- type". Only the bare payload form survives.
|
||||
--
|
||||
-- We extract the payload from the engine's full link rather than
|
||||
-- composing `item:ID:0:0:0` ourselves, so per-instance fields
|
||||
-- (enchant / random-suffix / unique) survive round-trip and
|
||||
-- "Stringy Wolf Meat of the Bear" tooltips correctly instead of
|
||||
-- collapsing to the un-suffixed itemID.
|
||||
row:SetScript("OnEnter", function()
|
||||
if this.payload then
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||
GameTooltip:SetHyperlink(this.payload)
|
||||
GameTooltip:Show()
|
||||
end
|
||||
end)
|
||||
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||||
|
||||
row:Hide()
|
||||
return row
|
||||
end
|
||||
|
||||
for i = 1, MAX_ROWS do
|
||||
rows[i] = CreateRow(frame, i)
|
||||
end
|
||||
|
||||
-- ---------- Render ----------
|
||||
|
||||
-- Tail of a GUID string — last 4 hex chars of the low dword. Lets the
|
||||
-- user tell apart two rows of "Stringy Wolf Meat x1" coming from
|
||||
-- different corpses, without occupying a whole column with the full
|
||||
-- 16-digit GUID.
|
||||
local function ShortGuidSuffix(guid)
|
||||
return "[.." .. string.sub(guid, -4) .. "]"
|
||||
end
|
||||
|
||||
-- Extract the `item:ID:enchant:suffix:unique` payload out of the
|
||||
-- engine's full link string (`|cff...|Hitem:...|h[Name]|h|r`).
|
||||
-- Vanilla `SetHyperlink` requires the input to start literally with
|
||||
-- `"item:"` — so we can't pass the full link, but we also can't drop
|
||||
-- the per-instance fields (random suffix is what distinguishes "of
|
||||
-- the Bear" from "of the Eagle" on otherwise identical itemIDs).
|
||||
-- Returns the payload, or nil if extraction fails.
|
||||
local function PayloadFromLink(link)
|
||||
if not link then return nil end
|
||||
return string.match(link, "|H(item:[^|]+)|h")
|
||||
end
|
||||
|
||||
local function ClearRows()
|
||||
for i = 1, MAX_ROWS do
|
||||
rows[i]:Hide()
|
||||
rows[i]:SetScript("OnClick", nil)
|
||||
rows[i].payload = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function PopulateRows(results)
|
||||
local idx = 0
|
||||
for _, corpse in ipairs(results) do
|
||||
for _, item in ipairs(corpse.items) do
|
||||
idx = idx + 1
|
||||
if idx > MAX_ROWS then break end
|
||||
local row = rows[idx]
|
||||
local name, _, _, _, _, _, _, _, icon = GetItemInfo(item.itemID)
|
||||
row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
|
||||
local label = name or ("Item #" .. item.itemID)
|
||||
if item.count and item.count > 1 then
|
||||
label = label .. " x" .. item.count
|
||||
end
|
||||
label = label .. " " .. ShortGuidSuffix(corpse.guid)
|
||||
row.text:SetText(label)
|
||||
row.payload = PayloadFromLink(item.link) -- for OnEnter tooltip
|
||||
local guid, itemID = corpse.guid, item.itemID
|
||||
row:SetScript("OnClick", function()
|
||||
C_Loot.LootUnitItem(guid, itemID)
|
||||
C_Timer.After(RESCAN_DELAY, function()
|
||||
if frame:IsShown() then C_Loot.ScanNearbyLoot() end
|
||||
end)
|
||||
end)
|
||||
row:Show()
|
||||
end
|
||||
if idx > MAX_ROWS then break end
|
||||
end
|
||||
for i = idx + 1, MAX_ROWS do rows[i]:Hide() end
|
||||
local shown
|
||||
if idx == 0 then
|
||||
status:SetText("Nothing lootable in range.")
|
||||
shown = 0
|
||||
elseif idx > MAX_ROWS then
|
||||
status:SetText(MAX_ROWS .. "+ items (showing first " .. MAX_ROWS .. ")")
|
||||
shown = MAX_ROWS
|
||||
else
|
||||
status:SetText(idx .. " item(s) available")
|
||||
shown = idx
|
||||
end
|
||||
frame:SetHeight(HEADER_HEIGHT + shown * ROW_HEIGHT)
|
||||
end
|
||||
|
||||
-- ---------- Scan trigger ----------
|
||||
|
||||
local function StartScan()
|
||||
status:SetText("Scanning...")
|
||||
ClearRows()
|
||||
frame:SetHeight(HEADER_HEIGHT) -- collapse while we wait for results
|
||||
if not C_Loot.ScanNearbyLoot() then
|
||||
status:SetText("Scan busy or not in world.")
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- Event wiring ----------
|
||||
|
||||
local evt = CreateFrame("Frame")
|
||||
evt:RegisterEvent("LOOT_SCAN_COMPLETED")
|
||||
evt:SetScript("OnEvent", function()
|
||||
-- Race guard: a delayed scan may complete after the user closed
|
||||
-- the picker. Just drop the result rather than re-showing.
|
||||
if not frame:IsShown() then return end
|
||||
PopulateRows(C_Loot.GetLastScanResults())
|
||||
end)
|
||||
|
||||
-- ---------- Slash command ----------
|
||||
|
||||
RegisterNewSlashCommand(function()
|
||||
if frame:IsShown() then
|
||||
StartScan() -- already open → just refresh
|
||||
else
|
||||
frame:Show()
|
||||
StartScan()
|
||||
end
|
||||
end, "aoeloot", "aoe")
|
||||
@@ -0,0 +1,7 @@
|
||||
## Interface: 11200
|
||||
## Title: AoE Loot Test
|
||||
## Notes: Test bed for ClassicAPI's C_Loot.ScanNearbyLoot / LootUnitItem. /aoeloot
|
||||
## Dependencies: !!!ClassicAPI
|
||||
## Version: DEV
|
||||
|
||||
AoELootTest.lua
|
||||
@@ -38,21 +38,24 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
|
||||
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `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` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` (backports `GlobalColor.dbc`; companion addon wraps each row with `ColorMixin` and assigns `_G[baseTag]` + `_G[baseTag.."_CODE"]`) |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitTokenFromGUID` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
|
||||
|
||||
### GlueXML calls
|
||||
@@ -86,6 +89,17 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
|
||||
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
|
||||
|
||||
### Console commands
|
||||
|
||||
Registered on the engine's developer console (the `~` console available
|
||||
when launching with `-console`), not as Lua functions. See the
|
||||
[Console section in the Lua reference](docs/API.md#console) for details.
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `ExportInterfaceFiles code` | Extracts Blizzard's UI source (`.lua`/`.xml`/`.toc`/`.xsd`) from the MPQs to `BlizzardInterfaceCode\` |
|
||||
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Payload |
|
||||
@@ -99,6 +113,7 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `GLOBAL_MOUSE_UP` | `button` |
|
||||
| `HEARTHSTONE_BOUND` | *(none)* |
|
||||
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
|
||||
| `LOOT_SCAN_COMPLETED` | *(none)* |
|
||||
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
|
||||
| `NAME_PLATE_CREATED` | `nameplateFrame` |
|
||||
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+736
-1
@@ -33,6 +33,9 @@ build instructions.
|
||||
- [Combat](#combat)
|
||||
- [`InCombatLockdown()`](#incombatlockdown)
|
||||
|
||||
- [Console](#console)
|
||||
- [`ExportInterfaceFiles art|code` (console command)](#exportinterfacefiles-artcode-console-command)
|
||||
|
||||
- [Container](#container)
|
||||
- [`C_Container.GetContainerItemID(bagIndex, slotIndex)`](#c_containergetcontaineritemidbagindex-slotindex)
|
||||
- [`GetItemCooldown(itemInfo)` / `C_Container.GetItemCooldown(itemID)`](#getitemcooldowniteminfo--c_containergetitemcooldownitemid)
|
||||
@@ -88,6 +91,7 @@ build instructions.
|
||||
- [`EQUIPMENT_SWAP_PENDING` event](#equipment_swap_pending-event)
|
||||
- [`EQUIPMENT_SWAP_FINISHED` event](#equipment_swap_finished-event)
|
||||
- [`FACTION_STANDING_CHANGED` event](#faction_standing_changed-event)
|
||||
- [`LOOT_SCAN_COMPLETED` event](#loot_scan_completed-event)
|
||||
- [`MODIFIER_STATE_CHANGED` event](#modifier_state_changed-event)
|
||||
- [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
|
||||
- [`PLAYER_FOCUS_CHANGED` event](#player_focus_changed-event)
|
||||
@@ -200,6 +204,7 @@ build instructions.
|
||||
- [`C_Item.UnlockAllItems()`](#c_itemunlockallitems)
|
||||
- [`C_Item.UnlockItem(itemLocation)`](#c_itemunlockitemitemlocation)
|
||||
- [`C_Item.UseAtCursor(itemInfo)`](#c_itemuseatcursoriteminfo)
|
||||
- [`C_Item.UseAtUnit(itemInfo, unit)`](#c_itemuseatunititeminfo-unit)
|
||||
- [`C_Item.UseItemByName(itemInfo [, unit])`](#c_itemuseitembynameiteminfo--unit)
|
||||
- [`Get*ItemID` — companions to the engine's `Get*ItemLink` family](#getitemid--companions-to-the-engines-getitemlink-family)
|
||||
- [`GetAverageItemLevel()`](#getaverageitemlevel)
|
||||
@@ -210,12 +215,24 @@ build instructions.
|
||||
- [`GetItemIcon(itemID)` / `C_Item.GetItemIcon(itemLocation)` / `C_Item.GetItemIconByID(item)`](#getitemiconitemid--c_itemgetitemiconitemlocation--c_itemgetitemiconbyiditem)
|
||||
- [`OffhandHasWeapon()`](#offhandhasweapon)
|
||||
|
||||
- [Loot](#loot)
|
||||
- [`C_Loot.GetNearbyLootableUnits()`](#c_lootgetnearbylootableunits)
|
||||
- [`C_Loot.LootUnit(guid)`](#c_lootlootunitguid)
|
||||
- [`C_Loot.LootUnitItem(guid, itemID)`](#c_lootlootunititemguid-itemid)
|
||||
- [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
|
||||
- [`C_Loot.IsScanInProgress()`](#c_lootisscaninprogress)
|
||||
- [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults)
|
||||
|
||||
- [Macros](#macros)
|
||||
- [Numeric spellIDs in `/cast` and `CastSpellByName`](#numeric-spellids-in-cast-and-castspellbyname)
|
||||
- [`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line)
|
||||
- [`GetMacroSpell(macroSlot)`](#getmacrospellmacroslot)
|
||||
- [`GetMacroIcons` / `GetMacroItemIcons` / `GetLooseMacroIcons` / `GetLooseMacroItemIcons`](#getmacroicons--getmacroitemicons--getloosemacroicons--getloosemacroitemicons)
|
||||
|
||||
- [Mail](#mail)
|
||||
- [`GetSendMailItemLink([attachmentIndex])`](#getsendmailitemlinkattachmentindex)
|
||||
- [`GetInboxItemLink(messageIndex[, attachmentIndex])`](#getinboxitemlinkmessageindex-attachmentindex)
|
||||
|
||||
- [Map](#map)
|
||||
- [`C_Map.GetBestMapForUnit(unitToken)`](#c_mapgetbestmapforunitunittoken)
|
||||
|
||||
@@ -236,6 +253,7 @@ build instructions.
|
||||
|
||||
- [NameCache](#namecache)
|
||||
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
|
||||
- [`UnitNameFromGUID(guid)`](#unitnamefromguidguid)
|
||||
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
|
||||
- [`C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`](#c_playerinfoguidisplayerguid--guidiscreature--guidispet--guidisgameobject)
|
||||
- [`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
|
||||
@@ -255,6 +273,9 @@ build instructions.
|
||||
- [`C_QuestLog.IsQuestDataCachedByID(questID)`](#c_questlogisquestdatacachedbyidquestid)
|
||||
- [`GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])`](#getquestlogleaderboardidobjectiveindex--questindex)
|
||||
|
||||
- [Screen](#screen)
|
||||
- [`GetPhysicalScreenSize()`](#getphysicalscreensize)
|
||||
|
||||
- [Spell](#spell)
|
||||
- [`C_Spell.DoesSpellExist(spellID)`](#c_spelldoesspellexistspellid)
|
||||
- [`C_Spell.GetSchoolString(schoolMask)`](#c_spellgetschoolstringschoolmask)
|
||||
@@ -265,6 +286,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)
|
||||
@@ -284,7 +308,10 @@ build instructions.
|
||||
- [`GetSpellSchool(spellID)`](#getspellschoolspellid)
|
||||
- [`CastSpellNoToggle(name | spellID)`](#castspellnotogglename--spellid)
|
||||
- [`C_Spell.CastAtCursor(spellID)`](#c_spellcastatcursorspellid)
|
||||
- [`C_Spell.CastAtUnit(spellID, unit)`](#c_spellcastatunitspellid-unit)
|
||||
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
|
||||
- [`UnitCastingInfo(unit)` / `CastingInfo()`](#unitcastinginfounit--castinginfo)
|
||||
- [`UnitChannelInfo(unit)` / `ChannelInfo()`](#unitchannelinfounit--channelinfo)
|
||||
|
||||
- [SpellBook](#spellbook)
|
||||
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
|
||||
@@ -333,6 +360,7 @@ build instructions.
|
||||
- [Unit](#unit)
|
||||
- [`UnitGUID(unit)`](#unitguidunit)
|
||||
- [`UnitTokenFromGUID(guid)`](#unittokenfromguidguid)
|
||||
- [`UnitSubName(unit)`](#unitsubnameunit)
|
||||
- [`GetUnitSpeed(unit)`](#getunitspeedunit)
|
||||
- [`UnitClassBase(unit)`](#unitclassbaseunit)
|
||||
- [`UnitRaceBase(unit)`](#unitracebaseunit)
|
||||
@@ -811,6 +839,45 @@ if UnitAffectingCombat("player") then
|
||||
end
|
||||
```
|
||||
|
||||
## Console
|
||||
|
||||
### `ExportInterfaceFiles art|code` (console command)
|
||||
|
||||
A **developer-console command** (not a Lua function) — type it into the
|
||||
`~` console, which is available when the client is launched with
|
||||
`-console`. It extracts Blizzard's stock UI files out of the game's MPQ
|
||||
archives onto disk, mirroring the same-named command from later clients
|
||||
(4.3.4). Useful for reading the FrameXML / GlueXML source you're
|
||||
backporting addons against, or pulling out the default art.
|
||||
|
||||
- `ExportInterfaceFiles code` — writes `.lua` / `.xml` / `.toc` /
|
||||
`.xsd` files to `BlizzardInterfaceCode\…`
|
||||
- `ExportInterfaceFiles art` — writes `.blp` / `.tga` files to
|
||||
`BlizzardInterfaceArt\…`
|
||||
|
||||
Files are written relative to the client's working directory (next to
|
||||
`WoW.exe`), preserving their `Interface\…` subtree under the
|
||||
destination folder — e.g. `Interface\FrameXML\FrameXML.toc` becomes
|
||||
`BlizzardInterfaceCode\FrameXML\FrameXML.toc`. On completion the command
|
||||
prints a `wrote N file(s)` line back to the console.
|
||||
|
||||
```
|
||||
> ExportInterfaceFiles code
|
||||
ExportInterfaceFiles: wrote 1234 code file(s) to BlizzardInterfaceCode\
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- It enumerates the mounted MPQs' `(listfile)`, so it surfaces whatever
|
||||
the archives actually ship — including Blizzard's own UI addons under
|
||||
`Interface\AddOns\Blizzard_*` (AuctionUI, TalentUI, TradeSkillUI,
|
||||
etc.), which are part of the stock UI source. Files present in
|
||||
multiple archives (base + patches) are written once.
|
||||
- Your own loose, on-disk addons are *not* exported — they aren't in
|
||||
any archive's listfile, so they're never enumerated.
|
||||
- It runs synchronously and briefly freezes the client while it walks
|
||||
`Interface\` — expected for a one-shot extraction.
|
||||
|
||||
## Container
|
||||
|
||||
### `C_Container.GetContainerItemID(bagIndex, slotIndex)`
|
||||
@@ -1971,6 +2038,29 @@ forwarding, which [`C_Reputation.GetLastStandingChange`](#c_reputationgetlaststa
|
||||
exposes — so addons can read the structured payload from inside the
|
||||
chat event without re-parsing the localized string.
|
||||
|
||||
### `LOOT_SCAN_COMPLETED` event
|
||||
|
||||
Fires (no payload) once a [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
|
||||
call has finished walking every reachable corpse. Handler should call
|
||||
[`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults) to read the
|
||||
collected `{ guid, coin, items }` array.
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("LOOT_SCAN_COMPLETED")
|
||||
f:SetScript("OnEvent", function()
|
||||
local results = C_Loot.GetLastScanResults()
|
||||
-- ... process results
|
||||
end)
|
||||
C_Loot.ScanNearbyLoot()
|
||||
```
|
||||
|
||||
Fires exactly once per `ScanNearbyLoot()` call regardless of
|
||||
outcome — even if every corpse timed out and the results table is
|
||||
empty. Doesn't fire spuriously for scans triggered by other addons
|
||||
(only one scan runs at a time; concurrent attempts return `false`
|
||||
from `ScanNearbyLoot`). See the Loot section for the full scan flow.
|
||||
|
||||
### `MODIFIER_STATE_CHANGED` event
|
||||
|
||||
Fires on every modifier-key press and release with `(key, down)`:
|
||||
@@ -4177,7 +4267,7 @@ return nil; hits never need a fallback.
|
||||
|
||||
### `C_Item.GetItemMaxStackSize(itemLocation)` / `C_Item.GetItemMaxStackSizeByID(item)`
|
||||
|
||||
Returns the item type's max stack size — what you'd find as the 8th
|
||||
Returns the item type's max stack size — what you'd find as the 7th
|
||||
return of `GetItemInfo(item)`. `1` for non-stackable items; `5`, `20`,
|
||||
`200`, etc. for stackables. Different from `C_Item.GetStackCount` /
|
||||
the engine's `GetContainerItemInfo`, which return the **current**
|
||||
@@ -4716,6 +4806,32 @@ Cancels placement automatically when the cursor isn't on terrain —
|
||||
the item-use packet is never sent, so an off-screen click doesn't
|
||||
waste the grenade.
|
||||
|
||||
### `C_Item.UseAtUnit(itemInfo, unit)`
|
||||
|
||||
Unit-position analog of
|
||||
[`C_Item.UseAtCursor`](#c_itemuseatcursoriteminfo): uses `itemInfo` at
|
||||
`unit`'s feet rather than the cursor. ClassicAPI's `[@unit]` for
|
||||
ground-target on-use items. `itemInfo` accepts the same forms as
|
||||
`UseAtCursor` (itemID, bare `"item:N"`, full chat link, localized
|
||||
name); `unit` is any unit token (`"player"`, `"target"`,
|
||||
`"mouseover"`, `"party1"`, …).
|
||||
|
||||
```lua
|
||||
C_Item.UseAtUnit(4068, "target") -- Iron Grenade at the target's feet
|
||||
C_Item.UseAtUnit("Iron Grenade", "player")
|
||||
```
|
||||
|
||||
Same chain as `UseAtCursor` (`Item::Location::FindByArgInBags` +
|
||||
`FUN_ITEM_USE`) but committing the placement at the unit's world
|
||||
position via
|
||||
[`Spell::AtCursor::CommitAtCoords`](#c_spellcastatunitspellid-unit)
|
||||
instead of the cursor raycast. Returns `true` when the placement
|
||||
landed at the unit; `false` for non-ground-target items (the item
|
||||
still fires with any implicit target), unparseable input,
|
||||
item-not-in-bags, or an unresolvable unit. The unit is resolved
|
||||
before the item fires, so an absent unit fails without consuming the
|
||||
item.
|
||||
|
||||
### `C_Item.UseItemByName(itemInfo [, unit])`
|
||||
|
||||
Finds the first item in the player's bags matching `itemInfo` and
|
||||
@@ -5110,6 +5226,177 @@ if OffhandHasWeapon() then
|
||||
end
|
||||
```
|
||||
|
||||
## Loot
|
||||
|
||||
Programmatic loot operations that vanilla 1.12's Lua surface doesn't
|
||||
expose: enumerate nearby lootable corpses, open a loot session against
|
||||
one by GUID, take a specific item out of one without going through the
|
||||
visible `LootFrame`, and pre-scan every reachable corpse to build a
|
||||
picker table before any cursor work. Foundation for AoE-loot,
|
||||
auto-loot-by-filter, and similar addons that 1.12's stock
|
||||
`LootSlot` / `LootSlotInfo` flow can't drive.
|
||||
|
||||
All `guid` args are hex-string GUIDs in the same form `UnitGUID`
|
||||
returns (`"0xF130..."`). All `itemID` args are integers.
|
||||
|
||||
### `C_Loot.GetNearbyLootableUnits()`
|
||||
|
||||
Returns a 1-indexed array of `{ guid = "0x..." }` subtables, one per
|
||||
visible unit that the server has flagged lootable AND that's within
|
||||
the engine's right-click-loot interact range. Empty table pre-login,
|
||||
when no lootable units are in range, or when the local player has no
|
||||
loot rights on visible corpses (the server only sets the lootable
|
||||
flag for clients with rights).
|
||||
|
||||
```lua
|
||||
for _, unit in ipairs(C_Loot.GetNearbyLootableUnits()) do
|
||||
C_Loot.LootUnit(unit.guid)
|
||||
end
|
||||
```
|
||||
|
||||
The interact-range check matches what the engine uses for the
|
||||
hover-to-loot cursor — slightly tighter than melee range, looser than
|
||||
"on top of." Server still enforces real range on the resulting
|
||||
`CMSG_LOOT`; in-client OOR requests just silently get no response.
|
||||
|
||||
### `C_Loot.LootUnit(guid)`
|
||||
|
||||
Opens a loot session against the given corpse. The loot window
|
||||
appears asynchronously via the normal `LOOT_OPENED` event once
|
||||
`SMSG_LOOT_RESPONSE` arrives — same flow as right-clicking the
|
||||
corpse, just dispatched programmatically.
|
||||
|
||||
No return value. Silent no-op when:
|
||||
- The object manager is NULL (glue / character-select).
|
||||
- The GUID doesn't resolve to a visible unit (out of render range,
|
||||
despawned, wrong typemask).
|
||||
- The local player pointer can't be resolved.
|
||||
|
||||
Errors only on bad Lua usage (missing arg, unparseable GUID).
|
||||
|
||||
```lua
|
||||
-- Loot everything in range, one corpse at a time. With autoloot on,
|
||||
-- the engine drains each window automatically; without it, you
|
||||
-- still get the visible loot frame per corpse.
|
||||
for _, unit in ipairs(C_Loot.GetNearbyLootableUnits()) do
|
||||
C_Loot.LootUnit(unit.guid)
|
||||
end
|
||||
```
|
||||
|
||||
`useDistanceCheck` is hardcoded off — the engine won't try to
|
||||
auto-walk the player toward a corpse just out of range. That'd be a
|
||||
surprise side effect for an AoE-loot loop.
|
||||
|
||||
### `C_Loot.LootUnitItem(guid, itemID)`
|
||||
|
||||
Takes a specific item from a specific corpse without using the
|
||||
visible loot frame's slot list. Dispatches `CMSG_LOOT_ITEM` directly.
|
||||
|
||||
Two paths, picked automatically:
|
||||
|
||||
1. **Fast path** — the corpse's loot window is already open for the
|
||||
requested target. Synchronously finds the slot, sends the take
|
||||
packet, returns `true` if the slot was found / `false` if not.
|
||||
2. **Async path** — opens the corpse's window first (same wire as
|
||||
`C_Loot.LootUnit`), waits for the response, then sends the take
|
||||
packet automatically. Returns `true` once the request is queued.
|
||||
No "did it succeed" event; observe `BAG_UPDATE` / `ITEM_PUSH` to
|
||||
confirm.
|
||||
|
||||
`false` immediately when:
|
||||
- Args missing or wrong types (raises an error, not returns false).
|
||||
- Another `LootUnitItem` is mid-flight (one at a time).
|
||||
- The world isn't loaded.
|
||||
- The target GUID doesn't resolve to a visible unit.
|
||||
- (sync path only) No slot in the open window holds the itemID.
|
||||
|
||||
The send bypasses vanilla's BoP-confirmation dialog. Server still
|
||||
enforces all real permissions (loot rights, distance, master-loot
|
||||
rules); the BoP prompt is purely a client-side courtesy that callers
|
||||
of a programmatic API have already opted out of.
|
||||
|
||||
```lua
|
||||
-- Scan, then take just one specific item out of one specific corpse.
|
||||
C_Loot.ScanNearbyLoot() -- LOOT_SCAN_COMPLETED → results below
|
||||
local results = C_Loot.GetLastScanResults()
|
||||
local guid = results[1].guid
|
||||
local itemID = results[1].items[1].itemID
|
||||
C_Loot.LootUnitItem(guid, itemID)
|
||||
```
|
||||
|
||||
### `C_Loot.ScanNearbyLoot()`
|
||||
|
||||
Initiates a pre-scan: opens each lootable corpse in range one at a
|
||||
time, scrapes its slot contents (itemID, count, item link), closes
|
||||
the window, advances. The `LOOT_OPENED` / `LOOT_CLOSED` events are
|
||||
suppressed for the duration of the scan so `LootFrame` and other
|
||||
listeners don't react — the scan is invisible to addons that aren't
|
||||
opted in.
|
||||
|
||||
Returns `true` if the scan was queued, `false` if another scan is
|
||||
already in progress or the world isn't loaded. Completion is signaled
|
||||
via the [`LOOT_SCAN_COMPLETED`](#loot_scan_completed-event) event;
|
||||
results are read via [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults).
|
||||
|
||||
```lua
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("LOOT_SCAN_COMPLETED")
|
||||
f:SetScript("OnEvent", function()
|
||||
for _, corpse in ipairs(C_Loot.GetLastScanResults()) do
|
||||
for _, item in ipairs(corpse.items) do
|
||||
ChatFrame1:AddMessage(item.link or ("item:"..item.itemID))
|
||||
end
|
||||
end
|
||||
end)
|
||||
C_Loot.ScanNearbyLoot()
|
||||
```
|
||||
|
||||
Per-corpse step timeout is ~3-6 seconds (180 WorldTick frames) so a
|
||||
single dropped server response doesn't hang the whole scan. Failed
|
||||
corpses simply don't appear in the results.
|
||||
|
||||
### `C_Loot.IsScanInProgress()`
|
||||
|
||||
Returns `true` while a `ScanNearbyLoot` call is mid-flight, `false`
|
||||
otherwise. Use to gate re-scan attempts so addons don't spam-call
|
||||
during the engine's per-corpse round-trip.
|
||||
|
||||
### `C_Loot.GetLastScanResults()`
|
||||
|
||||
Returns the most-recent completed-scan results as a 1-indexed array:
|
||||
|
||||
```lua
|
||||
{
|
||||
[1] = {
|
||||
guid = "0x...", -- corpse GUID
|
||||
coin = 1234, -- copper amount; 0 if no coin
|
||||
items = {
|
||||
[1] = {
|
||||
itemID = 2589,
|
||||
count = 4,
|
||||
link = "|cff...|Hitem:2589:0:0:0|h[Linen Cloth]|h|r",
|
||||
-- omitted if cache entry wasn't loaded
|
||||
},
|
||||
...
|
||||
},
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Empty table before the first scan completes. Replaced wholesale by
|
||||
each new scan — re-read after `LOOT_SCAN_COMPLETED` rather than
|
||||
caching the table.
|
||||
|
||||
The `link` field is the engine's full hyperlink (color + payload +
|
||||
display name + reset). Random-suffix items like "Stringy Wolf Meat
|
||||
of the Bear" survive round-trip — the link encodes the per-instance
|
||||
enchant / suffix / unique fields, not just the base itemID. For
|
||||
tooltip display, extract the payload form via
|
||||
`string.match(link, "|H(item:[^|]+)|h")` — vanilla's `SetHyperlink`
|
||||
requires the literal `"item:"` prefix and rejects full `|cff...|Hitem...|h`
|
||||
input.
|
||||
|
||||
## Macros
|
||||
|
||||
Engine-level extensions to how macros are parsed and dispatched. These
|
||||
@@ -5282,6 +5569,74 @@ MPQ will appear in both the loose and mpq lists).
|
||||
The vanilla legacy globals `GetNumMacroIcons` / `GetMacroIconInfo`
|
||||
remain available unchanged.
|
||||
|
||||
## Mail
|
||||
|
||||
Backports of modern WoW's mail-attachment link getters. Both follow
|
||||
the modern signature including the optional `attachmentIndex` arg
|
||||
(added with TBC's multi-attachment mail), even though vanilla 1.12
|
||||
only supports one attachment per message — pass-through compatibility
|
||||
for addons originally written against later API versions. Any
|
||||
`attachmentIndex` other than `1` is treated as no-op.
|
||||
|
||||
Both functions return `(link, itemID)` — modern returns just `link`,
|
||||
but `itemID` is cheap to surface at the same lookup site and saves
|
||||
callers a `string.match` on the link.
|
||||
|
||||
### `GetSendMailItemLink([attachmentIndex])`
|
||||
|
||||
Returns the **fully-decorated per-instance** hyperlink for the item
|
||||
currently attached to the outgoing-mail slot, plus that item's
|
||||
`itemID`. Same enchant / random-suffix / unique-ID decoration as
|
||||
`GetContainerItemLink` would return for the same item back in the
|
||||
player's bag — the attached item still exists as a live CGItem until
|
||||
the mail is sent, so the link builder has full instance state to
|
||||
draw from.
|
||||
|
||||
Returns nothing when:
|
||||
- No item is attached.
|
||||
- `attachmentIndex` is anything other than `nil` or `1`.
|
||||
- The attachment's CGItem can't be resolved (shouldn't normally
|
||||
happen — engine writes the GUID on attach and clears it on detach).
|
||||
|
||||
```lua
|
||||
local link, itemID = GetSendMailItemLink()
|
||||
if link then
|
||||
ChatFrame1:AddMessage("Attached: " .. link .. " (id " .. itemID .. ")")
|
||||
end
|
||||
```
|
||||
|
||||
### `GetInboxItemLink(messageIndex[, attachmentIndex])`
|
||||
|
||||
Returns the **basic itemID-only** hyperlink (`|cff…|Hitem:N:0:0:0|h[Name]|h|r`)
|
||||
for the item attached to inbox message `messageIndex` (1-based),
|
||||
plus the `itemID`.
|
||||
|
||||
**Why basic-link instead of per-instance:** vanilla inbox entries do
|
||||
store per-instance modifiers inline (enchant at `+0x12C`, suffix
|
||||
factor at `+0x134`, random property at `+0x138` — the same fields
|
||||
`GameTooltip:SetInboxItem` copies onto the tooltip to render a fully-
|
||||
decorated preview). We intentionally ignore them in the link to match
|
||||
modern 3.3.5's behavior — its `GetInboxItemLink` calls the
|
||||
itemID-only link builder (`FUN_0061E290(itemID)`) and ignores the
|
||||
per-instance fields. Per-instance data only fully manifests on the
|
||||
client side once the player takes the item out of the mail and the
|
||||
engine spawns a real CGItem; the inbox-entry fields are display-only
|
||||
hints used by the tooltip builder.
|
||||
|
||||
Errors on missing or non-numeric `messageIndex`. Returns nothing when:
|
||||
- `messageIndex` is out of range (`< 1` or `> GetInboxNumItems()`).
|
||||
- The message slot is empty / has no attached item.
|
||||
- `attachmentIndex` is anything other than `nil` or `1`.
|
||||
|
||||
```lua
|
||||
for i = 1, GetInboxNumItems() do
|
||||
local link, itemID = GetInboxItemLink(i)
|
||||
if link then
|
||||
ChatFrame1:AddMessage(i .. ": " .. link .. " (id " .. itemID .. ")")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Map
|
||||
|
||||
### `C_Map.GetBestMapForUnit(unitToken)`
|
||||
@@ -5728,6 +6083,35 @@ instance lives at `0x00C0E228`; entry layout (name, realm, race,
|
||||
sex, class) was reverse-engineered from the
|
||||
`SMSG_NAME_QUERY_RESPONSE` write path at `0x0055F310`.
|
||||
|
||||
### `UnitNameFromGUID(guid)`
|
||||
|
||||
Returns `name, realm` for the player identified by `guid`, or `nil`
|
||||
if no player with that GUID has been encountered yet. Same lookup
|
||||
chain as [`GetPlayerInfoByGUID`](#getplayerinfobyguidguid) (engine
|
||||
NameCache, persistent on-disk fallback when enabled) — just narrower
|
||||
return shape for callers that only need the name.
|
||||
|
||||
```lua
|
||||
local name, realm = UnitNameFromGUID(UnitGUID("target"))
|
||||
if name then
|
||||
ChatFrame1:AddMessage("Target: " .. name)
|
||||
end
|
||||
|
||||
-- Resolving a chat link
|
||||
local name, realm = UnitNameFromGUID("0x0000000000000777")
|
||||
```
|
||||
|
||||
`realm` is always `""` in vanilla — the engine doesn't populate
|
||||
per-player realm names and 1.12 has no cross-realm interaction.
|
||||
We push the empty string rather than `nil` to match the convention
|
||||
of the other player-info accessors; addons can gate on `realm == ""`.
|
||||
|
||||
Doesn't trigger a network query on miss. To populate an unknown
|
||||
GUID, either let the engine receive a `SMSG_NAME_QUERY_RESPONSE`
|
||||
through normal chat/target interaction, or call
|
||||
[`C_PlayerCache.RememberPlayer`](#c_playercacherememberplayerguid-name-classtoken)
|
||||
explicitly.
|
||||
|
||||
### `C_PlayerCache.GetPlayerInfoByName(name)`
|
||||
|
||||
Returns
|
||||
@@ -6270,6 +6654,40 @@ A separate function keeps the existing call wire-compatible.
|
||||
> first, skip zero slots, then the item array. 1-based `objectiveIndex`
|
||||
> counts only non-empty slots.
|
||||
|
||||
## Screen
|
||||
|
||||
### `GetPhysicalScreenSize()`
|
||||
|
||||
Returns `(widthPixels, heightPixels)` — the display's physical
|
||||
resolution in pixels. Modern WoW added this as a native in 7.0
|
||||
(Legion); FrameXML's `PixelUtil` builds its entire pixel↔UI-unit
|
||||
conversion on it (`768.0 / physicalHeight`), which is what lets addons
|
||||
snap frames to whole pixels for crisp borders at fractional UI scales.
|
||||
|
||||
```lua
|
||||
local w, h = GetPhysicalScreenSize() -- e.g. 1920, 1080
|
||||
```
|
||||
|
||||
Vanilla 1.12 has no such native — and no pre-parsed pixel-dimension
|
||||
global. `GetScreenWidth()`/`GetScreenHeight()` return UI-space units
|
||||
(derived from the UIParent aspect ratio at `[UIParent + 0x7c]`), not
|
||||
pixels. The engine only ever holds the current mode as the
|
||||
`gxResolution` CVar string (`"WIDTHxHEIGHT"`); both `SetScreenResolution`
|
||||
(`0x0048bfd0`) and the display-init path sscanf it on demand rather
|
||||
than caching dimensions anywhere. So we read the `gxResolution` CVar
|
||||
directly through the engine's by-name lookup (`FUN_FIND_CVAR`, value at
|
||||
`+0x20`) and parse it — the same source of truth the engine uses.
|
||||
|
||||
Returns `1024, 768` (→ factor 1.0, making PixelUtil a 1:1 no-op) when
|
||||
the CVar is missing or unparseable. Caveat: in windowed mode
|
||||
`gxResolution` reflects the configured render resolution, which may
|
||||
differ from the OS window's client size — vanilla exposes no separate
|
||||
window-pixel query.
|
||||
|
||||
The companion `PixelUtil` table (`PixelUtil.SetPoint`/`SetSize`/
|
||||
`SetStatusBarValue`/…) is provided Lua-side by the ClassicAPI addon and
|
||||
consumes this function.
|
||||
|
||||
## Spell
|
||||
|
||||
> **All `C_Spell.*` functions in this section accept any spell
|
||||
@@ -6527,6 +6945,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
|
||||
@@ -6886,6 +7432,70 @@ addons reading positions 4..8 by index still work.
|
||||
Equivalent to the extension of `GetWeaponEnchantInfo` introduced
|
||||
in 3.x.
|
||||
|
||||
### `C_Item.GetEnchantInfo(enchantID)`
|
||||
|
||||
Resolves an item-enchantment ID — the `enchantID` returned by
|
||||
[`C_Item.GetWeaponEnchantInfo`](#c_itemgetweaponenchantinfo) for a
|
||||
weapon's temporary enchant, and the same IDs item permanent enchants
|
||||
use — into a table:
|
||||
|
||||
```lua
|
||||
local info = C_Item.GetEnchantInfo(enchantID)
|
||||
-- info.enchantID = <id>
|
||||
-- info.name = "Crusader" -- localized display name
|
||||
-- info.effects = { {type=1, amount=0, arg=20007} }
|
||||
-- info.spellID = 20007 -- spell-type enchants only
|
||||
```
|
||||
|
||||
```lua
|
||||
-- A proc/equip enchant → chain its spellID into C_Spell:
|
||||
local info = C_Item.GetEnchantInfo(1900) -- "Crusader"
|
||||
if info.spellID then
|
||||
print(C_Spell.GetSpellDescription(info.spellID)) -- the proc's text
|
||||
end
|
||||
```
|
||||
|
||||
`effects` is an array of the record's non-empty effect slots, each
|
||||
`{ type, amount, arg }` where `type` is the standard
|
||||
`ITEM_ENCHANTMENT_TYPE`:
|
||||
|
||||
| type | meaning | carries |
|
||||
|------|---------|---------|
|
||||
| 1 | combat-proc spell | `arg` = spellID |
|
||||
| 2 | weapon damage | `amount` = +damage |
|
||||
| 3 | equip spell / aura | `arg` = spellID |
|
||||
| 4 | resistance / armor | `amount` = +value |
|
||||
| 5 | stat | `arg` = stat index, `amount` = value |
|
||||
| 6 | totem | — |
|
||||
| 7 | use spell | `arg` = spellID |
|
||||
|
||||
For spell types (1/3/7) `arg` is a spellID feedable into
|
||||
[`C_Spell.GetSpellInfo`](#getspellinfospellid--getspellinfoslot-booktype) /
|
||||
`GetSpellDescription`; the first such id is also surfaced at top level
|
||||
as `spellID` for convenience (absent for non-spell enchants like
|
||||
sharpening stones).
|
||||
|
||||
Returns `nil` for a non-numeric / non-positive id, an out-of-range
|
||||
id, or a record with no name.
|
||||
|
||||
Reads `SpellItemEnchantment.dbc` (records `0x00C0D7D8`, count
|
||||
`0x00C0D7DC`) — the 24-column table every enchant ID indexes:
|
||||
`Type[3]@+0x04`, `Amount[3]@+0x10`, `EffectArg[3]@+0x28`,
|
||||
`Name[8]@+0x34` (locale-indexed). The layout was verified by parsing
|
||||
the on-disk DBC against known records (Crusader 1900 → type 1, arg
|
||||
20007; Sharpened +3 → type 2, amount 3). Fully resident from boot, so
|
||||
it answers for any enchant ID with no caching or round-trip.
|
||||
|
||||
Lives in `C_Item` (not `C_Spell`) because the id originates from
|
||||
`C_Item.GetWeaponEnchantInfo` and the concept is an item enchantment —
|
||||
`SpellItemEnchantment` is just the DBC's internal name (enchants are
|
||||
*implemented* via spell effects).
|
||||
|
||||
> **Not derivable: the source item.** The enchant record holds no
|
||||
> back-reference to the item that applied it, and vanilla has no
|
||||
> client-side reverse index (enchantID → item). Finding it would need
|
||||
> an external scraped DB (pfQuest/Questie-style).
|
||||
|
||||
### `IsHarmfulSpell(spell)` / `IsHelpfulSpell(spell)`
|
||||
|
||||
Classify a spell as offensive (`IsHarmfulSpell`) or non-offensive
|
||||
@@ -7081,6 +7691,36 @@ The companion item version is
|
||||
[`C_Item.UseAtCursor`](#c_itemuseatcursoriteminfo) — same chain via
|
||||
the item-use path for grenades / on-use ground-target items.
|
||||
|
||||
### `C_Spell.CastAtUnit(spellID, unit)`
|
||||
|
||||
Casts a ground-target spell at `unit`'s feet, bypassing the AoE
|
||||
reticle click. ClassicAPI's analog of modern's
|
||||
`/cast [@unit] Flamestrike` — `CastAtUnit(spellID, "player")` drops it
|
||||
at the player's position, `"target"` at the target's, and so on for
|
||||
any unit token (`"mouseover"`, `"party1"`, `"raid7"`, …). Returns
|
||||
`true` when the placement landed at the unit; `false` for
|
||||
non-ground-target spells (cast still fires normally), unknown
|
||||
spellIDs, or a unit with no resolvable position.
|
||||
|
||||
```lua
|
||||
C_Spell.CastAtUnit(2120, "target") -- Flamestrike at the target's feet
|
||||
C_Spell.CastAtUnit(10, "player") -- Blizzard centered on yourself
|
||||
```
|
||||
|
||||
Same two-stage shape as
|
||||
[`C_Spell.CastAtCursor`](#c_spellcastatcursorspellid), but instead of
|
||||
the cursor raycast, step 2 reads the unit's world position from its
|
||||
`GetPosition` virtual (the same one `CheckInteractDistance` /
|
||||
`UnitInRange` use) and commits placement there via
|
||||
`FUN_COMMIT_PLACEMENT_COORDS`. The unit is resolved *before* the cast
|
||||
is dispatched, so an absent/invalid unit fails cleanly without
|
||||
leaving the engine armed in placement mode. A genuinely unrecognized
|
||||
unit-token string raises the engine's standard "Unknown unit" error,
|
||||
matching `UnitHealth("garbage")`.
|
||||
|
||||
The companion item version is
|
||||
[`C_Item.UseAtUnit`](#c_itemuseatunititeminfo-unit).
|
||||
|
||||
### `C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`
|
||||
|
||||
Cancel a buff on the player by spellID or by spell name. Modern WoW's
|
||||
@@ -7116,6 +7756,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,
|
||||
@@ -7976,6 +8684,33 @@ end
|
||||
> ex-targets, ex-mouseover units, distant players seen in the chat
|
||||
> log, etc. The engine simply doesn't address those by token.
|
||||
|
||||
### `UnitSubName(unit)`
|
||||
|
||||
Returns the NPC's subtitle / title string (the small italic text
|
||||
under their name in the tooltip): `"Innkeeper"`, `"Stable Master"`,
|
||||
`"<Master Engineer>"`, etc. Returns `nil` for players, unsynced
|
||||
NPCs, and NPCs with no subtitle.
|
||||
|
||||
```lua
|
||||
/dump UnitSubName("target")
|
||||
-- "Innkeeper" (Stormwind innkeep)
|
||||
-- "<Auction House>" (auctioneer)
|
||||
-- nil (a player, or a wolf in the woods)
|
||||
```
|
||||
|
||||
Modern WoW returns the subtitle as `UnitName`'s second return;
|
||||
vanilla 1.12's `UnitName` returns only one value, leaving no
|
||||
direct route to the subtitle. Addons that wanted it had to scrape
|
||||
`GameTooltip` text, which is fragile and required the unit to be
|
||||
hovered. `UnitSubName` reads it straight off the engine's creature
|
||||
cache (`[unit + 0xB30] → [+0x10]`), no tooltip round-trip.
|
||||
|
||||
Coverage caveat: the creature cache row is populated when an NPC
|
||||
becomes visible to the client AND the server has sent its
|
||||
`CMSG_CREATURE_QUERY_RESPONSE`. The first time a fresh NPC appears
|
||||
the row may be briefly NULL — usually only one or two frames.
|
||||
Subsequent calls succeed once the response lands.
|
||||
|
||||
### `GetUnitSpeed(unit)`
|
||||
|
||||
Returns `currentSpeed, runSpeed, flightSpeed, swimSpeed` — all four
|
||||
|
||||
@@ -236,6 +236,25 @@ void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
}
|
||||
} // namespace Lua
|
||||
|
||||
namespace Console {
|
||||
namespace {
|
||||
using RegisterCommand_t = int(__fastcall *)(const char *name, void *handler,
|
||||
int category, const char *description);
|
||||
using Write_t = void(__fastcall *)(const char *line, int colorFlag);
|
||||
} // namespace
|
||||
|
||||
void RegisterCommand(const char *name, CommandHandler handler, int category,
|
||||
const char *description) {
|
||||
auto fn = reinterpret_cast<RegisterCommand_t>(Offsets::FUN_CONSOLE_COMMAND_REGISTER);
|
||||
fn(name, reinterpret_cast<void *>(handler), category, description);
|
||||
}
|
||||
|
||||
void Write(const char *line) {
|
||||
auto fn = reinterpret_cast<Write_t>(Offsets::FUN_CONSOLE_WRITE);
|
||||
fn(line, 0);
|
||||
}
|
||||
} // namespace Console
|
||||
|
||||
namespace {
|
||||
ModuleAutoRegister *g_moduleHead = nullptr;
|
||||
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
|
||||
|
||||
+44
@@ -242,6 +242,50 @@ void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
const char *fallback, int n);
|
||||
} // namespace Lua
|
||||
|
||||
// Developer-console command system — the `~` console available when the
|
||||
// client is launched with `-console`. Vanilla maintains a
|
||||
// `TSExplicitList<CONSOLECOMMAND>`; these wrap the engine's registrar
|
||||
// and output paths. See the "Console commands & Interface file export"
|
||||
// section in CLAUDE.md for the underlying offsets and ABI.
|
||||
namespace Console {
|
||||
|
||||
// Console-command handler ABI: `args` is the command-line text after
|
||||
// the command name (empty string when invoked bare). The engine
|
||||
// ignores the return value; return 1 by convention.
|
||||
using CommandHandler = int(__fastcall *)(void *unused, const char *args);
|
||||
|
||||
// Help-grouping categories — the engine's `help` enumerates these nine
|
||||
// (in this order). Cosmetic: the value only affects which group `help`
|
||||
// lists the command under; the engine doesn't validate it. Values are
|
||||
// sequential table indices (graphics=1 confirmed via FUN_0066F6C0, the
|
||||
// rest follow the displayed order).
|
||||
enum Category {
|
||||
CATEGORY_DEBUG = 0,
|
||||
CATEGORY_GRAPHICS = 1,
|
||||
CATEGORY_CONSOLE = 2,
|
||||
CATEGORY_COMBAT = 3,
|
||||
CATEGORY_GAME = 4,
|
||||
CATEGORY_DEFAULT = 5,
|
||||
CATEGORY_NET = 6,
|
||||
CATEGORY_SOUND = 7,
|
||||
CATEGORY_GM = 8,
|
||||
};
|
||||
|
||||
// Registers a console command. `name` and `description` are stored BY
|
||||
// POINTER (the engine doesn't copy them), so pass string literals or
|
||||
// otherwise process-lifetime storage. Dedup-safe: re-registering the
|
||||
// same name is a harmless no-op, so it's fine to call from a hook that
|
||||
// fires more than once. Safe any time after engine boot — the command
|
||||
// table self-initializes on first use. `description` may be nullptr.
|
||||
void RegisterCommand(const char *name, CommandHandler handler, int category,
|
||||
const char *description);
|
||||
|
||||
// Writes a line to the console output buffer. No-ops cleanly when the
|
||||
// console isn't active, so it's always safe to call.
|
||||
void Write(const char *line);
|
||||
|
||||
} // namespace Console
|
||||
|
||||
// Self-registration for API modules. Each module .cpp declares a file-scope
|
||||
// `static const Game::ModuleAutoRegister _r{&RegisterLuaFunctions};`, which
|
||||
// chains itself onto a global list at DLL-load time. `RunModuleRegistrations`
|
||||
|
||||
+438
-10
@@ -286,7 +286,10 @@ enum Offsets {
|
||||
// holds the local player's 64-bit GUID, and the visible-object
|
||||
// iterator at FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS walks its
|
||||
// +0xAC list. Useful for "is this GUID me?" checks without
|
||||
// round-tripping through Lua.
|
||||
// round-tripping through Lua. Also a NULL-check proxy for "is
|
||||
// the object manager initialised" — engine iterators deref this
|
||||
// unconditionally on entry, so callers must skip on the glue /
|
||||
// character-select screen.
|
||||
VAR_LOCAL_PLAYER_PTR = 0x00B41414,
|
||||
OFF_LOCAL_PLAYER_GUID = 0xC0,
|
||||
// `__fastcall(ecx = const char *token) → uint64_t GUID` — the
|
||||
@@ -402,6 +405,23 @@ enum Offsets {
|
||||
// descriptor at +0x114 — these are sibling classes under CGObject
|
||||
// with class-specific descriptor offsets.
|
||||
OFF_UNIT_DESCRIPTOR = 0x110,
|
||||
|
||||
// CGCreature client-side creature-data cache row. Populated by the
|
||||
// engine when an NPC GUID becomes visible (the same row the
|
||||
// `CreatureCache` SMSG fills with the templated NPC's name,
|
||||
// subname, etc.). NULL for unsynced units or for player CGUnits
|
||||
// (players don't have a row in this cache — their name comes from
|
||||
// the SMSG_NAME_QUERY_RESPONSE-fed name cache at `0x00C0E228`).
|
||||
//
|
||||
// Layout of the cache row:
|
||||
// +0x00 ?
|
||||
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
|
||||
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
|
||||
//
|
||||
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
|
||||
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
|
||||
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
|
||||
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
|
||||
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
|
||||
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
|
||||
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
|
||||
@@ -607,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
|
||||
@@ -1397,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,
|
||||
@@ -2361,6 +2416,41 @@ 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
|
||||
|
||||
// SpellItemEnchantment.dbc — the table a weapon/item enchant ID
|
||||
// (e.g. the enchantID from `C_Item.GetWeaponEnchantInfo`) indexes.
|
||||
// Records-ptr at instance +0x08, count at +0x0C (instance 0x00C0D7D0).
|
||||
// 24-DWORD records (recordSize 0x60). Layout fully verified by
|
||||
// parsing the on-disk SpellItemEnchantment.dbc against known records
|
||||
// (Crusader 1900: Type[0]=1, Arg[0]=20007 "Holy Strength";
|
||||
// Sharpened+3 id 13: Type[0]=2, Amount[0]=3; Reinforced Armor+8 id 15:
|
||||
// Type[0]=4, Amount[0]=8). OFF_..._NAME (+0x34) is cross-confirmed by
|
||||
// two engine readers (FUN_00496170 / FUN_00495d60) that push
|
||||
// `[rec + 0x34 + VAR_LOCALE_INDEX*4]` as the display name.
|
||||
// Type[i] : ITEM_ENCHANTMENT_TYPE — 1=combat-proc spell,
|
||||
// 2=damage, 3=equip spell, 4=resistance, 5=stat,
|
||||
// 6=totem, 7=use spell. 0 = empty slot.
|
||||
// Amount[i] : magnitude for damage/resist/stat types (min; vanilla
|
||||
// min==max). 0 for spell types (value lives in the spell).
|
||||
// Arg[i] : spellID for spell types (1/3/7); stat index for STAT.
|
||||
VAR_SPELLITEMENCHANT_RECORDS = 0x00C0D7D8, // SpellItemEnchantmentRecord *records[enchantID]
|
||||
VAR_SPELLITEMENCHANT_COUNT = 0x00C0D7DC, // max enchant ID
|
||||
OFF_SPELLITEMENCHANT_TYPE = 0x04, // int Type[3]
|
||||
OFF_SPELLITEMENCHANT_AMOUNT = 0x10, // int Amount[3] (EffectPointsMin)
|
||||
OFF_SPELLITEMENCHANT_ARG = 0x28, // int EffectArg[3]
|
||||
OFF_SPELLITEMENCHANT_NAME = 0x34, // char *name[8], 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
|
||||
@@ -2386,6 +2476,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
|
||||
@@ -2724,8 +2843,12 @@ enum Offsets {
|
||||
// trailing zeros, hands the buffer to the caller. Caller frees
|
||||
// via `FUN_STORM_SMEM_FREE`.
|
||||
//
|
||||
// __cdecl int FileRead(
|
||||
// int unused, // always 0 from engine call sites
|
||||
// __stdcall int FileRead( // callee cleans 28 bytes (RET 0x1C);
|
||||
// // NOT __cdecl — see src/addons/Embedded.cpp
|
||||
// int archive, // 0 = merged VFS; or an archive
|
||||
// // handle to read from one MPQ
|
||||
// // (e.g. FUN_00648FB0 reads its
|
||||
// // "(listfile)" this way)
|
||||
// const char *path, // "Interface\AddOns\X\Y.lua"
|
||||
// void **outBuf, // Storm-allocated content
|
||||
// size_t *outSize, // optional, may be NULL
|
||||
@@ -2740,6 +2863,85 @@ enum Offsets {
|
||||
// normal `SMemFree` works without us tracking lifetime.
|
||||
FUN_FILE_READ = 0x00648620,
|
||||
|
||||
// MPQ file enumerator — walks every mounted archive's `(listfile)`
|
||||
// and invokes a callback for each entry whose path begins with a
|
||||
// given prefix. This is the engine's flat, recursive name walk (the
|
||||
// `SFileEnumFiles` analog), NOT a per-directory find. It's the
|
||||
// function the macro-icon loader `FUN_LOAD_MACRO_ICONS` uses to
|
||||
// surface MPQ-baked icons under `Interface\Icons\`.
|
||||
//
|
||||
// void __fastcall MpqEnumFiles(
|
||||
// int archiveSelector, // ecx — archive-set selector;
|
||||
// // the loader passes 6.
|
||||
// // Covers the primary
|
||||
// // mounted archives plus
|
||||
// // one selected extra.
|
||||
// const char *pathPrefix, // edx — e.g. "Interface\" —
|
||||
// // only listfile entries
|
||||
// // under this prefix are
|
||||
// // reported.
|
||||
// FileCb callback, // per-file callback (below)
|
||||
// void *userParam); // loader passes 0
|
||||
//
|
||||
// The callback is `int __fastcall(const char *fullPath /*ecx*/)`;
|
||||
// `fullPath` is the entry's full archive-relative path (e.g.
|
||||
// "Interface\FrameXML\Foo.lua"). Returning 0 STOPS enumeration —
|
||||
// return non-zero to keep going (note: the OPPOSITE of the disk
|
||||
// find at `FindFirstFileW`). The continue/stop sense is verified in
|
||||
// the per-archive walker `FUN_00648FB0`, whose loop does
|
||||
// `if (... && callback() == 0) break;`.
|
||||
//
|
||||
// Internally: `FUN_00401470` loops the archive-handle array
|
||||
// (`DAT_008826BC`, count `DAT_00882740`) calling the per-archive
|
||||
// listfile walker `FUN_00648FB0`, which reads each archive's
|
||||
// `(listfile)` via `FUN_FILE_READ`, splits on CR/LF, prefix-filters
|
||||
// via a trampoline, and dispatches the callback. Used by
|
||||
// `Interface::Export` (`ExportInterfaceFiles`).
|
||||
//
|
||||
// NB: the sibling `FUN_0042AD10` is a DISK-only walker (it resolves
|
||||
// to kernel32 `FindFirstFileW` and never sees MPQ contents) — don't
|
||||
// confuse the two. The macro `Icons.cpp` callback labels have the
|
||||
// disk/MPQ roles swapped; this entry is the authoritative note.
|
||||
FUN_MPQ_ENUM_FILES = 0x00401470,
|
||||
|
||||
// Console-command registrar — the vanilla equivalent of 4.3.4's
|
||||
// `FUN_00654c90`. Registers a developer-console command (the `~`
|
||||
// console you get when launching with `-console`).
|
||||
//
|
||||
// int __fastcall RegisterConsoleCommand(
|
||||
// const char *name, // ecx — command name; stored BY
|
||||
// // POINTER (no copy), so must
|
||||
// // have static lifetime
|
||||
// Handler handler, // edx — see below
|
||||
// int category, // help-grouping category id
|
||||
// const char *description);// help text, or NULL
|
||||
// Returns 1 on success, 0 if a command with that name already
|
||||
// exists (dedup-safe — repeat calls are harmless no-ops). The
|
||||
// command node is never freed for a static-literal name, so we
|
||||
// don't need SStrDup the way the event table does.
|
||||
//
|
||||
// The handler is `int __fastcall(void *unused /*ecx*/, const char
|
||||
// *args /*edx*/)`; `args` is the text after the command name
|
||||
// (`*args == '\0'` when invoked bare). Return 1. Categories seen in
|
||||
// the binary: graphics=1, help=2, misc=5 (we use 5, matching
|
||||
// 4.3.4's `ExportInterfaceFiles`); the value is cosmetic (only
|
||||
// affects `help` grouping) and unvalidated by the registrar.
|
||||
//
|
||||
// Self-initializes the command hash table on first use, so it is
|
||||
// safe to call any time after the engine's own console subsystem
|
||||
// boots (well before our glue / load-script hooks fire). Verified
|
||||
// via the bulk graphics registrar `FUN_0066F6C0` and the `help`
|
||||
// command's own registration in `FUN_006400E0`.
|
||||
FUN_CONSOLE_COMMAND_REGISTER = 0x0063F9E0,
|
||||
|
||||
// `ConsoleWrite(const char *line /*ecx*/, int colorFlag /*edx*/)` —
|
||||
// appends a line to the developer console's output buffer. No-ops
|
||||
// cleanly when the console isn't active (gated on the console-init
|
||||
// globals `DAT_00C4EC1C` / `DAT_00C4EC34`), so it's safe to call
|
||||
// unconditionally from a console-command handler. `colorFlag` 0 =
|
||||
// default. Used by `Interface::Export` for "wrote N files" feedback.
|
||||
FUN_CONSOLE_WRITE = 0x0063CB50,
|
||||
|
||||
// TOC parser — `__fastcall(this=addonName)`. Already documented in
|
||||
// CLAUDE.md under "AddOn registry & hot-reload". Dedup-safe: if
|
||||
// the addon is already in the hash table, returns immediately
|
||||
@@ -3283,13 +3485,19 @@ enum Offsets {
|
||||
// Bits 4-7 — WASD movement-key state. Each `MoveForwardStart`
|
||||
// / `StrafeLeftStart` / etc. pushes one of these as the bit
|
||||
// mask into the engine's button-press handler. Bit `0x1000` is
|
||||
// the autorun-active flag (set by `ToggleAutoRun` and the
|
||||
// both-mouse-buttons combo). `INPUT_FLAGS_MOVING_ANY` is the
|
||||
// engine's own "is the user currently inputting translational
|
||||
// movement" mask, used to drive `PLAYER_STARTED_MOVING` —
|
||||
// matches modern's "STOPPED fires on key release, even
|
||||
// mid-air" semantics because no physics/airborne bit is
|
||||
// involved.
|
||||
// the autorun-active flag (set by `ToggleAutoRun`).
|
||||
// `INPUT_FLAGS_MOVING_ANY` is the engine's own "is the user
|
||||
// currently inputting translational movement via keys/autorun"
|
||||
// mask, used to drive `PLAYER_STARTED_MOVING` — matches modern's
|
||||
// "STOPPED fires on key release, even mid-air" semantics because
|
||||
// no physics/airborne bit is involved.
|
||||
//
|
||||
// NOTE: the "hold both mouse buttons to run forward" gesture is
|
||||
// NOT in this mask. The button handler (`FUN_00514840`) drives
|
||||
// that forward move directly via `FUN_005103e0` and explicitly
|
||||
// *clears* the autorun bit when both buttons go down. Detect it
|
||||
// from `INPUT_FLAG_MOUSELOOK & INPUT_FLAG_FREE_LOOK` both held
|
||||
// instead — see `Player::InputEvents`.
|
||||
INPUT_FLAG_MOVE_FORWARD = 0x10,
|
||||
INPUT_FLAG_MOVE_BACKWARD = 0x20,
|
||||
INPUT_FLAG_STRAFE_LEFT = 0x40,
|
||||
@@ -3351,6 +3559,174 @@ enum Offsets {
|
||||
VAR_LOOT_LOOTABLE = 0x00B71BA0,
|
||||
LOOT_SLOT_STRIDE = 0x1C,
|
||||
LOOT_MAX_SLOTS = 0x10,
|
||||
// Per-slot field offsets within a `VAR_LOOT_SLOTS + slot * 0x1C`
|
||||
// entry. Decoded from `Script_GetLootSlotInfo` and the populator
|
||||
// at `FUN_004C1CB0`:
|
||||
// +0x00 itemID (Script_GetLootSlotInfo's first push)
|
||||
// +0x04 displayInfoID (icon lookup via DBC index)
|
||||
// +0x08 count (FUN_004C22E0 reads `[base+0x8]`)
|
||||
// +0x0C ?
|
||||
// +0x10 ?
|
||||
// +0x14 wire-slot byte (the server-side slot index — what
|
||||
// `CMSG_LOOT_ITEM` expects; differs from the local
|
||||
// densely-packed index when the response had gaps)
|
||||
OFF_LOOT_SLOT_COUNT = 0x08,
|
||||
OFF_LOOT_SLOT_WIRE_INDEX = 0x14,
|
||||
|
||||
// `__fastcall(uint userFacingSlot, char *outBuf, int bufSize) →
|
||||
// const char *outBuf` — builds the loot-slot hyperlink string
|
||||
// (e.g. `|cff1eff00|Hitem:12345:0:0:0|h[Item Name]|h|r`) and
|
||||
// copies it into `outBuf`. Returns the buffer pointer on success,
|
||||
// `NULL` if no loot window is open, the slot is empty, or the
|
||||
// item's cache record isn't loaded yet. Used by
|
||||
// `Script_GetLootSlotLink` at `0x004C2D20`.
|
||||
//
|
||||
// `userFacingSlot` is the post-`__ftol`-decrement slot index — the
|
||||
// same shape Lua's `LootSlot(slot)` produces internally after
|
||||
// `dec eax`:
|
||||
// - When coin is present (`VAR_LOOT_LOOTABLE != 0`), input `0`
|
||||
// is the coin slot itself and returns NULL; subsequent
|
||||
// internal items live at `userFacingSlot = internal_slot + 1`
|
||||
// (the function dec's again inside).
|
||||
// - When no coin, `userFacingSlot = internal_slot` directly.
|
||||
// The internal `_GetRecord` call inside uses `callback = NULL`, so
|
||||
// the link is a pure cache hit — no network request gets queued
|
||||
// when the cache is cold, just a NULL return.
|
||||
FUN_LOOT_SLOT_LINK_BUILDER = 0x004C2670,
|
||||
|
||||
// `__stdcall(uint8_t slotByte)` — sends `CMSG_LOOT_ITEM` (opcode
|
||||
// `0x108`) carrying the wire-protocol slot byte for the currently-
|
||||
// open loot window. Built directly from the engine's packet
|
||||
// primitives — bypasses the `Script_LootSlot` Lua wrapper at
|
||||
// `FUN_004C2790`, which routes through a `param_2 != 0`
|
||||
// BoP-confirm-after-dialog branch that's of no use to us. The
|
||||
// server cheerfully takes whatever slot you send for the current
|
||||
// loot session; no client-side BoP confirmation gate.
|
||||
//
|
||||
// **Calling convention matters.** The callee reads its arg from
|
||||
// `[ebp+8]` (stack) and ends with `ret 4`. Mis-typedef'ing as
|
||||
// `__fastcall` produces a stack-corrupting caller — symptoms are
|
||||
// delayed crashes on unrelated vtable reads.
|
||||
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
|
||||
|
||||
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
|
||||
// server-driven dynamic-state bitmask. Empirically verified by
|
||||
// cross-referencing three `Script_*` accessors that all read
|
||||
// bytes at `[m_objectFields + 0x224]`:
|
||||
// - `Script_UnitIsTapped` (`0x00519C90`): `test [eax+0x224], 0x04`
|
||||
// - `Script_UnitIsTappedByPlayer` (`0x00519D00`): `test [eax+0x224], 0x08`
|
||||
// - `Script_UnitIsDead` (`0x00517AC0`): `[eax+0x224] >> 5 & 1`
|
||||
// Bit values match CMaNGOS conventions: 0x01 LOOTABLE,
|
||||
// 0x02 TRACK_UNIT, 0x04 TAPPED, 0x08 TAPPED_BY_PLAYER,
|
||||
// 0x10 SPECIALINFO, 0x20 DEAD, 0x40 REFER_A_FRIEND_LINKED,
|
||||
// 0x80 ZONE_OUT. The +0x224 offset is significantly later than
|
||||
// CMaNGOS-doc'd vanilla (which puts DYNAMIC_FLAGS at field 0x46
|
||||
// = +0x118) — yet another non-uniform shift in 1.12.1's actual
|
||||
// UNIT_FIELD layout. Direct empirical read is the authority.
|
||||
OFF_UNIT_FIELD_DYNAMIC_FLAGS = 0x224,
|
||||
UNIT_DYNFLAG_LOOTABLE = 0x01,
|
||||
UNIT_DYNFLAG_DEAD = 0x20,
|
||||
|
||||
// UNIT_FIELD_BOUNDINGRADIUS within m_objectFields — single
|
||||
// float, the unit's interact-radius in world yards. Both
|
||||
// `FUN_005EC110` (interact-state check) and `FUN_005DF130`
|
||||
// (CMSG_LOOT sender) compute the interaction-range threshold
|
||||
// as `target_radius + player_radius + INTERACT_REACH`, clamped
|
||||
// to a floor of `MIN_INTERACT_RANGE`. Verified at `0x005EC1A4`
|
||||
// where the function reads
|
||||
// `[(target_m_objectFields) + 0x1F0]`.
|
||||
OFF_UNIT_FIELD_BOUNDING_RADIUS = 0x1F0,
|
||||
|
||||
// `__thiscall(player, target, useDistanceCheck)` — engine's
|
||||
// CMSG_LOOT sender for **dead mobs** (CGCreature_C with
|
||||
// DYNFLAG_LOOTABLE set). Pre-checks: `FUN_005EC110` interact
|
||||
// validity + `FUN_006003A0` lootability (target dead + bit 0 of
|
||||
// `m_objectFields[+0x224]` set) + local player not in a state
|
||||
// that forbids interaction. Releases any open loot window before
|
||||
// sending the new request.
|
||||
//
|
||||
// `useDistanceCheck` is the auto-walk gate:
|
||||
// - `1` (engine default): if target out of range, start
|
||||
// walking toward it and *don't* send CMSG_LOOT. If in range,
|
||||
// send normally.
|
||||
// - `0`: skip the range check; send immediately. The server
|
||||
// enforces server-side range, so OOR sends silently fail
|
||||
// rather than walking the player toward distant corpses —
|
||||
// the right pick for AoE-loot-style callers.
|
||||
//
|
||||
// Engine callsite at `FUN_0060FA20` (right-click world handler)
|
||||
// resolves the target via
|
||||
// `ClntObjMgrObjectPtr(TYPEMASK_UNIT, ..., guid)` first, same as
|
||||
// we do.
|
||||
//
|
||||
// Distinct from the sibling sender at `0x005DF130`, which gates
|
||||
// on `FUN_CGCORPSE_IS_LOOTABLE` (player corpses only) instead of
|
||||
// the mob path. The CGCorpse helper is documented immediately
|
||||
// below.
|
||||
FUN_CMSG_LOOT_UNIT = 0x005DF2A0,
|
||||
|
||||
// `__fastcall(int loot_response_struct, undefined4 coin, int unk)`
|
||||
// — master loot-window state controller. Called by the
|
||||
// `SMSG_LOOT_RESPONSE` handler tail (via `FUN_0048F3A0`) and by
|
||||
// `CloseLoot`-inner (via `FUN_0048F200(0, 0, 0)`). Two phases:
|
||||
// - **Close prior**: if `VAR_LOOT_GUID_LO/HI != 0`, clears
|
||||
// them, releases item-cache callbacks for the prior slots,
|
||||
// fires `LOOT_CLOSED` (event id `0x10D`).
|
||||
// - **Open new**: if `param_1 != 0`, copies the GUID from
|
||||
// `[param_1 + 8]` into `VAR_LOOT_GUID_LO/HI`, zeroes
|
||||
// `VAR_LOOT_SLOTS`, walks 16 slots populating itemID via
|
||||
// `FUN_005EBC90(slot)` and sibling helpers, sets
|
||||
// `VAR_LOOT_LOOTABLE = param_2` (coin), then fires
|
||||
// `LOOT_OPENED` (event id `0x10B`).
|
||||
// Both event fires go through `FUN_FIRE_EVENT_NO_ARGS` —
|
||||
// intercepting that function lets a caller swallow the events
|
||||
// without touching this one.
|
||||
FUN_LOOT_CONTROLLER = 0x004C1CB0,
|
||||
|
||||
// `__fastcall(int eventID)` — engine's no-args event dispatcher,
|
||||
// the lighter-weight sibling of `FUN_FIRE_EVENT` (variadic).
|
||||
// Called from ~100 sites across the engine, including the
|
||||
// `LOOT_OPENED` / `LOOT_CLOSED` fires inside
|
||||
// `FUN_LOOT_CONTROLLER`. Hooking it lets us conditionally swallow
|
||||
// specific event ids while leaving the rest untouched — the
|
||||
// mechanism we use to do silent scan loops without flickering
|
||||
// the `LootFrame`. Fan-in is wide but call frequency is
|
||||
// event-driven (state changes only), not per-frame, so per-call
|
||||
// overhead from a hooked predicate is negligible.
|
||||
FUN_FIRE_EVENT_NO_ARGS = 0x00703E50,
|
||||
|
||||
// `__fastcall(int sendRelease, char earlyReturnOnGameObject,
|
||||
// char showError)` — engine's "close the current loot window"
|
||||
// inner. Behavior keyed off args:
|
||||
// - `sendRelease` (ECX): if non-zero, builds and sends
|
||||
// `CMSG_LOOT_RELEASE` (opcode `0x15F`) for the current loot
|
||||
// target before clearing client state.
|
||||
// - `earlyReturnOnGameObject` (EDX): if non-zero, bails early
|
||||
// when the loot target is a game object that's been
|
||||
// interacted with in a particular way (fishing-style?).
|
||||
// - `showError` (stack): if non-zero, calls `FUN_00496720(0x87)`
|
||||
// to display a "cannot close loot" error.
|
||||
// Side effects: clears `[player + 0x1D28/+0x1D2C]` (the player's
|
||||
// prior-interact GUID slots), then calls `FUN_004C1CB0(0, 0, 0)`
|
||||
// which clears `VAR_LOOT_GUID_LO/HI`, zeroes `VAR_LOOT_SLOTS`,
|
||||
// and fires the `LOOT_CLOSED` event (`0x10D`). Use
|
||||
// `(1, 0, 0)` for a clean release + clear.
|
||||
FUN_CLOSE_LOOT_INNER = 0x0048F200,
|
||||
|
||||
// `CGCorpse::IsLootable(this)` — `__fastcall(corpse) → uint`,
|
||||
// tests bit 0 of `[corpse_m_objectFields + 0x78]`. Specific to
|
||||
// `TYPEMASK_CORPSE` (player corpses) — `+0x78` of a CGCorpse_C's
|
||||
// m_objectFields holds dynamic flags with bit 0 = LOOTABLE. The
|
||||
// same offset in CGUnit_C / CGPlayer_C's m_objectFields holds
|
||||
// BYTES_0 (race / class / gender / power type), so this helper
|
||||
// returns nonsense for live units (e.g. it returns 1 for Human
|
||||
// players because race=1 has bit 0 = 1). Used by the engine's
|
||||
// right-click-on-corpse handler at `FUN_005D6BF0` to gate
|
||||
// CMSG_LOOT for player corpses, and from `FUN_005DF130` (one of
|
||||
// two CMSG_LOOT senders) for the same corpse path. Don't reuse
|
||||
// for live-unit lootability — use `OFF_UNIT_FIELD_DYNAMIC_FLAGS`
|
||||
// bit 0 against a CGUnit instead.
|
||||
FUN_CGCORPSE_IS_LOOTABLE = 0x005D6E20,
|
||||
|
||||
// Merchant inventory: flat array at `0xBDD118`, stride 0x1C,
|
||||
// itemID at `entry+4`. The "flag" globals are actually the
|
||||
@@ -3451,10 +3827,37 @@ enum Offsets {
|
||||
// `+0x120`. (The engine's `Script_GetInboxItem` passes a per-
|
||||
// entry completion callback at `0x004AF0A0` to GetRecord; for
|
||||
// ID-only retrieval we skip the callback.)
|
||||
//
|
||||
// Entries also carry per-instance modifiers at `+0x12C`
|
||||
// (enchantID), `+0x134` (suffix factor), `+0x138` (random
|
||||
// property ID), etc. — `Script_GameTooltip_SetInboxItem` at
|
||||
// `0x005354C0` copies them onto the tooltip object to render a
|
||||
// fully-decorated tooltip. We don't expose them in
|
||||
// `GetInboxItemLink` because 3.3.5's same function only emits
|
||||
// basic itemID-only links (`FUN_0061E290(itemID)`); per-instance
|
||||
// data only fully manifests when the player takes the item and
|
||||
// the engine spawns a real CGItem.
|
||||
VAR_INBOX_ENTRIES = 0x00B6EF54,
|
||||
VAR_INBOX_COUNT = 0x00B6EFC0,
|
||||
OFF_INBOX_ENTRY_ITEM_ID = 0x120,
|
||||
|
||||
// SendMail attached item — the engine stores the attached item's
|
||||
// 64-bit GUID at these globals when the player drops an item onto
|
||||
// the SendMail slot (`SetSendMailItem` / `ClickSendMailItemButton`).
|
||||
// Cleared to zero by `ClearSendMail` and on `MAIL_CLOSED`. Vanilla
|
||||
// allows exactly one attachment per outgoing mail (modern WoW lifted
|
||||
// this to 12+; we ignore the modern `attachmentIndex` arg since only
|
||||
// index 1 is meaningful).
|
||||
//
|
||||
// The attached item still exists as a live CGItem in the player's
|
||||
// inventory until the mail is actually sent, so the GUID resolves
|
||||
// via `FUN_GET_OBJECT_BY_GUID(OBJECT_TYPE_ITEM, ...)`. That lets
|
||||
// `GetSendMailItemLink` return the full per-instance link (enchant
|
||||
// / random suffix preserved) by feeding the resolved CGItem to
|
||||
// `Item::Link::FromCGItem`.
|
||||
VAR_SEND_MAIL_ITEM_GUID_LO = 0x00B6EF90,
|
||||
VAR_SEND_MAIL_ITEM_GUID_HI = 0x00B6EF94,
|
||||
|
||||
// Craft window (engineering, alchemy, etc. before the "trade
|
||||
// skill" overhaul). Same slot-indirection shape as inbox: the
|
||||
// address holds a pointer to the heap-allocated entry-pointer
|
||||
@@ -3495,6 +3898,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>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// 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/>.
|
||||
|
||||
// `ExportInterfaceFiles art|code` — backport of the 4.3.4 developer-
|
||||
// console command of the same name. Registered as a real console
|
||||
// command (the `~` console available when the client is launched with
|
||||
// `-console`), exactly like retail. Dumps Blizzard's UI files out of
|
||||
// the mounted MPQ archives onto disk:
|
||||
//
|
||||
// code → .lua / .xml / .toc / .xsd → BlizzardInterfaceCode\...
|
||||
// art → .blp / .tga → BlizzardInterfaceArt\...
|
||||
//
|
||||
// Files land relative to the client's working directory, mirroring the
|
||||
// retail command's `BlizzardInterface{Code,Art}\<relative-path>`
|
||||
// layout. On completion it writes a "wrote N files" line back to the
|
||||
// console.
|
||||
//
|
||||
// **How it works (vanilla 1.12 / Octo):**
|
||||
//
|
||||
// Like 4.3.4, we enumerate the mounted archives' `(listfile)` — the
|
||||
// flat, full-path file index — rather than walking directories. The
|
||||
// vanilla primitive is `FUN_MPQ_ENUM_FILES` (`0x00401470`, the same
|
||||
// MPQ walk the macro-icon loader uses): it loops the archive handles
|
||||
// and dispatches a callback per listfile entry under a path prefix
|
||||
// (here `"Interface\"`). We collect every matching full path (deduped
|
||||
// across archives), then read each via `FUN_FILE_READ` and write it to
|
||||
// disk with Win32.
|
||||
//
|
||||
// Don't confuse this with `FUN_0042AD10`, the disk-only
|
||||
// `FindFirstFileW` walker — pointing the export at that yields zero
|
||||
// MPQ files. See the `FUN_MPQ_ENUM_FILES` note in Offsets.h.
|
||||
//
|
||||
// We export the full `Interface\` tree, matching 4.3.4 (extension
|
||||
// filter only, no path exclusion). That INCLUDES `Interface\AddOns\` —
|
||||
// the listfile only indexes MPQ-baked files, so those AddOns entries
|
||||
// are Blizzard's own UI addons (Blizzard_AuctionUI, Blizzard_TalentUI,
|
||||
// …), which are part of the stock UI source. The player's loose,
|
||||
// on-disk addons live in no archive's listfile, so they're never
|
||||
// enumerated and never re-exported.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace Interface::Export {
|
||||
|
||||
namespace {
|
||||
|
||||
// Engine's file-content reader (see Offsets::FUN_FILE_READ). __stdcall
|
||||
// (callee cleans 28 bytes via RET 0x1C — NOT __cdecl; getting this
|
||||
// wrong corrupts the caller's stack). Allocates a Storm buffer; free
|
||||
// with SMemFree.
|
||||
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
|
||||
unsigned int *outSize, unsigned int extraBytes,
|
||||
int flag1, int flag2);
|
||||
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line, int flags);
|
||||
|
||||
// MPQ enumerator (see Offsets::FUN_MPQ_ENUM_FILES). The callback
|
||||
// receives each listfile entry's full path in ecx; returning 0 STOPS
|
||||
// enumeration, so we return 1 to keep going.
|
||||
using MpqEnumCb_t = int(__fastcall *)(const char *fullPath);
|
||||
using MpqEnumFiles_t = void(__fastcall *)(int archiveSelector, const char *pathPrefix,
|
||||
MpqEnumCb_t callback, void *userParam);
|
||||
|
||||
// The archive-set selector the engine's own macro-icon loader passes to
|
||||
// FUN_00401470 — covers the primary mounted archives plus the selected
|
||||
// extra. We mirror it.
|
||||
constexpr int kArchiveSelector = 6;
|
||||
|
||||
// Case-insensitive ASCII equality.
|
||||
bool EqualsCI(const char *a, const char *b) {
|
||||
for (;; ++a, ++b) {
|
||||
char ca = *a, cb = *b;
|
||||
if (ca >= 'A' && ca <= 'Z') ca = static_cast<char>(ca + ('a' - 'A'));
|
||||
if (cb >= 'A' && cb <= 'Z') cb = static_cast<char>(cb + ('a' - 'A'));
|
||||
if (ca != cb)
|
||||
return false;
|
||||
if (ca == '\0')
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool HasExtension(const char *name, const char *const *exts, int count) {
|
||||
const char *dot = nullptr;
|
||||
for (const char *p = name; *p; ++p) {
|
||||
if (*p == '.')
|
||||
dot = p;
|
||||
}
|
||||
if (dot == nullptr)
|
||||
return false;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (EqualsCI(dot + 1, exts[i]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create every parent directory of `relPath` (a backslash-separated
|
||||
// path, e.g. "BlizzardInterfaceCode\FrameXML\Foo.lua"). Each component
|
||||
// is created relative to the working directory; existing dirs are a
|
||||
// no-op.
|
||||
void EnsureParentDirs(const std::string &relPath) {
|
||||
std::string accum;
|
||||
for (size_t i = 0; i < relPath.size(); ++i) {
|
||||
const char c = relPath[i];
|
||||
if (c == '\\' || c == '/') {
|
||||
if (!accum.empty())
|
||||
CreateDirectoryA(accum.c_str(), nullptr);
|
||||
accum.push_back('\\');
|
||||
} else {
|
||||
accum.push_back(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool WriteFileToDisk(const std::string &relPath, const void *data, unsigned int size) {
|
||||
EnsureParentDirs(relPath);
|
||||
HANDLE h = CreateFileA(relPath.c_str(), GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (h == INVALID_HANDLE_VALUE)
|
||||
return false;
|
||||
bool ok = true;
|
||||
if (size != 0) {
|
||||
DWORD written = 0;
|
||||
ok = WriteFile(h, data, size, &written, nullptr) != 0 && written == size;
|
||||
}
|
||||
CloseHandle(h);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Length of the "Interface\" prefix we strip from MPQ paths before
|
||||
// re-rooting under the destination folder.
|
||||
constexpr size_t kRootPrefixLen = 10; // strlen("Interface\\")
|
||||
|
||||
const char *const kCodeExts[] = {"lua", "xml", "toc", "xsd"};
|
||||
const char *const kArtExts[] = {"blp", "tga"};
|
||||
|
||||
enum Mode { MODE_CODE, MODE_ART };
|
||||
|
||||
// Collector for the MPQ enumeration callback. File-static because
|
||||
// FUN_00401470 hands the callback only the path string; enumeration is
|
||||
// synchronous and single-threaded, so a file-static sink is safe.
|
||||
struct Collector {
|
||||
const char *const *exts = nullptr;
|
||||
int extCount = 0;
|
||||
std::vector<std::string> files;
|
||||
std::unordered_set<std::string> seen; // dedup across archives
|
||||
};
|
||||
Collector *g_collector = nullptr;
|
||||
|
||||
// MPQ enumeration callback — one call per listfile entry under the
|
||||
// "Interface\" prefix. Keep going (return 1) regardless.
|
||||
int __fastcall CollectCb(const char *fullPath) {
|
||||
Collector *c = g_collector;
|
||||
if (c != nullptr && fullPath != nullptr &&
|
||||
HasExtension(fullPath, c->exts, c->extCount)) {
|
||||
if (c->seen.insert(fullPath).second)
|
||||
c->files.emplace_back(fullPath);
|
||||
}
|
||||
return 1; // 0 would stop enumeration
|
||||
}
|
||||
|
||||
// Enumerate every MPQ file under Interface\ whose extension matches
|
||||
// `mode`, read each, and write it under the destination root. Returns
|
||||
// the number of files successfully written.
|
||||
int ExportTree(Mode mode) {
|
||||
auto MpqEnumFiles = reinterpret_cast<MpqEnumFiles_t>(Offsets::FUN_MPQ_ENUM_FILES);
|
||||
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
|
||||
const char *dstRoot =
|
||||
(mode == MODE_ART) ? "BlizzardInterfaceArt" : "BlizzardInterfaceCode";
|
||||
|
||||
Collector collector;
|
||||
collector.exts = (mode == MODE_ART) ? kArtExts : kCodeExts;
|
||||
collector.extCount = (mode == MODE_ART) ? 2 : 4;
|
||||
|
||||
g_collector = &collector;
|
||||
MpqEnumFiles(kArchiveSelector, "Interface\\", &CollectCb, nullptr);
|
||||
g_collector = nullptr;
|
||||
|
||||
const std::vector<std::string> &files = collector.files;
|
||||
int written = 0;
|
||||
for (const std::string &src : files) {
|
||||
void *buf = nullptr;
|
||||
unsigned int size = 0;
|
||||
if (FileRead(0, src.c_str(), &buf, &size, 0, 1, 0) == 0 || buf == nullptr)
|
||||
continue;
|
||||
|
||||
// Re-root: "Interface\FrameXML\Foo.lua" -> "<dstRoot>\FrameXML\Foo.lua".
|
||||
std::string dst = dstRoot;
|
||||
dst.push_back('\\');
|
||||
dst.append(src, kRootPrefixLen, std::string::npos);
|
||||
|
||||
if (WriteFileToDisk(dst, buf, size))
|
||||
++written;
|
||||
|
||||
SMemFree(buf, __FILE__, __LINE__, 0);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
// Console-command handler ABI: edx = the argument text after the
|
||||
// command name ("" when bare). Mirrors 4.3.4's `art`/`code` dispatch.
|
||||
bool FirstTokenEquals(const char *s, const char *token) {
|
||||
size_t i = 0;
|
||||
for (; token[i] != '\0'; ++i) {
|
||||
char a = s[i];
|
||||
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a + ('a' - 'A'));
|
||||
if (a != token[i])
|
||||
return false;
|
||||
}
|
||||
const char end = s[i];
|
||||
return end == '\0' || end == ' ' || end == '\t';
|
||||
}
|
||||
|
||||
int __fastcall Console_ExportInterfaceFiles(void * /*unused*/, const char *args) {
|
||||
const char *p = (args != nullptr) ? args : "";
|
||||
while (*p == ' ' || *p == '\t')
|
||||
++p;
|
||||
|
||||
Mode mode;
|
||||
if (FirstTokenEquals(p, "art"))
|
||||
mode = MODE_ART;
|
||||
else if (FirstTokenEquals(p, "code"))
|
||||
mode = MODE_CODE;
|
||||
else {
|
||||
Game::Console::Write("Usage: ExportInterfaceFiles art|code");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const int written = ExportTree(mode);
|
||||
|
||||
char msg[160];
|
||||
snprintf(msg, sizeof(msg), "ExportInterfaceFiles: wrote %d %s file(s) to %s\\",
|
||||
written, (mode == MODE_ART) ? "art" : "code",
|
||||
(mode == MODE_ART) ? "BlizzardInterfaceArt" : "BlizzardInterfaceCode");
|
||||
Game::Console::Write(msg);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Register the console command. A console command is process-global
|
||||
// (not Lua-state-bound) and persists for the whole session, so the
|
||||
// single registration at glue boot — the earliest hook, firing at
|
||||
// launch before any console can be opened — covers both the login
|
||||
// console and in-world. The glue hook re-fires on each world→glue
|
||||
// return (logout), but the engine's registrar dedups by name (and we
|
||||
// pass the same string literal, so its pointer-equality fast path
|
||||
// catches the repeat), making a second call a no-op. The command name
|
||||
// and help string are stored by pointer, so they must be static —
|
||||
// string literals satisfy that for the DLL's lifetime.
|
||||
void EnsureRegistered() {
|
||||
Game::Console::RegisterCommand(
|
||||
"ExportInterfaceFiles", &Console_ExportInterfaceFiles,
|
||||
Game::Console::CATEGORY_DEBUG,
|
||||
"Extracts Blizzard's UI files from the MPQs to disk. "
|
||||
"Usage: ExportInterfaceFiles art|code");
|
||||
}
|
||||
|
||||
const Game::GlueModuleAutoRegister _autoreg{&EnsureRegistered};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Interface::Export
|
||||
+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,139 @@
|
||||
// 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_Item.GetEnchantInfo(enchantID)` -> table | nil
|
||||
//
|
||||
// Resolves an item-enchantment ID — the kind `C_Item.GetWeaponEnchantInfo`
|
||||
// returns for a weapon's temporary enchant (poisons, oils, sharpening
|
||||
// stones, shaman imbues, …), and the same IDs item permanent enchants
|
||||
// use — into its `SpellItemEnchantment.dbc` record and returns a table:
|
||||
//
|
||||
// {
|
||||
// enchantID = <id>,
|
||||
// name = "<localized display name>", -- "Crusader", "+8 Spirit"
|
||||
// effects = { -- 1..3 active effect slots
|
||||
// { type = <ITEM_ENCHANTMENT_TYPE>, amount = <int>, arg = <int> },
|
||||
// ...
|
||||
// },
|
||||
// spellID = <int>, -- present only for spell-type enchants (proc/
|
||||
// -- equip/use): the EffectArg of the first such
|
||||
// -- effect, e.g. Crusader -> 20007 "Holy Strength".
|
||||
// }
|
||||
//
|
||||
// `effects[i].type` is the standard ITEM_ENCHANTMENT_TYPE:
|
||||
// 1 = combat-proc spell (arg = spellID)
|
||||
// 2 = weapon damage (amount = +dmg)
|
||||
// 3 = equip spell / aura (arg = spellID)
|
||||
// 4 = resistance / armor (amount = +value)
|
||||
// 5 = stat (arg = stat index, amount = value)
|
||||
// 6 = totem, 7 = use spell
|
||||
// For spell types (1/3/7) `arg` is a spellID you can feed straight into
|
||||
// `C_Spell.GetSpellInfo` / `GetSpellDescription`; `spellID` is surfaced
|
||||
// at top level as a convenience for that common case.
|
||||
//
|
||||
// Returns nil for a non-numeric / non-positive id, an out-of-range id,
|
||||
// or a record with no name. Lives in C_Item (not C_Spell) because the
|
||||
// id originates from `C_Item.GetWeaponEnchantInfo` and the concept is an
|
||||
// item enchantment — `SpellItemEnchantment` is just the DBC's internal
|
||||
// name (enchants are *implemented* via spell effects).
|
||||
//
|
||||
// Not derivable: the source item. The record holds no back-reference to
|
||||
// the item that applied the enchant, and vanilla has no client-side
|
||||
// reverse index (enchantID -> item).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::EnchantInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kNumEffects = 3;
|
||||
constexpr int kNumNameLocales = 8; // Name[8]; field after is the locale mask
|
||||
|
||||
// Spell-type enchants whose EffectArg is a spellID (proc / equip / use).
|
||||
bool IsSpellType(int type) { return type == 1 || type == 3 || type == 7; }
|
||||
|
||||
int __fastcall Script_C_Item_GetEnchantInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int enchantID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (enchantID <= 0)
|
||||
return 0;
|
||||
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
|
||||
Offsets::VAR_SPELLITEMENCHANT_COUNT,
|
||||
static_cast<uint32_t>(enchantID));
|
||||
if (rec == nullptr)
|
||||
return 0; // OOR id or unpopulated slot -> nil
|
||||
|
||||
// Localized display name (8-slot locale array at +0x34). Clamp the
|
||||
// locale index to the array bounds — index 8 would read the locale
|
||||
// mask column that follows. enUS (0) fallback for empty slots.
|
||||
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLITEMENCHANT_NAME);
|
||||
int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
if (locale < 0 || locale >= kNumNameLocales)
|
||||
locale = 0;
|
||||
const char *name = names[locale];
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
name = names[0]; // enUS fallback
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
return 0; // unnamed record -> nil
|
||||
|
||||
auto type = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_TYPE);
|
||||
auto amount = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
|
||||
auto arg = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_ARG);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "enchantID", enchantID);
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
|
||||
// effects = { {type,amount,arg}, ... } — only non-empty slots.
|
||||
Game::Lua::PushString(L, "effects");
|
||||
Game::Lua::NewTable(L);
|
||||
int n = 0;
|
||||
int spellID = 0;
|
||||
for (int i = 0; i < kNumEffects; ++i) {
|
||||
if (type[i] == 0)
|
||||
continue;
|
||||
if (spellID == 0 && IsSpellType(type[i]) && arg[i] != 0)
|
||||
spellID = arg[i];
|
||||
++n;
|
||||
Game::Lua::PushNumber(L, n); // 1-based array index
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "type", type[i]);
|
||||
Game::Lua::SetFieldNumber(L, "amount", amount[i]);
|
||||
Game::Lua::SetFieldNumber(L, "arg", arg[i]);
|
||||
Game::Lua::SetTable(L, -3); // effects[n] = entry
|
||||
}
|
||||
Game::Lua::SetTable(L, -3); // result.effects = effects
|
||||
|
||||
if (spellID != 0)
|
||||
Game::Lua::SetFieldNumber(L, "spellID", spellID);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetEnchantInfo",
|
||||
&Script_C_Item_GetEnchantInfo);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::EnchantInfo
|
||||
@@ -16,13 +16,31 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/QualityColor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Item::Link {
|
||||
|
||||
namespace {
|
||||
|
||||
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
|
||||
|
||||
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
|
||||
// the codebase uses; passes a noop NULL callback so no SMSG fires for
|
||||
// uncached items — caller treats "not cached" as a clean failure.
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int dedupFlag);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *FromCGItem(const uint8_t *cgItem) {
|
||||
@@ -32,6 +50,25 @@ const char *FromCGItem(const uint8_t *cgItem) {
|
||||
return fn(cgItem);
|
||||
}
|
||||
|
||||
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
|
||||
if (out == nullptr || outSize == 0)
|
||||
return false;
|
||||
const uint8_t *record = PeekItemRecord(itemID);
|
||||
if (record == nullptr)
|
||||
return false;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || *name == '\0')
|
||||
return false;
|
||||
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
const int n = std::snprintf(out, outSize,
|
||||
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
|
||||
Item::QualityColor::Prefix(static_cast<int>(quality)),
|
||||
itemID, name);
|
||||
return n > 0 && static_cast<size_t>(n) < outSize;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Link {
|
||||
@@ -28,4 +29,15 @@ namespace Item::Link {
|
||||
// produce for the same slot.
|
||||
const char *FromCGItem(const uint8_t *cgItem);
|
||||
|
||||
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
|
||||
// into `out` from the cached ItemStats record for `itemID`. No
|
||||
// enchant / random-suffix decoration — use this when the caller has
|
||||
// only an itemID and no live CGItem (mail inbox attachments, cursor
|
||||
// drag-source items, vendor roster previews). Returns true on
|
||||
// success, false if the item isn't cached or the buffer is too small.
|
||||
//
|
||||
// `outSize` should be at least 256 to comfortably fit the longest
|
||||
// possible link (`item:` payload + colored prefix + bracketed name).
|
||||
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
|
||||
|
||||
} // namespace Item::Link
|
||||
|
||||
@@ -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_Item.UseAtUnit(itemInfo, unit)` — use the named item at the given
|
||||
// unit's feet. For items whose on-use spell is ground-target (Iron
|
||||
// Grenade, demolition charges, etc.), bypasses the manual click on the
|
||||
// AoE reticle. `UseAtUnit(itemInfo, "target")` drops the reticle at
|
||||
// the target's position.
|
||||
//
|
||||
// Unit-position analog of `C_Item.UseAtCursor` — same bag-walk +
|
||||
// `FUN_ITEM_USE` dispatch, but the placement is committed at the
|
||||
// unit's world position (via `Spell::AtCursor::CommitAtCoords`)
|
||||
// instead of the cursor raycast.
|
||||
//
|
||||
// Returns `true` when the placement landed at the unit; `false` for
|
||||
// non-ground-target items (the item still fires, no implicit target),
|
||||
// unparseable input, item-not-in-bags, or an unresolvable unit.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Location.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::UseAtUnit {
|
||||
|
||||
namespace {
|
||||
|
||||
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
|
||||
int flag);
|
||||
|
||||
int __fastcall Script_C_Item_UseAtUnit(void *L) {
|
||||
const auto arg = Item::Arg::Resolve(L, 1);
|
||||
if (arg.itemID <= 0 && arg.name == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 2);
|
||||
|
||||
// Resolve the unit position first — fail fast before firing the
|
||||
// item if the unit is absent. (FindByArgInBags below stomps the
|
||||
// Lua stack, so read the token argument before that runs.)
|
||||
float pos[3];
|
||||
if (!Unit::Position::ReadToken(token, pos)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const uint64_t zeroTarget = 0;
|
||||
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
|
||||
useItem(found.item, &zeroTarget, 0);
|
||||
|
||||
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
|
||||
Game::Lua::PushBool(L, placed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "UseAtUnit",
|
||||
&Script_C_Item_UseAtUnit);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::UseAtUnit
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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 "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../guid/Guid.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Loot::Nearby {
|
||||
|
||||
namespace {
|
||||
|
||||
// Engine interact-range constants, read out of `.rdata` once and
|
||||
// pinned here as `constexpr`. Both are referenced by `FUN_005EC110`
|
||||
// (the master interact-state checker) and propagated through
|
||||
// `FUN_005DF130` / `FUN_005DF2A0` (CMSG_LOOT senders). Yards units;
|
||||
// values verified at runtime addresses `DAT_0080B058` and
|
||||
// `DAT_0080A1E8`.
|
||||
//
|
||||
// INTERACT_REACH (1.333…) is added to `target_radius + player_radius`
|
||||
// for the unclamped range; modeled as the "reach beyond your touch"
|
||||
// constant.
|
||||
// MIN_INTERACT_RANGE (5.0) clamps the result so even tiny
|
||||
// targets with near-zero bounding radius still get the standard
|
||||
// 5-yard right-click reach.
|
||||
constexpr float INTERACT_REACH = 1.3333333f;
|
||||
constexpr float MIN_INTERACT_RANGE = 5.0f;
|
||||
|
||||
// `ClntObjMgrEnumVisibleObjects(callback, context)` — walks the engine's
|
||||
// visible-object table. The callback shape's `void *unusedEdx` slot
|
||||
// absorbs the second `__fastcall` register so MSVC lays out the trailing
|
||||
// `uint64_t guid` on the stack exactly where the engine puts it. The
|
||||
// callback returns non-zero to keep iterating; zero stops the walk early.
|
||||
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
|
||||
void *unusedEdx,
|
||||
uint64_t guid);
|
||||
using ClntObjMgrEnumVisibleObjects_t =
|
||||
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
|
||||
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
|
||||
// caller-provided 12-byte buffer with the unit's world position and
|
||||
// return it. Same call shape `FUN_005EC110` uses to read the
|
||||
// player / target positions when deciding interact range.
|
||||
struct C3Vector {
|
||||
float x, y, z;
|
||||
};
|
||||
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
|
||||
C3Vector *outBuf);
|
||||
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
|
||||
|
||||
// `ClntObjMgrObjectPtr(typeMask, debugMessage, guid_lo, guid_hi, debugCode)`
|
||||
// — resolves a 64-bit GUID to the loaded `CGObject_C *` it currently
|
||||
// refers to, filtered by `typeMask`. Returns NULL when no live object
|
||||
// matches the GUID *and* the typeMask. Splitting the GUID into lo/hi
|
||||
// DWORDs on the stack is equivalent to the engine's
|
||||
// `__fastcall(uint32_t, const char*, uint64_t, int)` signature once the
|
||||
// `uint64_t` argument is placed in the same stack slots.
|
||||
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugMsg,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int debugCode);
|
||||
|
||||
// Reads `(unit.m_objectFields[+0x224] & UNIT_DYNFLAG_LOOTABLE) != 0`
|
||||
// — the server-driven bit it sets when the local player has loot
|
||||
// rights on this unit. Works uniformly for live and dead CGUnit
|
||||
// subclasses; the bit is simply 0 for non-lootable targets.
|
||||
//
|
||||
// Don't call `FUN_CGCORPSE_IS_LOOTABLE` here even though the engine
|
||||
// itself uses it in `FUN_005D6BF0`: that helper reads `+0x78` of
|
||||
// m_objectFields, which is the CORPSE dynamic-flags slot for
|
||||
// CGCorpse_C but the BYTES_0 (race / class / gender / power type)
|
||||
// slot for CGUnit_C. Routing live units through it returns the low
|
||||
// bit of *race* — yields false positives for any Human / Dwarf /
|
||||
// Undead / Gnome player and false negatives for dead mobs.
|
||||
bool IsLootable(const void *unit) {
|
||||
auto *fields = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
|
||||
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
|
||||
}
|
||||
|
||||
// Reads the bounding-radius float from a unit's m_objectFields. The
|
||||
// engine uses this as the contribution from both sides (player + target)
|
||||
// in its interact-range computation.
|
||||
float BoundingRadius(const void *unit) {
|
||||
auto *fields = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
return *reinterpret_cast<const float *>(
|
||||
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
|
||||
}
|
||||
|
||||
// Invokes the unit's virtual `GetPosition` (vtable slot at offset
|
||||
// `0x14`). The engine's signature is `__thiscall(this, outBuf) →
|
||||
// outBuf`; we pass our own 12-byte stack buffer and read x/y/z out
|
||||
// of the returned pointer.
|
||||
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
|
||||
auto vtable = *reinterpret_cast<void *const *const *>(unit);
|
||||
auto fn = reinterpret_cast<GetPosition_t>(
|
||||
*reinterpret_cast<const void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(vtable) +
|
||||
VTBL_GET_POSITION_OFFSET));
|
||||
return fn(unit, outBuf);
|
||||
}
|
||||
|
||||
// Mirrors the range portion of `FUN_005EC110`. We skip the engine's
|
||||
// player-state checks (mounted / casting / prior-interaction GUID
|
||||
// gate) because they fire intermittently during normal play and would
|
||||
// make the enumerator return an empty list whenever the player has
|
||||
// already clicked something — surprising for a "what's lootable
|
||||
// nearby" reader. The pure distance test is what controls cursor
|
||||
// highlight, which is the contract the user asked for.
|
||||
bool InInteractRange(const void *player, const void *target) {
|
||||
C3Vector pBuf{};
|
||||
C3Vector tBuf{};
|
||||
const C3Vector *pPos = GetPosition(player, &pBuf);
|
||||
const C3Vector *tPos = GetPosition(target, &tBuf);
|
||||
if (pPos == nullptr || tPos == nullptr)
|
||||
return false;
|
||||
|
||||
const float dx = pPos->x - tPos->x;
|
||||
const float dy = pPos->y - tPos->y;
|
||||
const float dz = pPos->z - tPos->z;
|
||||
const float distSq = dx * dx + dy * dy + dz * dz;
|
||||
|
||||
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
|
||||
if (range < MIN_INTERACT_RANGE)
|
||||
range = MIN_INTERACT_RANGE;
|
||||
return distSq <= range * range;
|
||||
}
|
||||
|
||||
// Only `TYPEMASK_UNIT` carries lootable-mob state in 1.12 — dead
|
||||
// creatures stay as CGCreature_C objects with the lootable bit flipped
|
||||
// on, rather than transitioning to a separate corpse object. Dead
|
||||
// players become CGCorpse_C (`TYPEMASK_CORPSE`) but those aren't
|
||||
// lootable by other players in vanilla — no reason to walk them here.
|
||||
|
||||
struct ScanCtx {
|
||||
void *L;
|
||||
const void *player; // resolved once outside the iterator
|
||||
int idx; // 1-based slot of the next entry to append
|
||||
};
|
||||
|
||||
int __fastcall ScanCallback(ScanCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
|
||||
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0);
|
||||
if (obj == nullptr)
|
||||
return 1; // GUID isn't a unit; continue
|
||||
|
||||
if (!IsLootable(obj))
|
||||
return 1; // unit isn't lootable to us; continue
|
||||
|
||||
if (!InInteractRange(ctx->player, obj))
|
||||
return 1; // out of click-loot range; continue
|
||||
|
||||
char guidBuf[Guid::STRING_SIZE];
|
||||
Game::Lua::PushNumber(ctx->L, static_cast<double>(ctx->idx));
|
||||
Game::Lua::NewTable(ctx->L);
|
||||
Game::Lua::SetFieldString(ctx->L, "guid",
|
||||
Guid::FormatAsString(guid, guidBuf, sizeof guidBuf));
|
||||
Game::Lua::SetTable(ctx->L, -3);
|
||||
++ctx->idx;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Loot.GetNearbyLootableUnits()` — returns a 1-indexed array of
|
||||
// `{ guid = "0x..." }` subtables, one per visible unit that is (a)
|
||||
// flagged DYNFLAG_LOOTABLE by the server and (b) inside the engine's
|
||||
// own interact range (the distance threshold that lights up the
|
||||
// right-click-loot cursor). Empty table pre-login, when no lootable
|
||||
// units are in range, or when we don't have loot rights on visible
|
||||
// corpses (the server only sets the bit for clients with rights).
|
||||
int __fastcall Script_GetNearbyLootableUnits(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
// The engine derefs the object manager unconditionally on iterator
|
||||
// entry. NULL during glue / character-select; bail with an empty
|
||||
// table rather than crashing.
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
|
||||
return 1;
|
||||
|
||||
// Resolve the local player once — the canonical CGPlayer_C the
|
||||
// engine's own interact-range checks use, distinct from the
|
||||
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
|
||||
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
const void *player = ResolveUnitToken("player");
|
||||
if (player == nullptr)
|
||||
return 1;
|
||||
|
||||
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
|
||||
ScanCtx ctx{L, player, 1};
|
||||
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&ScanCallback),
|
||||
&ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Register() {
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "GetNearbyLootableUnits",
|
||||
&Script_GetNearbyLootableUnits);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&Register};
|
||||
|
||||
} // namespace Loot::Nearby
|
||||
@@ -0,0 +1,479 @@
|
||||
// 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.
|
||||
//
|
||||
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
|
||||
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
|
||||
// the engine's loot-window response* before any visual updates happen.
|
||||
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
|
||||
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
|
||||
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
|
||||
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
|
||||
// the engine's state machine cycles silently for each corpse and
|
||||
// `LootFrame` never reacts. Other Lua addons listening to those events
|
||||
// also see nothing during the scan; that's deliberate.
|
||||
//
|
||||
// Compared to the older polling-driven version this also collapses the
|
||||
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
|
||||
// per-tick `VAR_LOOT_GUID` reads, because the controller hook *is* the
|
||||
// state transition.
|
||||
|
||||
#include "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../event/Custom.h"
|
||||
#include "../guid/Guid.h"
|
||||
#include "../tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Loot::Scan {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEventCompleted = "LOOT_SCAN_COMPLETED";
|
||||
const Event::Custom::AutoReserve _reserve{kEventCompleted};
|
||||
|
||||
// Per-step timeout for the controller hook to fire on a queued
|
||||
// CMSG_LOOT. WorldTick fires at the client frame rate (~30-60 Hz),
|
||||
// so 180 ticks ≈ 3-6s wall clock. Long enough for high-latency
|
||||
// servers, short enough that a single dropped response doesn't hang
|
||||
// the whole scan.
|
||||
constexpr int kStepTimeoutTicks = 180;
|
||||
|
||||
// Event IDs the engine fires through `FUN_FIRE_EVENT_NO_ARGS` for
|
||||
// loot-window transitions. The hook swallows these only when the
|
||||
// scanner has the suppression flag raised — normal play sees them
|
||||
// fire unmodified.
|
||||
constexpr int EVENT_LOOT_OPENED = 0x10B;
|
||||
constexpr int EVENT_LOOT_CLOSED = 0x10D;
|
||||
|
||||
enum class State {
|
||||
Idle,
|
||||
WaitingForResponse, // CMSG_LOOT sent; awaiting our controller hook
|
||||
};
|
||||
|
||||
struct SlotItem {
|
||||
uint32_t itemID;
|
||||
uint32_t count;
|
||||
std::string link; // empty if the cache record wasn't loaded
|
||||
};
|
||||
|
||||
struct LootEntry {
|
||||
uint64_t guid;
|
||||
uint32_t coin; // copper amount; 0 if no coin
|
||||
std::vector<SlotItem> items;
|
||||
};
|
||||
|
||||
struct ScanCtx {
|
||||
State state = State::Idle;
|
||||
void *player = nullptr;
|
||||
uint64_t currentGuid = 0;
|
||||
int ticksSinceTransition = 0;
|
||||
bool suppressEvents = false; // read by the FireEvent hook
|
||||
std::vector<uint64_t> queue;
|
||||
std::vector<LootEntry> results;
|
||||
};
|
||||
|
||||
ScanCtx s_scan;
|
||||
|
||||
// === Typedefs for engine helpers ===
|
||||
|
||||
using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
|
||||
void *unusedEdx,
|
||||
uint64_t guid);
|
||||
using ClntObjMgrEnumVisibleObjects_t =
|
||||
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
|
||||
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugMsg,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int debugCode);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using LootUnit_t = void(__thiscall *)(void *player, void *target,
|
||||
char useDistanceCheck);
|
||||
using CloseLootInner_t = void(__fastcall *)(int sendRelease,
|
||||
char earlyReturnOnGameObject,
|
||||
char showError);
|
||||
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
|
||||
char *outBuf,
|
||||
int bufSize);
|
||||
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
|
||||
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
|
||||
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
|
||||
// the engine doesn't pop, drifting ESP by 4 bytes per call until a
|
||||
// later return reads `0x00000001` as a corrupt frame pointer and the
|
||||
// process crashes. We do see / forward the `coin` value because the
|
||||
// engine reads it as `DAT_00b71ba0` later.
|
||||
using LootController_t = void(__fastcall *)(int lootStructPtr,
|
||||
int coin,
|
||||
int unk);
|
||||
using FireEventNoArgs_t = void(__fastcall *)(int eventID,
|
||||
int /*edx unused*/);
|
||||
|
||||
struct C3Vector {
|
||||
float x, y, z;
|
||||
};
|
||||
using GetPosition_t = const C3Vector *(__thiscall *)(const void *self,
|
||||
C3Vector *outBuf);
|
||||
constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
|
||||
|
||||
constexpr float INTERACT_REACH = 1.3333333f;
|
||||
constexpr float MIN_INTERACT_RANGE = 5.0f;
|
||||
|
||||
// === Range / lootability helpers (identical to Loot::Nearby) ===
|
||||
|
||||
bool IsLootableUnit(const void *unit) {
|
||||
auto *fields = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
|
||||
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
|
||||
}
|
||||
|
||||
float BoundingRadius(const void *unit) {
|
||||
auto *fields = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
return *reinterpret_cast<const float *>(
|
||||
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
|
||||
}
|
||||
|
||||
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
|
||||
auto vtable = *reinterpret_cast<void *const *const *>(unit);
|
||||
auto fn = reinterpret_cast<GetPosition_t>(
|
||||
*reinterpret_cast<const void *const *>(
|
||||
reinterpret_cast<const uint8_t *>(vtable) +
|
||||
VTBL_GET_POSITION_OFFSET));
|
||||
return fn(unit, outBuf);
|
||||
}
|
||||
|
||||
bool InInteractRange(const void *player, const void *target) {
|
||||
C3Vector pBuf{};
|
||||
C3Vector tBuf{};
|
||||
const C3Vector *pPos = GetPosition(player, &pBuf);
|
||||
const C3Vector *tPos = GetPosition(target, &tBuf);
|
||||
if (pPos == nullptr || tPos == nullptr)
|
||||
return false;
|
||||
const float dx = pPos->x - tPos->x;
|
||||
const float dy = pPos->y - tPos->y;
|
||||
const float dz = pPos->z - tPos->z;
|
||||
const float distSq = dx * dx + dy * dy + dz * dz;
|
||||
float range = BoundingRadius(player) + BoundingRadius(target) + INTERACT_REACH;
|
||||
if (range < MIN_INTERACT_RANGE)
|
||||
range = MIN_INTERACT_RANGE;
|
||||
return distSq <= range * range;
|
||||
}
|
||||
|
||||
// === Scrape ===
|
||||
|
||||
// Reads the post-population `VAR_LOOT_SLOTS` table into a LootEntry.
|
||||
// The controller hook calls this *after* letting the original
|
||||
// `FUN_LOOT_CONTROLLER` run, so the slot table is fully filled in
|
||||
// and the link builder works against it.
|
||||
void ScrapeCurrentLoot(LootEntry *out) {
|
||||
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_LOOT_LOOTABLE);
|
||||
out->coin = (coinRaw == 0xFFFFFFFFu) ? 0u : coinRaw;
|
||||
|
||||
const bool hasCoin = coinRaw != 0;
|
||||
auto LinkBuilder = reinterpret_cast<LootSlotLinkBuilder_t>(
|
||||
Offsets::FUN_LOOT_SLOT_LINK_BUILDER);
|
||||
|
||||
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
|
||||
auto *entry = reinterpret_cast<const uint8_t *>(
|
||||
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
|
||||
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
|
||||
if (itemID == 0)
|
||||
continue;
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(entry + 8);
|
||||
|
||||
SlotItem item;
|
||||
item.itemID = itemID;
|
||||
item.count = count;
|
||||
|
||||
char linkBuf[0x400];
|
||||
const uint32_t slotArg = hasCoin ? static_cast<uint32_t>(slot + 1)
|
||||
: static_cast<uint32_t>(slot);
|
||||
const char *link = LinkBuilder(slotArg, linkBuf, sizeof linkBuf);
|
||||
if (link != nullptr)
|
||||
item.link = link;
|
||||
|
||||
out->items.push_back(std::move(item));
|
||||
}
|
||||
}
|
||||
|
||||
// === State-machine transitions ===
|
||||
|
||||
void StartLoot(uint64_t guid);
|
||||
void TryStartNext();
|
||||
|
||||
void SendCloseLoot() {
|
||||
auto Close = reinterpret_cast<CloseLootInner_t>(Offsets::FUN_CLOSE_LOOT_INNER);
|
||||
Close(1, '\0', '\0');
|
||||
}
|
||||
|
||||
void Complete() {
|
||||
s_scan.state = State::Idle;
|
||||
s_scan.currentGuid = 0;
|
||||
s_scan.suppressEvents = false;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
|
||||
}
|
||||
|
||||
void TryStartNext() {
|
||||
if (s_scan.queue.empty()) {
|
||||
Complete();
|
||||
return;
|
||||
}
|
||||
uint64_t guid = s_scan.queue.back();
|
||||
s_scan.queue.pop_back();
|
||||
StartLoot(guid);
|
||||
}
|
||||
|
||||
void StartLoot(uint64_t guid) {
|
||||
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0);
|
||||
if (target == nullptr) {
|
||||
TryStartNext();
|
||||
return;
|
||||
}
|
||||
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
|
||||
LootUnit(s_scan.player, target, /*useDistanceCheck=*/0);
|
||||
s_scan.currentGuid = guid;
|
||||
s_scan.state = State::WaitingForResponse;
|
||||
s_scan.ticksSinceTransition = 0;
|
||||
}
|
||||
|
||||
// === Hooks ===
|
||||
|
||||
LootController_t s_lootController_o = nullptr;
|
||||
FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
|
||||
|
||||
// `LOOT_OPENED` / `LOOT_CLOSED` get suppressed only while a scan is
|
||||
// actively interleaving open/close cycles, signaled by
|
||||
// `s_scan.suppressEvents`. The flag is raised by the controller hook
|
||||
// just around the original-loot-controller call and the
|
||||
// `SendCloseLoot` that follows, then dropped before
|
||||
// `TryStartNext`-initiated CMSG_LOOTs go out. Every other engine
|
||||
// event fire — and normal play's loot events — pass through.
|
||||
void __fastcall FireEventNoArgs_h(int eventID, int /*unusedEdx*/) {
|
||||
if (s_scan.suppressEvents &&
|
||||
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED)) {
|
||||
return;
|
||||
}
|
||||
s_fireEventNoArgs_o(eventID, 0);
|
||||
}
|
||||
|
||||
// Intercepts the engine's loot-window state controller. Two paths:
|
||||
//
|
||||
// 1. **Scan-driven open** — the incoming `lootStructPtr` carries a
|
||||
// GUID matching our expected one. Let the original run (so the
|
||||
// slot table populates), then scrape, send a release (which
|
||||
// will recursively re-enter via `FUN_0048F200` → close-path
|
||||
// `FUN_LOOT_CONTROLLER(NULL, …)`), and advance the scan. Both
|
||||
// the inner open and the recursive close fire their respective
|
||||
// events through our `FireEventNoArgs_h`, which swallows them
|
||||
// while `suppressEvents` is on.
|
||||
//
|
||||
// 2. **Anything else** — pass through unmodified. Normal loot
|
||||
// interactions outside a scan are unaffected; the inner-close
|
||||
// call generated by our scan's `SendCloseLoot` re-enters here
|
||||
// with `arg1 == 0` and is also a passthrough (its
|
||||
// suppression-flag check happens at the FireEvent hook layer).
|
||||
void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
|
||||
const bool scanOpenCall =
|
||||
s_scan.state == State::WaitingForResponse &&
|
||||
lootStructPtr != 0 &&
|
||||
[&] {
|
||||
// Read the GUID off the response struct (`+8` deref to a
|
||||
// `{uint32 lo; uint32 hi}` block — same shape the original
|
||||
// copies into `VAR_LOOT_GUID_LO/HI`).
|
||||
auto *guidPtr = *reinterpret_cast<const uint32_t *const *>(
|
||||
reinterpret_cast<const uint8_t *>(
|
||||
static_cast<intptr_t>(lootStructPtr)) + 8);
|
||||
if (guidPtr == nullptr) return false;
|
||||
const uint64_t guid =
|
||||
static_cast<uint64_t>(guidPtr[0]) |
|
||||
(static_cast<uint64_t>(guidPtr[1]) << 32);
|
||||
return guid == s_scan.currentGuid;
|
||||
}();
|
||||
|
||||
if (scanOpenCall) {
|
||||
s_scan.suppressEvents = true;
|
||||
s_lootController_o(lootStructPtr, coin, unk);
|
||||
|
||||
LootEntry entry;
|
||||
entry.guid = s_scan.currentGuid;
|
||||
ScrapeCurrentLoot(&entry);
|
||||
s_scan.results.push_back(std::move(entry));
|
||||
|
||||
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
|
||||
// with `arg1 == 0`. That recursive call falls into the
|
||||
// passthrough branch but still sees `suppressEvents == true`
|
||||
// at the FireEvent layer, so the `LOOT_CLOSED` fire inside
|
||||
// gets swallowed.
|
||||
SendCloseLoot();
|
||||
|
||||
s_scan.suppressEvents = false;
|
||||
TryStartNext();
|
||||
return;
|
||||
}
|
||||
|
||||
s_lootController_o(lootStructPtr, coin, unk);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookController{
|
||||
Offsets::FUN_LOOT_CONTROLLER,
|
||||
reinterpret_cast<void *>(&LootController_h),
|
||||
reinterpret_cast<void **>(&s_lootController_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookFireEvent{
|
||||
Offsets::FUN_FIRE_EVENT_NO_ARGS,
|
||||
reinterpret_cast<void *>(&FireEventNoArgs_h),
|
||||
reinterpret_cast<void **>(&s_fireEventNoArgs_o)};
|
||||
|
||||
// === Tick (timeout only) ===
|
||||
|
||||
void OnTick() {
|
||||
if (s_scan.state == State::Idle)
|
||||
return;
|
||||
++s_scan.ticksSinceTransition;
|
||||
if (s_scan.ticksSinceTransition >= kStepTimeoutTicks) {
|
||||
// Controller hook never fired — engine guards rejected the
|
||||
// send (player state / OOR), or the server dropped the
|
||||
// packet. Skip this corpse and advance.
|
||||
s_scan.suppressEvents = false;
|
||||
TryStartNext();
|
||||
}
|
||||
}
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
|
||||
|
||||
// === Enumeration ===
|
||||
|
||||
struct EnumCtx {
|
||||
const void *player;
|
||||
std::vector<uint64_t> *out;
|
||||
};
|
||||
|
||||
int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
|
||||
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *obj = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0);
|
||||
if (obj == nullptr)
|
||||
return 1;
|
||||
if (!IsLootableUnit(obj))
|
||||
return 1;
|
||||
if (!InInteractRange(ctx->player, obj))
|
||||
return 1;
|
||||
ctx->out->push_back(guid);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void BuildQueue(void *player) {
|
||||
s_scan.queue.clear();
|
||||
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
|
||||
EnumCtx ctx{player, &s_scan.queue};
|
||||
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
|
||||
&ctx);
|
||||
}
|
||||
|
||||
// === Lua bindings ===
|
||||
|
||||
int __fastcall Script_ScanNearbyLoot(void *L) {
|
||||
if (s_scan.state != State::Idle) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
s_scan.results.clear();
|
||||
s_scan.player = player;
|
||||
BuildQueue(player);
|
||||
|
||||
if (s_scan.queue.empty()) {
|
||||
Complete();
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
TryStartNext();
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsScanInProgress(void *L) {
|
||||
Game::Lua::PushBool(L, s_scan.state != State::Idle);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetLastScanResults(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
int outIdx = 1;
|
||||
for (const auto &entry : s_scan.results) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx++));
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
char guidBuf[Guid::STRING_SIZE];
|
||||
Game::Lua::SetFieldString(L, "guid",
|
||||
Guid::FormatAsString(entry.guid, guidBuf,
|
||||
sizeof guidBuf));
|
||||
Game::Lua::SetFieldNumber(L, "coin", static_cast<double>(entry.coin));
|
||||
|
||||
Game::Lua::PushString(L, "items");
|
||||
Game::Lua::NewTable(L);
|
||||
int itemIdx = 1;
|
||||
for (const auto &item : entry.items) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemIdx++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(item.itemID));
|
||||
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(item.count));
|
||||
if (!item.link.empty())
|
||||
Game::Lua::SetFieldString(L, "link", item.link.c_str());
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Register() {
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
|
||||
&Script_ScanNearbyLoot);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
|
||||
&Script_IsScanInProgress);
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
|
||||
&Script_GetLastScanResults);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&Register};
|
||||
|
||||
} // namespace Loot::Scan
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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 "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../guid/Guid.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Loot::Unit {
|
||||
|
||||
namespace {
|
||||
|
||||
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugMsg,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int debugCode);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
|
||||
// MSVC `__thiscall` puts `player` in ECX and the rest on the stack —
|
||||
// matching the engine's own callsite at `FUN_0060FA20`. The trailing
|
||||
// `char` argument is padded to 4 stack bytes by the ABI.
|
||||
using LootUnit_t = void(__thiscall *)(void *player, void *target,
|
||||
char useDistanceCheck);
|
||||
|
||||
// `C_Loot.LootUnit(guid)` — initiates a loot session against the unit
|
||||
// identified by `guid` (a hex-string as returned by `UnitGUID` /
|
||||
// `C_Loot.GetNearbyLootableUnits`). The engine handles release-prior-
|
||||
// loot, packet construction, and dispatch; the loot window appears
|
||||
// asynchronously via the regular `LOOT_OPENED` event once the server's
|
||||
// `SMSG_LOOT_RESPONSE` lands.
|
||||
//
|
||||
// `useDistanceCheck` is hardcoded to `0` so the engine doesn't auto-
|
||||
// walk the player toward a corpse that happens to be just out of
|
||||
// range — which would be a surprise side-effect for an AoE-loot
|
||||
// caller looping over `GetNearbyLootableUnits`. The server still
|
||||
// enforces real range; OOR requests just don't get a response.
|
||||
//
|
||||
// Returns nothing. Silent no-op when:
|
||||
// - The object manager is NULL (glue / character-select).
|
||||
// - The GUID doesn't resolve to a visible `TYPEMASK_UNIT` object
|
||||
// (target despawned, out of render range, wrong typemask).
|
||||
// - The local player pointer can't be resolved.
|
||||
// Errors only on Lua usage problems (missing arg, unparseable GUID).
|
||||
int __fastcall Script_LootUnit(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Loot.LootUnit(guid)");
|
||||
return 0;
|
||||
}
|
||||
const char *guidStr = Game::Lua::ToString(L, 1);
|
||||
uint64_t guid = 0;
|
||||
if (!Guid::Parse(guidStr, &guid) || guid == 0) {
|
||||
Game::Lua::Error(L, "Usage: C_Loot.LootUnit(guid) — unparseable GUID");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Object manager NULL pre-login; bail silently rather than
|
||||
// crashing inside the resolver.
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
|
||||
return 0;
|
||||
|
||||
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0);
|
||||
if (target == nullptr)
|
||||
return 0;
|
||||
|
||||
// `ResolveUnitToken("player")` gives the canonical CGPlayer_C the
|
||||
// engine uses for `__thiscall` calls — distinct from the global
|
||||
// at `VAR_LOCAL_PLAYER_PTR` (see the comment on that offset).
|
||||
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = ResolveUnitToken("player");
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
|
||||
LootUnit(player, target, /*useDistanceCheck=*/0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void Register() {
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "LootUnit", &Script_LootUnit);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&Register};
|
||||
|
||||
} // namespace Loot::Unit
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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.
|
||||
//
|
||||
// Targeted single-item looting. Given a corpse GUID and a desired
|
||||
// itemID, opens that corpse's loot window via the engine's standard
|
||||
// `FUN_005DF2A0` path, finds the slot containing that itemID once the
|
||||
// window populates, and dispatches `CMSG_LOOT_ITEM` directly through
|
||||
// `FUN_005E1AD0`. The Lua-side `LootSlot` wrapper isn't used — see
|
||||
// the `FUN_CMSG_LOOT_ITEM` comment in Offsets.h for why.
|
||||
//
|
||||
// Fast path when the window is already open for the requested target:
|
||||
// skip the open round-trip and dispatch immediately. This makes
|
||||
// "take items A, B, C from corpse X" a series of cheap calls — the
|
||||
// first opens the window, the rest land in fast-path.
|
||||
|
||||
#include "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../guid/Guid.h"
|
||||
#include "../tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Loot::UnitItem {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kStepTimeoutTicks = 180;
|
||||
|
||||
enum class State {
|
||||
Idle,
|
||||
WaitingOpen,
|
||||
};
|
||||
|
||||
using ClntObjMgrObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugMsg,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int debugCode);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using LootUnit_t = void(__thiscall *)(void *player, void *target,
|
||||
char useDistanceCheck);
|
||||
// `FUN_005E1AD0` is `__stdcall(uint8_t)`, not `__fastcall` — the
|
||||
// callee reads its arg at `[ebp+8]` (stack) and ends with `ret 4`.
|
||||
// Mis-declaring as `__fastcall` makes MSVC skip the push but the
|
||||
// callee still `ret 4`'s, drifting the caller's ESP by 4 bytes per
|
||||
// call and corrupting the surrounding frame. The first symptom is a
|
||||
// crash several frames later on an unrelated vtable lookup —
|
||||
// exactly the EIP=0x00000A70-style faults we saw before this fix.
|
||||
using SendLootItem_t = void(__stdcall *)(uint8_t slotByte);
|
||||
|
||||
State s_state = State::Idle;
|
||||
uint64_t s_targetGuid = 0;
|
||||
uint32_t s_desiredItemID = 0;
|
||||
int s_ticksSinceTransition = 0;
|
||||
|
||||
uint64_t ReadLootTargetGuid() {
|
||||
const uint32_t lo = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_LO);
|
||||
const uint32_t hi = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_HI);
|
||||
return static_cast<uint64_t>(hi) << 32 | lo;
|
||||
}
|
||||
|
||||
// Walks the currently-open loot window's slots looking for `itemID`.
|
||||
// Returns the wire-protocol slot byte the server expects in
|
||||
// `CMSG_LOOT_ITEM`, or `-1` if no slot matches. First match wins; if
|
||||
// the same itemID appears twice, only the first is reachable through
|
||||
// this function (caller can issue a second call after the first
|
||||
// completes).
|
||||
int FindWireSlotForItemID(uint32_t itemID) {
|
||||
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
|
||||
auto *entry = reinterpret_cast<const uint8_t *>(
|
||||
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
|
||||
const uint32_t id = *reinterpret_cast<const uint32_t *>(entry);
|
||||
if (id == 0)
|
||||
continue;
|
||||
if (id == itemID)
|
||||
return static_cast<int>(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool TakeItem(uint32_t itemID) {
|
||||
const int slotByte = FindWireSlotForItemID(itemID);
|
||||
if (slotByte < 0)
|
||||
return false;
|
||||
auto Send = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
|
||||
Send(static_cast<uint8_t>(slotByte));
|
||||
return true;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
s_state = State::Idle;
|
||||
s_targetGuid = 0;
|
||||
s_desiredItemID = 0;
|
||||
}
|
||||
|
||||
void OnTick() {
|
||||
if (s_state == State::Idle)
|
||||
return;
|
||||
++s_ticksSinceTransition;
|
||||
if (s_state == State::WaitingOpen) {
|
||||
if (ReadLootTargetGuid() == s_targetGuid) {
|
||||
TakeItem(s_desiredItemID);
|
||||
Reset();
|
||||
return;
|
||||
}
|
||||
if (s_ticksSinceTransition >= kStepTimeoutTicks)
|
||||
Reset();
|
||||
}
|
||||
}
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
|
||||
|
||||
// `C_Loot.LootUnitItem(guid, itemID)` — takes a specific item from a
|
||||
// specific corpse. If the corpse's loot window is already open, the
|
||||
// item is dispatched synchronously and the function returns `true` if
|
||||
// the slot was found, `false` if not. Otherwise the corpse's window is
|
||||
// opened asynchronously, and once it populates the item is sent
|
||||
// automatically. The async path returns `true` once the request is
|
||||
// queued; there's no "did it succeed" event — observe the player's
|
||||
// inventory or `LOOT_OPENED`/`ITEM_PUSH` events to confirm.
|
||||
//
|
||||
// Returns `false` immediately if:
|
||||
// - args are missing or invalid types,
|
||||
// - another `LootUnitItem` is mid-flight,
|
||||
// - the world isn't loaded,
|
||||
// - the target GUID doesn't resolve to a visible unit,
|
||||
// - (sync path only) no slot in the currently-open window holds
|
||||
// the requested itemID.
|
||||
//
|
||||
// The CMSG_LOOT_ITEM send bypasses the BoP-confirmation dialog the
|
||||
// vanilla UI normally shows. Server still enforces all real
|
||||
// permissions (loot rights, distance, master-loot rules); the
|
||||
// client-side BoP-prompt is purely a courtesy that callers of this
|
||||
// programmatic API have already opted out of by virtue of asking.
|
||||
int __fastcall Script_LootUnitItem(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: C_Loot.LootUnitItem(guid, itemID)");
|
||||
return 0;
|
||||
}
|
||||
uint64_t guid = 0;
|
||||
if (!Guid::Parse(Game::Lua::ToString(L, 1), &guid) || guid == 0) {
|
||||
Game::Lua::Error(L, "Usage: C_Loot.LootUnitItem(guid, itemID) — invalid guid");
|
||||
return 0;
|
||||
}
|
||||
const uint32_t itemID = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
|
||||
|
||||
if (s_state != State::Idle) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fast path: window already open for the requested target — dispatch
|
||||
// CMSG_LOOT_ITEM synchronously. No waiting on `WorldTick`, no state
|
||||
// transition needed.
|
||||
if (ReadLootTargetGuid() == guid) {
|
||||
Game::Lua::PushBool(L, TakeItem(itemID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto ObjectPtr = reinterpret_cast<ClntObjMgrObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *target = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32), 0);
|
||||
if (target == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto LootUnit = reinterpret_cast<LootUnit_t>(Offsets::FUN_CMSG_LOOT_UNIT);
|
||||
LootUnit(player, target, /*useDistanceCheck=*/0);
|
||||
|
||||
s_targetGuid = guid;
|
||||
s_desiredItemID = itemID;
|
||||
s_state = State::WaitingOpen;
|
||||
s_ticksSinceTransition = 0;
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Register() {
|
||||
Game::Lua::RegisterTableFunction("C_Loot", "LootUnitItem",
|
||||
&Script_LootUnitItem);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&Register};
|
||||
|
||||
} // namespace Loot::UnitItem
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetInboxItemLink(messageIndex[, attachmentIndex])` — returns
|
||||
// `(link, itemID)` for the item attached to inbox message
|
||||
// `messageIndex` (1-based). `link` is a basic itemID-only hyperlink
|
||||
// (see note below). Vanilla supports one attachment per message; the
|
||||
// optional 1-based `attachmentIndex` exists for modern-signature
|
||||
// parity and is no-op for any value other than 1.
|
||||
//
|
||||
// `itemID` as a second return is a ClassicAPI extension on top of
|
||||
// modern's link-only signature — saves callers a `string.match` to
|
||||
// parse it back out of the link.
|
||||
//
|
||||
// **Basic link only** (`|cff...|Hitem:N:0:0:0|h[Name]|h|r`). Vanilla
|
||||
// inbox entries DO store per-instance modifiers inline at `+0x12C..`
|
||||
// (enchant, suffix factor, random property — same fields
|
||||
// `Script_GameTooltip_SetInboxItem` copies onto the tooltip), but
|
||||
// 3.3.5's `GetInboxItemLink` ignores them and returns the itemID-only
|
||||
// link too (`FUN_0061E290(itemID)`). We match modern behavior — per-
|
||||
// instance data only fully manifests when the player takes the item
|
||||
// and the engine spawns a real CGItem; until then the link is
|
||||
// definitionally itemID-bound.
|
||||
//
|
||||
// Returns nothing on out-of-range messageIndex, empty message slot,
|
||||
// no attachment on the message, or attachmentIndex ≠ 1.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Link.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Mail::InboxItemLink {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_GetInboxItemLink(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: GetInboxItemLink(messageIndex[, attachmentIndex])");
|
||||
return 0;
|
||||
}
|
||||
const int oneBasedMsg = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (oneBasedMsg < 1)
|
||||
return 0;
|
||||
if (Game::Lua::IsNumber(L, 2)) {
|
||||
const int attach = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
if (attach != 1)
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int msgIdx = oneBasedMsg - 1;
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_INBOX_COUNT));
|
||||
if (msgIdx >= count)
|
||||
return 0;
|
||||
|
||||
// `VAR_INBOX_ENTRIES` is a pointer slot, not the array itself —
|
||||
// one extra deref to reach the entry-pointer array.
|
||||
auto **entries = *reinterpret_cast<const uint8_t ***>(
|
||||
Offsets::VAR_INBOX_ENTRIES);
|
||||
if (entries == nullptr)
|
||||
return 0;
|
||||
const uint8_t *entry = entries[msgIdx];
|
||||
if (entry == nullptr)
|
||||
return 0;
|
||||
|
||||
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_INBOX_ENTRY_ITEM_ID);
|
||||
if (itemID == 0)
|
||||
return 0;
|
||||
|
||||
char buf[256];
|
||||
if (!Item::Link::BasicFromItemID(itemID, buf, sizeof buf))
|
||||
return 0;
|
||||
Game::Lua::PushString(L, buf);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetInboxItemLink",
|
||||
&Script_GetInboxItemLink);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Mail::InboxItemLink
|
||||
@@ -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/>.
|
||||
|
||||
// `GetSendMailItemLink([attachmentIndex])` — returns
|
||||
// `(link, itemID)` for the item currently attached to the outgoing-
|
||||
// mail slot. `link` is the fully-decorated per-instance hyperlink
|
||||
// (enchant / random suffix preserved). Modern WoW expanded mail to up
|
||||
// to 12 attachments and added the index arg; vanilla 1.12 allows
|
||||
// exactly one, so we accept the arg for signature parity but treat
|
||||
// only index 1 as meaningful (any other index returns nothing).
|
||||
//
|
||||
// `itemID` as a second return is a ClassicAPI extension on top of
|
||||
// modern's link-only signature — saves callers a `string.match` to
|
||||
// parse it back out of the link.
|
||||
//
|
||||
// The attached item still exists as a live CGItem in the player's
|
||||
// inventory until the mail is actually sent — the engine stores its
|
||||
// GUID at `VAR_SEND_MAIL_ITEM_GUID_LO/HI`. We resolve the GUID via
|
||||
// `FUN_GET_OBJECT_BY_GUID(OBJECT_TYPE_ITEM, ...)` and feed the
|
||||
// resulting CGItem to the standard `Item::Link::FromCGItem` builder,
|
||||
// so enchant / random-suffix / unique-ID decoration matches what
|
||||
// `GetContainerItemLink` would return for the same item back in
|
||||
// the player's bag.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Mail::SendMailItemLink {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetObjectByGuid_t = void *(__fastcall *)(uint32_t typeMask, const char *debugMsg,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int debugCode);
|
||||
|
||||
int __fastcall Script_GetSendMailItemLink(void *L) {
|
||||
// Optional 1-based attachment index. Anything other than 1 is a
|
||||
// no-op (vanilla is single-slot). Missing arg = treat as index 1.
|
||||
if (Game::Lua::IsNumber(L, 1)) {
|
||||
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (idx != 1)
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SEND_MAIL_ITEM_GUID_LO));
|
||||
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SEND_MAIL_ITEM_GUID_HI));
|
||||
if (guidLo == 0 && guidHi == 0)
|
||||
return 0;
|
||||
|
||||
auto fn = reinterpret_cast<GetObjectByGuid_t>(Offsets::FUN_GET_OBJECT_BY_GUID);
|
||||
auto *cgItem = static_cast<const uint8_t *>(
|
||||
fn(Offsets::OBJECT_TYPE_ITEM, nullptr, guidLo, guidHi, 0));
|
||||
if (cgItem == nullptr)
|
||||
return 0;
|
||||
|
||||
const char *link = Item::Link::FromCGItem(cgItem);
|
||||
if (link == nullptr || *link == '\0')
|
||||
return 0;
|
||||
const int itemID = Item::ID::FromCGItem(cgItem);
|
||||
Game::Lua::PushString(L, link);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetSendMailItemLink",
|
||||
&Script_GetSendMailItemLink);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Mail::SendMailItemLink
|
||||
@@ -41,6 +41,7 @@
|
||||
#include "guid/Guid.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Player::Info {
|
||||
|
||||
@@ -182,6 +183,92 @@ int PushFromNameCacheEntry(void *L, const char *name,
|
||||
return 7;
|
||||
}
|
||||
|
||||
// `UnitNameFromGUID(guid)` → `(name, realm)`. Modern WoW backport;
|
||||
// vanilla 1.12 doesn't expose any GUID → name accessor at the Lua
|
||||
// surface, so addons that need to resolve a chat link or combat-log
|
||||
// GUID can't get a name without round-tripping through a unit token.
|
||||
//
|
||||
// Resolution order (matches vanilla's own `Script_UnitName` at
|
||||
// `0x00517020`):
|
||||
// 1. Resolve the GUID through the object manager. Any currently-
|
||||
// synced unit — player you can see, NPC in interact range,
|
||||
// target / mouseover / party / raid member — gets a `CGObject *`
|
||||
// back. Call the engine's canonical polymorphic name getter
|
||||
// `FUN_OBJECT_GET_NAME` on it; this routes through the player
|
||||
// NameCache for players AND the creature cache for NPCs, so it
|
||||
// works for both with a single call.
|
||||
// 2. If the unit isn't currently synced (ex-target who logged off,
|
||||
// player encountered earlier in chat but no longer visible),
|
||||
// fall back to the persistent NameCache (when enabled).
|
||||
//
|
||||
// `realm` is always `""` in vanilla — the engine doesn't populate
|
||||
// per-player realm names and 1.12 has no cross-realm interaction.
|
||||
// We push the empty string rather than nil so callers don't have to
|
||||
// special-case the second return; modern WoW returns nil, but
|
||||
// `realm == ""` is just as easy to gate on.
|
||||
//
|
||||
// Returns nothing on:
|
||||
// - Missing or non-string arg (raises an error rather than return).
|
||||
// - Unparseable GUID string.
|
||||
// - Zero / NULL GUID.
|
||||
// - GUID not resolvable in the object manager AND not in persistent
|
||||
// NameCache (i.e., we've never seen this unit).
|
||||
//
|
||||
// Doesn't trigger a network query on miss — passive read, same as
|
||||
// `GetPlayerInfoByGUID`. Use `C_PlayerCache.RememberPlayer` or let
|
||||
// the engine populate via normal interaction.
|
||||
int __fastcall Script_UnitNameFromGUID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitNameFromGUID(\"0x...\")");
|
||||
return 0;
|
||||
}
|
||||
const char *guidStr = Game::Lua::ToString(L, 1);
|
||||
uint32_t hi, lo;
|
||||
if (!ParseGUID(guidStr, hi, lo))
|
||||
return 0;
|
||||
if (hi == 0 && lo == 0)
|
||||
return 0;
|
||||
|
||||
// Object-manager path — TYPEMASK_OBJECT (0x01) is permissive; the
|
||||
// name getter at `FUN_OBJECT_GET_NAME` does its own type check and
|
||||
// safely returns the "UNKNOWNOBJECT" sentinel for non-unit
|
||||
// objects (gameobjects etc.), so we just gate on the sentinel
|
||||
// rather than pre-filtering by typemask.
|
||||
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugMsg,
|
||||
uint32_t guidLo,
|
||||
uint32_t guidHi,
|
||||
int debugCode);
|
||||
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
|
||||
|
||||
auto objectPtr = reinterpret_cast<ObjectPtr_t>(
|
||||
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
|
||||
void *obj = objectPtr(Offsets::TYPEMASK_OBJECT, nullptr, lo, hi, 0);
|
||||
if (obj != nullptr) {
|
||||
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
|
||||
const char *name = getName(obj, nullptr);
|
||||
if (name != nullptr && *name != '\0' &&
|
||||
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
|
||||
std::strcmp(name, "Unknown Being") != 0) {
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::PushString(L, "");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Object-manager miss / sentinel — fall back to the persistent
|
||||
// NameCache (when enabled). Covers ex-units no longer in the
|
||||
// engine's sync window.
|
||||
const std::string *cachedName = nullptr;
|
||||
const NameCache::Entry *cached = NameCache::Lookup(
|
||||
(static_cast<uint64_t>(hi) << 32) | lo, &cachedName);
|
||||
if (cached == nullptr || cachedName == nullptr || cachedName->empty())
|
||||
return 0;
|
||||
Game::Lua::PushString(L, cachedName->c_str());
|
||||
Game::Lua::PushString(L, "");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_PlayerCache.GetPlayerInfoByName(name)` — name-keyed lookup over
|
||||
// the persistent NameCache. Returns `(localizedClass, englishClass,
|
||||
// localizedRace, englishRace, sex, name, realm, guid)` or nothing if
|
||||
@@ -212,6 +299,8 @@ int __fastcall Script_C_PlayerCache_GetPlayerInfoByName(void *L) {
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetPlayerInfoByGUID",
|
||||
&Script_GetPlayerInfoByGUID);
|
||||
Game::Lua::RegisterGlobalFunction("UnitNameFromGUID",
|
||||
&Script_UnitNameFromGUID);
|
||||
Game::Lua::RegisterTableFunction("C_PlayerCache", "GetPlayerInfoByName",
|
||||
&Script_C_PlayerCache_GetPlayerInfoByName);
|
||||
}
|
||||
|
||||
@@ -111,10 +111,21 @@ void OnWorldTick() {
|
||||
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
ctrl + Offsets::OFF_UI_INPUT_FLAGS);
|
||||
const bool moving = (flags & Offsets::INPUT_FLAGS_MOVING_ANY) != 0;
|
||||
const bool freeLookHeld = (flags & Offsets::INPUT_FLAG_FREE_LOOK) != 0;
|
||||
const bool mouselookHeld = (flags & Offsets::INPUT_FLAG_MOUSELOOK) != 0;
|
||||
|
||||
// MOVING — translational input. The WASD / autorun keys set the
|
||||
// INPUT_FLAGS_MOVING_ANY bits directly. The "hold both mouse
|
||||
// buttons to run forward" gesture does NOT: the engine drives that
|
||||
// forward move at the movement layer (FUN_005103e0 from the button
|
||||
// handler) and actually *clears* the autorun bit on the both-button
|
||||
// press, so no MOVING_ANY bit is ever set. Detect it from the
|
||||
// bit combo instead — both mouselook (RMB) and free-look (LMB)
|
||||
// held means the engine is mouse-steering the character forward.
|
||||
const bool mouseSteerMoving = mouselookHeld && freeLookHeld;
|
||||
const bool moving =
|
||||
(flags & Offsets::INPUT_FLAGS_MOVING_ANY) != 0 || mouseSteerMoving;
|
||||
|
||||
// TURNING — latched bit-AND-rotation signal:
|
||||
// STARTED fires when mouselook bit is held AND the body yaw
|
||||
// changes (the first actual drag after pressing RMB).
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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/>.
|
||||
|
||||
// `GetPhysicalScreenSize()` → (widthPixels, heightPixels) — the
|
||||
// display's physical resolution in pixels. Modern WoW added this as a
|
||||
// native in 7.0 (Legion); FrameXML's `PixelUtil` hangs its entire
|
||||
// pixel↔UI-unit conversion off it (`768.0 / physicalHeight`), which is
|
||||
// what lets addons snap frames to whole pixels.
|
||||
//
|
||||
// Vanilla 1.12 has no such native and no pre-parsed pixel-dimension
|
||||
// global — the engine only ever holds the current mode as the
|
||||
// `gxResolution` CVar string ("WIDTHxHEIGHT"). `Script_GetScreenWidth`
|
||||
// / `GetScreenHeight` return UI-space units (derived from the UIParent
|
||||
// aspect ratio at `[UIParent + 0x7c]`), not pixels, so they can't
|
||||
// serve. `SetScreenResolution` (`FUN_0048bfd0`) and the display-init
|
||||
// path both sscanf the `gxResolution` value on demand rather than
|
||||
// caching width/height anywhere — so the CVar string is the
|
||||
// authoritative current-resolution source, and we parse it the same
|
||||
// way the engine does.
|
||||
//
|
||||
// Returns 1024x768 (→ factor 1.0, i.e. 1 pixel == 1 UI unit, making
|
||||
// PixelUtil a no-op pass-through) when the CVar is missing or
|
||||
// unparseable. Caveat: in windowed mode `gxResolution` reflects the
|
||||
// configured render resolution, which may differ from the OS window's
|
||||
// client size — vanilla exposes no separate window-pixel query.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Screen::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
using FindCVar_t = const uint8_t *(__fastcall *)(const char *name);
|
||||
|
||||
// Parses "WIDTHxHEIGHT" (e.g. "1920x1080"). Tolerant of the separator
|
||||
// (any run of non-digits between the two numbers) so an uppercase 'X'
|
||||
// or stray spaces don't break it. Returns false unless both numbers
|
||||
// are present and positive.
|
||||
bool ParseResolution(const char *s, int *outW, int *outH) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
|
||||
const char *p = s;
|
||||
int w = 0;
|
||||
bool gotW = false;
|
||||
while (*p >= '0' && *p <= '9') {
|
||||
w = w * 10 + (*p - '0');
|
||||
++p;
|
||||
gotW = true;
|
||||
}
|
||||
if (!gotW)
|
||||
return false;
|
||||
|
||||
while (*p != '\0' && (*p < '0' || *p > '9')) // skip the 'x' separator
|
||||
++p;
|
||||
|
||||
int h = 0;
|
||||
bool gotH = false;
|
||||
while (*p >= '0' && *p <= '9') {
|
||||
h = h * 10 + (*p - '0');
|
||||
++p;
|
||||
gotH = true;
|
||||
}
|
||||
if (!gotH || w <= 0 || h <= 0)
|
||||
return false;
|
||||
|
||||
*outW = w;
|
||||
*outH = h;
|
||||
return true;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetPhysicalScreenSize(void *L) {
|
||||
int width = 1024;
|
||||
int height = 768; // fallback → 768/768 = factor 1.0 (no-op snapping)
|
||||
|
||||
auto findCVar = reinterpret_cast<FindCVar_t>(Offsets::FUN_FIND_CVAR);
|
||||
const uint8_t *cvar = findCVar("gxResolution");
|
||||
if (cvar != nullptr) {
|
||||
const char *value = *reinterpret_cast<const char *const *>(
|
||||
cvar + Offsets::OFF_CVAR_VALUE_STR);
|
||||
ParseResolution(value, &width, &height);
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, width);
|
||||
Game::Lua::PushNumber(L, height);
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetPhysicalScreenSize",
|
||||
&Script_GetPhysicalScreenSize);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Screen::Info
|
||||
+51
-23
@@ -70,9 +70,17 @@ void CancelPlacement() {
|
||||
fn();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
void Commit(const float coords[3]) {
|
||||
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
|
||||
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
|
||||
commit(coords);
|
||||
}
|
||||
|
||||
bool Resolve() {
|
||||
// Shared placement-readiness gate for both the cursor and explicit-
|
||||
// coords commit paths. Decision tree (preserved from the original
|
||||
// Resolve): no placement → false (no cancel); non-ground placement →
|
||||
// cancel + false; not in world → false; otherwise ready → true.
|
||||
bool PlacementReady() {
|
||||
const uint32_t state = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_SPELL_PLACEMENT_STATE);
|
||||
if (state == 0)
|
||||
@@ -81,12 +89,25 @@ bool Resolve() {
|
||||
if ((state & Offsets::SPELL_PLACEMENT_GROUND_MASK) == 0) {
|
||||
// Spell wants a unit / item / corpse / etc. — not ground.
|
||||
// Cancel so the cursor recovers; caller learns via the false
|
||||
// return that no cursor-placement happened.
|
||||
// return that no placement happened.
|
||||
CancelPlacement();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!InWorld())
|
||||
return InWorld();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool CommitAtCoords(const float coords[3]) {
|
||||
if (!PlacementReady())
|
||||
return false;
|
||||
Commit(coords);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Resolve() {
|
||||
if (!PlacementReady())
|
||||
return false;
|
||||
|
||||
void *clickInfo = *reinterpret_cast<void *const *>(
|
||||
@@ -114,9 +135,7 @@ bool Resolve() {
|
||||
const float *coords = reinterpret_cast<const float *>(
|
||||
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_COORDS);
|
||||
|
||||
auto commit = reinterpret_cast<CommitPlacementCoords_t>(
|
||||
Offsets::FUN_COMMIT_PLACEMENT_COORDS);
|
||||
commit(coords);
|
||||
Commit(coords);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -126,16 +145,11 @@ using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
|
||||
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
|
||||
unsigned targetGuidLo, float targetGuidHi);
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (spellID <= 0) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool DispatchSpellCast(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
|
||||
// Hand the spellID off as a numeric string — our `Spell::CastByID`
|
||||
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
|
||||
@@ -148,17 +162,31 @@ int __fastcall Script_C_Spell_CastAtCursor(void *L) {
|
||||
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
|
||||
int bookType = 0;
|
||||
const int slot = nameToSlot(numBuf, &bookType);
|
||||
if (slot < 0) {
|
||||
// Spell not in the player's spellbook — can't cast it from
|
||||
// here either way. Match `CastSpellByName`'s silent failure.
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
if (slot < 0)
|
||||
return false; // not in the player's spellbook
|
||||
|
||||
auto dispatch = reinterpret_cast<CastDispatch_t>(
|
||||
Offsets::FUN_SPELL_CAST_DISPATCH);
|
||||
dispatch(static_cast<unsigned>(slot), bookType,
|
||||
0, 0.0f); // no implicit target unit; engine handles placement
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
// Spell not in the player's spellbook → match `CastSpellByName`'s
|
||||
// silent failure.
|
||||
if (!DispatchSpellCast(spellID)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool placed = Resolve();
|
||||
Game::Lua::PushBool(L, placed);
|
||||
|
||||
@@ -30,4 +30,22 @@ namespace Spell::AtCursor {
|
||||
// click-info struct pointer.
|
||||
bool Resolve();
|
||||
|
||||
// Commits an in-flight ground-target placement at explicit world
|
||||
// coords (x, y, z) instead of the cursor raycast. Same placement
|
||||
// guards as `Resolve` (no placement active → false; non-ground
|
||||
// placement → cancel + false; not in world → false), but skips the
|
||||
// cursor entirely. Used by `C_Spell.CastAtUnit` / `C_Item.UseAtUnit`
|
||||
// to drop the reticle at a unit's feet. Returns true when the
|
||||
// placement was committed.
|
||||
bool CommitAtCoords(const float coords[3]);
|
||||
|
||||
// Resolves a numeric `spellID` to a spellbook slot and dispatches the
|
||||
// cast through the engine — the shared front half of
|
||||
// `C_Spell.CastAtCursor` / `CastAtUnit`. For a ground-target spell
|
||||
// the engine enters placement mode (commit follows via `Resolve` /
|
||||
// `CommitAtCoords`); for a normal spell it fires immediately. Returns
|
||||
// true if the cast was dispatched (spell is in the player's
|
||||
// spellbook), false otherwise.
|
||||
bool DispatchSpellCast(int spellID);
|
||||
|
||||
} // namespace Spell::AtCursor
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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.CastAtUnit(spellID, unit)` — cast a ground-target spell at
|
||||
// the given unit's feet, bypassing the manual click on the AoE
|
||||
// reticle. `CastAtUnit(spellID, "player")` drops it at the player's
|
||||
// position, `"target"` at the target's, etc.
|
||||
//
|
||||
// Same two-step shape as `C_Spell.CastAtCursor`, but the placement is
|
||||
// committed at the unit's world position instead of the cursor
|
||||
// raycast:
|
||||
//
|
||||
// 1. `Spell::AtCursor::DispatchSpellCast(spellID)` initiates the
|
||||
// cast; the engine enters placement mode for a ground-target
|
||||
// spell.
|
||||
// 2. Read the unit's world position (vtable `GetPosition`) and
|
||||
// `Spell::AtCursor::CommitAtCoords()` commits the placement
|
||||
// there.
|
||||
//
|
||||
// Returns `true` when the placement was committed at the unit; `false`
|
||||
// when the spell isn't ground-target (placement cancelled), the spell
|
||||
// isn't in the spellbook, or the unit has no resolvable position. The
|
||||
// cast itself still proceeds for non-ground spells, just with no
|
||||
// implicit target — matching `CastAtCursor`'s semantics.
|
||||
|
||||
#include "Game.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
namespace Spell::AtUnit {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *token = Game::Lua::ToString(L, 2);
|
||||
|
||||
// Resolve the unit's position before dispatching — fail fast so a
|
||||
// bad/absent unit doesn't leave the engine sitting in placement
|
||||
// mode. (A genuinely unrecognized token string raises the engine's
|
||||
// standard "Unknown unit" error inside the resolver, same as
|
||||
// `UnitHealth("garbage")`.)
|
||||
float pos[3];
|
||||
if (!Unit::Position::ReadToken(token, pos)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!Spell::AtCursor::DispatchSpellCast(spellID)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
|
||||
Game::Lua::PushBool(L, placed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "CastAtUnit",
|
||||
&Script_C_Spell_CastAtUnit);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
// Macro-parser pattern: tag macros calling
|
||||
// `C_Spell.CastAtUnit(<spellID>, ...)` with the spellID so action-bar
|
||||
// UIs highlight the slot. The first arg is the numeric spellID, so the
|
||||
// extractor just reads the leading numeric literal — same shape as
|
||||
// `CastAtCursor`'s.
|
||||
int ExtractCastAtUnitArg(const char *p, const char *end) {
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
int value = 0;
|
||||
const char *digitStart = p;
|
||||
while (p < end && *p >= '0' && *p <= '9') {
|
||||
if (value > 100000000) // sanity cap before overflow
|
||||
return 0;
|
||||
value = value * 10 + (*p - '0');
|
||||
++p;
|
||||
}
|
||||
return (p > digitStart && value > 0) ? value : 0;
|
||||
}
|
||||
|
||||
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
|
||||
"C_Spell.CastAtUnit(", &ExtractCastAtUnitArg};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Spell::AtUnit
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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 "Offsets.h"
|
||||
|
||||
// Shared world-position read for any CGObject-derived unit. The
|
||||
// position comes from the object's `GetPosition` virtual (vtable
|
||||
// slot 5) — the same one `CheckInteractDistance` / `UnitInRange` use.
|
||||
// Header-only so callers (UnitInRange, CastAtUnit, UseAtUnit) don't
|
||||
// each re-derive the vtable-slot math.
|
||||
|
||||
namespace Unit::Position {
|
||||
|
||||
// Resolves a unit token ("player", "target", "party1", "mouseover",
|
||||
// …) to a `CGUnit *`. Returns nullptr for a recognized-but-absent
|
||||
// token (e.g. "party5" with no such member). NOTE: the engine's
|
||||
// resolver raises a Lua error for genuinely unrecognized token
|
||||
// strings — same contract as `UnitHealth("garbage")` — so only feed
|
||||
// it real unit tokens, or strings you're willing to let error.
|
||||
inline void *ResolveToken(const char *token) {
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
// Reads `obj`'s world position into `out[3]` (x, y, z). Calls
|
||||
// `obj->vtable[5](outBuf)`; the returned float* may equal `outBuf`
|
||||
// (object filled it) or point at a cached position field — either
|
||||
// way the values land in `out`. Returns false if `obj` is null or
|
||||
// the virtual yields no position (object has no known position yet).
|
||||
inline bool Read(void *obj, float out[3]) {
|
||||
if (obj == nullptr)
|
||||
return false;
|
||||
auto **vtable = *reinterpret_cast<void ***>(obj);
|
||||
using GetPosition_t = float *(__thiscall *)(void *self, float outBuf[3]);
|
||||
auto fn = reinterpret_cast<GetPosition_t>(
|
||||
vtable[Offsets::OFF_CGOBJECT_VTBL_GET_POSITION / 4]);
|
||||
float *p = fn(obj, out);
|
||||
if (p == nullptr)
|
||||
return false;
|
||||
if (p != out) {
|
||||
out[0] = p[0];
|
||||
out[1] = p[1];
|
||||
out[2] = p[2];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convenience: resolve a token and read its position in one step.
|
||||
inline bool ReadToken(const char *token, float out[3]) {
|
||||
return Read(ResolveToken(token), out);
|
||||
}
|
||||
|
||||
} // namespace Unit::Position
|
||||
+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
|
||||
+5
-39
@@ -15,42 +15,17 @@
|
||||
// uses, applied with a fixed 40-yard threshold.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Unit::Range {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetPosition_t = float *(__thiscall *)(void *self, float outBuf[3]);
|
||||
|
||||
constexpr float kRangeYards = 40.0f;
|
||||
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
|
||||
|
||||
void *ResolveUnit(const char *token) {
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
// Calls `obj->vtable[5](outBuf)`. The returned float* may equal
|
||||
// outBuf (the object filled it directly) or point at a cached
|
||||
// position field on the object — either way, deref it for x/y/z.
|
||||
// Returns nullptr if the object's vtable virtual returns null (no
|
||||
// known position for this object yet).
|
||||
float *ReadPosition(void *obj, float outBuf[3]) {
|
||||
if (obj == nullptr)
|
||||
return nullptr;
|
||||
auto **vtable = *reinterpret_cast<void ***>(obj);
|
||||
auto fn = reinterpret_cast<GetPosition_t>(
|
||||
vtable[Offsets::OFF_CGOBJECT_VTBL_GET_POSITION / 4]);
|
||||
return fn(obj, outBuf);
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitInRange(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
|
||||
@@ -69,19 +44,10 @@ int __fastcall Script_UnitInRange(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
void *unit = ResolveUnit(token);
|
||||
void *player = ResolveUnit("player");
|
||||
if (unit == nullptr || player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
float unitBuf[3] = {};
|
||||
float playerBuf[3] = {};
|
||||
const float *unitPos = ReadPosition(unit, unitBuf);
|
||||
const float *playerPos = ReadPosition(player, playerBuf);
|
||||
if (unitPos == nullptr || playerPos == nullptr) {
|
||||
float unitPos[3] = {};
|
||||
float playerPos[3] = {};
|
||||
if (!Unit::Position::ReadToken(token, unitPos) ||
|
||||
!Unit::Position::ReadToken("player", playerPos)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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/>.
|
||||
|
||||
// `UnitSubName(unit)` — returns the NPC's subname / title (the small
|
||||
// italic text under their name in the tooltip): "Innkeeper",
|
||||
// "Stable Master", "<Master Engineer>", etc. Modern WoW exposes this
|
||||
// via `UnitName`'s second return; vanilla 1.12's `UnitName` returns
|
||||
// only one value, so addons that want the subtitle have to roll their
|
||||
// own — until now.
|
||||
//
|
||||
// Returns `nil` for:
|
||||
// - Unresolvable unit token (`target` with nothing targeted, empty
|
||||
// `partyN` slot, etc.).
|
||||
// - Player units — they don't have a row in the creature cache.
|
||||
// - NPCs whose creature data hasn't synced yet (rare; happens when
|
||||
// the unit comes into view but the SMSG_CREATURE_QUERY_RESPONSE
|
||||
// hasn't arrived).
|
||||
// - NPCs with no subname (most non-vendor / non-trainer creatures).
|
||||
//
|
||||
// Raises a Lua error on missing / non-string `unit` argument — same
|
||||
// semantics as the engine's `UnitName`.
|
||||
//
|
||||
// Implementation: walks the creature cache row at `[unit + 0xB30]` →
|
||||
// `[+0x10]` (subname C string). Verified path; same one
|
||||
// VanillaMinimapTracking's blip subname filter uses.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::SubName {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
int __fastcall Script_UnitSubName(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
if (token == nullptr)
|
||||
return 0;
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
|
||||
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
|
||||
if (cacheRow == nullptr)
|
||||
return 0;
|
||||
|
||||
auto *subName = *reinterpret_cast<const char *const *>(
|
||||
cacheRow + Offsets::OFF_CREATURE_CACHE_SUB_NAME);
|
||||
if (subName == nullptr || *subName == '\0')
|
||||
return 0;
|
||||
|
||||
Game::Lua::PushString(L, subName);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitSubName", &Script_UnitSubName);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Unit::SubName
|
||||
Reference in New Issue
Block a user