mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-11 12:27:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2576eb424e | ||
|
|
d80d87e28b | ||
|
|
a0ebfe99b4 | ||
|
|
2bc624c271 | ||
|
|
39608efe75 | ||
|
|
af1d6d25a4 | ||
|
|
5efe47cc34 | ||
|
|
aacbc0d92c | ||
|
|
241a5befd2 | ||
|
|
edab1d1e2a | ||
|
|
b55424bab6 | ||
|
|
11191e8d4c | ||
|
|
4263bae064 | ||
|
|
6f928575fa | ||
|
|
1aeafd41a7 | ||
|
|
3003b1129e | ||
|
|
bf9250141a | ||
|
|
3df21247a2 | ||
|
|
3b33a926ed | ||
|
|
dfddf1698d | ||
|
|
6308429b26 | ||
|
|
37e8b3242c | ||
|
|
bdc574d6ce | ||
|
|
85e3f3c051 | ||
|
|
ee2b4d22ac | ||
|
|
59a603f9b2 | ||
|
|
0332e49ac2 | ||
|
|
b1b564a533 | ||
|
|
444875ae38 | ||
|
|
a7ffe1c704 | ||
|
|
9f75737882 | ||
|
|
10d5790975 | ||
|
|
b571483c5f | ||
|
|
0a79016b0f | ||
|
|
3c4f24192a | ||
|
|
93b4dc5db8 | ||
|
|
d786c607b8 | ||
|
|
598c66894e | ||
|
|
75b4ea3863 | ||
|
|
5f66e381d2 | ||
|
|
4dd892297d | ||
|
|
51cb7a5674 | ||
|
|
cc0bf769c4 | ||
|
|
ad13440743 | ||
|
|
999a5846c4 | ||
|
|
9b5d12c8a1 | ||
|
|
d1bab7a3a4 | ||
|
|
e657bac266 | ||
|
|
0248661bb7 | ||
|
|
56fef8ec08 | ||
|
|
09ac68e674 | ||
|
|
19ad6e6514 | ||
|
|
41bb687608 | ||
|
|
d9cc3f9d7e | ||
|
|
6b19c8bbb5 | ||
|
|
09e57c3d97 | ||
|
|
2f3c67be1a | ||
|
|
f94af6cb73 | ||
|
|
1d370ada65 | ||
|
|
35c78ea6be | ||
|
|
ad83eb9575 | ||
|
|
8995e82627 | ||
|
|
94b521881b | ||
|
|
634a440def | ||
|
|
4dce58a457 | ||
|
|
99e9621590 | ||
|
|
e8fc583f19 | ||
|
|
03f63d391a | ||
|
|
6df403444c | ||
|
|
cb7953b503 | ||
|
|
774df8d0d4 | ||
|
|
36e1aa87e4 | ||
|
|
40532aca8e | ||
|
|
8592254b2d | ||
|
|
289f82a7e7 |
@@ -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
|
||||
@@ -1,20 +1,24 @@
|
||||
## Interface: 11200
|
||||
## Title: !!!ClassicAPI
|
||||
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
|
||||
## Version: 1.3
|
||||
## Version: DEV
|
||||
## Author: Brues
|
||||
|
||||
GlobalStrings.lua
|
||||
locales\locales.xml
|
||||
|
||||
Compat.lua
|
||||
Util\Print.lua
|
||||
Util\Mixin.lua
|
||||
Util\Color.lua
|
||||
Util\Constants.lua
|
||||
Util\String.lua
|
||||
Util\Mixin.lua
|
||||
Util\TableUtil.lua
|
||||
Util\MathUtil.lua
|
||||
Util\FrameUtil.lua
|
||||
Util\FormattingUtil.lua
|
||||
Util\TimeUtil.lua
|
||||
Util\CallbackRegistry.lua
|
||||
Util\GlobalCallbackRegistry.lua
|
||||
Util\Color.lua
|
||||
Util\ColorUtil.lua
|
||||
Util\ItemLocation.lua
|
||||
Util\EventUtil.lua
|
||||
@@ -29,3 +33,4 @@ Util\PlayerUtil.lua
|
||||
Util\SlashCommandsRegistry.lua
|
||||
Util\LinkUtil.lua
|
||||
Util\IconDataProvider.lua
|
||||
Util\GameTooltip.lua
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
|
||||
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";
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -83,12 +83,11 @@ function EventUtil.TriggerOnVariablesLoaded()
|
||||
end
|
||||
|
||||
function EventUtil.ContinueOnAddOnLoaded(addOnName, callback)
|
||||
-- 1.12's IsAddOnLoaded returns a single bool. 3.3.5 split that into
|
||||
-- (isLoadedOrLoading, isLoaded); treat truthy as "ready to use".
|
||||
if IsAddOnLoaded(addOnName) then
|
||||
callback()
|
||||
return
|
||||
end
|
||||
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
|
||||
if isLoaded then
|
||||
callback();
|
||||
return;
|
||||
end
|
||||
|
||||
EventUtil.RegisterOnceFrameEventAndCallback("ADDON_LOADED", callback, addOnName)
|
||||
end
|
||||
|
||||
@@ -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,3 @@
|
||||
function GameTooltip_Hide()
|
||||
GameTooltip:Hide()
|
||||
end
|
||||
@@ -4,4 +4,22 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
|
||||
_G["SLASH_"..name.."1"] = "/"..command;
|
||||
_G["SLASH_"..name.."2"] = "/"..commandAlias;
|
||||
SlashCmdList[name] = callback;
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["FOCUS"] = function(msg)
|
||||
if msg == "" then
|
||||
FocusUnit();
|
||||
else
|
||||
FocusUnit(msg);
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLEARFOCUS"] = function(msg)
|
||||
ClearFocus()
|
||||
end
|
||||
|
||||
SlashCmdList["EQUIP_SET"] = function(msg)
|
||||
if msg ~= "" then
|
||||
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,18 @@ function CreateTableReverseEnumerator(tbl, minIndex, maxIndex)
|
||||
return Enumerator, tbl, maxIndex
|
||||
end
|
||||
|
||||
function tDeleteItem(tbl, item)
|
||||
local size = tGetN(tbl);
|
||||
local index = size;
|
||||
while index > 0 do
|
||||
if item == tbl[index] then
|
||||
tRemove(tbl, index);
|
||||
end
|
||||
index = index - 1;
|
||||
end
|
||||
return size - tGetN(tbl);
|
||||
end
|
||||
|
||||
function tIndexOf(tbl, item)
|
||||
for i, v in ipairs(tbl) do
|
||||
if item == v then
|
||||
@@ -73,6 +85,15 @@ function tIndexOf(tbl, item)
|
||||
end
|
||||
end
|
||||
|
||||
function tContains(tbl, item)
|
||||
for k, v in pairs(tbl) do
|
||||
if item == v then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
function TableUtil.ContainsAllKeys(lhsTable, rhsTable)
|
||||
for key, _ in pairs(lhsTable) do
|
||||
if rhsTable[key] == nil then
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Backport of FrameXML/TimeUtil.lua (feasible subset) to vanilla 1.12 /
|
||||
-- Lua 5.0.
|
||||
--
|
||||
-- Ports the deprecated-but-widely-used duration formatters (SecondsToTime,
|
||||
-- SecondsToClock, SecondsToTimeAbbrev) plus the unit constants/helpers. The
|
||||
-- SecondsFormatterMixin (the modern replacement) is omitted — it's a heavy
|
||||
-- mixin class little addon code targets on classic.
|
||||
--
|
||||
-- 5.0 notes: `%` -> math.mod, `s:format()` -> string.format. The FrameXML
|
||||
-- format-string globals these functions consume (DAYS_ABBR, D_HOURS,
|
||||
-- HOURS_MINUTES_SECONDS, the *_ONELETTER_ABBR set, …) are defined in the
|
||||
-- locale layer (locales/enUS.lua), which loads first.
|
||||
|
||||
SECONDS_PER_MIN = 60;
|
||||
SECONDS_PER_HOUR = 60 * SECONDS_PER_MIN;
|
||||
SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
|
||||
SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY;
|
||||
SECONDS_PER_YEAR = 12 * SECONDS_PER_MONTH;
|
||||
|
||||
function SecondsToMinutes(seconds)
|
||||
return seconds / SECONDS_PER_MIN;
|
||||
end
|
||||
|
||||
function MinutesToSeconds(minutes)
|
||||
return minutes * SECONDS_PER_MIN;
|
||||
end
|
||||
|
||||
function ConvertSecondsToUnits(timestamp)
|
||||
timestamp = math.max(timestamp, 0);
|
||||
local days = math.floor(timestamp / SECONDS_PER_DAY);
|
||||
timestamp = timestamp - (days * SECONDS_PER_DAY);
|
||||
local hours = math.floor(timestamp / SECONDS_PER_HOUR);
|
||||
timestamp = timestamp - (hours * SECONDS_PER_HOUR);
|
||||
local minutes = math.floor(timestamp / SECONDS_PER_MIN);
|
||||
timestamp = timestamp - (minutes * SECONDS_PER_MIN);
|
||||
local seconds = math.floor(timestamp);
|
||||
local milliseconds = timestamp - seconds;
|
||||
return {
|
||||
days = days,
|
||||
hours = hours,
|
||||
minutes = minutes,
|
||||
seconds = seconds,
|
||||
milliseconds = milliseconds,
|
||||
};
|
||||
end
|
||||
|
||||
function SecondsToClock(seconds, displayZeroHours)
|
||||
local units = ConvertSecondsToUnits(seconds);
|
||||
if units.hours > 0 or displayZeroHours then
|
||||
return string.format(HOURS_MINUTES_SECONDS, units.hours, units.minutes, units.seconds);
|
||||
else
|
||||
return string.format(MINUTES_SECONDS, units.minutes, units.seconds);
|
||||
end
|
||||
end
|
||||
|
||||
-- 1.12's native unit strings are bare labels ("Hr", "Min") with no `%d`,
|
||||
-- unlike modern's "%d Hr"; insert the count correctly for either shape.
|
||||
local function FormatUnit(fmt, count)
|
||||
if string.find(fmt, "%%d") then
|
||||
return string.format(fmt, count);
|
||||
end
|
||||
return count .. " " .. fmt;
|
||||
end
|
||||
|
||||
function SecondsToTime(seconds, noSeconds, notAbbreviated, maxCount, roundUp)
|
||||
local time = "";
|
||||
local count = 0;
|
||||
local tempTime;
|
||||
seconds = roundUp and math.ceil(seconds) or math.floor(seconds);
|
||||
maxCount = maxCount or 2;
|
||||
|
||||
-- With a single term, use a higher 1.5x threshold; with 2+, 1.0x.
|
||||
local threshold = (maxCount > 1) and 1.0 or 1.5;
|
||||
|
||||
if seconds >= SECONDS_PER_DAY * threshold then
|
||||
count = count + 1;
|
||||
tempTime = (count == maxCount and roundUp) and math.ceil(seconds / SECONDS_PER_DAY)
|
||||
or math.floor(seconds / SECONDS_PER_DAY);
|
||||
time = FormatUnit(notAbbreviated and D_DAYS or DAYS_ABBR, tempTime);
|
||||
seconds = math.mod(seconds, SECONDS_PER_DAY);
|
||||
end
|
||||
if count < maxCount and seconds >= SECONDS_PER_HOUR * threshold then
|
||||
count = count + 1;
|
||||
if time ~= "" then time = time .. TIME_UNIT_DELIMITER; end
|
||||
tempTime = (count == maxCount and roundUp) and math.ceil(seconds / SECONDS_PER_HOUR)
|
||||
or math.floor(seconds / SECONDS_PER_HOUR);
|
||||
time = time .. FormatUnit(notAbbreviated and D_HOURS or HOURS_ABBR, tempTime);
|
||||
seconds = math.mod(seconds, SECONDS_PER_HOUR);
|
||||
end
|
||||
if count < maxCount and seconds >= SECONDS_PER_MIN * threshold then
|
||||
count = count + 1;
|
||||
if time ~= "" then time = time .. TIME_UNIT_DELIMITER; end
|
||||
tempTime = (count == maxCount and roundUp) and math.ceil(seconds / SECONDS_PER_MIN)
|
||||
or math.floor(seconds / SECONDS_PER_MIN);
|
||||
time = time .. FormatUnit(notAbbreviated and D_MINUTES or MINUTES_ABBR, tempTime);
|
||||
seconds = math.mod(seconds, SECONDS_PER_MIN);
|
||||
end
|
||||
if count < maxCount and seconds > 0 and not noSeconds then
|
||||
if time ~= "" then time = time .. TIME_UNIT_DELIMITER; end
|
||||
time = time .. FormatUnit(notAbbreviated and D_SECONDS or SECONDS_ABBR, seconds);
|
||||
end
|
||||
return time;
|
||||
end
|
||||
|
||||
-- Returns (formatString, value) for the single largest applicable unit.
|
||||
function SecondsToTimeAbbrev(seconds, thresholdOverride)
|
||||
local threshold = thresholdOverride or 1.5;
|
||||
if seconds >= SECONDS_PER_DAY * threshold then
|
||||
return DAY_ONELETTER_ABBR, math.ceil(seconds / SECONDS_PER_DAY);
|
||||
end
|
||||
if seconds >= SECONDS_PER_HOUR * threshold then
|
||||
return HOUR_ONELETTER_ABBR, math.ceil(seconds / SECONDS_PER_HOUR);
|
||||
end
|
||||
if seconds >= SECONDS_PER_MIN * threshold then
|
||||
return MINUTE_ONELETTER_ABBR, math.ceil(seconds / SECONDS_PER_MIN);
|
||||
end
|
||||
return SECOND_ONELETTER_ABBR, seconds;
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "deDE" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
|
||||
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
|
||||
FOCUSTARGET = "Ziel als Fokus festlegen";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
|
||||
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
|
||||
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
|
||||
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
|
||||
ITEM_MISSING = "%s fehlt";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
|
||||
TARGETFOCUS = "Fokussierte Einheit anvisieren";
|
||||
@@ -0,0 +1,39 @@
|
||||
BINDING_NAME_AOELOOT = "AoE Loot"
|
||||
BINDING_NAME_FOCUSTARGET = "Focus Target";
|
||||
BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
FOCUSTARGET = "Focus Target";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
|
||||
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";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
TARGETFOCUS = "Target Focus";
|
||||
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
SILVER_AMOUNT_SYMBOL = "s";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
TIME_UNIT_DELIMITER = " ";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
DAYS_ABBR = "%d |4Day:Days;";
|
||||
HOURS_ABBR = "%d |4Hr:Hr;";
|
||||
MINUTES_ABBR = "%d |4Min:Min;";
|
||||
SECONDS_ABBR = "%d |4Sec:Sec;";
|
||||
D_DAYS = "%d |4Day:Days;";
|
||||
D_HOURS = "%d |4Hour:Hours;";
|
||||
D_MINUTES = "%d |4Minute:Minutes;";
|
||||
D_SECONDS = "%d |4Second:Seconds;";
|
||||
DAY_ONELETTER_ABBR = "%d d";
|
||||
HOUR_ONELETTER_ABBR = "%d h";
|
||||
MINUTE_ONELETTER_ABBR = "%d m";
|
||||
SECOND_ONELETTER_ABBR = "%d s";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "esES" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
|
||||
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
|
||||
FOCUSTARGET = "Establecer objetivo";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
|
||||
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
|
||||
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
|
||||
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
|
||||
ITEM_MISSING = "Falta: %s";
|
||||
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
|
||||
TARGETFOCUS = "Fijar unidad objetivo";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "frFR" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Définir cible";
|
||||
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
|
||||
FOCUSTARGET = "Définir la cible de focalisation";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
|
||||
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
|
||||
ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
|
||||
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
|
||||
ITEM_MISSING = "Objet manquant : %s";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
|
||||
TARGETFOCUS = "Cibler l'unité focalisée";
|
||||
@@ -0,0 +1,9 @@
|
||||
<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="enUS.lua"/>
|
||||
<Script file="deDE.lua"/>
|
||||
<Script file="esES.lua"/>
|
||||
<Script file="frFR.lua"/>
|
||||
<Script file="ruRU.lua"/>
|
||||
<Script file="zhCN.lua"/>
|
||||
<Script file="zhTW.lua"/>
|
||||
</Ui>
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "ruRU" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
|
||||
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
|
||||
FOCUSTARGET = "Запомнить цель";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
|
||||
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
|
||||
ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов; в сумках";
|
||||
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
|
||||
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "zhCN" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "焦点目标";
|
||||
BINDING_NAME_TARGETFOCUS = "目标焦点";
|
||||
FOCUSTARGET = "设定焦点目标";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d件物品";
|
||||
ITEMS_EQUIPPED = "已装备%d件物品";
|
||||
ITEMS_IN_INVENTORY = "背包中有%d件物品";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
|
||||
ITEM_MISSING = "%s缺失";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
@@ -0,0 +1,12 @@
|
||||
if GetLocale() ~= "zhTW" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "鎖定目標";
|
||||
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
|
||||
FOCUSTARGET = "設定鎖定目標";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d件物品";
|
||||
ITEMS_EQUIPPED = "裝備%d件物品";
|
||||
ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
|
||||
ITEM_MISSING = "缺少%s物品";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
@@ -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
|
||||
+21
-1
@@ -47,9 +47,29 @@ add_custom_command(
|
||||
COMMENT "Embedding !!!ClassicAPI addon into ${EMBEDDED_CLASSICAPI_HEADER}"
|
||||
VERBATIM)
|
||||
|
||||
# Bundle `src/bindings/Bindings.xml` (our TARGETING-group additions)
|
||||
# into a generated header. `src/bindings/Inject.cpp` extracts the
|
||||
# inner `<Binding>` elements from the embedded buffer and splices them
|
||||
# into the engine's `Interface\FrameXML\Bindings.xml` at load time.
|
||||
set(EMBEDDED_BINDINGS_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml")
|
||||
set(EMBEDDED_BINDINGS_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_bindings.h")
|
||||
add_custom_command(
|
||||
OUTPUT "${EMBEDDED_BINDINGS_HEADER}"
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DINPUT_FILE=${EMBEDDED_BINDINGS_SRC}
|
||||
-DOUT_HEADER=${EMBEDDED_BINDINGS_HEADER}
|
||||
-DSYMBOL=kEmbeddedBindingsXml
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
|
||||
DEPENDS
|
||||
"${EMBEDDED_BINDINGS_SRC}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
|
||||
COMMENT "Embedding bindings XML into ${EMBEDDED_BINDINGS_HEADER}"
|
||||
VERBATIM)
|
||||
|
||||
add_library(${PROJECT_NAME} SHARED
|
||||
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
|
||||
${EMBEDDED_CLASSICAPI_HEADER})
|
||||
${EMBEDDED_CLASSICAPI_HEADER}
|
||||
${EMBEDDED_BINDINGS_HEADER})
|
||||
|
||||
# Dev-build sentinel — represents "newer than every plausible
|
||||
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
|
||||
|
||||
@@ -18,40 +18,43 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
|
||||
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` (sets persist to `WTF\Account\...\ClassicAPI_EquipmentSets.txt`) |
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [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.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.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` |
|
||||
| [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` |
|
||||
| [Quest](docs/API.md#quest) | `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.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` |
|
||||
| [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.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`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [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
|
||||
@@ -71,6 +74,7 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
|
||||
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
| State | `IsLoggedIn` |
|
||||
|
||||
### Macros
|
||||
|
||||
@@ -97,6 +101,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") |
|
||||
@@ -131,6 +136,19 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
|
||||
### Bindings
|
||||
|
||||
Injected into the engine's **Targeting Functions** group at FrameXML
|
||||
Bindings.xml load time, so they appear in the keybind UI alongside
|
||||
native targeting bindings instead of orphaned at the bottom.
|
||||
|
||||
| Binding | Action |
|
||||
|---------|--------|
|
||||
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
|
||||
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
|
||||
|
||||
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
|
||||
|
||||
## Installation
|
||||
|
||||
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# embed_file.cmake — bundle a single file into a generated C++ header.
|
||||
#
|
||||
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads
|
||||
# INPUT_FILE and emits OUT_HEADER containing a constexpr byte array
|
||||
# named SYMBOL plus a `SYMBOL ## Size` size constant. Both live at file
|
||||
# scope; the header is meant to be included exactly once.
|
||||
#
|
||||
# Usage:
|
||||
# cmake -DINPUT_FILE=... -DOUT_HEADER=... -DSYMBOL=... -P embed_file.cmake
|
||||
|
||||
if(NOT INPUT_FILE)
|
||||
message(FATAL_ERROR "embed_file.cmake: INPUT_FILE required")
|
||||
endif()
|
||||
if(NOT OUT_HEADER)
|
||||
message(FATAL_ERROR "embed_file.cmake: OUT_HEADER required")
|
||||
endif()
|
||||
if(NOT SYMBOL)
|
||||
message(FATAL_ERROR "embed_file.cmake: SYMBOL required")
|
||||
endif()
|
||||
|
||||
file(READ "${INPUT_FILE}" CONTENT_HEX HEX)
|
||||
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
|
||||
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
|
||||
|
||||
# Convert hex string to comma-separated byte literals. 16 bytes per
|
||||
# line keeps the generated header readable.
|
||||
set(BYTES "")
|
||||
set(COL 0)
|
||||
set(POS 0)
|
||||
while(POS LESS HEX_LEN)
|
||||
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
|
||||
if(COL EQUAL 0)
|
||||
string(APPEND BYTES " ")
|
||||
endif()
|
||||
string(APPEND BYTES "0x${BYTE},")
|
||||
math(EXPR POS "${POS} + 2")
|
||||
math(EXPR COL "${COL} + 1")
|
||||
if(COL EQUAL 16)
|
||||
string(APPEND BYTES "\n")
|
||||
set(COL 0)
|
||||
else()
|
||||
string(APPEND BYTES " ")
|
||||
endif()
|
||||
endwhile()
|
||||
if(NOT COL EQUAL 0)
|
||||
string(APPEND BYTES "\n")
|
||||
endif()
|
||||
|
||||
set(HEADER_CONTENT "// Auto-generated by embed_file.cmake — do not edit.
|
||||
// Source: ${INPUT_FILE}
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
static constexpr unsigned char ${SYMBOL}[] = {
|
||||
${BYTES}};
|
||||
static constexpr size_t ${SYMBOL}Size = sizeof(${SYMBOL});
|
||||
")
|
||||
|
||||
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
|
||||
message(STATUS "embed_file: wrote ${OUT_HEADER} (${FILE_SIZE} bytes)")
|
||||
+1938
-788
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -3,7 +3,9 @@
|
||||
Catalogue of the Lua C API functions linked into the client. Vanilla WoW is
|
||||
built against **Lua 5.0** (not 5.1), and the entire API plus the auxiliary
|
||||
library lives in a contiguous block of `.text` from roughly `0x006F2000` to
|
||||
`0x006F5100`. ~120 functions; the public C API is the core of that.
|
||||
`0x006F5100`. ~120 functions; the public C API is the core of that. The
|
||||
thread / coroutine machinery (`lua_resume`, `lua_yield`, the inner
|
||||
`luaE_newthread`) lives slightly higher at `0x006F6000`–`0x006F6900`.
|
||||
|
||||
**Cross-verified against two binaries:**
|
||||
|
||||
@@ -75,6 +77,8 @@ context but not byte-by-byte verified.
|
||||
|
||||
| VA | Function | Conv. | Notes | Conf |
|
||||
|--------------|-------------------------|----------|--------------------------------------------------------------|------|
|
||||
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
|
||||
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
|
||||
| `0x006F3070` | `lua_gettop` | fastcall | `(top - base) >> 4` | ★ |
|
||||
| `0x006F3080` | `lua_settop` | fastcall | | ✓ |
|
||||
| `0x006F30D0` | `lua_remove` | fastcall | shift-down loop + decr_top; previously misidentified as `lua_pushvalue` | ★ |
|
||||
@@ -155,7 +159,10 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
|
||||
| `0x006F4290` | *internal CFunction trampoline* | | called via `lua_cpcall` | ★ |
|
||||
| `0x006F4320` | `lua_load` | fastcall | reader / loader entry | ★ |
|
||||
| `0x006F4370` | `lua_dump` | fastcall | requires tag 6 + `Closure.isC == 0` | ★ |
|
||||
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
|
||||
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
|
||||
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
|
||||
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
|
||||
| `0x006F6B10` | *internal* `luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
|
||||
|
||||
### Misc / GC / version
|
||||
|
||||
|
||||
+42
-6
@@ -48,7 +48,15 @@ namespace Lua {
|
||||
F(PCall, lua_pcall, LUA_PCALL) \
|
||||
F(Next, lua_next, LUA_NEXT) \
|
||||
F(Type, lua_type, LUA_TYPE) \
|
||||
F(Error, lua_error, LUA_ERROR)
|
||||
F(Error, lua_error, LUA_ERROR) \
|
||||
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
|
||||
F(ToThread, lua_tothread, LUA_TO_THREAD) \
|
||||
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
|
||||
F(XMove, lua_xmove, LUA_X_MOVE) \
|
||||
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
|
||||
F(Resume, lua_resume, LUA_RESUME) \
|
||||
F(Yield, lua_yield, LUA_YIELD) \
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
@@ -186,17 +194,45 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
|
||||
// the string at the top of the stack with the substitution result.
|
||||
// Discards gsub's second return (the substitution count) by
|
||||
// requesting only one return from `Call`.
|
||||
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
|
||||
PushString(L, "string");
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
PushString(L, "gsub");
|
||||
GetTable(L, -2); // [..., str, string, gsub]
|
||||
Remove(L, -2); // [..., str, gsub]
|
||||
Insert(L, -2); // [..., gsub, str]
|
||||
PushString(L, pattern);
|
||||
PushString(L, replacement);
|
||||
Call(L, 3, 1); // [..., result]
|
||||
}
|
||||
|
||||
void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
const char *fallback, int n) {
|
||||
// Resolve `string.format` from _G. The engine ships it as both a
|
||||
// global and as `string.format`; the global is faster (one
|
||||
// gettable instead of two) and equally available across builds.
|
||||
// Resolve `string.format` from _G.
|
||||
PushString(L, "format");
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
|
||||
PushLocalizedString(L, globalName, fallback);
|
||||
|
||||
// Expand WoW's `|4singular:plural;` (and 3-form
|
||||
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
|
||||
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
|
||||
// grammar (Slavic counting has a separate genitive-singular form
|
||||
// for 2..4); we collapse to nominative/plural since vanilla has
|
||||
// no proper locale-aware counting rules. 3-form pattern runs
|
||||
// first because it's more specific. Vanilla's `string.format`
|
||||
// doesn't process `|4` natively, so without this the template
|
||||
// would render literally.
|
||||
const char *picker3 = (n == 1) ? "%1" : "%3";
|
||||
const char *picker2 = (n == 1) ? "%1" : "%2";
|
||||
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
|
||||
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
|
||||
|
||||
PushNumber(L, static_cast<double>(n));
|
||||
Call(L, 2, 1); // format(fmt, n) → string
|
||||
Call(L, 2, 1); // format(expandedFmt, n) → string
|
||||
}
|
||||
} // namespace Lua
|
||||
|
||||
|
||||
+31
-1
@@ -49,6 +49,8 @@ constexpr int TYPE_NUMBER = 3;
|
||||
constexpr int TYPE_STRING = 4;
|
||||
constexpr int TYPE_TABLE = 5;
|
||||
constexpr int TYPE_FUNCTION = 6;
|
||||
constexpr int TYPE_USERDATA = 7;
|
||||
constexpr int TYPE_THREAD = 8;
|
||||
|
||||
using lua_isnumber_t = bool(__fastcall *)(void *L, int index);
|
||||
using lua_isstring_t = bool(__fastcall *)(void *L, int index);
|
||||
@@ -76,7 +78,27 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
|
||||
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
|
||||
using lua_next_t = int(__fastcall *)(void *L, int idx);
|
||||
using lua_type_t = int(__fastcall *)(void *L, int index);
|
||||
using lua_error_t = void(__cdecl *)(void *L, const char *);
|
||||
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
|
||||
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
|
||||
// and throws. Existing callers pass a single literal (no `%`), which
|
||||
// is fine because cdecl tolerates extra-arg-less calls. New callers
|
||||
// forwarding user-supplied error strings should use `Error(L, "%s",
|
||||
// userMsg)` to avoid format-string interpretation of `%` in the
|
||||
// message body.
|
||||
using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
|
||||
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
|
||||
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
|
||||
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
|
||||
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
|
||||
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
|
||||
// infers `void` because the inner call's value just flows through.
|
||||
// We declare the return type honestly here; callers that don't want
|
||||
// to rely on the calling convention preserving eax can read the
|
||||
// thread back from L's top via `ToPointer(L, -1)`.
|
||||
using lua_newthread_t = void *(__fastcall *)(void *L);
|
||||
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
|
||||
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
|
||||
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -110,6 +132,14 @@ extern const lua_pcall_t PCall;
|
||||
extern const lua_next_t Next;
|
||||
extern const lua_type_t Type;
|
||||
extern const lua_error_t Error;
|
||||
extern const lua_topointer_t ToPointer;
|
||||
extern const lua_tothread_t ToThread;
|
||||
extern const lua_iscfunction_t IsCFunction;
|
||||
extern const lua_xmove_t XMove;
|
||||
extern const lua_newthread_t NewThread;
|
||||
extern const lua_resume_t Resume;
|
||||
extern const lua_yield_t Yield;
|
||||
extern const luaL_argerror_t ArgError;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
|
||||
+687
-5
@@ -18,6 +18,18 @@ enum Offsets {
|
||||
FUN_INVALID_FUNCTION_PTR_CHECK = 0x42A320,
|
||||
FUN_LOAD_SCRIPT_FUNCTIONS = 0x490250,
|
||||
|
||||
// Fatal-error dispatcher. `__fastcall(uint code)`. Writes `code`
|
||||
// to `DAT_00882738` and chains into the process-teardown path;
|
||||
// `FUN_00403BC0` reads the code at exit and shows a localized
|
||||
// popup keyed off it. Code `10` = "interface files are corrupt"
|
||||
// — the engine fires this from FrameXML's `FUN_0048FBF0` hash
|
||||
// mismatch and per-addon `FUN_0051F240` hash mismatch (plus
|
||||
// other call sites we haven't pinned down by byte pattern).
|
||||
// Hooked by `Security::FrameXMLBypass` to suppress code 10 so
|
||||
// our in-memory file modifications (Bindings.xml splice +
|
||||
// embedded `!!!ClassicAPI` addon) don't trigger termination.
|
||||
FUN_FATAL_ERROR = 0x00401560,
|
||||
|
||||
// Master glue Lua init — clean linear caller of all 5 glue batch
|
||||
// trampolines (60 main + 11 char-select + 24 char-create + 10 realm
|
||||
// + 4 frame globals = 109 total). Body: 0x0046ABB0..0x0046ABD2,
|
||||
@@ -274,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
|
||||
@@ -390,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
|
||||
@@ -595,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
|
||||
@@ -1056,6 +1119,26 @@ enum Offsets {
|
||||
// With `requestIfMissing=false`, returns NULL if not in cache (no server
|
||||
// round-trip) — exactly the "instant" semantics we want.
|
||||
FUN_DBCACHE_ITEMSTATS_GET_RECORD = 0x0055BA30,
|
||||
// Item-cache response handler — the function called from
|
||||
// `0x00555140` (success path for `SMSG_ITEM_QUERY_SINGLE_RESPONSE`)
|
||||
// and `0x00555160` (failure path). Parses one or more itemIDs out
|
||||
// of the packet, looks up (or creates) the cache entry per ID,
|
||||
// copies the item data block via `FUN_007c9640` to `entry+0x18`,
|
||||
// sets `[entry+0x1F0]=1` (the loaded byte), then walks the entry's
|
||||
// pending callback list at `[entry+0x1FC]` and invokes each with
|
||||
// `(userData, success)`. High-bit-on itemID in the packet signals
|
||||
// a per-item failure for the batch.
|
||||
//
|
||||
// We hook this to observe any item-cache fill the engine performs
|
||||
// — covers the engine's natural inventory prefetch and any
|
||||
// addon-triggered queries without having to register our own
|
||||
// engine callbacks. Lets `RequestLoadItemData` be purely passive:
|
||||
// we track itemIDs, the engine (or whoever) issues the query, we
|
||||
// see the fill via this hook and fire `ITEM_DATA_LOAD_RESULT`.
|
||||
//
|
||||
// Signature: `__thiscall(void *cache, void *packetReader, int flag)`.
|
||||
// `cache` is the same `VAR_ITEMDB_CACHE` we already use.
|
||||
FUN_ITEMSTATS_CACHE_RESPONSE = 0x0055BDB0,
|
||||
// ItemStats_C field offsets we read. Full struct layout in
|
||||
// VanillaHelpers's `Game.h` (`struct ItemStats_C`); we only need these.
|
||||
OFF_ITEMSTATS_CLASS = 0x00,
|
||||
@@ -1170,6 +1253,10 @@ enum Offsets {
|
||||
// always at index 0 (some Turtle WoW custom items put it later).
|
||||
OFF_ITEMSTATS_SPELL_ID = 0x11C, // u32[5] — spell ID per slot
|
||||
OFF_ITEMSTATS_SPELL_TRIGGER = 0x130, // u32[5] — trigger code per slot
|
||||
OFF_ITEMSTATS_SPELL_CHARGES = 0x144, // i32[5] — charges per slot (-1 = infinite)
|
||||
OFF_ITEMSTATS_SPELL_COOLDOWN = 0x158, // u32[5] — cooldown ms per slot
|
||||
OFF_ITEMSTATS_SPELL_CATEGORY = 0x16C, // u32[5] — category (groups shared cooldowns)
|
||||
OFF_ITEMSTATS_SPELL_CATEGORY_CD = 0x180, // u32[5] — category cooldown ms
|
||||
ITEMSTATS_SPELL_SLOT_COUNT = 5,
|
||||
ITEM_SPELLTRIGGER_ON_USE = 0,
|
||||
|
||||
@@ -1181,6 +1268,63 @@ enum Offsets {
|
||||
// members in VanillaHelpers's `ItemStats_C` definition.
|
||||
OFF_ITEMSTATS_MAX_COUNT = 0x5C,
|
||||
|
||||
// Remaining `ItemStats_C` fields used by `C_Item.GetItemData(ByID)`.
|
||||
// Offsets derived from VanillaHelpers's `struct ItemStats_C`
|
||||
// definition (`C:\Git\VanillaHelpers\src\Game.h`), sized to match
|
||||
// the verified anchors above (`+0x60 m_stackable`, `+0x1D0
|
||||
// m_bagFamily`, `+0x1AC m_lockID`, etc.). Block follows the same
|
||||
// ordering as the struct so it's easy to cross-reference.
|
||||
OFF_ITEMSTATS_BUY_PRICE = 0x24, // u32
|
||||
OFF_ITEMSTATS_ALLOWABLE_CLASS = 0x30, // u32 — class bitmask, -1 = all
|
||||
OFF_ITEMSTATS_ALLOWABLE_RACE = 0x34, // u32 — race bitmask, -1 = all
|
||||
OFF_ITEMSTATS_REQUIRED_LEVEL = 0x3C, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_SKILL = 0x40, // u32 — SkillLine.dbc row
|
||||
OFF_ITEMSTATS_REQUIRED_SKILL_RANK = 0x44, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_SPELL = 0x48, // u32 — Spell.dbc row
|
||||
OFF_ITEMSTATS_REQUIRED_HONOR_RANK = 0x4C, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_CITY_RANK = 0x50, // u32
|
||||
OFF_ITEMSTATS_REQUIRED_FACTION = 0x54, // u32 — Faction.dbc row
|
||||
OFF_ITEMSTATS_REQUIRED_FACTION_RANK = 0x58, // u32 — Friendly/Honored/etc.
|
||||
OFF_ITEMSTATS_STAT_TYPE = 0x68, // u32[10] — ItemModType enum per slot
|
||||
OFF_ITEMSTATS_STAT_VALUE = 0x90, // i32[10] — magnitude per slot
|
||||
ITEMSTATS_STAT_SLOT_COUNT = 10,
|
||||
OFF_ITEMSTATS_DAMAGE_MIN = 0xB8, // f32[5]
|
||||
OFF_ITEMSTATS_DAMAGE_MAX = 0xCC, // f32[5]
|
||||
OFF_ITEMSTATS_DAMAGE_TYPE = 0xE0, // u32[5] — schools per damage slot
|
||||
ITEMSTATS_DAMAGE_SLOT_COUNT = 5,
|
||||
OFF_ITEMSTATS_ARMOR = 0xF4, // u32
|
||||
OFF_ITEMSTATS_RES_HOLY = 0xF8, // u32 (six resists contiguous)
|
||||
OFF_ITEMSTATS_RES_FIRE = 0xFC,
|
||||
OFF_ITEMSTATS_RES_NATURE = 0x100,
|
||||
OFF_ITEMSTATS_RES_FROST = 0x104,
|
||||
OFF_ITEMSTATS_RES_SHADOW = 0x108,
|
||||
OFF_ITEMSTATS_RES_ARCANE = 0x10C,
|
||||
OFF_ITEMSTATS_DELAY = 0x110, // u32 — weapon swing time (ms)
|
||||
OFF_ITEMSTATS_AMMO_TYPE = 0x114, // u32 — ammo subclass for ranged
|
||||
OFF_ITEMSTATS_RANGED_MOD_RANGE = 0x118, // f32 — range modifier on bows/guns
|
||||
OFF_ITEMSTATS_BONDING = 0x194, // u32 — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest
|
||||
OFF_ITEMSTATS_DESCRIPTION = 0x198, // char * — flavor text (locale-applied)
|
||||
OFF_ITEMSTATS_PAGE_TEXT = 0x19C, // u32 — PageText.dbc row (readable books)
|
||||
OFF_ITEMSTATS_LANGUAGE_ID = 0x1A0, // u32 — language for book text
|
||||
OFF_ITEMSTATS_PAGE_MATERIAL = 0x1A4, // u32 — book material
|
||||
OFF_ITEMSTATS_START_QUEST = 0x1A8, // u32 — questID started by right-click
|
||||
OFF_ITEMSTATS_MATERIAL = 0x1B0, // i32 — material type
|
||||
OFF_ITEMSTATS_SHEATH = 0x1B4, // u32 — weapon sheath style
|
||||
OFF_ITEMSTATS_RANDOM_PROPERTY = 0x1B8, // i32 — random property template
|
||||
OFF_ITEMSTATS_BLOCK = 0x1BC, // u32 — shield block value
|
||||
OFF_ITEMSTATS_MAX_DURABILITY = 0x1C4, // u32
|
||||
OFF_ITEMSTATS_AREA = 0x1C8, // u32 — bound area (AreaTable.dbc)
|
||||
OFF_ITEMSTATS_MAP = 0x1CC, // u32 — bound map (Map.dbc)
|
||||
|
||||
// `m_flags` bits we surface as decoded booleans. The OPENABLE bit
|
||||
// (0x4) is verified by the tooltip-builder's reader at 0x0052E323;
|
||||
// the others are documented in the standard 1.12 protocol
|
||||
// (CMaNGOS / TrinityCore `ITEM_FLAG_*`) and exposed as-is. Add
|
||||
// verification anchors as consumers materialize.
|
||||
ITEM_FLAG_CONJURED = 0x2,
|
||||
ITEM_FLAG_LOOTABLE = 0x10,
|
||||
ITEM_FLAG_WRAPPER = 0x200,
|
||||
|
||||
// `Spell.dbc` `Name[9]` field at record +0x1E0 (9-locale string
|
||||
// array, indexed by `[VAR_LOCALE_INDEX]`). Used by `GetItemSpell`
|
||||
// to push the spell name half of its return.
|
||||
@@ -1304,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,
|
||||
@@ -1488,6 +1636,35 @@ enum Offsets {
|
||||
VAR_SKILL_LINE_COUNT = 0x00C0D930,
|
||||
OFF_SKILL_LINE_NAME = 0x0C,
|
||||
|
||||
// Player's known-skills table — separate from the DBC. Populated
|
||||
// by the engine on login from server data; entries are pointers
|
||||
// to per-skill records. Each record's `+0x00` is the
|
||||
// `SkillLine.dbc` row (or `0` for header / category rows), and
|
||||
// `+0x10` is the current skill rank (other rank/modifier fields
|
||||
// follow). Source: `Script_GetSkillLineInfo` at `0x004D3610`,
|
||||
// which walks this array indexed by the Lua arg.
|
||||
VAR_PLAYER_SKILL_LIST = 0x00B7318C,
|
||||
VAR_PLAYER_SKILL_COUNT = 0x00B731B8,
|
||||
OFF_PLAYER_SKILL_LINE_ID = 0x00,
|
||||
OFF_PLAYER_SKILL_RANK = 0x10,
|
||||
|
||||
// Per-itemClass proficiency bitmap maintained by the engine.
|
||||
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
|
||||
// entry is a bitmask where bit N is set if the player has
|
||||
// proficiency in subclass N of that item class — i.e.
|
||||
// `table[4] & (1 << 3)` = "can wear Mail" (armor=class 4,
|
||||
// mail=subclass 3).
|
||||
//
|
||||
// Populated by SMSG_SET_PROFICIENCY's handler at
|
||||
// `FUN_005E7B70`, which writes `table[itemClass] = mask`
|
||||
// verbatim from the packet. Same source the engine itself uses
|
||||
// for equip eligibility (the tooltip "Mail" red-text logic
|
||||
// and the cursor-drop-on-paperdoll check both read this). Note:
|
||||
// covers **weapons too** (class 2), making it strictly more
|
||||
// general than walking the per-skill-line table for individual
|
||||
// armor proficiencies.
|
||||
VAR_PROFICIENCY_TABLE = 0x00C4D4A0,
|
||||
|
||||
// Spell.dbc `BaseLevel` — the level a spell becomes available
|
||||
// (trainer offers it, quest reward grants it, etc.). Distinct
|
||||
// from `SpellLevel` at +0x74 which is the effective level used
|
||||
@@ -1662,6 +1839,10 @@ enum Offsets {
|
||||
// Note: the bit's MEANING is "can't toggle" — `canToggleAtWar` is
|
||||
// its inverse, NOT its value.
|
||||
REP_SLOT_FLAG_PEACE_FORCED = 0x10,
|
||||
// INACTIVE flag — set by `Script_SetFactionInactive` (the "Inactive"
|
||||
// collapsible category in FactionFrame). Toggles via
|
||||
// `FUN_FACTION_SET_INACTIVE`; same byte as AT_WAR and PEACE_FORCED.
|
||||
REP_SLOT_FLAG_INACTIVE = 0x20,
|
||||
MAX_REP_SLOTS = 64,
|
||||
|
||||
// Static reaction-band threshold tables, indexed by reaction band
|
||||
@@ -1681,6 +1862,81 @@ enum Offsets {
|
||||
// against `FUN_004D5620(slot)`'s return.
|
||||
OFF_CGPLAYER_INFO_WATCHED_REP_LIST_ID = 0x10C4,
|
||||
|
||||
// `FactionSetAtWar(factionID, newState)` — `__fastcall(ecx = factionID,
|
||||
// edx = char newState)`. The sole worker behind
|
||||
// `Script_FactionToggleAtWar` (`0x004D6950`); the script wrapper just
|
||||
// reads the current at-war flag, negates it, and tail-calls here.
|
||||
//
|
||||
// Validates (only when turning OFF) that current standing ≥ -3000,
|
||||
// resolves factionID → repListID, bails if the player is in a loot
|
||||
// session (UNIT_FIELD_FLAGS bit `0x80000`, error code `0x1A1`),
|
||||
// mutates the rep-slot flag byte (`AT_WAR` gated by `PEACE_FORCED`),
|
||||
// and sends `CMSG_SET_FACTION_ATWAR` (opcode `0x125`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — a vanilla engine omission. 3.3.5
|
||||
// added a `FUN_0071F8F0(player, 0)` call here, whose inner
|
||||
// `FUN_0060BF10(playerGUID, UNIT_FACTION_id)` dispatches the event
|
||||
// for every unit token referencing the player. We polyfill by
|
||||
// hooking this function and firing `UNIT_FACTION("player")` post-
|
||||
// original (see `src/faction/UnitFactionPolyfill.cpp`).
|
||||
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
|
||||
|
||||
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
|
||||
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
|
||||
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
|
||||
// (`0x004D6A00`, clears it) — both script wrappers tail-call here.
|
||||
//
|
||||
// Resolves factionID → repListID via `FUN_004D5600`, mutates the
|
||||
// rep-slot flag byte (sets/clears `INACTIVE` = `0x20`), writes via
|
||||
// `FUN_004D5FC0`, sends `CMSG_SET_FACTION_INACTIVE` (opcode `0x317`),
|
||||
// then calls `FUN_004D5C40` (recompute displayed-list, fire
|
||||
// `UPDATE_FACTION` event `0x174`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — same engine omission as the at-
|
||||
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
|
||||
|
||||
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires when the server force-changes the player's
|
||||
// at-war state on a faction (e.g., a flag the player can't toggle
|
||||
// changes server-side, or faction reset). Reads `(u32 repListID,
|
||||
// u8 flags)`, mutates AT_WAR on the slot accordingly, writes via
|
||||
// `FUN_004D5FC0`, then fires `UPDATE_FACTION` (event `0x174`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — 3.3.5 does (unconditionally
|
||||
// after the byte write). Polyfilled in
|
||||
// `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_SMSG_SET_FACTION_ATWAR = 0x004D56B0,
|
||||
|
||||
// SMSG_INITIALIZE_FACTIONS handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires once at login from the SMSG dispatcher. Reads
|
||||
// a count, then loops `(u8 flags, i32 standing)` pairs into the
|
||||
// 64-entry rep slot array: `FUN_004D5FC0(slot, flags)` +
|
||||
// `FUN_004D6330(slot, standing, 0 /*notify*/)`. Trailing
|
||||
// `FUN_004D5430` rebuilds the displayed-list snapshot, then fires
|
||||
// `UPDATE_FACTION` (event `0x174`).
|
||||
//
|
||||
// **Does NOT fire `UNIT_FACTION`** — modern WoW does. Addons that
|
||||
// observe UNIT_FACTION miss the initial sync entirely in vanilla.
|
||||
// Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_SMSG_INITIALIZE_FACTIONS = 0x004D5640,
|
||||
|
||||
// SMSG_SET_FACTION_STANDINGS handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires once per server-pushed rep change (kills,
|
||||
// quest turn-ins, etc.). Reads count, loops `(flags, standing)`
|
||||
// pairs, calling `FUN_004D6330(slot, standing, 1 /*notify*/)` which
|
||||
// routes through `FUN_REPUTATION_FIRE_NOTIFY` for the
|
||||
// `CHAT_MSG_COMBAT_FACTION_CHANGE` dispatch.
|
||||
//
|
||||
// Also mutates AT_WAR / VISIBLE flag bits as standing crosses
|
||||
// thresholds (e.g., recovering to -2999 clears the force-set AT_WAR
|
||||
// from "below Hated"). **Vanilla doesn't fire `UNIT_FACTION` for
|
||||
// these side-effect flag flips**; modern 3.3.5 does. Polyfilled in
|
||||
// `src/faction/UnitFactionPolyfill.cpp` — snapshots the 64-entry
|
||||
// flag-byte array before/after and fires a single
|
||||
// `UNIT_FACTION("player")` if any bit changed.
|
||||
FUN_SMSG_SET_FACTION_STANDINGS = 0x004D5760,
|
||||
|
||||
// `__fastcall(ecx = factionID, edx = signedDelta)`. This is the
|
||||
// engine's "reputation changed, fire the chat event" notify
|
||||
// helper — it formats the localized chat message and dispatches
|
||||
@@ -2012,6 +2268,67 @@ enum Offsets {
|
||||
// to cast next", so we check this slot too.
|
||||
VAR_QUEUED_CAST_SPELL = 0x00CECAA8,
|
||||
|
||||
// Spell-placement state bitmask. Non-zero when the engine is
|
||||
// waiting on user input for an in-progress cast — the AoE reticle,
|
||||
// unit-target cursor, trade-target cursor, etc. Bits encode what
|
||||
// kind of input is needed; the engine's click handlers read this
|
||||
// to decide whether a click was relevant. Read by
|
||||
// `Script_SpellIsTargeting` via `FUN_006E48A0` (returns
|
||||
// `state != 0`). Bits the engine OR-sets via `FUN_006E5250` when
|
||||
// entering placement (subset relevant to us):
|
||||
// - `0x20` source world location needed
|
||||
// - `0x40` dest world location needed
|
||||
// The `0x60` mask therefore identifies "ground-target placement"
|
||||
// (vs. unit-target, trade-target, etc.). See `Spell::AtCursor`.
|
||||
VAR_SPELL_PLACEMENT_STATE = 0x00CECAC0,
|
||||
SPELL_PLACEMENT_GROUND_MASK = 0x60,
|
||||
|
||||
// `FUN_006E60F0(float *xyz)` — commits the in-flight ground-target
|
||||
// placement at the given world position. Reads three contiguous
|
||||
// floats from `xyz`; checks the placement-state mask
|
||||
// (`VAR_SPELL_PLACEMENT_STATE`) to decide whether this is a
|
||||
// source-coord (`0x20`) or dest-coord (`0x40`) commit; clears the
|
||||
// matching bit and stores the coords into engine globals which the
|
||||
// cast-finalize path then reads. Self-gates when neither bit is
|
||||
// set — safe no-op for unit-target placement. Called by the engine
|
||||
// itself when a player left-clicks ground while the reticle is up.
|
||||
FUN_COMMIT_PLACEMENT_COORDS = 0x006E60F0,
|
||||
|
||||
// `FUN_006E4900()` — cancels in-flight placement mode. Internally
|
||||
// checks `VAR_SPELL_PLACEMENT_STATE != 0` and, if so, fires the
|
||||
// cast-cancel path via `FUN_006E4940(0x1C)`. Safe to call when no
|
||||
// placement is active (no-ops). Wraps the action behind
|
||||
// `Script_SpellStopTargeting`.
|
||||
FUN_STOP_PLACEMENT = 0x006E4900,
|
||||
|
||||
// `*VAR_WORLDFRAME_CLICK_INFO` is a pointer to the engine's
|
||||
// CWorldFrame "click info" struct — the data block where the input
|
||||
// dispatcher stashes "what is the cursor pointed at right now"
|
||||
// after every mouse motion. Allocated at world enter, populated
|
||||
// continuously by the rendering pipeline (viewport bounds and
|
||||
// camera position at fixed offsets) and the OS message pump
|
||||
// (cursor screen pos). NULL when not in world.
|
||||
// - `+0x350` (uint32) click type after last raycast:
|
||||
// `0` = no hit
|
||||
// `1` = world / terrain hit
|
||||
// `2` = unit / object hit
|
||||
// - `+0x360..+0x368` (3 floats) hit world position (when type == 1)
|
||||
VAR_WORLDFRAME_CLICK_INFO = 0x00B4B2BC,
|
||||
OFF_CLICK_INFO_TYPE = 0x350,
|
||||
OFF_CLICK_INFO_COORDS = 0x360,
|
||||
|
||||
// `FUN_00481F00(void *clickInfo)` — refreshes the cursor's "what
|
||||
// am I pointed at?" state on demand. Internally reads cursor
|
||||
// screen pos from input state at `[clickInfo+0xA0]+0x1118/+0x111C`,
|
||||
// calls the screen→world ray builder at `FUN_004813B0` (uses
|
||||
// viewport bounds at `[clickInfo+0x390..+0x39C]` plus camera
|
||||
// position at `[clickInfo+0x65B8]+8/+0xC/+0x10`), then raycasts
|
||||
// via `FUN_0069BFF0` (terrain + WMO intersection). Writes click
|
||||
// type to `[clickInfo+0x350]` and hit coords to
|
||||
// `[clickInfo+0x360..+0x368]`. Safe to call from arbitrary code
|
||||
// as long as the player is in-world.
|
||||
FUN_REFRESH_CURSOR_RAYCAST = 0x00481F00,
|
||||
|
||||
// `Script_CastSpellByName` — engine's Lua wrapper for the
|
||||
// `CastSpellByName(name [, onSelf])` global. `int __fastcall(void *L)`
|
||||
// — standard Lua C function ABI. Reads `name` from stack[1] (string)
|
||||
@@ -2021,6 +2338,16 @@ enum Offsets {
|
||||
// results). Callable directly from C++ — push args on stack, call.
|
||||
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
|
||||
|
||||
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
|
||||
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
|
||||
// the spell name to a spellbook slot. For ground-target spells, the
|
||||
// engine enters placement mode (sets `VAR_SPELL_PLACEMENT_STATE`)
|
||||
// and returns; placement is resolved later by mouse-click or
|
||||
// programmatically via `FUN_COMMIT_PLACEMENT_COORDS`. Passing (0, 0)
|
||||
// for the GUID is the "no implicit target" form — engine falls back
|
||||
// to the player's current selected target.
|
||||
FUN_SPELL_CAST_DISPATCH = 0x004B3300,
|
||||
|
||||
// Macro "primary spell" parser — `__fastcall(MacroEntry *entry)`.
|
||||
// Walks the macro body (stored at `entry + OFF_MACRO_BODY`) line
|
||||
// by line, looking for `/cast <name>`, `/castsequence <name>`,
|
||||
@@ -2089,6 +2416,18 @@ enum Offsets {
|
||||
VAR_SPELL_RECORDS = 0x00C0D788, // SpellRecord *records[spellID]
|
||||
VAR_SPELL_RECORD_COUNT = 0x00C0D78C, // max spellID
|
||||
|
||||
// SpellMechanic.dbc — maps a SpellMechanic ID to its localized
|
||||
// name. Standard 5-DWORD DBC instance at 0x00C0D7BC; records ptr at
|
||||
// +0x08, count at +0x0C (see docs/DBCs.md). Records are 1-based
|
||||
// (records[mechanicID]); the engine only ever uses the table for
|
||||
// existence/bounds checks (FUN_006e9ca0, FUN_00612df0, FUN_006e8eb0
|
||||
// case 0x8d all do `records[id] != 0`), never reading the name — so
|
||||
// the name offset below follows the canonical 2-column
|
||||
// (ID + localized Name) DBC schema rather than an engine-reader anchor.
|
||||
VAR_SPELLMECHANIC_RECORDS = 0x00C0D7C4, // SpellMechanicRecord *records[mechanicID]
|
||||
VAR_SPELLMECHANIC_COUNT = 0x00C0D7C8, // max mechanic ID
|
||||
OFF_SPELLMECHANIC_NAME = 0x04, // char *name[9], locale-indexed
|
||||
|
||||
// Spell.dbc School field — 0-based integer at record +0x04.
|
||||
// Verified empirically against Fireball (133) → School=2 (Fire)
|
||||
// and Frostbolt (116) → School=4 (Frost) on Octo 1.12.1. Vanilla
|
||||
@@ -2114,6 +2453,35 @@ enum Offsets {
|
||||
VAR_SPELL_CAST_TIMES_RECORDS = 0x00C0D878, // SpellCastTimes.dbc
|
||||
VAR_SPELL_CAST_TIMES_COUNT = 0x00C0D87C,
|
||||
|
||||
// SpellRadius.dbc — AOE radius records, indexed by a spell's
|
||||
// EffectRadiusIndex (see OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX). Record
|
||||
// shape: { id@+0, radius@+4 (f32), radiusPerLevel@+8 (f32), ... }.
|
||||
// Verified by decompiling FUN_006e6350 (the engine's effective-radius
|
||||
// helper), which reads `radiusRec[+4]` as the base radius and
|
||||
// `radiusRec[+8] * casterLevel` as the per-level term. Read by
|
||||
// GetSpellRadius (base radius only — no level scaling).
|
||||
VAR_SPELL_RADIUS_RECORDS = 0x00C0D7B0,
|
||||
VAR_SPELL_RADIUS_COUNT = 0x00C0D7B4,
|
||||
OFF_SPELL_RADIUS_VALUE = 0x04, // base radius, f32
|
||||
|
||||
// SpellMod (talent / item) accumulators for the LOCAL PLAYER. Two
|
||||
// parallel tables of int32, indexed [familyFlagBit][op]: flat adds at
|
||||
// VAR_SPELLMOD_FLAT_TABLE, percent adds at VAR_SPELLMOD_PCT_TABLE.
|
||||
// Per-bit stride is 0x74 (29 ops); the op is the dword index (op*4
|
||||
// within a slot). A spell's per-effect value is modified by summing,
|
||||
// over the bits set in its SpellFamilyFlags, flat[bit][op] and
|
||||
// pct[bit][op], then `value = (value + flat) * (100 + pct) * 0.01`.
|
||||
// Gated on the spell's SpellFamilyName matching the player's class
|
||||
// family. Decompiled from FUN_006e6b30 / FUN_006e6bf0 (the engine's
|
||||
// ApplySpellMod), which the radius helper FUN_006e6350 invokes with
|
||||
// op 6. These are single global instances = the local player's mods
|
||||
// (vanilla only tracks spell mods for the local player).
|
||||
VAR_SPELLMOD_FLAT_TABLE = 0x00CEAD60,
|
||||
VAR_SPELLMOD_PCT_TABLE = 0x00CECB30,
|
||||
SPELLMOD_SLOT_STRIDE = 0x74, // bytes per familyFlagBit row
|
||||
SPELLMOD_SLOT_COUNT = 64, // SpellFamilyFlags is 64-bit
|
||||
SPELLMOD_OP_RADIUS = 6, // op index for spell radius
|
||||
|
||||
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
|
||||
// names like "Magic", "Curse", "Disease", "Poison". Used by
|
||||
// `Script_UnitDebuff` to fill its third return value. Standard
|
||||
@@ -2147,6 +2515,7 @@ enum Offsets {
|
||||
// `Script_IsInInstance`).
|
||||
VAR_CURRENT_MAP_ID = 0x00B4E378,
|
||||
|
||||
|
||||
// Local player's current AreaTable.dbc area ID (u32 storage; only
|
||||
// the low 16 bits are used — the value is broadcast over the wire
|
||||
// as a u16 in `SMSG_PARTY_MEMBER_STATS`). Written by the zone-change
|
||||
@@ -2252,6 +2621,55 @@ enum Offsets {
|
||||
LUA_NEXT = 0x6F4450,
|
||||
LUA_ERROR = 0x6F4940,
|
||||
|
||||
// `lua_xmove(from, to, n)` — moves the top `n` TValues from one
|
||||
// thread to another. Decrements `from->top` by `n*16`, copies each
|
||||
// 16-byte TValue, increments `to->top`. Needed by the `coroutine.*`
|
||||
// trampolines to shuttle args/returns between the main state and a
|
||||
// suspended coroutine.
|
||||
LUA_X_MOVE = 0x6F2F80,
|
||||
// `lua_newthread(L)` — does GC check, calls inner `luaE_newthread`
|
||||
// at `0x006F6B10` to allocate a new state, pushes it onto L's stack
|
||||
// with type tag 8 (LUA_TTHREAD). Ghidra reports the return as
|
||||
// `void` (decompiler limitation — the inner call's eax flows
|
||||
// through), so callers should retrieve the new state via
|
||||
// `ToPointer(L, -1)` rather than relying on the return value.
|
||||
LUA_NEW_THREAD = 0x6F3030,
|
||||
// `lua_resume(L_co, nargs)` — runs a coroutine. Zero internal
|
||||
// callers in the engine (coroutine library was stripped from the
|
||||
// standard-library registration tables at build time); linker
|
||||
// kept the symbol so it's callable from C. Goes through
|
||||
// `luaD_rawrunprotected` at `0x006F5DAC` for setjmp/longjmp
|
||||
// safety; inner static `resume` helper lives at `0x006F67B0`.
|
||||
LUA_RESUME = 0x6F6620,
|
||||
// `lua_yield(L, nresults)` — flags the current CallInfo to yield
|
||||
// and returns -1. Sets a `0x10` bit on the CI->state field rather
|
||||
// than writing `L->status` directly in this build. Zero internal
|
||||
// callers (same reason as `lua_resume`). Errors with
|
||||
// "attempt to yield across metamethod/C-call boundary" or
|
||||
// "cannot yield a C function" when called from the wrong context.
|
||||
LUA_YIELD = 0x6F6860,
|
||||
// `lua_topointer(L, idx)` — returns the pointer value of a
|
||||
// pointer-valued TValue: type 5 (table), 6 (function), 8 (thread)
|
||||
// return `tvalue[2]` directly; type 2 (lightuserdata) returns its
|
||||
// value; type 7 (userdata) returns the userdata payload (record +
|
||||
// 0x10). Returns 0 for anything else. We use it as `lua_tothread`
|
||||
// — for a known-thread TValue, returns the embedded `lua_State *`.
|
||||
LUA_TO_POINTER = 0x6F3790,
|
||||
// `luaL_argerror(L, narg, msg)` — formats "bad argument #%d to
|
||||
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
|
||||
// calls) and calls `lua_error`. Doesn't return.
|
||||
LUAL_ARG_ERROR = 0x6F4810,
|
||||
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
|
||||
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
|
||||
// otherwise. Used by `coroutine.create` to reject C functions
|
||||
// (Lua 5.0 only allows Lua-defined functions as coroutine bodies).
|
||||
LUA_IS_C_FUNCTION = 0x6F34A0,
|
||||
// `lua_tothread(L, idx)` — returns the embedded `lua_State *` for
|
||||
// type-8 (LUA_TTHREAD) TValues, NULL for anything else. Used by
|
||||
// the `coroutine` library to extract the coroutine pointer from a
|
||||
// stack slot.
|
||||
LUA_TO_THREAD = 0x6F3770,
|
||||
|
||||
// Global `lua_State *`. The engine keeps one main thread state here; we
|
||||
// read it on demand in helpers that run outside a Lua callback (e.g.
|
||||
// RegisterTableFunction during LoadScriptFunctions).
|
||||
@@ -2433,6 +2851,48 @@ enum Offsets {
|
||||
// registry before the load pass.
|
||||
FUN_ADDON_INIT = 0x0051C740,
|
||||
|
||||
// Generic intrusive-list insert/move helper used by `FUN_TOC_PARSER`
|
||||
// to thread new addon entries into the registry's doubly-linked
|
||||
// list. Signature (`__thiscall`):
|
||||
// `(ListCtrl *this, void *entry, int position, int anchor)`
|
||||
// Where:
|
||||
// - `this` — list-control struct (for the addon registry,
|
||||
// this is `&VAR_ADDON_LIST_CTRL`).
|
||||
// - `entry` — entry to insert. If already linked, the
|
||||
// function removes it from its current
|
||||
// position first.
|
||||
// - `position` — `1` = insert at head, anything else (engine
|
||||
// passes `2`) = insert at tail.
|
||||
// - `anchor` — `0` = anchor against the list sentinel
|
||||
// (default), nonzero = anchor relative to
|
||||
// another entry's link field at that offset.
|
||||
//
|
||||
// Used by `Addons::Embedded` to move our injected `!!!ClassicAPI`
|
||||
// entry from the tail (where `FUN_TOC_PARSER` puts it) to the head
|
||||
// — vanilla's addon load pass at `FUN_0051F600` walks the linked
|
||||
// list in insertion order, so without this fix our addon would
|
||||
// load *last*, breaking dependent addons that expect our globals
|
||||
// to be ready in their main chunk.
|
||||
FUN_INTRUSIVE_LIST_INSERT = 0x00521AD0,
|
||||
|
||||
// Addon-registry intrusive-list control. Layout
|
||||
// (`__thiscall`-friendly, generic across all intrusive lists in the
|
||||
// binary):
|
||||
// +0x00 (= `0x00BE1B64`) — `int` next-pointer offset within each
|
||||
// entry (= `0xC` at runtime; entries
|
||||
// store their next-link at
|
||||
// `entry+0xC..0x10`).
|
||||
// +0x04 (= `0x00BE1B68`) — sentinel `next` field
|
||||
// (points back into &sentinel+1 when
|
||||
// the list is empty).
|
||||
// +0x08 (= `0x00BE1B6C`) — list head (alias for sentinel `prev`
|
||||
// when treated as circular).
|
||||
//
|
||||
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
|
||||
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
|
||||
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
|
||||
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
|
||||
|
||||
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
|
||||
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
|
||||
// itself to populate event entry names. We use it from `Event::Custom`
|
||||
@@ -2567,9 +3027,11 @@ enum Offsets {
|
||||
// We dispatch to these directly so the wrappers don't need to
|
||||
// bounce through `Script_GetAddOnInfo` and unwind 7 returns.
|
||||
//
|
||||
// IS_LOAD_ON_DEMAND `__fastcall(entry) → byte`
|
||||
// Hash-table lookup in the addon registry. Non-zero ⇒
|
||||
// `## LoadOnDemand: 1` in the .toc.
|
||||
// IS_LOADED `__fastcall(entry) → byte`
|
||||
// Hash-table lookup in the addon registry. Returns the byte
|
||||
// at `entry+0x18`, which the addon loader (`FUN_0051F240`)
|
||||
// writes `1` to when the addon has finished loading. Non-zero
|
||||
// ⇒ the addon is loaded.
|
||||
//
|
||||
// CAN_LOAD `__fastcall(entry, char fullCheck=1, int *outStatus,
|
||||
// int *outDepHandle, const char *accountName) → byte`
|
||||
@@ -2590,7 +3052,7 @@ enum Offsets {
|
||||
// Hash-table lookup that returns the security category index
|
||||
// (0=SECURE, 1=INSECURE, 2=BANNED). Defaults to `1` (INSECURE)
|
||||
// when the entry isn't in the override table.
|
||||
FUN_ADDON_IS_LOAD_ON_DEMAND = 0x0051E6F0,
|
||||
FUN_ADDON_IS_LOADED = 0x0051E6F0,
|
||||
FUN_ADDON_CAN_LOAD = 0x0051E780,
|
||||
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
|
||||
|
||||
@@ -2985,6 +3447,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
|
||||
@@ -3085,10 +3715,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
|
||||
@@ -3129,6 +3786,31 @@ enum Offsets {
|
||||
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
|
||||
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
|
||||
SPELL_RECORD_EFFECT_COUNT = 3,
|
||||
|
||||
// Per-effect EffectRadiusIndex[3] → SpellRadius.dbc. Verified by
|
||||
// FUN_006e6350, which reads spellRec[+0x160] (effect 0) and
|
||||
// spellRec[+0x164] (effect 1) as radius indices. 0 = effect has no
|
||||
// radius. Read by GetSpellRadius.
|
||||
OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX = 0x160, // int32[3]
|
||||
|
||||
// SpellFamily classification, used by the SpellMod system (see
|
||||
// VAR_SPELLMOD_*). Verified from FUN_006e6b30: SpellFamilyName at
|
||||
// +0x280 (gates whether the player's class mods apply), 64-bit
|
||||
// SpellFamilyFlags at +0x284 (selects which familyFlagBit rows to
|
||||
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
|
||||
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
|
||||
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
|
||||
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
|
||||
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
|
||||
|
||||
// Spell.dbc Mechanic field — the spell-level SpellMechanic ID (→
|
||||
// SpellMechanic.dbc), 0 = none. Field 5 of the record
|
||||
// (ID, School, Category, castUI, Dispel, Mechanic), which lands at
|
||||
// +0x14 given Attributes (field 6) is verified at +0x18. Cross-
|
||||
// confirmed by FUN_006e9ca0 (the mechanic-immunity check), which
|
||||
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
|
||||
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
|
||||
OFF_SPELL_RECORD_MECHANIC = 0x14,
|
||||
SPELL_AURA_MOD_SHAPESHIFT = 36,
|
||||
SPELL_AURA_MOUNTED = 78,
|
||||
|
||||
|
||||
+57
-1
@@ -40,13 +40,14 @@
|
||||
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
|
||||
// `0x10015A74` while figuring this out). The addon load pass then
|
||||
// reads each `.lua` file through the same hooked read path —
|
||||
// `GlobalStrings.lua`, `Util\Print.lua`, etc. all surface from
|
||||
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
|
||||
// embedded buffers.
|
||||
|
||||
#include "embedded_classicapi.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "bindings/Inject.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -248,6 +249,14 @@ void DecideSource() {
|
||||
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
|
||||
size_t *outSize, size_t extraBytes,
|
||||
int flag1, int flag2) {
|
||||
// FrameXML Bindings.xml gets an inline splice for our TARGETING
|
||||
// additions — see `bindings/Inject.cpp`. Returns true with the
|
||||
// patched buffer ready for the engine's binding parser to consume.
|
||||
if (Bindings::Inject::TryHandle(unused, path, outBuf, outSize,
|
||||
extraBytes, flag1, flag2, FileRead_o)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *suffix = StripAddonPrefix(path);
|
||||
if (suffix == nullptr) {
|
||||
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
|
||||
@@ -301,11 +310,58 @@ using AddonInit_t = void(__fastcall *)(const char *accountName);
|
||||
AddonInit_t AddonInit_o = nullptr;
|
||||
|
||||
using TocParser_t = void(__fastcall *)(const char *name);
|
||||
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
int position, int anchor);
|
||||
|
||||
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
|
||||
// registry's linked list, and the engine's addon-load pass at
|
||||
// `FUN_0051F600` walks that list in *insertion order* (NOT
|
||||
// alphabetical order — the alphabetical sort only applies to the
|
||||
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
|
||||
// injection lands at the very end of the load order, after every
|
||||
// disk-scanned addon. Addons that consume our globals from their
|
||||
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
|
||||
// hard-error during their boot.
|
||||
//
|
||||
// Fix: walk the linked list, find our entry by name, and re-link
|
||||
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
|
||||
// engine's own insert helper handles remove-then-insert atomically,
|
||||
// so it's safe to call on an entry that's already in the list.
|
||||
//
|
||||
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
|
||||
// with the next-pointer field at `entry + 0x10` (computed as
|
||||
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
|
||||
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
|
||||
// Name pointer lives at `entry + 0x14`.
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
void MoveEmbeddedToListHead() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
while ((entry & 1) == 0 && entry != 0) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
||||
reinterpret_cast<void *>(entry),
|
||||
/*position=*/ 1, /*anchor=*/ 0);
|
||||
return;
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + linkOffset + 4);
|
||||
}
|
||||
}
|
||||
|
||||
void __fastcall AddonInit_h(const char *accountName) {
|
||||
AddonInit_o(accountName);
|
||||
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
TocParser(kAddonName);
|
||||
MoveEmbeddedToListHead();
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookFileRead{
|
||||
|
||||
+51
-4
@@ -115,13 +115,24 @@ int PushFieldOrNil(void *L, FieldByName_t accessor) {
|
||||
// `C_AddOns.GetAddOnName(indexOrName)` — the addon's directory name.
|
||||
// For numeric input, returns the engine's canonical casing. For
|
||||
// string input, returns the input verbatim once existence is
|
||||
// confirmed.
|
||||
// confirmed; nil for an unregistered name.
|
||||
//
|
||||
// String-input existence isn't free: `ResolveAddOnName` echoes the
|
||||
// string for the field accessors below (`GetAddOnTitle`, etc.) which
|
||||
// short-circuit cleanly on unknown names via the engine's own NULL-
|
||||
// return paths. `GetAddOnName` doesn't call any engine accessor, so
|
||||
// it'd otherwise echo any string. Probe the registry explicitly.
|
||||
int __fastcall Script_GetAddOnName(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING &&
|
||||
!AddOnExistsByName(reinterpret_cast<const char *>(entry))) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushString(L, reinterpret_cast<const char *>(entry));
|
||||
return 1;
|
||||
}
|
||||
@@ -163,9 +174,13 @@ int __fastcall Script_IsAddOnLoadable(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
auto isLoD = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOAD_ON_DEMAND);
|
||||
if (isLoD(entry) != 0) {
|
||||
// Already-loaded addons are trivially loadable — skip the
|
||||
// recursive dep walk and return (true, nil) directly. `IS_LOADED`
|
||||
// reads the byte at `entry+0x18` (the loader sets that to 1 on
|
||||
// successful load).
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
if (isLoaded(entry) != 0) {
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
Game::Lua::PushNil(L);
|
||||
return 2;
|
||||
@@ -229,6 +244,36 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
|
||||
//
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
|
||||
// matters for LoD addons whose load is split across multiple
|
||||
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
|
||||
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
|
||||
// inside a single call, so the "loading" state is never observable
|
||||
// from Lua. We return the same boolean twice — `loadedOrLoading`
|
||||
// and `loaded` are identical here.
|
||||
//
|
||||
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
|
||||
// string names. `IS_LOADED` accepts an entry pointer as its name arg
|
||||
// because the entry's first 12 bytes are the inline NUL-terminated
|
||||
// directory name (same as the other per-field accessors), so we can
|
||||
// feed it either an entry or a Lua-side string transparently.
|
||||
int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
const bool loaded = isLoaded(entry) != 0;
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_AddOns.DoesAddOnExist(indexOrName)` — true iff the engine's
|
||||
// addon registry has a matching entry. For numeric input we trust
|
||||
// `GetByIndex`'s OOR-NULL; for string input we walk the flat array,
|
||||
@@ -276,6 +321,8 @@ static void RegisterLuaFunctions() {
|
||||
&Script_GetAddOnSecurity);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "DoesAddOnExist",
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
"Enum", "AddOnSecurityStatus",
|
||||
kAddOnSecurityStatusEntries,
|
||||
|
||||
+11
-4
@@ -110,6 +110,13 @@ const uint8_t *FindPlayerBuffEntry(uint32_t spellID) {
|
||||
// what Lua's `GetTime()` returns (engineMs * 0.001), so converting
|
||||
// `expirationMs * 0.001` lands directly on the same timeline addons
|
||||
// compare against on the Lua side. No epoch reconciliation needed.
|
||||
//
|
||||
// **Read as `uint32_t`**: GetTickCount overflows `INT_MAX` at ~24.86
|
||||
// days of system uptime. A signed read flips negative past that
|
||||
// point, which broke a `<= 0` early-bail that was supposed to catch
|
||||
// only the "no timing" (0) sentinel. The engine's own arithmetic
|
||||
// works on the unsigned tick because both sides of `expiration -
|
||||
// currentMs` wrap together.
|
||||
double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
if (entry == nullptr)
|
||||
return 0.0;
|
||||
@@ -117,12 +124,12 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE);
|
||||
if (slotCode < 0)
|
||||
return 0.0;
|
||||
auto *expirationTable = reinterpret_cast<const int *>(
|
||||
auto *expirationTable = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_EXPIRATION_TABLE));
|
||||
const int expirationMs = expirationTable[slotCode];
|
||||
if (expirationMs <= 0)
|
||||
const uint32_t expirationMs = expirationTable[slotCode];
|
||||
if (expirationMs == 0)
|
||||
return 0.0;
|
||||
return static_cast<double>(static_cast<uint32_t>(expirationMs)) * 0.001;
|
||||
return static_cast<double>(expirationMs) * 0.001;
|
||||
}
|
||||
|
||||
// Computes the spell's base duration in seconds via Spell.dbc →
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- Spliced into the engine's Interface\FrameXML\Bindings.xml at
|
||||
load time by src/bindings/Inject.cpp. The `header` attribute on
|
||||
the root tag tells the splicer which engine section to extend —
|
||||
it picks the appropriate insertion point so the bindings appear
|
||||
at the tail of that group in the keybind UI. The engine's own
|
||||
XML parser ignores attributes on the root tag, so this is
|
||||
metadata for the splicer only. -->
|
||||
<Bindings header="TARGETING">
|
||||
<Binding name="FOCUSTARGET">
|
||||
FocusUnit("target");
|
||||
</Binding>
|
||||
<Binding name="TARGETFOCUS">
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
<Binding name="AOELOOT">
|
||||
if not C_Loot.IsScanInProgress() then
|
||||
C_Loot.ScanNearbyLoot()
|
||||
end
|
||||
</Binding>
|
||||
</Bindings>
|
||||
@@ -0,0 +1,297 @@
|
||||
// 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/>.
|
||||
|
||||
// Inject ClassicAPI-owned bindings into a chosen engine section.
|
||||
//
|
||||
// Why a file-read hook rather than an addon-side Bindings.xml: vanilla
|
||||
// stores every binding in a single linearly-indexed table, and the
|
||||
// keybind UI walks that table in index order with `HEADER_*` pseudo-
|
||||
// entries splitting groups. Addon Bindings.xml files get parsed
|
||||
// AFTER FrameXML, so even with `header="TARGETING"` an addon binding
|
||||
// receives a display index higher than every engine binding — it
|
||||
// renders orphaned at the very bottom of the keybind list rather than
|
||||
// under TARGETING.
|
||||
//
|
||||
// To land inside a specific section, the binding's display index has
|
||||
// to fall between that section's `HEADER_*` entry and the next one.
|
||||
// The cleanest way to arrange that is to splice the bindings into the
|
||||
// FrameXML file the engine itself parses, so they take their indices
|
||||
// from the same running counter every native binding does.
|
||||
//
|
||||
// What gets spliced is driven by `src/bindings/Bindings.xml`. The
|
||||
// root `<Bindings header="X">` tag names the engine section to extend
|
||||
// (e.g. `"TARGETING"`); the inner `<Binding>` children are spliced
|
||||
// verbatim. The splicer locates `header="X"` in the engine file
|
||||
// (which appears on the first binding of section X), then the next
|
||||
// `header="..."` after that (start of the following section), and
|
||||
// inserts our content just before that next-section marker — so our
|
||||
// entries land at the tail of section X. If X is the last section in
|
||||
// the file, the splice goes just before `</Bindings>`.
|
||||
//
|
||||
// The engine's binding parser is `FUN_004b6f70`. It calls
|
||||
// `FUN_FILE_READ` (`0x00648620`) to load the file, then walks `<Binding>`
|
||||
// elements assigning each the next display index from a running
|
||||
// counter. By the time our injected bindings are processed, the
|
||||
// engine has already emitted the section's `HEADER_*` and a sequence
|
||||
// of native entries; ours append to that sequence and the next
|
||||
// section's header follows.
|
||||
|
||||
#include "Inject.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include "embedded_bindings.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Bindings::Inject {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kFrameXMLBindingsPath = "Interface\\FrameXML\\Bindings.xml";
|
||||
|
||||
// Parsed view of `src/bindings/Bindings.xml`:
|
||||
// - `header` is the value of `header="..."` on the root `<Bindings>`
|
||||
// tag. Tells the splicer which engine section to extend.
|
||||
// - `innerData / innerSize` point at the bytes between the
|
||||
// `<Bindings ...>` opening and `</Bindings>` closing tags.
|
||||
//
|
||||
// All-zero fields mean "embedded buffer is malformed" — defensive,
|
||||
// shouldn't happen since the source is build-time-validated. Splice
|
||||
// is skipped (engine sees its original Bindings.xml unchanged).
|
||||
struct EmbeddedConfig {
|
||||
char header[64];
|
||||
const char *innerData;
|
||||
size_t innerSize;
|
||||
};
|
||||
|
||||
// Advances `p` past any `<!-- ... -->` comment block starting at the
|
||||
// current position. Returns the new position (past the closing `-->`)
|
||||
// if a comment was skipped, or `p` unchanged if no comment was at
|
||||
// the current position. Caps at `end` if the comment is unterminated.
|
||||
const char *SkipCommentIfAny(const char *p, const char *end) {
|
||||
if (p + 4 > end) return p;
|
||||
if (std::memcmp(p, "<!--", 4) != 0) return p;
|
||||
p += 4;
|
||||
while (p + 3 <= end) {
|
||||
if (std::memcmp(p, "-->", 3) == 0)
|
||||
return p + 3;
|
||||
++p;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
// Linear scan for `marker` in `[start, end)`, skipping any `<!--...-->`
|
||||
// comment blocks (where unrelated occurrences of XML tags might live).
|
||||
const char *FindOutsideComment(const char *start, const char *end, const char *marker) {
|
||||
const size_t markerLen = std::strlen(marker);
|
||||
const char *p = start;
|
||||
while (p + markerLen <= end) {
|
||||
const char *afterComment = SkipCommentIfAny(p, end);
|
||||
if (afterComment != p) {
|
||||
p = afterComment;
|
||||
continue;
|
||||
}
|
||||
if (std::memcmp(p, marker, markerLen) == 0)
|
||||
return p;
|
||||
++p;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Parses `header="X"` out of the byte range `[tagStart, tagEnd)` (the
|
||||
// opening `<Bindings ...>` tag). Copies the value into `out` (size
|
||||
// `outSize`) and returns true on success.
|
||||
bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
|
||||
char *out, size_t outSize) {
|
||||
static constexpr const char kAttr[] = "header=\"";
|
||||
constexpr size_t kAttrLen = sizeof(kAttr) - 1;
|
||||
for (const char *p = tagStart; p + kAttrLen < tagEnd; ++p) {
|
||||
if (std::memcmp(p, kAttr, kAttrLen) != 0) continue;
|
||||
const char *valStart = p + kAttrLen;
|
||||
const char *valEnd = valStart;
|
||||
while (valEnd < tagEnd && *valEnd != '"') ++valEnd;
|
||||
const size_t len = static_cast<size_t>(valEnd - valStart);
|
||||
if (len == 0 || len + 1 > outSize) return false;
|
||||
std::memcpy(out, valStart, len);
|
||||
out[len] = '\0';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
EmbeddedConfig ResolveEmbeddedConfig() {
|
||||
EmbeddedConfig out = {};
|
||||
const char *content = reinterpret_cast<const char *>(kEmbeddedBindingsXml);
|
||||
const char *end = content + kEmbeddedBindingsXmlSize;
|
||||
|
||||
// Locate `<Bindings` opening tag (skipping any comment blocks
|
||||
// that mention the tag literally for documentation purposes).
|
||||
const char *open = FindOutsideComment(content, end, "<Bindings");
|
||||
if (open == nullptr) return out;
|
||||
|
||||
// Find the closing `>` of the opening tag.
|
||||
const char *tagEnd = open;
|
||||
while (tagEnd < end && *tagEnd != '>') ++tagEnd;
|
||||
if (tagEnd == end) return out;
|
||||
|
||||
// Pull out the `header="..."` attribute. Required — this tells
|
||||
// the splicer which engine section to extend.
|
||||
if (!ExtractHeaderAttr(open, tagEnd, out.header, sizeof(out.header)))
|
||||
return out;
|
||||
|
||||
const char *innerStart = tagEnd + 1;
|
||||
const char *close = FindOutsideComment(innerStart, end, "</Bindings>");
|
||||
if (close == nullptr || close < innerStart) {
|
||||
out.header[0] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
out.innerData = innerStart;
|
||||
out.innerSize = static_cast<size_t>(close - innerStart);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Computes the byte offset in the engine's Bindings.xml buffer where
|
||||
// our entries should be spliced — i.e., the tail of the section
|
||||
// identified by `headerName`. Strategy:
|
||||
//
|
||||
// 1. Find `header="<HEADER>"` in the engine's buffer (skipping
|
||||
// comments). This is the attribute the engine itself uses on
|
||||
// the FIRST binding of each section, so this finds where that
|
||||
// section *starts*.
|
||||
// 2. Find the NEXT `header="` after that. That marks the start of
|
||||
// the FOLLOWING section.
|
||||
// 3. Back up from that next-header position to the `<` of the
|
||||
// `<Binding` tag containing it — that's our splice point.
|
||||
// 4. If there's no next header (last section in the file), splice
|
||||
// just before `</Bindings>`.
|
||||
//
|
||||
// Returns `nullptr` if step 1 fails (header missing from engine file
|
||||
// — e.g., a heavily-modified FrameXML). Caller falls back to passing
|
||||
// the original buffer through unchanged.
|
||||
const char *FindSplicePosition(const char *content, size_t size,
|
||||
const char *headerName) {
|
||||
const char *end = content + size;
|
||||
|
||||
char headerNeedle[80];
|
||||
std::snprintf(headerNeedle, sizeof(headerNeedle),
|
||||
"header=\"%s\"", headerName);
|
||||
|
||||
const char *sectionStart = FindOutsideComment(content, end, headerNeedle);
|
||||
if (sectionStart == nullptr) return nullptr;
|
||||
|
||||
// Walk past the matched attribute, then look for the next
|
||||
// `header="` anywhere later in the file.
|
||||
const char *afterCurrent = sectionStart + std::strlen(headerNeedle);
|
||||
const char *nextHeader = FindOutsideComment(afterCurrent, end, "header=\"");
|
||||
|
||||
if (nextHeader != nullptr) {
|
||||
// Back up to the `<` of the `<Binding` tag that owns this
|
||||
// next-header attribute — that `<` is where the next section
|
||||
// visually starts, and we splice immediately before it.
|
||||
const char *p = nextHeader;
|
||||
while (p > content && *p != '<') --p;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Last section in the file — splice just before `</Bindings>`.
|
||||
return FindOutsideComment(content, end, "</Bindings>");
|
||||
}
|
||||
|
||||
char NormalizeChar(char c) {
|
||||
if (c == '/') return '\\';
|
||||
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool PathEqualsCI(const char *a, const char *b) {
|
||||
while (*a != '\0' && *b != '\0') {
|
||||
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
|
||||
++a; ++b;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
|
||||
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
|
||||
} // namespace
|
||||
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
|
||||
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
|
||||
return false;
|
||||
|
||||
// Pull the real file through the original reader with the caller's
|
||||
// requested `extraBytes` honored — the original allocates a Storm
|
||||
// buffer of `fileSize + extraBytes` and zero-fills the tail.
|
||||
void *origBuf = nullptr;
|
||||
size_t origSize = 0;
|
||||
const int readOk = original(unused, path, &origBuf, &origSize,
|
||||
extraBytes, flag1, flag2);
|
||||
if (readOk == 0 || origBuf == nullptr)
|
||||
return false;
|
||||
|
||||
static const EmbeddedConfig config = ResolveEmbeddedConfig();
|
||||
if (config.innerData == nullptr || config.innerSize == 0 ||
|
||||
config.header[0] == '\0') {
|
||||
// Embedded XML is missing the `<Bindings header="...">` root
|
||||
// or the `</Bindings>` close tag. Pass the original through.
|
||||
if (outBuf != nullptr) *outBuf = origBuf;
|
||||
if (outSize != nullptr) *outSize = origSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *content = static_cast<const char *>(origBuf);
|
||||
const char *splice = FindSplicePosition(content, origSize, config.header);
|
||||
if (splice == nullptr) {
|
||||
// Requested section isn't present in this engine file (heavily
|
||||
// modified FrameXML or different build). Pass through unchanged
|
||||
// rather than risking corruption.
|
||||
if (outBuf != nullptr) *outBuf = origBuf;
|
||||
if (outSize != nullptr) *outSize = origSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t splicePos = static_cast<size_t>(splice - content);
|
||||
const size_t newSize = origSize + config.innerSize;
|
||||
|
||||
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC);
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
|
||||
void *newBuf = SMemAlloc(newSize + extraBytes, __FILE__, __LINE__, 0);
|
||||
if (newBuf == nullptr) {
|
||||
// Allocation failed — fall back to the original buffer so the
|
||||
// engine still loads its own bindings.
|
||||
if (outBuf != nullptr) *outBuf = origBuf;
|
||||
if (outSize != nullptr) *outSize = origSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto *dest = static_cast<char *>(newBuf);
|
||||
std::memcpy(dest, content, splicePos);
|
||||
std::memcpy(dest + splicePos, config.innerData, config.innerSize);
|
||||
std::memcpy(dest + splicePos + config.innerSize,
|
||||
content + splicePos, origSize - splicePos);
|
||||
if (extraBytes > 0)
|
||||
std::memset(dest + newSize, 0, extraBytes);
|
||||
|
||||
SMemFree(origBuf, __FILE__, __LINE__, 0);
|
||||
|
||||
if (outBuf != nullptr) *outBuf = newBuf;
|
||||
if (outSize != nullptr) *outSize = newSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Bindings::Inject
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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 <cstddef>
|
||||
|
||||
namespace Bindings::Inject {
|
||||
|
||||
// `FUN_FILE_READ` shape — Storm-style: __stdcall, returns 1 on success
|
||||
// with the buffer allocated via SMemAlloc into `*outBuf` (size in
|
||||
// `*outSize`, plus `extraBytes` of trailing zero padding).
|
||||
using FileReadFn = int(__stdcall *)(int unused, const char *path,
|
||||
void **outBuf, size_t *outSize,
|
||||
size_t extraBytes,
|
||||
int flag1, int flag2);
|
||||
|
||||
// Path-matches `Interface\FrameXML\Bindings.xml`. On match: fetches
|
||||
// the real file via `original`, splices our ClassicAPI bindings into
|
||||
// the TARGETING block, and returns `true` with `*outBuf` / `*outSize`
|
||||
// set to a fresh Storm buffer the caller can SMemFree normally.
|
||||
// Returns `false` for any other path — caller should fall through to
|
||||
// its own logic.
|
||||
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
|
||||
size_t extraBytes, int flag1, int flag2, FileReadFn original);
|
||||
|
||||
} // namespace Bindings::Inject
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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/>.
|
||||
//
|
||||
// Restores Lua 5.0's stripped `coroutine.*` library to the in-game Lua
|
||||
// state. WoW 1.12.1 ships with the C-level coroutine machinery linked
|
||||
// in (`lua_resume`, `lua_yield`, `lua_newthread`, threads as a real
|
||||
// type tag 8) but the `coroutine` global table was never registered.
|
||||
// The five trampolines below mirror Lua 5.0's `lbaselib.c` shapes,
|
||||
// cross-checked against 5.4.8's wired-up library via Ghidra (see the
|
||||
// "Cross-binary reference" technique in CLAUDE.md).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Coroutine {
|
||||
|
||||
// Lua 5.0 result codes returned by `lua_resume`.
|
||||
static constexpr int LUA_OK = 0;
|
||||
static constexpr int LUA_YIELD = 1;
|
||||
|
||||
// Field offsets in `lua_State`, confirmed from `lua_resume`'s error
|
||||
// cleanup path (`L->ci = L->base_ci; L->base = base_ci->base`):
|
||||
// *(L+0x14) = *(L+0x28)
|
||||
// *(L+0x0C) = **(L+0x28)
|
||||
static constexpr uintptr_t OFF_L_CI = 0x14;
|
||||
static constexpr uintptr_t OFF_L_BASE_CI = 0x28;
|
||||
|
||||
// Reads `L->ci` (the current CallInfo). When greater than `L->base_ci`
|
||||
// the coroutine's body function is mid-execution (paused at a yield);
|
||||
// when equal, no Lua function is on this thread's call stack.
|
||||
static void *LuaCI(void *L) {
|
||||
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_CI);
|
||||
}
|
||||
static void *LuaBaseCI(void *L) {
|
||||
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_BASE_CI);
|
||||
}
|
||||
|
||||
// Coroutine status codes returned by `CoStatus` below — fed into the
|
||||
// `kStatusNames` table for the `status` API. The dense 0..3 encoding
|
||||
// also lets us label "unsuspended" states for resume's error path.
|
||||
static constexpr int CO_RUNNING = 0;
|
||||
static constexpr int CO_SUSPENDED = 1;
|
||||
static constexpr int CO_NORMAL = 2;
|
||||
static constexpr int CO_DEAD = 3;
|
||||
|
||||
static const char *const kStatusNames[] = {
|
||||
"running",
|
||||
"suspended",
|
||||
"normal",
|
||||
"dead",
|
||||
};
|
||||
|
||||
// Inner status classifier. Equivalent to Lua 5.0's `auxstatus` from
|
||||
// lbaselib.c, but we can't rely on `L->status` here: this build's
|
||||
// `lua_yield` doesn't write status (it sets a CI state flag bit
|
||||
// `0x10` instead), so the canonical "ttype(status) == LUA_YIELD"
|
||||
// check fails. We use the `ci > base_ci` signal instead — true iff
|
||||
// a Lua function is currently paused on this thread's call stack.
|
||||
//
|
||||
// State map:
|
||||
// co->ci > co->base_ci → SUSPENDED (yielded; body paused)
|
||||
// co->ci == co->base_ci, top==0 → DEAD (returned, all consumed)
|
||||
// co->ci == co->base_ci, top>0 → SUSPENDED (initial: fn pushed,
|
||||
// never resumed)
|
||||
// After AuxResume's XMove cycle, both "returned" and "yielded" leave
|
||||
// co's stack empty; the ci comparison is what distinguishes them.
|
||||
// "Normal" (coroutine that resumed a deeper coroutine — appears as
|
||||
// the parent in the call chain) is rare and would require walking
|
||||
// the global thread list; we collapse it into "suspended".
|
||||
static int CoStatus(void *L, void *co) {
|
||||
if (co == L)
|
||||
return CO_RUNNING;
|
||||
if (LuaCI(co) > LuaBaseCI(co))
|
||||
return CO_SUSPENDED;
|
||||
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUSPENDED;
|
||||
}
|
||||
|
||||
// Common back-end for resume / wrap. Returns nres ≥ 0 on success
|
||||
// (with values already xmove'd onto L), or -1 on error (with the
|
||||
// error message left on L's stack).
|
||||
//
|
||||
// WoW's modified Lua doesn't follow the standard `lua_resume` status
|
||||
// contract: it returns `0` for normal completion and `1` for both
|
||||
// "yielded" AND "errored mid-execution". We disambiguate the two
|
||||
// `s==1` cases by checking the call-stack depth — a yielded
|
||||
// coroutine still has its body paused (`ci > base_ci`), while an
|
||||
// errored coroutine has been unwound back to `base_ci`. Errors hit
|
||||
// from the "dead/already-resumed" early-out (e.g. the inner
|
||||
// `cannot resume dead coroutine` path) also land in `s != 0` with
|
||||
// `ci == base_ci`, so the same branch covers them.
|
||||
static int AuxResume(void *L, void *co, int nargs) {
|
||||
const int st = CoStatus(L, co);
|
||||
if (st != CO_SUSPENDED) {
|
||||
Game::Lua::PushString(L, st == CO_DEAD
|
||||
? "cannot resume dead coroutine"
|
||||
: "cannot resume non-suspended coroutine");
|
||||
return -1;
|
||||
}
|
||||
Game::Lua::XMove(L, co, nargs);
|
||||
const int s = Game::Lua::Resume(co, nargs);
|
||||
const bool yielded = LuaCI(co) > LuaBaseCI(co);
|
||||
if (s == LUA_OK || yielded) {
|
||||
const int nres = Game::Lua::GetTop(co);
|
||||
Game::Lua::XMove(co, L, nres);
|
||||
return nres;
|
||||
}
|
||||
// Error path: engine left a single error message on co's stack.
|
||||
Game::Lua::XMove(co, L, 1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Create(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_FUNCTION || Game::Lua::IsCFunction(L, 1)) {
|
||||
Game::Lua::ArgError(L, 1, "Lua function expected");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::NewThread(L); // [arg1, NL]
|
||||
void *NL = Game::Lua::ToThread(L, -1);
|
||||
if (NL == nullptr) {
|
||||
Game::Lua::Error(L, "coroutine.create: failed to allocate thread");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
|
||||
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Yield(void *L) {
|
||||
return Game::Lua::Yield(L, Game::Lua::GetTop(L));
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Resume(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
|
||||
Game::Lua::ArgError(L, 1, "coroutine expected");
|
||||
return 0; // unreachable
|
||||
}
|
||||
void *co = Game::Lua::ToThread(L, 1);
|
||||
const int nargs = Game::Lua::GetTop(L) - 1;
|
||||
const int r = AuxResume(L, co, nargs);
|
||||
if (r < 0) {
|
||||
// Error message is on top of L's stack. Push `false` and
|
||||
// shuffle it below the message: [..., msg] -> [..., false, msg].
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::Insert(L, -2);
|
||||
return 2;
|
||||
}
|
||||
// Success: `r` values on top of L's stack. Push `true` and slide
|
||||
// it into position so returns are [..., true, v1, v2, ..., vr].
|
||||
Game::Lua::PushBool(L, true);
|
||||
Game::Lua::Insert(L, -(r + 1));
|
||||
return r + 1;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Status(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
|
||||
Game::Lua::ArgError(L, 1, "coroutine expected");
|
||||
return 0; // unreachable
|
||||
}
|
||||
void *co = Game::Lua::ToThread(L, 1);
|
||||
Game::Lua::PushString(L, kStatusNames[CoStatus(L, co)]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `coroutine.wrap`'s returned closure. The captured coroutine lives
|
||||
// in upvalue 1; each invocation forwards args via resume and either
|
||||
// returns the yielded/returned values directly (no { ok, ... }
|
||||
// wrapping like resume does) or re-raises on error.
|
||||
static int __fastcall WrapAux(void *L) {
|
||||
void *co = Game::Lua::ToThread(L, Game::Lua::UpvalueIndex(1));
|
||||
if (co == nullptr) {
|
||||
Game::Lua::Error(L, "coroutine.wrap: missing thread upvalue");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const int nargs = Game::Lua::GetTop(L);
|
||||
const int r = AuxResume(L, co, nargs);
|
||||
if (r < 0) {
|
||||
// The error message is at -1. `lua_error`'s 1.12 wrapper takes
|
||||
// a printf-format string + va args; we pass `%s` so any `%`
|
||||
// characters in the message are treated as literal.
|
||||
const char *msg = Game::Lua::ToString(L, -1);
|
||||
Game::Lua::Error(L, "%s", msg != nullptr ? msg : "coroutine error");
|
||||
return 0; // unreachable
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static int __fastcall Script_Coroutine_Wrap(void *L) {
|
||||
// Reuse cocreate to build the thread (validates arg, allocates,
|
||||
// moves fn into the new state). Leaves the thread on top of L.
|
||||
Script_Coroutine_Create(L);
|
||||
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures thread as upvalue 1
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
|
||||
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Coroutine
|
||||
+151
-16
@@ -35,6 +35,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "Set.h"
|
||||
#include "event/Custom.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Swap.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -330,6 +331,19 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
|
||||
// would race with). Reads the per-CGItem instance flag at
|
||||
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
|
||||
// and the engine's own pickup/unlock paths manipulate.
|
||||
//
|
||||
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
|
||||
// for items the player has deleted/mailed/traded away, the engine's
|
||||
// CGItem cache often retains a stale entry that `ResolveByGUID`
|
||||
// still hands back, but the inventory walk no longer finds the GUID
|
||||
// anywhere. Reading the lock flag off the stale CGItem reflects
|
||||
// whatever bits were last set on it (frequently the "locked"
|
||||
// in-transaction state from when it was being deleted/swapped),
|
||||
// which would falsely report the set as "contains locked items"
|
||||
// and prevent `UseEquipmentSet` from running through addon
|
||||
// wrappers that gate on this check. The inventory presence check
|
||||
// matches modern semantics: a missing item is *missing*, not
|
||||
// *locked*.
|
||||
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
|
||||
const uint32_t setID = ArgSetID(L, 1);
|
||||
const Set *s = Data::FindByID(setID);
|
||||
@@ -340,6 +354,8 @@ int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
|
||||
const uint64_t g = s->items[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED)
|
||||
continue;
|
||||
if (Locations::FindGUID(g) == 0)
|
||||
continue; // not in inventory — treat as missing, not locked
|
||||
const uint8_t *item = Locations::ResolveItemByGUID(g);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
@@ -401,39 +417,158 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
uint64_t guids[SLOT_COUNT];
|
||||
std::memcpy(guids, s->items, sizeof(guids));
|
||||
|
||||
// Snapshot empty bag slots up front. The CGItem state doesn't
|
||||
// update between packet sends (server has to acknowledge each
|
||||
// swap), so iterating the bag walk fresh per unequip would
|
||||
// repeatedly pick the same "first empty" slot — second packet
|
||||
// collides with what packet 1 just put there, and the server
|
||||
// tries to swap that item back onto the wrong paperdoll slot
|
||||
// → "This item cannot be equipped".
|
||||
struct FreeSlot {
|
||||
int bag;
|
||||
int slot;
|
||||
};
|
||||
FreeSlot freeSlots[64];
|
||||
int freeSlotCount = 0;
|
||||
for (int bag = 0; bag <= 4 && freeSlotCount < 64; ++bag) {
|
||||
const int slots = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= slots && freeSlotCount < 64; ++slot) {
|
||||
if (Item::Location::ResolveBag(L, bag, slot) == nullptr)
|
||||
freeSlots[freeSlotCount++] = {bag, slot};
|
||||
}
|
||||
}
|
||||
int nextFreeSlot = 0;
|
||||
|
||||
// Pre-snapshot each set item's location against the ORIGINAL
|
||||
// state, before any swap packets fly. This mirrors how 4.3.4's
|
||||
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
|
||||
// resolve all 19 source locations up front, then execute. The
|
||||
// engine's CGItem / InvMgr state lags behind our packet sends
|
||||
// (waits for server ACK), so any `FindGUID` mid-execution would
|
||||
// return stale answers and we'd re-resolve items into the wrong
|
||||
// place.
|
||||
int sourceLoc[SLOT_COUNT];
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const uint64_t g = guids[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED) {
|
||||
sourceLoc[i] = 0;
|
||||
continue;
|
||||
}
|
||||
sourceLoc[i] = Locations::FindGUID(g);
|
||||
}
|
||||
|
||||
// Virtual paperdoll occupant model — seeded from the engine's
|
||||
// initial paperdoll GUID array, then updated in-place after each
|
||||
// swap so phase 1 can chain rotations and phase 3 can spot
|
||||
// already-empty slots.
|
||||
uint64_t virtualPaperdoll[SLOT_COUNT];
|
||||
Locations::SnapshotPaperdoll(virtualPaperdoll);
|
||||
|
||||
// Locates the current virtual slot of a GUID on the paperdoll,
|
||||
// or 0 if it's not currently there. Used during phase 1 so
|
||||
// chained paperdoll-to-paperdoll moves operate on the *current*
|
||||
// virtual position, not the stale pre-snapshot.
|
||||
auto currentPaperdollSlot = [&](uint64_t g) -> int {
|
||||
if (g == 0)
|
||||
return 0;
|
||||
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
|
||||
if (virtualPaperdoll[slot - 1] == g)
|
||||
return slot;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
|
||||
// Move items already on the paperdoll into their target slots.
|
||||
// Source and destination are both paperdoll, so atomic swaps
|
||||
// never touch bag state — phase 2's bag lookups stay accurate.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const int targetSlot = i + 1;
|
||||
const uint64_t g = guids[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED)
|
||||
continue;
|
||||
|
||||
const int loc = Locations::FindGUID(g);
|
||||
const int loc = sourceLoc[i];
|
||||
if (loc == 0)
|
||||
continue; // missing — nothing to do
|
||||
|
||||
// Already in its target slot? Skip.
|
||||
if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
|
||||
(loc & LOC_SLOT_MASK) == targetSlot)
|
||||
continue;
|
||||
|
||||
// In bank (main or bank-bag) — server rejects equip-from-bank.
|
||||
if (loc & LOC_BANK)
|
||||
continue;
|
||||
continue; // server rejects equip-from-bank
|
||||
if (loc & LOC_BAGS)
|
||||
continue; // phase 2
|
||||
|
||||
const int srcSlot = currentPaperdollSlot(g);
|
||||
if (srcSlot == 0 || srcSlot == targetSlot)
|
||||
continue; // missing or already there
|
||||
|
||||
const uint8_t *item = Locations::ResolveItemByGUID(g);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
if (loc & LOC_BAGS) {
|
||||
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
|
||||
const int srcSlot = loc & LOC_SLOT_MASK;
|
||||
Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot);
|
||||
} else {
|
||||
const int srcSlot = loc & LOC_SLOT_MASK;
|
||||
Item::Swap::FromPaperdoll(item, srcSlot, targetSlot);
|
||||
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
|
||||
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
|
||||
virtualPaperdoll[targetSlot - 1] = g;
|
||||
virtualPaperdoll[srcSlot - 1] = displaced;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: bag → paperdoll ───────────────────────────────────
|
||||
// Move items from bag slots onto the paperdoll. Each atomic swap
|
||||
// displaces the current paperdoll occupant into the bag slot the
|
||||
// incoming item vacated. Anything we still wanted on the paperdoll
|
||||
// was already placed by phase 1, so phase 2 displacements can't
|
||||
// strand a future set item in a bag.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const int targetSlot = i + 1;
|
||||
const uint64_t g = guids[i];
|
||||
if (g == GUID_EMPTY || g == GUID_IGNORED)
|
||||
continue;
|
||||
if (virtualPaperdoll[targetSlot - 1] == g)
|
||||
continue; // already at target (probably by phase 1)
|
||||
|
||||
const int loc = sourceLoc[i];
|
||||
if (loc == 0)
|
||||
continue;
|
||||
if (loc & LOC_BANK)
|
||||
continue;
|
||||
if ((loc & LOC_BAGS) == 0)
|
||||
continue; // not from a bag — phase 1 handled, or item missing
|
||||
|
||||
const uint8_t *item = Locations::ResolveItemByGUID(g);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
|
||||
const int srcSlot = loc & LOC_SLOT_MASK;
|
||||
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
|
||||
virtualPaperdoll[targetSlot - 1] = g;
|
||||
// The displaced item lands in (srcBag, srcSlot). We
|
||||
// don't track bag-side virtual state — by phase 3 we
|
||||
// only care about the paperdoll occupant.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: unequip slots the set wants empty ─────────────────
|
||||
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
|
||||
// (if any) to a pre-snapshotted free bag slot. Runs last so any
|
||||
// displaced items from phases 1/2 that landed in the slot get
|
||||
// unequipped here too.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i) {
|
||||
const int targetSlot = i + 1;
|
||||
if (guids[i] != GUID_EMPTY)
|
||||
continue;
|
||||
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
|
||||
if (occupant == 0)
|
||||
continue;
|
||||
if (nextFreeSlot >= freeSlotCount)
|
||||
continue;
|
||||
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
|
||||
if (equipped == nullptr)
|
||||
continue;
|
||||
const FreeSlot &fs = freeSlots[nextFreeSlot++];
|
||||
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
|
||||
virtualPaperdoll[targetSlot - 1] = 0;
|
||||
}
|
||||
|
||||
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
|
||||
if (evt >= 0)
|
||||
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
|
||||
|
||||
@@ -140,8 +140,6 @@ uint32_t Create(const char *name, const char *icon) {
|
||||
EnsureLoaded();
|
||||
if (g_path.empty() || name == nullptr || name[0] == '\0')
|
||||
return 0;
|
||||
if (g_sets.size() >= MAX_SETS)
|
||||
return 0;
|
||||
if (FindByName(name) != nullptr)
|
||||
return 0;
|
||||
Set s;
|
||||
|
||||
@@ -177,4 +177,15 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
|
||||
return Item::Location::ResolveByGUID(guid);
|
||||
}
|
||||
|
||||
void SnapshotPaperdoll(uint64_t *out) {
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
out[i] = 0;
|
||||
auto *invMgr = ResolvePlayerInvMgr();
|
||||
auto *guids = InvMgrGuidArray(invMgr);
|
||||
if (guids == nullptr)
|
||||
return;
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
out[i] = guids[i];
|
||||
}
|
||||
|
||||
} // namespace EquipmentSet::Locations
|
||||
|
||||
@@ -42,4 +42,16 @@ int FindGUID(uint64_t targetGuid);
|
||||
// isn't known to the client. Side-effect free.
|
||||
const uint8_t *ResolveItemByGUID(uint64_t guid);
|
||||
|
||||
// Copies the current paperdoll GUIDs into `out[0..SLOT_COUNT-1]`,
|
||||
// where `out[i]` is the GUID at 1-based equipment slot `i+1`.
|
||||
// Empty / unequipped slots write 0. Reads from the player invMgr's
|
||||
// flat GUID array directly — no Lua stack, no engine state changes.
|
||||
//
|
||||
// Used by `UseEquipmentSet` to seed a "virtual paperdoll" model that
|
||||
// stays current across in-flight swaps. The engine's CGItem state
|
||||
// lags behind packet sends (server has to ACK each swap), so any
|
||||
// subsequent `FindGUID` after the first packet would return stale
|
||||
// pre-swap locations. Tracking moves locally bypasses that lag.
|
||||
void SnapshotPaperdoll(uint64_t *out);
|
||||
|
||||
} // namespace EquipmentSet::Locations
|
||||
|
||||
@@ -56,11 +56,6 @@ inline const char *DefaultIcon() { return "INV_Misc_QuestionMark"; }
|
||||
// (1.15.x is also 32). 4.3.4 used the same buffer size.
|
||||
constexpr int MAX_NAME_LEN = 31;
|
||||
|
||||
// Hard cap matching 4.3.4 behavior. Modern Classic Era (1.15) raised
|
||||
// this to 30; we follow the 4.3.4 number so legacy addons that show
|
||||
// "X / 10" don't surprise users.
|
||||
constexpr int MAX_SETS = 10;
|
||||
|
||||
// `GetItemLocations` encoding — matches Blizzard FrameXML's
|
||||
// `EquipmentManager_UnpackLocation` (constants are in
|
||||
// `Constants.lua`: `ITEM_INVENTORY_LOCATION_*` and
|
||||
|
||||
@@ -105,6 +105,23 @@ int Lookup(const char *name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int LookupByName(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char *entryName = *reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void RetryClaims() {
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (g_reserved[i].slot < 0)
|
||||
|
||||
@@ -47,8 +47,22 @@ struct AutoReserve {
|
||||
// claimed (e.g. before the first Lua-side `RegisterEvent` has
|
||||
// triggered `RetryClaims`). Slot indices may change across `/reload`,
|
||||
// so call this at fire time rather than caching the value.
|
||||
//
|
||||
// Only consults our reservation cache — for engine-defined events
|
||||
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
|
||||
int Lookup(const char *name);
|
||||
|
||||
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
|
||||
// looking for an entry whose name strcmps equal to `name`. Returns
|
||||
// the slot index, or -1 if no entry matches.
|
||||
//
|
||||
// Works for both engine-defined events AND our `AutoReserve`-claimed
|
||||
// custom events (both populate the same table). Slightly slower than
|
||||
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
|
||||
// names we've reserved. Use this for engine-defined events we want to
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// Dispatches a custom event via the engine's printf-style event
|
||||
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
|
||||
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
|
||||
@@ -74,6 +88,22 @@ inline void Fire(int eventID, const char *format, Args... args) {
|
||||
fn(eventID, format, args...);
|
||||
}
|
||||
|
||||
// Convenience for the modern WoW `(id: number, success: bool)` event
|
||||
// shape (ITEM_DATA_LOAD_RESULT, GET_ITEM_INFO_RECEIVED,
|
||||
// QUEST_DATA_LOAD_RESULT, …). The engine's printf-style dispatcher has
|
||||
// no `%b`, so a naive `%d%d` with `0` for failure surfaces `arg2 = 0` —
|
||||
// truthy in Lua, breaking the canonical `if success then` branch. We
|
||||
// push `1` for success and `nil` for failure instead, leaning on the
|
||||
// engine's `lua_pushstring(L, NULL) → lua_pushnil` tail-jump so the
|
||||
// dispatcher itself emits the nil without any pre-staging. Lua handlers
|
||||
// can then use the natural idiom: nil is falsy, `1` is truthy.
|
||||
inline void FireIdSuccess(int eventID, int id, bool success) {
|
||||
if (success)
|
||||
Fire(eventID, "%d%d", id, 1);
|
||||
else
|
||||
Fire(eventID, "%d%s", id, static_cast<const char *>(nullptr));
|
||||
}
|
||||
|
||||
// Internal: try to claim a slot for every reservation that's still
|
||||
// unclaimed (`slot < 0`). Called from the `Frame::RegisterEvent` hook
|
||||
// in DllMain — every time Lua calls `frame:RegisterEvent(...)`, we
|
||||
|
||||
+2
-28
@@ -12,36 +12,10 @@
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include "event/Custom.h"
|
||||
|
||||
namespace Event::Util {
|
||||
|
||||
// Walks the same `EventEntry` array that `Frame::RegisterEvent` strcmps
|
||||
// against (base at `[VAR_EVENT_TABLE_BASE_PTR]`, count at
|
||||
// `[VAR_EVENT_TABLE_COUNT]`, stride 0x10, name field at +0x00). This is
|
||||
// the source of truth for "events the engine will accept registration
|
||||
// for", so it picks up anything we've injected via `Event::Custom::Register`
|
||||
// in addition to all built-in events.
|
||||
static bool IsKnownEventName(const char *needle) {
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (name == nullptr)
|
||||
continue;
|
||||
if (std::strcmp(name, needle) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int __fastcall Script_IsEventValid(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_STRING) {
|
||||
Game::Lua::Error(L, "Usage: C_EventUtils.IsEventValid(eventName)");
|
||||
@@ -52,7 +26,7 @@ static int __fastcall Script_IsEventValid(void *L) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBoolean(L, IsKnownEventName(needle));
|
||||
Game::Lua::PushBoolean(L, Event::Custom::LookupByName(needle) >= 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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/>.
|
||||
|
||||
// Restores `UNIT_FACTION("player")` event semantics that vanilla 1.12.1
|
||||
// silently dropped from four engine paths. In 3.3.5 each of these calls
|
||||
// `FUN_0071F8F0(player, 0) → FUN_0060BF10(playerGUID, UNIT_FACTION_id)`
|
||||
// which broadcasts the event for every unit token referencing the
|
||||
// local player; vanilla just returns. We hook each path and fire
|
||||
// `UNIT_FACTION("player")` post-original via the engine's printf-style
|
||||
// dispatcher.
|
||||
//
|
||||
// Why one file and not four: every hook does the same shape work
|
||||
// (snapshot rep-slot state, run original, compare, fire). The shared
|
||||
// `LookupEngineEvent` + `FireUnitFactionPlayer` helpers are the value;
|
||||
// splitting four 30-line hooks into four files would just spread that
|
||||
// helper around.
|
||||
//
|
||||
// Why hook the inner setters (vs. `Script_*` wrappers): same coverage,
|
||||
// and any future C++ caller of the setter inherits the polyfill.
|
||||
//
|
||||
// Coverage map:
|
||||
//
|
||||
// 1. `FUN_FACTION_SET_AT_WAR` — `FactionToggleAtWar` toggles
|
||||
// AT_WAR. Hook fires UNIT_FACTION if the flag byte changed.
|
||||
//
|
||||
// 2. `FUN_FACTION_SET_INACTIVE` — `SetFactionInactive` /
|
||||
// `SetFactionActive` toggle INACTIVE. Same change-detection
|
||||
// shape as at-war.
|
||||
//
|
||||
// 3. `FUN_SMSG_SET_FACTION_STANDINGS` — server rep update.
|
||||
// The handler mutates AT_WAR / VISIBLE bits as standing crosses
|
||||
// thresholds (recovery from below-Hated clears the force-set
|
||||
// AT_WAR). Snapshots the 64-entry flag-byte array before/after
|
||||
// and fires once if any byte changed.
|
||||
//
|
||||
// 4. `FUN_SMSG_INITIALIZE_FACTIONS` — server initial sync at login.
|
||||
// Fires unconditionally after the engine populates the rep table.
|
||||
// Lets addons that observe UNIT_FACTION (rather than
|
||||
// PLAYER_LOGIN / VARIABLES_LOADED) see the initial state.
|
||||
//
|
||||
// 5. `FUN_SMSG_SET_FACTION_ATWAR` — server force-change of AT_WAR
|
||||
// (e.g., faction reset, server-side state change). Fires
|
||||
// unconditionally after the engine writes the slot byte, matching
|
||||
// 3.3.5's behavior — server-pushed packets are infrequent and
|
||||
// always indicate a state change worth signaling.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Faction::UnitFactionPolyfill {
|
||||
|
||||
namespace {
|
||||
|
||||
// Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// `UNIT_FACTION`'s event-table slot. The engine's `RebuildEventTable`
|
||||
// preserves slot order across reloads and other DLLs claim only NULL
|
||||
// gaps, so this is stable once resolved. Lazy-init on first fire.
|
||||
int s_unitFactionSlot = -1;
|
||||
|
||||
void FireUnitFactionPlayer() {
|
||||
if (s_unitFactionSlot < 0)
|
||||
s_unitFactionSlot = Event::Custom::LookupByName("UNIT_FACTION");
|
||||
if (s_unitFactionSlot < 0)
|
||||
return;
|
||||
using FireEventFn_t = void(__cdecl *)(int eventID, const char *format, ...);
|
||||
auto fire = reinterpret_cast<FireEventFn_t>(Offsets::FUN_FIRE_EVENT);
|
||||
fire(s_unitFactionSlot, "%s", "player");
|
||||
}
|
||||
|
||||
uint8_t ReadRepSlotFlags(int repListID) {
|
||||
return *reinterpret_cast<uint8_t *>(
|
||||
Offsets::VAR_PLAYER_REP_SLOTS +
|
||||
repListID * Offsets::REP_SLOT_STRIDE +
|
||||
Offsets::OFF_REP_SLOT_FLAGS);
|
||||
}
|
||||
|
||||
// Resolves `factionID → repListID` (0..63 rep-slot index in
|
||||
// `VAR_PLAYER_REP_SLOTS`) by reading the `+0x04` field on the
|
||||
// `Faction.dbc` record directly. Same value the engine's helper at
|
||||
// `0x004D5600` returns — it just dereferences the same DBC field after
|
||||
// its own bounds check. Returns -1 for factionIDs that don't resolve
|
||||
// to a record (out of range / empty slot) or are header categories
|
||||
// (record stores -1 in the field).
|
||||
int FactionIDToRepListID(int factionID) {
|
||||
if (factionID <= 0)
|
||||
return -1;
|
||||
const auto *record = DBC::Record(Offsets::VAR_FACTION_DBC_RECORDS,
|
||||
Offsets::VAR_FACTION_DBC_COUNT,
|
||||
static_cast<uint32_t>(factionID));
|
||||
if (record == nullptr)
|
||||
return -1;
|
||||
return *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
|
||||
}
|
||||
|
||||
// Hook 1 — at-war setter ──────────────────────────────────────────────
|
||||
|
||||
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
|
||||
SetAtWar_t s_setAtWar_o = nullptr;
|
||||
|
||||
void __fastcall SetAtWar_h(int factionID, int newState) {
|
||||
const int repListID = FactionIDToRepListID(factionID);
|
||||
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
|
||||
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
|
||||
|
||||
s_setAtWar_o(factionID, newState);
|
||||
|
||||
if (!slotValid)
|
||||
return;
|
||||
if (ReadRepSlotFlags(repListID) == beforeFlags)
|
||||
return; // engine bailed (looting / PEACE_FORCED no-op / standing block)
|
||||
|
||||
FireUnitFactionPlayer();
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookAtWar{
|
||||
Offsets::FUN_FACTION_SET_AT_WAR,
|
||||
reinterpret_cast<void *>(&SetAtWar_h),
|
||||
reinterpret_cast<void **>(&s_setAtWar_o)};
|
||||
|
||||
// Hook 2 — inactive setter ────────────────────────────────────────────
|
||||
|
||||
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
|
||||
SetInactive_t s_setInactive_o = nullptr;
|
||||
|
||||
void __fastcall SetInactive_h(int factionID, int newState) {
|
||||
const int repListID = FactionIDToRepListID(factionID);
|
||||
const bool slotValid = repListID >= 0 && repListID < Offsets::MAX_REP_SLOTS;
|
||||
const uint8_t beforeFlags = slotValid ? ReadRepSlotFlags(repListID) : 0;
|
||||
|
||||
s_setInactive_o(factionID, newState);
|
||||
|
||||
if (!slotValid)
|
||||
return;
|
||||
if (ReadRepSlotFlags(repListID) == beforeFlags)
|
||||
return; // no-op (already in requested state)
|
||||
|
||||
FireUnitFactionPlayer();
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookInactive{
|
||||
Offsets::FUN_FACTION_SET_INACTIVE,
|
||||
reinterpret_cast<void *>(&SetInactive_h),
|
||||
reinterpret_cast<void **>(&s_setInactive_o)};
|
||||
|
||||
// Hook 3 — SMSG_SET_FACTION_STANDINGS ─────────────────────────────────
|
||||
|
||||
// Snapshot all 64 rep-slot flag bytes into a packed buffer. The engine's
|
||||
// per-rep-change handler mutates AT_WAR / VISIBLE bits as standing
|
||||
// crosses thresholds, so we need a delta-detect across the whole table
|
||||
// (single rep change may flip flags on an unrelated slot — and the SMSG
|
||||
// can carry multiple rep updates in one packet).
|
||||
struct RepFlagsSnapshot {
|
||||
uint8_t flags[Offsets::MAX_REP_SLOTS];
|
||||
};
|
||||
|
||||
void TakeSnapshot(RepFlagsSnapshot *out) {
|
||||
for (int i = 0; i < Offsets::MAX_REP_SLOTS; ++i)
|
||||
out->flags[i] = ReadRepSlotFlags(i);
|
||||
}
|
||||
|
||||
bool SnapshotsDiffer(const RepFlagsSnapshot *a, const RepFlagsSnapshot *b) {
|
||||
return std::memcmp(a->flags, b->flags, sizeof(a->flags)) != 0;
|
||||
}
|
||||
|
||||
using SmsgHandler_t = int(__stdcall *)(uint32_t opcode, void *packet);
|
||||
|
||||
SmsgHandler_t s_smsgSetStandings_o = nullptr;
|
||||
|
||||
int __stdcall SmsgSetStandings_h(uint32_t opcode, void *packet) {
|
||||
RepFlagsSnapshot before;
|
||||
TakeSnapshot(&before);
|
||||
|
||||
const int result = s_smsgSetStandings_o(opcode, packet);
|
||||
|
||||
RepFlagsSnapshot after;
|
||||
TakeSnapshot(&after);
|
||||
if (SnapshotsDiffer(&before, &after))
|
||||
FireUnitFactionPlayer();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookSetStandings{
|
||||
Offsets::FUN_SMSG_SET_FACTION_STANDINGS,
|
||||
reinterpret_cast<void *>(&SmsgSetStandings_h),
|
||||
reinterpret_cast<void **>(&s_smsgSetStandings_o)};
|
||||
|
||||
// Hook 4 — SMSG_INITIALIZE_FACTIONS ──────────────────────────────────
|
||||
|
||||
SmsgHandler_t s_smsgInitFactions_o = nullptr;
|
||||
|
||||
int __stdcall SmsgInitFactions_h(uint32_t opcode, void *packet) {
|
||||
const int result = s_smsgInitFactions_o(opcode, packet);
|
||||
// Initial sync — engine has just populated all 64 rep slots from
|
||||
// server state. Always fire so addons listening for UNIT_FACTION
|
||||
// (rather than PLAYER_LOGIN / VARIABLES_LOADED) see the initial
|
||||
// load. Modern WoW's behavior.
|
||||
FireUnitFactionPlayer();
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookInitFactions{
|
||||
Offsets::FUN_SMSG_INITIALIZE_FACTIONS,
|
||||
reinterpret_cast<void *>(&SmsgInitFactions_h),
|
||||
reinterpret_cast<void **>(&s_smsgInitFactions_o)};
|
||||
|
||||
// Hook 5 — SMSG_SET_FACTION_ATWAR ─────────────────────────────────────
|
||||
|
||||
SmsgHandler_t s_smsgSetAtWar_o = nullptr;
|
||||
|
||||
int __stdcall SmsgSetAtWar_h(uint32_t opcode, void *packet) {
|
||||
const int result = s_smsgSetAtWar_o(opcode, packet);
|
||||
// Server-pushed AT_WAR change — packet is rare and always carries
|
||||
// intent to update. Match 3.3.5's behavior: fire unconditionally
|
||||
// after the byte write, without delta-detecting against the slot
|
||||
// (the engine writes the byte regardless of whether it differs, so
|
||||
// a delta check would conflate "value didn't change" with "server
|
||||
// had nothing to say" — but the server only sends this when
|
||||
// something did).
|
||||
FireUnitFactionPlayer();
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookSetAtWar{
|
||||
Offsets::FUN_SMSG_SET_FACTION_ATWAR,
|
||||
reinterpret_cast<void *>(&SmsgSetAtWar_h),
|
||||
reinterpret_cast<void **>(&s_smsgSetAtWar_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Faction::UnitFactionPolyfill
|
||||
@@ -18,6 +18,12 @@
|
||||
// per-frame `OnMouseDown` scripts — these fire even when the click
|
||||
// misses any UI frame.
|
||||
//
|
||||
// `IsMouseButtonDown([button])` — query the current held state of a
|
||||
// specific button by name or 1-based ID, or "any button held" when
|
||||
// called with no args. State is maintained from the same hook stream
|
||||
// as the events (single source of truth — events firing and the
|
||||
// state being readable happen at the same point in the pipeline).
|
||||
//
|
||||
// Same `WH_GETMESSAGE` thread-message hook pattern as
|
||||
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
|
||||
// messages on the engine's main thread *before* dispatch. The hook
|
||||
@@ -28,6 +34,9 @@
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace Input::GlobalMouse {
|
||||
@@ -37,6 +46,19 @@ namespace {
|
||||
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
|
||||
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
|
||||
|
||||
// Held-state bitmap — bit `i` set means the button with 1-based
|
||||
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
|
||||
// 1 = LeftButton, 2 = RightButton, 3 = MiddleButton,
|
||||
// 4 = Button4 (XBUTTON1), 5 = Button5 (XBUTTON2).
|
||||
constexpr int BIT_LEFT_BUTTON = 0;
|
||||
constexpr int BIT_RIGHT_BUTTON = 1;
|
||||
constexpr int BIT_MIDDLE_BUTTON = 2;
|
||||
constexpr int BIT_BUTTON4 = 3;
|
||||
constexpr int BIT_BUTTON5 = 4;
|
||||
constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
|
||||
|
||||
uint32_t g_mouseButtonMask = 0;
|
||||
|
||||
HHOOK g_msgHook = nullptr;
|
||||
|
||||
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
|
||||
@@ -60,16 +82,60 @@ const char *DecodeButton(UINT msg, WPARAM wParam, bool *outDown) {
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a "LeftButton"/.../"Button5" name to its bit index. -1 for
|
||||
// any other string.
|
||||
int NameToBitIdx(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
if (std::strcmp(name, "LeftButton") == 0) return BIT_LEFT_BUTTON;
|
||||
if (std::strcmp(name, "RightButton") == 0) return BIT_RIGHT_BUTTON;
|
||||
if (std::strcmp(name, "MiddleButton") == 0) return BIT_MIDDLE_BUTTON;
|
||||
if (std::strcmp(name, "Button4") == 0) return BIT_BUTTON4;
|
||||
if (std::strcmp(name, "Button5") == 0) return BIT_BUTTON5;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ProcessMouseMessage(UINT msg, WPARAM wParam) {
|
||||
bool down = false;
|
||||
const char *name = DecodeButton(msg, wParam, &down);
|
||||
if (name == nullptr)
|
||||
return;
|
||||
|
||||
const int bitIdx = NameToBitIdx(name);
|
||||
if (bitIdx >= 0) {
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
if (down) g_mouseButtonMask |= bit;
|
||||
else g_mouseButtonMask &= ~bit;
|
||||
}
|
||||
|
||||
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%s", name);
|
||||
}
|
||||
|
||||
int __fastcall Script_IsMouseButtonDown(void *L) {
|
||||
bool down;
|
||||
if (Game::Lua::GetTop(L) == 0) {
|
||||
// No-arg form: any button held.
|
||||
down = (g_mouseButtonMask & MASK_ANY_BUTTON) != 0;
|
||||
} else {
|
||||
int bitIdx = -1;
|
||||
if (Game::Lua::IsNumber(L, 1)) {
|
||||
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (id >= 1 && id <= 5)
|
||||
bitIdx = id - 1;
|
||||
} else if (Game::Lua::IsString(L, 1)) {
|
||||
bitIdx = NameToBitIdx(Game::Lua::ToString(L, 1));
|
||||
}
|
||||
// Unrecognized button (bad id, unknown name) → false, matching
|
||||
// the modern API's "did this specific button happen to be held"
|
||||
// semantic. Bad input doesn't promote to the any-button check.
|
||||
down = (bitIdx >= 0) && (g_mouseButtonMask & (1u << bitIdx)) != 0;
|
||||
}
|
||||
Game::Lua::PushBool(L, down);
|
||||
return 1;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
|
||||
// Only process when the message is being removed from the queue
|
||||
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
|
||||
@@ -88,7 +154,11 @@ void InstallHook() {
|
||||
GetCurrentThreadId());
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() { InstallHook(); }
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
|
||||
&Script_IsMouseButtonDown);
|
||||
InstallHook();
|
||||
}
|
||||
|
||||
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
|
||||
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
|
||||
|
||||
+265
-34
@@ -17,6 +17,7 @@
|
||||
#include "item/Arg.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/Location.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -52,16 +53,14 @@ static constexpr const char *kGetItemInfoReceived = "GET_ITEM_INFO_RECEIVED";
|
||||
static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoadResult};
|
||||
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
|
||||
|
||||
static void FireGetItemInfoReceived(int itemID, int success) {
|
||||
const int slot = Event::Custom::Lookup(kGetItemInfoReceived);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", itemID, success);
|
||||
static void FireGetItemInfoReceived(int itemID, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
|
||||
itemID, success);
|
||||
}
|
||||
|
||||
static void FireItemDataLoadResult(int itemID, int success) {
|
||||
const int slot = Event::Custom::Lookup(kItemDataLoadResult);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", itemID, success);
|
||||
static void FireItemDataLoadResult(int itemID, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
|
||||
itemID, success);
|
||||
}
|
||||
|
||||
// Engine callback for **implicit** cache fills (transparent warmup
|
||||
@@ -76,16 +75,7 @@ static void FireItemDataLoadResult(int itemID, int success) {
|
||||
// callback would crash the engine, so stdcall is verified by induction.
|
||||
static void __stdcall ItemLoadCallback_Implicit(void *userData, int success) {
|
||||
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
FireGetItemInfoReceived(itemID, static_cast<int>(success != 0));
|
||||
}
|
||||
|
||||
// Engine callback for **explicit** cache fills (player-side
|
||||
// `C_Item.RequestLoadItemData(ByID)` calls). Fires ITEM_DATA_LOAD_RESULT
|
||||
// only — the implicit GET_ITEM_INFO_RECEIVED path is for ambient cache
|
||||
// fills, not for callers who explicitly asked.
|
||||
static void __stdcall ItemLoadCallback_Explicit(void *userData, int success) {
|
||||
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
FireItemDataLoadResult(itemID, static_cast<int>(success != 0));
|
||||
FireGetItemInfoReceived(itemID, success != 0);
|
||||
}
|
||||
|
||||
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
|
||||
@@ -103,6 +93,15 @@ using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
|
||||
// VanillaHelpers documented as `requestIfMissing` — that slot is reused
|
||||
// as a local immediately after entry, never read. The trigger for the
|
||||
// request path is `callback != NULL`.
|
||||
//
|
||||
// The 6th arg IS used elsewhere though: when non-zero, the engine
|
||||
// walks the entry's existing pending-callback list and skips the
|
||||
// append if a node with matching `(callback, userData)` is already
|
||||
// queued. We always pass `1` so that bursts of `WarmCache` calls for
|
||||
// the same itemID (e.g. Bagnon enumerating the same slot repeatedly
|
||||
// while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in flight) collapse to
|
||||
// a single callback registration — otherwise the response fires our
|
||||
// `GET_ITEM_INFO_RECEIVED` once per call.
|
||||
static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
@@ -110,7 +109,7 @@ static const uint8_t *CacheFetch(uint32_t itemID, ItemLoadCallback_t callback) {
|
||||
void *cb = reinterpret_cast<void *>(callback);
|
||||
void *userData =
|
||||
callback ? reinterpret_cast<void *>(static_cast<uintptr_t>(itemID)) : nullptr;
|
||||
return fn(cache, itemID, &zeroGuid, cb, userData, 0);
|
||||
return fn(cache, itemID, &zeroGuid, cb, userData, 1);
|
||||
}
|
||||
|
||||
// Resolves an item-location table at stack idx to the itemID by walking
|
||||
@@ -148,18 +147,233 @@ static int __fastcall Script_IsItemDataCached(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Explicit-request path: kicks off the cache fill via the explicit
|
||||
// callback (which fires ITEM_DATA_LOAD_RESULT). If the item is already
|
||||
// cached, the engine won't invoke our callback — synthesize the event
|
||||
// so addons get the same notification regardless of cache state,
|
||||
// matching modern `C_Item.RequestLoadItemData(ByID)` semantics.
|
||||
static void RequestAndMaybeNotify(uint32_t itemID) {
|
||||
const bool wasCached = (CacheFetch(itemID, nullptr) != nullptr);
|
||||
CacheFetch(itemID, &ItemLoadCallback_Explicit);
|
||||
if (wasCached)
|
||||
FireItemDataLoadResult(static_cast<int>(itemID), 1);
|
||||
// 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`.
|
||||
//
|
||||
// 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), `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 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 = 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) {
|
||||
if (g_pending[i].itemID == itemID)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void RemovePendingAt(int idx) {
|
||||
--g_pendingCount;
|
||||
if (idx != g_pendingCount)
|
||||
g_pending[idx] = g_pending[g_pendingCount];
|
||||
}
|
||||
|
||||
// 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 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) {
|
||||
// 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;
|
||||
}
|
||||
if (g_pendingCount >= kMaxPending)
|
||||
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
|
||||
// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and walks the per-entry callback
|
||||
// lists, we sweep `g_pending` for anything now-loaded and fire the
|
||||
// event. This is the engine-aligned alternative to per-tick polling:
|
||||
// we observe fills exactly when they happen, with zero added latency
|
||||
// and no risk of racing the engine's own callback walk.
|
||||
//
|
||||
// `__thiscall` is mimicked as `__fastcall(ecx, edx_unused, ...)` per
|
||||
// the convention used elsewhere in this codebase (see
|
||||
// `KeyringDescChange_h`, `FrameRegisterEvent_h`).
|
||||
using ItemStatsCacheResponse_t = void(__thiscall *)(void *cache, void *packetReader, int flag);
|
||||
static ItemStatsCacheResponse_t ItemStatsCacheResponse_o = nullptr;
|
||||
|
||||
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 (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;) {
|
||||
if (TryCompletePending(i))
|
||||
continue;
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookCacheResponse{
|
||||
Offsets::FUN_ITEMSTATS_CACHE_RESPONSE,
|
||||
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
|
||||
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
|
||||
|
||||
// Escalation + timeout. The hook covers the happy path (engine fills
|
||||
// cache → we fire). This tick is only responsible for:
|
||||
// 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_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);
|
||||
FireByTag(itemID, false, implicit);
|
||||
continue;
|
||||
}
|
||||
++g_pending[i].ticksWaiting;
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup
|
||||
// path used by the `Script_GetItemInfo` hook and `SetItemByID`. Fires
|
||||
// the cache request only if the item isn't already cached; uses the
|
||||
@@ -167,7 +381,24 @@ static void RequestAndMaybeNotify(uint32_t itemID) {
|
||||
// `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
|
||||
@@ -190,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;
|
||||
}
|
||||
|
||||
@@ -205,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;
|
||||
}
|
||||
|
||||
|
||||
+5
-49
@@ -166,49 +166,6 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Walks bags 0..4 looking for an item matching `arg`. On hit, sets
|
||||
// `*outBag` / `*outSlot` / `*outItem` and returns true.
|
||||
//
|
||||
// itemID match is direct; name match peeks the item-cache record's
|
||||
// `m_name[0]` and compares case-insensitively. Stomps the Lua stack
|
||||
// per the helpers it calls.
|
||||
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, int *outBag, int *outSlot,
|
||||
const uint8_t **outItem) {
|
||||
for (int bag = 0; bag <= 4; ++bag) {
|
||||
const int slotCount = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= slotCount; ++slot) {
|
||||
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
|
||||
if (item == nullptr) continue;
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0) continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id == arg.itemID) {
|
||||
*outBag = bag;
|
||||
*outSlot = slot;
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr) continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr) continue;
|
||||
if (_stricmp(name, arg.name) == 0) {
|
||||
*outBag = bag;
|
||||
*outSlot = slot;
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// `C_Item.EquipItemByName(itemInfo [, dstSlot])` — finds the first
|
||||
// item in the player's bags matching `itemInfo` (itemID, link, or
|
||||
// name) and equips it.
|
||||
@@ -255,14 +212,13 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
reinterpret_cast<int(__fastcall *)(void *)>(
|
||||
Offsets::FUN_SCRIPT_CLEAR_CURSOR)(L);
|
||||
|
||||
int bag = -1, slot = -1;
|
||||
const uint8_t *cgItem = nullptr;
|
||||
if (!FindItemInBags(L, arg, &bag, &slot, &cgItem)) {
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (hasDstSlot) {
|
||||
Item::Swap::FromBag(cgItem, bag, slot, dstSlot);
|
||||
Item::Swap::FromBag(found.item, found.bagID, found.slotIndex, dstSlot);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -273,8 +229,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
|
||||
// resolves the local player and calls this with flag=0.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(bag));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
|
||||
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
|
||||
pickup(L);
|
||||
|
||||
|
||||
+22
-33
@@ -14,28 +14,12 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Data.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Exists {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
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
|
||||
|
||||
// `C_Item.DoesItemExist(itemLocation)` — true iff the location resolves
|
||||
// to a populated inventory slot on the active player. Equipment-slot
|
||||
// locations check the character pane; bag-slot locations walk the
|
||||
@@ -51,27 +35,32 @@ static int __fastcall Script_C_Item_DoesItemExist(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Item.DoesItemExistByID(itemInfo)` — true iff the cache currently
|
||||
// has data for this itemID (i.e. `GetItemInfo` would return non-nil
|
||||
// right now). Same semantics as the modern function and the existing
|
||||
// Lua shim: cache miss → false, but we kick off the network query
|
||||
// so a subsequent call after `GET_ITEM_INFO_RECEIVED` will succeed.
|
||||
// `C_Item.DoesItemExistByID(itemInfo)` — true if `itemID` looks like a
|
||||
// valid item identifier. Modern WoW answers from the client-side item
|
||||
// database (Item-sparse.db2) and is independent of whether the network
|
||||
// cache has been warmed yet. 1.12 has no client-side DB, so we
|
||||
// optimistically accept any positive itemID; callers needing the
|
||||
// "is the cache populated?" check should use `IsItemDataCachedByID`.
|
||||
//
|
||||
// This split matters for `ItemMixin:IsItemEmpty` (FrameXML ItemUtil),
|
||||
// which uses `DoesItemExistByID` to gate `ContinueOnItemLoad` — the
|
||||
// whole point of `ContinueOnItemLoad` is to wait for an uncached item
|
||||
// to load, so the existence check must succeed for items that aren't
|
||||
// in the cache yet.
|
||||
//
|
||||
// We deliberately do NOT auto-warm the cache here. Existence is a pure
|
||||
// query about the ID, not a load request — and during early-login
|
||||
// (cold WDB, pfQuest's PLAYER_ENTERING_WORLD path) a spurious cache
|
||||
// touch can race the engine's natural inventory prefetch and leave the
|
||||
// item stuck. Callers wanting to actually load data should use
|
||||
// `RequestLoadItemDataByID`; passive readers (`GetItemInfo`,
|
||||
// `GetItemNameByID`) already warm on their own miss path.
|
||||
//
|
||||
// Accepts numeric itemID or `"item:NNN..."` string (including full
|
||||
// chat links). Returns false for any input we can't resolve to an
|
||||
// itemID; never raises.
|
||||
// chat links); never raises.
|
||||
static int __fastcall Script_C_Item_DoesItemExistByID(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
if (PeekItemRecord(static_cast<uint32_t>(itemID)) == nullptr) {
|
||||
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
Game::Lua::PushBoolean(L, itemID > 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// 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.GetItemDataByID(itemInfo)` / `C_Item.GetItemData(itemLocation)`
|
||||
// — return a single table containing every field we can extract from
|
||||
// the cached `ItemStats_C` record. Convenience for addons that want
|
||||
// the kitchen sink without making 15 separate `C_Item.*` calls.
|
||||
//
|
||||
// Returns `nil` if the item isn't cached (caller should
|
||||
// `RequestLoadItemData` and listen for `ITEM_DATA_LOAD_RESULT`).
|
||||
// Does not warm the cache itself — this is a passive reader, same as
|
||||
// `GetItemInfoInstant`'s contract.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Spell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Item::GetData {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, bool requestIfMissing);
|
||||
|
||||
const uint8_t *FetchItemRecord(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, false);
|
||||
}
|
||||
|
||||
int CurrentLocaleIndex() {
|
||||
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
}
|
||||
|
||||
// Mirrors the lookup in `Item::Info`. ItemClass.dbc record `+0x0C` is
|
||||
// the 9-locale name array; null/empty slots fall back to "".
|
||||
const char *LookupClassName(uint32_t classID) {
|
||||
const char *s = DBC::LocalizedField(
|
||||
Offsets::VAR_ITEMCLASS_RECORDS, Offsets::VAR_ITEMCLASS_COUNT,
|
||||
classID, Offsets::OFF_ITEMCLASS_NAMES);
|
||||
return (s != nullptr && s[0] != '\0') ? s : "";
|
||||
}
|
||||
|
||||
// ItemSubClass.dbc walker — same fallback chain as Script_GetItemInfo
|
||||
// (verbose name first, short name on miss).
|
||||
const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
|
||||
if (count <= 0)
|
||||
return "";
|
||||
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
|
||||
if (records == nullptr)
|
||||
return "";
|
||||
const int locale = CurrentLocaleIndex();
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint8_t *record = records + i * Offsets::OFF_ITEMSUBCLASS_RECORD_STRIDE;
|
||||
if (*reinterpret_cast<const uint32_t *>(record + 0x00) != classID)
|
||||
continue;
|
||||
if (*reinterpret_cast<const uint32_t *>(record + 0x04) != subClassID)
|
||||
continue;
|
||||
const char *verbose = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
|
||||
if (verbose != nullptr && verbose[0] != '\0')
|
||||
return verbose;
|
||||
const char *shortName = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
|
||||
return (shortName != nullptr && shortName[0] != '\0') ? shortName : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const char *LookupInvType(uint32_t invType) {
|
||||
if (invType > Offsets::INVTYPE_TABLE_MAX_INDEX)
|
||||
return "";
|
||||
auto **table = reinterpret_cast<const char **>(Offsets::VAR_INVTYPE_STRING_TABLE);
|
||||
const char *s = table[invType];
|
||||
return (s != nullptr && s[0] != '\0') ? s : "";
|
||||
}
|
||||
|
||||
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
if (out == nullptr || outSize == 0)
|
||||
return false;
|
||||
out[0] = '\0';
|
||||
const char *iconName = DBC::StringField(
|
||||
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
|
||||
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
|
||||
if (iconName == nullptr || iconName[0] == '\0')
|
||||
return false;
|
||||
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wrath flipped the bagFamily encoding from raw 1-based IDs to a
|
||||
// bitmask. We surface the modern shape so backported addons compile
|
||||
// cleanly. See `Item::Info::BagFamilyIdToBitmask` for the full story.
|
||||
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
|
||||
return (rawId == 0) ? 0 : (1u << (rawId - 1));
|
||||
}
|
||||
|
||||
// Shorthand: read a u32 field from the record at `offset`, push it on
|
||||
// the stack as a Lua number, and set `t[key] = value` on the table at
|
||||
// stack[-3]. `SetFieldNumber` already handles the push/set; this is
|
||||
// just the field-fetch convenience.
|
||||
void SetU32(void *L, const uint8_t *record, const char *key, int offset) {
|
||||
const uint32_t v = *reinterpret_cast<const uint32_t *>(record + offset);
|
||||
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
|
||||
}
|
||||
|
||||
void SetI32(void *L, const uint8_t *record, const char *key, int offset) {
|
||||
const int32_t v = *reinterpret_cast<const int32_t *>(record + offset);
|
||||
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
|
||||
}
|
||||
|
||||
void SetF32(void *L, const uint8_t *record, const char *key, int offset) {
|
||||
const float v = *reinterpret_cast<const float *>(record + offset);
|
||||
Game::Lua::SetFieldNumber(L, key, static_cast<double>(v));
|
||||
}
|
||||
|
||||
// Sets `t[key]` to a sub-array of N consecutive u32 values from the
|
||||
// record. Pushes 1-indexed (Lua array convention).
|
||||
void SetU32Array(void *L, const uint8_t *record, const char *key,
|
||||
int offset, int count) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
auto *arr = reinterpret_cast<const uint32_t *>(record + offset);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
void SetI32Array(void *L, const uint8_t *record, const char *key,
|
||||
int offset, int count) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
auto *arr = reinterpret_cast<const int32_t *>(record + offset);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
void SetF32Array(void *L, const uint8_t *record, const char *key,
|
||||
int offset, int count) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::NewTable(L);
|
||||
auto *arr = reinterpret_cast<const float *>(record + offset);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(arr[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Sparse stats: walks the 10 (statType, statValue) slots and pushes
|
||||
// only the populated ones into a map keyed by statType. Modern addons
|
||||
// expect `stats[STAT_AGI] = 5`-style access; matching that here avoids
|
||||
// callers having to walk the fixed-10 array themselves.
|
||||
void SetStatsMap(void *L, const uint8_t *record) {
|
||||
Game::Lua::PushString(L, "stats");
|
||||
Game::Lua::NewTable(L);
|
||||
auto *types = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
|
||||
auto *values = reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i) {
|
||||
if (types[i] == 0 && values[i] == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(types[i]));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(values[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Spell slots: walks all 5 slots, pushes only entries with a non-zero
|
||||
// spellID as `{ id, trigger, charges, cooldown, category,
|
||||
// categoryCooldown }` records. Sparse but 1-indexed in declaration
|
||||
// order so the slot index is preserved.
|
||||
void SetSpellsArray(void *L, const uint8_t *record) {
|
||||
Game::Lua::PushString(L, "spells");
|
||||
Game::Lua::NewTable(L);
|
||||
auto *ids = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
|
||||
auto *triggers = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
|
||||
auto *charges = reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
|
||||
auto *cooldowns = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
|
||||
auto *categories = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
|
||||
auto *catCooldowns = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
|
||||
int written = 0;
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
|
||||
if (ids[i] == 0)
|
||||
continue;
|
||||
++written;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(written));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
|
||||
Game::Lua::SetFieldNumber(L, "trigger", static_cast<double>(triggers[i]));
|
||||
Game::Lua::SetFieldNumber(L, "charges", static_cast<double>(charges[i]));
|
||||
Game::Lua::SetFieldNumber(L, "cooldown", static_cast<double>(cooldowns[i]));
|
||||
Game::Lua::SetFieldNumber(L, "category", static_cast<double>(categories[i]));
|
||||
Game::Lua::SetFieldNumber(L, "categoryCooldown",
|
||||
static_cast<double>(catCooldowns[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
// Pushes a single Lua table for `record` onto the stack, populated
|
||||
// with every field we can read out of `ItemStats_C`. Caller is
|
||||
// responsible for verifying `record` is non-null and pushing the
|
||||
// itemID as a setup step.
|
||||
void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
|
||||
|
||||
// Identity strings
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
const char *description = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
|
||||
if (description != nullptr && description[0] != '\0')
|
||||
Game::Lua::SetFieldString(L, "description", description);
|
||||
|
||||
// Display
|
||||
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
Game::Lua::SetFieldNumber(L, "displayInfoID", static_cast<double>(displayInfoID));
|
||||
char iconPath[260];
|
||||
if (BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
|
||||
Game::Lua::SetFieldString(L, "icon", iconPath);
|
||||
|
||||
// Quality / classification
|
||||
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
|
||||
|
||||
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subClassID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
Game::Lua::SetFieldNumber(L, "classID", static_cast<double>(classID));
|
||||
Game::Lua::SetFieldNumber(L, "subclassID", static_cast<double>(subClassID));
|
||||
Game::Lua::SetFieldString(L, "className", LookupClassName(classID));
|
||||
Game::Lua::SetFieldString(L, "subclassName", LookupSubClassName(classID, subClassID));
|
||||
|
||||
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
|
||||
Game::Lua::SetFieldNumber(L, "inventoryType", static_cast<double>(invType));
|
||||
Game::Lua::SetFieldString(L, "equipLoc", LookupInvType(invType));
|
||||
|
||||
// Bind
|
||||
SetU32(L, record, "bindType", Offsets::OFF_ITEMSTATS_BONDING);
|
||||
|
||||
// Flags: raw + decoded bits.
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
Game::Lua::SetFieldNumber(L, "flags", static_cast<double>(flags));
|
||||
Game::Lua::SetFieldBool(L, "isConjured",
|
||||
(flags & Offsets::ITEM_FLAG_CONJURED) != 0);
|
||||
Game::Lua::SetFieldBool(L, "isOpenable",
|
||||
(flags & Offsets::ITEMSTATS_FLAG_OPENABLE) != 0);
|
||||
Game::Lua::SetFieldBool(L, "isLootable",
|
||||
(flags & Offsets::ITEM_FLAG_LOOTABLE) != 0);
|
||||
Game::Lua::SetFieldBool(L, "isWrapper",
|
||||
(flags & Offsets::ITEM_FLAG_WRAPPER) != 0);
|
||||
|
||||
// Stacking / inventory
|
||||
SetI32(L, record, "maxStackSize", Offsets::OFF_ITEMSTATS_STACKABLE);
|
||||
SetI32(L, record, "maxCount", Offsets::OFF_ITEMSTATS_MAX_COUNT);
|
||||
SetU32(L, record, "containerSlots", Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS);
|
||||
const uint32_t rawBagFamily = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
|
||||
Game::Lua::SetFieldNumber(L, "bagFamily",
|
||||
static_cast<double>(BagFamilyIdToBitmask(rawBagFamily)));
|
||||
|
||||
// Economy
|
||||
SetU32(L, record, "buyPrice", Offsets::OFF_ITEMSTATS_BUY_PRICE);
|
||||
SetU32(L, record, "sellPrice", Offsets::OFF_ITEMSTATS_SELL_PRICE);
|
||||
|
||||
// Level / requirements
|
||||
SetU32(L, record, "itemLevel", Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
|
||||
SetU32(L, record, "requiredLevel", Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
SetU32(L, record, "requiredSkill", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
|
||||
SetU32(L, record, "requiredSkillRank", Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
|
||||
SetU32(L, record, "requiredSpell", Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
|
||||
SetU32(L, record, "requiredHonorRank", Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
|
||||
SetU32(L, record, "requiredCityRank", Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
|
||||
SetU32(L, record, "requiredFaction", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
|
||||
SetU32(L, record, "requiredFactionRank", Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
|
||||
SetU32(L, record, "allowableClass", Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
|
||||
SetU32(L, record, "allowableRace", Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
|
||||
|
||||
// Equipment defenses
|
||||
SetU32(L, record, "armor", Offsets::OFF_ITEMSTATS_ARMOR);
|
||||
SetU32(L, record, "block", Offsets::OFF_ITEMSTATS_BLOCK);
|
||||
SetU32(L, record, "maxDurability", Offsets::OFF_ITEMSTATS_MAX_DURABILITY);
|
||||
|
||||
// Sparse stats map (statType -> statValue, only populated entries)
|
||||
SetStatsMap(L, record);
|
||||
|
||||
// Resistances
|
||||
SetU32(L, record, "resistanceHoly", Offsets::OFF_ITEMSTATS_RES_HOLY);
|
||||
SetU32(L, record, "resistanceFire", Offsets::OFF_ITEMSTATS_RES_FIRE);
|
||||
SetU32(L, record, "resistanceNature", Offsets::OFF_ITEMSTATS_RES_NATURE);
|
||||
SetU32(L, record, "resistanceFrost", Offsets::OFF_ITEMSTATS_RES_FROST);
|
||||
SetU32(L, record, "resistanceShadow", Offsets::OFF_ITEMSTATS_RES_SHADOW);
|
||||
SetU32(L, record, "resistanceArcane", Offsets::OFF_ITEMSTATS_RES_ARCANE);
|
||||
|
||||
// Weapon
|
||||
SetF32Array(L, record, "damageMin", Offsets::OFF_ITEMSTATS_DAMAGE_MIN,
|
||||
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
|
||||
SetF32Array(L, record, "damageMax", Offsets::OFF_ITEMSTATS_DAMAGE_MAX,
|
||||
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
|
||||
SetU32Array(L, record, "damageType", Offsets::OFF_ITEMSTATS_DAMAGE_TYPE,
|
||||
Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT);
|
||||
SetU32(L, record, "delay", Offsets::OFF_ITEMSTATS_DELAY);
|
||||
SetU32(L, record, "ammoType", Offsets::OFF_ITEMSTATS_AMMO_TYPE);
|
||||
SetF32(L, record, "rangedModRange", Offsets::OFF_ITEMSTATS_RANGED_MOD_RANGE);
|
||||
|
||||
// Spell slots (sparse array of records) + ON_USE convenience
|
||||
SetSpellsArray(L, record);
|
||||
const int useSpellID = Item::Spell::OnUseSpellIDForItemID(itemID);
|
||||
if (useSpellID > 0)
|
||||
Game::Lua::SetFieldNumber(L, "useSpellID", static_cast<double>(useSpellID));
|
||||
|
||||
// Lock / item set
|
||||
SetU32(L, record, "lockID", Offsets::OFF_ITEMSTATS_LOCK_ID);
|
||||
SetU32(L, record, "itemSet", Offsets::OFF_ITEMSTATS_ITEM_SET);
|
||||
|
||||
// Books / quest / binding
|
||||
SetU32(L, record, "pageText", Offsets::OFF_ITEMSTATS_PAGE_TEXT);
|
||||
SetU32(L, record, "pageMaterial", Offsets::OFF_ITEMSTATS_PAGE_MATERIAL);
|
||||
SetU32(L, record, "languageID", Offsets::OFF_ITEMSTATS_LANGUAGE_ID);
|
||||
SetU32(L, record, "startQuest", Offsets::OFF_ITEMSTATS_START_QUEST);
|
||||
SetI32(L, record, "material", Offsets::OFF_ITEMSTATS_MATERIAL);
|
||||
SetU32(L, record, "sheath", Offsets::OFF_ITEMSTATS_SHEATH);
|
||||
SetI32(L, record, "randomProperty", Offsets::OFF_ITEMSTATS_RANDOM_PROPERTY);
|
||||
SetU32(L, record, "area", Offsets::OFF_ITEMSTATS_AREA);
|
||||
SetU32(L, record, "map", Offsets::OFF_ITEMSTATS_MAP);
|
||||
}
|
||||
|
||||
// Common path for both entry points: take an itemID, peek the cache,
|
||||
// build & push the data table or push nil for uncached.
|
||||
int PushDataForItemID(void *L, int itemID) {
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushItemDataTable(L, static_cast<uint32_t>(itemID), record);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Item.GetItemDataByID(itemInfo)` — accepts numeric itemID,
|
||||
// `"item:NNN..."` string, or full chat link. Returns the data table
|
||||
// or nil for invalid input / uncached items.
|
||||
int __fastcall Script_GetItemDataByID(void *L) {
|
||||
return PushDataForItemID(L, Item::Arg::ResolveItemID(L, 1));
|
||||
}
|
||||
|
||||
// `C_Item.GetItemData(itemLocation)` — resolves the equipment-slot /
|
||||
// bag-slot location to its CGItem, extracts the itemID, then peeks
|
||||
// the cache. Returns nil for empty slots, invalid locations, or
|
||||
// uncached items.
|
||||
int __fastcall Script_GetItemData(void *L) {
|
||||
if (!Item::Location::IsLocationArg(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Item.GetItemData(itemLocation)");
|
||||
return 0;
|
||||
}
|
||||
return PushDataForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemDataByID", &Script_GetItemDataByID);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "GetItemData", &Script_GetItemData);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::GetData
|
||||
+20
-3
@@ -93,13 +93,31 @@ static bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `GetItemInfoInstant` is the modern "synchronous, never blocks" item
|
||||
// info call. In modern WoW the trailing 6 fields come from client-side
|
||||
// `Item-sparse.db2` and so the call always returns the full 7-tuple.
|
||||
// 1.12 has no client-side item DB — those fields live only in the
|
||||
// network-fed `DBCache_ItemStats`. We mirror the contract by:
|
||||
// - always returning the itemID (echoed from input) so callers using
|
||||
// `GetItemInfoInstant(link_or_id)` as an ID extractor never see nil
|
||||
// - returning the remaining 6 fields from the cache when present,
|
||||
// nil when not (preserving the 7-tuple shape)
|
||||
// - not warming the cache; "Instant" implies no side effects. Callers
|
||||
// that need the full info should use `GetItemInfo`, which already
|
||||
// triggers the warmup via the `Script_GetItemInfo` hook.
|
||||
static int __fastcall Script_GetItemInfoInstant(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
|
||||
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
if (record == nullptr) {
|
||||
for (int i = 0; i < 6; ++i)
|
||||
Game::Lua::PushNil(L);
|
||||
return 7;
|
||||
}
|
||||
|
||||
const uint32_t classID =
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
@@ -114,7 +132,6 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
|
||||
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
|
||||
iconPath[0] = 0;
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(itemID));
|
||||
Game::Lua::PushString(L, LookupItemClassName(classID));
|
||||
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID));
|
||||
Game::Lua::PushString(L, LookupInvType(invType));
|
||||
|
||||
@@ -105,6 +105,47 @@ bool FitsSlot(int slot1Based, uint32_t invType) {
|
||||
return (kSlotMaskByInvType[invType] & (1u << (slot1Based - 1))) != 0;
|
||||
}
|
||||
|
||||
// ── Proficiency gate ──────────────────────────────────────────────
|
||||
// `FitsSlot` only checks invType→slot shape compatibility; without
|
||||
// also gating on the player's proficiencies this would return (e.g.)
|
||||
// mail helms for a warlock since both are `invType=HEAD`. Modern
|
||||
// WoW's equipment-set picker filters by what the player can actually
|
||||
// equip; we mirror that.
|
||||
//
|
||||
// Source of truth: the engine's per-itemClass proficiency bitmap at
|
||||
// `[VAR_PROFICIENCY_TABLE]` (17 u32 entries indexed by `m_class`).
|
||||
// SMSG_SET_PROFICIENCY's handler at `0x005E7B70` writes the bitmask
|
||||
// for each class verbatim from the wire. Bit N of `table[class]` =
|
||||
// "player can equip subclass N of this class". Same data the engine
|
||||
// itself reads for the tooltip "Mail/Leather/Plate" red-text and the
|
||||
// cursor-drop-on-paperdoll-slot check — covers both armor and weapon
|
||||
// proficiencies in one lookup.
|
||||
//
|
||||
// 4.3.4's `Script_GetInventoryItemsForSlot` does the equivalent check
|
||||
// via `FUN_00553AD0(itemClass) & (1 << subclass)`, reading from
|
||||
// `DAT_00DD4D38` (its own copy of the same packet-fed table).
|
||||
|
||||
bool PlayerHasProficiency(uint32_t itemClass, uint32_t subclass) {
|
||||
if (itemClass > 16)
|
||||
return true; // table only covers 0..16; be permissive past that
|
||||
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE) + itemClass * 4);
|
||||
if (mask == 0)
|
||||
return true; // table not populated yet (pre-SMSG_SET_PROFICIENCY)
|
||||
// — fail open so empty-mask doesn't blank the list
|
||||
return (mask & (1u << subclass)) != 0;
|
||||
}
|
||||
|
||||
bool PlayerCanEquip(const uint8_t *record) {
|
||||
if (record == nullptr)
|
||||
return true; // missing record — be permissive, don't hide the item
|
||||
const uint32_t classCode = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subclass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
return PlayerHasProficiency(classCode, subclass);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -112,11 +153,14 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
uint32_t ReadInventoryType(const uint8_t *cgItem) {
|
||||
const uint8_t *ItemRecordForCGItem(const uint8_t *cgItem) {
|
||||
const int itemID = Item::ID::FromCGItem(cgItem);
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
return nullptr;
|
||||
return PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
}
|
||||
|
||||
uint32_t ReadInventoryType(const uint8_t *record) {
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
@@ -132,7 +176,10 @@ void CollectIfFits(int slot, const uint8_t *cgItem, int location,
|
||||
std::vector<Hit> &out) {
|
||||
if (cgItem == nullptr)
|
||||
return;
|
||||
if (!FitsSlot(slot, ReadInventoryType(cgItem)))
|
||||
const uint8_t *record = ItemRecordForCGItem(cgItem);
|
||||
if (!FitsSlot(slot, ReadInventoryType(record)))
|
||||
return;
|
||||
if (!PlayerCanEquip(record))
|
||||
return;
|
||||
out.push_back({location, cgItem});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
#include "../Game.h"
|
||||
#include "../Offsets.h"
|
||||
#include "../guid/Guid.h"
|
||||
#include "Arg.h"
|
||||
#include "ID.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Location {
|
||||
|
||||
namespace {
|
||||
@@ -183,6 +186,44 @@ bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
|
||||
if (arg.itemID <= 0 && arg.name == nullptr)
|
||||
return false;
|
||||
|
||||
for (int bagID = 0; bagID <= 4; ++bagID) {
|
||||
const int slotCount = GetBagSlotCount(bagID);
|
||||
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
|
||||
auto *item = ResolveBag(L, bagID, slotIndex);
|
||||
if (item == nullptr)
|
||||
continue;
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0)
|
||||
continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id != arg.itemID)
|
||||
continue;
|
||||
} else {
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr)
|
||||
continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || _stricmp(name, arg.name) != 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
out->equipmentSlotIndex = 0;
|
||||
out->bagID = bagID;
|
||||
out->slotIndex = slotIndex;
|
||||
out->item = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
|
||||
if (guid == 0)
|
||||
return false;
|
||||
|
||||
@@ -96,6 +96,26 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
|
||||
// for link-decoration purposes. Stomps the Lua stack.
|
||||
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
|
||||
|
||||
} // namespace Item::Location
|
||||
|
||||
// Forward-declare for `FindByArgInBags` below.
|
||||
namespace Item::Arg { struct Resolved; }
|
||||
|
||||
namespace Item::Location {
|
||||
|
||||
// Walks the player's bags (0..4 only — no equipment) looking for the
|
||||
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
|
||||
// otherwise `arg.name` is matched case-insensitively against the
|
||||
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
|
||||
// `out->bagID` / `out->slotIndex` / `out->item` and sets
|
||||
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
|
||||
//
|
||||
// Used by the by-name action APIs (`C_Item.EquipItemByName`,
|
||||
// `C_Item.UseItemByName`, `C_Item.UseAtCursor`) — none of them want
|
||||
// equipment in the candidate pool (the engine doesn't equip-from-
|
||||
// equipped or use-from-equipped; the item has to be in bags first).
|
||||
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
|
||||
|
||||
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
|
||||
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
|
||||
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
|
||||
|
||||
@@ -199,6 +199,38 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
|
||||
dstPaperdollSlot);
|
||||
}
|
||||
|
||||
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag) {
|
||||
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
|
||||
return false;
|
||||
if (cgItem == nullptr)
|
||||
return false;
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
uint32_t playerLo = 0, playerHi = 0;
|
||||
if (!ReadGuid(player, &playerLo, &playerHi))
|
||||
return false;
|
||||
|
||||
uint32_t dstContainerLo = 0, dstContainerHi = 0, dstLinear = 0;
|
||||
if (!EncodeBagSlot(dstBagID, dstSlotInBag,
|
||||
&dstContainerLo, &dstContainerHi, &dstLinear))
|
||||
return false;
|
||||
|
||||
uint32_t itemLo = 0, itemHi = 0;
|
||||
if (!ReadGuid(cgItem, &itemLo, &itemHi))
|
||||
return false;
|
||||
|
||||
auto fn = reinterpret_cast<SwapFn_t>(Offsets::FUN_INVENTORY_SWAP);
|
||||
fn(player,
|
||||
itemLo, itemHi,
|
||||
playerLo, playerHi,
|
||||
static_cast<uint32_t>(srcPaperdollSlot - 1),
|
||||
dstContainerLo, dstContainerHi, dstLinear,
|
||||
0);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int count) {
|
||||
// Vanilla protocol writes count as a single byte. Clamp at 255;
|
||||
// anything larger would either truncate silently (255 sent) or
|
||||
@@ -222,6 +254,24 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
|
||||
if (srcItem == nullptr)
|
||||
return false;
|
||||
|
||||
// Vanilla server rejects `CMSG_SPLIT_ITEM` when count == srcStack
|
||||
// (`EQUIP_ERR_COULDNT_SPLIT_ITEMS`) — conceptually "split N off,
|
||||
// leave residual", and splitting the entire stack would leave
|
||||
// source empty. The drag-and-drop UI's "drop whole stack on
|
||||
// matching stack" goes through CMSG_SWAP_ITEM instead, which the
|
||||
// server treats as a merge for same-item destinations (up to
|
||||
// maxStack). Route there for count == srcStack so callers don't
|
||||
// have to special-case "move everything".
|
||||
auto *srcDescriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
srcItem + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
const int srcStack = srcDescriptor == nullptr ? 0 :
|
||||
static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
|
||||
if (count > srcStack)
|
||||
return false; // server would reject anyway; fail fast and locally
|
||||
if (count == srcStack)
|
||||
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
|
||||
|
||||
void *player = ResolvePlayer();
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
+17
-3
@@ -32,6 +32,12 @@ namespace Item::Swap {
|
||||
// paperdoll slot `srcSlot` (1-based). Used for paperdoll-to-
|
||||
// paperdoll swaps like rings 11/12, trinkets 13/14.
|
||||
//
|
||||
// - `ToBag(item, srcPaperdollSlot, dstBag, dstSlot)` — item currently
|
||||
// in paperdoll slot, moving to a bag slot. The "unequip" direction
|
||||
// `UseEquipmentSet` needs for slots the set wants empty. Uses the
|
||||
// same atomic swap packet — server-side accept rules are identical
|
||||
// (bagID 0..4, dst slot must be valid for that bag).
|
||||
//
|
||||
// All slot args are 1-based Lua-facing; helpers do the
|
||||
// linear-slot conversion. Returns `false` on bad args (slot out of
|
||||
// range, no player object, missing instance block on the item or
|
||||
@@ -44,6 +50,8 @@ bool FromBag(const void *cgItem, int bagID, int slotInBag, int dstPaperdollSlot)
|
||||
|
||||
bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlot);
|
||||
|
||||
bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotInBag);
|
||||
|
||||
// General bag-to-bag swap — neither side is constrained to the
|
||||
// paperdoll. Same engine helper (`FUN_INVENTORY_SWAP`), which routes
|
||||
// to opcode 0x10D (same-container slot swap, e.g. backpack ↔ backpack)
|
||||
@@ -74,9 +82,15 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot);
|
||||
// - `dst` same item & fits maxStack → merges `count` into dst
|
||||
// - `dst` different item / overflow → server rejects (no partial)
|
||||
//
|
||||
// Source must be occupied with at least `count` items; server rejects
|
||||
// if not. Returns false locally only on bad args or unresolvable bags.
|
||||
// All other validation is server-side — same as `Containers` above.
|
||||
// Source must be occupied with at least `count` items. Locally
|
||||
// validates `count <= srcStack` (server would reject otherwise with
|
||||
// `EQUIP_ERR_COULDNT_SPLIT_ITEMS` — fail fast). When `count ==
|
||||
// srcStack` ("move the whole stack"), routes through `Containers`
|
||||
// instead — vanilla's `CMSG_SPLIT_ITEM` rejects splits that would
|
||||
// leave the source empty, but `CMSG_SWAP_ITEM` handles the case
|
||||
// (merges into a matching destination stack, swaps otherwise).
|
||||
// Returns false locally on bad args, unresolvable bags, or
|
||||
// `count > srcStack`; all other validation is server-side.
|
||||
//
|
||||
// Requires Lua state for the same reason as `Containers`: the source
|
||||
// CGItem lookup goes through `Item::Location::ResolveBag`. Stomps the
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Tooltip {
|
||||
|
||||
@@ -290,8 +291,82 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `GameTooltip:SetItemByGUID(guidString)` — modern method that renders
|
||||
// the tooltip for the specific item instance identified by GUID
|
||||
// (`"0xHHHHHHHHLLLLLLLL"` per `C_Item.GetItemGUID`). Distinct from
|
||||
// `SetItemByID`, which shows the base `ItemSparse` data with no
|
||||
// per-instance state: this path uses the live CGItem so the tooltip
|
||||
// includes enchant lines, random-suffix-decorated name + bonuses,
|
||||
// locked/broken state, etc.
|
||||
//
|
||||
// Implementation: parse the GUID string, resolve via the engine's
|
||||
// `FUN_OBJECT_RESOLVE_BY_GUID` (same path `C_Item.GetItemLocation`
|
||||
// and `GameTooltip:GetItem` use), build the fully-decorated
|
||||
// hyperlink via `Item::Link::FromCGItem` (engine helper at
|
||||
// `FUN_GAMETOOLTIP_BUILD_ITEM_LINK`), then dispatch to the engine's
|
||||
// existing `Script_GameTooltip_SetHyperlink`. SetHyperlink parses
|
||||
// the link's `item:N:enchant:gem:gem:gem:gem:suffix:unique` payload
|
||||
// back into instance-specific tooltip data — closing the loop
|
||||
// without us needing a separate engine entry point for "tooltip
|
||||
// from CGItem*".
|
||||
//
|
||||
// Silent no-op when the GUID is malformed, not loaded in the
|
||||
// client, or doesn't resolve to an item (creature/object GUIDs go
|
||||
// to a different resolver).
|
||||
static int __fastcall Script_GameTooltipSetItemByGUID(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
|
||||
return 0;
|
||||
}
|
||||
if (Game::Lua::Type(L, 2) != Game::Lua::TYPE_STRING) {
|
||||
Game::Lua::Error(L, "Usage: GameTooltip:SetItemByGUID(itemGUID)");
|
||||
return 0;
|
||||
}
|
||||
const char *guidStr = Game::Lua::ToString(L, 2);
|
||||
uint64_t guid = 0;
|
||||
if (!Item::Location::ParseGUIDString(guidStr, &guid))
|
||||
return 0;
|
||||
const uint8_t *cgItem = Item::Location::ResolveByGUID(guid);
|
||||
if (cgItem == nullptr)
|
||||
return 0;
|
||||
const char *link = Item::Link::FromCGItem(cgItem);
|
||||
if (link == nullptr || *link == '\0')
|
||||
return 0;
|
||||
|
||||
// `Item::Link::FromCGItem` returns the full
|
||||
// `|cffXXXXXXXX|Hitem:N:E:S:S:S:S:R:U|h[Name]|h|r` decorated link
|
||||
// (the public-link form addons paste into chat). `SetHyperlink`
|
||||
// wants just the bare `item:N:E:S:S:S:S:R:U` payload — passing the
|
||||
// decorated form trips its "Unknown link type" Lua error because
|
||||
// the parser sees `|c` first and gives up. Extract the payload by
|
||||
// finding `|Hitem:` and copying through to the closing `|h`.
|
||||
const char *open = std::strstr(link, "|Hitem:");
|
||||
if (open == nullptr)
|
||||
return 0;
|
||||
open += 2; // skip past `|H` so we start at `item:`
|
||||
const char *close = std::strstr(open, "|h");
|
||||
if (close == nullptr)
|
||||
return 0;
|
||||
const size_t payloadLen = static_cast<size_t>(close - open);
|
||||
char payload[128];
|
||||
if (payloadLen + 1 > sizeof(payload))
|
||||
return 0;
|
||||
std::memcpy(payload, open, payloadLen);
|
||||
payload[payloadLen] = '\0';
|
||||
|
||||
// Replace stack[2] (the GUID) with the bare item link and
|
||||
// dispatch to SetHyperlink with (self, "item:...").
|
||||
Game::Lua::SetTop(L, 1);
|
||||
Game::Lua::PushString(L, payload);
|
||||
|
||||
using Script_t = int(__fastcall *)(void *L);
|
||||
auto fn = reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_HYPERLINK);
|
||||
return fn(L);
|
||||
}
|
||||
|
||||
static const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetItemByID", &Script_GameTooltipSetItemByID},
|
||||
{"SetItemByGUID", &Script_GameTooltipSetItemByGUID},
|
||||
{"SetInventoryItemByID", &Script_GameTooltipSetInventoryItemByID},
|
||||
{"GetItem", &Script_GameTooltipGetItem},
|
||||
{"HasItem", &Script_GameTooltipHasItem},
|
||||
|
||||
+62
-45
@@ -11,18 +11,31 @@
|
||||
// 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.GetItemUniqueness(itemLocation)` — uniqueness cap. Reads
|
||||
// `ItemStats_C.m_maxCount` from the item's cached record; that's the
|
||||
// only uniqueness signal vanilla's client carries. `1` = unique
|
||||
// ("Held In Off-hand" trinkets, special quest items, etc.); `0` =
|
||||
// unlimited; higher values cap the inventory count.
|
||||
// `C_Item.GetItemUniqueness(itemLocation)` and the `*ByID` variant
|
||||
// — uniqueness cap. Both read `ItemStats_C.m_maxCount` from the
|
||||
// item's cached record; that's the only uniqueness signal vanilla
|
||||
// carries. `1` = unique-equipped (one at a time); higher values
|
||||
// cap the inventory count; `0` = unlimited.
|
||||
//
|
||||
// Modern WoW returns a 3-tuple `(maxEquipped, category, equippedCount)`
|
||||
// keyed off the post-TBC category system (e.g. "Unique-Equipped:
|
||||
// Brewfest Mug"). Vanilla has no category system, so we surface
|
||||
// `m_maxCount` as the first return and leave the category nil and
|
||||
// equipped count `0` — matching modern's "field present but inapplicable"
|
||||
// convention.
|
||||
// Modern WoW (Classic Era 1.15.x) gives the two functions **different
|
||||
// signatures**, verified against `_classic_` BlizzardInterfaceCode's
|
||||
// `ItemDocumentation.lua`:
|
||||
//
|
||||
// GetItemUniqueness(itemLocation) →
|
||||
// limitCategory (number, non-nilable),
|
||||
// limitMax (number, non-nilable)
|
||||
//
|
||||
// GetItemUniquenessByID(itemInfo) →
|
||||
// isUnique (bool, non-nilable),
|
||||
// limitCategoryName (string, nilable),
|
||||
// limitCategoryCount (number, nilable),
|
||||
// limitCategoryID (number, nilable)
|
||||
//
|
||||
// Vanilla has no `LimitCategory.dbc` system (TBC+ addition for
|
||||
// "Unique-Equipped: Brewfest Mug"-style cross-item category caps),
|
||||
// so the category fields are always nil/0. `isUnique` collapses to
|
||||
// `m_maxCount > 0`; `limitMax` and `limitCategoryCount` both surface
|
||||
// the raw `m_maxCount`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -50,54 +63,58 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Pushes the 3-tuple `(maxAllowed, nil, 0)` derived from the
|
||||
// ItemStats_C record, or nil + early-return on cache miss.
|
||||
int PushFromItemID(void *L, uint32_t itemID) {
|
||||
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
|
||||
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
|
||||
// so the Lua side sees the modern "MayReturnNothing" behaviour.
|
||||
int ReadMaxCount(uint32_t itemID) {
|
||||
const uint8_t *record = PeekItemRecord(itemID);
|
||||
if (record == nullptr) {
|
||||
Item::Data::WarmCache(itemID);
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
return -1;
|
||||
}
|
||||
const int maxCount = *reinterpret_cast<const int *>(
|
||||
return *reinterpret_cast<const int *>(
|
||||
record + Offsets::OFF_ITEMSTATS_MAX_COUNT);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
|
||||
Game::Lua::PushNil(L); // category (vanilla has none)
|
||||
Game::Lua::PushNumber(L, 0.0); // equippedCount (not tracked)
|
||||
return 3;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// `GetItemUniqueness(itemLocation)` shape: `(limitCategory, limitMax)`.
|
||||
static int __fastcall Script_GetItemUniqueness(void *L) {
|
||||
if (!Item::Location::IsLocationArg(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (!Item::Location::IsLocationArg(L, 1))
|
||||
return 0;
|
||||
auto *item = Item::Location::Resolve(L, 1);
|
||||
if (item == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (item == nullptr)
|
||||
return 0;
|
||||
const int itemID = Item::ID::FromCGItem(item);
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
return PushFromItemID(L, static_cast<uint32_t>(itemID));
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
|
||||
if (maxCount < 0)
|
||||
return 0; // cache miss — modern API documents "MayReturnNothing"
|
||||
Game::Lua::PushNumber(L, 0.0); // limitCategory (vanilla has no LimitCategory.dbc)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `GetItemUniquenessByID(itemID)` shape:
|
||||
// `(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`.
|
||||
static int __fastcall Script_GetItemUniquenessByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
const int itemID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (itemID <= 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
return PushFromItemID(L, static_cast<uint32_t>(itemID));
|
||||
if (itemID <= 0)
|
||||
return 0;
|
||||
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
|
||||
if (maxCount < 0)
|
||||
return 0;
|
||||
Game::Lua::PushBool(L, maxCount > 0);
|
||||
Game::Lua::PushNil(L); // limitCategoryName (no categories in vanilla)
|
||||
if (maxCount > 0)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
|
||||
else
|
||||
Game::Lua::PushNil(L); // limitCategoryCount — nil when not limited
|
||||
Game::Lua::PushNil(L); // limitCategoryID
|
||||
return 4;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
|
||||
+82
-54
@@ -11,9 +11,12 @@
|
||||
// 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.UseItemByName(itemInfo)` — finds the first item in the
|
||||
// player's bags matching `itemInfo` (itemID, link, or name) and uses
|
||||
// it via the engine's `CGItem::UseItem` primitive.
|
||||
// `C_Item.UseItemByName(itemInfo [, unit])` — finds the first item in
|
||||
// the player's bags matching `itemInfo` (itemID, link, or name) and
|
||||
// uses it via the engine's `CGItem::UseItem` primitive. Optional
|
||||
// `unit` is a unit token (`"player"`, `"target"`, `"focus"`, …)
|
||||
// passed to spell-cast items (scrolls, traps, on-use targeted
|
||||
// effects) as the cast target.
|
||||
//
|
||||
// Same structure 3.3.5's `Script_UseItemByName` uses: locate the item
|
||||
// directly, then hand it to the engine's by-item-pointer use call.
|
||||
@@ -25,91 +28,116 @@
|
||||
// food / potion / hearthstone / scroll / quiver all route through
|
||||
// the same entry.
|
||||
//
|
||||
// Target arg: we pass nullptr / a zero GUID. The engine substitutes
|
||||
// the player itself for self-use items (hearthstone, food, potion);
|
||||
// for target-required items (scrolls, traps) the engine reads
|
||||
// cursor-target state at call time, which a Lua-side caller can
|
||||
// pre-set with `TargetUnit("...")`.
|
||||
// Target arg: `FUN_ITEM_USE`'s `param_1` is read as a `uint64_t *`
|
||||
// pointing at the target's GUID. The engine writes it through to the
|
||||
// "use item" event (`DAT_00c4c1d0`/`DAT_00c4c1d4` + event 0x122) for
|
||||
// spell-cast items, and into the cast-targeting helper at
|
||||
// `FUN_006e5a90`. For self-use items (hearthstone, food, potion) the
|
||||
// engine overwrites with the item's own GUID before dispatch, so a
|
||||
// stray target is harmless. We default to a zero GUID when the caller
|
||||
// omits `unit` — matches existing call sites.
|
||||
//
|
||||
// Returns nothing. Silently no-ops on bad input, item not found in
|
||||
// bags, item locked, on cooldown, etc.
|
||||
// bags, item locked, on cooldown, etc. An unrecognized `unit` string
|
||||
// is treated as "no target" rather than raising, matching arg-1's
|
||||
// "silently no-ops on bad input" contract.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string.h>
|
||||
#include <cstring>
|
||||
|
||||
namespace Item::Use {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
|
||||
int flag);
|
||||
|
||||
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);
|
||||
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
|
||||
|
||||
bool HasPrefix(const char *s, const char *prefix) {
|
||||
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
|
||||
}
|
||||
|
||||
// Returns the matching CGItem* in `*outItem` on hit; mirrors the
|
||||
// `Item::Equipment::FindItemInBags` walk but skips bag/slot output
|
||||
// since `FUN_ITEM_USE` operates directly on the CGItem pointer.
|
||||
bool FindItemInBags(void *L, const Item::Arg::Resolved &arg, const uint8_t **outItem) {
|
||||
for (int bag = 0; bag <= 4; ++bag) {
|
||||
const int slotCount = Item::Location::GetBagSlotCount(bag);
|
||||
for (int slot = 1; slot <= slotCount; ++slot) {
|
||||
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
|
||||
if (item == nullptr) continue;
|
||||
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
|
||||
// …); returns a pointer at the first non-"target" character.
|
||||
const char *SkipTargetSuffixes(const char *s) {
|
||||
while (HasPrefix(s, "target"))
|
||||
s += 6;
|
||||
return s;
|
||||
}
|
||||
|
||||
const int id = Item::ID::FromCGItem(item);
|
||||
if (id == 0) continue;
|
||||
|
||||
if (arg.itemID > 0) {
|
||||
if (id == arg.itemID) {
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
if (record == nullptr) continue;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr) continue;
|
||||
if (_stricmp(name, arg.name) == 0) {
|
||||
*outItem = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Predicate mirror of the engine's token grammar inside
|
||||
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
|
||||
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
|
||||
// engine raises a Lua error on unknown input, so we gate before
|
||||
// dispatching to keep our "silently no-ops on bad input" contract.
|
||||
//
|
||||
// Every base token may be followed by zero or more `target` suffixes
|
||||
// (`playertarget`, `party1targettarget`, …) which the engine's
|
||||
// resolver walks via `LAB_005159d3..0x00515A2C`.
|
||||
bool MatchBase(const char *s, const char *base, bool requireDigits) {
|
||||
if (!HasPrefix(s, base))
|
||||
return false;
|
||||
const char *rest = s + std::strlen(base);
|
||||
if (requireDigits) {
|
||||
const char *p = rest;
|
||||
while (*p >= '0' && *p <= '9')
|
||||
++p;
|
||||
if (p == rest)
|
||||
return false;
|
||||
rest = p;
|
||||
}
|
||||
return *SkipTargetSuffixes(rest) == '\0';
|
||||
}
|
||||
|
||||
bool LooksLikeToken(const char *s) {
|
||||
if (s == nullptr || *s == '\0')
|
||||
return false;
|
||||
// Order matters for shared roots: longer prefix first
|
||||
// (`partypet`/`raidpet` before `party`/`raid`).
|
||||
if (MatchBase(s, "partypet", true)) return true;
|
||||
if (MatchBase(s, "raidpet", true)) return true;
|
||||
if (MatchBase(s, "party", true)) return true;
|
||||
if (MatchBase(s, "raid", true)) return true;
|
||||
if (MatchBase(s, "nameplate", true)) return true;
|
||||
if (MatchBase(s, "mouseover", false)) return true;
|
||||
if (MatchBase(s, "player", false)) return true;
|
||||
if (MatchBase(s, "target", false)) return true;
|
||||
if (MatchBase(s, "focus", false)) return true;
|
||||
if (MatchBase(s, "npc", false)) return true;
|
||||
if (MatchBase(s, "pet", false)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t ResolveUnitGuid(void *L, int idx) {
|
||||
if (!Game::Lua::IsString(L, idx))
|
||||
return 0;
|
||||
const char *token = Game::Lua::ToString(L, idx);
|
||||
if (!LooksLikeToken(token))
|
||||
return 0;
|
||||
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Item_UseItemByName(void *L) {
|
||||
const auto arg = Item::Arg::Resolve(L, 1);
|
||||
if (arg.itemID <= 0 && arg.name == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint8_t *cgItem = nullptr;
|
||||
if (!FindItemInBags(L, arg, &cgItem)) {
|
||||
Item::Location::ByGUIDResult found;
|
||||
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint64_t zeroTarget = 0;
|
||||
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
|
||||
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
|
||||
useItem(cgItem, &zeroTarget, 0);
|
||||
useItem(found.item, &targetGuid, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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.UseAtCursor(itemInfo)` — use the named item at the player's
|
||||
// current cursor world position. For items whose on-use spell is
|
||||
// ground-target (Iron Grenade, demolition charges, etc.), bypasses
|
||||
// the manual click on the AoE reticle that the engine would
|
||||
// otherwise require.
|
||||
//
|
||||
// Mirrors `C_Item.UseItemByName`'s bag-walk + `FUN_ITEM_USE` dispatch
|
||||
// — the engine's item-use path eventually hands the on-use spell to
|
||||
// the same cast dispatcher that ground-target spells go through, so
|
||||
// placement-mode activation works identically. After firing the
|
||||
// item, `Spell::AtCursor::Resolve()` commits the placement at cursor.
|
||||
//
|
||||
// Returns `true` when the cursor-placement leg landed; `false` for
|
||||
// items that aren't ground-target (the item still fires, just with
|
||||
// no implicit target), unparseable input, item-not-in-bags, etc.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Location.h"
|
||||
#include "spell/AtCursor.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::UseAtCursor {
|
||||
|
||||
namespace {
|
||||
|
||||
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
|
||||
int flag);
|
||||
|
||||
int __fastcall Script_C_Item_UseAtCursor(void *L) {
|
||||
const auto arg = Item::Arg::Resolve(L, 1);
|
||||
if (arg.itemID <= 0 && arg.name == nullptr) {
|
||||
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::Resolve();
|
||||
Game::Lua::PushBool(L, placed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Item", "UseAtCursor",
|
||||
&Script_C_Item_UseAtCursor);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Item::UseAtCursor
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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/>.
|
||||
|
||||
// `IsLoggedIn()` — exposed on both Lua states (glue and in-game).
|
||||
// Returns `true` once a character has been loaded *and identified*
|
||||
// (GUID populated), `false` on glue, pre-load, and the brief window
|
||||
// between struct allocation and character-data unpack.
|
||||
//
|
||||
// Check is two-step:
|
||||
// 1. `[VAR_LOCAL_PLAYER_PTR]` non-NULL — the player aggregator
|
||||
// struct has been allocated
|
||||
// 2. `*(uint64*)(player + OFF_LOCAL_PLAYER_GUID)` non-zero — the
|
||||
// character's GUID has been populated into the struct
|
||||
//
|
||||
// The pointer alone is too lax: the engine allocates the struct
|
||||
// shortly before the character data lands, so there's a window where
|
||||
// the pointer is non-NULL but the GUID is still zero. Most addons
|
||||
// that check `IsLoggedIn` go straight to `UnitGUID("player")` next,
|
||||
// and we don't want them to see GUID==nil right after IsLoggedIn
|
||||
// reported true.
|
||||
//
|
||||
// 3.3.5 uses a single byte flag at `[0x00BD0793]` cleared on world
|
||||
// entry — we couldn't pin down the analogous 1.12 flag (the engine
|
||||
// path that signals event 253 wires through CVar-init machinery and
|
||||
// doesn't map cleanly across the binary). The pointer+GUID check is
|
||||
// the same effective signal: both flip together at the moment the
|
||||
// player object is ready, and stay stable across zone transitions.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::LoginStatus {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_IsLoggedIn(void *L) {
|
||||
auto *player = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_LOCAL_PLAYER_PTR);
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const uint64_t guid = *reinterpret_cast<const uint64_t *>(
|
||||
player + Offsets::OFF_LOCAL_PLAYER_GUID);
|
||||
Game::Lua::PushBool(L, guid != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterInGame() {
|
||||
Game::Lua::RegisterGlobalFunction("IsLoggedIn", &Script_IsLoggedIn);
|
||||
}
|
||||
|
||||
void RegisterGlue() {
|
||||
Game::Lua::RegisterGlueFunction("IsLoggedIn", &Script_IsLoggedIn);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
|
||||
static const Game::GlueModuleAutoRegister _gluereg{&RegisterGlue};
|
||||
|
||||
} // namespace Player::LoginStatus
|
||||
+155
-1
@@ -40,15 +40,156 @@ inline const uint8_t *Peek(uint32_t questID) {
|
||||
}
|
||||
|
||||
// Field offsets within the data block returned by `Lookup`.
|
||||
constexpr int OFF_TITLE = 0x9C;
|
||||
//
|
||||
// Derived from the engine's own `Script_GetQuestLog*` accessors at
|
||||
// `0x004DF930..0x004E12B0`. Each accessor calls `_GetRecord` then
|
||||
// reads from one of these offsets; the existing comment block on each
|
||||
// constant identifies the source function. See CLAUDE.md's
|
||||
// "Quest static-info cache" section for the full trace.
|
||||
|
||||
// Numeric metadata fields, in wire order from `SMSG_QUEST_QUERY_RESPONSE`.
|
||||
//
|
||||
// The cache layout mirrors the packet's field order — the deserializer
|
||||
// at the SMSG handler unpacks 15 uint32 fields at +0x00..+0x38 before
|
||||
// the reward/choice item arrays. Names and semantics cross-referenced
|
||||
// from the in-binary serializer (`FUN_006DBCEC`) plus the canonical
|
||||
// vanilla 1.12 packet structure documented by vmangos/cmangos emulator
|
||||
// projects. Fields without a direct binary-side accessor (so just
|
||||
// known from wire-format alignment) are marked **(hypothesis)** —
|
||||
// they should be verified empirically before being relied on for
|
||||
// gameplay logic.
|
||||
//
|
||||
// QuestId — uint32, echo of the questID this entry caches.
|
||||
constexpr int OFF_QUEST_ID = 0x00;
|
||||
// QuestMethod — uint32. Server-side classification (typically `2` for
|
||||
// active-and-available, `0` for unavailable). Vanilla doesn't use
|
||||
// this for gameplay; it's stored but no engine accessor reads it.
|
||||
// **(hypothesis)**
|
||||
constexpr int OFF_QUEST_METHOD = 0x04;
|
||||
// Quest level — int32. Source: `FUN_004df340` (called by
|
||||
// `Script_GetQuestLogTitle`) does `mov eax, [block + 0x08]` and
|
||||
// pushes it as `level`.
|
||||
constexpr int OFF_LEVEL = 0x08;
|
||||
// ZoneOrSort — int32. The packet's `ZoneOrSort` field, used by the
|
||||
// quest log UI to group entries under headers:
|
||||
// - Positive = `AreaTable.dbc` zone areaID (e.g. 132 = Coldridge
|
||||
// Valley, 12 = Elwynn Forest)
|
||||
// - Negative = `-QuestSort.dbc` row (e.g. -263 = "Druid" sort)
|
||||
// `FUN_004dee60` reads this directly and matches it against
|
||||
// `VAR_QUEST_LOG_CATEGORY_TABLE` to find the collapsible header index.
|
||||
constexpr int OFF_ZONE_OR_SORT = 0x0C;
|
||||
// QuestInfo.dbc row — uint32 row index into the small DBC at
|
||||
// `0x00C0D9CC` (records) / `0x00C0D9D0` (count). Each record is
|
||||
// `{id@+0, char *name[locale]@+4}`; the localized name is the
|
||||
// quest-type tag string ("Dungeon", "Raid", "Group", "PvP", "Elite"
|
||||
// — modern WoW's `frequency`/`questTag`). Source: `FUN_004df2a0`.
|
||||
constexpr int OFF_QUEST_INFO_ROW = 0x10;
|
||||
// +0x14, +0x18, +0x1C, +0x20, +0x24 — five uint32 cache slots that
|
||||
// SMSG_QUEST_QUERY_RESPONSE in vanilla 1.12 doesn't populate. The
|
||||
// struct has them (the serializer at `0x006DBCEC` writes them as part
|
||||
// of the WDB save / network shape), but the response handler leaves
|
||||
// them zero-filled. Verified empirically across three diverse quests
|
||||
// (race-restricted starter, dungeon, authored-timer delivery) — all
|
||||
// five slots zero in every case.
|
||||
//
|
||||
// Wire-format docs (vmangos / cmangos emulator quest_template) name
|
||||
// them SuggestedPlayers, LimitTime, RequiredRaces, RequiredSkill,
|
||||
// RequiredSkillValue respectively, but the vanilla server *enforces*
|
||||
// these constraints server-side and filters quests before broadcasting
|
||||
// them, so the client never needs the data. Addons that want race /
|
||||
// class / skill / timer info must source from an external scraped DB
|
||||
// (pfQuest-style). Constants intentionally omitted — no use case.
|
||||
//
|
||||
// Signed money delta — int32. Positive = reward copper, negative =
|
||||
// `-required` copper. The engine has two accessors that share this
|
||||
// field with complementary sign:
|
||||
// - `Script_GetQuestLogRewardMoney` returns max(0, +money)
|
||||
// - `Script_GetQuestLogRequiredMoney` returns max(0, -money)
|
||||
constexpr int OFF_MONEY_DELTA = 0x28;
|
||||
// Level-cap reward money bonus — int32. Added to the reward when
|
||||
// the player's level is ≥ 60. Source: `Script_GetQuestLogRewardMoney`
|
||||
// does `cmp [player.unit + 0x70], 0x3C` (level >= 60) then
|
||||
// `add edx, [block + 0x2C]`.
|
||||
constexpr int OFF_LEVEL_CAP_BONUS_MONEY = 0x2C;
|
||||
// Reward spellID — int32. 0 = no spell reward. Source:
|
||||
// `Script_GetQuestLogRewardSpell` does `mov eax, [block + 0x30]` then
|
||||
// resolves the SpellIcon path and locale-applied name via `Spell.dbc`.
|
||||
// This is a spell *learned by the player* on completion (recipes,
|
||||
// taught spells); not to be confused with `OFF_SRC_ITEM_ID` which
|
||||
// is the item the questgiver hands over at accept-time.
|
||||
constexpr int OFF_REWARD_SPELL_ID = 0x30;
|
||||
// SrcItemId — itemID given to the player when the quest is accepted.
|
||||
// 0 = no source item. The questgiver hands this item over so the
|
||||
// player can complete the quest objective (e.g. "Read the Verdant
|
||||
// Sigil" gives item 9580; player uses it, then turns the same item
|
||||
// back in).
|
||||
//
|
||||
// Empirically derived (the offset was originally interpreted as
|
||||
// `RewardSpellCast` from one emulator wire-order variant, but a
|
||||
// real-world dump of quest 3120 stored `9580` here while the quest's
|
||||
// own item objective also references item 9580 — consistent with
|
||||
// SrcItemId semantics, not with a spell-cast-at-turn-in).
|
||||
constexpr int OFF_SRC_ITEM_ID = 0x34;
|
||||
// Quest flags — uint32. Bit definitions match modern WoW's
|
||||
// `QUEST_FLAGS_*` enum, of which only bit 3 (`0x08` SHARABLE) is
|
||||
// confirmed-tested by the engine (`Script_GetQuestLogPushable`'s
|
||||
// `test byte ptr [block + 0x38], 0x08`). Other bits are presumed
|
||||
// stored but unverified.
|
||||
constexpr int OFF_QUEST_FLAGS = 0x38;
|
||||
// Reward and choice item arrays — up to 4 reward and 6 choice
|
||||
// itemIDs stored inline (4 bytes each). 0 in any slot means
|
||||
// "no item in this slot". Used by `GetQuestLogItemLink` /
|
||||
// `GetQuestLogItemID`.
|
||||
//
|
||||
// Each itemID array is paired with a parallel uint32 count array
|
||||
// (the "how many of this item" reward). Layout in cache memory:
|
||||
// +0x3C RewardItems[4]
|
||||
// +0x4C RewardItemsCount[4]
|
||||
// +0x5C ChoiceItems[6]
|
||||
// +0x74 ChoiceItemsCount[6]
|
||||
// Verified from `Script_GetQuestLogRewardInfo` (`fild [block + slot*4 + 0x4C]`
|
||||
// for numItems) and `Script_GetQuestLogChoiceInfo` (`fild [block + slot*4 + 0x74]`).
|
||||
constexpr int OFF_REWARD_ITEMS = 0x3C;
|
||||
constexpr int OFF_REWARD_ITEMS_COUNT = 0x4C;
|
||||
constexpr int OFF_CHOICE_ITEMS = 0x5C;
|
||||
constexpr int OFF_CHOICE_ITEMS_COUNT = 0x74;
|
||||
constexpr int REWARD_COUNT = 4;
|
||||
constexpr int CHOICE_COUNT = 6;
|
||||
// Point-of-interest marker — server-supplied map coordinate hint
|
||||
// used by the questgiver objective panel to display "go here" arrows
|
||||
// in modern WoW. Set on quests that have a designated location;
|
||||
// 0/0/0 = no POI. Read sequentially by the serializer at
|
||||
// `0x006DBE5B..0x006DBE8E` using mixed integer + float wire methods.
|
||||
// **(hypothesis)**
|
||||
constexpr int OFF_POI_MAP_ID = 0x8C; // uint32
|
||||
constexpr int OFF_POI_X = 0x90; // float
|
||||
constexpr int OFF_POI_Y = 0x94; // float
|
||||
constexpr int OFF_POI_OPT = 0x98; // uint32 — display option, or doubles as title length
|
||||
constexpr int OFF_TITLE = 0x9C;
|
||||
// Inline narrative text — both locale-applied and printf-style
|
||||
// format-expanded by the engine's variable substituter at
|
||||
// `0x00506F70` (handles tokens like `$N` for player name, `$C` for
|
||||
// class, etc.). Source: `Script_GetQuestLogQuestText` reads both,
|
||||
// pushes `description` first then `objectives`.
|
||||
//
|
||||
// We deliberately expose the *raw* inline bytes, not the format-
|
||||
// expanded version, because the substituter wants a 1024-byte
|
||||
// scratch buffer and the substitutions (player name, class) belong
|
||||
// in the addon layer anyway. Addons can run `string.gsub` if they
|
||||
// need the runtime values, otherwise raw text is more useful for
|
||||
// guides / databases.
|
||||
constexpr int OFF_OBJECTIVES = 0x29C;
|
||||
constexpr int OFF_DESCRIPTION = 0xA9C;
|
||||
// Completion text — the "talk to me now" body shown on the
|
||||
// quest-reward UI panel after objectives are met. 0x200 (512) bytes
|
||||
// inline. Modern API exposes this as `GetQuestLogCompletionText()`.
|
||||
// Confirmed by the cache copy at `0x00562E1F..0x00562E36` (a 0x200-byte
|
||||
// memcpy from src+0x129C to dst+0x129C) and by the serializer at
|
||||
// `0x006DBEBE` (`lea eax, [edi+0x129C]` followed by the string-writer
|
||||
// call). Vanilla LeaderBoard reads from this offset for some override
|
||||
// cases but the engine never directly exposes the text to Lua —
|
||||
// `Script_GetQuestLogQuestText` returns description + objectives only.
|
||||
constexpr int OFF_COMPLETION_TEXT = 0x129C;
|
||||
// Objective arrays — four uint32[4] parallel slabs carrying
|
||||
// SMSG_QUEST_QUERY_RESPONSE's RequiredNPCOrGo / Count / Item / Count
|
||||
// fields verbatim. NPC-or-GO is **signed**: positive = creature
|
||||
@@ -66,5 +207,18 @@ constexpr int OFF_REQUIRED_NPC_OR_GO_COUNT = 0x14AC; // uint32[4]
|
||||
constexpr int OFF_REQUIRED_ITEM = 0x14BC; // uint32[4]
|
||||
constexpr int OFF_REQUIRED_ITEM_COUNT = 0x14CC; // uint32[4]
|
||||
constexpr int OBJECTIVE_COUNT = 4;
|
||||
// Per-objective override text — array of 4 inline char buffers,
|
||||
// 256 (0x100) bytes per entry, indexed in objective declaration
|
||||
// order (NPC/GO slots fill first, then item slots — same order the
|
||||
// LeaderBoard accessor enumerates). Empty string = no override
|
||||
// (engine auto-formats as `"<NPC name>: X/Y"`); otherwise the
|
||||
// engine displays the buffer verbatim. Confirmed by the
|
||||
// `shl ecx, 8; mov dl, [block + ecx + 0x14DC]` access pattern in
|
||||
// `Script_GetQuestLogLeaderBoard` and by the four sequential reads
|
||||
// in the packet serializer at `0x006DBF17..0x006DBF41` (0x14DC,
|
||||
// 0x15DC, 0x16DC, 0x17DC).
|
||||
constexpr int OFF_OBJECTIVE_TEXT = 0x14DC;
|
||||
constexpr int OBJECTIVE_TEXT_STRIDE = 0x100;
|
||||
constexpr int OBJECTIVE_TEXT_COUNT = 4;
|
||||
|
||||
} // namespace Quest::Cache
|
||||
|
||||
+4
-7
@@ -31,12 +31,10 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
|
||||
// `ret 8` cleans both args. We encode the questID into `userData` at
|
||||
// request time so the callback knows which quest completed.
|
||||
static void __stdcall QuestLoadCallback(void *userData, int success) {
|
||||
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
|
||||
if (slot < 0)
|
||||
return;
|
||||
const auto questID =
|
||||
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
Event::Custom::Fire(slot, "%d%d", questID, static_cast<int>(success != 0));
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
|
||||
questID, success != 0);
|
||||
}
|
||||
|
||||
static void RequestAndMaybeNotify(uint32_t questID) {
|
||||
@@ -47,9 +45,8 @@ static void RequestAndMaybeNotify(uint32_t questID) {
|
||||
// invoking our callback. Fire the event ourselves so addons get the
|
||||
// notification regardless of cache state, matching modern semantics.
|
||||
if (wasCached) {
|
||||
const int slot = Event::Custom::Lookup(kQuestDataLoadResult);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", static_cast<int>(questID), 1);
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
|
||||
static_cast<int>(questID), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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_QuestLog.GetQuestDetails(questID)` — single accessor that returns
|
||||
// everything we know how to read from the quest static-info cache as a
|
||||
// Lua table. Powers quest-helper-style addons that want one round-trip
|
||||
// instead of N separate `C_QuestLog.GetTitleForQuestID` / etc. calls.
|
||||
//
|
||||
// Returns `nil` if the quest isn't cached. Caller should pair with
|
||||
// `C_QuestLog.RequestLoadQuestByID` + listen for `QUEST_DATA_LOAD_RESULT`.
|
||||
// All offsets read here come from the engine's own `Script_GetQuestLog*`
|
||||
// accessors — see `Cache.h` for the per-field derivation.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "quest/Cache.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Quest::Details {
|
||||
|
||||
namespace {
|
||||
|
||||
// QuestInfo.dbc — 12-row table holding the localized quest-type tags
|
||||
// ("Dungeon" / "Raid" / "Group" / "PvP" / "Elite"). Address pair from
|
||||
// `docs/DBCs.md`.
|
||||
constexpr uintptr_t VAR_QUESTINFO_DBC_RECORDS = 0x00C0D9CC;
|
||||
constexpr uintptr_t VAR_QUESTINFO_DBC_COUNT = 0x00C0D9D0;
|
||||
|
||||
const char *LookupQuestType(uint32_t row) {
|
||||
if (row == 0)
|
||||
return nullptr;
|
||||
const int count = *reinterpret_cast<const int *>(VAR_QUESTINFO_DBC_COUNT);
|
||||
if (count <= 0 || row > static_cast<uint32_t>(count))
|
||||
return nullptr;
|
||||
const uint8_t *const *records =
|
||||
*reinterpret_cast<const uint8_t *const *const *>(
|
||||
VAR_QUESTINFO_DBC_RECORDS);
|
||||
if (!records)
|
||||
return nullptr;
|
||||
const uint8_t *record = records[row];
|
||||
if (!record)
|
||||
return nullptr;
|
||||
const int localeIdx =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
if (localeIdx < 0 || localeIdx > 8)
|
||||
return nullptr;
|
||||
auto *names = reinterpret_cast<const char *const *>(record + 4);
|
||||
return names[localeIdx];
|
||||
}
|
||||
|
||||
// Pushes an array of `{id=itemID, count=N}` tables for each non-zero
|
||||
// reward slot. `ids[count]` and `counts[count]` are parallel arrays
|
||||
// in the cache block. Leaves exactly one table on top of the stack.
|
||||
void PushItemArray(void *L, const uint32_t *ids, const uint32_t *counts,
|
||||
int count) {
|
||||
Game::Lua::NewTable(L);
|
||||
int next = 1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (ids[i] == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(next++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(ids[i]));
|
||||
Game::Lua::SetFieldNumber(L, "count", static_cast<double>(counts[i]));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-objective override text — the 4-slot array at `+0x14DC` with
|
||||
// 256-byte stride. Index in objective-declaration order (NPC/GO first,
|
||||
// then item, same enumeration the LeaderBoard accessor walks).
|
||||
// Returns empty string for slots past the cache's 4-entry capacity
|
||||
// or for slots with an empty buffer (engine auto-formats those).
|
||||
const char *OverrideTextFor(const uint8_t *data, int objectiveSlot) {
|
||||
if (objectiveSlot < 0 || objectiveSlot >= Quest::Cache::OBJECTIVE_TEXT_COUNT)
|
||||
return "";
|
||||
return reinterpret_cast<const char *>(
|
||||
data + Quest::Cache::OFF_OBJECTIVE_TEXT +
|
||||
objectiveSlot * Quest::Cache::OBJECTIVE_TEXT_STRIDE);
|
||||
}
|
||||
|
||||
void PushRequirements(void *L, const uint8_t *data) {
|
||||
Game::Lua::NewTable(L);
|
||||
int next = 1;
|
||||
int overrideSlot = 0;
|
||||
|
||||
auto *npcOrGo = reinterpret_cast<const int32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
|
||||
auto *npcOrGoCount = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO_COUNT);
|
||||
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
|
||||
const int32_t v = npcOrGo[i];
|
||||
if (v == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(next++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "kind", v > 0 ? "monster" : "object");
|
||||
Game::Lua::SetFieldNumber(L, "id",
|
||||
static_cast<double>(v > 0 ? v : -v));
|
||||
Game::Lua::SetFieldNumber(L, "count",
|
||||
static_cast<double>(npcOrGoCount[i]));
|
||||
Game::Lua::SetFieldString(L, "text",
|
||||
OverrideTextFor(data, overrideSlot++));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
auto *items = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_ITEM);
|
||||
auto *itemCounts = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_ITEM_COUNT);
|
||||
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
|
||||
if (items[i] == 0)
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(next++));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "kind", "item");
|
||||
Game::Lua::SetFieldNumber(L, "id", static_cast<double>(items[i]));
|
||||
Game::Lua::SetFieldNumber(L, "count",
|
||||
static_cast<double>(itemCounts[i]));
|
||||
Game::Lua::SetFieldString(L, "text",
|
||||
OverrideTextFor(data, overrideSlot++));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
}
|
||||
|
||||
int __fastcall Script_GetQuestDetails(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
|
||||
return 0;
|
||||
}
|
||||
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (questID <= 0)
|
||||
return 0;
|
||||
|
||||
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
|
||||
if (data == nullptr)
|
||||
return 0;
|
||||
|
||||
const auto u32 = [data](int off) {
|
||||
return *reinterpret_cast<const uint32_t *>(data + off);
|
||||
};
|
||||
const auto i32 = [data](int off) {
|
||||
return *reinterpret_cast<const int32_t *>(data + off);
|
||||
};
|
||||
const auto str = [data](int off) {
|
||||
return reinterpret_cast<const char *>(data + off);
|
||||
};
|
||||
|
||||
const auto f32 = [data](int off) {
|
||||
return *reinterpret_cast<const float *>(data + off);
|
||||
};
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "questID", static_cast<double>(questID));
|
||||
Game::Lua::SetFieldString(L, "title", str(Quest::Cache::OFF_TITLE));
|
||||
Game::Lua::SetFieldNumber(L, "level",
|
||||
static_cast<double>(i32(Quest::Cache::OFF_LEVEL)));
|
||||
|
||||
if (const char *tag =
|
||||
LookupQuestType(u32(Quest::Cache::OFF_QUEST_INFO_ROW))) {
|
||||
if (*tag != '\0')
|
||||
Game::Lua::SetFieldString(L, "questType", tag);
|
||||
}
|
||||
|
||||
// Race / class / skill / timer / suggested-players gates aren't
|
||||
// here intentionally — vanilla 1.12's quest-query response leaves
|
||||
// those cache slots (+0x14..+0x24) zero-filled; the server enforces
|
||||
// those constraints and only ships quests to clients who could
|
||||
// accept them. Addons that need that data must source it from an
|
||||
// external scraped DB (pfQuest-style).
|
||||
const int32_t money = i32(Quest::Cache::OFF_MONEY_DELTA);
|
||||
Game::Lua::SetFieldNumber(L, "rewardMoney",
|
||||
money > 0 ? static_cast<double>(money) : 0.0);
|
||||
Game::Lua::SetFieldNumber(L, "requiredMoney",
|
||||
money < 0 ? static_cast<double>(-money) : 0.0);
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "rewardMoneyAtMaxLevel",
|
||||
static_cast<double>(i32(Quest::Cache::OFF_LEVEL_CAP_BONUS_MONEY)));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "rewardSpellID",
|
||||
static_cast<double>(i32(Quest::Cache::OFF_REWARD_SPELL_ID)));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "srcItemID",
|
||||
static_cast<double>(u32(Quest::Cache::OFF_SRC_ITEM_ID)));
|
||||
|
||||
const uint32_t flags = u32(Quest::Cache::OFF_QUEST_FLAGS);
|
||||
Game::Lua::SetFieldNumber(L, "questFlags", static_cast<double>(flags));
|
||||
Game::Lua::SetFieldBool(L, "isSharable", (flags & 0x08) != 0);
|
||||
|
||||
Game::Lua::SetFieldString(L, "description",
|
||||
str(Quest::Cache::OFF_DESCRIPTION));
|
||||
Game::Lua::SetFieldString(L, "objectives",
|
||||
str(Quest::Cache::OFF_OBJECTIVES));
|
||||
Game::Lua::SetFieldString(L, "completionText",
|
||||
str(Quest::Cache::OFF_COMPLETION_TEXT));
|
||||
|
||||
if (const uint32_t mapID = u32(Quest::Cache::OFF_POI_MAP_ID)) {
|
||||
Game::Lua::PushString(L, "poi");
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(mapID));
|
||||
Game::Lua::SetFieldNumber(L, "x", static_cast<double>(f32(Quest::Cache::OFF_POI_X)));
|
||||
Game::Lua::SetFieldNumber(L, "y", static_cast<double>(f32(Quest::Cache::OFF_POI_Y)));
|
||||
Game::Lua::SetFieldNumber(L, "opt",
|
||||
static_cast<double>(u32(Quest::Cache::OFF_POI_OPT)));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, "rewardItems");
|
||||
PushItemArray(L,
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REWARD_ITEMS),
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REWARD_ITEMS_COUNT),
|
||||
Quest::Cache::REWARD_COUNT);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
Game::Lua::PushString(L, "choiceItems");
|
||||
PushItemArray(L,
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_CHOICE_ITEMS),
|
||||
reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_CHOICE_ITEMS_COUNT),
|
||||
Quest::Cache::CHOICE_COUNT);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
Game::Lua::PushString(L, "requirements");
|
||||
PushRequirements(L, data);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
|
||||
&Script_GetQuestDetails);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Quest::Details
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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/>.
|
||||
|
||||
// Suppress vanilla 1.12.1's "Your game interface files are corrupt"
|
||||
// fatal error so the engine accepts the in-memory file modifications
|
||||
// ClassicAPI makes via the file-read hook:
|
||||
//
|
||||
// - `src/bindings/Inject.cpp` splices into FrameXML's `Bindings.xml`
|
||||
// - `src/addons/Embedded.cpp` surfaces the embedded `!!!ClassicAPI`
|
||||
// addon
|
||||
//
|
||||
// Both diverge from what's on disk, so the cumulative hashes the
|
||||
// engine computes against `Interface\FrameXML.sig` (global) and each
|
||||
// addon entry's stored signature (per-addon) no longer match. The
|
||||
// engine calls `FUN_00401560(10)` and tears down the process.
|
||||
//
|
||||
// We hook `FUN_00401560` (the fatal-error dispatcher) and drop calls
|
||||
// with code `10`. Other codes pass through to the original handler
|
||||
// unmolested — we don't want to mask legitimate fatal conditions
|
||||
// (login failures, connection drops, etc., which use other codes).
|
||||
//
|
||||
// Modded clients (Octo + TLS-bootloader, SuperWoW-style) usually
|
||||
// neutralize the integrity checks at startup; the engine never
|
||||
// reaches `FUN_00401560(10)` there and our hook is a harmless no-op.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Security::FrameXMLBypass {
|
||||
|
||||
namespace {
|
||||
|
||||
using FatalError_t = void(__fastcall *)(unsigned code);
|
||||
FatalError_t FatalError_o = nullptr;
|
||||
|
||||
void __fastcall FatalError_h(unsigned code) {
|
||||
if (code == 10)
|
||||
return;
|
||||
FatalError_o(code);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_FATAL_ERROR,
|
||||
reinterpret_cast<void *>(&FatalError_h),
|
||||
reinterpret_cast<void **>(&FatalError_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Security::FrameXMLBypass
|
||||
@@ -0,0 +1,199 @@
|
||||
// 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.CastAtCursor(spellID)` — cast a ground-target spell at
|
||||
// the player's current cursor world position, bypassing the manual
|
||||
// click on the AoE reticle.
|
||||
//
|
||||
// Implementation chains the engine's existing primitives:
|
||||
//
|
||||
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
|
||||
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
|
||||
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
|
||||
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
|
||||
// target spell, the engine enters placement mode at this point
|
||||
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
|
||||
// fires immediately.
|
||||
//
|
||||
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
|
||||
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
|
||||
// placement at the resulting world coords via
|
||||
// `FUN_COMMIT_PLACEMENT_COORDS`. Self-cancelling when the spell
|
||||
// isn't ground-target (unit-target placement → cancel) or when
|
||||
// the cursor isn't on terrain (over UI / off-screen → cancel).
|
||||
//
|
||||
// Returns `true` from `Resolve()` when we successfully placed at
|
||||
// cursor; `false` otherwise (no placement was needed, or cursor was
|
||||
// invalid). The cast itself proceeds either way — `Resolve()`'s
|
||||
// return tells the caller whether the cursor-placement leg landed.
|
||||
|
||||
#include "AtCursor.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::AtCursor {
|
||||
|
||||
namespace {
|
||||
|
||||
using RefreshCursorRaycast_t = void(__fastcall *)(void *clickInfo);
|
||||
using CommitPlacementCoords_t = unsigned(__fastcall *)(const float *xyz);
|
||||
using StopPlacement_t = void(__fastcall *)();
|
||||
|
||||
bool InWorld() {
|
||||
// Player resolves only when in-world. Same gate other modules
|
||||
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
|
||||
// engine state during loading screens or glue.
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn("player") != nullptr;
|
||||
}
|
||||
|
||||
void CancelPlacement() {
|
||||
auto fn = reinterpret_cast<StopPlacement_t>(Offsets::FUN_STOP_PLACEMENT);
|
||||
fn();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Resolve() {
|
||||
const uint32_t state = *reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_SPELL_PLACEMENT_STATE);
|
||||
if (state == 0)
|
||||
return false; // no placement active — nothing to commit
|
||||
|
||||
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.
|
||||
CancelPlacement();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!InWorld())
|
||||
return false;
|
||||
|
||||
void *clickInfo = *reinterpret_cast<void *const *>(
|
||||
Offsets::VAR_WORLDFRAME_CLICK_INFO);
|
||||
if (clickInfo == nullptr)
|
||||
return false;
|
||||
|
||||
// Re-raycast at the current cursor screen position. Writes the
|
||||
// new click type to `[clickInfo + OFF_CLICK_INFO_TYPE]` and the
|
||||
// hit world coords to `[clickInfo + OFF_CLICK_INFO_COORDS]`.
|
||||
auto refresh = reinterpret_cast<RefreshCursorRaycast_t>(
|
||||
Offsets::FUN_REFRESH_CURSOR_RAYCAST);
|
||||
refresh(clickInfo);
|
||||
|
||||
const uint32_t hitType = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<const uint8_t *>(clickInfo) + Offsets::OFF_CLICK_INFO_TYPE);
|
||||
if (hitType != 1) {
|
||||
// Cursor wasn't on terrain (over UI, off-screen, on a unit
|
||||
// we didn't ask for). Modern `[@cursor]` would error; we
|
||||
// chose silent cancel-and-return-false.
|
||||
CancelPlacement();
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Hand the spellID off as a numeric string — our `Spell::CastByID`
|
||||
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
|
||||
// input into the locale-resolved spell name internally, so the
|
||||
// engine treats it just like `CastSpellByName("Blizzard")`.
|
||||
char numBuf[16];
|
||||
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
|
||||
|
||||
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const bool placed = Resolve();
|
||||
Game::Lua::PushBool(L, placed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "CastAtCursor",
|
||||
&Script_C_Spell_CastAtCursor);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
// Macro-parser pattern: tag macros that call
|
||||
// `C_Spell.CastAtCursor(<spellID>)` with the spellID, so action-bar
|
||||
// UIs highlight the slot the same way they do for `/cast SpellName`.
|
||||
// The Lua call only accepts a numeric spellID, so the extractor just
|
||||
// reads a numeric literal — no name resolution needed.
|
||||
int ExtractCastAtCursorArg(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.CastAtCursor(", &ExtractCastAtCursorArg};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Spell::AtCursor
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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 Spell::AtCursor {
|
||||
|
||||
// Resolves the engine's in-flight ground-target placement at the
|
||||
// player's current cursor world position. Intended to be called
|
||||
// immediately after initiating a ground-target cast (spell or item)
|
||||
// to skip the manual click. Decision tree:
|
||||
//
|
||||
// 1. No placement active → return false (nothing to do)
|
||||
// 2. Placement is unit-target, not ground → cancel placement, return false
|
||||
// 3. Cursor not pointed at terrain → cancel placement, return false
|
||||
// 4. Cursor on terrain → commit at those world coords, return true
|
||||
//
|
||||
// Shared by `C_Spell.CastAtCursor` and `C_Item.UseAtCursor`. Safe to
|
||||
// call from any in-world context — internally gates on a non-NULL
|
||||
// click-info struct pointer.
|
||||
bool Resolve();
|
||||
|
||||
} // namespace Spell::AtCursor
|
||||
@@ -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
|
||||
+27
-130
@@ -42,6 +42,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -208,141 +209,37 @@ void RegisterLuaFunctions() {
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Macro parser hook
|
||||
// Macro parser pattern
|
||||
//
|
||||
// Without this hook, a macro whose only cast statement is
|
||||
// `CastSpellNoToggle("Shoot")` doesn't get tagged in the spell-state
|
||||
// cache with the resolved spellID, because the engine's parser
|
||||
// (`FUN_004EFE00`) only knows to look for `/cast` slash-aliases and
|
||||
// `CastSpellByName("...")`. `IsAutoRepeatAction(macroSlot)` then has
|
||||
// nothing to match against the active auto-repeat spell, so the slot
|
||||
// fails to highlight in action-bar UIs (pfUI etc.).
|
||||
//
|
||||
// We hook the parser, let the original run, and if it didn't recognize
|
||||
// any pattern (cache field stays 0), scan the body ourselves for our
|
||||
// function name and use the engine's own name→spellID resolver to
|
||||
// fill in the field.
|
||||
// Tags macros containing `CastSpellNoToggle("Name")` with the
|
||||
// resolved spellID, so `IsAutoRepeatAction(macroSlot)` reports
|
||||
// correctly and action-bar UIs (pfUI etc.) highlight the slot for
|
||||
// auto-repeat spells. `Spell::MacroPrimarySpell` owns the parser
|
||||
// hook and the line walk; we just declare our `(prefix, extractor)`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
using MacroParse_t = void(__fastcall *)(int macroEntry);
|
||||
MacroParse_t MacroParse_o = nullptr;
|
||||
|
||||
|
||||
// Scan a macro body for `CastSpellNoToggle("<name>")` and return the
|
||||
// resolved spellID, or 0 if no recognized invocation is present.
|
||||
// Mirrors the engine parser's "first hit wins, line-at-a-time" walk
|
||||
// order so behavior is consistent with how `/cast` and
|
||||
// `CastSpellByName` are matched.
|
||||
int ScanMacroBodyForOurPattern(const char *body) {
|
||||
static constexpr char kPattern[] = "CastSpellNoToggle(";
|
||||
static constexpr size_t kPatternLen = sizeof(kPattern) - 1;
|
||||
|
||||
const char *line = body;
|
||||
while (*line != '\0') {
|
||||
const char *eol = line;
|
||||
while (*eol != '\0' && *eol != '\n')
|
||||
++eol;
|
||||
|
||||
const char *p = line;
|
||||
const char *end = eol;
|
||||
while (p + kPatternLen <= end) {
|
||||
if (std::memcmp(p, kPattern, kPatternLen) == 0) {
|
||||
p += kPatternLen;
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
if (p < end && *p == '"') {
|
||||
++p;
|
||||
const char *q = p;
|
||||
while (q < end && *q != '"')
|
||||
++q;
|
||||
if (q < end) {
|
||||
const size_t n = static_cast<size_t>(q - p);
|
||||
if (n > 0 && n < 128) {
|
||||
char name[128];
|
||||
std::memcpy(name, p, n);
|
||||
name[n] = '\0';
|
||||
const int spellID = Spell::Arg::NameToSpellID(name);
|
||||
if (spellID > 0)
|
||||
return spellID;
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // matched our prefix on this line; don't double-match
|
||||
}
|
||||
++p;
|
||||
}
|
||||
|
||||
line = (*eol == '\n') ? eol + 1 : eol;
|
||||
}
|
||||
return 0;
|
||||
int ExtractCastSpellNoToggleArg(const char *p, const char *end) {
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
++p;
|
||||
if (p >= end || *p != '"')
|
||||
return 0;
|
||||
++p;
|
||||
const char *q = p;
|
||||
while (q < end && *q != '"')
|
||||
++q;
|
||||
if (q >= end)
|
||||
return 0;
|
||||
const size_t n = static_cast<size_t>(q - p);
|
||||
if (n == 0 || n >= 128)
|
||||
return 0;
|
||||
char name[128];
|
||||
std::memcpy(name, p, n);
|
||||
name[n] = '\0';
|
||||
return Spell::Arg::NameToSpellID(name);
|
||||
}
|
||||
|
||||
// The vanilla parser's per-line stack buffer is 256 bytes. When a
|
||||
// macro line exceeds that, the parser's local at `[EBP-4]` (the saved
|
||||
// entry pointer) and the saved return address get clobbered with body
|
||||
// bytes — observed in a crash with `entry@[EBP-4]` reading as ASCII
|
||||
// `"onTi"` (fragment of `"expirationTime"` from a long `/run` line).
|
||||
// The bound-check on the line-read function reads correctly in
|
||||
// Ghidra, so the overflow path is somewhere we couldn't isolate from
|
||||
// static analysis — we settle for a conservative pre-check that
|
||||
// skips the original entirely when any line is long enough to risk
|
||||
// the bug.
|
||||
//
|
||||
// 240 (not 256) leaves a safety margin: the parser writes a NUL
|
||||
// terminator and may walk one or two bytes past in its loop logic,
|
||||
// so we keep clear of the boundary. Macros that hit this branch
|
||||
// can't have a `/cast` line anyway (`/cast SpellName(Rank N)` is
|
||||
// well under 100 bytes), so losing the engine's `/cast` /
|
||||
// `CastSpellByName` tagging is no loss. We still run our own
|
||||
// `CastSpellNoToggle` pattern scan since it bound-checks its own
|
||||
// extraction.
|
||||
bool BodyHasLongLine(const char *body) {
|
||||
if (body == nullptr)
|
||||
return false;
|
||||
constexpr int kMaxSafeLineBytes = 240;
|
||||
int lineLen = 0;
|
||||
for (; *body != '\0'; ++body) {
|
||||
if (*body == '\n') {
|
||||
lineLen = 0;
|
||||
} else if (++lineLen > kMaxSafeLineBytes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void __fastcall MacroParse_h(int macroEntry) {
|
||||
const char *body = reinterpret_cast<const char *>(
|
||||
macroEntry + Offsets::OFF_MACRO_BODY);
|
||||
int *cacheField = reinterpret_cast<int *>(
|
||||
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
|
||||
|
||||
if (BodyHasLongLine(body)) {
|
||||
// Skip the engine parser entirely — it overflows on lines
|
||||
// longer than its 256-byte buffer. Mark the cache as "no
|
||||
// recognized cast" (0); our own scan below may still tag it
|
||||
// if it's a `CastSpellNoToggle("...")` macro.
|
||||
*cacheField = 0;
|
||||
const int spellID = ScanMacroBodyForOurPattern(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
return;
|
||||
}
|
||||
|
||||
MacroParse_o(macroEntry);
|
||||
|
||||
if (*cacheField != 0)
|
||||
return;
|
||||
|
||||
const int spellID = ScanMacroBodyForOurPattern(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
|
||||
reinterpret_cast<void *>(&MacroParse_h),
|
||||
reinterpret_cast<void **>(&MacroParse_o)};
|
||||
const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
|
||||
"CastSpellNoToggle(", &ExtractCastSpellNoToggleArg};
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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/>.
|
||||
|
||||
// Owns the hook on `FUN_MACRO_PARSE_PRIMARY_SPELL` and dispatches to
|
||||
// feature-module-registered `(prefix, extractor)` pairs. The engine's
|
||||
// parser only recognizes `/cast SpellName` and `CastSpellByName(...)`
|
||||
// — addons that define new cast wrappers (`CastSpellNoToggle`,
|
||||
// `C_Spell.CastAtCursor`, etc.) need their macro slots tagged with
|
||||
// the resolved spellID too, otherwise action-bar UIs lose
|
||||
// auto-repeat highlight and cooldown overlay.
|
||||
//
|
||||
// Each feature module owns an extractor that knows how to read the
|
||||
// arg shape for its specific function (quoted string, numeric
|
||||
// literal, etc.). Patterns auto-register at static-init via
|
||||
// `PatternAutoRegister`. The hook walks each macro line ONCE,
|
||||
// checking every registered prefix at every position; on a prefix
|
||||
// match it calls the extractor to resolve a spellID.
|
||||
//
|
||||
// Long-line overflow guard: the vanilla parser has a 256-byte
|
||||
// per-line stack buffer that overflows on `/run` lines longer than
|
||||
// that. We pre-check and skip the original entirely when any line
|
||||
// exceeds the safe threshold — the original's tagging is lost in
|
||||
// that case (no `/cast` on a 240+ byte line anyway) but our
|
||||
// patterns still run since they bound-check their own extraction.
|
||||
|
||||
#include "MacroPrimarySpell.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::MacroPrimarySpell {
|
||||
|
||||
namespace {
|
||||
|
||||
PatternAutoRegister *g_patternHead = nullptr;
|
||||
|
||||
using MacroParse_t = void(__fastcall *)(int macroEntry);
|
||||
MacroParse_t MacroParse_o = nullptr;
|
||||
|
||||
// Vanilla's parser uses a 256-byte stack buffer per line; bytes past
|
||||
// the buffer clobber the saved entry pointer at `[EBP-4]` and the
|
||||
// return address. 240 leaves a safety margin.
|
||||
constexpr int kMaxSafeLineBytes = 240;
|
||||
|
||||
bool BodyHasLongLine(const char *body) {
|
||||
if (body == nullptr)
|
||||
return false;
|
||||
int lineLen = 0;
|
||||
for (; *body != '\0'; ++body) {
|
||||
if (*body == '\n') {
|
||||
lineLen = 0;
|
||||
} else if (++lineLen > kMaxSafeLineBytes) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walks each line once. At every byte position, checks every
|
||||
// registered prefix; on first match, invokes the extractor. First
|
||||
// extractor that returns a positive spellID wins for the whole macro
|
||||
// — matches the engine parser's "first hit, line-at-a-time" walk.
|
||||
int ScanBody(const char *body) {
|
||||
if (body == nullptr)
|
||||
return 0;
|
||||
const char *line = body;
|
||||
while (*line != '\0') {
|
||||
const char *eol = line;
|
||||
while (*eol != '\0' && *eol != '\n')
|
||||
++eol;
|
||||
|
||||
for (const char *p = line; p < eol; ++p) {
|
||||
for (auto *node = g_patternHead; node != nullptr; node = node->next) {
|
||||
if (p + node->prefixLen > eol)
|
||||
continue;
|
||||
if (std::memcmp(p, node->prefix, node->prefixLen) != 0)
|
||||
continue;
|
||||
const int spellID = node->extract(p + node->prefixLen, eol);
|
||||
if (spellID > 0)
|
||||
return spellID;
|
||||
// Prefix matched but extraction failed (malformed
|
||||
// args). Don't keep checking other prefixes at this
|
||||
// position — they couldn't possibly also match here.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
line = (*eol == '\n') ? eol + 1 : eol;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void __fastcall MacroParse_h(int macroEntry) {
|
||||
const char *body = reinterpret_cast<const char *>(
|
||||
macroEntry + Offsets::OFF_MACRO_BODY);
|
||||
int *cacheField = reinterpret_cast<int *>(
|
||||
macroEntry + Offsets::OFF_MACRO_PRIMARY_SPELL);
|
||||
|
||||
if (BodyHasLongLine(body)) {
|
||||
*cacheField = 0;
|
||||
const int spellID = ScanBody(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
return;
|
||||
}
|
||||
|
||||
MacroParse_o(macroEntry);
|
||||
|
||||
if (*cacheField != 0)
|
||||
return;
|
||||
|
||||
const int spellID = ScanBody(body);
|
||||
if (spellID > 0)
|
||||
*cacheField = spellID;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_MACRO_PARSE_PRIMARY_SPELL,
|
||||
reinterpret_cast<void *>(&MacroParse_h),
|
||||
reinterpret_cast<void **>(&MacroParse_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
PatternAutoRegister::PatternAutoRegister(const char *p, ArgExtractor e)
|
||||
: prefix(p),
|
||||
prefixLen(static_cast<unsigned>(std::strlen(p))),
|
||||
extract(e),
|
||||
next(g_patternHead) {
|
||||
g_patternHead = this;
|
||||
}
|
||||
|
||||
} // namespace Spell::MacroPrimarySpell
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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 Spell::MacroPrimarySpell {
|
||||
|
||||
// Parses the argument(s) immediately after a known function-call
|
||||
// prefix. `p` points to the first byte past the closing `(` of the
|
||||
// prefix (so for `CastSpellNoToggle("Shoot")`, `p` would be at the
|
||||
// `"`). `end` is end-of-line. Returns the resolved spellID, or 0 if
|
||||
// extraction failed (malformed args, unknown spell, etc.).
|
||||
using ArgExtractor = int(*)(const char *p, const char *end);
|
||||
|
||||
// File-scope static auto-registers a `(prefix, extractor)` pair. Each
|
||||
// feature module that wants its Lua wrapper to tag macros declares:
|
||||
//
|
||||
// static const Spell::MacroPrimarySpell::PatternAutoRegister _r{
|
||||
// "CastSpellNoToggle(", &MyExtractor};
|
||||
//
|
||||
// The macro-parser hook walks every line once, checks each registered
|
||||
// prefix at each position, and on match invokes the extractor to
|
||||
// resolve a spellID. The resolved ID is written to the macro's
|
||||
// primary-spell cache field — drives auto-repeat highlight, cooldown
|
||||
// overlay, etc. in action-bar UIs (pfUI).
|
||||
struct PatternAutoRegister {
|
||||
PatternAutoRegister(const char *prefix, ArgExtractor extract);
|
||||
const char *prefix;
|
||||
unsigned prefixLen;
|
||||
ArgExtractor extract;
|
||||
PatternAutoRegister *next;
|
||||
};
|
||||
|
||||
} // namespace Spell::MacroPrimarySpell
|
||||
@@ -0,0 +1,90 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellMechanicByID(spellID)` -> (mechanicID, localizedName)
|
||||
//
|
||||
// Reads the spell-level Mechanic field straight out of Spell.dbc, so it
|
||||
// covers *every* spell the client knows — not just the player's
|
||||
// spellbook — with no caching or network round-trip (Spell.dbc is fully
|
||||
// resident from boot). This replaces hand-maintained spellID -> mechanic
|
||||
// tables (e.g. SuperCleveRoidMacros' `CCSpellMechanics`) that addons keep
|
||||
// solely because vanilla exposes no Lua reader for the field.
|
||||
//
|
||||
// Returns:
|
||||
// - nothing (nil) for an invalid / out-of-range spell ID
|
||||
// - (0, nil) for a known spell that carries no mechanic
|
||||
// - (mechanicID, name) otherwise. `name` is the enUS SpellMechanic.dbc
|
||||
// name (always English, locale-independent — it's a stable token
|
||||
// consumers match against). nil only if the mechanic has no record /
|
||||
// no English name; the numeric ID is still returned in that case.
|
||||
//
|
||||
// The mechanic numbering is WoW's standard SpellMechanic enum
|
||||
// (1 = Charm, 5 = Fear, 7 = Root, 12 = Stun, 17 = Polymorph, ...);
|
||||
// addons map those IDs to their own names as needed.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::Mechanic {
|
||||
|
||||
// SpellMechanic.dbc name — always the enUS (index 0) column, regardless
|
||||
// of client locale. The mechanic name is a stable token consumers key
|
||||
// off (`"rooted"`, `"stunned"`, …), so it must be identical on every
|
||||
// client; a localized value would break cross-locale string matching.
|
||||
// Vanilla only populates the enUS column of this table anyway. Returns
|
||||
// nullptr if the record is missing or the English slot is empty.
|
||||
static const char *MechanicName(uint32_t mechanicID) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELLMECHANIC_RECORDS,
|
||||
Offsets::VAR_SPELLMECHANIC_COUNT, mechanicID);
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLMECHANIC_NAME);
|
||||
const char *s = names[0]; // enUS
|
||||
return (s != nullptr && s[0] != '\0') ? s : nullptr;
|
||||
}
|
||||
|
||||
static int __fastcall Script_GetSpellMechanicByID(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return 0; // nil for invalid / out-of-range spell IDs
|
||||
|
||||
const uint32_t mechanicID =
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_MECHANIC);
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(mechanicID));
|
||||
|
||||
if (mechanicID == 0) {
|
||||
Game::Lua::PushNil(L); // known spell, no mechanic
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Locale name with enUS fallback (nullptr → nil if even English is
|
||||
// empty); the caller still gets the numeric ID either way.
|
||||
Game::Lua::PushString(L, MechanicName(mechanicID)); // pushstring(NULL) → pushnil
|
||||
return 2;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellMechanicByID",
|
||||
&Script_GetSpellMechanicByID);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::Mechanic
|
||||
@@ -0,0 +1,88 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#include "spell/Mod.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
namespace Spell::Mod {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// The local player's class SpellFamilyName, or 0 if it can't be resolved
|
||||
// (e.g. at the login screen). Gates the SpellMod family match — only
|
||||
// spells of the player's own class family receive the player's mods, so
|
||||
// a same-flag-bit spell of another family isn't falsely modified.
|
||||
uint32_t PlayerSpellFamily() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve("player"));
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
const uint8_t *cls = DBC::Record(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT, classByte);
|
||||
if (cls == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(cls + Offsets::OFF_CHRCLASSES_SPELL_FAMILY);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
float Apply(const uint8_t *spellRecord, int op, float base) {
|
||||
if (spellRecord == nullptr)
|
||||
return base;
|
||||
|
||||
const uint32_t familyName = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
|
||||
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
|
||||
if (familyName == 0 || (attrEx2 & Offsets::SPELL_ATTR_EX2_NO_SPELL_MODS) != 0)
|
||||
return base;
|
||||
if (familyName != PlayerSpellFamily())
|
||||
return base;
|
||||
|
||||
const uint64_t familyFlags = *reinterpret_cast<const uint64_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
|
||||
if (familyFlags == 0)
|
||||
return base;
|
||||
|
||||
const auto *flatTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_FLAT_TABLE);
|
||||
const auto *pctTable = reinterpret_cast<const uint8_t *>(Offsets::VAR_SPELLMOD_PCT_TABLE);
|
||||
const int opByteOffset = op * 4;
|
||||
|
||||
int flat = 0;
|
||||
int pct = 0;
|
||||
for (int slot = 0; slot < Offsets::SPELLMOD_SLOT_COUNT; ++slot) {
|
||||
if (((familyFlags >> slot) & 1ull) == 0)
|
||||
continue;
|
||||
const int byteOff = slot * Offsets::SPELLMOD_SLOT_STRIDE + opByteOffset;
|
||||
flat += *reinterpret_cast<const int *>(flatTable + byteOff);
|
||||
pct += *reinterpret_cast<const int *>(pctTable + byteOff);
|
||||
}
|
||||
if (flat == 0 && pct == 0)
|
||||
return base;
|
||||
|
||||
int pctTotal = pct + 100;
|
||||
if (pctTotal < 0)
|
||||
pctTotal = 0;
|
||||
return (base + static_cast<float>(flat)) * static_cast<float>(pctTotal) * 0.01f;
|
||||
}
|
||||
|
||||
} // namespace Spell::Mod
|
||||
@@ -0,0 +1,41 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// `Spell::Mod` — the engine's SpellMod (talent / item) system, for the
|
||||
// LOCAL PLAYER. Replicates `FUN_006e6b30` / `FUN_006e6bf0`: the player's
|
||||
// flat and percent modifiers are accumulated into two tables indexed
|
||||
// `[familyFlagBit][op]` (see `VAR_SPELLMOD_*` in Offsets.h). A spell's
|
||||
// value for a given op is modified by summing, over the bits set in the
|
||||
// spell's SpellFamilyFlags, the flat and percent entries, then applying
|
||||
// `value = (value + flat) * (100 + pct) * 0.01`.
|
||||
//
|
||||
// This underlies many spell properties — radius, cast time, mana cost,
|
||||
// duration, range, cooldown — each a distinct `op` (e.g.
|
||||
// `Offsets::SPELLMOD_OP_RADIUS`). The op numbering is the engine's
|
||||
// internal one; verify each against the binary before relying on it.
|
||||
//
|
||||
// Inherently local-player-only: the client tracks spell mods for the
|
||||
// player alone, so there's no way to know another caster's talents.
|
||||
namespace Spell::Mod {
|
||||
|
||||
// Returns `base` with the local player's modifiers for `op` applied to
|
||||
// `spellRecord` (a `Spell.dbc` record pointer). Returns `base` unchanged
|
||||
// when no modifier matches — wrong spell family, mods disabled on the
|
||||
// spell, the player can't be resolved, or no relevant talent/item.
|
||||
float Apply(const uint8_t *spellRecord, int op, float base);
|
||||
|
||||
} // namespace Spell::Mod
|
||||
@@ -0,0 +1,98 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellPowerCost(spellIdentifier)` -> SpellPowerCostInfo[]
|
||||
//
|
||||
// Returns an array of power-cost tables, or nil if the spell isn't found
|
||||
// or has no resource cost. Vanilla spells have exactly one power cost, so
|
||||
// the array always holds at most one entry.
|
||||
//
|
||||
// `cost` is the **effective** cost for the local player — base + level
|
||||
// scaling + ManaCostPercent-of-resource + the descriptor power-cost
|
||||
// modifiers + the cost SpellMod (op 14) — via the engine's own cost
|
||||
// helper `FUN_006e31b0` (the same value the engine charges). So talents
|
||||
// like reduced-cost spells are reflected. Per-entry fields:
|
||||
//
|
||||
// type Enum.PowerType (0 mana, 1 rage, 2 focus, 3 energy,
|
||||
// 4 happiness) — the spell's PowerType (+0x7C)
|
||||
// name power token ("MANA", "RAGE", …)
|
||||
// cost effective cost (see above)
|
||||
// minCost == cost; vanilla has no "optional" cost component
|
||||
// costPercent ManaCostPercent (+0x270) — the spell's %-of-base-
|
||||
// resource cost, or 0 for a flat cost
|
||||
// costPerSec 0 — vanilla doesn't expose a per-second channel cost
|
||||
// in these terms
|
||||
// requiredAuraID 0 — vanilla has no form/aura-conditional costs here
|
||||
// hasRequiredAura false
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "unit/Power.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Spell::PowerCost {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
|
||||
|
||||
constexpr int OFF_POWER_TYPE = 0x7C; // int PowerType
|
||||
constexpr int OFF_MANA_COST_PERCENT = 0x270; // int, % of base resource (0 = flat)
|
||||
|
||||
uint32_t EffectiveCost(int spellID) {
|
||||
return reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST)(spellID, 0);
|
||||
}
|
||||
|
||||
int __fastcall Script_GetSpellPowerCost(void *L) {
|
||||
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return 0; // nil — spell not found
|
||||
|
||||
const uint32_t cost = EffectiveCost(spellID);
|
||||
if (cost == 0xFFFFFFFF)
|
||||
return 0; // engine error (no player context, etc.)
|
||||
|
||||
const int powerType = *reinterpret_cast<const int *>(rec + OFF_POWER_TYPE);
|
||||
const int costPercent = *reinterpret_cast<const int *>(rec + OFF_MANA_COST_PERCENT);
|
||||
if (cost == 0 && costPercent == 0)
|
||||
return 0; // nil — no resource cost
|
||||
|
||||
// SpellPowerCostInfo[] — a one-element array: outer[1] = entry.
|
||||
Game::Lua::NewTable(L); // outer
|
||||
Game::Lua::PushNumber(L, 1.0); // key 1
|
||||
Game::Lua::NewTable(L); // entry (top)
|
||||
Game::Lua::SetFieldNumber(L, "type", static_cast<double>(powerType));
|
||||
Game::Lua::SetFieldString(L, "name", Unit::Power::PowerTypeToken(powerType));
|
||||
Game::Lua::SetFieldNumber(L, "cost", static_cast<double>(cost));
|
||||
Game::Lua::SetFieldNumber(L, "minCost", static_cast<double>(cost));
|
||||
Game::Lua::SetFieldNumber(L, "costPercent", static_cast<double>(costPercent));
|
||||
Game::Lua::SetFieldNumber(L, "costPerSec", 0);
|
||||
Game::Lua::SetFieldNumber(L, "requiredAuraID", 0);
|
||||
Game::Lua::SetFieldBool(L, "hasRequiredAura", false);
|
||||
Game::Lua::SetTable(L, -3); // outer[1] = entry
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellPowerCost", &Script_GetSpellPowerCost);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::PowerCost
|
||||
@@ -0,0 +1,120 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Spell.GetSpellRadius(spellID)` and the parallel
|
||||
// `GetSpellRadius(slot, bookType)` -> (baseRadius, modifiedRadius)
|
||||
//
|
||||
// Returns the spell's AOE radius in yards, or nil for a non-AOE spell
|
||||
// (no effect carries a radius). Two values:
|
||||
// - baseRadius — the raw SpellRadius.dbc value (caster-independent,
|
||||
// what an AOE tracker watching other units wants)
|
||||
// - modifiedRadius — baseRadius with the LOCAL PLAYER's spell modifiers
|
||||
// (talents / items, e.g. a Mage's Arctic Reach)
|
||||
// applied. Equal to baseRadius when the player has
|
||||
// no matching modifier.
|
||||
//
|
||||
// Two signatures mirroring the other dual-signature spell globals
|
||||
// (`GetSpellInfo`, `SpellHasRange`, etc.): the namespaced form takes a
|
||||
// modern spell *identifier* (numeric ID or name), the bare global takes
|
||||
// the vanilla `(slot, bookType)` spellbook position. `bookType` is the
|
||||
// `GetSpellName` convention: `"pet"` → pet book, anything else (or
|
||||
// omitted) → player book.
|
||||
//
|
||||
// baseRadius reads `Spell.dbc`'s per-effect `EffectRadiusIndex[3]` and
|
||||
// resolves each into `SpellRadius.dbc`, taking the largest base radius
|
||||
// across the three effects. No caster-level scaling (the engine's
|
||||
// FUN_006e6350 also adds `radiusPerLevel * casterLevel`, which needs a
|
||||
// unit context; modern `GetSpellRadius` reports the base value).
|
||||
//
|
||||
// modifiedRadius applies the local player's SpellMods for op 6 (radius)
|
||||
// via `Spell::Mod::Apply`. It is inherently local-player-only — the
|
||||
// client tracks spell mods for the player alone, so there's no way to
|
||||
// know another caster's talents.
|
||||
//
|
||||
// All DBCs / mod tables are resident from boot, so the lookup is
|
||||
// synchronous with no caching.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "spell/Mod.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::Radius {
|
||||
|
||||
// Largest base radius across the spell's effects, or a negative sentinel
|
||||
// (< 0) if no effect has a radius.
|
||||
static float MaxBaseRadius(const uint8_t *record) {
|
||||
float best = -1.0f;
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
|
||||
const int radiusIndex = *reinterpret_cast<const int *>(
|
||||
record + Offsets::OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX + i * 4);
|
||||
const uint8_t *rad = DBC::Record(Offsets::VAR_SPELL_RADIUS_RECORDS,
|
||||
Offsets::VAR_SPELL_RADIUS_COUNT,
|
||||
static_cast<uint32_t>(radiusIndex));
|
||||
if (rad == nullptr)
|
||||
continue;
|
||||
const float r = *reinterpret_cast<const float *>(rad + Offsets::OFF_SPELL_RADIUS_VALUE);
|
||||
if (r > best)
|
||||
best = r;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Pushes (baseRadius, modifiedRadius) for `spellID`, or nil (0 returns)
|
||||
// for an invalid / non-AOE spell.
|
||||
static int PushRadiusForSpellID(void *L, int spellID) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return 0; // nil
|
||||
const float base = MaxBaseRadius(record);
|
||||
if (base < 0.0f)
|
||||
return 0; // nil — not an AOE spell
|
||||
Game::Lua::PushNumber(L, static_cast<double>(base));
|
||||
Game::Lua::PushNumber(L,
|
||||
static_cast<double>(Spell::Mod::Apply(record, Offsets::SPELLMOD_OP_RADIUS, base)));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Spell.GetSpellRadius(spellID)` — modern spell-identifier form.
|
||||
static int __fastcall Script_C_Spell_GetSpellRadius(void *L) {
|
||||
return PushRadiusForSpellID(L, Spell::Arg::ResolveSpellID(L, 1));
|
||||
}
|
||||
|
||||
// `GetSpellRadius(slot, bookType)` — vanilla-style positional shape.
|
||||
static int __fastcall Script_GetSpellRadius(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0; // nil
|
||||
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
int bookType = 0;
|
||||
if (Game::Lua::IsString(L, 2)) {
|
||||
const char *s = Game::Lua::ToString(L, 2);
|
||||
if (s != nullptr && _stricmp(s, "pet") == 0)
|
||||
bookType = 1;
|
||||
}
|
||||
return PushRadiusForSpellID(L, Spell::Lookup::SpellbookSlotToID(slot, bookType));
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRadius",
|
||||
&Script_C_Spell_GetSpellRadius);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellRadius", &Script_GetSpellRadius);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::Radius
|
||||
+17
-8
@@ -25,11 +25,12 @@ namespace Spell::Usable {
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
|
||||
|
||||
// Spell.dbc record field offsets we read for usability. Fully documented
|
||||
// in CLAUDE.md.
|
||||
constexpr int OFF_SPELL_POWER_TYPE = 0x7C; // int8: 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness
|
||||
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost
|
||||
constexpr int OFF_SPELL_MANA_COST = 0x80; // u32 base cost (fallback only)
|
||||
|
||||
// Returns true if the player knows `spellID` per the engine's spell-
|
||||
// knowledge bitmap at `[VAR_PLAYER_SPELL_BITMAP]`. Same check
|
||||
@@ -135,8 +136,9 @@ bool HasReagents(void *L, const uint8_t *record) {
|
||||
// 3. Player descriptor is reachable (post-login).
|
||||
// 4. Player is alive (HEALTH > 0).
|
||||
// 5. Spell is not on cooldown (engine cooldown helper).
|
||||
// 6. Player has enough power for `ManaCost` of the spell's
|
||||
// `PowerType`. Only this failure flips `noMana=true`.
|
||||
// 6. Player has enough power for the spell's *effective* cost (talent
|
||||
// reductions applied) of its `PowerType`. Only this flips
|
||||
// `noMana=true`.
|
||||
// 7. Player has all required reagents in bags (Reagent[8] /
|
||||
// ReagentCount[8] from the spell record).
|
||||
//
|
||||
@@ -170,16 +172,23 @@ Usability ComputeUsability(void *L, int spellID) {
|
||||
if (IsOnCooldown(spellID))
|
||||
return r;
|
||||
|
||||
// Mana check is the only one that flips noMana.
|
||||
// Mana check is the only one that flips noMana. Use the engine's
|
||||
// effective-cost helper (op-14 SpellMod + descriptor power-cost mods +
|
||||
// ManaCostPercent), so talent reductions like Frost Channeling count —
|
||||
// not just the base ManaCost. Falls back to base if the engine can't
|
||||
// resolve a cost (shouldn't happen here: the player descriptor
|
||||
// resolved above, so it has a player context).
|
||||
const int powerType = static_cast<int>(*reinterpret_cast<const int8_t *>(
|
||||
record + OFF_SPELL_POWER_TYPE));
|
||||
if (powerType >= 0 && powerType <= 4) {
|
||||
const int manaCost = static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
record + OFF_SPELL_MANA_COST));
|
||||
if (manaCost > 0) {
|
||||
auto getCost = reinterpret_cast<GetSpellCost_t>(Offsets::FUN_GET_SPELL_COST);
|
||||
uint32_t cost = getCost(spellID, 0 /* local player */);
|
||||
if (cost == 0xFFFFFFFF)
|
||||
cost = *reinterpret_cast<const uint32_t *>(record + OFF_SPELL_MANA_COST);
|
||||
if (cost > 0) {
|
||||
const int currentPower = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_POWER1 + powerType * 4);
|
||||
if (currentPower < manaCost) {
|
||||
if (currentPower < static_cast<int>(cost)) {
|
||||
r.noMana = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
+18
-16
@@ -33,11 +33,29 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "unit/Power.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::Power {
|
||||
|
||||
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
|
||||
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
|
||||
// can't appear on a 1.12 unit but we map them anyway in case a
|
||||
// private server pushes one through.
|
||||
const char *PowerTypeToken(int type) {
|
||||
switch (type) {
|
||||
case 0: return "MANA";
|
||||
case 1: return "RAGE";
|
||||
case 2: return "FOCUS";
|
||||
case 3: return "ENERGY";
|
||||
case 4: return "HAPPINESS";
|
||||
case 5: return "RUNES";
|
||||
case 6: return "RUNIC_POWER";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
@@ -68,22 +86,6 @@ uint32_t PowerDivisor(int type) {
|
||||
return table[type];
|
||||
}
|
||||
|
||||
// Modern power-type tokens. Vanilla descriptor byte values 0..4 map
|
||||
// to MANA/RAGE/FOCUS/ENERGY/HAPPINESS. 5/6 (Runes / Runic Power)
|
||||
// can't appear on a 1.12 unit but we map them anyway in case a
|
||||
// private server pushes one through.
|
||||
const char *PowerTypeToken(int type) {
|
||||
switch (type) {
|
||||
case 0: return "MANA";
|
||||
case 1: return "RAGE";
|
||||
case 2: return "FOCUS";
|
||||
case 3: return "ENERGY";
|
||||
case 4: return "HAPPINESS";
|
||||
case 5: return "RUNES";
|
||||
case 6: return "RUNIC_POWER";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
// `Enum.PowerType` — modern Blizzard-style enum table. Slot 4 is
|
||||
// `Happiness` in 1.12 (pet happiness 0..3); modern retail relabels
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU Lesser General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Lesser General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Unit::Power {
|
||||
|
||||
// Modern power-type token for a PowerType value: 0 MANA, 1 RAGE, 2 FOCUS,
|
||||
// 3 ENERGY, 4 HAPPINESS, 5 RUNES, 6 RUNIC_POWER, anything else "UNKNOWN".
|
||||
// Used by `UnitPowerType`'s second return and by spell power-cost APIs.
|
||||
const char *PowerTypeToken(int type);
|
||||
|
||||
} // namespace Unit::Power
|
||||
@@ -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