mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 12:57:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba82e59e4 | ||
|
|
b05c15ffa7 | ||
|
|
4daf6b9364 | ||
|
|
4c6c9deae6 | ||
|
|
983cc3bdff | ||
|
|
18ac033381 | ||
|
|
81434f7014 | ||
|
|
22605dad64 | ||
|
|
7378e0b128 | ||
|
|
94b0545631 | ||
|
|
243ab37836 | ||
|
|
a12fff6704 | ||
|
|
d747c699f5 | ||
|
|
88d76c04ad | ||
|
|
47d4b9c660 | ||
|
|
8e9e30567c | ||
|
|
8be78ad946 | ||
|
|
0dea869ab1 | ||
|
|
b4dffb75c0 | ||
|
|
9445fd6333 | ||
|
|
c8effdd6ec | ||
|
|
b3ac1c07f7 | ||
|
|
cfbc4f4e9c | ||
|
|
bb9579c398 | ||
|
|
5642644833 | ||
|
|
58e7e1a2c6 | ||
|
|
5339c97b1e | ||
|
|
7406ac5db1 | ||
|
|
1c263152e3 | ||
|
|
ab211962e5 | ||
|
|
33a23c563c | ||
|
|
17304929b5 | ||
|
|
d88f02fc90 | ||
|
|
1c79742cc3 | ||
|
|
d696a16ac4 | ||
|
|
2c1962b207 | ||
|
|
23cdbe196d | ||
|
|
8b6760cd77 | ||
|
|
7c2317aca1 | ||
|
|
b764b14df1 | ||
|
|
fc1bd30bef | ||
|
|
fe29ec050c | ||
|
|
7fafb89d1d | ||
|
|
bcefe3b87f | ||
|
|
9e03f69e9b | ||
|
|
694a7415ab | ||
|
|
23b102c60c | ||
|
|
e165aa6cd4 | ||
|
|
8ed32e6c8e | ||
|
|
26da489773 | ||
|
|
9c73430ddd | ||
|
|
e712af11a5 | ||
|
|
45291cb16b | ||
|
|
9bfbb2e6b0 | ||
|
|
d6bf05c12c | ||
|
|
07a4cbde43 | ||
|
|
017dc5b6f1 | ||
|
|
ddcb6ba9f0 | ||
|
|
17cf323a52 | ||
|
|
a0b7d03c28 | ||
|
|
15cae8a474 | ||
|
|
49865e9b9e | ||
|
|
98d4504b6f | ||
|
|
597530d558 | ||
|
|
2165a8e986 |
@@ -35,5 +35,6 @@ Util\EquipmentManager.lua
|
||||
Util\PlayerUtil.lua
|
||||
Util\SlashCommandsRegistry.lua
|
||||
Util\LinkUtil.lua
|
||||
Util\TradeSkillLink.lua
|
||||
Util\IconDataProvider.lua
|
||||
Util\GameTooltip.lua
|
||||
|
||||
@@ -8,5 +8,34 @@ if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
unknownColor.colorStr = unknownColor:GenerateHexColor()
|
||||
return unknownColor
|
||||
end})
|
||||
|
||||
-- Compatibility with OLDER forks of pfUI. Our DLL adds the modern
|
||||
-- HookScript widget method; older pfUI forks branch on
|
||||
-- `if button.HookScript` and take a modern code path they were never
|
||||
-- written for on their pfActionBar buttons, which breaks them. While
|
||||
-- the actionbar module builds those buttons, temporarily wrap
|
||||
-- CreateFrame to shadow HookScript with a falsy field so those forks
|
||||
-- fall back to their vanilla SetScript path. The maintained "brues"
|
||||
-- fork handles HookScript itself, so it's detected via the TOC
|
||||
-- X-Website tag and left alone. The wrapper is removed as soon as the
|
||||
-- actionbar module finishes loading so it affects nothing else.
|
||||
local website = GetAddOnMetadata("pfUI", "X-Website")
|
||||
if not website or not strfind(website, 'brues') then
|
||||
local _createFrame = CreateFrame
|
||||
local HookedCreateFrame = function(frameType, name, parent, template)
|
||||
local frame = _createFrame(frameType, name, parent, template)
|
||||
if frameType == "Button" and strfind(name or "", "pfActionBar") then
|
||||
frame.HookScript = false
|
||||
end
|
||||
return frame
|
||||
end
|
||||
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
|
||||
if moduleName == "updatenotify" then
|
||||
CreateFrame = HookedCreateFrame
|
||||
elseif moduleName == "actionbar" then
|
||||
CreateFrame = _createFrame
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -123,14 +123,6 @@ PLAYER_FACTION_COLORS =
|
||||
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
|
||||
};
|
||||
|
||||
WOW_PROJECT_MAINLINE = 1;
|
||||
WOW_PROJECT_CLASSIC = 2;
|
||||
WOW_PROJECT_BURNING_CRUSADE_CLASSIC = 5;
|
||||
WOW_PROJECT_WRATH_CLASSIC = 11;
|
||||
WOW_PROJECT_CATACLYSM_CLASSIC = 14;
|
||||
WOW_PROJECT_MISTS_CLASSIC = 19;
|
||||
WOW_PROJECT_ID = WOW_PROJECT_CLASSIC;
|
||||
|
||||
--
|
||||
-- Glyph
|
||||
--
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
-- Trade-skill *list* links (`|Htrade:...|h`) — backport of the 2.x+ shareable
|
||||
-- profession link. The link is built entirely in the DLL
|
||||
-- (`GetTradeSkillListLink` / `GetCraftListLink`, see src/tradeskill/Link.cpp);
|
||||
-- this file only intercepts clicks on received links and renders the linker's
|
||||
-- recipe list in a scroll frame (the UI half).
|
||||
--
|
||||
-- link: |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
|
||||
--
|
||||
-- "Which recipes are known" is a bitfield over the skill line's canonical
|
||||
-- recipe list (deterministic per DBC). The viewer lists the recipes the linker
|
||||
-- knows (with an "X of Y" summary of the whole skill line), regardless of what
|
||||
-- the reader can craft themselves.
|
||||
|
||||
-- Frame geometry mirrors the real TradeSkillFrame. Turtle ships an HD
|
||||
-- 768x512 two-column frame (TW-TradeSkill art); stock 1.12.1 only has the
|
||||
-- vanilla 384x512 single-column art, so on that client we fall back to the
|
||||
-- native Blizzard_TradeSkillUI geometry (list on top, detail below) rather
|
||||
-- than stretch the wide art. LO holds every layout value that differs.
|
||||
local TURTLE = (TURTLE_WOW_VERSION ~= nil);
|
||||
local FRAME_W = TURTLE and 768 or 384;
|
||||
local FRAME_H = 512;
|
||||
local NUM_ROWS = TURTLE and 19 or 8;
|
||||
local ROW_HEIGHT = 16;
|
||||
|
||||
local LO = TURTLE and {
|
||||
hit = { 2, 82, 2, 73 },
|
||||
quads = {
|
||||
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT" },
|
||||
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT" },
|
||||
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT" },
|
||||
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT" },
|
||||
},
|
||||
close = { -77, -8 },
|
||||
bar = { 596, 15, 76, -44 },
|
||||
barBdr = { 604, 22 },
|
||||
summary = { "TOPRIGHT", -110, -70 },
|
||||
drop = { 40, -60 },
|
||||
scroll = { 296, 330, 37, -98 },
|
||||
-- detail scroll frame: { x, y, width, height }. Tall enough that the
|
||||
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
|
||||
detail = { 371, -110, 340, 290 },
|
||||
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
|
||||
} or {
|
||||
hit = { 0, 34, 0, 75 },
|
||||
quads = {
|
||||
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft", 256, 256, "TOPLEFT" },
|
||||
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight", 128, 256, "TOPRIGHT" },
|
||||
{ "Interface\\TradeSkillFrame\\UI-TradeSkill-BotLeft", 256, 256, "BOTTOMLEFT" },
|
||||
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight", 128, 256, "BOTTOMRIGHT" },
|
||||
},
|
||||
close = { -29, -8 },
|
||||
bar = { 268, 15, 73, -37 },
|
||||
barBdr = { 278, 22 },
|
||||
summary = { "TOPRIGHT", -40, -58 },
|
||||
drop = { 12, -62 },
|
||||
scroll = { 296, 130, 21, -96 },
|
||||
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
|
||||
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
|
||||
-- recipes scroll (clipping partial rows), like native.
|
||||
detail = { 20, -234, 297, 176 },
|
||||
exit = { "CENTER", "TOPLEFT", 305, -422 },
|
||||
sep = { 15, -221, 331 },
|
||||
};
|
||||
|
||||
-- Reagents render in a 2-column grid inside the detail scroll child (see
|
||||
-- the detail pane below). The detail is a real ScrollFrame like the
|
||||
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
|
||||
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
|
||||
-- of one reagent row.
|
||||
local REAGENT_ROW_HEIGHT = 43;
|
||||
|
||||
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
|
||||
-- list/detail scrolls use the ClassTrainer templates (they add the
|
||||
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
|
||||
-- doesn't want that groove, so it keeps the plain templates. Both detail
|
||||
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
|
||||
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
|
||||
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
|
||||
or "ClassTrainerListScrollFrameTemplate";
|
||||
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
|
||||
or "ClassTrainerDetailScrollFrameTemplate";
|
||||
|
||||
local PROFESSION_ICONS = {
|
||||
[171] = "Trade_Alchemy",
|
||||
[164] = "Trade_BlackSmithing",
|
||||
[185] = "INV_Misc_Food_15", -- Cooking
|
||||
[333] = "Trade_Engraving", -- Enchanting
|
||||
[202] = "Trade_Engineering",
|
||||
[129] = "Spell_Holy_SealOfSacrifice", -- First Aid
|
||||
[165] = "INV_Misc_ArmorKit_17", -- Leatherworking
|
||||
[197] = "Trade_Tailoring",
|
||||
[186] = "Trade_Mining",
|
||||
[182] = "Trade_Herbalism",
|
||||
[393] = "INV_Misc_Pelt_Wolf_01", -- Skinning
|
||||
[356] = "Trade_Fishing",
|
||||
};
|
||||
|
||||
if TURTLE then
|
||||
PROFESSION_ICONS[142] = "Trade_Survival"
|
||||
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
|
||||
end
|
||||
|
||||
local function ProfessionIcon(skillLineID)
|
||||
local icon = PROFESSION_ICONS[skillLineID];
|
||||
return icon and ("Interface\\Icons\\" .. icon)
|
||||
or "Interface\\Icons\\INV_Misc_QuestionMark";
|
||||
end
|
||||
|
||||
local function DifficultyBand(skill, hi, lo)
|
||||
if not hi or hi <= 0 then
|
||||
return 3;
|
||||
end
|
||||
if not lo or lo == 0 then
|
||||
lo = (hi ~= 25) and (hi - 25) or 0;
|
||||
end
|
||||
local yellow = (hi + lo) / 2;
|
||||
if skill < lo then
|
||||
return 0; -- orange (optimal)
|
||||
elseif skill < yellow then
|
||||
return 1; -- yellow (medium)
|
||||
elseif skill < hi then
|
||||
return 2; -- green (easy)
|
||||
end
|
||||
return 3; -- grey (trivial)
|
||||
end
|
||||
|
||||
local BAND_COLOR = {
|
||||
[0] = DIFFICULT_DIFFICULTY_COLOR, -- orange
|
||||
[1] = FAIR_DIFFICULTY_COLOR, -- yellow
|
||||
[2] = EASY_DIFFICULTY_COLOR, -- green
|
||||
[3] = TRIVIAL_DIFFICULTY_COLOR, -- grey
|
||||
};
|
||||
|
||||
local function CraftableCount(entry)
|
||||
local reagents = entry.reagents;
|
||||
if not reagents or table.getn(reagents) == 0 then
|
||||
return nil;
|
||||
end
|
||||
local best;
|
||||
for i = 1, table.getn(reagents) do
|
||||
local r = reagents[i];
|
||||
local need = r.count or 1;
|
||||
local n = math.floor((C_Item.GetItemCount(r.itemID) or 0) / need);
|
||||
if not best or n < best then
|
||||
best = n;
|
||||
end
|
||||
end
|
||||
return best;
|
||||
end
|
||||
|
||||
local function ParseTradeLink(link)
|
||||
-- Current format, with the linker's name: trade:id:cur:max:Name:bits
|
||||
local _, _, id, cur, max, who, bits =
|
||||
string.find(link, "^trade:(%d+):(%d+):(%d+):([^:]+):([%w%+/]*)$");
|
||||
if not id then
|
||||
-- Legacy format (no name): trade:id:cur:max:bits
|
||||
who = nil;
|
||||
_, _, id, cur, max, bits =
|
||||
string.find(link, "^trade:(%d+):(%d+):(%d+):([%w%+/]*)$");
|
||||
end
|
||||
if not id then
|
||||
return nil;
|
||||
end
|
||||
return tonumber(id), tonumber(cur), tonumber(max), who, bits or "";
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Display frame (built lazily on first use)
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
local frame;
|
||||
local function ResolveSubclasses(list, onLoaded)
|
||||
local present, subs, anyItem = {}, {}, false;
|
||||
for i = 1, table.getn(list) do
|
||||
local r = list[i];
|
||||
if r.createdItem and r.createdItem > 0 then
|
||||
anyItem = true;
|
||||
if not r.subclass then
|
||||
-- GetItemInfoInstant's 3rd return is the localized subclass
|
||||
-- name (or nil when uncached).
|
||||
local _, _, subType = C_Item.GetItemInfoInstant(r.createdItem);
|
||||
if subType and subType ~= "" then
|
||||
r.subclass = subType;
|
||||
elseif onLoaded then
|
||||
-- Not cached — re-derive when the item loads.
|
||||
Item:CreateFromItemID(r.createdItem):ContinueOnItemLoad(onLoaded);
|
||||
end
|
||||
end
|
||||
if r.subclass and not present[r.subclass] then
|
||||
present[r.subclass] = true;
|
||||
table.insert(subs, r.subclass);
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(subs);
|
||||
return subs, anyItem;
|
||||
end
|
||||
|
||||
local SUBCLASS_ALL = {};
|
||||
|
||||
local function SubClassButton_OnClick()
|
||||
local v = this.value;
|
||||
if v == SUBCLASS_ALL then
|
||||
v = nil;
|
||||
end
|
||||
frame.subclassFilter = v;
|
||||
UIDropDownMenu_SetText(v or ALL_SUBCLASSES, frame.dropdown);
|
||||
frame:ApplyFilter();
|
||||
end
|
||||
|
||||
local function SubClassDropDown_Initialize()
|
||||
local info = {};
|
||||
info.text = ALL_SUBCLASSES;
|
||||
info.value = SUBCLASS_ALL;
|
||||
info.checked = (frame.subclassFilter == nil);
|
||||
info.func = SubClassButton_OnClick;
|
||||
UIDropDownMenu_AddButton(info);
|
||||
local subs = frame.subclasses or {};
|
||||
for i = 1, table.getn(subs) do
|
||||
local b = {};
|
||||
b.text = subs[i];
|
||||
b.value = subs[i];
|
||||
b.checked = (frame.subclassFilter == subs[i]);
|
||||
b.func = SubClassButton_OnClick;
|
||||
UIDropDownMenu_AddButton(b);
|
||||
end
|
||||
end
|
||||
|
||||
local function RefreshSubclasses()
|
||||
if not (frame and frame:IsShown() and frame.recipes) then
|
||||
return;
|
||||
end
|
||||
local subs, anyItem = ResolveSubclasses(frame.recipes, RefreshSubclasses);
|
||||
frame.subclasses = subs;
|
||||
if anyItem then
|
||||
UIDropDownMenu_Initialize(frame.dropdown, SubClassDropDown_Initialize);
|
||||
UIDropDownMenu_SetWidth(120, frame.dropdown);
|
||||
UIDropDownMenu_SetText(frame.subclassFilter or ALL_SUBCLASSES, frame.dropdown);
|
||||
frame.dropdown:Show();
|
||||
else
|
||||
frame.dropdown:Hide();
|
||||
end
|
||||
if frame.subclassFilter then
|
||||
frame:ApplyFilter();
|
||||
end
|
||||
end
|
||||
|
||||
local function CreateFrame_TradeSkillLink()
|
||||
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
|
||||
f:SetSize(FRAME_W, FRAME_H);
|
||||
f:SetPoint("CENTER", 0, 0);
|
||||
f:SetMovable(true);
|
||||
f:EnableMouse(true);
|
||||
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
|
||||
f:RegisterForDrag("LeftButton");
|
||||
f:SetScript("OnDragStart", function() this:StartMoving(); end);
|
||||
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
|
||||
f:SetFrameStrata("DIALOG");
|
||||
f:Hide();
|
||||
tinsert(UISpecialFrames, f:GetName())
|
||||
|
||||
local portrait = f:CreateTexture(nil, "BACKGROUND");
|
||||
portrait:SetSize(60, 60);
|
||||
portrait:SetPoint("TOPLEFT", 7, -6);
|
||||
f.portrait = portrait;
|
||||
|
||||
for i = 1, table.getn(LO.quads) do
|
||||
local q = LO.quads[i];
|
||||
local tx = f:CreateTexture(nil, "BORDER");
|
||||
tx:SetTexture(q[1]);
|
||||
tx:SetSize(q[2], q[3]);
|
||||
tx:SetPoint(q[4]);
|
||||
end
|
||||
|
||||
-- Horizontal divider between the recipe list and the detail pane.
|
||||
-- Stock only (Turtle's HD frame art already has its own).
|
||||
if LO.sep then
|
||||
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
|
||||
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
|
||||
-- bottom half empty). So sample the top quarter, stretched to width —
|
||||
-- the same region the native TradeSkillFrame uses for this divider.
|
||||
local bar = f:CreateTexture(nil, "ARTWORK");
|
||||
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
|
||||
bar:SetTexCoord(0, 1, 0, 0.25);
|
||||
bar:SetSize(LO.sep[3], 16);
|
||||
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
|
||||
end
|
||||
|
||||
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
|
||||
title:SetPoint("TOP", 0, -18);
|
||||
f.title = title;
|
||||
|
||||
local close = CreateFrame("Button", nil, f, "UIPanelCloseButton");
|
||||
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
|
||||
|
||||
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
|
||||
exit:SetSize(80, 22);
|
||||
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
|
||||
exit:SetText(EXIT or "Exit");
|
||||
exit:SetScript("OnClick", function() f:Hide(); end);
|
||||
|
||||
local bar = CreateFrame("StatusBar", nil, f);
|
||||
bar:SetSize(LO.bar[1], LO.bar[2]);
|
||||
bar:SetPoint("TOPLEFT", LO.bar[3], LO.bar[4]);
|
||||
bar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar");
|
||||
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5);
|
||||
bar:SetMinMaxValues(0, 1);
|
||||
local barBg = bar:CreateTexture(nil, "BACKGROUND");
|
||||
barBg:SetAllPoints(bar);
|
||||
barBg:SetTexture(1, 1, 1, 0.2);
|
||||
barBg:SetVertexColor(0.0, 0.0, 0.75, 0.5);
|
||||
-- Border: tooltip edge around the bar (TradeSkillRankFrameBorder).
|
||||
local border = CreateFrame("Frame", nil, bar);
|
||||
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
|
||||
border:SetPoint("LEFT", -5, 1);
|
||||
border:SetBackdrop({
|
||||
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
|
||||
});
|
||||
border:SetBackdropBorderColor(0.4, 0.4, 0.4);
|
||||
local barName = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
|
||||
barName:SetPoint("LEFT", 6, 1);
|
||||
local barRank = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
|
||||
barRank:SetJustifyH("LEFT");
|
||||
barRank:SetWidth(128);
|
||||
barRank:SetPoint("LEFT", barName, "RIGHT", 13, 0);
|
||||
f.bar = bar;
|
||||
f.barName = barName;
|
||||
f.barRank = barRank;
|
||||
|
||||
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
|
||||
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
|
||||
f.summary = summary;
|
||||
|
||||
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
|
||||
"UIDropDownMenuTemplate");
|
||||
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
|
||||
f.dropdown = dropdown;
|
||||
f.subclassFilter = nil; -- nil = all subclasses
|
||||
|
||||
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
|
||||
f, LIST_SCROLL_TEMPLATE);
|
||||
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
|
||||
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
|
||||
scroll:SetScript("OnVerticalScroll", function()
|
||||
FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, function() f:Refresh(); end)
|
||||
end)
|
||||
f.scroll = scroll;
|
||||
|
||||
f.rows = {};
|
||||
for i = 1, NUM_ROWS do
|
||||
local rowName = "ClassicAPITradeSkillLinkRow" .. i;
|
||||
local row = CreateFrame("Button", rowName, f,
|
||||
"ClassTrainerSkillButtonTemplate");
|
||||
row:SetWidth(290); -- height (16) comes from the template
|
||||
if i == 1 then
|
||||
row:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0);
|
||||
else
|
||||
row:SetPoint("TOPLEFT", f.rows[i - 1], "BOTTOMLEFT", 0, 0);
|
||||
end
|
||||
|
||||
row:SetNormalTexture("");
|
||||
row:SetDisabledTexture("");
|
||||
row:SetHighlightTexture("");
|
||||
|
||||
local txt = getglobal(rowName .. "Text");
|
||||
if txt then
|
||||
txt:ClearAllPoints();
|
||||
txt:SetPoint("LEFT", row, "LEFT", 4, 0);
|
||||
end
|
||||
|
||||
local sel = row:CreateTexture(nil, "BACKGROUND");
|
||||
sel:SetTexture("Interface\\Buttons\\UI-Listbox-Highlight2");
|
||||
sel:SetAllPoints(row);
|
||||
sel:Hide();
|
||||
row.select = sel;
|
||||
|
||||
-- Override the template's trainer scripts with ours.
|
||||
row:SetScript("OnClick", function()
|
||||
if this.entry then
|
||||
f:SelectRecipe(this.entry);
|
||||
end
|
||||
end);
|
||||
|
||||
f.rows[i] = row;
|
||||
end
|
||||
|
||||
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
|
||||
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
|
||||
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
|
||||
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
|
||||
-- auto-hides when the content fits (scrollBarHideable).
|
||||
local detailScroll = CreateFrame("ScrollFrame",
|
||||
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
|
||||
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
|
||||
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
|
||||
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
|
||||
f.detailScroll = detailScroll;
|
||||
-- Off until a recipe overflows the pane (toggled in RenderDetail).
|
||||
-- The template shows its slider and enables the wheel by default; on
|
||||
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
|
||||
-- wheel events and over-scrolls a hair. Start both disabled.
|
||||
detailScroll:EnableMouseWheel(false);
|
||||
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if detailSB then
|
||||
detailSB:Hide();
|
||||
end
|
||||
-- The template re-shows its slider when the frame transitions to shown
|
||||
-- (on first open, before the auto-selected recipe's RenderDetail runs),
|
||||
-- leaving a dead scrollbar until the first click. Re-assert the hidden
|
||||
-- state on show unless the current recipe genuinely overflows.
|
||||
detailScroll:SetScript("OnShow", function()
|
||||
f:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if f:IsShown() then
|
||||
f:UpdateDetailScrollbar();
|
||||
end
|
||||
end);
|
||||
end);
|
||||
|
||||
local detailChild = CreateFrame("Frame",
|
||||
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
|
||||
detailChild:SetSize(LO.detail[3], LO.detail[4]);
|
||||
detailScroll:SetScrollChild(detailChild);
|
||||
f.detailChild = detailChild;
|
||||
|
||||
local prodIcon = CreateFrame("Button", nil, detailChild);
|
||||
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
|
||||
prodIcon:SetPoint("TOPLEFT", 8, -3);
|
||||
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
|
||||
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
|
||||
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
|
||||
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
|
||||
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
|
||||
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
|
||||
hdrRight:SetSize(64, 64);
|
||||
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
|
||||
local prodCount = prodIcon:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
|
||||
prodCount:SetPoint("BOTTOMRIGHT", -5, 2);
|
||||
prodIcon.count = prodCount;
|
||||
f.prodIcon = prodIcon;
|
||||
|
||||
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
|
||||
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
|
||||
prodName:SetWidth(LO.detail[3] - 60);
|
||||
prodName:SetJustifyH("LEFT");
|
||||
prodName:SetJustifyV("MIDDLE");
|
||||
f.prodName = prodName;
|
||||
|
||||
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
|
||||
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
|
||||
prodDesc:SetWidth(LO.detail[3] - 20);
|
||||
prodDesc:SetJustifyH("LEFT");
|
||||
prodDesc:SetJustifyV("TOP");
|
||||
f.prodDesc = prodDesc;
|
||||
|
||||
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
|
||||
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
|
||||
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
|
||||
f.reagentLabel = reagentLabel;
|
||||
|
||||
prodIcon:SetScript("OnEnter", function()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
if this.itemLink then
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
elseif this.spellID then
|
||||
GameTooltip:SetSpellByID(this.spellID);
|
||||
end
|
||||
GameTooltip:Show();
|
||||
end);
|
||||
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
|
||||
|
||||
-- Reagent buttons live in the scroll child (2 per row), built on
|
||||
-- demand so any reagent count is handled and they scroll/clip with
|
||||
-- the child.
|
||||
f.reagents = {};
|
||||
local function AcquireReagent(i)
|
||||
if f.reagents[i] then
|
||||
return f.reagents[i];
|
||||
end
|
||||
local rowN = math.floor((i - 1) / 2);
|
||||
local col = (i - 1) - rowN * 2;
|
||||
local btn = CreateFrame("Button", nil, detailChild);
|
||||
btn:SetSize(39, 39);
|
||||
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
|
||||
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
|
||||
-- Extend the hit area rightward over the name plate so the tooltip
|
||||
-- fires anywhere on the element, not just the icon. Stop short of the
|
||||
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
|
||||
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
|
||||
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
local plate = btn:CreateTexture(nil, "BACKGROUND");
|
||||
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
|
||||
plate:SetSize(114, 64);
|
||||
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
|
||||
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
|
||||
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
|
||||
btn.count = cnt;
|
||||
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
|
||||
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
|
||||
nm:SetSize(80, 36);
|
||||
nm:SetJustifyH("LEFT");
|
||||
nm:SetJustifyV("MIDDLE");
|
||||
btn.nameText = nm;
|
||||
btn:SetScript("OnEnter", function()
|
||||
if this.itemLink then
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
GameTooltip:Show();
|
||||
end
|
||||
end);
|
||||
btn:SetScript("OnLeave", GameTooltip_Hide);
|
||||
f.reagents[i] = btn;
|
||||
return btn;
|
||||
end
|
||||
|
||||
-- Show the detail slider + enable the wheel only when the current
|
||||
-- recipe overflows the pane (tracked by `detailNeedScroll`).
|
||||
function f:UpdateDetailScrollbar()
|
||||
local need = self.detailNeedScroll and true or false;
|
||||
self.detailScroll:EnableMouseWheel(need);
|
||||
local base = "ClassicAPITradeSkillLinkDetailScroll";
|
||||
local sb = getglobal(base .. "ScrollBar");
|
||||
if sb then sb:SetShown(need) end
|
||||
local top = getglobal(base .. "Top");
|
||||
if top then top:SetShown(need) end
|
||||
local bottom = getglobal(base .. "Bottom");
|
||||
if bottom then bottom:SetShown(need) end
|
||||
end
|
||||
|
||||
function f:RenderDetail(entry)
|
||||
local function itemInfo(id)
|
||||
local item = Item:CreateFromItemID(id);
|
||||
if not item:IsItemDataCached() then
|
||||
item:ContinueOnItemLoad(function()
|
||||
if self:IsShown() and self.selectedSpellID == entry.spellID then
|
||||
self:RenderDetail(entry);
|
||||
end
|
||||
end);
|
||||
end
|
||||
return item:GetItemName(), item:GetItemIcon(), item:GetItemLink();
|
||||
end
|
||||
|
||||
local prodTex, prodText, prodLink;
|
||||
local created = entry.createdItem or 0;
|
||||
if created > 0 then
|
||||
local name, tex, link = itemInfo(created);
|
||||
prodText = name or entry.name;
|
||||
prodTex = tex;
|
||||
prodLink = link;
|
||||
else
|
||||
prodText = entry.name;
|
||||
prodTex = C_Spell.GetSpellTexture(entry.spellID);
|
||||
end
|
||||
local descText = (created > 0) and ""
|
||||
or (C_Spell.GetSpellDescription(entry.spellID) or "");
|
||||
self.prodDesc:SetText(descText);
|
||||
self.prodIcon:SetNormalTexture(prodTex or
|
||||
"Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
self.prodName:SetText(prodText or "");
|
||||
self.prodName:SetTextColor(NORMAL_FONT_COLOR:GetRGB());
|
||||
self.prodIcon.itemLink = prodLink; -- item tooltip when created > 0
|
||||
self.prodIcon.spellID = entry.spellID; -- else spell tooltip
|
||||
local made = entry.numMade or 0;
|
||||
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
|
||||
self.prodIcon:Show();
|
||||
|
||||
-- Fill every reagent slot; the scroll child clips/scrolls the
|
||||
-- overflow. Buttons past the count are hidden.
|
||||
local reagents = entry.reagents or {};
|
||||
local total = table.getn(reagents);
|
||||
for i = 1, total do
|
||||
local btn = AcquireReagent(i);
|
||||
local r = reagents[i];
|
||||
local name, tex, link = itemInfo(r.itemID);
|
||||
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
btn.itemLink = link;
|
||||
btn.nameText:SetText(name or ("item:" .. r.itemID));
|
||||
local need = r.count or 1;
|
||||
local have = C_Item.GetItemCount(r.itemID) or 0;
|
||||
if have >= need then
|
||||
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
|
||||
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
|
||||
else
|
||||
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
|
||||
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
|
||||
end
|
||||
local haveText = (have >= 100) and "*" or tostring(have);
|
||||
btn.count:SetText(haveText .. " /" .. need);
|
||||
btn:Show();
|
||||
end
|
||||
for i = total + 1, table.getn(self.reagents) do
|
||||
self.reagents[i].itemLink = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
|
||||
-- Grow the scroll child to the content height so the pane scrolls
|
||||
-- once the header + reagent rows exceed the visible area. Reagent
|
||||
-- row 0 sits ~67px below the child top (icon + label); add the
|
||||
-- wrapped description height for spell recipes.
|
||||
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
|
||||
local rows = math.ceil(total / 2);
|
||||
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
|
||||
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
|
||||
-- Reset to the top and manage the slider explicitly: a range that
|
||||
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
|
||||
-- range-changed event that `scrollBarHideable` relies on, so the
|
||||
-- slider would otherwise sit visible-but-useless. Show it only when
|
||||
-- the content actually overflows.
|
||||
self.detailNeedScroll = contentH > LO.detail[4];
|
||||
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if sb then
|
||||
sb:SetValue(0);
|
||||
end
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
self:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if self:IsShown() then
|
||||
self:UpdateDetailScrollbar();
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function f:SelectRecipe(entry)
|
||||
self.selectedSpellID = entry.spellID;
|
||||
self.selectedEntry = entry;
|
||||
self:RenderDetail(entry); -- also resets the detail scroll to top
|
||||
self:Refresh();
|
||||
end
|
||||
|
||||
function f:ClearDetail()
|
||||
self.selectedSpellID = nil;
|
||||
self.selectedEntry = nil;
|
||||
self.prodIcon:Hide();
|
||||
self.prodName:SetText("");
|
||||
self.prodDesc:SetText("");
|
||||
for i = 1, table.getn(self.reagents) do
|
||||
self.reagents[i].itemLink = nil;
|
||||
self.reagents[i]:Hide();
|
||||
end
|
||||
self.detailChild:SetHeight(LO.detail[4]);
|
||||
self.detailNeedScroll = false;
|
||||
self:UpdateDetailScrollbar();
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
end
|
||||
|
||||
function f:Refresh()
|
||||
local recipes = self.displayed or {};
|
||||
local total = table.getn(recipes);
|
||||
FauxScrollFrame_Update(self.scroll, total, NUM_ROWS, ROW_HEIGHT);
|
||||
local offset = FauxScrollFrame_GetOffset(self.scroll);
|
||||
for i = 1, NUM_ROWS do
|
||||
local row = self.rows[i];
|
||||
local entry = recipes[offset + i];
|
||||
if entry then
|
||||
-- Append " [N]" when the reader can craft any (Blizzard's list).
|
||||
local craftable = CraftableCount(entry);
|
||||
local label = entry.name or "";
|
||||
if craftable and craftable > 0 then
|
||||
label = label .. " [" .. craftable .. "]";
|
||||
end
|
||||
row:SetText(label);
|
||||
local r, g, b = BAND_COLOR[entry.band or 3]:GetRGB();
|
||||
if self.selectedSpellID and entry.spellID == self.selectedSpellID then
|
||||
row.select:SetVertexColor(r, g, b);
|
||||
row.select:Show();
|
||||
row:SetTextColor(1, 1, 1);
|
||||
else
|
||||
row.select:Hide();
|
||||
row:SetTextColor(r, g, b);
|
||||
end
|
||||
row.spellID = entry.spellID;
|
||||
row.entry = entry;
|
||||
row:Show();
|
||||
else
|
||||
row.spellID = nil;
|
||||
row.entry = nil;
|
||||
row.select:Hide();
|
||||
row:Hide();
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function f:ApplyFilter()
|
||||
local src = self.recipes or {};
|
||||
if not self.subclassFilter then
|
||||
self.displayed = src;
|
||||
else
|
||||
local out = {};
|
||||
for i = 1, table.getn(src) do
|
||||
if src[i].subclass == self.subclassFilter then
|
||||
table.insert(out, src[i]);
|
||||
end
|
||||
end
|
||||
self.displayed = out;
|
||||
end
|
||||
local sb = getglobal("ClassicAPITradeSkillLinkScrollScrollBar");
|
||||
if sb then
|
||||
sb:SetValue(0);
|
||||
end
|
||||
self:Refresh();
|
||||
end
|
||||
|
||||
f:RegisterEvent("BAG_UPDATE_DELAYED");
|
||||
f:SetScript("OnEvent", function()
|
||||
if not f:IsShown() then
|
||||
return;
|
||||
end
|
||||
f:Refresh();
|
||||
if f.selectedEntry then
|
||||
f:RenderDetail(f.selectedEntry);
|
||||
end
|
||||
end);
|
||||
|
||||
return f;
|
||||
end
|
||||
|
||||
local function ShowTradeSkillLink(link, text)
|
||||
local id, cur, max, who, bits = ParseTradeLink(link);
|
||||
if not id then
|
||||
return false;
|
||||
end
|
||||
local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(id, bits);
|
||||
if not recipes then
|
||||
return false;
|
||||
end
|
||||
|
||||
local prof = text and string.match(text, "|h%[(.+)%]|h") or "Trade Skills";
|
||||
local total = table.getn(recipes);
|
||||
local knownList = {};
|
||||
for i = 1, total do
|
||||
local r = recipes[i];
|
||||
if r.isKnown then
|
||||
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
|
||||
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
|
||||
table.insert(knownList, r);
|
||||
end
|
||||
end
|
||||
table.sort(knownList, function(a, b)
|
||||
if a.band ~= b.band then
|
||||
return a.band < b.band;
|
||||
end
|
||||
return a.name < b.name;
|
||||
end);
|
||||
local known = table.getn(knownList);
|
||||
|
||||
if not frame then
|
||||
frame = CreateFrame_TradeSkillLink();
|
||||
end
|
||||
frame.title:SetText(who and who ~= "" and (who .. " - " .. prof) or prof);
|
||||
SetPortraitToTexture(frame.portrait, ProfessionIcon(id));
|
||||
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
|
||||
frame.bar:SetValue(cur or 0);
|
||||
frame.barName:SetText(prof);
|
||||
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
|
||||
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
|
||||
frame.recipes = knownList;
|
||||
frame.subclassFilter = nil;
|
||||
|
||||
frame:ClearDetail();
|
||||
frame:Show();
|
||||
RefreshSubclasses();
|
||||
frame:ApplyFilter();
|
||||
local first = frame.displayed and frame.displayed[1];
|
||||
if first then
|
||||
frame:SelectRecipe(first);
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Click interception: handle `trade:` before the engine's SetHyperlink (which
|
||||
-- in 1.12 only knows item:/enchant: and would reject the link).
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
local originalSetItemRef = SetItemRef;
|
||||
function SetItemRef(link, text, button, chatFrame)
|
||||
if type(link) == "string" and string.sub(link, 1, 6) == "trade:" then
|
||||
if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
|
||||
ChatFrameEditBox:Insert(text);
|
||||
return;
|
||||
end
|
||||
if ShowTradeSkillLink(link, text) then
|
||||
return;
|
||||
end
|
||||
end
|
||||
return originalSetItemRef(link, text, button, chatFrame);
|
||||
end
|
||||
|
||||
local function CreateProfessionLinkButton(parentName, getLink)
|
||||
local parent = _G[parentName];
|
||||
local btnName = parentName .. "LinkButton";
|
||||
if _G[btnName] or not parent then
|
||||
return;
|
||||
end
|
||||
local title = _G[parentName .. "TitleText"];
|
||||
local btn = CreateFrame("Button", btnName, parent);
|
||||
btn:SetSize(16, 16);
|
||||
|
||||
local function reposition()
|
||||
if not title then
|
||||
return;
|
||||
end
|
||||
btn:ClearAllPoints();
|
||||
if title:GetJustifyH() == "LEFT" then
|
||||
btn:SetPoint("LEFT", title, "LEFT", title:GetStringWidth() + 4, 0);
|
||||
else
|
||||
btn:SetPoint("LEFT", title, "CENTER", title:GetStringWidth() / 2 + 4, 0);
|
||||
end
|
||||
end
|
||||
reposition()
|
||||
btn:SetScript("OnShow", function() RunNextFrame(reposition) end)
|
||||
hooksecurefunc(title, "SetText", function() RunNextFrame(reposition) end)
|
||||
|
||||
local icon = "Interface\\GossipFrame\\PetitionGossipIcon";
|
||||
btn:SetNormalTexture(icon);
|
||||
btn:SetHighlightTexture(icon, "ADD");
|
||||
|
||||
btn:SetScript("OnClick", function()
|
||||
local link = getLink();
|
||||
if not link then
|
||||
return;
|
||||
end
|
||||
if not ChatFrameEditBox:IsVisible() then
|
||||
ChatFrameEditBox:Show();
|
||||
end
|
||||
ChatFrameEditBox:Insert(link);
|
||||
end);
|
||||
btn:SetScript("OnEnter", function()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_TOPLEFT");
|
||||
GameTooltip:SetText(LINK_TRADESKILL_TOOLTIP, nil, nil, nil, nil, 1);
|
||||
GameTooltip:Show();
|
||||
end);
|
||||
btn:SetScript("OnLeave", GameTooltip_Hide);
|
||||
end
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
|
||||
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
|
||||
end)
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
|
||||
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
|
||||
end)
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
|
||||
CreateProfessionLinkButton("ATSWFrame", function()
|
||||
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
|
||||
end)
|
||||
end)
|
||||
|
||||
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
|
||||
CreateProfessionLinkButton("ATSWFrame", function()
|
||||
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
|
||||
end)
|
||||
end)
|
||||
@@ -9,4 +9,45 @@ 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";
|
||||
TARGETFOCUS = "Fokussierte Einheit anvisieren";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "K";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "G";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
|
||||
ITEM_MOD_AGILITY_SHORT = "Beweglichkeit";
|
||||
ITEM_MOD_STRENGTH_SHORT = "Stärke";
|
||||
ITEM_MOD_INTELLECT_SHORT = "Intelligenz";
|
||||
ITEM_MOD_SPIRIT_SHORT = "Willenskraft";
|
||||
ITEM_MOD_STAMINA_SHORT = "Ausdauer";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Angriffskraft";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Distanzangriffskraft";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Kritischer Trefferwert (Nah)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Kritischer Trefferwert (Distanz)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Trefferwert (Nah)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Trefferwert (Distanz)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Trefferwert (Zauber)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Kritischer Trefferwert (Zauber)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonusschaden";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonusheilung";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Manaregeneration";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Verteidigung";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Ausweichen";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parieren";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Blocken";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
|
||||
LARGE_NUMBER_SEPERATOR = ".";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "S";
|
||||
SLASH_CLEARFOCUS1 = "/fokusaufheben";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/fokus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
|
||||
|
||||
@@ -7,6 +7,30 @@ GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Health";
|
||||
ITEM_MOD_AGILITY_SHORT = "Agility";
|
||||
ITEM_MOD_STRENGTH_SHORT = "Strength";
|
||||
ITEM_MOD_INTELLECT_SHORT = "Intellect";
|
||||
ITEM_MOD_SPIRIT_SHORT = "Spirit";
|
||||
ITEM_MOD_STAMINA_SHORT = "Stamina";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Attack Power";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Ranged Attack Power";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Critical Strike (Melee)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Critical Strike (Ranged)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Hit (Melee)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Hit (Ranged)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Hit (Spell)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Critical Strike (Spell)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Spell Damage";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Spell Healing";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Mana Regeneration";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defense";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
|
||||
ITEMS_EQUIPPED = "%d |4item:items; equipped";
|
||||
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
|
||||
@@ -24,3 +48,4 @@ SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
TARGETFOCUS = "Target Focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
|
||||
@@ -9,4 +9,45 @@ 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";
|
||||
TARGETFOCUS = "Fijar unidad objetivo";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "o";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
|
||||
ITEM_MOD_MANA_SHORT = "Maná";
|
||||
ITEM_MOD_HEALTH_SHORT = "Salud";
|
||||
ITEM_MOD_AGILITY_SHORT = "agilidad";
|
||||
ITEM_MOD_STRENGTH_SHORT = "fuerza";
|
||||
ITEM_MOD_INTELLECT_SHORT = "intelecto";
|
||||
ITEM_MOD_SPIRIT_SHORT = "espíritu";
|
||||
ITEM_MOD_STAMINA_SHORT = "aguante";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Poder de ataque";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Poder de ataque a distancia";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Golpe crítico (cuerpo a cuerpo)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Golpe crítico (a distancia)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Golpe (cuerpo a cuerpo)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Golpe (a distancia)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Golpe (hechizo)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Golpe crítico (hechizo)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bon. daño";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bon. sanac.";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Regeneración de maná";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defensa";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Esquiva";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parada";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Bloqueo";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "p";
|
||||
SLASH_CLEARFOCUS1 = "/quitarenfoque";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/conjequip";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/enfoque";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
|
||||
|
||||
@@ -9,4 +9,45 @@ 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";
|
||||
TARGETFOCUS = "Cibler l'unité focalisée";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "o";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Vie";
|
||||
ITEM_MOD_AGILITY_SHORT = "Agilité";
|
||||
ITEM_MOD_STRENGTH_SHORT = "Force";
|
||||
ITEM_MOD_INTELLECT_SHORT = "Intelligence";
|
||||
ITEM_MOD_SPIRIT_SHORT = "Esprit";
|
||||
ITEM_MOD_STAMINA_SHORT = "Endurance";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "Puissance d'attaque";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Puissance d'attaque à distance";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Score de critique (mêlée)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Score de critique (distance)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Toucher (mêlée)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Toucher (distance)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Toucher (sorts)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Score de critique (sorts)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonus aux dégâts";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonus aux soins";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "Régénération du mana";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Défense";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Esquive";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Parade";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Blocage";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "a";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/annulerfocal";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/équipens";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focal";
|
||||
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов;
|
||||
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
|
||||
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
|
||||
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "м";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "з";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
|
||||
ITEM_MOD_MANA_SHORT = "к мане";
|
||||
ITEM_MOD_HEALTH_SHORT = "к здоровью";
|
||||
ITEM_MOD_AGILITY_SHORT = "к ловкости";
|
||||
ITEM_MOD_STRENGTH_SHORT = "к силе";
|
||||
ITEM_MOD_INTELLECT_SHORT = "к интеллекту";
|
||||
ITEM_MOD_SPIRIT_SHORT = "к духу";
|
||||
ITEM_MOD_STAMINA_SHORT = "к выносливости";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "к силе атаки";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "к силе атаки дальнего боя";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "к показателю критического удара в ближнем бою";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "к показателю критического удара в дальнем бою";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "к меткости в ближнем бою";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "к меткости в дальнем бою";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "к меткости заклинаний";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "к критическому эффекту заклинаний";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "к дополнительному урону";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "к дополнительному исцелению";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "к восполнению маны";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Защита";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "с";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/сбросфокуса";
|
||||
SLASH_EQUIP_SET1 = "/комплект";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/фокус";
|
||||
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "背包中有%d件物品";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
|
||||
ITEM_MISSING = "%s缺失";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "铜";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "金";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
|
||||
ITEM_MOD_MANA_SHORT = "法力值";
|
||||
ITEM_MOD_HEALTH_SHORT = "生命值";
|
||||
ITEM_MOD_AGILITY_SHORT = "敏捷";
|
||||
ITEM_MOD_STRENGTH_SHORT = "力量";
|
||||
ITEM_MOD_INTELLECT_SHORT = "智力";
|
||||
ITEM_MOD_SPIRIT_SHORT = "精神";
|
||||
ITEM_MOD_STAMINA_SHORT = "耐力";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "攻击强度";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "远程攻击强度";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "爆击(近战)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "爆击(远程)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近战)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(远程)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法术)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "爆击(法术)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "伤害加成";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治疗加成";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "法力回复";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防御";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "招架";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "银";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/focus";
|
||||
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
|
||||
|
||||
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
|
||||
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
|
||||
ITEM_MISSING = "缺少%s物品";
|
||||
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "銅";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "金";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
|
||||
ITEM_MOD_MANA_SHORT = "法力";
|
||||
ITEM_MOD_HEALTH_SHORT = "生命力";
|
||||
ITEM_MOD_AGILITY_SHORT = "敏捷";
|
||||
ITEM_MOD_STRENGTH_SHORT = "力量";
|
||||
ITEM_MOD_INTELLECT_SHORT = "智力";
|
||||
ITEM_MOD_SPIRIT_SHORT = "精神";
|
||||
ITEM_MOD_STAMINA_SHORT = "耐力";
|
||||
ITEM_MOD_ATTACK_POWER_SHORT = "攻擊強度";
|
||||
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "遠程攻擊強度";
|
||||
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "致命一擊(近戰)";
|
||||
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "致命一擊(遠程)";
|
||||
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近戰)";
|
||||
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(遠程)";
|
||||
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法術)";
|
||||
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "致命一擊(法術)";
|
||||
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "傷害加成";
|
||||
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治療加成";
|
||||
ITEM_MOD_MANA_REGENERATION_SHORT = "法力恢復";
|
||||
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防禦";
|
||||
ITEM_MOD_DODGE_RATING_SHORT = "閃躲";
|
||||
ITEM_MOD_PARRY_RATING_SHORT = "招架";
|
||||
ITEM_MOD_BLOCK_RATING_SHORT = "格擋";
|
||||
ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SILVER_AMOUNT_SYMBOL = "銀";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/清除專注目標";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/裝備套裝";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
SLASH_FOCUS2 = "/專注目標";
|
||||
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
|
||||
|
||||
+4
-1
@@ -99,7 +99,10 @@ target_compile_definitions(
|
||||
${PROJECT_NAME}
|
||||
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
|
||||
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
|
||||
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
|
||||
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
|
||||
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
|
||||
|
||||
target_include_directories(
|
||||
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
|
||||
@@ -18,7 +18,7 @@ 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`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `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` |
|
||||
@@ -33,32 +33,39 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [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` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
|
||||
| [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:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `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.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `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.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `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.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
|
||||
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
|
||||
| [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` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit`, `C_Map.GetMapOverlays` |
|
||||
| [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_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` |
|
||||
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
|
||||
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `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`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `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`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [Table](docs/API.md#table) | `table.wipe` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [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` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `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`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
|
||||
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
|
||||
|
||||
### GlueXML calls
|
||||
|
||||
@@ -116,11 +123,16 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `GLOBAL_MOUSE_UP` | `button` |
|
||||
| `HEARTHSTONE_BOUND` | *(none)* |
|
||||
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
|
||||
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
|
||||
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
|
||||
| `LOOT_SCAN_COMPLETED` | *(none)* |
|
||||
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
|
||||
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
|
||||
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
|
||||
| `NAME_PLATE_CREATED` | `nameplateFrame` |
|
||||
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
|
||||
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
|
||||
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
|
||||
| `PLAYER_FOCUS_CHANGED` | *(none)* |
|
||||
| `PLAYER_STARTED_LOOKING` | *(none)* |
|
||||
| `PLAYER_STOPPED_LOOKING` | *(none)* |
|
||||
@@ -130,8 +142,14 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `PLAYER_STOPPED_TURNING` | *(none)* |
|
||||
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
|
||||
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
|
||||
| `QUEST_REMOVED` | `questID` |
|
||||
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
|
||||
|
||||
### Globals
|
||||
|
||||
|
||||
@@ -2725,7 +2725,26 @@ matching modern's 3-tuple. `CastSpellNoToggle("<name>")` macros are
|
||||
also recognized because `Spell::CastNoToggle` already hooks the same
|
||||
parser to register that pattern.
|
||||
|
||||
## 88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event
|
||||
## ~~88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event~~ — DONE
|
||||
|
||||
**Resolution (2026-07-04, src/player/Equipment.cpp):** the 2026-05-22
|
||||
investigation kept trying to hook a FIRER of UNIT_INVENTORY_CHANGED.
|
||||
The winning move was the opposite: the engine has a generic
|
||||
descriptor-field observer system (the "__AUCMirrorHandler__" nodes —
|
||||
registrar `FUN_00467E70`, dispatcher `FUN_00465570` with per-node
|
||||
mirror + memcmp change detection). `FUN_004F8CC0` registers observers
|
||||
for the bag/backpack/bank/keyring GUID ranges but never for equipment
|
||||
`0x4A8..0x538` — so we co-hook it and REGISTER OUR OWN observer per
|
||||
equipment field, mirroring the engine's registration exactly (bank 4,
|
||||
size 8, player GUID). Callback ABI verified from the dispatcher call
|
||||
site at `0x004655DC` + `FUN_004F8DB0`'s `RET 0x10`:
|
||||
`__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
|
||||
oldValue*, userArg)`. hasCurrent read from the invMgr GUID array (the
|
||||
same "current truth" the engine's bag callback reads). No polling, no
|
||||
hot hook, engine-managed lifetime (setup re-runs each enter-world).
|
||||
Offsets documented at `FUN_DESC_OBSERVER_REGISTER` in Offsets.h.
|
||||
|
||||
Original notes below kept for the investigation trail.
|
||||
|
||||
WotLK-era event that fires every time a paperdoll slot changes
|
||||
(equip, unequip, swap). Heavily used by gear-tracking,
|
||||
@@ -2828,7 +2847,33 @@ Same diff feeds `EQUIPMENT_SETS_CHANGED` /
|
||||
module — currently those fire only on user-side mutations, not
|
||||
on inventory changes that affect a set's "equipped count".
|
||||
|
||||
## 89. `UPDATE_INVENTORY_DURABILITY` event
|
||||
## ~~89. `UPDATE_INVENTORY_DURABILITY` event~~ — DONE
|
||||
|
||||
**Resolution (2026-07-04, src/player/Equipment.cpp):** co-hook the
|
||||
engine's inventory-alerts recompute `FUN_004C7EE0` — the routine that
|
||||
walks equipment slots, reads each item's durability/broken state from
|
||||
the descriptor, and unconditionally fires vanilla's
|
||||
`UPDATE_INVENTORY_ALERTS` (event 501). The engine calls it whenever an
|
||||
owned item's fields change (the item post-update handler `FUN_005D9A90`
|
||||
routes every item values-update through it, alongside
|
||||
UNIT_INVENTORY_CHANGED and the bag-update chain) plus equip and
|
||||
enter-world paths — i.e., it IS the engine's own "durability may have
|
||||
changed" signal. Post-original we diff a per-slot {guid, durability}
|
||||
snapshot (seeded silently on the first recompute per enter-world) and
|
||||
fire once per recompute with a real change. No polling, no hot hook.
|
||||
|
||||
**Dead end tried first:** bank-1 (ITEM field) descriptor observers per
|
||||
equipped item, via the TODO #88 observer system. They register without
|
||||
error but are NEVER invoked — item values-updates demonstrably arrive
|
||||
(probed the values pre-pass `FUN_00465330`) and player-bank observers
|
||||
fire in the same session; registration arithmetic fully verified (bank
|
||||
base `FUN_00465690(1)=0x18`, bank walk item 0→1→2, anchors match
|
||||
dispatch). Prime suspect: the registrar's internal wrapper lookup
|
||||
(`FUN_00464890`, the observer registry — NOT the object manager)
|
||||
silently no-ops for item objects. See the DEAD END note at
|
||||
`OFF_DESC_ITEM_DURABILITY` in Offsets.h.
|
||||
|
||||
Original notes below kept for the investigation trail.
|
||||
|
||||
Fires when any equipped item's durability changes — picks up
|
||||
combat damage, repairs, item destruction. Used by Repair-O-Matic
|
||||
|
||||
+1181
-77
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -56,7 +56,8 @@ namespace Lua {
|
||||
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)
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
|
||||
F(SetN, luaL_setn, LUAL_SETN)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
|
||||
@@ -99,6 +99,7 @@ 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);
|
||||
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
|
||||
extern const lua_resume_t Resume;
|
||||
extern const lua_yield_t Yield;
|
||||
extern const luaL_argerror_t ArgError;
|
||||
extern const luaL_setn_t SetN;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
|
||||
+669
-25
@@ -504,6 +504,29 @@ enum Offsets {
|
||||
// layout.
|
||||
OFF_UNIT_FIELD_TARGET = 0x28,
|
||||
|
||||
// Owner/controller GUID fields within `m_objectFields`, each a 64-bit
|
||||
// GUID (lo/hi). Both byte-verified from engine readers:
|
||||
// CHARMEDBY +0x10 — `Script_UnitIsCharmed`
|
||||
// (`mov ecx,[eax+0x10]; or ecx,[eax+0x14]; jz`).
|
||||
// CREATEDBY +0x20 — the unit owner/title builder `FUN_0052FD30`
|
||||
// reads `[desc+0x20]`/`[+0x24]` as the summoner GUID.
|
||||
// A pet/guardian/totem's owner lives in CREATEDBY; a charmed unit's in
|
||||
// CHARMEDBY. `Unit::Pet` reads these to find a minion's player owner.
|
||||
// (The engine uses only these two for ownership — there's a GUID slot
|
||||
// at +0x18 too, but nothing reads it as an owner, so it's left out.)
|
||||
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
|
||||
OFF_UNIT_FIELD_CREATEDBY = 0x20,
|
||||
|
||||
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
|
||||
// Returns 1 for a controllable **pet** (classified by creature family
|
||||
// at descriptor +0x212 → family DBC, with a ChrRaces fallback), a
|
||||
// non-1 value otherwise. The engine's unit-title builder `FUN_0052FD30`
|
||||
// uses it as `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's
|
||||
// Minion" — so it's the exact signal separating pet from minion, which
|
||||
// neither the GUID (pets and guardians share the 0xF14… prefix) nor
|
||||
// UNIT_FLAG_PLAYER_CONTROLLED reliably does.
|
||||
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
|
||||
|
||||
// Party / raid roster counts and the party GUID array referenced
|
||||
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
|
||||
// `UnitTokenFromGUID` to cap its candidate iteration — solo
|
||||
@@ -542,7 +565,99 @@ enum Offsets {
|
||||
// addon's CHAT_MSG_* OnEvent.
|
||||
FUN_CHAT_DISPATCH = 0x0049A870,
|
||||
// Per-player inventory manager lives at this offset on the player object.
|
||||
// +0x00 = u32 slot count (OFF_INVMGR_SLOT_COUNT), +0x04 = u64* GUID
|
||||
// array (see OFF_INVMGR_GUID_ARRAY below for the slot-range map).
|
||||
OFF_PLAYER_INVENTORY_MANAGER = 0x1D38,
|
||||
OFF_INVMGR_SLOT_COUNT = 0x00,
|
||||
|
||||
// --- Descriptor-field observer system --------------------------------
|
||||
// The engine's generic "watch a descriptor field range for changes"
|
||||
// mechanism (nodes tagged "__AUCMirrorHandler__", 0x30 bytes). The
|
||||
// registrar attaches a node to the object's per-field observer anchors;
|
||||
// each node keeps a private MIRROR of the watched bytes, seeded from
|
||||
// the live value at registration. During SMSG_UPDATE_OBJECT processing
|
||||
// the dispatcher FUN_00465570 memcmps live vs mirror and invokes the
|
||||
// callback only on a real change (the mirror is then re-synced by
|
||||
// FUN_004667A0). Firing engine events from the callback is sanctioned —
|
||||
// the engine's own bag observer does exactly that.
|
||||
//
|
||||
// Registrar — __fastcall(int bank /*ecx*/, uint32_t fieldOffset /*edx*/,
|
||||
// uint32_t guidLo, uint32_t guidHi, int size, const void *callback,
|
||||
// void *userArg1, void *userArg2). `guid` selects the watched OBJECT
|
||||
// (player, item, container, …); `bank` selects its field bank
|
||||
// (1 = ITEM, 2 = CONTAINER, 4 = PLAYER — the engine's per-bag
|
||||
// manager FUN_004F91A0 registers bank-2 observers on each equipped
|
||||
// bag's CGContainer); `fieldOffset` is BANK-relative (container
|
||||
// SLOT_1 = 8, not its descriptor-absolute 0x20). The engine's
|
||||
// inventory setup FUN_004F8CC0 registers the player's bag/backpack/
|
||||
// bank/keyring GUID ranges this way — but NOT equipment.
|
||||
FUN_DESC_OBSERVER_REGISTER = 0x00467E70,
|
||||
|
||||
// Unregister — __fastcall(int bank /*ecx*/, uint32_t fieldOffset
|
||||
// /*edx*/, uint32_t guidLo, uint32_t guidHi, const void *callback,
|
||||
// void *userArg1). Removes the node matching (callback, userArg1)
|
||||
// from that object+field's observer list; harmless no-op when
|
||||
// nothing matches. What FUN_004F91A0 calls for an unequipped bag.
|
||||
FUN_DESC_OBSERVER_UNREGISTER = 0x00467FB0,
|
||||
|
||||
// Observer callback ABI (verified from the dispatcher's call site at
|
||||
// 0x004655DC + the engine callback FUN_004F8DB0's RET 0x10):
|
||||
// int __fastcall cb(uint32_t fieldOffset /*ecx, as registered*/,
|
||||
// uint32_t size /*edx*/,
|
||||
// uint32_t guidLo, uint32_t guidHi,
|
||||
// const uint32_t *oldValue /*the node's mirror*/,
|
||||
// void *userArg1) → return 1, callee cleans 0x10.
|
||||
// The live (new) value is NOT passed — read it from the object (the
|
||||
// engine's bag callback reads the invMgr GUID array).
|
||||
|
||||
// The engine's inventory observer setup — registers the bag-slot
|
||||
// GUID-field observers above, once per enter-world (sole caller is the
|
||||
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
|
||||
// Player::Equipment co-hooks it to register the equipment-slot
|
||||
// observers the engine never installs; the co-hook inherits the exact
|
||||
// engine timing + lifetime (nodes die with the player object).
|
||||
FUN_INV_OBSERVER_SETUP = 0x004F8CC0,
|
||||
|
||||
// PLAYER_FIELD_INV_SLOT_HEAD — first of the 19 equipment-slot u64 GUID
|
||||
// fields in the player descriptor (0x4A8..0x538, stride 8; 0x540 is
|
||||
// the first equipped-bag slot). 0-based slot = (offset - 0x4A8) >> 3;
|
||||
// Lua inventory slot (GetInventoryItemLink etc.) = that + 1.
|
||||
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
|
||||
DESC_PLAYER_EQUIP_SLOTS = 19,
|
||||
DESC_OBSERVER_BANK_PLAYER = 4,
|
||||
|
||||
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
|
||||
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
|
||||
// the 0x18-byte OBJECT prefix. u32.
|
||||
//
|
||||
// DEAD END — bank-1 (ITEM field) observers register without error but
|
||||
// are NEVER invoked: item values-updates demonstrably arrive
|
||||
// (verified by probing the values pre-pass FUN_00465330) and
|
||||
// player-bank observers fire in the same session, yet item-bank
|
||||
// callbacks stay silent. Registration arithmetic was fully verified
|
||||
// (bank base FUN_00465690(1)=0x18, next-bank walk FUN_004656E0
|
||||
// item: 0→1→2, anchors match dispatch), so the suspect is the
|
||||
// registrar's internal wrapper lookup (FUN_00464890 — the observer
|
||||
// registry, NOT the object manager) silently no-op'ing for item
|
||||
// objects. UPDATE_INVENTORY_DURABILITY is instead backed by
|
||||
// FUN_INVENTORY_ALERTS_RECOMPUTE below. Constants kept for the
|
||||
// investigation trail.
|
||||
DESC_OBSERVER_BANK_ITEM = 1,
|
||||
OFF_DESC_ITEM_DURABILITY = 0x88,
|
||||
|
||||
// The engine's inventory-alerts recompute — walks the equipment
|
||||
// slots, reads each item's ITEM_FIELD_DURABILITY / MAXDURABILITY /
|
||||
// broken flag from the descriptor, fills the per-slot alert table at
|
||||
// [DAT_00B71F60], and UNCONDITIONALLY fires UPDATE_INVENTORY_ALERTS
|
||||
// (event 501) at the end. The engine calls it whenever an owned
|
||||
// item's fields change (the item post-update handler FUN_005D9A90 —
|
||||
// which also fires UNIT_INVENTORY_CHANGED and the bag-update chain —
|
||||
// routes every item values-update through it) plus equip and
|
||||
// enter-world paths. That makes it the engine's own "durability may
|
||||
// have changed" signal: Player::Equipment co-hooks it and diffs a
|
||||
// {guid, durability} snapshot per slot to back
|
||||
// UPDATE_INVENTORY_DURABILITY. void __fastcall(), no args.
|
||||
FUN_INVENTORY_ALERTS_RECOMPUTE = 0x004C7EE0,
|
||||
// ItemMgr::GetItemBySlot — __thiscall(this, slot) → CGItem* (NULL if empty).
|
||||
// Slot is the engine's linearized slot index, not bagID/slot tuple.
|
||||
FUN_ITEMMGR_GET_ITEM_BY_SLOT = 0x006228A0,
|
||||
@@ -811,6 +926,22 @@ enum Offsets {
|
||||
// folds in the descriptor cost mods).
|
||||
FUN_GET_SPELL_COST = 0x006E31B0,
|
||||
|
||||
// Full spell-castability check for the LOCAL player:
|
||||
// `char __fastcall(const uint8_t *spellRecord /*ecx*/,
|
||||
// int *outNoMana /*edx*/)`.
|
||||
// Resolves the local player internally (no unit arg), then gates
|
||||
// on casting state, stun/confuse flags, mechanic immunities,
|
||||
// shapeshift/form + required stances, aura-state requirements,
|
||||
// combo points (finishers), spell RequiredSkill, and finally the
|
||||
// power check: `cost(FUN_GET_SPELL_COST) <= currentPower` → usable,
|
||||
// else sets `*outNoMana = 1`. Returns nonzero (low byte) when
|
||||
// usable. Does NOT check spell knowledge — so it's valid to feed
|
||||
// an item's on-use spell record (which the player never "knows").
|
||||
// This is the helper the action-usability recompute uses for
|
||||
// player spell slots; `Item::Usable` reuses it for item on-use
|
||||
// spells. Returns 0 cleanly pre-world (no player).
|
||||
FUN_SPELL_IS_USABLE = 0x006E3D60,
|
||||
|
||||
// Effective spell/channel duration (ms):
|
||||
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
|
||||
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
|
||||
@@ -870,12 +1001,231 @@ enum Offsets {
|
||||
|
||||
// `SMSG_SPELL_DELAYED` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`. Body: `guid(u64), delayMs(u32)`. The server
|
||||
// only sends this to the affected caster, so in practice it's always
|
||||
// the local player's cast pushback (taking damage mid-cast). `Spell::
|
||||
// Cast` co-hooks it to extend the tracked cast's end time so the bar
|
||||
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
|
||||
// only sends this to the affected caster — empirically confirmed on
|
||||
// Turtle (melee-pushback test: only the victim's client received it) —
|
||||
// so remote units' pushback is NOT recoverable client-side; only the
|
||||
// local player's cast pushback is trackable. `Spell::Cast` co-hooks it
|
||||
// to extend the tracked cast's end time so the bar reflects pushback.
|
||||
// Mirrored from nampower's SpellDelayedHook.
|
||||
FUN_SPELL_DELAYED = 0x006E74F0,
|
||||
|
||||
// `MSG_CHANNEL_START` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`. Body: `spellId(u32), durationMs(u32)`. Sent
|
||||
// only to the caster when their channel begins, with the server-
|
||||
// authoritative duration. `Spell::Cast` co-hooks it to stamp the
|
||||
// player's channel — required for cast-then-channel spells (Mind
|
||||
// Control: 3 s cast then 60 s channel), whose SMSG_SPELL_START only
|
||||
// covers the cast phase. Address + layout mirrored from nampower's
|
||||
// SpellChannelStartHandlerHook (they hook it too — accepted co-hook
|
||||
// collision, per-channel-start frequency).
|
||||
FUN_SPELL_CHANNEL_START = 0x006E7550,
|
||||
|
||||
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
|
||||
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
|
||||
// broadcast to observers when a started cast aborts (interrupt, death,
|
||||
// movement, fizzle) — but Turtle's core does NOT send it (verified
|
||||
// in-game: interrupts arrive with neither this nor SMSG_SPELL_FAILURE;
|
||||
// observers learn of the abort via SuperWoW instead — see
|
||||
// FUN_UNIT_CLEAR_CASTING_SPELL). `Spell::Cast` co-hooks it anyway as a
|
||||
// backstop for servers that do send it, invalidating the matching
|
||||
// remote-cast cache entry (and the player's own server-stamped chained
|
||||
// recast). Address + packet layout mirrored from nampower's
|
||||
// SpellFailedOtherHandlerHook.
|
||||
FUN_SPELL_FAILED_OTHER = 0x006E8E40,
|
||||
|
||||
// `SMSG_SPELL_FAILURE` handler — same `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)` shape. Body: `casterGuid(u64, plain),
|
||||
// spellId(u32), result(u8)` — a superset of FAILED_OTHER's with a
|
||||
// trailing result byte. Decompiled at 0x006E8D80: resolves the caster
|
||||
// unit (FUN_00468460 typemask 8) and stops its cast visual via the SAME
|
||||
// unit methods FAILED_OTHER's handler calls (FUN_0060d040 +
|
||||
// FUN_00614150), plus extra channel/visual cleanup. Servers derived
|
||||
// from (v)mangos broadcast BOTH packets from Spell::SendInterrupted,
|
||||
// but which one a given core actually emits per abort path varies —
|
||||
// `Spell::Cast` co-hooks this one alongside FAILED_OTHER so a remote
|
||||
// interrupt evicts the cast cache regardless of which sibling arrives.
|
||||
// nampower calls this SpellFailedHandler (offsets.hpp) but doesn't
|
||||
// hook it.
|
||||
FUN_SPELL_FAILURE = 0x006E8D80,
|
||||
|
||||
// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
|
||||
// int spellID, char notify, char cleanup)` (nampower names the offset
|
||||
// but never hooks it). THE engine choke point for "this unit stopped
|
||||
// casting": gated on `spellID == [unit+0xC8C]` (the unit's live
|
||||
// current-cast spellID, set by SMSG_SPELL_START processing), it zeros
|
||||
// that field and clears the casting flag (bit 0x400 at [unit+0xD58]).
|
||||
// Ten callers converge here: both failure-packet handlers, the
|
||||
// SPELL_GO handler (natural completion), the movement handler
|
||||
// (0x006018f0 — moving units aren't casting), the health-diff handler
|
||||
// (0x006046f0, death), the unit animation-event dispatcher
|
||||
// (0x0060e0a0), the local cancel path (0x006e7040, sends
|
||||
// CMSG_CANCEL_CAST), and Spell_C_SpellFailed. Hooking THIS instead of
|
||||
// individual packets makes remote-cast eviction independent of which
|
||||
// packet the server actually sends — load-bearing on Turtle, whose
|
||||
// core does NOT broadcast SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER
|
||||
// on interrupt (verified: neither handler fires while the victim's
|
||||
// cast visual demonstrably stops).
|
||||
FUN_UNIT_CLEAR_CASTING_SPELL = 0x0060D040,
|
||||
|
||||
// CGUnit field: the unit's current-cast spellID (0 = not casting).
|
||||
// The regular-cast analog of OFF_UNIT_FIELD_CHANNEL_SPELL — but a
|
||||
// plain CGUnit member, NOT a descriptor UpdateField (client-derived
|
||||
// from SMSG_SPELL_START, not server-broadcast state).
|
||||
OFF_UNIT_CAST_SPELL = 0xC8C,
|
||||
|
||||
// NetClient message dispatch — `__thiscall void(void *conn, uint32_t
|
||||
// arg, CDataStore *packet)`. Reads the u16 opcode off the packet
|
||||
// cursor, then invokes `[conn + opcode*4 + 0x74]` (the handler table
|
||||
// FUN_005ab650 / FUN_00537a60 register into; per-handler userParam
|
||||
// parallel table at +0xD64). EVERY server message the client processes
|
||||
// funnels through here (the raw-ingress sibling at 0x00537B10 only
|
||||
// special-cases 0x1DD compressed-moves before queueing into this).
|
||||
// Useful as a temporary whole-protocol sniff point: co-hook, peek the
|
||||
// u16 at the cursor, restore, log.
|
||||
FUN_NET_MESSAGE_DISPATCH = 0x00537AA0,
|
||||
|
||||
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
|
||||
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
|
||||
// client sends funnels through it (the argless wrapper `FUN_005AB630`,
|
||||
// nampower's ClientServices_Send, resolves the connection singleton
|
||||
// with the packet in ECX and calls this). The packet's leading u32 is
|
||||
// the opcode; `m_size - m_read` is what goes on the wire.
|
||||
// `Aura::ComboDuration` co-hooks it to snapshot combo points the
|
||||
// moment CMSG_CAST_SPELL leaves — before the server consumes them —
|
||||
// which also covers nampower-queued casts (they re-enter the engine's
|
||||
// cast path and send normally, unlike a Spell_C_CastSpell co-hook,
|
||||
// which nampower's trampoline re-entry would bypass).
|
||||
FUN_NET_SEND = 0x005379A0,
|
||||
|
||||
// Outgoing opcode: CMSG_CAST_SPELL. Body: `spellId(u32),
|
||||
// targetFlags(u16), <target per flags>`.
|
||||
OP_CMSG_CAST_SPELL = 0x12E,
|
||||
|
||||
// Spell.dbc DurationIndex (column 30) — row into SpellDuration.dbc.
|
||||
// Offset = 30*4, consistent with the verified neighbors
|
||||
// (CastingTimeIndex col 18 = +0x48, PowerType col 31 = +0x7C).
|
||||
OFF_SPELL_RECORD_DURATION_INDEX = 0x78,
|
||||
|
||||
// SpellDuration.dbc — records/count slots per docs/DBCs.md. Record:
|
||||
// `{id@+0, baseMs@+4 (i32), perLevelMs@+8 (i32), maxMs@+0xC (i32)}`.
|
||||
// Combo-point finishers are exactly the rows with base != max: the
|
||||
// server computes `base + (max - base) * comboPoints / 5`
|
||||
// ((v)mangos CalculateSpellDuration — verified against the DBC:
|
||||
// Rupture 6000/16000 = 6+2*CP, Kidney Shot 1000/6000 = 1+1*CP,
|
||||
// Slice and Dice 6000/21000 = 6+3*CP, Expose Armor 30000/30000 =
|
||||
// fixed). NOTE: rows can dangle — Turtle's client DBC has no record
|
||||
// for Rip's DurationIndex 87 (records[87] == NULL), which is why
|
||||
// Rip reads duration 0 client-side there.
|
||||
VAR_SPELL_DURATION_RECORDS = 0x00C0D828,
|
||||
VAR_SPELL_DURATION_COUNT = 0x00C0D82C,
|
||||
|
||||
// Combo points, within the CGPlayer `+0xE68` sub-struct (see
|
||||
// OFF_CGPLAYER_INFO). Verified from Script_GetComboPoints
|
||||
// (0x0051A190): CP count byte at `[player+0xE68]+0x1029`, the combo
|
||||
// TARGET's guid (u64) at `[player+0xE68]+0x838` (compared against
|
||||
// the current-target global 0x00B4E2D8 before the Lua function
|
||||
// reports a non-zero count).
|
||||
OFF_CGPLAYER_COMBO_POINTS = 0x1029,
|
||||
OFF_CGPLAYER_COMBO_TARGET = 0x838,
|
||||
|
||||
// ---- Frame-method registries beyond GameTooltip's ------------------
|
||||
// Same per-type registry mechanism as VAR_GAMETOOLTIP_METHOD_REGISTRY
|
||||
// (docs/BlizzardScriptAPI.md "Frame types & their methods" has the
|
||||
// full 23-table catalogue). The dispatcher resolves methods through
|
||||
// the type hierarchy, so a method added to the REGION base registry
|
||||
// (the 19-entry core-layout table: GetWidth/SetPoint/…) is callable
|
||||
// on every region type — frames, buttons, textures, fontstrings.
|
||||
// `Frame::Modern` uses these to backport modern API (SetSize,
|
||||
// SetShown, SetResizeBounds).
|
||||
VAR_REGION_METHOD_REGISTRY = 0x00CF54B4,
|
||||
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
|
||||
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
|
||||
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
|
||||
|
||||
// Engine Script_* method implementations `Frame::Modern` delegates to
|
||||
// (each `int __fastcall(void *L)`, reading self at stack index 1).
|
||||
// Addresses from the docs/raw_methods.txt catalogue. Show/Hide are
|
||||
// PER-BRANCH (Frame vs Texture vs FontString each register their own
|
||||
// script function with its own object typecheck), so SetShown is
|
||||
// registered per branch with the matching delegates.
|
||||
FUN_SCRIPT_REGION_SETWIDTH = 0x007A1F20,
|
||||
FUN_SCRIPT_REGION_SETHEIGHT = 0x007A2150,
|
||||
FUN_SCRIPT_REGION_GETWIDTH = 0x007A1E00,
|
||||
FUN_SCRIPT_REGION_GETHEIGHT = 0x007A2030,
|
||||
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
|
||||
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
|
||||
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
|
||||
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
|
||||
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
|
||||
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
|
||||
FUN_SCRIPT_TEXTURE_SHOW = 0x0079B770,
|
||||
FUN_SCRIPT_TEXTURE_HIDE = 0x0079B830,
|
||||
FUN_SCRIPT_FONTSTRING_SHOW = 0x0079CDB0,
|
||||
FUN_SCRIPT_FONTSTRING_HIDE = 0x0079CE70,
|
||||
|
||||
// ---- World map overlay data (WorldMapOverlay.dbc + WorldMapArea.dbc)
|
||||
// Backs `C_Map.GetMapOverlays`. Instance VAs per docs/DBCs.md;
|
||||
// schemas verified against the extracted DBCs AND the engine's own
|
||||
// reader `Script_GetMapOverlayInfo` (0x004A8A00), which reads the
|
||||
// same fields for its 7 returns.
|
||||
//
|
||||
// WorldMapArea.dbc record (8 fields, 0x20 bytes):
|
||||
// {ID@+0, mapID@+4, areaID@+8 (AreaTable, 0 for continent rows),
|
||||
// name@+0xC (char*, the map directory name GetMapInfo returns),
|
||||
// locLeft/locRight/locTop/locBottom floats}
|
||||
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
|
||||
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
|
||||
OFF_WMA_AREA_ID = 0x08,
|
||||
OFF_WMA_NAME = 0x0C,
|
||||
|
||||
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
|
||||
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
|
||||
// areaID[4]@+8..+0x14 (AreaTable ids the overlay reveals),
|
||||
// mapPointX@+0x18, mapPointY@+0x1C, textureName@+0x20 (char*,
|
||||
// bare name), textureWidth@+0x24, textureHeight@+0x28,
|
||||
// offsetX@+0x2C, offsetY@+0x30, hitRect@+0x34..+0x40}
|
||||
// The engine's Lua surface only exposes EXPLORED overlays: the
|
||||
// discovered-overlay ID list at 0x00B6E630 (count 0x00B6E6B4) gates
|
||||
// Script_GetMapOverlayInfo. Reading the DBC directly bypasses the
|
||||
// exploration gate — every overlay of a zone, explored or not.
|
||||
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
|
||||
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
|
||||
OFF_WMO_WORLDMAP_AREA = 0x04,
|
||||
OFF_WMO_MAP_POINT_X = 0x18,
|
||||
OFF_WMO_MAP_POINT_Y = 0x1C,
|
||||
OFF_WMO_TEXTURE_NAME = 0x20,
|
||||
OFF_WMO_TEXTURE_WIDTH = 0x24,
|
||||
OFF_WMO_TEXTURE_HEIGHT = 0x28,
|
||||
OFF_WMO_OFFSET_X = 0x2C,
|
||||
OFF_WMO_OFFSET_Y = 0x30,
|
||||
|
||||
// Current world-map view selection — the globals the engine's
|
||||
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
|
||||
// continent index, zone index (-1 = whole continent), the default
|
||||
// WorldMapArea row used when no continent is selected, and the
|
||||
// per-continent data blob (base ptr; stride 0x10024 per continent,
|
||||
// continent's own WMA row at +0x04, per-zone WMA row array ptr at
|
||||
// +0x10).
|
||||
VAR_WORLDMAP_CONTINENT_INDEX = 0x0084506C,
|
||||
VAR_WORLDMAP_ZONE_INDEX = 0x00845070,
|
||||
VAR_WORLDMAP_DEFAULT_AREA_ROW = 0x00845074,
|
||||
VAR_WORLDMAP_CONTINENT_DATA = 0x00B6E668,
|
||||
WORLDMAP_CONTINENT_STRIDE = 0x10024,
|
||||
|
||||
// `Script_SetPoint` — the ONE SetPoint implementation, registered in
|
||||
// the Region base table and inherited by every region type. Vanilla's
|
||||
// parser accepts (point, region), (point, region, relPoint[, x, y]),
|
||||
// (point, x, y), and even an explicit nil region — but NOT the bare
|
||||
// one-arg form (`fs:SetPoint("RIGHT")`): a fully-omitted arg 3 is
|
||||
// LUA_TNONE, which fails the string/table/number/nil check and raises
|
||||
// the usage error. Modern clients accept it (anchor to parent at the
|
||||
// same point, zero offsets). `Frame::Modern` co-hooks this and
|
||||
// normalizes the one-arg call to (point, 0, 0) — the vanilla
|
||||
// (point, x, y) form, which is parent-relative and thus exactly the
|
||||
// modern semantic.
|
||||
FUN_SCRIPT_REGION_SETPOINT = 0x007A2540,
|
||||
|
||||
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
|
||||
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
|
||||
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
|
||||
@@ -907,6 +1257,42 @@ enum Offsets {
|
||||
// aura that's still inside its computed duration window.
|
||||
FUN_ON_AURA_REMOVED = 0x00612320,
|
||||
|
||||
// Group-loot roll packet handlers (the inner display functions in
|
||||
// LootRoll.cpp). Each is `__fastcall`; the deserialized 0x48-byte
|
||||
// PendingLootRoll message struct is fully populated at entry and freed
|
||||
// at exit, so read its fields BEFORE calling the original. The register
|
||||
// the message arrives in differs per handler (matched in
|
||||
// loot/History.cpp): ROLL passes it in ECX, WON / ALL_PASSED in EDX.
|
||||
// Backs the C_LootHistory backport (loot/History.cpp) — 1.12 has no
|
||||
// loot-history store, so we reconstruct one from these packets.
|
||||
FUN_LOOT_ROLL_HANDLER = 0x0061C0B0, // SMSG_LOOT_ROLL (msg=ECX)
|
||||
FUN_LOOT_ROLL_WON_HANDLER = 0x0061B9E0, // SMSG_LOOT_ROLL_WON (msg=EDX)
|
||||
FUN_LOOT_ALL_PASSED_HANDLER = 0x0061B640, // SMSG_LOOT_ALL_PASSED (msg=EDX)
|
||||
|
||||
// SMSG_LOOT_START_ROLL (opcode 0x2A1) handler — `__fastcall(ECX = packet)`,
|
||||
// RET 0. The result packets (ROLL/WON/PASSED) carry 0 for the item's
|
||||
// random suffix/seed; only START_ROLL does. It deserializes a fresh
|
||||
// PendingLootRoll node (same 0x20/0x24 field offsets as the message) and
|
||||
// publishes it as the store head at VAR_LOOTROLL_STORE_HEAD, so we read
|
||||
// the suffix straight off that node right after the original runs.
|
||||
FUN_LOOT_START_ROLL_HANDLER = 0x0061B310,
|
||||
VAR_LOOTROLL_STORE_HEAD = 0x00C4DC00, // PendingLootRoll* — newest node
|
||||
|
||||
// PendingLootRoll message field offsets, derived from the SMSG_LOOT_ROLL
|
||||
// deserializer FUN_0061BFA0 (reads GUID/u32/byte fields off the packet).
|
||||
OFF_LOOTROLL_SOURCE_GUID = 0x10, // u64 looted-object GUID (roll identity)
|
||||
OFF_LOOTROLL_SLOT = 0x18, // u32 loot slot
|
||||
OFF_LOOTROLL_ITEM_ID = 0x1C, // u32 itemID
|
||||
// Random-enchant fields for the item link `item:id:enchant:suffix:unique`.
|
||||
// +0x20 is the suffix/random-property ID the engine's item-name builder
|
||||
// FUN_005D8B00 uses (indexes the random-properties DBC for the "of the X"
|
||||
// name); +0x24 is the paired seed/unique value.
|
||||
OFF_LOOTROLL_ITEM_SUFFIX = 0x20, // i32 random suffix / property ID
|
||||
OFF_LOOTROLL_ITEM_SEED = 0x24, // u32 random seed (link unique field)
|
||||
OFF_LOOTROLL_ROLLER_GUID = 0x30, // u64 roller / winner GUID
|
||||
OFF_LOOTROLL_ROLL_NUM = 0x38, // u8 roll (1..100); high bit = didn't roll
|
||||
OFF_LOOTROLL_ROLL_TYPE = 0x40, // u32 rollType: 0 = need, 2 = greed
|
||||
|
||||
// CGPlayer-side sub-struct, allocated for any player-controlled
|
||||
// unit (local self, target, party, raid, inspect targets — all of
|
||||
// them). Holds player-specific data that's *not* in the broadcast
|
||||
@@ -941,6 +1327,17 @@ enum Offsets {
|
||||
// exists on the +0xE68 sub-struct here.
|
||||
OFF_CGPLAYER_INFO = 0xE68,
|
||||
|
||||
// PvP rank fields within the CGPlayer +0xE68 sub-struct (siblings of
|
||||
// the combo-point byte at +0x1029). Read by the item tooltip's
|
||||
// "Requires <rank>" / PVP_MEDAL red-text logic (FUN_0052B650) and
|
||||
// mirrored by C_PlayerInfo.CanUseItem:
|
||||
// +0x102B u8 current PvP honor rank (compared >= item
|
||||
// RequiredHonorRank).
|
||||
// +0x1034 u32 earned PvP-medal/rank bitmask (item RequiredCityRank
|
||||
// R passes when bit (R-1) is set).
|
||||
OFF_CGPLAYER_HONOR_RANK = 0x102B,
|
||||
OFF_CGPLAYER_PVP_MEDALS = 0x1034,
|
||||
|
||||
// CGObject's `GetPosition` virtual — vtable slot 5 (offset 0x14).
|
||||
// Signature: `__thiscall(this, float outBuf[3]) → float *position`.
|
||||
// The returned float* may equal outBuf (the object filled it
|
||||
@@ -1083,6 +1480,7 @@ enum Offsets {
|
||||
OBJ_TYPE_ITEM = 2,
|
||||
OBJ_TYPE_CONTAINER = 4,
|
||||
OBJ_TYPE_UNIT = 8,
|
||||
OBJ_TYPE_PLAYER = 0x10, // what the engine's bag observer FUN_004F8DB0 passes
|
||||
OBJ_TYPE_GAMEOBJECT = 0x20, // 1<<5; passed by FUN_0052AA20 (hover-tooltip populator).
|
||||
|
||||
// `CGObject::GetName` — returns the display-name `const char *` for
|
||||
@@ -1872,6 +2270,15 @@ enum Offsets {
|
||||
OFF_SLA_CLASS_MASK = 0x10,
|
||||
OFF_SLA_EXCLUDE_RACE = 0x14,
|
||||
OFF_SLA_EXCLUDE_CLASS = 0x18,
|
||||
// Skill-up threshold ranks (grey / green) at record fields 10/11.
|
||||
// A row with either nonzero is a craftable recipe; profession
|
||||
// rank-up / passive rows have both zero. Empirically: the
|
||||
// Blacksmithing skill line has 343 SLA rows, 334 with a nonzero
|
||||
// threshold — the 9 excluded are the Apprentice..Artisan rank
|
||||
// spells and skill grants. This is the discriminator
|
||||
// `TradeSkill::Link` uses to build the canonical recipe universe.
|
||||
OFF_SLA_TRIVIAL_SKILL_HIGH = 0x28,
|
||||
OFF_SLA_TRIVIAL_SKILL_LOW = 0x2C,
|
||||
|
||||
// SkillLine.dbc — the skill/category each SLA row points into via
|
||||
// its `skillId` field. Each record carries a 9-locale localized
|
||||
@@ -1904,6 +2311,24 @@ enum Offsets {
|
||||
OFF_PLAYER_SKILL_LINE_ID = 0x00,
|
||||
OFF_PLAYER_SKILL_RANK = 0x10,
|
||||
|
||||
// Live per-skill current/max rank. These are NOT in
|
||||
// `VAR_PLAYER_SKILL_LIST` (that entry's `+0x10` is numTempPoints) —
|
||||
// the real ranks live in the CGPlayer `+0xE68` sub-struct
|
||||
// (`OFF_CGPLAYER_INFO`). `FUN_SKILL_LINE_TO_SLOT`
|
||||
// (`__thiscall(player, skillLineID) -> slot`) maps a SkillLine.dbc
|
||||
// row to its slot; the slot indexes a `SKILL_INFO_STRIDE`-byte
|
||||
// record at `[player+0xE68] + OFF_SKILL_INFO_TABLE + slot*stride`:
|
||||
// current `+0x0`, max `+0x2`, rank bonus `+0x6` (all u16 — add the
|
||||
// bonus when the base value is nonzero). Source: `Script_GetTradeSkillLine`
|
||||
// (`0x004FDD40`) / `Script_GetSkillLineInfo` (`0x004D3610`). Verified
|
||||
// in-game vs. those functions (Tailoring 261/300, Enchanting 186/240).
|
||||
FUN_SKILL_LINE_TO_SLOT = 0x005EA3F0,
|
||||
OFF_SKILL_INFO_TABLE = 0x84C,
|
||||
SKILL_INFO_STRIDE = 0xC,
|
||||
OFF_SKILL_INFO_CUR = 0x0,
|
||||
OFF_SKILL_INFO_MAX = 0x2,
|
||||
OFF_SKILL_INFO_BONUS = 0x6,
|
||||
|
||||
// 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
|
||||
@@ -1940,7 +2365,18 @@ enum Offsets {
|
||||
// Verified via the `IsAssistingRitual` development session by
|
||||
// observing `0xEE00 → 0xEE05` on `/sit` over a chair (the medium-
|
||||
// chair sit value 5).
|
||||
//
|
||||
// Byte 3 (the AnimTier / misc-flags byte, mask 0x02000000 on the
|
||||
// u32) is the **stealth / sneak flag**: set while the unit is in
|
||||
// the sneaking anim tier (Rogue Stealth, Druid Prowl, Shadowmeld),
|
||||
// which is what drives the client's semi-transparent render of a
|
||||
// stealthed unit. Verified via `_classicapi_DiffAll`: a Prowl
|
||||
// toggle from a Cat-Form baseline flips byte 3 `0x00 → 0x02` while
|
||||
// every other descriptor change is aura-array slot metadata — so
|
||||
// this is the only standalone broadcast bit that means "stealthed".
|
||||
// Read by `IsStealthed()`.
|
||||
OFF_UNIT_FIELD_BYTES_1 = 0x210,
|
||||
UNIT_BYTES_1_STEALTH_FLAG = 0x02000000,
|
||||
|
||||
// `__fastcall(void)` engine helper that refreshes the bonus
|
||||
// action bar from the local player's current shapeshift form
|
||||
@@ -2418,10 +2854,10 @@ enum Offsets {
|
||||
// walks the player's slot data, repopulates `VAR_QUEST_LOG_ENTRIES`
|
||||
// / `VAR_QUEST_LOG_ENTRY_COUNT`, then fires QUEST_LOG_UPDATE.
|
||||
//
|
||||
// Hooked by `Quest::Accepted` to snapshot the log pre-/post-rebuild
|
||||
// and fire `QUEST_ACCEPTED(logIndex, questID)` for the diff. Single
|
||||
// quiet target in the `0x004DExxx` quest region — no known DLL
|
||||
// collisions.
|
||||
// Hooked by `Quest::LogEvents` to snapshot the log pre-/post-rebuild
|
||||
// and fire `QUEST_ACCEPTED(logIndex, questID)` / `QUEST_REMOVED(questID)`
|
||||
// for the diff. Single quiet target in the `0x004DExxx` quest region —
|
||||
// no known DLL collisions.
|
||||
FUN_QUEST_LOG_REBUILD = 0x004DE510,
|
||||
|
||||
// Player and pet spellbooks — flat int32 arrays indexed by 0-based slot.
|
||||
@@ -2456,6 +2892,17 @@ enum Offsets {
|
||||
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
|
||||
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
|
||||
|
||||
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
|
||||
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
|
||||
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
|
||||
// knows any *higher* rank than `spellID` (each node tested through the
|
||||
// bitmap helper `0x0060C740`). The item tooltip's "Requires <spell>"
|
||||
// gate is `bitmap(spellID) OR this(spellID)`; C_PlayerInfo.CanUseItem
|
||||
// mirrors that so a RequiredSpell satisfied by a higher known rank
|
||||
// still passes. Does NOT test `spellID` itself — that's the caller's
|
||||
// direct bitmap check.
|
||||
FUN_SPELL_RANK_CHAIN_KNOWN = 0x0060C8D0,
|
||||
|
||||
// Per-spell cooldown query. `__fastcall(int spellID, int bookType,
|
||||
// int *outDuration, int *outStart, int *outEnable)`. Same path
|
||||
// `Script_GetSpellCooldown` (`0x004B40A0`) uses internally after
|
||||
@@ -2850,6 +3297,7 @@ enum Offsets {
|
||||
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
|
||||
SPELLMOD_OP_DURATION = 1, // op index for aura duration
|
||||
|
||||
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
|
||||
// names like "Magic", "Curse", "Disease", "Poison". Used by
|
||||
@@ -3028,6 +3476,17 @@ enum Offsets {
|
||||
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
|
||||
// calls) and calls `lua_error`. Doesn't return.
|
||||
LUAL_ARG_ERROR = 0x6F4810,
|
||||
// `luaL_setn(L, t, n)` — Lua 5.0 auxlib table-length setter
|
||||
// (verified: `FUN_006f4ea0`, __fastcall L/ecx, t/edx, n/stack,
|
||||
// RET 4). Matches 5.0.2 `luaL_setn`: sets `t.n = n` if a numeric
|
||||
// `n` field exists, else `sizes[t] = n` in the registry's weak
|
||||
// LUA_SIZES table. Found via `table.insert` (`FUN_007fb6b0`), which
|
||||
// calls it to record the new length; `luaL_getn` is `FUN_006f5050`.
|
||||
// Needed by `table.wipe` to reset the length after clearing keys —
|
||||
// the length is out-of-band from the keys, so a `lua_next` +
|
||||
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
|
||||
// appends past the wiped slots.
|
||||
LUAL_SETN = 0x6F4EA0,
|
||||
// `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
|
||||
@@ -3112,6 +3571,15 @@ enum Offsets {
|
||||
OFF_EVENT_ENTRY_NAME = 0x00,
|
||||
OFF_EVENT_ENTRY_HEAD = 0x0C,
|
||||
|
||||
// Subscriber-chain node layout — the linked list hanging off each
|
||||
// entry's +0x0C head, derived from Frame::RegisterEvent
|
||||
// (FUN_00702140): each node is `{ ?, next@+0x04, frame@+0x08 }`, where
|
||||
// `frame` is the CFrameScriptObject* subscriber. A node value with its
|
||||
// low bit set (or null) is the end/empty sentinel. Walked by
|
||||
// Frame:IsEventRegistered to test membership.
|
||||
OFF_EVENT_NODE_NEXT = 0x04,
|
||||
OFF_EVENT_NODE_FRAME = 0x08,
|
||||
|
||||
// `Frame::RegisterEvent` — the C++ helper called by the Lua
|
||||
// `frame:RegisterEvent(eventName)` method (`Script_RegisterEvent` at
|
||||
// `0x00774A40`). `__thiscall` with `(this=frame, eventName)`. Walks
|
||||
@@ -3474,6 +3942,30 @@ enum Offsets {
|
||||
FUN_ADDON_GET_TITLE_BY_NAME = 0x0051DF20, // (name) → title string or NULL
|
||||
FUN_ADDON_GET_NOTES_BY_NAME = 0x0051E050, // (name) → notes string or NULL
|
||||
|
||||
// `__fastcall(const char *nameOrEntry) → AddOnEntry*+0x48 or NULL`.
|
||||
// Hash-table lookup by name (accepts an entry pointer too — its
|
||||
// first 12 bytes are the inline name), returning a pointer to the
|
||||
// matched record's RequiredDeps descriptor (`record + 0x48`), or
|
||||
// NULL on a miss. Stock `Script_GetAddOnDependencies` (`0x0048E5E0`)
|
||||
// calls this then reads count@+4 / data@+8 off the return to walk
|
||||
// the required deps. We reuse it only to recover the record base for
|
||||
// string input: `record = resolve(name) - OFF_ADDON_REQDEPS_DESC`.
|
||||
FUN_ADDON_RESOLVE_REQ_DEPS = 0x0051E350,
|
||||
|
||||
// AddOnEntry dependency-array descriptors are `{cap, count, data,
|
||||
// quantum}` (each field 4 bytes) laid out 0x10 apart, parsed by the
|
||||
// TOC parser `FUN_0051c9b0`:
|
||||
// OptionalDeps desc@+0x38 (count@+0x3C, data@+0x40)
|
||||
// RequiredDeps desc@+0x48 (count@+0x4C, data@+0x50) ← stock walks
|
||||
// LoadWith desc@+0x58
|
||||
// Optional offsets verified against the parser's append site at
|
||||
// `0x0051CDBA` (`mov esi,[rec+0x3C]` count, `lea edi,[rec+0x38]`);
|
||||
// required desc@+0x48 confirmed by `FUN_ADDON_RESOLVE_REQ_DEPS`'s
|
||||
// `lea eax,[rec+0x48]` return.
|
||||
OFF_ADDON_REQDEPS_DESC = 0x48,
|
||||
OFF_ADDON_OPTIONALDEPS_COUNT = 0x3C,
|
||||
OFF_ADDON_OPTIONALDEPS_ARRAY = 0x40,
|
||||
|
||||
// Per-field accessors `Script_GetAddOnInfo` calls internally for
|
||||
// returns 5 (loadable), 6 (reason string), 7 (security category).
|
||||
// We dispatch to these directly so the wrappers don't need to
|
||||
@@ -3540,23 +4032,15 @@ enum Offsets {
|
||||
VAR_ADDON_ARRAY = 0x00BE1B94,
|
||||
VAR_ADDON_COUNT = 0x00BE1B90,
|
||||
|
||||
// Player visibility/state byte field, broadcast in UpdateFields.
|
||||
// Found empirically via _classicapi_DescDump (see src/debug/).
|
||||
// Treated as a u32 but the meaningful bits all live in byte 0:
|
||||
// 0x02 — set when the player has any visibility/form-modifying
|
||||
// state active (stealth, mount, possibly shapeshift —
|
||||
// set by stealth and mount in our test data).
|
||||
// 0x08, 0x10 — set together when mounted (alongside 0x02).
|
||||
//
|
||||
// Ambiguity: bit 0x02 alone doesn't distinguish stealth from
|
||||
// other visibility-modifying states. Stealth detection AND-gates
|
||||
// with `mountDisplayID == 0` to exclude mount; if shapeshift
|
||||
// also sets bit 0x02 we'd false-positive on druids in form
|
||||
// (untested). The robust path is to walk the player's aura
|
||||
// list looking for the actual stealth/prowl spell — left as
|
||||
// future work if false-positives turn up.
|
||||
OFF_PLAYER_FIELD_VIS_BYTES = 0x17C,
|
||||
PLAYER_VIS_BIT_STEALTH = 0x02,
|
||||
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
|
||||
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
|
||||
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
|
||||
// (per-slot caster-level bytes; see OFF_UNIT_FIELD_AURALEVELS above),
|
||||
// and the old read only appeared to work by coincidence (a caster-
|
||||
// level byte that happened to have bit 0x02 set on the test char;
|
||||
// at level 60 = 0x3C it doesn't, so it read false). The real stealth
|
||||
// flag is UNIT_FIELD_BYTES_1 byte 3 (UNIT_BYTES_1_STEALTH_FLAG); see
|
||||
// that field above. IsStealthed reads it.
|
||||
|
||||
// Mount display ID — the creature display ID the engine renders
|
||||
// under the mounted player. Zero when not mounted, non-zero
|
||||
@@ -4221,6 +4705,19 @@ enum Offsets {
|
||||
// with craft).
|
||||
VAR_TRADESKILL_ENTRIES = 0x00BDDFC0,
|
||||
VAR_TRADESKILL_COUNT = 0x00BDE04C,
|
||||
// SkillLine.dbc row ID of the currently-open trade skill window
|
||||
// (the profession the player has open). Bounds-checked against
|
||||
// `VAR_SKILL_LINE_COUNT` and used as `records[id]` by
|
||||
// `Script_GetTradeSkillLine` (`0x004FDD40`). <= 0 / stale when no
|
||||
// window is open. `FUN_004FC870` zeroes it on close (and fires
|
||||
// event `0x13B`), so `> 0` reliably means "trade window open".
|
||||
VAR_CURRENT_TRADESKILL_LINE = 0x00BDE040,
|
||||
// Craft window (Enchanting, pet training) open-state flag — nonzero
|
||||
// (3 for the enchanting-style craft) while open; `FUN_004F5FE0`
|
||||
// zeroes it on close (fires event `0x169`). The Craft window has no
|
||||
// skill-line-ID global — `TradeSkill::Link` derives the skill line
|
||||
// from the first craft recipe — so this is the "craft open" gate.
|
||||
VAR_CRAFT_WINDOW_STATE = 0x00BDCFB8,
|
||||
// Spell.dbc "produced item" field for crafting recipes — the
|
||||
// itemID this spell creates when cast. Reagent itemIDs and
|
||||
// counts live at `OFF_SPELL_REAGENT_ID` / `OFF_SPELL_REAGENT_COUNT`
|
||||
@@ -4245,6 +4742,18 @@ enum Offsets {
|
||||
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
|
||||
SPELL_RECORD_EFFECT_COUNT = 3,
|
||||
|
||||
// SPELL_EFFECT_* id array (`Effect[3]`) — five arrays before
|
||||
// EffectBasePoints (`+0x130`), so `0x130 - 5*0xC`. Used to tell craft
|
||||
// recipes from profession abilities (Disenchant etc.) that also carry
|
||||
// skill-up thresholds. Recipe-producing effects: CREATE_ITEM (items),
|
||||
// ENCHANT_ITEM / _TEMPORARY (enchants), and LEARN_SPELL (Turtle's custom
|
||||
// recipes, e.g. "Steel Plate Boots" = effect 36).
|
||||
OFF_SPELL_RECORD_EFFECT = 0xF4, // int32[3]
|
||||
SPELL_EFFECT_CREATE_ITEM = 24,
|
||||
SPELL_EFFECT_LEARN_SPELL = 36,
|
||||
SPELL_EFFECT_ENCHANT_ITEM = 53,
|
||||
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY = 54,
|
||||
|
||||
// EffectBasePoints[3] — the base magnitude of each effect, stored as
|
||||
// (value - 1) for the fixed-die spells that back item stats (the
|
||||
// engine adds a 1..dieSides roll; dieSides == 1 makes it value = base
|
||||
@@ -4285,9 +4794,33 @@ enum Offsets {
|
||||
// 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_STEALTH = 16,
|
||||
SPELL_AURA_MOD_SHAPESHIFT = 36,
|
||||
SPELL_AURA_MOUNTED = 78,
|
||||
|
||||
// Control-loss aura effect types (EffectApplyAuraName values), used by
|
||||
// C_LossOfControl to classify a debuff. Values are authoritative from the
|
||||
// Turtle server's indexed SPELL_AURA proc-handler table (SharedDefines).
|
||||
SPELL_AURA_MOD_POSSESS = 2,
|
||||
SPELL_AURA_MOD_CONFUSE = 5,
|
||||
SPELL_AURA_MOD_CHARM = 6,
|
||||
SPELL_AURA_MOD_FEAR = 7,
|
||||
SPELL_AURA_MOD_STUN = 12,
|
||||
SPELL_AURA_MOD_PACIFY = 25,
|
||||
SPELL_AURA_MOD_ROOT = 26,
|
||||
SPELL_AURA_MOD_SILENCE = 27,
|
||||
SPELL_AURA_MOD_PACIFY_SILENCE = 60,
|
||||
SPELL_AURA_MOD_DISARM = 67,
|
||||
|
||||
// SMSG_SPELL_COOLDOWN (0x134) handler, registered by the spell-opcode
|
||||
// boot registrar FUN_006e7150 exactly like the failure/channel handlers
|
||||
// Spell::Cast co-hooks — same `int __stdcall(uint32_t *opCode,
|
||||
// CDataStore *packet)` shape. Body: GUID(u64) then (spellID, durationMs)
|
||||
// u32 pairs. A player-GUID packet carrying >=2 spells that all share one
|
||||
// duration is a school-interrupt lockout (Player::ProhibitSpellSchool is
|
||||
// the only sender of that shape); see src/lossofcontrol/Info.cpp.
|
||||
FUN_SPELL_COOLDOWN_HANDLER = 0x006E9460,
|
||||
|
||||
// Gossip menu data. Populated by the SMSG_GOSSIP_MESSAGE packet
|
||||
// handler at 0x004E26E0 and reset by FUN_004E26A0 each time a new
|
||||
// gossip window opens. Two parallel storage arrays:
|
||||
@@ -4337,6 +4870,80 @@ enum Offsets {
|
||||
FUN_GOSSIP_SELECT_ACTIVE_QUEST = 0x004E2600,
|
||||
FUN_SCRIPT_CLOSE_GOSSIP = 0x004E2B20,
|
||||
|
||||
// Current gossip NPC GUID (u64). Set by the SMSG_GOSSIP_MESSAGE
|
||||
// handler, cleared by the gossip-close worker at 0x004E1FA0 (which
|
||||
// then fires GOSSIP_CLOSED, event 0x12B). Non-zero = a gossip
|
||||
// session is live — the gate that keeps `C_GossipInfo` from serving
|
||||
// the previous NPC's stale arrays/text after close.
|
||||
VAR_GOSSIP_NPC_GUID = 0x00BC3F58,
|
||||
|
||||
// --- Quest-greeting state (SMSG_QUESTGIVER_QUEST_LIST) -------------
|
||||
// Vanilla's SECOND questgiver path: NPCs without a gossip menu send
|
||||
// SMSG_QUESTGIVER_QUEST_LIST, whose handler (FUN_005dbb10) fills a
|
||||
// storage entirely separate from the gossip arrays and fires
|
||||
// QUEST_GREETING (event 300 = 0x12C) — the QuestFrameGreetingPanel
|
||||
// ("Current Quests" / "Available Quests"). Vanilla Lua reads it via
|
||||
// GetGreetingText / Get{Available,Active}Title / etc.; C_GossipInfo
|
||||
// merges it in so modern-addon ports work on greeting NPCs too.
|
||||
//
|
||||
// Shared quest-session state, written by the panel-begin helper
|
||||
// FUN_00500bd0(panelType, textID, guidLo, guidHi, ...):
|
||||
// VAR_QUESTGIVER_GUID — u64, the current questgiver. Cleared by
|
||||
// the session-close worker FUN_00501130 (fires QUEST_FINISHED,
|
||||
// event 0x130). Non-zero = quest session live.
|
||||
// VAR_QUEST_PANEL_TYPE — i32 panel: 0 greeting, 1 detail,
|
||||
// 2 progress, 3 reward. begin(0) also zeroes both greeting
|
||||
// arrays + counts + the text buffers.
|
||||
VAR_QUESTGIVER_GUID = 0x00BE0810,
|
||||
VAR_QUEST_PANEL_TYPE = 0x00BE0818,
|
||||
|
||||
// GUID the greeting arrays were filled for — written ONLY by the
|
||||
// SMSG_QUESTGIVER_QUEST_LIST handler. The greeting arrays are valid
|
||||
// iff this equals VAR_QUESTGIVER_GUID (and that is non-zero);
|
||||
// otherwise they're leftovers from an older NPC (e.g. the current
|
||||
// quest session came from a gossip-menu quest click, which begins
|
||||
// at panel 1 without ever clearing the greeting arrays).
|
||||
VAR_GREETING_NPC_GUID = 0x00C4D740,
|
||||
|
||||
// Greeting text (0x200 inline, $N/$C-expanded) — what vanilla's
|
||||
// Script_GetGreetingText pushes.
|
||||
VAR_QUEST_GREETING_TEXT = 0x00BDE068,
|
||||
|
||||
// The two greeting quest arrays: 32 entries, stride 0x4C =
|
||||
// { u32 questID @ +0, i32 level @ +4, char title[0x40] @ +8,
|
||||
// u32 extra @ +0x48 (available: status==0 flag; active: always
|
||||
// 0 — the appender FUN_00500e90 never stores the status, see
|
||||
// the scratch arrays below for isComplete) }.
|
||||
// Appenders: FUN_00500e40 (available), FUN_00500e90 (active).
|
||||
// Readers: Script_Get{Available,Active}{Title,Level} @ 0x00501AC0/
|
||||
// 0x00501B30/0x00501BA0/0x00501C20.
|
||||
VAR_GREETING_AVAILABLE_ENTRIES = 0x00BDFE60,
|
||||
VAR_GREETING_ACTIVE_ENTRIES = 0x00BDE690,
|
||||
GREETING_QUESTS_STRIDE = 0x4C,
|
||||
GREETING_QUESTS_MAX = 32,
|
||||
OFF_GREETING_QUEST_ID = 0x000,
|
||||
OFF_GREETING_QUEST_LEVEL = 0x004,
|
||||
OFF_GREETING_QUEST_TITLE = 0x008,
|
||||
VAR_GREETING_AVAILABLE_COUNT = 0x00BE0834,
|
||||
VAR_GREETING_ACTIVE_COUNT = 0x00BE0838,
|
||||
|
||||
// Scratch arrays the SMSG_QUESTGIVER_QUEST_LIST handler fills in
|
||||
// packet order (32 × u32 each, zeroed per packet): questIDs and the
|
||||
// per-quest dialog status. Status 3|4 = active (same sentinel as
|
||||
// the gossip quest rows), 4 = complete/turn-in-ready. The canonical
|
||||
// greeting arrays drop the status, so `isComplete` for an active
|
||||
// greeting quest is recovered by questID lookup here.
|
||||
VAR_GREETING_SCRATCH_IDS = 0x00C4D2B0,
|
||||
VAR_GREETING_SCRATCH_STATUS = 0x00C4D340,
|
||||
|
||||
// Engine greeting-quest selectors — the workers vanilla's
|
||||
// Script_Select{Available,Active}Quest call: `__fastcall(int
|
||||
// idx0Based)`, index straight into the corresponding greeting
|
||||
// array. They validate VAR_QUESTGIVER_GUID internally and send
|
||||
// CMSG_QUESTGIVER_{QUERY_QUEST,COMPLETE_QUEST}.
|
||||
FUN_GREETING_SELECT_AVAILABLE_QUEST = 0x005012A0,
|
||||
FUN_GREETING_SELECT_ACTIVE_QUEST = 0x00501320,
|
||||
|
||||
// CDataStore primitive readers — `__thiscall void(this, void *out)`.
|
||||
// Read at the current cursor (`+0x14`) and advance it by the value
|
||||
// size. We call these directly from `Unit::MirrorTimer` to peek
|
||||
@@ -4506,4 +5113,41 @@ enum Offsets {
|
||||
// matching what `FUN_00494B60` and the trade-attached paths pass
|
||||
// when re-resolving the cursor GUID to a `CGItem *`.
|
||||
FUN_OBJECT_FROM_GUID = 0x00468460,
|
||||
|
||||
// Virtual XML-template registry (a Storm hash table) — the store that
|
||||
// backs `inherits=` and `C_XMLUtil.GetTemplates`. The XML parser
|
||||
// (`FUN_006ede10`) registers every `virtual="true"` frame into it via
|
||||
// `FUN_006ee500`; the inherits resolver reads it via `FUN_006ee6f0`.
|
||||
// Both operate on these globals, so it's the authoritative template list.
|
||||
// See src/xml/Templates.cpp.
|
||||
//
|
||||
// VAR_XML_TEMPLATE_TABLE holds a POINTER to the bucket array (0 / the
|
||||
// array unallocated) and VAR_XML_TEMPLATE_MASK the bucket-count mask;
|
||||
// mask == 0xFFFFFFFF means no template has ever registered (walk = empty).
|
||||
VAR_XML_TEMPLATE_TABLE = 0x00CEEA1C,
|
||||
VAR_XML_TEMPLATE_MASK = 0x00CEEA24,
|
||||
// Bucket struct (0xC bytes): { linkOffset@+0x0, ?@+0x4, chainHead@+0x8 }.
|
||||
// A node's next pointer is at `*(node + linkOffset + 4)`; the tail
|
||||
// sentinel has its low bit set (chain ends on `(ptr & 1) != 0`).
|
||||
XML_TEMPLATE_BUCKET_STRIDE = 0xC,
|
||||
OFF_XML_TEMPLATE_BUCKET_LINKOFF = 0x0,
|
||||
OFF_XML_TEMPLATE_BUCKET_HEAD = 0x8,
|
||||
// Registry node payload: template name string @+0x14, parsed definition
|
||||
// XML node @+0x18 (hash @+0x0, virtual flag @+0x1C — unused here).
|
||||
OFF_XML_TEMPLATE_NODE_NAME = 0x14,
|
||||
OFF_XML_TEMPLATE_NODE_DEF = 0x18,
|
||||
// Parsed XML element node: its tag-name string ("Frame" / "Button" /
|
||||
// "CheckButton" / …) lives at +0x8 — this is the frame "type".
|
||||
OFF_XML_NODE_TAG = 0x8,
|
||||
// The definition node retains its full parsed DOM subtree — used by
|
||||
// GetTemplateInfo to read `<Size>` / `inherits=`. First child @+0x4, next
|
||||
// sibling @+0x1C. Attributes are read by name via the node method
|
||||
// FUN_006f2cf0 (`__thiscall(node, name) -> value string`, null if absent).
|
||||
OFF_XML_NODE_CHILD = 0x4,
|
||||
OFF_XML_NODE_SIBLING = 0x1C,
|
||||
FUN_XML_NODE_GET_ATTRIBUTE = 0x006F2CF0,
|
||||
// By-name template lookup — the same one `inherits=` resolves through.
|
||||
// `__fastcall(const char *name) -> definition node`, 0 if unregistered
|
||||
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
|
||||
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,11 @@ using CanLoadFn_t = uint8_t(__fastcall *)(const uint8_t *entry, char fullCheck,
|
||||
int *outStatus, int *outDepHandle,
|
||||
const char *accountName);
|
||||
using GetAccountName_t = const char *(*)();
|
||||
// Hash-lookup by name → the matched record's RequiredDeps descriptor
|
||||
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
|
||||
// first bytes are the inline name). We use it only to recover the
|
||||
// record base for string input; see `Script_GetAddOnOptionalDependencies`.
|
||||
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
|
||||
|
||||
// Resolves the user's arg to a `const uint8_t *` that the engine's
|
||||
// per-field accessors can consume. They all read the addon name as
|
||||
@@ -295,6 +300,63 @@ int __fastcall Script_DoesAddOnExist(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
|
||||
// `## OptionalDeps:` names as multiple return values, in declared order.
|
||||
// The exact shape of the stock `GetAddOnDependencies`, but reading the
|
||||
// *optional* dep array instead of the required one. No optional deps →
|
||||
// returns nothing.
|
||||
//
|
||||
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
|
||||
// into a `{cap, count, data, quantum}` name-array descriptor at
|
||||
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
|
||||
//
|
||||
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
|
||||
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
|
||||
// raises the usage error; an unknown *name* string returns nothing
|
||||
// (no error), matching the engine's own not-found path.
|
||||
//
|
||||
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
|
||||
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
|
||||
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
|
||||
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
|
||||
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
|
||||
// *name pointer*, NOT the record — reading dep offsets off it lands in
|
||||
// string memory and crashes (a numeric-path caller like pfUI's addon
|
||||
// list hit exactly that). The resolver returns the RequiredDeps
|
||||
// descriptor at `record+0x48`; subtract to recover the base. An unknown
|
||||
// name yields NULL → return nothing (no error), like stock.
|
||||
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
|
||||
static constexpr const char *kUsage =
|
||||
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
|
||||
|
||||
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
|
||||
// stock raises the usage error in both cases.
|
||||
const uint8_t *nameOrEntry = ResolveAddOnName(L);
|
||||
if (nameOrEntry == nullptr) {
|
||||
Game::Lua::Error(L, "%s", kUsage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
|
||||
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
|
||||
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
|
||||
if (reqDesc == nullptr)
|
||||
return 0; // unknown addon name — no error, push nothing
|
||||
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
|
||||
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
|
||||
if (count == 0)
|
||||
return 0;
|
||||
auto names = *reinterpret_cast<const char *const *const *>(
|
||||
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
|
||||
if (names == nullptr)
|
||||
return 0;
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
|
||||
return static_cast<int>(count);
|
||||
}
|
||||
|
||||
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
|
||||
// returns. Slots 0..2 match the engine's own ordering at
|
||||
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
|
||||
@@ -323,6 +385,8 @@ static void RegisterLuaFunctions() {
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
|
||||
&Script_GetAddOnOptionalDependencies);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
"Enum", "AddOnSecurityStatus",
|
||||
kAddOnSecurityStatusEntries,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// See ComboDuration.h for the contract and the protocol findings that
|
||||
// motivate the design (the server never transmits target-debuff
|
||||
// durations in 1.12).
|
||||
|
||||
#include "ComboDuration.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "spell/Mod.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Aura::ComboDuration {
|
||||
|
||||
namespace {
|
||||
|
||||
using Net::CDataStore;
|
||||
|
||||
uint32_t NowMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
|
||||
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
|
||||
|
||||
// The player's live combo points, read the same way Script_GetComboPoints
|
||||
// (0x0051A190) does: the CP byte on the CGPlayer +0xE68 sub-struct. The player
|
||||
// comes from the shared Unit::Identity::PlayerObject() (the object-manager GUID
|
||||
// resolve, our fastest player-object path — returns null pre-world rather than
|
||||
// throwing), so this stays safe from any context the send hook can fire in.
|
||||
uint8_t CurrentComboPoints() {
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
const uint8_t *info = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (info == nullptr)
|
||||
return 0;
|
||||
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
|
||||
}
|
||||
|
||||
// One cast in flight at a time is the practical case (a second finisher
|
||||
// can't be cast until the server consumes the first one's combo points),
|
||||
// so a single slot with a spellID match and a short TTL suffices.
|
||||
struct Capture {
|
||||
uint32_t spellId;
|
||||
uint8_t comboPoints;
|
||||
uint32_t capturedMs;
|
||||
};
|
||||
Capture g_capture{0, 0, 0};
|
||||
|
||||
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
|
||||
// stale (the cast failed / was rejected) and must not scale a later cast.
|
||||
constexpr uint32_t kCaptureTtlMs = 2000;
|
||||
|
||||
int ConsumeCapturedCP(uint32_t spellId) {
|
||||
const Capture c = g_capture;
|
||||
g_capture = {0, 0, 0};
|
||||
if (c.spellId != spellId || c.comboPoints == 0)
|
||||
return 0;
|
||||
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
|
||||
return 0;
|
||||
return c.comboPoints;
|
||||
}
|
||||
|
||||
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
|
||||
// passes through, including casts released from nampower's spell queue
|
||||
// (which bypass any co-hook on Spell_C_CastSpell itself via trampoline
|
||||
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
|
||||
// Snapshot the combo points at the moment the cast request leaves: the
|
||||
// server consumes them before SMSG_SPELL_GO arrives, so reading at
|
||||
// SpellGo time is too late.
|
||||
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
|
||||
CDataStore *packet);
|
||||
NetSend_t g_origNetSend = nullptr;
|
||||
|
||||
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint32_t opcode = Net::Read<uint32_t>(packet);
|
||||
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
g_capture = {spellId, CurrentComboPoints(), NowMs()};
|
||||
}
|
||||
packet->m_read = saved;
|
||||
}
|
||||
g_origNetSend(conn, edx, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _sendHook{
|
||||
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
|
||||
reinterpret_cast<void **>(&g_origNetSend)};
|
||||
|
||||
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
|
||||
|
||||
// Lua-registered overrides for spells whose client DBC row is missing or
|
||||
// wrong. Turtle's reworked Rip is the motivating case: its DurationIndex
|
||||
// dangles (records[87] == NULL in Turtle's SpellDuration.dbc) because the
|
||||
// rework lives entirely server-side, so the values must come from config.
|
||||
struct Override {
|
||||
uint32_t spellId;
|
||||
int32_t baseMs;
|
||||
int32_t maxMs;
|
||||
};
|
||||
constexpr int kMaxOverrides = 64;
|
||||
Override g_overrides[kMaxOverrides];
|
||||
int g_overrideCount = 0;
|
||||
|
||||
const Override *FindOverride(uint32_t spellId) {
|
||||
for (int i = 0; i < g_overrideCount; ++i) {
|
||||
if (g_overrides[i].spellId == spellId)
|
||||
return &g_overrides[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Known values for spells whose duration row DANGLES — a non-zero
|
||||
// DurationIndex pointing at a SpellDuration row the client doesn't have.
|
||||
// Consulted ONLY when that exact condition holds, so the gate is the bug
|
||||
// itself, not a server fingerprint: on a stock client every one of these
|
||||
// resolves to a valid row and this table is never reached, and if the
|
||||
// broken client data is ever fixed the DBC row takes over automatically.
|
||||
//
|
||||
// Turtle's reworked Rip is the known case: all six ranks point at
|
||||
// SpellDuration row 87, which Turtle added on the SERVER only
|
||||
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
|
||||
// i.e. 8s + 2s per combo point); their client patch never shipped the
|
||||
// row (the client table jumps 86 -> 105). Verified the only dangling
|
||||
// duration index in the entire client Spell.dbc.
|
||||
constexpr Override kDanglingRowKnown[] = {
|
||||
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
|
||||
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
|
||||
};
|
||||
|
||||
const Override *FindDanglingRowKnown(uint32_t spellId) {
|
||||
for (const auto &k : kDanglingRowKnown) {
|
||||
if (k.spellId == spellId)
|
||||
return &k;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
|
||||
// -> true on success. Re-registering a spellID replaces its entry.
|
||||
// Values are in seconds to match the C_UnitAuras duration convention;
|
||||
// the formula applied is the server's own:
|
||||
// `base + (max - base) * comboPoints / 5`.
|
||||
int __fastcall Script_RegisterComboDuration(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
|
||||
!Game::Lua::IsNumber(L, 3)) {
|
||||
Game::Lua::Error(
|
||||
L, "Usage: C_UnitAuras.RegisterComboDuration(spellID, "
|
||||
"baseSeconds, maxSeconds)");
|
||||
return 0;
|
||||
}
|
||||
const auto spellId = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
const auto baseMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 2) * 1000.0);
|
||||
const auto maxMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 3) * 1000.0);
|
||||
if (spellId == 0 || baseMs < 0 || maxMs <= 0) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
for (int i = 0; i < g_overrideCount; ++i) {
|
||||
if (g_overrides[i].spellId == spellId) {
|
||||
g_overrides[i] = {spellId, baseMs, maxMs};
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (g_overrideCount >= kMaxOverrides) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
g_overrides[g_overrideCount++] = {spellId, baseMs, maxMs};
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterComboDuration",
|
||||
&Script_RegisterComboDuration);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
|
||||
if (spellRecord == nullptr || spellId == 0)
|
||||
return 0;
|
||||
const int cp = ConsumeCapturedCP(spellId);
|
||||
if (cp <= 0 || cp > 5)
|
||||
return 0;
|
||||
|
||||
int32_t baseMs = 0;
|
||||
int32_t maxMs = 0;
|
||||
if (const Override *o = FindOverride(spellId)) {
|
||||
baseMs = o->baseMs;
|
||||
maxMs = o->maxMs;
|
||||
} else {
|
||||
const int idx = *reinterpret_cast<const int *>(
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_DURATION_INDEX);
|
||||
const uint8_t *row =
|
||||
DBC::Record(Offsets::VAR_SPELL_DURATION_RECORDS,
|
||||
Offsets::VAR_SPELL_DURATION_COUNT,
|
||||
static_cast<uint32_t>(idx));
|
||||
if (row != nullptr) {
|
||||
baseMs = *reinterpret_cast<const int32_t *>(row + 0x4);
|
||||
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
|
||||
} else if (idx != 0) {
|
||||
// Dangling duration index — the client data is broken for this
|
||||
// spell. Fall back to the built-in known values (Turtle Rip).
|
||||
const Override *k = FindDanglingRowKnown(spellId);
|
||||
if (k == nullptr)
|
||||
return 0;
|
||||
baseMs = k->baseMs;
|
||||
maxMs = k->maxMs;
|
||||
} else {
|
||||
return 0; // no duration row at all
|
||||
}
|
||||
}
|
||||
|
||||
// The server scales only when the row has a combo range: base != max
|
||||
// ((v)mangos CalculateSpellDuration). base < 0 is the "infinite"
|
||||
// sentinel; fixed-duration rows (base == max) take the regular path.
|
||||
if (baseMs < 0 || maxMs <= 0 || baseMs == maxMs)
|
||||
return 0;
|
||||
|
||||
int32_t durationMs = baseMs + (maxMs - baseMs) * cp / 5;
|
||||
if (durationMs <= 0)
|
||||
return 0;
|
||||
|
||||
// Player duration SpellMods apply on top of the combo-scaled value,
|
||||
// matching the server's order (scale, then ApplySpellMod).
|
||||
durationMs = static_cast<int32_t>(Spell::Mod::Apply(
|
||||
spellRecord, Offsets::SPELLMOD_OP_DURATION,
|
||||
static_cast<float>(durationMs)));
|
||||
return durationMs > 0 ? static_cast<uint32_t>(durationMs) : 0;
|
||||
}
|
||||
|
||||
} // namespace Aura::ComboDuration
|
||||
@@ -0,0 +1,50 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// `Aura::ComboDuration` — combo-point-scaled durations for finisher
|
||||
// auras (Rupture, Kidney Shot, Slice and Dice, Rip, …).
|
||||
//
|
||||
// The 1.12 server computes a finisher's real duration at cast time as
|
||||
// `base + (max - base) * comboPoints / 5` from the spell's
|
||||
// SpellDuration.dbc row and never transmits the result for auras the
|
||||
// player puts on OTHER units (verified with a whole-protocol sniff:
|
||||
// SMSG_UPDATE_AURA_DURATION is self-scoped, and nothing else carries
|
||||
// it). So this module mirrors the computation client-side:
|
||||
//
|
||||
// - a co-hook on the NetClient send (`FUN_NET_SEND`) snapshots the
|
||||
// player's combo points the instant CMSG_CAST_SPELL leaves — the
|
||||
// one moment the count is still intact (the server clears it
|
||||
// before SMSG_SPELL_GO comes back);
|
||||
// - `TryComboScaledMs` consumes that snapshot when `Aura::Source`'s
|
||||
// SpellGo hook stores the aura, computing the scaled duration from
|
||||
// SpellDuration.dbc and applying the player's duration SpellMods on
|
||||
// top, matching the server's order of operations. Spells whose
|
||||
// duration index DANGLES (client data bug — Turtle's reworked Rip
|
||||
// is the known case) fall back to built-in values gated on that
|
||||
// exact condition; a Lua-registered override
|
||||
// (`C_UnitAuras.RegisterComboDuration`) beats both, as an escape
|
||||
// hatch for other servers whose values differ from the client DBC.
|
||||
namespace Aura::ComboDuration {
|
||||
|
||||
// Combo-scaled duration (ms) for a player-cast spell, or 0 when it
|
||||
// doesn't apply: no combo points captured for this cast, the spell's
|
||||
// duration row isn't combo-scaled (base == max) and has no override,
|
||||
// or the row is missing. A 0 return means "use the regular base
|
||||
// duration path".
|
||||
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
|
||||
|
||||
} // namespace Aura::ComboDuration
|
||||
+60
-4
@@ -492,6 +492,50 @@ void PushFromCache(void *L, const uint8_t *unit,
|
||||
c.casterGuid);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
// descriptor holds at least one raw aura the unit is in view and fully synced,
|
||||
// so it's authoritative — any cached entry for the unit whose spell isn't in
|
||||
// the array was removed while we couldn't see it (e.g. cancelled out of range,
|
||||
// so no OnAuraRemoved reached us) and must be dropped before the fallback
|
||||
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
|
||||
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
|
||||
// but flag-hidden is still legitimately on the unit. Skips when the array is
|
||||
// empty — that can't be told apart from an out-of-range descriptor drop, and
|
||||
// reconciling then would wipe still-valid out-of-range entries.
|
||||
void ReconcileCache(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
uint32_t present[Offsets::UNIT_AURA_TOTAL];
|
||||
int n = 0;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = ReadSpellID(unit, slot);
|
||||
if (id != 0)
|
||||
present[n++] = id;
|
||||
}
|
||||
if (n == 0)
|
||||
return;
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
|
||||
}
|
||||
|
||||
// True if `unit`'s descriptor currently exposes any visible aura in either
|
||||
// range. The cache fallback is only trustworthy when the descriptor has been
|
||||
// wholesale-cleared: the two states it exists for — rogue stealth and party
|
||||
// range fluctuation — clear the *entire* aura array at once. If the unit shows
|
||||
// even one live aura, the descriptor is authoritative, so a cache entry the
|
||||
// descriptor lacks is a genuinely-gone aura we never saw removed (e.g. a
|
||||
// no-duration debuff — Ghost/Spirit — on a unit that died and resurrected out
|
||||
// of our visibility, so no OnAuraRemoved fired and its infinite entry never
|
||||
// expired). Surfacing it then produces a stuck phantom.
|
||||
bool DescriptorHasVisibleAura(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
if (IsSlotPopulated(unit, slot))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
|
||||
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
|
||||
if (unit == nullptr)
|
||||
@@ -515,12 +559,18 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
|
||||
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
|
||||
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
|
||||
// (no double-listing the same live aura) and, when player-filtered, must have
|
||||
// been cast by us. Superseded / dispelled auras are kept out of the cache by
|
||||
// `Aura::Source`'s removal eviction, not filtered here.
|
||||
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
|
||||
// already be in a populated descriptor slot (no double-listing the same live
|
||||
// aura) and, when player-filtered, must have been cast by us. Superseded /
|
||||
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
|
||||
// eviction, not filtered here.
|
||||
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
Filter filter, bool playerOnly) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
|
||||
// the descriptor path drops these via IsSlotPopulated, so
|
||||
// the cache path must apply the same engine predicate or
|
||||
// they leak through as tooltip-less phantom buffs.
|
||||
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
|
||||
return false; // still in the descriptor — returned via the slot path
|
||||
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
|
||||
@@ -534,6 +584,9 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return false; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
// than `oneBasedIndex` there, so the cache supplies index
|
||||
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
|
||||
@@ -562,6 +615,9 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return;
|
||||
|
||||
+69
-5
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "Source.h"
|
||||
|
||||
#include "ComboDuration.h"
|
||||
#include "Data.h"
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -149,10 +150,40 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
|
||||
void FlushAll() {
|
||||
for (auto &e : g_cache)
|
||||
e.used = false;
|
||||
}
|
||||
|
||||
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
|
||||
// records the current map without flushing.
|
||||
int g_lastMapId = -1;
|
||||
|
||||
// Drop entries whose timed aura has elapsed so the table doesn't fill with
|
||||
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
|
||||
// until overwritten.
|
||||
//
|
||||
// Also flush the entire cache on a map-ID change. A battleground/instance/
|
||||
// continent transition (including the teleport out of a BG via /afk) bulk-
|
||||
// clears the player's aura descriptor WITHOUT firing per-aura OnAuraRemoved,
|
||||
// so entries for auras that are now gone would linger and the
|
||||
// GetAuraDataByIndex fallback would keep surfacing them — the "buffs/debuffs
|
||||
// stuck after leaving a battleground" report. Every other unit from the old
|
||||
// map has despawned too, and the local player's real auras re-sync into the
|
||||
// descriptor / PLAYER_BUFF tables on entering the new world, so a full flush
|
||||
// loses nothing legitimate. Crucially, the descriptor drops the fallback
|
||||
// must survive — rogue stealth and party-range fluctuation — do NOT change
|
||||
// the map ID, so they're unaffected.
|
||||
void OnWorldTick() {
|
||||
const int mapId = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
|
||||
if (mapId != g_lastMapId) {
|
||||
if (g_lastMapId != -1)
|
||||
FlushAll();
|
||||
g_lastMapId = mapId;
|
||||
}
|
||||
|
||||
const uint32_t now = NowMs();
|
||||
for (auto &e : g_cache) {
|
||||
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
|
||||
@@ -203,7 +234,15 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
|
||||
return;
|
||||
|
||||
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
|
||||
const uint32_t durationMs = SpellDurationMs(rec, casterIsPlayer);
|
||||
// Combo-point finishers (Rupture, Kidney Shot, …) have their real
|
||||
// duration computed at cast time from the combo points spent — the
|
||||
// base-duration helper can't know it. ComboDuration mirrors the
|
||||
// server's computation from the CP snapshot its send hook captured;
|
||||
// 0 means "not combo-scaled", fall through to the base path.
|
||||
uint32_t durationMs =
|
||||
casterIsPlayer ? ComboDuration::TryComboScaledMs(rec, spellId) : 0;
|
||||
if (durationMs == 0)
|
||||
durationMs = SpellDurationMs(rec, casterIsPlayer);
|
||||
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
|
||||
|
||||
if (numTargets == 0) {
|
||||
@@ -286,10 +325,18 @@ const Game::HookAutoRegister _hookAuraStacks{
|
||||
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
|
||||
|
||||
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
|
||||
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
|
||||
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
|
||||
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
|
||||
// stack); `slot` is unused here — we evict by spellId.
|
||||
// dispelled, was cancelled by its owner, or was replaced by a higher rank
|
||||
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
|
||||
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
|
||||
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
|
||||
// here — we evict by spellId.
|
||||
//
|
||||
// Always evict, even for a death-persistent aura (flask, world buff) on a
|
||||
// dead unit: the server keeps those through death WITHOUT changing any
|
||||
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
|
||||
// for one is therefore genuine — e.g. the owner cancelled the flask while
|
||||
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
|
||||
// whose SetUnitAura tooltip is empty.
|
||||
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
|
||||
uint32_t spellId);
|
||||
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
|
||||
@@ -322,6 +369,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
return false;
|
||||
}
|
||||
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
|
||||
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
|
||||
return;
|
||||
for (auto &e : g_cache) {
|
||||
if (!e.used || e.targetGuid != unitGuid)
|
||||
continue;
|
||||
bool present = false;
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (presentSpellIds[i] == e.spellId) {
|
||||
present = true;
|
||||
break;
|
||||
}
|
||||
if (!present)
|
||||
e.used = false;
|
||||
}
|
||||
}
|
||||
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
|
||||
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
|
||||
return 0;
|
||||
|
||||
@@ -50,6 +50,18 @@ struct CachedAura {
|
||||
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
|
||||
};
|
||||
|
||||
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
|
||||
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
|
||||
// authoritative descriptor when it is back in view (a populated aura array
|
||||
// means the unit is fully synced): an entry the descriptor no longer lists
|
||||
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
|
||||
// while out of our range, whose `OnAuraRemoved` we never received — so drop
|
||||
// it before the descriptor-drop fallback resurfaces it as a phantom. The
|
||||
// caller must only invoke this when the descriptor is populated (count > 0);
|
||||
// an empty array can't distinguish "out of range" from "genuinely buffless",
|
||||
// so reconciling then would wrongly wipe still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
|
||||
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
|
||||
// whose helpful/harmful classification matches `harmful`. Returns the count
|
||||
// written. The classification is recorded from the aura's descriptor slot at
|
||||
|
||||
+29
-26
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
|
||||
|
||||
// ---------------- encode ----------------
|
||||
|
||||
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
|
||||
// switches to counting mode (end = NULL, accumulates bytes_needed) and
|
||||
// encoding must CONTINUE so the final tally is complete — a nested
|
||||
// container's tally only reaches the parent via close_container. So
|
||||
// CborErrorOutOfMemory is not a failure here; the caller reads
|
||||
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
|
||||
bool EncOk(CborError err) {
|
||||
return err == CborNoError || err == CborErrorOutOfMemory;
|
||||
}
|
||||
|
||||
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
|
||||
|
||||
bool IsLuaArray(void *L, int idx, int &outLen) {
|
||||
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
int arrayLen = 0;
|
||||
if (IsLuaArray(L, idx, arrayLen)) {
|
||||
CborEncoder arr;
|
||||
if (cbor_encoder_create_array(enc, &arr,
|
||||
static_cast<size_t>(arrayLen)) != CborNoError)
|
||||
if (!EncOk(cbor_encoder_create_array(enc, &arr,
|
||||
static_cast<size_t>(arrayLen))))
|
||||
return false;
|
||||
for (int i = 1; i <= arrayLen; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i));
|
||||
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
}
|
||||
Game::Lua::SetTop(L, -2);
|
||||
}
|
||||
return cbor_encoder_close_container(enc, &arr) == CborNoError;
|
||||
return EncOk(cbor_encoder_close_container(enc, &arr));
|
||||
}
|
||||
|
||||
const int pairs = CountTablePairs(L, idx);
|
||||
CborEncoder map;
|
||||
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
|
||||
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
|
||||
return false;
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, idx) != 0) {
|
||||
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
}
|
||||
Game::Lua::SetTop(L, -2);
|
||||
}
|
||||
return cbor_encoder_close_container(enc, &map) == CborNoError;
|
||||
return EncOk(cbor_encoder_close_container(enc, &map));
|
||||
}
|
||||
|
||||
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
switch (Game::Lua::Type(L, idx)) {
|
||||
case Game::Lua::TYPE_NIL:
|
||||
return cbor_encode_null(enc) == CborNoError;
|
||||
return EncOk(cbor_encode_null(enc));
|
||||
case Game::Lua::TYPE_BOOLEAN:
|
||||
return cbor_encode_boolean(enc,
|
||||
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
|
||||
return EncOk(cbor_encode_boolean(enc,
|
||||
Game::Lua::ToBoolean(L, idx) != 0));
|
||||
case Game::Lua::TYPE_NUMBER: {
|
||||
const double n = Game::Lua::ToNumber(L, idx);
|
||||
// Whole numbers in [-2^53, 2^53] go out as integers so
|
||||
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
if (std::isfinite(n) && n == std::floor(n) &&
|
||||
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
|
||||
if (n >= 0.0)
|
||||
return cbor_encode_uint(enc,
|
||||
static_cast<uint64_t>(n)) == CborNoError;
|
||||
return cbor_encode_negative_int(enc,
|
||||
static_cast<uint64_t>(-n)) == CborNoError;
|
||||
return EncOk(cbor_encode_uint(enc,
|
||||
static_cast<uint64_t>(n)));
|
||||
return EncOk(cbor_encode_negative_int(enc,
|
||||
static_cast<uint64_t>(-n)));
|
||||
}
|
||||
return cbor_encode_double(enc, n) == CborNoError;
|
||||
return EncOk(cbor_encode_double(enc, n));
|
||||
}
|
||||
case Game::Lua::TYPE_STRING: {
|
||||
const char *s = Game::Lua::ToString(L, idx);
|
||||
const unsigned int n = Game::Lua::StrLen(L, idx);
|
||||
return cbor_encode_text_string(enc, s, n) == CborNoError;
|
||||
return EncOk(cbor_encode_text_string(enc, s, n));
|
||||
}
|
||||
case Game::Lua::TYPE_TABLE:
|
||||
return EncodeTable(L, idx, enc, depth);
|
||||
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// tinycbor encodes into a fixed-size buffer; if it overflows, it
|
||||
// tracks the extra bytes needed so we can resize and retry.
|
||||
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
|
||||
// encoding in counting mode (EncOk treats OutOfMemory as success) so
|
||||
// the extra-bytes tally is complete, then we resize and retry once.
|
||||
std::vector<uint8_t> buf(256);
|
||||
for (;;) {
|
||||
CborEncoder enc;
|
||||
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
|
||||
if (!EncodeValue(L, 1, &enc, 0)) {
|
||||
// If it was a buffer-size issue we'll catch it via the
|
||||
// extra-bytes-needed check below; otherwise this is a
|
||||
// genuine "value not representable" failure.
|
||||
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
|
||||
if (extra == 0)
|
||||
return 0;
|
||||
buf.resize(buf.size() + extra);
|
||||
continue;
|
||||
}
|
||||
if (!EncodeValue(L, 1, &enc, 0))
|
||||
return 0; // genuine "value not representable" failure
|
||||
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
|
||||
if (extra > 0) {
|
||||
buf.resize(buf.size() + extra);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Modern Region/Frame method backports.
|
||||
//
|
||||
// Modern addon ports routinely call these methods unconditionally —
|
||||
// they're the C-level frame surface later clients take for granted.
|
||||
// This module supplies the ones vanilla lacks (surfaced via
|
||||
// AI_VoiceOver's modern code path, but generic to every port):
|
||||
//
|
||||
// - `region:SetPoint("RIGHT")` — the one-arg form. Vanilla's parser
|
||||
// accepts every other modern form but errors when arg 3 is fully
|
||||
// omitted; a co-hook on Script_SetPoint normalizes it to
|
||||
// `(point, 0, 0)` (parent-relative, zero offsets — the exact modern
|
||||
// semantic).
|
||||
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
|
||||
// REGION base method registry, so they resolve on frames, buttons,
|
||||
// textures, fontstrings — everything.
|
||||
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
|
||||
// (each with its own object typecheck), so SetShown is registered
|
||||
// separately on Frame / Texture / FontString with matching delegates.
|
||||
// - `frame:SetResizeBounds(minW, minH [, maxW, maxH])` — the modern
|
||||
// rename of vanilla's SetMinResize/SetMaxResize pair.
|
||||
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
|
||||
// entirely. Chains after any existing handler and invokes `handler`
|
||||
// with modern positional args (see the implementation comment).
|
||||
//
|
||||
// Implementation style: each new method delegates to the ENGINE's own
|
||||
// Script_* implementations by reshaping the Lua stack and calling them
|
||||
// (`int __fastcall(void *L)`, self at index 1) — no re-derived setters,
|
||||
// so pixel/UI-scale conversion, object resolution, and type checking are
|
||||
// all the engine's own code. Values are read out BEFORE any stack
|
||||
// reshaping.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Frame::Modern {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
int CallScript(uintptr_t fn, void *L) {
|
||||
return reinterpret_cast<ScriptFn_t>(fn)(L);
|
||||
}
|
||||
|
||||
// ---- SetSize / GetSize (Region base — all region types) -------------------
|
||||
|
||||
// `region:SetSize(width, height)` → SetWidth + SetHeight.
|
||||
int __fastcall Script_SetSize(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
|
||||
Game::Lua::Error(L, "Usage: region:SetSize(width, height)");
|
||||
return 0;
|
||||
}
|
||||
const double h = Game::Lua::ToNumber(L, 3);
|
||||
Game::Lua::SetTop(L, 2); // (self, width)
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_SETWIDTH, L);
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushNumber(L, h);
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_SETHEIGHT, L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `region:GetSize()` → width, height.
|
||||
int __fastcall Script_GetSize(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_GETWIDTH, L); // (self, w)
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_GETHEIGHT, L); // (self, w, h)
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
|
||||
|
||||
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
|
||||
// matching modern semantics.
|
||||
template <uintptr_t ShowFn, uintptr_t HideFn>
|
||||
int __fastcall Script_SetShown(void *L) {
|
||||
const bool shown = Game::Lua::ToBoolean(L, 2) != 0;
|
||||
Game::Lua::SetTop(L, 1); // (self) — Show/Hide take no args
|
||||
CallScript(shown ? ShowFn : HideFn, L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- SetResizeBounds (Frame) -----------------------------------------------
|
||||
|
||||
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
|
||||
// modern rename of the vanilla SetMinResize/SetMaxResize pair.
|
||||
int __fastcall Script_SetResizeBounds(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
|
||||
Game::Lua::Error(L, "Usage: frame:SetResizeBounds(minWidth, "
|
||||
"minHeight [, maxWidth, maxHeight])");
|
||||
return 0;
|
||||
}
|
||||
const bool haveMax = Game::Lua::IsNumber(L, 4) && Game::Lua::IsNumber(L, 5);
|
||||
const double maxW = haveMax ? Game::Lua::ToNumber(L, 4) : 0.0;
|
||||
const double maxH = haveMax ? Game::Lua::ToNumber(L, 5) : 0.0;
|
||||
Game::Lua::SetTop(L, 3); // (self, minW, minH)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMINRESIZE, L);
|
||||
if (haveMax) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushNumber(L, maxW);
|
||||
Game::Lua::PushNumber(L, maxH);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMAXRESIZE, L);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- HookScript (Frame) ----------------------------------------------------
|
||||
|
||||
// `frame:HookScript(scriptType, handler)` — vanilla has no HookScript at
|
||||
// all. Chains onto any existing handler: the old handler runs first,
|
||||
// vanilla-style (no args — it reads the `this`/`event`/`argN` globals like
|
||||
// every 1.12 handler), then `handler` is invoked MODERN-style with
|
||||
// positional arguments `(frame, event, arg1..arg9)` for OnEvent and
|
||||
// `(frame, arg1..arg9)` otherwise — mirroring the compat convention
|
||||
// modern addon ports expect (and ship themselves when they detect a
|
||||
// legacy client). The chain is a C closure over (handler, old, isOnEvent).
|
||||
|
||||
// Pushes _G[name].
|
||||
void PushGlobal(void *L, const char *name) {
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
}
|
||||
|
||||
// The chained handler the engine actually invokes (with no args, like any
|
||||
// vanilla script handler). Upvalues: 1 = hook handler, 2 = old handler
|
||||
// (or nil), 3 = isOnEvent (number). Errors inside lua_call propagate to
|
||||
// the engine's own protected script dispatch.
|
||||
int __fastcall HookThunk(void *L) {
|
||||
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(2)) ==
|
||||
Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2));
|
||||
Game::Lua::Call(L, 0, 0);
|
||||
}
|
||||
const bool isOnEvent =
|
||||
Game::Lua::ToNumber(L, Game::Lua::UpvalueIndex(3)) != 0;
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
PushGlobal(L, "this");
|
||||
if (isOnEvent)
|
||||
PushGlobal(L, "event");
|
||||
static const char *const kArgs[] = {"arg1", "arg2", "arg3", "arg4",
|
||||
"arg5", "arg6", "arg7", "arg8",
|
||||
"arg9"};
|
||||
for (const char *a : kArgs)
|
||||
PushGlobal(L, a);
|
||||
Game::Lua::Call(L, (isOnEvent ? 2 : 1) + 9, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_HookScript(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2) ||
|
||||
Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::Error(L, "Usage: frame:HookScript(\"type\", function)");
|
||||
return 0;
|
||||
}
|
||||
const char *scriptName = Game::Lua::ToString(L, 2);
|
||||
const bool isOnEvent =
|
||||
scriptName != nullptr && std::strcmp(scriptName, "OnEvent") == 0;
|
||||
|
||||
Game::Lua::SetTop(L, 3); // (self, name, handler)
|
||||
// GetScript reads self/name at 1/2 and pushes the current handler
|
||||
// (or nil) — extra stack entries above are ignored by its reads.
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (.., old)
|
||||
// Closure upvalues, in upvalue order: handler, old, isOnEvent.
|
||||
Game::Lua::PushValue(L, 3); // handler
|
||||
Game::Lua::PushValue(L, 4); // old
|
||||
Game::Lua::PushNumber(L, isOnEvent ? 1.0 : 0.0); // flag
|
||||
Game::Lua::PushCClosure(L, &HookThunk, 3); // (self,name,handler,old,closure)
|
||||
Game::Lua::Remove(L, 4); // drop old
|
||||
Game::Lua::Remove(L, 3); // drop handler → (self,name,closure)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- IsEventRegistered (Frame) ---------------------------------------------
|
||||
|
||||
// `frame:IsEventRegistered("event")` — vanilla has RegisterEvent /
|
||||
// UnregisterEvent but not the query. There's no engine Script_* to
|
||||
// delegate to, so we replicate the membership check that lives inside
|
||||
// Frame::RegisterEvent (FUN_00702140): find the event by name in the live
|
||||
// event table, then walk that entry's subscriber chain for this frame.
|
||||
// The frame pointer we compare against is the same CFrameScriptObject*
|
||||
// the engine stores (Game::Lua::ResolveObject resolves self exactly as
|
||||
// Script_RegisterEvent does).
|
||||
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
|
||||
|
||||
bool FrameHasEvent(void *frame, const char *eventName) {
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
|
||||
auto *base = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
|
||||
if (count == 0 || base == nullptr)
|
||||
return false;
|
||||
|
||||
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
|
||||
|
||||
for (uint32_t 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 (sstrcmpi(name, eventName, 0x7FFFFFFF) != 0)
|
||||
continue;
|
||||
// Names are unique, so this is the one entry — walk its chain.
|
||||
uintptr_t node = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
while ((node & 1) == 0 && node != 0) {
|
||||
if (*reinterpret_cast<void *const *>(
|
||||
node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
|
||||
return true;
|
||||
node = *reinterpret_cast<const uintptr_t *>(
|
||||
node + Offsets::OFF_EVENT_NODE_NEXT);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsEventRegistered(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:IsEventRegistered(\"event\")");
|
||||
return 0;
|
||||
}
|
||||
void *frame = Game::Lua::ResolveObject(L, 1);
|
||||
const char *eventName = Game::Lua::ToString(L, 2);
|
||||
const bool registered =
|
||||
frame != nullptr && eventName != nullptr && FrameHasEvent(frame, eventName);
|
||||
Game::Lua::PushBool(L, registered);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- SetPoint one-arg form (Script_SetPoint co-hook) -----------------------
|
||||
|
||||
// Vanilla's Script_SetPoint accepts every modern form except the bare
|
||||
// one-arg call (`region:SetPoint("RIGHT")`) — a fully-omitted arg 3 is
|
||||
// LUA_TNONE, which fails its string/table/number/nil validation and
|
||||
// raises the usage error. Normalize to the vanilla (point, x, y) form
|
||||
// with zero offsets, which anchors relative to the parent at the same
|
||||
// point — exactly the modern one-arg semantic. All other call shapes
|
||||
// pass through untouched.
|
||||
ScriptFn_t g_origSetPoint = nullptr;
|
||||
|
||||
int __fastcall SetPoint_h(void *L) {
|
||||
if (Game::Lua::GetTop(L) == 2 && Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
}
|
||||
return g_origSetPoint(L);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _setPointHook{
|
||||
Offsets::FUN_SCRIPT_REGION_SETPOINT,
|
||||
reinterpret_cast<void *>(&SetPoint_h),
|
||||
reinterpret_cast<void **>(&g_origSetPoint)};
|
||||
|
||||
// ---- Registration ----------------------------------------------------------
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
|
||||
{"SetSize", &Script_SetSize},
|
||||
{"GetSize", &Script_GetSize},
|
||||
};
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FRAME_SHOW,
|
||||
Offsets::FUN_SCRIPT_FRAME_HIDE>},
|
||||
{"SetResizeBounds", &Script_SetResizeBounds},
|
||||
{"HookScript", &Script_HookScript},
|
||||
{"IsEventRegistered", &Script_IsEventRegistered},
|
||||
};
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
|
||||
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_TEXTURE_SHOW,
|
||||
Offsets::FUN_SCRIPT_TEXTURE_HIDE>},
|
||||
};
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_fontStringMethods[] = {
|
||||
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FONTSTRING_SHOW,
|
||||
Offsets::FUN_SCRIPT_FONTSTRING_HIDE>},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY),
|
||||
g_regionMethods,
|
||||
static_cast<int>(sizeof(g_regionMethods) / sizeof(g_regionMethods[0])));
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
|
||||
g_frameMethods,
|
||||
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY),
|
||||
g_textureMethods,
|
||||
static_cast<int>(sizeof(g_textureMethods) / sizeof(g_textureMethods[0])));
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY),
|
||||
g_fontStringMethods,
|
||||
static_cast<int>(sizeof(g_fontStringMethods) /
|
||||
sizeof(g_fontStringMethods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::Modern
|
||||
+159
-11
@@ -22,6 +22,24 @@
|
||||
// SMSG_GOSSIP_MESSAGE handler at `0x004E26E0` and cleared each open.
|
||||
// We read the same shape the engine's own `Script_GetGossip*` reads.
|
||||
//
|
||||
// Vanilla has a SECOND questgiver path this namespace also covers:
|
||||
// NPCs without a gossip menu send SMSG_QUESTGIVER_QUEST_LIST instead —
|
||||
// the QUEST_GREETING event and the QuestFrameGreetingPanel ("Current
|
||||
// Quests" / "Available Quests") — whose data lands in a storage
|
||||
// entirely separate from the gossip arrays (see the VAR_GREETING_*
|
||||
// block in Offsets.h). Retail keeps the two APIs disjoint, but for
|
||||
// backporting it's far more useful for `C_GossipInfo` to serve
|
||||
// whichever questgiver session is actually live:
|
||||
// - gossip session live (VAR_GOSSIP_NPC_GUID != 0) → gossip arrays
|
||||
// - greeting session live (VAR_QUESTGIVER_GUID != 0
|
||||
// and == VAR_GREETING_NPC_GUID) → greeting arrays
|
||||
// - neither → empty / ""
|
||||
// The GUID gates double as staleness protection: both storages persist
|
||||
// after their frames close (the engine never clears them until the
|
||||
// next open), so ungated reads would report the previous NPC.
|
||||
// `GetOptions`/`GetNumOptions` stay gossip-only — greetings have no
|
||||
// options.
|
||||
//
|
||||
// Missing modern fields — vanilla 1.12's server simply never sends
|
||||
// them, so there's nothing to surface:
|
||||
// - `rewards`, `spellID` (post-vanilla; merged in with the
|
||||
@@ -46,6 +64,57 @@ namespace {
|
||||
|
||||
using EngineScriptFn = int(__fastcall *)(void *L);
|
||||
|
||||
uint64_t ReadU64(uintptr_t va) {
|
||||
return *reinterpret_cast<const uint64_t *>(va);
|
||||
}
|
||||
|
||||
// A gossip session is live from SMSG_GOSSIP_MESSAGE until the close
|
||||
// worker (0x004E1FA0) zeroes the GUID and fires GOSSIP_CLOSED.
|
||||
bool GossipSessionActive() {
|
||||
return ReadU64(Offsets::VAR_GOSSIP_NPC_GUID) != 0;
|
||||
}
|
||||
|
||||
// A quest-greeting session is live while the quest-session GUID is set
|
||||
// AND the greeting arrays were filled for that same NPC. The second
|
||||
// check matters because a gossip-menu quest click opens a quest session
|
||||
// at panel 1 without ever touching the greeting arrays — without it,
|
||||
// leftovers from an older greeting NPC would leak through.
|
||||
bool GreetingSessionActive() {
|
||||
const uint64_t giver = ReadU64(Offsets::VAR_QUESTGIVER_GUID);
|
||||
return giver != 0 && giver == ReadU64(Offsets::VAR_GREETING_NPC_GUID);
|
||||
}
|
||||
|
||||
const uint8_t *GreetingEntry(bool active, int slot) {
|
||||
const uintptr_t base = active ? Offsets::VAR_GREETING_ACTIVE_ENTRIES
|
||||
: Offsets::VAR_GREETING_AVAILABLE_ENTRIES;
|
||||
return reinterpret_cast<const uint8_t *>(
|
||||
base + static_cast<uintptr_t>(slot) * Offsets::GREETING_QUESTS_STRIDE);
|
||||
}
|
||||
|
||||
int GreetingCount(bool active) {
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
active ? Offsets::VAR_GREETING_ACTIVE_COUNT
|
||||
: Offsets::VAR_GREETING_AVAILABLE_COUNT);
|
||||
return (count < 0) ? 0
|
||||
: (count > Offsets::GREETING_QUESTS_MAX) ? Offsets::GREETING_QUESTS_MAX
|
||||
: count;
|
||||
}
|
||||
|
||||
// The greeting arrays drop the per-quest dialog status, but the packet
|
||||
// handler's scratch arrays (packet order, zeroed per packet) keep it —
|
||||
// status 4 = complete/turn-in-ready, same sentinel as the gossip rows.
|
||||
bool GreetingQuestIsComplete(uint32_t questID) {
|
||||
const auto *ids = reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_GREETING_SCRATCH_IDS);
|
||||
const auto *status = reinterpret_cast<const uint32_t *>(
|
||||
Offsets::VAR_GREETING_SCRATCH_STATUS);
|
||||
for (int i = 0; i < Offsets::GREETING_QUESTS_MAX; ++i) {
|
||||
if (ids[i] == questID)
|
||||
return status[i] == 4;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t *OptionEntry(int slot) {
|
||||
return reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_OPTIONS) +
|
||||
@@ -79,14 +148,21 @@ bool IsActiveQuest(uint32_t status) {
|
||||
return status == 3 || status == 4;
|
||||
}
|
||||
|
||||
// `C_GossipInfo.GetText()` — pushes the greeting string the engine
|
||||
// resolved for the gossip-giver's NPC_TEXT.dbc entry. Mirrors what
|
||||
// `Script_GetGossipText` does (one inline `lua_pushstring` of the
|
||||
// fixed buffer at `VAR_GOSSIP_GREETING_TEXT`).
|
||||
// `C_GossipInfo.GetText()` — the greeting string of whichever
|
||||
// questgiver session is live: the gossip buffer (what
|
||||
// `Script_GetGossipText` pushes) for a gossip session, the quest
|
||||
// greeting buffer (what `Script_GetGreetingText` pushes) for a
|
||||
// greeting session, "" when neither is open. Both buffers persist
|
||||
// after close, so the gates keep stale text from the previous NPC
|
||||
// out.
|
||||
int __fastcall Script_C_GossipInfo_GetText(void *L) {
|
||||
const char *text = reinterpret_cast<const char *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_GREETING_TEXT));
|
||||
Game::Lua::PushString(L, text);
|
||||
uintptr_t buf = 0;
|
||||
if (GossipSessionActive())
|
||||
buf = Offsets::VAR_GOSSIP_GREETING_TEXT;
|
||||
else if (GreetingSessionActive())
|
||||
buf = Offsets::VAR_QUEST_GREETING_TEXT;
|
||||
Game::Lua::PushString(
|
||||
L, (buf != 0) ? reinterpret_cast<const char *>(buf) : "");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -108,6 +184,8 @@ int __fastcall Script_C_GossipInfo_GetText(void *L) {
|
||||
int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (!GossipSessionActive())
|
||||
return 1; // greeting sessions have no options; stale data stays hidden
|
||||
|
||||
int outIdx = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
@@ -138,6 +216,36 @@ int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Greeting-session variant: walks the pre-split greeting array for the
|
||||
// wanted side. Same table shape as the gossip rows (questID / title /
|
||||
// questLevel / isComplete) so consumers can't tell the sources apart.
|
||||
int PushGreetingQuestList(void *L, bool wantActive) {
|
||||
const int count = GreetingCount(wantActive);
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const uint8_t *entry = GreetingEntry(wantActive, slot);
|
||||
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_GREETING_QUEST_ID);
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "questID",
|
||||
static_cast<double>(questID));
|
||||
Game::Lua::SetFieldString(L, "title", reinterpret_cast<const char *>(
|
||||
entry + Offsets::OFF_GREETING_QUEST_TITLE));
|
||||
const int32_t level = *reinterpret_cast<const int32_t *>(
|
||||
entry + Offsets::OFF_GREETING_QUEST_LEVEL);
|
||||
Game::Lua::SetFieldNumber(L, "questLevel",
|
||||
static_cast<double>(level));
|
||||
if (wantActive)
|
||||
Game::Lua::SetFieldBool(L, "isComplete",
|
||||
GreetingQuestIsComplete(questID));
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Shared body for `GetAvailableQuests` / `GetActiveQuests`. Both walk
|
||||
// the same backing array and split by the `status` field — `wantActive`
|
||||
// inverts the filter.
|
||||
@@ -145,6 +253,12 @@ int PushQuestList(void *L, bool wantActive) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
if (!GossipSessionActive()) {
|
||||
if (GreetingSessionActive())
|
||||
return PushGreetingQuestList(L, wantActive);
|
||||
return 1; // no questgiver session — empty table, never stale data
|
||||
}
|
||||
|
||||
int outIdx = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
|
||||
const uint8_t *entry = QuestEntry(slot);
|
||||
@@ -189,15 +303,19 @@ int __fastcall Script_C_GossipInfo_GetActiveQuests(void *L) {
|
||||
|
||||
int __fastcall Script_C_GossipInfo_GetNumOptions(void *L) {
|
||||
int count = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
if (OptionIndex(OptionEntry(slot)) >= 0)
|
||||
count += 1;
|
||||
if (GossipSessionActive()) {
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
if (OptionIndex(OptionEntry(slot)) >= 0)
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(count));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int CountQuests(bool wantActive) {
|
||||
if (!GossipSessionActive())
|
||||
return GreetingSessionActive() ? GreetingCount(wantActive) : 0;
|
||||
int count = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
|
||||
const uint8_t *entry = QuestEntry(slot);
|
||||
@@ -247,6 +365,17 @@ void EngineSelectActiveQuest(int idx0Based) {
|
||||
fn(idx0Based);
|
||||
}
|
||||
|
||||
// Greeting-session selectors — the workers vanilla's
|
||||
// Script_Select{Available,Active}Quest call. Index straight into the
|
||||
// corresponding greeting array (they validate the questgiver GUID
|
||||
// internally before sending the CMSG).
|
||||
void EngineSelectGreetingQuest(bool wantActive, int idx0Based) {
|
||||
auto fn = reinterpret_cast<SelectQuest_t>(
|
||||
wantActive ? Offsets::FUN_GREETING_SELECT_ACTIVE_QUEST
|
||||
: Offsets::FUN_GREETING_SELECT_AVAILABLE_QUEST);
|
||||
fn(idx0Based);
|
||||
}
|
||||
|
||||
// `C_GossipInfo.SelectOption(gossipOptionID[, text])` — wraps vanilla's
|
||||
// `SelectGossipOption(slotIndex)`. `text` is the boxCoded password,
|
||||
// passed through to the engine helper which gates on the
|
||||
@@ -260,6 +389,8 @@ int __fastcall Script_C_GossipInfo_SelectOption(void *L) {
|
||||
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *password =
|
||||
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
if (!GossipSessionActive())
|
||||
return 0; // stale option slots must not send a CMSG
|
||||
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
|
||||
const uint8_t *entry = OptionEntry(slot);
|
||||
@@ -284,7 +415,8 @@ int __fastcall Script_C_GossipInfo_SelectOptionByIndex(void *L) {
|
||||
return 0;
|
||||
}
|
||||
const int slot0Based = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
|
||||
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX)
|
||||
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX ||
|
||||
!GossipSessionActive())
|
||||
return 0;
|
||||
EngineSelectOption(slot0Based, nullptr);
|
||||
return 0;
|
||||
@@ -301,6 +433,22 @@ int SelectQuestByID(void *L, bool wantActive, const char *errUsage) {
|
||||
}
|
||||
const uint32_t target = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
if (!GossipSessionActive()) {
|
||||
if (!GreetingSessionActive())
|
||||
return 0;
|
||||
const int count = GreetingCount(wantActive);
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
|
||||
GreetingEntry(wantActive, slot) +
|
||||
Offsets::OFF_GREETING_QUEST_ID);
|
||||
if (questID == target) {
|
||||
EngineSelectGreetingQuest(wantActive, slot);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int idx = 0;
|
||||
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
|
||||
const uint8_t *entry = QuestEntry(slot);
|
||||
|
||||
@@ -24,6 +24,26 @@
|
||||
// as the events (single source of truth — events firing and the
|
||||
// state being readable happen at the same point in the pipeline).
|
||||
//
|
||||
// `GetMouseButtonClicked()` — the button name of the most recent
|
||||
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
|
||||
// when no click is being handled. Modern addons call it inside a
|
||||
// mouse handler to learn which button drove it; the value is evicted
|
||||
// shortly after, so — like the real API — it reads nil outside a
|
||||
// handler rather than returning a stale last-click forever.
|
||||
//
|
||||
// Modern WoW backs this with a frame-manager field set right before a
|
||||
// mouse handler runs and cleared right after (verified in 3.3.5:
|
||||
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
|
||||
// field is null). We can't bracket the handler the same way here: the
|
||||
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
|
||||
// dispatches the frame-script mouse handlers *deferred* (during the
|
||||
// frame update, not the message pump). So instead we capture the
|
||||
// button on the message and evict it from the per-frame `WorldTick`
|
||||
// once a couple of quiet ticks pass with no button held — the value
|
||||
// survives the whole frame its click lands in (so every handler,
|
||||
// including multiple addons hooking one `OnClick`, sees it) then
|
||||
// clears. A held button keeps it set so `OnDragStart` still reads it.
|
||||
//
|
||||
// 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
|
||||
@@ -33,6 +53,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
|
||||
|
||||
uint32_t g_mouseButtonMask = 0;
|
||||
|
||||
// Name of the button from the most recent mouse-button message, or
|
||||
// nullptr when no click is being handled. Points at one of
|
||||
// DecodeButton's string literals (stable for the process lifetime),
|
||||
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
|
||||
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
|
||||
// caller with no extra branch. Set on each button message; evicted by
|
||||
// EvictTick (see the header note on GetMouseButtonClicked).
|
||||
const char *g_lastButton = nullptr;
|
||||
|
||||
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
|
||||
// tell a just-clicked frame from a quiet one. Number of consecutive
|
||||
// quiet ticks (no activity, nothing held) required before eviction —
|
||||
// >1 so a handler dispatched a frame after its message still reads
|
||||
// the button.
|
||||
bool g_buttonActivity = false;
|
||||
int g_quietTicks = 0;
|
||||
constexpr int kQuietTicksToEvict = 2;
|
||||
|
||||
HHOOK g_msgHook = nullptr;
|
||||
|
||||
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
|
||||
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
|
||||
if (name == nullptr)
|
||||
return;
|
||||
|
||||
// Capture for GetMouseButtonClicked on both press and release —
|
||||
// during an OnClick / OnMouseUp handler the "clicked" button is the
|
||||
// one just released, same as the press for a normal click.
|
||||
g_lastButton = name;
|
||||
g_buttonActivity = true;
|
||||
g_quietTicks = 0;
|
||||
|
||||
const int bitIdx = NameToBitIdx(name);
|
||||
if (bitIdx >= 0) {
|
||||
const uint32_t bit = 1u << bitIdx;
|
||||
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetMouseButtonClicked(void *L) {
|
||||
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
|
||||
// callers outside a mouse handler see nil, matching the modern API.
|
||||
Game::Lua::PushString(L, g_lastButton);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
|
||||
// world tick. While a button is held we keep the value (drag handlers
|
||||
// still read it) and reset the quiet counter. A frame that saw a fresh
|
||||
// button message consumes its activity flag but doesn't yet evict —
|
||||
// that one-frame grace lets a handler dispatched the frame after its
|
||||
// message still read the button. Only after kQuietTicksToEvict ticks
|
||||
// with nothing held and no activity do we clear, so outside a click
|
||||
// GetMouseButtonClicked reads nil instead of a stale last-click.
|
||||
void EvictTick() {
|
||||
if (g_lastButton == nullptr)
|
||||
return;
|
||||
if (g_mouseButtonMask != 0) {
|
||||
g_quietTicks = 0;
|
||||
return;
|
||||
}
|
||||
if (g_buttonActivity) {
|
||||
g_buttonActivity = false;
|
||||
g_quietTicks = 0;
|
||||
return;
|
||||
}
|
||||
if (++g_quietTicks >= kQuietTicksToEvict) {
|
||||
g_lastButton = nullptr;
|
||||
g_quietTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -157,11 +236,14 @@ void InstallHook() {
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
|
||||
&Script_IsMouseButtonDown);
|
||||
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
|
||||
&Script_GetMouseButtonClicked);
|
||||
InstallHook();
|
||||
}
|
||||
|
||||
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
|
||||
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
|
||||
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
+23
-23
@@ -48,29 +48,6 @@ const uint8_t *FetchItemRecord(uint32_t itemID) {
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
|
||||
}
|
||||
|
||||
// Walks the item's 5 spell slots looking for the first ON_USE
|
||||
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
|
||||
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
|
||||
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
|
||||
// stay invisible to the API.
|
||||
//
|
||||
// Vanilla server data on Turtle WoW has been observed to put the
|
||||
// ON_USE entry at slot index 0 for most items, but a few customized
|
||||
// items use later slots, so we scan all 5.
|
||||
int FindOnUseSpellIDInRecord(const uint8_t *record) {
|
||||
auto *spellIDs = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
|
||||
auto *triggers = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
|
||||
if (spellIDs[i] != 0 &&
|
||||
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
|
||||
return static_cast<int>(spellIDs[i]);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *SpellNameForID(int spellID) {
|
||||
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
@@ -110,6 +87,29 @@ int __fastcall Script_GetItemSpell(void *L) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// Walks the item's 5 spell slots looking for the first ON_USE
|
||||
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
|
||||
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
|
||||
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
|
||||
// stay invisible to the API.
|
||||
//
|
||||
// Vanilla server data on Turtle WoW has been observed to put the
|
||||
// ON_USE entry at slot index 0 for most items, but a few customized
|
||||
// items use later slots, so we scan all 5.
|
||||
int FindOnUseSpellIDInRecord(const uint8_t *record) {
|
||||
auto *spellIDs = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
|
||||
auto *triggers = reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
|
||||
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
|
||||
if (spellIDs[i] != 0 &&
|
||||
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
|
||||
return static_cast<int>(spellIDs[i]);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int OnUseSpellIDForItemID(uint32_t itemID) {
|
||||
if (itemID == 0)
|
||||
return 0;
|
||||
|
||||
@@ -29,4 +29,12 @@ namespace Item::Spell {
|
||||
// the same use-spell still register).
|
||||
int OnUseSpellIDForItemID(uint32_t itemID);
|
||||
|
||||
// Walks a cached `ItemStats_C` record's 5 spell slots and returns the
|
||||
// first ON_USE (trigger=0) spell ID, or 0 if none. Split out so callers
|
||||
// that already hold the record (e.g. `Item::Usable`, which also reads the
|
||||
// record's use requirements) don't peek the cache twice or re-derive the
|
||||
// slot walk. `OnUseSpellIDForItemID` is the peek-by-itemID convenience
|
||||
// wrapper over this.
|
||||
int FindOnUseSpellIDInRecord(const uint8_t *record);
|
||||
|
||||
} // namespace Item::Spell
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `IsUsableItem(item)` / `C_Item.IsUsableItem(item)` — is the item's
|
||||
// *on-use* ability usable by the local player right now, returning
|
||||
// `(usable, noMana)`. This is the item analog of `IsUsableSpell`; it has
|
||||
// nothing to do with whether the item can be *equipped* (armor
|
||||
// proficiency / class-restricted gear is not this function's concern —
|
||||
// that's the tooltip's red requirement lines, evaluated separately).
|
||||
//
|
||||
// Semantics (mirrors 3.3.5's `Script_IsUsableItem` structure, rebuilt on
|
||||
// 1.12 primitives — vanilla ships no `IsUsableItem` and its item-action
|
||||
// usability path never computed on-use power/noMana):
|
||||
// 1. Item must have an on-use spell (a `SPELL_TRIGGER[i] == ON_USE(0)`
|
||||
// slot with a non-zero `SPELL_ID[i]`). No on-use ability → not
|
||||
// usable (there's nothing to "use").
|
||||
// 2. The item's own *use* requirements must be met: RequiredLevel, and
|
||||
// the AllowableClass / AllowableRace masks. These gate activation
|
||||
// (e.g. a potion below your level, a class-restricted trinket) and
|
||||
// are use — not equip — requirements, so they belong here.
|
||||
// 3. The on-use spell must be castable now: delegated to the engine's
|
||||
// own spell-castability helper `FUN_SPELL_IS_USABLE`, which folds in
|
||||
// form/stance, mechanic immunities, aura state, and the power check.
|
||||
// Insufficient power is the only condition that flips `noMana` (and
|
||||
// leaves `usable` false), exactly like `IsUsableSpell`.
|
||||
//
|
||||
// Cache-miss handling matches `C_Item.IsEquippableItem`: a sync peek with
|
||||
// no load fired, returning `(false, false)` if the item isn't cached yet.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "item/Spell.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Item::Usable {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
|
||||
|
||||
// Same item-cache peek pattern as the other item modules (no shared
|
||||
// helper — each call site is one line different). Sync, no load fired.
|
||||
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);
|
||||
}
|
||||
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(resolve("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
}
|
||||
|
||||
// True iff `mask` restricts a 1-based `index` (class/race) the player
|
||||
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
|
||||
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
|
||||
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
|
||||
return false;
|
||||
return (mask & (1u << (index1Based - 1))) == 0;
|
||||
}
|
||||
|
||||
struct Usability {
|
||||
bool usable;
|
||||
bool noMana;
|
||||
};
|
||||
|
||||
Usability ComputeUsability(int itemID) {
|
||||
Usability r{false, false};
|
||||
if (itemID <= 0)
|
||||
return r;
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return r; // not cached → sync "false", no load fired
|
||||
|
||||
const int onUseSpellID = Item::Spell::FindOnUseSpellIDInRecord(record);
|
||||
if (onUseSpellID == 0)
|
||||
return r; // no on-use ability → nothing to use
|
||||
|
||||
auto *desc = PlayerDescriptor();
|
||||
if (desc == nullptr)
|
||||
return r; // pre-login / no player
|
||||
|
||||
// Use requirements (NOT equip requirements): level + class/race.
|
||||
const int playerLevel = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
|
||||
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
if (static_cast<int>(reqLevel) > playerLevel)
|
||||
return r;
|
||||
|
||||
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
|
||||
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
if (RestrictedOut(allowClass, playerClass))
|
||||
return r;
|
||||
|
||||
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
|
||||
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
if (RestrictedOut(allowRace, playerRace))
|
||||
return r;
|
||||
|
||||
// On-use spell castability + noMana, from the engine's own helper.
|
||||
// `::Spell::Lookup` (leading `::`) — inside `Item::Usable`, a bare
|
||||
// `Spell::` binds to the included `Item::Spell` namespace.
|
||||
auto *spellRec = ::Spell::Lookup::RecordForID(onUseSpellID);
|
||||
if (spellRec == nullptr)
|
||||
return r;
|
||||
|
||||
auto isUsable = reinterpret_cast<SpellIsUsable_t>(Offsets::FUN_SPELL_IS_USABLE);
|
||||
int noMana = 0;
|
||||
const int usable = isUsable(spellRec, &noMana) & 0xFF;
|
||||
r.usable = usable != 0;
|
||||
r.noMana = noMana != 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
// `IsUsableItem(item)` — legacy global. Returns `(usable, noMana)` as
|
||||
// 1/nil pairs, matching stock 1.12's `IsUsableAction` / our
|
||||
// `IsUsableSpell` convention.
|
||||
int __fastcall Script_IsUsableItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
const Usability r = ComputeUsability(itemID);
|
||||
if (r.usable) {
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
Game::Lua::PushNil(L);
|
||||
} else if (r.noMana) {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
} else {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Item.IsUsableItem(item)` — modern table-namespace form. Returns
|
||||
// proper booleans (`usable`, `noMana`) per the `C_Item.*` convention.
|
||||
int __fastcall Script_C_Item_IsUsableItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
const Usability r = ComputeUsability(itemID);
|
||||
Game::Lua::PushBool(L, r.usable);
|
||||
Game::Lua::PushBool(L, r.noMana);
|
||||
return 2;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("IsUsableItem", &Script_IsUsableItem);
|
||||
Game::Lua::RegisterTableFunction("C_Item", "IsUsableItem",
|
||||
&Script_C_Item_IsUsableItem);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Item::Usable
|
||||
@@ -0,0 +1,559 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// C_LootHistory backport.
|
||||
//
|
||||
// Modern WoW (2.x+, incl. 5.4.8) exposes a `C_LootHistory` namespace backed
|
||||
// by an engine-maintained ring buffer that a dedicated loot-history server
|
||||
// packet fills. Vanilla 1.12 has NO such packet or store — but it DOES
|
||||
// receive the raw group-loot roll traffic (SMSG_LOOT_ROLL per player,
|
||||
// SMSG_LOOT_ROLL_WON, SMSG_LOOT_ALL_PASSED), which the default UI only turns
|
||||
// into chat lines. So we reconstruct the history client-side (à la
|
||||
// Aura::Source): co-hook the three roll-result handlers and accumulate a ring
|
||||
// of rolled items + per-player results, then expose C_LootHistory reading
|
||||
// from it.
|
||||
//
|
||||
// The handlers run on the deserialized PendingLootRoll message (0x48 bytes,
|
||||
// LootRoll.cpp) with the fields at the OFF_LOOTROLL_* offsets. The message is
|
||||
// populated at handler entry and freed at exit, so each co-hook reads its
|
||||
// fields BEFORE calling the original.
|
||||
//
|
||||
// === Handler ABI (verified from disassembly — RET conventions DIFFER) ===
|
||||
// ROLL FUN_0061C0B0: __fastcall(msg=ECX, ctx=EDX, int stackArg) -> RET 4
|
||||
// WON FUN_0061B9E0: __fastcall(ctx=ECX, msg=EDX, int stackArg) -> RET 4
|
||||
// PASSED FUN_0061B640: __fastcall(ctx=ECX, msg=EDX) -> RET 0
|
||||
// The trailing stack arg (and matching callee cleanup) is LOAD-BEARING: an
|
||||
// earlier revision declared these as 2-arg hooks (implicit RET 0), so every
|
||||
// ROLL/WON call left ESP unbalanced by 4 -> stack corruption -> crash on the
|
||||
// first loot roll. Each hook below matches its handler's exact shape.
|
||||
//
|
||||
// Name + class per roller come from the engine NameCache (read at hook time,
|
||||
// guaranteed loaded then), so they resolve even for someone who has left the
|
||||
// group by the time their roll packet arrives. Surface: C_LootHistory
|
||||
// GetNumItems / GetItem / GetPlayerInfo, plus LOOT_HISTORY_ROLL_CHANGED /
|
||||
// _ROLL_COMPLETE events. The MoP master-loot management functions
|
||||
// (SetExpiration / GiveMasterLoot / CanMasterLoot) are intentionally omitted
|
||||
// — they depend on server support 1.12 doesn't have.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
namespace Loot::History {
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
T Read(const void *base, int off) {
|
||||
return *reinterpret_cast<const T *>(static_cast<const uint8_t *>(base) + off);
|
||||
}
|
||||
|
||||
// Engine millisecond clock — same source (and epoch) as Lua's `GetTime()`
|
||||
// (which reads this scaled by 0.001), so a value stored here is directly
|
||||
// comparable to `GetTime()` once divided by 1000.
|
||||
uint32_t NowMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
|
||||
// Modern rollType constants (what C_LootHistory.GetPlayerInfo returns).
|
||||
constexpr int ROLL_PASS = 0;
|
||||
constexpr int ROLL_NEED = 1;
|
||||
constexpr int ROLL_GREED = 2;
|
||||
|
||||
// The SMSG_LOOT_ROLL packet encodes rollType as 0 = need, 2 = greed; map to
|
||||
// the modern convention. A "didn't actually roll" (pass) is flagged by the
|
||||
// high bit of the roll-number byte, handled by the caller.
|
||||
int MapRollType(int packetType) {
|
||||
return packetType == 2 ? ROLL_GREED : ROLL_NEED;
|
||||
}
|
||||
|
||||
// GUID -> engine NameCache entry-data block. This is the same primitive the
|
||||
// engine's own `%N` name substitution uses (FUN_005083A0): for a LOADED entry
|
||||
// the lookup returns the data block (name[48] at +0, class at +0x140, …),
|
||||
// regardless of the (null) callback and regardless of current group
|
||||
// membership — so it resolves a roller who has since left the group, which
|
||||
// is exactly what a loot roll needs (the roll packet arrives after they may
|
||||
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
|
||||
// gates on this same lookup succeeding. Returns nullptr if not cached.
|
||||
const uint8_t *LookupPlayerRecord(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
|
||||
uint32_t hi, void *cookie,
|
||||
void *cb, void *ud, char flag);
|
||||
auto fn = reinterpret_cast<Lookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
|
||||
uint32_t cookie[2] = {0, 0};
|
||||
return fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
cookie, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
const char *NameFromRecord(const uint8_t *rec) {
|
||||
return rec != nullptr
|
||||
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
uint32_t ClassIdFromRecord(const uint8_t *rec) {
|
||||
return rec != nullptr
|
||||
? *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_PLAYER_INFO_CLASS)
|
||||
: 0;
|
||||
}
|
||||
|
||||
// ChrClasses.dbc record ID -> class token ("WARRIOR", "MAGE", …). The DBC's
|
||||
// filename field is the token; RAID_CLASS_COLORS et al. key on it. nullptr if
|
||||
// the ID is out of range or the record is missing.
|
||||
const char *ClassTokenForId(uint32_t classId) {
|
||||
if (classId == 0)
|
||||
return nullptr;
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
|
||||
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
|
||||
if (records == nullptr || static_cast<int>(classId) > count)
|
||||
return nullptr;
|
||||
const uint8_t *rec = records[classId];
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_CHRCLASSES_FILENAME);
|
||||
}
|
||||
|
||||
constexpr int kNameLen = 48; // engine NameCache inline name[48]
|
||||
|
||||
void CopyName(char *dst, const char *src) {
|
||||
if (src == nullptr) {
|
||||
dst[0] = '\0';
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
for (; i < kNameLen - 1 && src[i] != '\0'; ++i)
|
||||
dst[i] = src[i];
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
struct PlayerRoll {
|
||||
uint64_t guid;
|
||||
char name[kNameLen];
|
||||
uint32_t classId; // ChrClasses.dbc ID (0 = unknown)
|
||||
int rollType; // ROLL_* (modern); ROLL_PASS when the player passed
|
||||
int roll; // 1..100, or 0 if they passed / didn't roll
|
||||
};
|
||||
|
||||
constexpr int kMaxPlayers = 40;
|
||||
|
||||
struct HistItem {
|
||||
uint32_t rollID; // stable monotonic ID — survives ring-index shifts
|
||||
uint64_t sourceGuid; // looted-object GUID — the roll's identity
|
||||
uint32_t slot;
|
||||
uint32_t itemId;
|
||||
int32_t itemSuffix; // random suffix/property ID (link suffix field)
|
||||
uint32_t itemSeed; // random seed (link unique field)
|
||||
uint64_t winnerGuid; // winner's roller GUID (0 if undecided)
|
||||
bool hasWinner;
|
||||
bool isDone;
|
||||
uint32_t createdMs; // engine tick at creation (GetTime()*1000 epoch)
|
||||
PlayerRoll players[kMaxPlayers];
|
||||
int playerCount;
|
||||
bool used;
|
||||
};
|
||||
|
||||
// Ring of recent rolled items. g_write is the next slot to fill; g_count
|
||||
// saturates at kMaxItems. GetNumItems returns g_count; item index i (1-based,
|
||||
// oldest→newest) maps to ring[(g_write - g_count + i - 1) mod kMaxItems].
|
||||
// ~3 KB per item (players[40] dominates), so 128 is ~380 KB static — bump
|
||||
// freely if a longer history is wanted.
|
||||
constexpr int kMaxItems = 128;
|
||||
HistItem g_items[kMaxItems];
|
||||
int g_write = 0;
|
||||
int g_count = 0;
|
||||
uint32_t g_nextRollId = 1; // 0 is reserved as "invalid" (never assigned)
|
||||
|
||||
// Set by FindOrCreate for the most recent call, read by the hooks to decide
|
||||
// whether a structural LOOT_HISTORY_FULL_UPDATE is warranted (a new item
|
||||
// appeared, or the ring evicted an old one so every index shifted).
|
||||
bool g_createdNew = false;
|
||||
bool g_evicted = false;
|
||||
|
||||
HistItem *Find(uint64_t sourceGuid, uint32_t slot) {
|
||||
for (auto &e : g_items) {
|
||||
if (e.used && e.sourceGuid == sourceGuid && e.slot == slot)
|
||||
return &e;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
HistItem *FindOrCreate(uint64_t sourceGuid, uint32_t slot, uint32_t itemId) {
|
||||
g_createdNew = false;
|
||||
g_evicted = false;
|
||||
HistItem *e = Find(sourceGuid, slot);
|
||||
if (e != nullptr) {
|
||||
if (itemId != 0)
|
||||
e->itemId = itemId;
|
||||
return e;
|
||||
}
|
||||
e = &g_items[g_write];
|
||||
g_evicted = e->used; // overwriting a live slot => ring full => eviction
|
||||
g_write = (g_write + 1) % kMaxItems;
|
||||
if (g_count < kMaxItems)
|
||||
++g_count;
|
||||
*e = HistItem{};
|
||||
e->used = true;
|
||||
e->rollID = g_nextRollId++;
|
||||
e->createdMs = NowMs();
|
||||
e->sourceGuid = sourceGuid;
|
||||
e->slot = slot;
|
||||
e->itemId = itemId;
|
||||
g_createdNew = true;
|
||||
return e;
|
||||
}
|
||||
|
||||
// Find the player-roll slot for `guid`, appending a fresh one (name/class
|
||||
// resolved from the NameCache) if absent and there's room. Returns the
|
||||
// 0-based index, or -1 when the roster is full. Shared by the roll recorder
|
||||
// and the winner recorder — the winner may never have sent an individual
|
||||
// SMSG_LOOT_ROLL (joined late / master loot), so ensuring they're present
|
||||
// lets GetItem's winnerIdx resolve through GetPlayerInfo.
|
||||
int FindOrAddPlayer(HistItem *e, uint64_t guid) {
|
||||
for (int i = 0; i < e->playerCount; ++i) {
|
||||
if (e->players[i].guid == guid)
|
||||
return i;
|
||||
}
|
||||
if (e->playerCount >= kMaxPlayers)
|
||||
return -1;
|
||||
const int idx = e->playerCount++;
|
||||
const uint8_t *rec = LookupPlayerRecord(guid); // cache loaded at hook time
|
||||
e->players[idx] = PlayerRoll{};
|
||||
e->players[idx].guid = guid;
|
||||
CopyName(e->players[idx].name, NameFromRecord(rec));
|
||||
e->players[idx].classId = ClassIdFromRecord(rec);
|
||||
return idx;
|
||||
}
|
||||
|
||||
// 1-based index within players[] of the winner, or 0 if undecided / absent.
|
||||
int WinnerIndex(const HistItem *e) {
|
||||
if (!e->hasWinner || e->winnerGuid == 0)
|
||||
return 0;
|
||||
for (int i = 0; i < e->playerCount; ++i) {
|
||||
if (e->players[i].guid == e->winnerGuid)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 1-based access in oldest→newest order (matching GetNumItems' count).
|
||||
HistItem *GetEntry(int index1Based) {
|
||||
if (index1Based < 1 || index1Based > g_count)
|
||||
return nullptr;
|
||||
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
|
||||
return &g_items[(start + index1Based - 1) % kMaxItems];
|
||||
}
|
||||
|
||||
// The 1-based index of an entry in oldest→newest order (0 if not live).
|
||||
int IndexOf(const HistItem *e) {
|
||||
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
|
||||
for (int i = 0; i < g_count; ++i) {
|
||||
if (&g_items[(start + i) % kMaxItems] == e)
|
||||
return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Modern loot-history events. ROLL_CHANGED(itemIdx, playerIdx) fires on each
|
||||
// roll; ROLL_COMPLETE(itemIdx) when the item is decided (winner / all-passed).
|
||||
// FULL_UPDATE is reserved for API/IsEventValid completeness (the UI fires it
|
||||
// on open); we don't dispatch it from C.
|
||||
constexpr const char *kRollChanged = "LOOT_HISTORY_ROLL_CHANGED";
|
||||
constexpr const char *kRollComplete = "LOOT_HISTORY_ROLL_COMPLETE";
|
||||
constexpr const char *kFullUpdate = "LOOT_HISTORY_FULL_UPDATE";
|
||||
const Event::Custom::AutoReserve _evRollChanged{kRollChanged};
|
||||
const Event::Custom::AutoReserve _evRollComplete{kRollComplete};
|
||||
const Event::Custom::AutoReserve _evFullUpdate{kFullUpdate};
|
||||
|
||||
// Returns the 1-based item index and writes the 1-based player index to
|
||||
// `*outPlayerIdx` (0 if none), so the hook can fire ROLL_CHANGED.
|
||||
int RecordRoll(const void *msg, int *outPlayerIdx) {
|
||||
*outPlayerIdx = 0;
|
||||
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
const uint64_t roller = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
|
||||
const uint8_t rollByte = Read<uint8_t>(msg, Offsets::OFF_LOOTROLL_ROLL_NUM);
|
||||
const int packetType = Read<int32_t>(msg, Offsets::OFF_LOOTROLL_ROLL_TYPE);
|
||||
|
||||
const bool didRoll = (rollByte & 0x80) == 0;
|
||||
const int rollNum = didRoll ? (rollByte & 0x7F) : 0;
|
||||
const int rollType = didRoll ? MapRollType(packetType) : ROLL_PASS;
|
||||
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
if (roller == 0)
|
||||
return IndexOf(e);
|
||||
|
||||
const int slotIdx = FindOrAddPlayer(e, roller);
|
||||
if (slotIdx >= 0) {
|
||||
e->players[slotIdx].rollType = rollType;
|
||||
e->players[slotIdx].roll = rollNum;
|
||||
*outPlayerIdx = slotIdx + 1;
|
||||
}
|
||||
return IndexOf(e);
|
||||
}
|
||||
|
||||
int RecordWinner(const void *msg) {
|
||||
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
const uint64_t winner = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
|
||||
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
e->winnerGuid = winner;
|
||||
e->hasWinner = true;
|
||||
e->isDone = true;
|
||||
if (winner != 0)
|
||||
FindOrAddPlayer(e, winner); // so GetItem's winnerIdx resolves
|
||||
return IndexOf(e);
|
||||
}
|
||||
|
||||
int RecordAllPassed(const void *msg) {
|
||||
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
e->isDone = true;
|
||||
return IndexOf(e);
|
||||
}
|
||||
|
||||
// SMSG_LOOT_START_ROLL carries the item's random suffix/seed; the result
|
||||
// packets don't. It's read from the freshly-built store-head node (rather
|
||||
// than the packet) right after the START_ROLL handler runs — the node has
|
||||
// the same field offsets as the message. This is the only place the random
|
||||
// fields are populated, so the result recorders leave them alone.
|
||||
void RecordStart() {
|
||||
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOOTROLL_STORE_HEAD));
|
||||
if (node == nullptr)
|
||||
return;
|
||||
const uint64_t src = Read<uint64_t>(node, Offsets::OFF_LOOTROLL_SOURCE_GUID);
|
||||
const uint32_t slot = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_SLOT);
|
||||
const uint32_t itemId = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_ID);
|
||||
HistItem *e = FindOrCreate(src, slot, itemId);
|
||||
e->itemSuffix = Read<int32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SUFFIX);
|
||||
e->itemSeed = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SEED);
|
||||
}
|
||||
|
||||
// ---- Packet co-hooks -------------------------------------------------------
|
||||
// Signatures match the verified per-handler ABI above (note the differing
|
||||
// message register, stack arg, and RET). Read the message fields before the
|
||||
// original runs — it frees the message.
|
||||
|
||||
using RollFn_t = void(__fastcall *)(void *msg, void *ctx, int a3); // RET 4
|
||||
using WonFn_t = void(__fastcall *)(void *ctx, void *msg, int a3); // RET 4
|
||||
using PassedFn_t = void(__fastcall *)(void *ctx, void *msg); // RET 0
|
||||
using StartFn_t = void(__fastcall *)(void *packet); // RET 0
|
||||
|
||||
RollFn_t g_origRoll = nullptr;
|
||||
WonFn_t g_origWon = nullptr;
|
||||
PassedFn_t g_origPassed = nullptr;
|
||||
StartFn_t g_origStart = nullptr;
|
||||
|
||||
// Fire LOOT_HISTORY_FULL_UPDATE (no payload) — the "rebuild everything"
|
||||
// signal, matching the UI's full refresh. Called when the item set changes
|
||||
// structurally: a new item appears or the ring evicts an old one (shifting
|
||||
// every index).
|
||||
void FireFullUpdate() {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
|
||||
}
|
||||
|
||||
// START_ROLL: let the original build the store node, then read the item's
|
||||
// random suffix off it. Adds the item to the history so a UI can show it as
|
||||
// soon as the roll opens (before any roll comes in) — hence the FULL_UPDATE.
|
||||
void __fastcall Start_h(void *packet) {
|
||||
g_origStart(packet);
|
||||
RecordStart();
|
||||
if (g_createdNew || g_evicted)
|
||||
FireFullUpdate();
|
||||
}
|
||||
|
||||
// Record fields (message is freed by the original), run the original, THEN
|
||||
// fire the event — firing after the original keeps the engine's own handler
|
||||
// off our re-entrancy (same order Item::Data uses from its packet hook). A
|
||||
// structural FULL_UPDATE (if any) precedes the granular event so a listener
|
||||
// that rebuilds on FULL_UPDATE sees the new row before the row-level refresh.
|
||||
void __fastcall Roll_h(void *msg, void *ctx, int a3) {
|
||||
int itemIdx = 0, playerIdx = 0;
|
||||
bool structural = false;
|
||||
if (msg != nullptr) {
|
||||
itemIdx = RecordRoll(msg, &playerIdx);
|
||||
structural = g_createdNew || g_evicted;
|
||||
}
|
||||
g_origRoll(msg, ctx, a3);
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
|
||||
playerIdx);
|
||||
}
|
||||
|
||||
void __fastcall Won_h(void *ctx, void *msg, int a3) {
|
||||
int itemIdx = 0;
|
||||
bool structural = false;
|
||||
if (msg != nullptr) {
|
||||
itemIdx = RecordWinner(msg);
|
||||
structural = g_createdNew || g_evicted;
|
||||
}
|
||||
g_origWon(ctx, msg, a3);
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
|
||||
}
|
||||
|
||||
void __fastcall Passed_h(void *ctx, void *msg) {
|
||||
int itemIdx = 0;
|
||||
bool structural = false;
|
||||
if (msg != nullptr) {
|
||||
itemIdx = RecordAllPassed(msg);
|
||||
structural = g_createdNew || g_evicted;
|
||||
}
|
||||
g_origPassed(ctx, msg);
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookRoll{
|
||||
Offsets::FUN_LOOT_ROLL_HANDLER, reinterpret_cast<void *>(&Roll_h),
|
||||
reinterpret_cast<void **>(&g_origRoll)};
|
||||
const Game::HookAutoRegister _hookWon{
|
||||
Offsets::FUN_LOOT_ROLL_WON_HANDLER, reinterpret_cast<void *>(&Won_h),
|
||||
reinterpret_cast<void **>(&g_origWon)};
|
||||
const Game::HookAutoRegister _hookPassed{
|
||||
Offsets::FUN_LOOT_ALL_PASSED_HANDLER, reinterpret_cast<void *>(&Passed_h),
|
||||
reinterpret_cast<void **>(&g_origPassed)};
|
||||
const Game::HookAutoRegister _hookStart{
|
||||
Offsets::FUN_LOOT_START_ROLL_HANDLER, reinterpret_cast<void *>(&Start_h),
|
||||
reinterpret_cast<void **>(&g_origStart)};
|
||||
|
||||
// ---- Lua surface -----------------------------------------------------------
|
||||
|
||||
// `C_LootHistory.GetNumItems()` -> number of rolled items in the history.
|
||||
int __fastcall Script_GetNumItems(void *L) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_count));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_LootHistory.GetItem(itemIndex)` ->
|
||||
// rollID, itemLink, numPlayers, isDone, winnerIdx, timestamp
|
||||
// Matches the MoP signature: `rollID` is a stable ID (unlike itemIndex, which
|
||||
// shifts as the ring saturates — key expanded/highlighted rows on it);
|
||||
// `itemLink` is the `item:id:enchant:suffix:unique` form (valid for
|
||||
// GetItemInfo / SetHyperlink), carrying the roll's random suffix so "of the X"
|
||||
// items keep their affix; `winnerIdx` is the winner's 1-based playerIndex for
|
||||
// GetPlayerInfo(itemIndex, winnerIdx) (nil if undecided). `timestamp` is a
|
||||
// ClassicAPI extension: the `GetTime()`-comparable creation time in seconds.
|
||||
int __fastcall Script_GetItem(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetItem(itemIndex)");
|
||||
return 0;
|
||||
}
|
||||
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
if (e == nullptr)
|
||||
return 0;
|
||||
static char link[64];
|
||||
// Vanilla item link: item:id:enchant:suffix:unique. Carry the roll's
|
||||
// random suffix/seed so "of the Bear"-style items keep their affix.
|
||||
std::snprintf(link, sizeof(link), "item:%u:0:%d:%u", e->itemId,
|
||||
e->itemSuffix, e->itemSeed);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(e->rollID)); // 1 rollID
|
||||
Game::Lua::PushString(L, link); // 2 itemLink
|
||||
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 3 numPlayers
|
||||
Game::Lua::PushBool(L, e->isDone); // 4 isDone
|
||||
const int winnerIdx = WinnerIndex(e);
|
||||
if (winnerIdx > 0)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(winnerIdx)); // 5 winnerIdx
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, e->createdMs / 1000.0); // 6 timestamp
|
||||
return 6;
|
||||
}
|
||||
|
||||
// `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)` ->
|
||||
// name, classToken, rollType, roll, isWinner, isMe. classToken is the
|
||||
// ChrClasses filename ("WARRIOR", …) for RAID_CLASS_COLORS-style coloring;
|
||||
// both name and class come from the engine NameCache captured at roll time.
|
||||
// isMe compares the roller GUID to the local player's.
|
||||
int __fastcall Script_GetPlayerInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: GetPlayerInfo(itemIndex, playerIndex)");
|
||||
return 0;
|
||||
}
|
||||
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
if (e == nullptr)
|
||||
return 0;
|
||||
const int p = static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1;
|
||||
if (p < 0 || p >= e->playerCount)
|
||||
return 0;
|
||||
const PlayerRoll &r = e->players[p];
|
||||
if (r.name[0] != '\0')
|
||||
Game::Lua::PushString(L, r.name); // 1 name
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
const char *classToken = ClassTokenForId(r.classId); // 2 class token
|
||||
if (classToken != nullptr)
|
||||
Game::Lua::PushString(L, classToken);
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(r.rollType)); // 3 rollType
|
||||
Game::Lua::PushNumber(L, static_cast<double>(r.roll)); // 4 roll
|
||||
Game::Lua::PushBool(L, e->hasWinner && e->winnerGuid == r.guid); // 5 isWinner
|
||||
Game::Lua::PushBool(L, r.guid == Unit::Identity::PlayerGuid()); // 6 isMe
|
||||
return 6;
|
||||
}
|
||||
|
||||
// `C_LootHistory.Clear()` -> wipe the accumulated history. A ClassicAPI
|
||||
// extension (no MoP equivalent) so a UI can offer a "clear history" button.
|
||||
// Fires LOOT_HISTORY_FULL_UPDATE so any open display rebuilds empty.
|
||||
int __fastcall Script_Clear(void *L) {
|
||||
(void)L;
|
||||
for (auto &e : g_items)
|
||||
e.used = false;
|
||||
g_write = 0;
|
||||
g_count = 0;
|
||||
FireFullUpdate();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "GetNumItems",
|
||||
&Script_GetNumItems);
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "GetItem",
|
||||
&Script_GetItem);
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "GetPlayerInfo",
|
||||
&Script_GetPlayerInfo);
|
||||
Game::Lua::RegisterTableFunction("C_LootHistory", "Clear", &Script_Clear);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Loot::History
|
||||
@@ -0,0 +1,373 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_LossOfControl` — active loss-of-control effects on the LOCAL PLAYER.
|
||||
// Vanilla 1.12 has no such aggregation layer (it's a MoP addition), so we
|
||||
// synthesize it from two sources:
|
||||
//
|
||||
// 1. SCHOOL_INTERRUPT — the Counterspell/Kick school lockout. The server
|
||||
// (Player::ProhibitSpellSchool) delivers it as a single
|
||||
// SMSG_SPELL_COOLDOWN carrying every spell of the interrupted school,
|
||||
// all with the same duration. That "player-GUID packet, >=2 spells, one
|
||||
// uniform duration" shape is produced by nothing else (normal cooldowns
|
||||
// are single-spell or applied client-side), so co-hooking the cooldown
|
||||
// handler and matching it yields an authoritative school + duration.
|
||||
// The interrupting spell isn't in the packet, so spellID is 0 and
|
||||
// displayText is a fixed "Interrupted" — the mechanically-relevant data
|
||||
// (school, duration, timing) is exact.
|
||||
//
|
||||
// 2. CC debuffs — stun / fear / root / confuse / charm / possess / silence /
|
||||
// pacify / disarm are real debuff auras. We scan the player's debuffs and
|
||||
// classify each by its spell's EffectApplyAuraName. Timing is best-effort
|
||||
// from the Aura::Source cache (nil when the cast wasn't observed — the
|
||||
// LoC timing fields are nullable, so that's contract-valid).
|
||||
//
|
||||
// Placeholders for fields with no vanilla equivalent: priority = 0,
|
||||
// displayType = 2 (show for full duration), auraInstanceID = nil. iconTexture
|
||||
// is the spell's icon PATH (vanilla has no FileDataIDs; a path is the
|
||||
// functional equivalent).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "aura/Data.h"
|
||||
#include "aura/Source.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "event/Custom.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace LossOfControl {
|
||||
|
||||
namespace {
|
||||
|
||||
using TickMs_t = uint32_t(__fastcall *)();
|
||||
int NowMs() {
|
||||
return static_cast<int>(
|
||||
reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)());
|
||||
}
|
||||
|
||||
// Spell.dbc field readers (mirrors the small locals in Spell::Cast).
|
||||
constexpr int OFF_NAME = 0x1E0; // localized name[9]
|
||||
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
|
||||
|
||||
const char *SpellName(const uint8_t *rec) {
|
||||
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
const char *n =
|
||||
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
|
||||
return (n != nullptr) ? n : "";
|
||||
}
|
||||
|
||||
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 *p = *reinterpret_cast<const char *const *>(iconRec + 0x04);
|
||||
return (p != nullptr) ? p : "";
|
||||
}
|
||||
|
||||
int SpellSchoolIndex(const uint8_t *rec) {
|
||||
return *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_SCHOOL);
|
||||
}
|
||||
|
||||
// Classify a spell's control-loss type from its EffectApplyAuraName array, or
|
||||
// null if it applies no control-loss aura. Priority-ordered (strongest control
|
||||
// first) so a multi-effect spell resolves to one locType, matching retail.
|
||||
const char *ClassifyControlLoss(const uint8_t *rec) {
|
||||
const int32_t *aura = reinterpret_cast<const int32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
|
||||
auto has = [&](int type) {
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
|
||||
if (aura[i] == type)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
|
||||
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
|
||||
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
|
||||
if (has(Offsets::SPELL_AURA_MOD_FEAR)) return "FEAR";
|
||||
if (has(Offsets::SPELL_AURA_MOD_CONFUSE)) return "CONFUSE";
|
||||
if (has(Offsets::SPELL_AURA_MOD_PACIFY_SILENCE)) return "PACIFYSILENCE";
|
||||
if (has(Offsets::SPELL_AURA_MOD_SILENCE)) return "SILENCE";
|
||||
if (has(Offsets::SPELL_AURA_MOD_PACIFY)) return "PACIFY";
|
||||
if (has(Offsets::SPELL_AURA_MOD_ROOT)) return "ROOT";
|
||||
if (has(Offsets::SPELL_AURA_MOD_DISARM)) return "DISARM";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---- School-interrupt lockout state (fed by the SMSG_SPELL_COOLDOWN hook) --
|
||||
|
||||
// One active lockout per school index (0..6); active while now < endMs.
|
||||
// Self-expiring, per-process — a stale entry from before a relog is already
|
||||
// in the past on the shared uptime clock, so no reset is needed.
|
||||
struct SchoolLock {
|
||||
int startMs;
|
||||
int endMs;
|
||||
};
|
||||
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
|
||||
|
||||
using CooldownHandler_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
CooldownHandler_t g_origCooldown = nullptr;
|
||||
|
||||
int __stdcall Cooldown_h(uint32_t *opCode, Net::CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
if (guid != 0 && guid == Unit::Identity::PlayerGuid()) {
|
||||
int count = 0;
|
||||
int firstDur = 0;
|
||||
bool uniform = true;
|
||||
int schoolIdx = -1;
|
||||
while (packet->m_read + 8 <= packet->m_size && count < 256) {
|
||||
const uint32_t spellID = Net::Read<uint32_t>(packet);
|
||||
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
if (count == 0)
|
||||
firstDur = dur;
|
||||
else if (dur != firstDur)
|
||||
uniform = false;
|
||||
if (schoolIdx < 0) {
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(spellID));
|
||||
if (rec != nullptr) {
|
||||
const int s = SpellSchoolIndex(rec);
|
||||
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
|
||||
schoolIdx = s;
|
||||
}
|
||||
}
|
||||
++count;
|
||||
}
|
||||
// A uniform-duration batch of >=2 same-school spells is a school
|
||||
// lockout (ProhibitSpellSchool); single/varied packets are normal
|
||||
// cooldowns and ignored.
|
||||
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
|
||||
const int now = NowMs();
|
||||
g_schoolLock[schoolIdx].startMs = now;
|
||||
g_schoolLock[schoolIdx].endMs = now + firstDur;
|
||||
}
|
||||
}
|
||||
packet->m_read = saved; // restore before the engine re-parses
|
||||
}
|
||||
return g_origCooldown(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _cooldownHook{
|
||||
Offsets::FUN_SPELL_COOLDOWN_HANDLER, reinterpret_cast<void *>(&Cooldown_h),
|
||||
reinterpret_cast<void **>(&g_origCooldown)};
|
||||
|
||||
// ---- Aggregated active-effect list ----------------------------------------
|
||||
|
||||
struct LocEntry {
|
||||
const char *locType;
|
||||
int spellID; // 0 = unknown (school interrupt)
|
||||
const uint8_t *rec; // spell record for name/icon; null for school interrupt
|
||||
int schoolMask; // lockoutSchool
|
||||
int startMs; // 0 = unknown
|
||||
int endMs; // 0 = unknown / no timing
|
||||
};
|
||||
|
||||
int BuildList(LocEntry *out, int maxOut) {
|
||||
const int now = NowMs();
|
||||
int n = 0;
|
||||
|
||||
// School-interrupt lockouts first.
|
||||
for (int s = 0; s < Offsets::SPELL_SCHOOL_COUNT && n < maxOut; ++s) {
|
||||
if (g_schoolLock[s].endMs != 0 && now < g_schoolLock[s].endMs) {
|
||||
LocEntry &e = out[n++];
|
||||
e.locType = "SCHOOL_INTERRUPT";
|
||||
e.spellID = 0;
|
||||
e.rec = nullptr;
|
||||
e.schoolMask = 1 << s;
|
||||
e.startMs = g_schoolLock[s].startMs;
|
||||
e.endMs = g_schoolLock[s].endMs;
|
||||
}
|
||||
}
|
||||
|
||||
// CC debuffs on the player.
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player != nullptr) {
|
||||
const uint64_t playerGuid = Unit::Identity::PlayerGuid();
|
||||
for (int i = 1; n < maxOut; ++i) {
|
||||
const int slot = Aura::Data::FindNthSlot(
|
||||
player, i, Aura::Data::Filter::Harmful, false);
|
||||
if (slot < 0)
|
||||
break;
|
||||
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
|
||||
if (spellID == 0)
|
||||
continue;
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(spellID));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const char *locType = ClassifyControlLoss(rec);
|
||||
if (locType == nullptr)
|
||||
continue;
|
||||
|
||||
LocEntry &e = out[n++];
|
||||
e.locType = locType;
|
||||
e.spellID = static_cast<int>(spellID);
|
||||
e.rec = rec;
|
||||
e.schoolMask = 0;
|
||||
e.startMs = 0;
|
||||
e.endMs = 0;
|
||||
uint64_t caster = 0;
|
||||
uint32_t expMs = 0, durMs = 0;
|
||||
if (Aura::Source::Get(playerGuid, spellID, &caster, &expMs, &durMs) &&
|
||||
expMs != 0) {
|
||||
e.endMs = static_cast<int>(expMs);
|
||||
if (durMs != 0)
|
||||
e.startMs = static_cast<int>(expMs - durMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- LOSS_OF_CONTROL_ADDED / _UPDATE events (WorldTick poll-and-diff) ------
|
||||
//
|
||||
// Effect *expiry* (a school lockout timing out, a CC aura falling off) has no
|
||||
// engine packet or callback, so a per-frame diff is the only complete signal.
|
||||
// Firing from the WorldTick tail also keeps the Lua handlers out of the
|
||||
// mid-packet contexts the state is updated in (the OnTooltipSetItem fire-safety
|
||||
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
|
||||
// the per-frame cost is negligible.
|
||||
|
||||
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
|
||||
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
|
||||
|
||||
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
|
||||
// spellID, a school interrupt by its school mask (spellID 0).
|
||||
struct EffectKey {
|
||||
int spellID;
|
||||
int schoolMask;
|
||||
};
|
||||
EffectKey g_prev[32];
|
||||
int g_prevCount = 0;
|
||||
|
||||
bool ContainsKey(const EffectKey *list, int count, EffectKey key) {
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (list[i].spellID == key.spellID &&
|
||||
list[i].schoolMask == key.schoolMask)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void OnWorldTick() {
|
||||
LocEntry entries[32];
|
||||
const int n = BuildList(entries, 32);
|
||||
|
||||
EffectKey cur[32];
|
||||
for (int i = 0; i < n; ++i)
|
||||
cur[i] = EffectKey{entries[i].spellID, entries[i].schoolMask};
|
||||
|
||||
bool changed = false;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
|
||||
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < g_prevCount && !changed; ++i) {
|
||||
if (!ContainsKey(cur, n, g_prev[i]))
|
||||
changed = true; // an effect ended (fell off / lockout expired)
|
||||
}
|
||||
// Any add or removal fires one coalesced "re-scan" signal. The payload is
|
||||
// the affected unit — always "player" here (vanilla only exposes the local
|
||||
// player's control-loss state), but passing it keeps the event shape
|
||||
// forward-compatible with per-unit tracking and matches modern WoW, whose
|
||||
// LOSS_OF_CONTROL_UPDATE carries the unit token.
|
||||
if (changed)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
|
||||
|
||||
for (int i = 0; i < n; ++i)
|
||||
g_prev[i] = cur[i];
|
||||
g_prevCount = n;
|
||||
}
|
||||
|
||||
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
|
||||
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
|
||||
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
|
||||
|
||||
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
|
||||
int __fastcall Script_GetActiveLossOfControlDataCount(void *L) {
|
||||
LocEntry entries[32];
|
||||
const int n = BuildList(entries, 32);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_LossOfControl.GetActiveLossOfControlData(index)` -> LossOfControlData, or
|
||||
// nil if index is out of range.
|
||||
int __fastcall Script_GetActiveLossOfControlData(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(
|
||||
L, "Usage: C_LossOfControl.GetActiveLossOfControlData(index)");
|
||||
return 0;
|
||||
}
|
||||
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
LocEntry entries[32];
|
||||
const int n = BuildList(entries, 32);
|
||||
if (index < 1 || index > n)
|
||||
return 0; // nil
|
||||
|
||||
const LocEntry &e = entries[index - 1];
|
||||
const int now = NowMs();
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "locType", e.locType);
|
||||
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(e.spellID));
|
||||
Game::Lua::SetFieldString(
|
||||
L, "displayText", (e.rec != nullptr) ? SpellName(e.rec) : "Interrupted");
|
||||
Game::Lua::SetFieldString(
|
||||
L, "iconTexture", (e.rec != nullptr) ? SpellIconPath(e.rec) : "");
|
||||
|
||||
// Timing (seconds, GetTime() epoch). Nullable — leave a field unset (nil)
|
||||
// when we don't know it, per the modern contract.
|
||||
if (e.endMs != 0 && now < e.endMs) {
|
||||
Game::Lua::SetFieldNumber(L, "timeRemaining", (e.endMs - now) * 0.001);
|
||||
if (e.startMs != 0) {
|
||||
Game::Lua::SetFieldNumber(L, "startTime", e.startMs * 0.001);
|
||||
Game::Lua::SetFieldNumber(L, "duration",
|
||||
(e.endMs - e.startMs) * 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetFieldNumber(L, "lockoutSchool",
|
||||
static_cast<double>(e.schoolMask));
|
||||
Game::Lua::SetFieldNumber(L, "priority", 0);
|
||||
Game::Lua::SetFieldNumber(L, "displayType", 2); // show for full duration
|
||||
// auraInstanceID: nil (vanilla has no aura instance IDs) — field omitted.
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_LossOfControl", "GetActiveLossOfControlDataCount",
|
||||
&Script_GetActiveLossOfControlDataCount);
|
||||
Game::Lua::RegisterTableFunction("C_LossOfControl",
|
||||
"GetActiveLossOfControlData",
|
||||
&Script_GetActiveLossOfControlData);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace LossOfControl
|
||||
@@ -0,0 +1,500 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map.GetMapOverlays([areaID])` — every WorldMapOverlay.dbc entry for
|
||||
// a zone, regardless of exploration, with the tile grid fully resolved.
|
||||
//
|
||||
// The engine's own surface (GetNumMapOverlays / GetMapOverlayInfo) only
|
||||
// exposes DISCOVERED overlays — the exploration gate is a server-fed
|
||||
// list of discovered overlay IDs — which is why map addons (pfUI's
|
||||
// mapreveal, Cartographer …) ship hand-measured overlay tables to draw
|
||||
// unexplored areas. This reads the DBC directly instead: live data for
|
||||
// every zone, on any client, no per-server table drift.
|
||||
//
|
||||
// With no argument, returns overlays for the world map's CURRENT view
|
||||
// (mirroring the resolution the engine's GetMapInfo does: continent /
|
||||
// zone selection → WorldMapArea row). With a numeric `areaID`
|
||||
// (AreaTable id — the stable zone identity), resolves the WorldMapArea
|
||||
// row whose areaID column matches.
|
||||
//
|
||||
// ## Why the tile grid is resolved here (Octo data quirks)
|
||||
//
|
||||
// An overlay's art is `<textureName>1.blp, 2, …` numbered row-major on a
|
||||
// 256px grid, canonically `ceil(w/256) x ceil(h/256)` tiles with only
|
||||
// the last column/row allowed partial. On stock 1.12 data that formula
|
||||
// is always right; on Octo it is not, in three ways (established
|
||||
// empirically by the InkLab map generator, mirrored here):
|
||||
//
|
||||
// 1. FOREIGN TILES: unrelated art continues an overlay's number
|
||||
// sequence in the same folder (Icepoint Rock's folder carries
|
||||
// `kaneqnuun` tiles, Hyjal carries Tel Abim art). Anything derived
|
||||
// from "what files exist" draws duplicate landmass. The overlay
|
||||
// list must come ONLY from the DBC (which this module guarantees),
|
||||
// and tiles past the true grid must be dropped.
|
||||
// 2. SLIVER COLUMNS: the DBC rect can round away a narrow edge column
|
||||
// (kaneqnuun's real art has a third, 8px-wide column; its DBC `w`
|
||||
// yields 2 columns). Trusting ceil(w/256) both loses the sliver
|
||||
// and — worse — shears the row-major layout into diagonal strips.
|
||||
// The art itself reveals the truth: only a row's LAST tile may be
|
||||
// narrower than 256, so the first partial-width tile (searched one
|
||||
// past the DBC's column count) marks the real column count, and
|
||||
// the first partial-height tile marks the last row.
|
||||
// 3. UPSCALED RE-EXPORTS: the same rect re-exported at higher
|
||||
// resolution (Stonetalon's WindshearCrag: 4 tiles for a 1-tile
|
||||
// rect). All tiles are real and must be kept, drawn scaled to the
|
||||
// rect. Only distinguishable from quirk 1 when the art has NO
|
||||
// partial edge anywhere AND the DBC rect is a whole multiple of
|
||||
// 256 in both dimensions; the grid shape is then recovered from
|
||||
// the aspect ratio (cols ≈ round(sqrt(n·w/h))).
|
||||
//
|
||||
// Tile dimensions come from the BLP headers via the engine's own VFS
|
||||
// reader (`FUN_FILE_READ`, so patch-MPQ overrides are honored). Both
|
||||
// BLP1 and BLP2 keep width/height at header offsets +0x0C/+0x10.
|
||||
// Resolution runs once per overlay per session (cached).
|
||||
//
|
||||
// ## HD map patches (uniform tile upscale)
|
||||
//
|
||||
// A custom `patch-*.mpq` can replace the overlay art with higher-res tiles
|
||||
// (every 256px tile re-exported at 256*scale). Because we read the *file*
|
||||
// dimensions, that upscale must not be assumed to be 256: the resolver
|
||||
// detects the full-cell size `S` (the largest tile dimension = 256*scale)
|
||||
// and expresses both the partial-tile threshold and the texcoords relative
|
||||
// to it. Draw sizes/positions remain in logical map pixels (the patch changes
|
||||
// texture resolution, not the map canvas), so an interior tile reads texcoord
|
||||
// 1.0 at any scale and stock data (S == 256) is byte-identical to before.
|
||||
// The upscale factor need not be integral; only a uniform per-overlay scale
|
||||
// is assumed.
|
||||
//
|
||||
// ## Return shape
|
||||
//
|
||||
// Array of overlay tables:
|
||||
// textureName — bare DBC name ("GOLDSHIRE")
|
||||
// texturePath — "Interface\WorldMap\<dir>\GOLDSHIRE" (engine format)
|
||||
// textureWidth / textureHeight — the DBC placement rect (reliable for
|
||||
// placement; its IMPLIED tile count is what lies)
|
||||
// offsetX / offsetY — placement on the zone canvas
|
||||
// mapPointX / mapPointY
|
||||
// tileCols / tileRows / upscaled — the resolved grid
|
||||
// tiles — array of ready-to-draw tiles:
|
||||
// { file — texture path incl. number (SetTexture-ready)
|
||||
// width, height — draw size in map pixels
|
||||
// texCoordX, texCoordY — right/bottom texcoords
|
||||
// offsetX, offsetY } — absolute canvas position
|
||||
//
|
||||
// A consumer draws an overlay with a dumb loop: per tile SetTexture,
|
||||
// SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
|
||||
namespace Map::Overlays {
|
||||
|
||||
namespace {
|
||||
|
||||
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
|
||||
unsigned int *outSize, unsigned int extraBytes,
|
||||
int flag1, int flag2);
|
||||
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line,
|
||||
int flags);
|
||||
|
||||
constexpr int kMaxTiles = 64;
|
||||
|
||||
// Smallest power of two >= v (min 16), matching the engine's own
|
||||
// WorldMapFrame tile-file-size loop. A tile's content fills content/NextPot
|
||||
// of its (power-of-two) file — a fraction that's identical whether the file
|
||||
// is stock or HD-upscaled, so texcoords derived from it are scale-invariant.
|
||||
int NextPot(int v) {
|
||||
int p = 16;
|
||||
while (p < v)
|
||||
p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Resolved tile grid for one overlay, cached for the session (DBC and
|
||||
// MPQ contents are immutable per process).
|
||||
struct ResolvedOverlay {
|
||||
uint8_t cols;
|
||||
uint8_t rows;
|
||||
uint8_t count; // tiles to draw (foreign tail already dropped)
|
||||
bool upscaled;
|
||||
uint16_t cell; // full-cell file size (256 on stock, 256*scale on an HD patch)
|
||||
uint16_t fileW[kMaxTiles]; // BLP file dimensions per tile
|
||||
uint16_t fileH[kMaxTiles];
|
||||
};
|
||||
|
||||
ResolvedOverlay **g_cache = nullptr;
|
||||
int g_cacheSize = 0;
|
||||
|
||||
// Reads one BLP's header dimensions through the engine VFS. Returns
|
||||
// false when the file doesn't exist (end of the tile sequence) or isn't
|
||||
// a BLP.
|
||||
bool ReadBlpDims(const char *path, uint16_t *outW, uint16_t *outH) {
|
||||
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
void *buf = nullptr;
|
||||
unsigned int size = 0;
|
||||
if (FileRead(0, path, &buf, &size, 0, 1, 0) == 0 || buf == nullptr)
|
||||
return false;
|
||||
bool ok = false;
|
||||
const uint8_t *b = static_cast<const uint8_t *>(buf);
|
||||
if (size >= 0x14 && b[0] == 'B' && b[1] == 'L' && b[2] == 'P') {
|
||||
const uint32_t w = *reinterpret_cast<const uint32_t *>(b + 0x0C);
|
||||
const uint32_t h = *reinterpret_cast<const uint32_t *>(b + 0x10);
|
||||
if (w > 0 && w <= 4096 && h > 0 && h <= 4096) {
|
||||
*outW = static_cast<uint16_t>(w);
|
||||
*outH = static_cast<uint16_t>(h);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
SMemFree(buf, __FILE__, __LINE__, 0);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// The quirk disambiguation (see module comment). `basePath` is the
|
||||
// numberless texture path; dbcW/dbcH the overlay's DBC rect.
|
||||
void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
|
||||
int dbcH) {
|
||||
char path[0x120];
|
||||
int n = 0;
|
||||
while (n < kMaxTiles) {
|
||||
std::snprintf(path, sizeof(path), "%s%d.blp", basePath, n + 1);
|
||||
if (!ReadBlpDims(path, &out->fileW[n], &out->fileH[n]))
|
||||
break;
|
||||
++n;
|
||||
}
|
||||
|
||||
int dbcCols = (dbcW + 255) / 256;
|
||||
int dbcRows = (dbcH + 255) / 256;
|
||||
if (dbcCols < 1)
|
||||
dbcCols = 1;
|
||||
if (dbcRows < 1)
|
||||
dbcRows = 1;
|
||||
|
||||
// Full-cell file size. On stock data a full (interior) tile is 256px; an
|
||||
// HD map patch (patch-*.mpq) upscales every tile uniformly, so a full cell
|
||||
// is 256*scale. Full tiles are the largest; edges/slivers are smaller — so
|
||||
// the max tile dimension is the full-cell size. Partial-tile detection
|
||||
// (and the texcoords in PushTiles) must compare against this S, not a
|
||||
// hardcoded 256, or an HD edge tile (e.g. a 200px edge upscaled to 400px)
|
||||
// reads as "full" and shears the row-major grid into diagonal strips.
|
||||
int S = 256;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (out->fileW[i] > S)
|
||||
S = out->fileW[i];
|
||||
if (out->fileH[i] > S)
|
||||
S = out->fileH[i];
|
||||
}
|
||||
out->cell = static_cast<uint16_t>(S);
|
||||
|
||||
// Real column count: the first partial-width tile is a row's right
|
||||
// edge. Search one past dbcCols so a sliver column the DBC width
|
||||
// rounds off is still found.
|
||||
int cols = dbcCols;
|
||||
bool widthEdge = false;
|
||||
for (int i = 0; i < n && i <= dbcCols; ++i) {
|
||||
if (out->fileW[i] < S) {
|
||||
cols = i + 1;
|
||||
widthEdge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Real row count: the row holding the first partial-height tile is
|
||||
// the last row.
|
||||
int rows = dbcRows;
|
||||
bool heightEdge = false;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (out->fileH[i] < S) {
|
||||
rows = i / cols + 1;
|
||||
heightEdge = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int limit = cols * rows;
|
||||
bool upscaled = false;
|
||||
// Upscale exception: no partial edge anywhere and a whole-tile DBC
|
||||
// rect means the extras are higher-res art for the same rect, not
|
||||
// foreign — keep them all; recover the grid from the aspect ratio.
|
||||
if (!widthEdge && !heightEdge && dbcW % 256 == 0 && dbcH % 256 == 0 &&
|
||||
n > limit) {
|
||||
upscaled = true;
|
||||
cols = static_cast<int>(std::floor(
|
||||
std::sqrt(static_cast<double>(n) * dbcW / dbcH) + 0.5));
|
||||
if (cols < 1)
|
||||
cols = 1;
|
||||
if (cols > n)
|
||||
cols = n;
|
||||
limit = n;
|
||||
rows = (n + cols - 1) / cols;
|
||||
}
|
||||
if (limit > n)
|
||||
limit = n;
|
||||
if (cols < 1)
|
||||
cols = 1;
|
||||
if (rows < 1)
|
||||
rows = 1;
|
||||
|
||||
out->cols = static_cast<uint8_t>(cols);
|
||||
out->rows = static_cast<uint8_t>(rows);
|
||||
out->count = static_cast<uint8_t>(limit);
|
||||
out->upscaled = upscaled;
|
||||
}
|
||||
|
||||
const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
|
||||
int dbcW, int dbcH) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
|
||||
if (g_cache == nullptr || g_cacheSize < count + 1) {
|
||||
auto **fresh = new ResolvedOverlay *[count + 1]();
|
||||
for (int i = 0; i < g_cacheSize; ++i)
|
||||
fresh[i] = g_cache[i];
|
||||
delete[] g_cache;
|
||||
g_cache = fresh;
|
||||
g_cacheSize = count + 1;
|
||||
}
|
||||
if (overlayId < 0 || overlayId >= g_cacheSize)
|
||||
return nullptr;
|
||||
if (g_cache[overlayId] == nullptr) {
|
||||
auto *r = new ResolvedOverlay();
|
||||
ResolveTiles(r, basePath, dbcW, dbcH);
|
||||
g_cache[overlayId] = r;
|
||||
}
|
||||
return g_cache[overlayId];
|
||||
}
|
||||
|
||||
// WorldMapArea row id for the world map's current view — the same
|
||||
// resolution the engine's current-map-name getter (FUN_004a6cf0, behind
|
||||
// GetMapInfo) performs: continent/zone selection indexes into the
|
||||
// per-continent data blob; no continent selected falls back to the
|
||||
// default row global.
|
||||
int CurrentWorldMapAreaRow() {
|
||||
const int continent =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
|
||||
if (continent < 0)
|
||||
return *reinterpret_cast<const int *>(
|
||||
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
|
||||
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
|
||||
if (blob == nullptr)
|
||||
return -1;
|
||||
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
|
||||
const int zone =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
|
||||
if (zone < 0)
|
||||
return *reinterpret_cast<const int *>(entry + 0x04);
|
||||
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
|
||||
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
|
||||
}
|
||||
|
||||
// WorldMapArea row id for an AreaTable areaID. Linear walk — the table
|
||||
// has ~175 rows; called once per Lua call.
|
||||
int WorldMapAreaRowForAreaID(uint32_t areaID) {
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
|
||||
areaID)
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Pushes the `tiles` array for one overlay (leaves it on the stack top).
|
||||
void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
|
||||
int dbcW, int dbcH, int offX, int offY) {
|
||||
Game::Lua::NewTable(L);
|
||||
if (r == nullptr || r->count == 0)
|
||||
return;
|
||||
|
||||
const int cols = r->cols;
|
||||
char file[0x120];
|
||||
for (int i = 0; i < r->count; ++i) {
|
||||
const int col = i % cols;
|
||||
const int row = i / cols;
|
||||
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
|
||||
|
||||
double drawW, drawH, tcX, tcY, posX, posY;
|
||||
if (r->upscaled) {
|
||||
// Higher-res re-export of the same rect: tiles split the DBC
|
||||
// rect evenly, full texcoords.
|
||||
drawW = static_cast<double>(dbcW) / r->cols;
|
||||
drawH = static_cast<double>(dbcH) / r->rows;
|
||||
tcX = 1.0;
|
||||
tcY = 1.0;
|
||||
posX = offX + col * drawW;
|
||||
posY = offY + row * drawH;
|
||||
} else {
|
||||
// Canonical 256px logical grid. Draw sizes/positions stay in
|
||||
// logical map pixels; the texcoord is the content fraction of the
|
||||
// (possibly HD-upscaled) file. `scale` = file px per logical px
|
||||
// (1 on stock, 2/4 on an HD patch), so a full interior tile reads
|
||||
// texCoord 1.0 regardless of file size, and an edge's content
|
||||
// (remainder * scale) is measured against its actual file width.
|
||||
const int fileW = r->fileW[i];
|
||||
const int fileH = r->fileH[i];
|
||||
const double scale = (r->cell > 0) ? (r->cell / 256.0) : 1.0;
|
||||
|
||||
double logicalW = 256.0, logicalH = 256.0;
|
||||
tcX = 1.0;
|
||||
tcY = 1.0;
|
||||
if (col == r->cols - 1) {
|
||||
const int rem = dbcW - 256 * (r->cols - 1);
|
||||
if (rem <= 0) {
|
||||
// Sliver the DBC width rounded away: whole file is content
|
||||
// (its logical width is the file width de-scaled).
|
||||
logicalW = fileW / scale;
|
||||
} else {
|
||||
// Content fills rem/NextPot(rem) of the POT tile file.
|
||||
// Scale-invariant, so an HD-upscaled file (POT*scale)
|
||||
// reads the same fraction — using the raw file width here
|
||||
// would halve the texcoord on any tile the patch doubled.
|
||||
logicalW = rem;
|
||||
tcX = rem / static_cast<double>(NextPot(rem));
|
||||
}
|
||||
}
|
||||
if (row == r->rows - 1) {
|
||||
const int rem = dbcH - 256 * (r->rows - 1);
|
||||
if (rem <= 0) {
|
||||
logicalH = fileH / scale;
|
||||
} else {
|
||||
logicalH = rem;
|
||||
tcY = rem / static_cast<double>(NextPot(rem));
|
||||
}
|
||||
}
|
||||
drawW = logicalW;
|
||||
drawH = logicalH;
|
||||
posX = offX + col * 256;
|
||||
posY = offY + row * 256;
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "file", file);
|
||||
Game::Lua::SetFieldNumber(L, "width", drawW);
|
||||
Game::Lua::SetFieldNumber(L, "height", drawH);
|
||||
Game::Lua::SetFieldNumber(L, "texCoordX", tcX);
|
||||
Game::Lua::SetFieldNumber(L, "texCoordY", tcY);
|
||||
Game::Lua::SetFieldNumber(L, "offsetX", posX);
|
||||
Game::Lua::SetFieldNumber(L, "offsetY", posY);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
}
|
||||
|
||||
// `C_Map.GetMapOverlays([areaID])` — see the module comment.
|
||||
int __fastcall Script_GetMapOverlays(void *L) {
|
||||
int wmaRow = -1;
|
||||
if (Game::Lua::IsNumber(L, 1)) {
|
||||
wmaRow = WorldMapAreaRowForAreaID(
|
||||
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
|
||||
} else {
|
||||
wmaRow = CurrentWorldMapAreaRow();
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (wmaRow <= 0)
|
||||
return 1;
|
||||
|
||||
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(wmaRow));
|
||||
const char *mapDir =
|
||||
(wmaRec != nullptr)
|
||||
? *reinterpret_cast<const char *const *>(wmaRec +
|
||||
Offsets::OFF_WMA_NAME)
|
||||
: nullptr;
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_OVERLAY_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_OVERLAY_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr ||
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
|
||||
wmaRow)
|
||||
continue;
|
||||
const char *texName = *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_WMO_TEXTURE_NAME);
|
||||
if (texName == nullptr || texName[0] == '\0')
|
||||
continue; // hit-rect-only rows have no drawable texture
|
||||
|
||||
// Full path, mirroring the engine's own GetMapOverlayInfo format
|
||||
// (unresolvable map dir falls back to the "World" folder).
|
||||
char basePath[0x104];
|
||||
if (mapDir != nullptr && mapDir[0] != '\0')
|
||||
std::snprintf(basePath, sizeof(basePath),
|
||||
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
|
||||
else
|
||||
std::snprintf(basePath, sizeof(basePath),
|
||||
"Interface\\WorldMap\\World\\%s", texName);
|
||||
|
||||
const auto fieldInt = [rec](uintptr_t off) {
|
||||
return *reinterpret_cast<const int32_t *>(rec + off);
|
||||
};
|
||||
const int dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
|
||||
const int dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
|
||||
const int offX = fieldInt(Offsets::OFF_WMO_OFFSET_X);
|
||||
const int offY = fieldInt(Offsets::OFF_WMO_OFFSET_Y);
|
||||
|
||||
const ResolvedOverlay *r = CachedResolve(id, basePath, dbcW, dbcH);
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "textureName", texName);
|
||||
Game::Lua::SetFieldString(L, "texturePath", basePath);
|
||||
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
|
||||
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
|
||||
Game::Lua::SetFieldNumber(L, "offsetX", offX);
|
||||
Game::Lua::SetFieldNumber(L, "offsetY", offY);
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "mapPointX", fieldInt(Offsets::OFF_WMO_MAP_POINT_X));
|
||||
Game::Lua::SetFieldNumber(
|
||||
L, "mapPointY", fieldInt(Offsets::OFF_WMO_MAP_POINT_Y));
|
||||
if (r != nullptr) {
|
||||
Game::Lua::SetFieldNumber(L, "tileCols", r->cols);
|
||||
Game::Lua::SetFieldNumber(L, "tileRows", r->rows);
|
||||
Game::Lua::SetFieldBool(L, "upscaled", r->upscaled);
|
||||
}
|
||||
Game::Lua::PushString(L, "tiles");
|
||||
PushTiles(L, r, basePath, dbcW, dbcH, offX, offY);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapOverlays",
|
||||
&Script_GetMapOverlays);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Overlays
|
||||
@@ -0,0 +1,312 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_PlayerInfo.CanUseItem(itemID)` — true iff the local player meets the
|
||||
// item's *use/equip* requirements. This is the "is the item red in the
|
||||
// tooltip" gate, and is distinct from its siblings:
|
||||
// - `C_Item.IsEquippableItem` — does the item fit *some* slot (static).
|
||||
// - `IsUsableItem` — is the item's on-use ability castable now.
|
||||
// - `C_PlayerInfo.CanUseItem` — may the player equip/use it at all.
|
||||
//
|
||||
// So this is the answer to "mail chest on a Mage" (proficiency), "level-40
|
||||
// item on a level-20" (RequiredLevel), and class/race-restricted gear.
|
||||
//
|
||||
// Not in vanilla 1.12 nor in our 3.3.5 reference client (the modern API
|
||||
// landed in 1.14.4 / 3.4.2 / 10.0.5), so there's no cross-binary source;
|
||||
// the logic mirrors the requirement checks the 1.12 item tooltip builder
|
||||
// (`FUN_0052B650`) inlines for its red/green line coloring — the same
|
||||
// `bVar23` "can't use this" flag, minus the display.
|
||||
//
|
||||
// Checks (all must pass):
|
||||
// 1. Proficiency — for weapon/armor the engine keeps a per-item-class
|
||||
// subclass-proficiency bitmask (VAR_PROFICIENCY_TABLE, fed by
|
||||
// SMSG_SET_PROFICIENCY). `mask[m_class] & (1 << m_subClass)` must be
|
||||
// set. Item classes with no proficiency concept store mask 0 and are
|
||||
// unrestricted (matches the tooltip's `mask != 0` gate).
|
||||
// 2. RequiredLevel <= player level.
|
||||
// 3. AllowableClass / AllowableRace masks include the player (0 or all-
|
||||
// bits = no restriction).
|
||||
// 4. RequiredSkill — the player must have the item's skill line at an
|
||||
// effective rank (current + temp bonus) >= RequiredSkillRank (e.g. a
|
||||
// mount "Requires Riding (150)"). Via FUN_SKILL_LINE_TO_SLOT.
|
||||
// 5. RequiredSpell — the player must know the item's prerequisite spell,
|
||||
// or a higher rank of it: a crafting specialization ("Requires
|
||||
// Armorsmith") or a proficiency spell. Via the spell-knowledge bitmap
|
||||
// plus the engine's rank-chain walk.
|
||||
// 6. RequiredHonorRank / RequiredCityRank — PvP-rank gates: the player's
|
||||
// honor rank (>= required) and earned PvP-medal bitmask.
|
||||
// 7. RequiredReputation — current standing with the item's faction must
|
||||
// reach the required reaction band.
|
||||
//
|
||||
// Cache-miss handling matches `C_Item.IsEquippableItem`: a synchronous
|
||||
// peek that returns false without firing a load.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/Arg.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::CanUseItem {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
// `__thiscall(player /*ecx*/, skillLineID) -> slot` (-1 if the player
|
||||
// doesn't have the skill line). See VAR docs on FUN_SKILL_LINE_TO_SLOT.
|
||||
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
|
||||
// `__fastcall(factionID) -> current total reputation` (base + bonus), 0
|
||||
// for factions not in the player's list. See FUN_REPUTATION_GET_STANDING.
|
||||
using RepStanding_t = int(__fastcall *)(int factionID);
|
||||
// `__thiscall(player /*ecx*/, spellID) -> nonzero` if the player knows a
|
||||
// higher rank in spellID's chain. See FUN_SPELL_RANK_CHAIN_KNOWN.
|
||||
using SpellRankChainKnown_t = int(__thiscall *)(void *player, uint32_t spellID);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// The local player's CGUnit/CGPlayer object (holds the descriptor at
|
||||
// +OFF_UNIT_DESCRIPTOR and the CGPlayer sub-struct at +OFF_CGPLAYER_INFO).
|
||||
const uint8_t *ResolvePlayer() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(resolve("player"));
|
||||
}
|
||||
|
||||
// True iff `mask` restricts a 1-based `index` (class/race) the player
|
||||
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
|
||||
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
|
||||
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
|
||||
return false;
|
||||
return (mask & (1u << ((index1Based - 1) & 31))) == 0;
|
||||
}
|
||||
|
||||
// Per-item-class proficiency bitmask lookup. Returns false only when the
|
||||
// item's class *has* a proficiency concept (mask != 0) and the item's
|
||||
// subclass bit is unset.
|
||||
bool ProficiencyMet(const uint8_t *record) {
|
||||
const uint32_t itemClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CLASS);
|
||||
const uint32_t subClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
|
||||
if (itemClass > 16) // table is 17 entries (m_class 0..16)
|
||||
return true;
|
||||
auto *table = reinterpret_cast<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE));
|
||||
const uint32_t mask = table[itemClass];
|
||||
if (mask == 0)
|
||||
return true; // class has no proficiency concept (consumables, …)
|
||||
return (mask & (1u << (subClass & 31))) != 0;
|
||||
}
|
||||
|
||||
// RequiredSkill + RequiredSkillRank gate (e.g. a mount "Requires Riding
|
||||
// (150)", a profession tool "Requires Engineering (200)"). Maps the
|
||||
// item's SkillLine.dbc row to the player's skill slot; the player must
|
||||
// have the line at an effective rank (current + temp bonus) >= required.
|
||||
bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
|
||||
const uint32_t reqSkill = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
|
||||
if (reqSkill == 0)
|
||||
return true; // no skill requirement
|
||||
const uint32_t reqRank = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
|
||||
|
||||
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
|
||||
const int slot = toSlot(const_cast<uint8_t *>(player), reqSkill);
|
||||
if (slot < 0)
|
||||
return false; // player doesn't have the skill line at all
|
||||
|
||||
auto *sub = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (sub == nullptr)
|
||||
return false;
|
||||
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
|
||||
slot * Offsets::SKILL_INFO_STRIDE;
|
||||
uint32_t rank = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
|
||||
if (rank != 0) // add the temp bonus only when there's a base value
|
||||
rank += *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
|
||||
return rank >= reqRank;
|
||||
}
|
||||
|
||||
// True if the local player knows `spellID`, via the engine's spell-
|
||||
// knowledge bitmap (the active-player path of `FUN_0060c740`, read
|
||||
// directly — no player object needed). Covers trained abilities, talents,
|
||||
// racials, profession recipes, and crafting specializations (Armorsmith,
|
||||
// Weaponsmith, the Engineering branches, …), which are all single learned
|
||||
// spells that land in this bitmap.
|
||||
bool PlayerKnowsSpell(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
if (bitmap == nullptr)
|
||||
return false;
|
||||
const int spellCount = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
|
||||
if (spellID > spellCount)
|
||||
return false;
|
||||
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
|
||||
}
|
||||
|
||||
// RequiredSpell gate — the item's `RequiredSpell` (a Spell.dbc row) is the
|
||||
// prerequisite the "Requires <spell>" tooltip line reflects: a crafting
|
||||
// specialization like Armorsmith, or an armor/weapon proficiency spell.
|
||||
// The player must know it. An unset (0) or invalid (no Spell.dbc record)
|
||||
// requirement is no gate — matching the tooltip's validity guard, which
|
||||
// skips the line for a missing row.
|
||||
//
|
||||
// Mirrors the tooltip's `bitmap(reqSpell) OR rank-chain(reqSpell)`: met if
|
||||
// the player knows the exact RequiredSpell, or a higher rank of it (the
|
||||
// latter covers a ranked RequiredSpell — `FUN_SPELL_RANK_CHAIN_KNOWN`
|
||||
// walks the forward chain). Item RequiredSpell is a non-ranked
|
||||
// specialization/proficiency in practice, so the chain walk is
|
||||
// belt-and-suspenders, but it makes the check exact.
|
||||
bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
|
||||
const int reqSpell = *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
|
||||
if (reqSpell <= 0)
|
||||
return true; // no requirement
|
||||
if (::Spell::Lookup::RecordForID(reqSpell) == nullptr)
|
||||
return true; // invalid Spell.dbc row → tooltip skips the gate
|
||||
if (PlayerKnowsSpell(reqSpell))
|
||||
return true; // knows the exact spell
|
||||
auto chainKnown = reinterpret_cast<SpellRankChainKnown_t>(
|
||||
Offsets::FUN_SPELL_RANK_CHAIN_KNOWN);
|
||||
return chainKnown(const_cast<uint8_t *>(player),
|
||||
static_cast<uint32_t>(reqSpell)) != 0;
|
||||
}
|
||||
|
||||
// RequiredHonorRank — the player's PvP honor rank must be >= the item's.
|
||||
// `sub` is the CGPlayer +0xE68 sub-struct (may be null pre-world).
|
||||
bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
|
||||
const uint32_t req = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
|
||||
if (req == 0)
|
||||
return true;
|
||||
if (sub == nullptr)
|
||||
return false;
|
||||
const uint8_t rank = *(sub + Offsets::OFF_CGPLAYER_HONOR_RANK);
|
||||
return rank >= req;
|
||||
}
|
||||
|
||||
// RequiredCityRank — a PvP-medal bitmask gate: the item's rank R passes
|
||||
// when the player has earned bit (R-1) in the medal mask.
|
||||
bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
|
||||
const uint32_t req = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
|
||||
if (req == 0)
|
||||
return true;
|
||||
if (sub == nullptr)
|
||||
return false;
|
||||
const uint32_t medals = *reinterpret_cast<const uint32_t *>(
|
||||
sub + Offsets::OFF_CGPLAYER_PVP_MEDALS);
|
||||
return (medals & (1u << ((req - 1) & 31))) != 0;
|
||||
}
|
||||
|
||||
// RequiredReputation — current standing with the item's faction must
|
||||
// reach the required reaction band. The band index (0=Hated … 7=Exalted)
|
||||
// indexes the engine's reaction-min threshold table; the player's total
|
||||
// rep with the faction must be >= that threshold.
|
||||
bool RequiredReputationMet(const uint8_t *record) {
|
||||
const int faction = *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
|
||||
if (faction == 0)
|
||||
return true;
|
||||
const uint32_t band = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
|
||||
if (band > 7) // reaction-min table is 8 bands (0..7); guard OOB
|
||||
return true;
|
||||
auto getStanding = reinterpret_cast<RepStanding_t>(Offsets::FUN_REPUTATION_GET_STANDING);
|
||||
const int current = getStanding(faction);
|
||||
const int threshold = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_REACTION_MIN_TABLE) + band * 4);
|
||||
return current >= threshold;
|
||||
}
|
||||
|
||||
bool ComputeCanUse(int itemID) {
|
||||
if (itemID <= 0)
|
||||
return false;
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return false; // not cached → sync false, no load fired
|
||||
auto *player = ResolvePlayer();
|
||||
if (player == nullptr)
|
||||
return false; // pre-login / no player
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return false;
|
||||
|
||||
if (!ProficiencyMet(record))
|
||||
return false;
|
||||
|
||||
const int playerLevel = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
|
||||
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
|
||||
if (static_cast<int>(reqLevel) > playerLevel)
|
||||
return false;
|
||||
|
||||
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
|
||||
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
|
||||
if (RestrictedOut(allowClass, playerClass))
|
||||
return false;
|
||||
|
||||
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
|
||||
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
|
||||
if (RestrictedOut(allowRace, playerRace))
|
||||
return false;
|
||||
|
||||
if (!RequiredSkillMet(player, record))
|
||||
return false;
|
||||
|
||||
if (!RequiredSpellMet(player, record))
|
||||
return false;
|
||||
|
||||
// PvP rank + reputation gates read the CGPlayer +0xE68 sub-struct.
|
||||
auto *sub = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (!RequiredHonorRankMet(sub, record))
|
||||
return false;
|
||||
if (!RequiredCityRankMet(sub, record))
|
||||
return false;
|
||||
if (!RequiredReputationMet(record))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int __fastcall Script_CanUseItem(void *L) {
|
||||
const int itemID = Item::Arg::ResolveItemID(L, 1);
|
||||
Game::Lua::PushBool(L, ComputeCanUse(itemID));
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_PlayerInfo", "CanUseItem", &Script_CanUseItem);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Player::CanUseItem
|
||||
@@ -0,0 +1,218 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` — the WotLK-era
|
||||
// per-slot paperdoll event. Vanilla only fires UNIT_INVENTORY_CHANGED for
|
||||
// the player, which doesn't say WHICH slot changed; gear trackers and stat
|
||||
// sheets built against 3.3+ gate their refresh on this event.
|
||||
//
|
||||
// Fully event-driven via the engine's own descriptor-field observer system
|
||||
// (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h). The engine's
|
||||
// inventory setup (FUN_INV_OBSERVER_SETUP, once per enter-world) registers
|
||||
// change observers for the bag/backpack/bank/keyring GUID fields of the
|
||||
// player descriptor — but never for the 19 equipment slots (0x4A8..0x538).
|
||||
// We co-hook that setup and register our own observer per equipment field,
|
||||
// mirroring the engine's registration shape exactly (bank 4, size 8). The
|
||||
// dispatcher invokes us only when a slot's item GUID actually changed
|
||||
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
|
||||
// on our side, and the nodes share the engine observers' lifetime (they
|
||||
// die with the player object; the setup re-runs each enter-world).
|
||||
//
|
||||
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
|
||||
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
|
||||
// item and nil when it's empty (the engine dispatcher has no `%b`; 1/nil
|
||||
// keeps the idiomatic `if hasCurrent then` working — see FireIdSuccess).
|
||||
//
|
||||
// Also `UPDATE_INVENTORY_DURABILITY` (no payload) — fires when any
|
||||
// equipped item's durability changes (combat damage, death, repairs) —
|
||||
// via a co-hook on the engine's inventory-alerts recompute
|
||||
// (FUN_INVENTORY_ALERTS_RECOMPUTE): the engine itself calls that whenever
|
||||
// an owned item's fields change (its item post-update handler FUN_005D9A90
|
||||
// routes every item values-update through it) and on equip/enter-world
|
||||
// paths, so it IS the engine's own "durability may have changed" signal.
|
||||
// Post-original we diff a per-slot {itemGuid, durability} snapshot and
|
||||
// fire only on a real change. NOT done with bank-1 (ITEM field) descriptor
|
||||
// observers: those register without error but are never invoked — the
|
||||
// registrar's internal wrapper lookup (FUN_00464890, the observer
|
||||
// registry, distinct from the object manager) silently no-ops for item
|
||||
// objects, verified empirically (item values-updates arrive and the
|
||||
// player-bank observers fire, item-bank ones never do).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "equipmentset/Locations.h"
|
||||
#include "event/Custom.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Player::Equipment {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEvtEquipmentChanged = "PLAYER_EQUIPMENT_CHANGED";
|
||||
const Event::Custom::AutoReserve _r{kEvtEquipmentChanged};
|
||||
constexpr const char *kEvtDurability = "UPDATE_INVENTORY_DURABILITY";
|
||||
const Event::Custom::AutoReserve _r2{kEvtDurability};
|
||||
|
||||
// Whether the slot currently holds an item — read from the player's
|
||||
// inventory-manager GUID array, the same source the engine's own bag
|
||||
// observer (FUN_004F8DB0) treats as the post-change truth when its field
|
||||
// observer fires. The player is resolved from the GUID the observer
|
||||
// callback receives, via the NON-THROWING object resolver — the same call
|
||||
// FUN_004F8DB0 makes. NOT via FUN_RESOLVE_UNIT_TOKEN("player"): that's the
|
||||
// assert-frame helper that raises a Lua error when the token doesn't
|
||||
// resolve, and this runs inside SMSG_UPDATE_OBJECT processing, where the
|
||||
// player object can be mid-create and momentarily unresolvable — a Lua
|
||||
// error thrown from there unwinds through raw engine code.
|
||||
bool SlotHasItem(uint32_t guidLo, uint32_t guidHi, int slot0Based) {
|
||||
using ResolveByGUID_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int line);
|
||||
auto resolve =
|
||||
reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
auto *player = static_cast<const uint8_t *>(resolve(
|
||||
Offsets::OBJ_TYPE_PLAYER, "ClassicAPI", guidLo, guidHi, 0x172));
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
invMgr + Offsets::OFF_INVMGR_SLOT_COUNT);
|
||||
if (static_cast<uint32_t>(slot0Based) >= count)
|
||||
return false;
|
||||
const uint64_t *guids = *reinterpret_cast<const uint64_t *const *>(
|
||||
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
|
||||
return guids != nullptr && guids[slot0Based] != 0;
|
||||
}
|
||||
|
||||
// --- PLAYER_EQUIPMENT_CHANGED (player-descriptor observers) -------------
|
||||
|
||||
// Descriptor-field observer callback — ABI per the Offsets.h note:
|
||||
// __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*,
|
||||
// userArg), return 1, callee cleans 0x10. Invoked by the dispatcher only
|
||||
// when the watched equipment GUID field actually changed.
|
||||
int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
const uint32_t * /*oldValue*/,
|
||||
void * /*userArg*/) {
|
||||
const int slot0 =
|
||||
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
|
||||
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
|
||||
slot0 + 1,
|
||||
SlotHasItem(guidLo, guidHi, slot0));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Co-hook on the engine's inventory observer setup: after it registers the
|
||||
// bag-range observers, register ours for the equipment range it skips.
|
||||
// Same registrar, same shape — only the field offsets differ.
|
||||
using RegisterObserver_t = void(__fastcall *)(
|
||||
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
|
||||
const void *callback, void *userArg1, void *userArg2);
|
||||
using ObserverSetup_t = void(__fastcall *)();
|
||||
ObserverSetup_t g_origSetup = nullptr;
|
||||
|
||||
// One-time (per process) seed latch for the durability diff below — the
|
||||
// very first recompute seeds the snapshot silently instead of firing for
|
||||
// the initial gear population. Deliberately NOT reset per enter-world:
|
||||
// death/release/graveyard teleports re-run the setup, and a per-setup
|
||||
// reset made the post-teleport recompute re-seed silently — swallowing
|
||||
// the death's own durability loss (verified in-game: repairs fired,
|
||||
// deaths didn't). Keeping the snapshot across loading screens means
|
||||
// changes that land around them diff normally; the cost is at most one
|
||||
// spurious fire on relog to a different character, which matches
|
||||
// modern's login-time fire anyway.
|
||||
bool g_durSnapValid = false;
|
||||
|
||||
void __fastcall ObserverSetup_h() {
|
||||
g_origSetup();
|
||||
|
||||
const uint64_t guid = Unit::Identity::PlayerGuid();
|
||||
if (guid == 0)
|
||||
return;
|
||||
auto reg = reinterpret_cast<RegisterObserver_t>(
|
||||
Offsets::FUN_DESC_OBSERVER_REGISTER);
|
||||
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
|
||||
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
|
||||
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
|
||||
nullptr, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _setupHook{
|
||||
Offsets::FUN_INV_OBSERVER_SETUP,
|
||||
reinterpret_cast<void *>(&ObserverSetup_h),
|
||||
reinterpret_cast<void **>(&g_origSetup)};
|
||||
|
||||
// --- UPDATE_INVENTORY_DURABILITY (alerts-recompute co-hook) --------------
|
||||
|
||||
struct SlotDurability {
|
||||
uint64_t guid; // equipped item, 0 = empty slot
|
||||
uint32_t durability; // ITEM_FIELD_DURABILITY, 0 when empty/unresolved
|
||||
};
|
||||
SlotDurability g_durSnap[EquipmentSet::SLOT_COUNT];
|
||||
|
||||
uint32_t ItemDurability(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
const uint8_t *item = EquipmentSet::Locations::ResolveItemByGUID(guid);
|
||||
if (item == nullptr)
|
||||
return 0;
|
||||
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
if (descriptor == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_DURABILITY);
|
||||
}
|
||||
|
||||
using AlertsRecompute_t = void(__fastcall *)();
|
||||
AlertsRecompute_t g_origAlertsRecompute = nullptr;
|
||||
|
||||
void __fastcall AlertsRecompute_h() {
|
||||
g_origAlertsRecompute();
|
||||
|
||||
uint64_t paperdoll[EquipmentSet::SLOT_COUNT];
|
||||
EquipmentSet::Locations::SnapshotPaperdoll(paperdoll);
|
||||
|
||||
bool changed = false;
|
||||
for (int i = 0; i < EquipmentSet::SLOT_COUNT; ++i) {
|
||||
const SlotDurability cur{paperdoll[i], ItemDurability(paperdoll[i])};
|
||||
if (g_durSnap[i].guid != cur.guid ||
|
||||
g_durSnap[i].durability != cur.durability) {
|
||||
changed = true;
|
||||
g_durSnap[i] = cur;
|
||||
}
|
||||
}
|
||||
|
||||
if (!g_durSnapValid) {
|
||||
g_durSnapValid = true; // first recompute of the session seeds only
|
||||
return;
|
||||
}
|
||||
if (changed)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _alertsHook{
|
||||
Offsets::FUN_INVENTORY_ALERTS_RECOMPUTE,
|
||||
reinterpret_cast<void *>(&AlertsRecompute_h),
|
||||
reinterpret_cast<void **>(&g_origAlertsRecompute)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Player::Equipment
|
||||
+117
-22
@@ -23,13 +23,19 @@
|
||||
// parse callbacks, and the registry handles load/save so neither
|
||||
// this module nor `Charlist::Order` clobbers the other's lines.
|
||||
//
|
||||
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
|
||||
// Per-realm cache contents. v2 format. One entry per line:
|
||||
// `guidHi guidLo classID raceID sex name`
|
||||
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
|
||||
// Per-realm cache contents, SHARED across every account on the
|
||||
// same server. Keyed by realmlist address (unambiguous server
|
||||
// identity — can't collide across unrelated private servers) plus
|
||||
// the realm name, rather than by account, so alts on different
|
||||
// accounts pool into one file. Not under `Account\`, so we create
|
||||
// the directory tree ourselves. v4 format. One entry per line:
|
||||
// `name guid classID raceID sex`
|
||||
// (fields tab-separated). Loaded once the realm name is
|
||||
// available post-login. Saved when (a) a recent write hasn't
|
||||
// been flushed AND (b) at least 30 seconds have elapsed since
|
||||
// the prior save, OR (c) DLL teardown / `Flush()` is called.
|
||||
// available post-login (migrating a pre-existing per-account file
|
||||
// in on first load). Saved when (a) a recent write hasn't been
|
||||
// flushed AND (b) at least 30 seconds have elapsed since the prior
|
||||
// save, OR (c) DLL teardown / `Flush()` is called.
|
||||
//
|
||||
// Population sources:
|
||||
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
|
||||
@@ -69,6 +75,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <istream>
|
||||
#include <ostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
|
||||
|
||||
// ----- path resolution -----
|
||||
|
||||
// realmList CVar value (the realmlist address, e.g.
|
||||
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
|
||||
// friendly realm name it can't collide across unrelated private servers,
|
||||
// so it's the outer key for the shared cache path. Mirrors
|
||||
// `Security::CredStore`'s realm scoping.
|
||||
const char *ReadRealmList() {
|
||||
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
|
||||
return reinterpret_cast<GetRealmList_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
|
||||
}
|
||||
|
||||
// Filesystem-safe path component. We create these directories ourselves
|
||||
// (WoW doesn't), so any character Windows rejects in a path — or that
|
||||
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
|
||||
// Deterministic, so a realm always maps to the same folder.
|
||||
std::string SanitizeComponent(const char *s) {
|
||||
std::string out;
|
||||
for (const char *p = s; p != nullptr && *p; ++p) {
|
||||
const char c = *p;
|
||||
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
|
||||
c == '_';
|
||||
out.push_back(safe ? c : '_');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Shared, server-specific cache path:
|
||||
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
|
||||
// Not under Account\, so alts on different accounts of the same server
|
||||
// share one pool. Empty (defer) until both realmlist + realm are known.
|
||||
std::string CachePath() {
|
||||
const char *realmList = ReadRealmList();
|
||||
if (realmList == nullptr || realmList[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
std::string out = "WTF\\ClassicAPI\\";
|
||||
out += SanitizeComponent(realmList);
|
||||
out += '\\';
|
||||
out += SanitizeComponent(realm);
|
||||
out += "\\ClassicAPI_NameCache.txt";
|
||||
return out;
|
||||
}
|
||||
|
||||
// The pre-shared per-account path, used only to migrate an existing
|
||||
// cache into the shared location on first load. Built exactly as it was
|
||||
// written (raw account + realm, unsanitized) so it matches the file
|
||||
// sitting in WoW's own folders.
|
||||
std::string OldCachePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
@@ -154,6 +211,21 @@ std::string CachePath() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
|
||||
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
|
||||
// directories, so a save into a fresh realm folder would silently fail
|
||||
// without this. CreateDirectory on an existing dir no-ops.
|
||||
void EnsureParentDirs(const std::string &filePath) {
|
||||
for (size_t pos = 0;;) {
|
||||
const size_t slash = filePath.find('\\', pos);
|
||||
if (slash == std::string::npos)
|
||||
break; // remainder is the filename
|
||||
if (slash > 0)
|
||||
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
|
||||
pos = slash + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- account settings (registered with Settings::Account) -----
|
||||
|
||||
void SettingsReset() {
|
||||
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
|
||||
return changed;
|
||||
}
|
||||
|
||||
void LoadCacheIfNeeded() {
|
||||
if (g_cacheLoaded)
|
||||
return;
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
|
||||
g_cacheLoaded = true;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open())
|
||||
return; // first run for this realm
|
||||
|
||||
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
|
||||
// it's the primary key; GUID is a peripheral field carried for
|
||||
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
|
||||
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
|
||||
// into the store. Name first since it's the primary key; GUID is a
|
||||
// peripheral field carried for GUID-keyed lookups. Variable-width hex
|
||||
// GUID (no leading zeros). Shared by the normal load and the one-time
|
||||
// migration from the old per-account file.
|
||||
void LoadFromStream(std::istream &file) {
|
||||
std::string line;
|
||||
while (std::getline(file, line)) {
|
||||
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
|
||||
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
|
||||
}
|
||||
}
|
||||
|
||||
void LoadCacheIfNeeded() {
|
||||
if (g_cacheLoaded)
|
||||
return;
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
|
||||
g_cacheLoaded = true;
|
||||
|
||||
std::ifstream file(path);
|
||||
if (file.is_open()) {
|
||||
LoadFromStream(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// No shared file yet. One-time migration: seed from the old
|
||||
// per-account file if it exists, then mark dirty so the next save
|
||||
// writes the merged data into the shared path. Only when the shared
|
||||
// file is absent — once it exists it's authoritative (a second
|
||||
// account's old-only entries just repopulate via scan / SMSG).
|
||||
const std::string oldPath = OldCachePath();
|
||||
if (oldPath.empty())
|
||||
return;
|
||||
std::ifstream oldFile(oldPath);
|
||||
if (oldFile.is_open()) {
|
||||
LoadFromStream(oldFile);
|
||||
g_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void SaveCache() {
|
||||
const std::string path = CachePath();
|
||||
if (path.empty())
|
||||
return;
|
||||
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
|
||||
// the tree first, else the ofstream open below silently fails.
|
||||
EnsureParentDirs(path);
|
||||
const std::string tmp = path + ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
@@ -319,7 +414,7 @@ void SaveCache() {
|
||||
// sort/scan key when hand-editing. GUID is variable-width
|
||||
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
|
||||
// GUID ever lands here.
|
||||
out << "# ClassicAPI name cache v4 (per-realm)\n";
|
||||
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
|
||||
out << "# name\tguid\tclassID\traceID\tsex\n";
|
||||
for (const auto &kv : g_entries) {
|
||||
const std::string &name = kv.first;
|
||||
|
||||
@@ -134,6 +134,43 @@ void PushRequirements(void *L, const uint8_t *data) {
|
||||
}
|
||||
}
|
||||
|
||||
// `C_QuestLog.GetNumQuestObjectives(questID)` — the quest's objective
|
||||
// count (the length `GetQuestDetails(questID).requirements` would have),
|
||||
// read straight from the cache block with zero Lua table/string
|
||||
// materialization. Exists so per-step validation loops (guide addons
|
||||
// bounds-checking objective indices at load time) stay O(1)-cheap even
|
||||
// with a warm quest cache — materializing full detail tables for hundreds
|
||||
// of quests inside a login-path loop can exhaust vanilla's fixed Lua
|
||||
// memory pool. Returns nil when the quest isn't cached (same contract as
|
||||
// GetQuestDetails).
|
||||
int __fastcall Script_GetNumQuestObjectives(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_QuestLog.GetNumQuestObjectives(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;
|
||||
|
||||
int count = 0;
|
||||
auto *npcOrGo = reinterpret_cast<const int32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
|
||||
auto *items = reinterpret_cast<const uint32_t *>(
|
||||
data + Quest::Cache::OFF_REQUIRED_ITEM);
|
||||
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
|
||||
if (npcOrGo[i] != 0)
|
||||
++count;
|
||||
if (items[i] != 0)
|
||||
++count;
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(count));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetQuestDetails(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
|
||||
@@ -245,6 +282,8 @@ int __fastcall Script_GetQuestDetails(void *L) {
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
|
||||
&Script_GetQuestDetails);
|
||||
Game::Lua::RegisterTableFunction("C_QuestLog", "GetNumQuestObjectives",
|
||||
&Script_GetNumQuestObjectives);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -11,18 +11,29 @@
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `QUEST_ACCEPTED(questLogIndex, questID)` event — fires once per
|
||||
// new quest entering the local quest log. Polyfills modern WoW's
|
||||
// event of the same name (added in 3.1.0). Matches the Cata/WotLK
|
||||
// signature: `(questLogIndex, questID)`, 1-based log index.
|
||||
// Quest log delta events — polyfills two modern WoW events from one
|
||||
// pre-/post-rebuild diff:
|
||||
//
|
||||
// `QUEST_ACCEPTED(questLogIndex, questID)` — fires once per new quest
|
||||
// entering the local quest log. Matches the Cata/WotLK signature,
|
||||
// 1-based log index (event added in 3.1.0).
|
||||
//
|
||||
// `QUEST_REMOVED(questID)` — fires once per quest leaving the local
|
||||
// quest log, for both turn-ins and abandons (event added in 8.0.1;
|
||||
// Classic-era signature is the bare questID). For turn-ins this fires
|
||||
// *after* `QUEST_TURNED_IN`, matching retail ordering: the 0x191
|
||||
// complete packet doesn't touch the log; the removal arrives in the
|
||||
// follow-up quest-log update packets, whose rebuild we diff here.
|
||||
// Observed turn-in sequence: QUEST_TURNED_IN → UNIT_QUEST_LOG_CHANGED
|
||||
// → QUEST_LOG_UPDATE (fired inside the rebuild) → QUEST_REMOVED.
|
||||
//
|
||||
// Hook target: `FUN_QUEST_LOG_REBUILD` (`0x004DE510`) — the single
|
||||
// chokepoint the engine uses to refresh the Lua-visible quest log
|
||||
// from the player's authoritative slot data at `[CGPlayer + 0xE68 +
|
||||
// 0x28]`. Every state change that adds, removes, or updates a quest
|
||||
// flows through here. By snapshotting the log before and after the
|
||||
// original runs, we can compute the delta and fire QUEST_ACCEPTED
|
||||
// only for genuine additions.
|
||||
// original runs, we can compute the delta and fire only for genuine
|
||||
// additions / removals.
|
||||
//
|
||||
// Login suppression: the same function handles the initial bulk-sync
|
||||
// at character entry, which appears as a single rebuild call adding
|
||||
@@ -30,7 +41,10 @@
|
||||
// added in a single rebuild (which can only happen on login / reload
|
||||
// resync — human input speed can't accept multiple quests in the
|
||||
// same engine tick). A brand-new character's first accept is
|
||||
// `0 → 1` and fires correctly.
|
||||
// `0 → 1` and fires correctly. Removals apply the same rule (a user
|
||||
// action removes at most one quest per rebuild) and are additionally
|
||||
// suppressed when the rebuild also added multiple quests — a
|
||||
// mixed-delta rebuild is a cross-character resync, not gameplay.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
@@ -39,13 +53,15 @@
|
||||
#include <cstdint>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace Quest::Accepted {
|
||||
namespace Quest::LogEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEventName = "QUEST_ACCEPTED";
|
||||
constexpr const char *kAcceptedEventName = "QUEST_ACCEPTED";
|
||||
constexpr const char *kRemovedEventName = "QUEST_REMOVED";
|
||||
|
||||
const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
const Event::Custom::AutoReserve _reserveAccepted{kAcceptedEventName};
|
||||
const Event::Custom::AutoReserve _reserveRemoved{kRemovedEventName};
|
||||
|
||||
// Walk the live quest log and collect questIDs of real (non-header)
|
||||
// entries. Headers are 16-byte rows with a non-NULL pointer at +0x8;
|
||||
@@ -80,7 +96,7 @@ QuestLogRebuild_t QuestLogRebuild_o = nullptr;
|
||||
void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
// `param_1 == 0` is the engine's "no-op rebuild" signal — just
|
||||
// refires QUEST_LOG_UPDATE without touching the entry array.
|
||||
// No diff possible, no accept happening, pass through.
|
||||
// No diff possible, no accept/remove happening, pass through.
|
||||
if (param_1 == 0) {
|
||||
QuestLogRebuild_o(param_1);
|
||||
return;
|
||||
@@ -93,14 +109,17 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
|
||||
// Walk the post-state in index order so the fired event's
|
||||
// `questLogIndex` matches the 1-based slot the engine just
|
||||
// assigned. Skip questIDs already in `pre`.
|
||||
// assigned. Collect the full post-set on the way for the
|
||||
// removal diff.
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
|
||||
if (count <= 0)
|
||||
return;
|
||||
const auto *entries = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
|
||||
|
||||
std::unordered_set<int> post;
|
||||
if (count > 0)
|
||||
post.reserve(static_cast<size_t>(count));
|
||||
|
||||
int newCount = 0;
|
||||
int firstNewIdx = 0;
|
||||
int firstNewID = 0;
|
||||
@@ -114,6 +133,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
|
||||
if (questID <= 0)
|
||||
continue;
|
||||
post.insert(questID);
|
||||
if (pre.count(questID) != 0)
|
||||
continue;
|
||||
++newCount;
|
||||
@@ -123,14 +143,35 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
}
|
||||
}
|
||||
|
||||
int removedCount = 0;
|
||||
int firstRemovedID = 0;
|
||||
for (const int questID : pre) {
|
||||
if (post.count(questID) != 0)
|
||||
continue;
|
||||
++removedCount;
|
||||
if (removedCount == 1)
|
||||
firstRemovedID = questID;
|
||||
}
|
||||
|
||||
// Single addition → user-driven accept. Multiple → bulk resync
|
||||
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
|
||||
// fire on initial sync either; we match that.
|
||||
if (newCount == 1) {
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = Event::Custom::Lookup(kAcceptedEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
|
||||
}
|
||||
|
||||
// Single removal → user-driven turn-in or abandon. The extra
|
||||
// `newCount <= 1` gate rejects the one resync shape the removal
|
||||
// count alone can't: switching to a character whose log shares
|
||||
// all but one of the previous character's quests (1 removal, N
|
||||
// additions in the same rebuild).
|
||||
if (removedCount == 1 && newCount <= 1) {
|
||||
const int slot = Event::Custom::Lookup(kRemovedEventName);
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d", firstRemovedID);
|
||||
}
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookreg{
|
||||
@@ -138,4 +179,4 @@ static const Game::HookAutoRegister _hookreg{
|
||||
reinterpret_cast<void *>(&QuestLogRebuild_h),
|
||||
reinterpret_cast<void **>(&QuestLogRebuild_o)};
|
||||
|
||||
} // namespace Quest::Accepted
|
||||
} // namespace Quest::LogEvents
|
||||
+48
-27
@@ -17,13 +17,12 @@
|
||||
//
|
||||
// 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.
|
||||
// 1. Initiate the cast: resolve the *exact* `spellID` to its spellbook
|
||||
// slot (`Spell::Lookup::FindSpellbookSlot` — NOT name resolution, which
|
||||
// would cast the highest rank) and 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
|
||||
@@ -41,10 +40,10 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "spell/MacroPrimarySpell.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::AtCursor {
|
||||
@@ -141,9 +140,16 @@ bool Resolve() {
|
||||
|
||||
namespace {
|
||||
|
||||
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
|
||||
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
|
||||
unsigned targetGuidLo, float targetGuidHi);
|
||||
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
|
||||
|
||||
void DispatchSlot(int slot0Based, int bookType) {
|
||||
auto dispatch = reinterpret_cast<CastDispatch_t>(
|
||||
Offsets::FUN_SPELL_CAST_DISPATCH);
|
||||
// (0, 0) GUID = no implicit target; the engine handles placement.
|
||||
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -151,39 +157,54 @@ bool DispatchSpellCast(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
|
||||
// Hand the spellID off as a numeric string — our `Spell::CastByID`
|
||||
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
|
||||
// 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);
|
||||
// Resolve the EXACT spellID to its spellbook slot. Every rank is its own
|
||||
// slot, so we must NOT go through name resolution — that walks to the
|
||||
// highest rank and would cast Blizzard(Rank 6) for a Rank 1 (spellID 10)
|
||||
// request. `FindSpellbookSlot` returns a 1-based slot; the dispatcher
|
||||
// wants the 0-based index into `VAR_PLAYER_SPELLBOOK`.
|
||||
int bookType = 0;
|
||||
const int slot1 = Spell::Lookup::FindSpellbookSlot(spellID, &bookType);
|
||||
if (slot1 <= 0)
|
||||
return false; // not in the player's spellbook
|
||||
|
||||
DispatchSlot(slot1 - 1, bookType);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DispatchSpellCastByName(const char *name) {
|
||||
if (name == nullptr || *name == '\0')
|
||||
return false;
|
||||
|
||||
// The engine's resolver strips a trailing `(Rank N)` and returns the
|
||||
// 0-based spellbook slot (highest rank when no rank is given). This is the
|
||||
// by-name path — for an exact rank prefer the numeric spellID overload.
|
||||
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
|
||||
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
|
||||
int bookType = 0;
|
||||
const int slot = nameToSlot(numBuf, &bookType);
|
||||
const int slot = nameToSlot(name, &bookType);
|
||||
if (slot < 0)
|
||||
return false; // not in the player's spellbook
|
||||
|
||||
auto dispatch = reinterpret_cast<CastDispatch_t>(
|
||||
Offsets::FUN_SPELL_CAST_DISPATCH);
|
||||
dispatch(static_cast<unsigned>(slot), bookType,
|
||||
0, 0.0f); // no implicit target unit; engine handles placement
|
||||
DispatchSlot(slot, bookType);
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
// Accept a numeric spellID (exact rank) or a spell name (the engine's
|
||||
// resolver parses a trailing "(Rank N)"). IsNumber is checked first so a
|
||||
// numeric string like "10" still takes the exact-spellID path.
|
||||
bool dispatched = false;
|
||||
if (Game::Lua::IsNumber(L, 1)) {
|
||||
dispatched = DispatchSpellCast(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
} else if (Game::Lua::IsString(L, 1)) {
|
||||
dispatched = DispatchSpellCastByName(Game::Lua::ToString(L, 1));
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
// Spell not in the player's spellbook → match `CastSpellByName`'s
|
||||
// silent failure.
|
||||
if (!DispatchSpellCast(spellID)) {
|
||||
// Bad input / spell not in the player's spellbook → match
|
||||
// `CastSpellByName`'s silent failure.
|
||||
if (!dispatched) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ bool CommitAtCoords(const float coords[3]);
|
||||
// `CommitAtCoords`); for a normal spell it fires immediately. Returns
|
||||
// true if the cast was dispatched (spell is in the player's
|
||||
// spellbook), false otherwise.
|
||||
//
|
||||
// The numeric form casts the *exact* spellID (a specific rank). The
|
||||
// name form defers to the engine's own resolver, which parses a
|
||||
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
|
||||
// `"Blizzard(Rank 6)"` casts that rank.
|
||||
bool DispatchSpellCast(int spellID);
|
||||
bool DispatchSpellCastByName(const char *name);
|
||||
|
||||
} // namespace Spell::AtCursor
|
||||
|
||||
+11
-3
@@ -43,11 +43,14 @@ namespace Spell::AtUnit {
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
// arg1: numeric spellID (exact rank) or spell name ("(Rank N)"-aware).
|
||||
// arg2: unit token.
|
||||
const bool byNumber = Game::Lua::IsNumber(L, 1);
|
||||
if ((!byNumber && !Game::Lua::IsString(L, 1)) ||
|
||||
!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *token = Game::Lua::ToString(L, 2);
|
||||
|
||||
// Resolve the unit's position before dispatching — fail fast so a
|
||||
@@ -61,7 +64,12 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!Spell::AtCursor::DispatchSpellCast(spellID)) {
|
||||
const bool dispatched =
|
||||
byNumber
|
||||
? Spell::AtCursor::DispatchSpellCast(
|
||||
static_cast<int>(Game::Lua::ToNumber(L, 1)))
|
||||
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
|
||||
if (!dispatched) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
+254
-30
@@ -32,26 +32,37 @@
|
||||
// time), dedup'd against a just-made client stamp so a normal cast's
|
||||
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
|
||||
// since the global was never set for these, the WorldTick VAR==0 clear
|
||||
// must skip them — they expire on their computed endMs (and pfUI clears
|
||||
// on nampower's SPELL_FAILED_SELF for interrupts).
|
||||
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
|
||||
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
|
||||
// channel duration.
|
||||
// must skip them — they expire on their computed endMs, or earlier via
|
||||
// the abort co-hooks (ClearCastingSpell / the failure packets) when the
|
||||
// engine learns the cast died.
|
||||
// - channel: stamped from `SMSG_SPELL_START` at packet receipt (instant
|
||||
// channels; engine-computed duration) and from `MSG_CHANNEL_START`
|
||||
// (server duration — and the ONLY start signal for cast-then-channel
|
||||
// spells like Mind Control, whose SpellStart covers just the cast
|
||||
// phase). The broadcast +0x228 field is NOT polled for the player —
|
||||
// it lags the channel start by ~1 tick.
|
||||
// `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.
|
||||
//
|
||||
// Other units have no engine-stored cast record, so we capture their casts
|
||||
// from the `SMSG_SPELL_START` packet (the only place the client sees
|
||||
// another unit's cast + a server-authoritative cast time) into a per-caster
|
||||
// cache — see the SpellStart co-hook below. Scope:
|
||||
// Other units have no engine-stored cast TIMES (the engine does keep the
|
||||
// current-cast spellID at [unit+0xC8C], but nothing temporal), so we
|
||||
// capture their casts from the `SMSG_SPELL_START` packet (the only place
|
||||
// the client sees another unit's cast + a server-authoritative cast time)
|
||||
// into a per-caster cache — see the SpellStart co-hook below. Scope:
|
||||
// - Player casts/channels: full timing (self-tracked, exact engine math).
|
||||
// - Other units' regular CASTS: full timing while within the cast window,
|
||||
// from the SpellStart cache. No clean per-unit interrupt signal exists
|
||||
// in 1.12, so an interrupted remote cast lingers until its computed end.
|
||||
// from the SpellStart cache. Aborts (interrupt, death, movement,
|
||||
// cancel) evict the entry the moment the engine stops the unit's cast
|
||||
// — the CGUnit_C::ClearCastingSpell co-hook below, backstopped by
|
||||
// co-hooks on both failure packets — so no lingering ghost bar.
|
||||
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
|
||||
// now"; the SpellStart cache adds real start/end times when we observed
|
||||
// the channel begin (else spellID/name/texture with nil times).
|
||||
// - Other units' PUSHBACK is invisible: SMSG_SPELL_DELAYED goes only to
|
||||
// the affected caster (empirically confirmed — during a melee-pushback
|
||||
// test only the victim's client received it), so a remote bar can't
|
||||
// stretch. Inherent 1.12 protocol gap, not recoverable client-side.
|
||||
//
|
||||
// Modern-signature fields vanilla can't fill are structurally-correct
|
||||
// placeholders: castID / castBarID = nil, notInterruptible = false,
|
||||
@@ -104,8 +115,44 @@ TrackedSpell g_channel{0, 0, 0, 0};
|
||||
// the WorldTick VAR==0 clear must not touch them.
|
||||
bool g_castFromServer = false;
|
||||
|
||||
// Armed once the broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228) has been seen to
|
||||
// reflect g_channel's spell — the enable for OnWorldTick's channel-stop poll.
|
||||
// StampChannel re-arms it (false) on every channel stamp so that field's
|
||||
// ~1-tick start lag can't false-clear a channel we just stamped.
|
||||
bool g_channelConfirmed = false;
|
||||
|
||||
// Stamp the player's channel and re-arm the stop latch. A channel supersedes
|
||||
// any in-flight cast. Both channel-stamp sites (SMSG_SPELL_START's instant-
|
||||
// channel path and the MSG_CHANNEL_START handler) route through here so
|
||||
// neither can forget to reset the latch.
|
||||
void StampChannel(int spellID, int startMs, int endMs) {
|
||||
g_channel = TrackedSpell{spellID, startMs, endMs, 0};
|
||||
g_channelConfirmed = false;
|
||||
g_cast.spellID = 0;
|
||||
}
|
||||
|
||||
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
|
||||
|
||||
// Pushes an engine-ms timestamp to Lua as an UNSIGNED 32-bit value.
|
||||
//
|
||||
// `FUN_OS_TICKCOUNT_MS` returns a 64-bit ms tick from one of two backends
|
||||
// (GetTickCount, or a scaled rdtsc — the latter survives warm reboots, so
|
||||
// the value can be well past 2^31 even right after a "restart"). We keep
|
||||
// ticks in a signed int internally, which is fine for the small deltas and
|
||||
// window compares (they cancel), but a direct int->double push surfaces a
|
||||
// NEGATIVE startTimeMs/endTimeMs once the tick's high bit is set.
|
||||
//
|
||||
// The contract is that these match `GetTime()*1000`. Script_GetTime
|
||||
// (0x00515EA0) does `(double)(tick & 0xFFFFFFFF) * 0.001` — i.e. it masks
|
||||
// the tick to the low 32 bits UNSIGNED, then scales. So reinterpreting our
|
||||
// stored tick as uint32 reproduces GetTime()'s exact value for the whole
|
||||
// range, including across the ~49.7-day 2^32 wrap (both wrap together).
|
||||
// Carrying 64-bit precision here would be WRONG: it would diverge from
|
||||
// GetTime() past 2^32 and break addons' (now-start)/(end-start) math.
|
||||
void PushMs(void *L, int ms) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(static_cast<uint32_t>(ms)));
|
||||
}
|
||||
|
||||
void *Resolve(const char *token) {
|
||||
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
|
||||
}
|
||||
@@ -173,8 +220,8 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
|
||||
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
|
||||
PushMs(L, c.startMs); // 4 startTimeMs
|
||||
PushMs(L, 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
|
||||
@@ -203,8 +250,8 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
|
||||
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
|
||||
PushMs(L, startMs); // 4 startTimeMs
|
||||
PushMs(L, endMs); // 5 endTimeMs
|
||||
} else {
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNil(L);
|
||||
@@ -332,7 +379,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
const bool channel = IsChannelSpell(rec);
|
||||
|
||||
if (caster == Unit::Identity::PlayerGuid()) {
|
||||
if (channel) {
|
||||
if (channel && castTime == 0) {
|
||||
// Player channel start. The broadcast +0x228 field that signals
|
||||
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
|
||||
// channel actually begins, so a +0x228 poll shows the bar far too
|
||||
@@ -341,14 +388,19 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
// now with the engine-computed duration. We run before the
|
||||
// original handler that fires SPELLCAST_CHANNEL_START, so the data
|
||||
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
|
||||
// Info) at endMs.
|
||||
// Info) at endMs. (MSG_CHANNEL_START re-stamps moments later with
|
||||
// the server's duration — see ChannelStart_h.)
|
||||
const int dur = ChannelDurationMs(spellID);
|
||||
if (dur > 0) {
|
||||
g_channel = TrackedSpell{spellID, now, now + dur, 0};
|
||||
g_cast.spellID = 0; // a channel supersedes any cast
|
||||
}
|
||||
if (dur > 0)
|
||||
StampChannel(spellID, now, now + dur);
|
||||
return;
|
||||
}
|
||||
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
|
||||
// 3 s cast, then a 60 s channel). This packet is the cast phase —
|
||||
// the channel begins only when the cast completes, signaled by
|
||||
// MSG_CHANNEL_START (ChannelStart_h). Fall through to regular cast
|
||||
// handling so the bar shows the 3 s cast, not a premature 60 s
|
||||
// channel (pfUI #9).
|
||||
if (castTime == 0)
|
||||
return; // instant — no cast bar
|
||||
// Backstop for chained same-spell recasts: the engine short-circuits
|
||||
@@ -367,9 +419,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
|
||||
g_channel.spellID = 0; // a cast supersedes any channel
|
||||
return;
|
||||
}
|
||||
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
|
||||
: now + static_cast<int>(castTime);
|
||||
StoreRemoteCast(caster, spellID, now, endMs, channel);
|
||||
// A channeled spell with a cast time (Mind Control) is in its CAST phase
|
||||
// here — store it as a regular cast so observers see the 3 s bar. The
|
||||
// remote channel phase has no packet (MSG_CHANNEL_START is caster-only);
|
||||
// it surfaces through the unit's broadcast +0x228 field as usual.
|
||||
const bool instantChannel = channel && castTime == 0;
|
||||
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
|
||||
: now + static_cast<int>(castTime);
|
||||
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
|
||||
}
|
||||
|
||||
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
|
||||
@@ -427,6 +484,151 @@ const Game::HookAutoRegister _spellDelayedHook{
|
||||
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
|
||||
reinterpret_cast<void **>(&g_origSpellDelayed)};
|
||||
|
||||
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
|
||||
// channel has begun, with the server-authoritative (modifier-applied)
|
||||
// duration. Sent only to the caster, so it's always the local player. This
|
||||
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
|
||||
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
|
||||
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
|
||||
// this packet marks the channel actually starting. For instant channels it
|
||||
// re-stamps what the SpellStart path just wrote (same moment, server
|
||||
// duration) — harmless. We stamp before the original runs so the data is
|
||||
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
|
||||
// mirrored from nampower's SpellChannelStartHandlerHook:
|
||||
// spellId(u32), durationMs(u32).
|
||||
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
ChannelStart_t g_origChannelStart = nullptr;
|
||||
|
||||
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
const uint32_t duration = Net::Read<uint32_t>(packet);
|
||||
packet->m_read = saved;
|
||||
if (spellID != 0 && duration > 0) {
|
||||
const int now = NowMs();
|
||||
StampChannel(spellID, now, now + static_cast<int>(duration));
|
||||
}
|
||||
}
|
||||
return g_origChannelStart(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _channelStartHook{
|
||||
Offsets::FUN_SPELL_CHANNEL_START,
|
||||
reinterpret_cast<void *>(&ChannelStart_h),
|
||||
reinterpret_cast<void **>(&g_origChannelStart)};
|
||||
|
||||
// Shared abort handling for the two sibling failure packets. Servers
|
||||
// derived from (v)mangos broadcast BOTH `SMSG_SPELL_FAILURE` and
|
||||
// `SMSG_SPELL_FAILED_OTHER` from Spell::SendInterrupted, but which one a
|
||||
// given core emits per abort path varies — so both handlers are co-hooked
|
||||
// and both feed this. Idempotent, so the both-arrive case is harmless.
|
||||
void HandleCastAborted(uint64_t guid, int spellID) {
|
||||
if (guid == 0 || spellID == 0)
|
||||
return;
|
||||
// Remote units: invalidate the cached cast/channel so
|
||||
// UnitCastingInfo/UnitChannelInfo stop reporting it this tick.
|
||||
for (auto &e : g_remoteCasts) {
|
||||
if (e.used && e.casterGuid == guid && e.spellID == spellID) {
|
||||
e.used = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Local player: a server-stamped chained recast (g_castFromServer)
|
||||
// never set VAR_CURRENT_CAST_SPELL, so the WorldTick VAR==0 poll can't
|
||||
// clear it on interrupt — this packet is the only signal. Whether the
|
||||
// caster receives their own broadcast is server-dependent; if it never
|
||||
// fires, the endMs self-expiry backstop still applies.
|
||||
if (guid == Unit::Identity::PlayerGuid() && g_cast.spellID == spellID)
|
||||
g_cast.spellID = 0;
|
||||
}
|
||||
|
||||
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
|
||||
// to observers whenever a started cast aborts (interrupt, death, movement,
|
||||
// fizzle). It's what lets us drop a remote cast the moment it dies instead
|
||||
// of letting the ghost bar run to its computed end (the documented
|
||||
// remote-unit limitation above). Packet body is plain (not packed):
|
||||
// casterGuid(u64), spellId(u32).
|
||||
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
SpellFailedOther_t g_origSpellFailedOther = nullptr;
|
||||
|
||||
int __stdcall SpellFailedOther_h(uint32_t *opCode, Net::CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
packet->m_read = saved;
|
||||
HandleCastAborted(guid, spellID);
|
||||
}
|
||||
return g_origSpellFailedOther(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _spellFailedOtherHook{
|
||||
Offsets::FUN_SPELL_FAILED_OTHER,
|
||||
reinterpret_cast<void *>(&SpellFailedOther_h),
|
||||
reinterpret_cast<void **>(&g_origSpellFailedOther)};
|
||||
|
||||
// Co-hook on the SMSG_SPELL_FAILURE handler — FAILED_OTHER's sibling, with
|
||||
// a trailing result(u8) after the same casterGuid(u64), spellId(u32) head
|
||||
// (we don't read the result). This is the packet that actually stops the
|
||||
// caster's cast VISUAL in the engine's own handler, so it demonstrably
|
||||
// arrives for interrupts; hooking both siblings makes the eviction
|
||||
// independent of which one the server chose to send.
|
||||
using SpellFailure_t = int(__stdcall *)(uint32_t *opCode,
|
||||
Net::CDataStore *packet);
|
||||
SpellFailure_t g_origSpellFailure = nullptr;
|
||||
|
||||
int __stdcall SpellFailure_h(uint32_t *opCode, Net::CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint64_t guid = Net::Read<uint64_t>(packet);
|
||||
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
|
||||
packet->m_read = saved;
|
||||
HandleCastAborted(guid, spellID);
|
||||
}
|
||||
return g_origSpellFailure(opCode, packet);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _spellFailureHook{
|
||||
Offsets::FUN_SPELL_FAILURE, reinterpret_cast<void *>(&SpellFailure_h),
|
||||
reinterpret_cast<void **>(&g_origSpellFailure)};
|
||||
|
||||
// Co-hook on CGUnit_C::ClearCastingSpell — the engine choke point every
|
||||
// "unit stopped casting" path funnels through (failure packets, SPELL_GO,
|
||||
// movement, death, animation events, local cancel — see Offsets.h). The
|
||||
// original is gated on `spellID == [unit+0xC8C]` (the unit's live
|
||||
// current-cast field), so a real stop only happens when the gate passes;
|
||||
// we mirror the gate and evict then. This is the PRIMARY interrupt signal
|
||||
// on Turtle: its core doesn't broadcast either failure packet — interrupt
|
||||
// propagation to observers happens via SuperWoW, whose code calls this
|
||||
// method directly (verified by return-address tracing: interrupted remote
|
||||
// casts clear with a caller inside a sibling DLL, natural completions
|
||||
// clear from the SPELL_GO handler). Verified in-game: Kick / Earth Shock /
|
||||
// Counterspell each evict the victim's cast, while non-interrupting damage
|
||||
// (pushback flinch) does NOT route through here — no false eviction.
|
||||
using ClearCastingSpell_t = void(__fastcall *)(void *unit, void *edx,
|
||||
int spellID, char notify,
|
||||
char cleanup);
|
||||
ClearCastingSpell_t g_origClearCastingSpell = nullptr;
|
||||
|
||||
void __fastcall ClearCastingSpell_h(void *unit, void *edx, int spellID,
|
||||
char notify, char cleanup) {
|
||||
if (unit != nullptr && spellID != 0) {
|
||||
const int current = *reinterpret_cast<const int *>(
|
||||
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_CAST_SPELL);
|
||||
if (current != 0 && current == spellID)
|
||||
HandleCastAborted(Unit::Identity::GuidForObject(unit), spellID);
|
||||
}
|
||||
g_origClearCastingSpell(unit, edx, spellID, notify, cleanup);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _clearCastingHook{
|
||||
Offsets::FUN_UNIT_CLEAR_CASTING_SPELL,
|
||||
reinterpret_cast<void *>(&ClearCastingSpell_h),
|
||||
reinterpret_cast<void **>(&g_origClearCastingSpell)};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Per-frame upkeep for the player. The regular cast is stamped by the
|
||||
@@ -445,10 +647,32 @@ void OnWorldTick() {
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
|
||||
g_cast.spellID = 0;
|
||||
|
||||
// Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
|
||||
// on their computed endMs (self-expiry in PushChannelInfo). We don't poll
|
||||
// the broadcast +0x228 field for the player — it lags the channel start by
|
||||
// ~1 tick, so the bar would show ~1s late.
|
||||
// Detect a player CHANNEL that ended before its computed endMs — the
|
||||
// summon-ritual case (UNIT_FIELD_CHANNEL_SPELL = 698, cleared when the
|
||||
// summon fills), and equally an interrupted / cancelled / LoS-broken
|
||||
// channel. Without this, g_channel only clears at endMs (self-expiry in
|
||||
// PushChannelInfo) or when a later TIMED cast supersedes it — so an early
|
||||
// end followed by an INSTANT cast (which supersedes nothing) leaves a
|
||||
// ghost bar counting down to a duration that no longer reflects reality.
|
||||
//
|
||||
// We deliberately don't use +0x228 to START the bar — it's a broadcast
|
||||
// UpdateField that lags the channel start by ~1 tick (HandleSpellStart
|
||||
// stamps from the packet instead). For the STOP that lag is harmless, but a
|
||||
// naive "clear when field == 0" would wipe a channel we just stamped during
|
||||
// the lag window. Guard with a latch: only honor the 0 once the field has
|
||||
// been seen to reflect our channel spell at least once. A channel too short
|
||||
// to ever appear in the field just self-expires at endMs.
|
||||
if (g_channel.spellID != 0) {
|
||||
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
|
||||
if (desc != nullptr) {
|
||||
const int chan = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
|
||||
if (chan == g_channel.spellID)
|
||||
g_channelConfirmed = true;
|
||||
else if (chan == 0 && g_channelConfirmed)
|
||||
g_channel.spellID = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
@@ -458,8 +682,8 @@ static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cas
|
||||
|
||||
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
|
||||
// from the SMSG_SPELL_START cache (regular casts still within their cast
|
||||
// window). An interrupted remote cast lingers until its computed end time
|
||||
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
|
||||
// window; aborted casts are evicted by the ClearCastingSpell / failure-
|
||||
// packet co-hooks).
|
||||
static int __fastcall Script_UnitCastingInfo(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
|
||||
|
||||
@@ -117,6 +117,139 @@ bool SpellHiddenFromAutoLearn(int spellID) {
|
||||
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
|
||||
}
|
||||
|
||||
// ---- Target-level gate for ranked beneficial auras ----------------------
|
||||
//
|
||||
// A ranked beneficial aura can't be applied to a target too low for the
|
||||
// rank — e.g. Divine Spirit Rank 1 (14752) requires the target to be level
|
||||
// >= 20. Server-enforced via `SpellMgr::SelectAuraRankForLevel`, whose rule
|
||||
// is: a ranked positive aura is castable when `targetLevel + 10 >=
|
||||
// spellLevel`, i.e. the minimum target level is `spellLevel - 10`. Below
|
||||
// it the lowest rank fails (SPELL_FAILED_LOWLEVEL); higher ranks auto-
|
||||
// downrank. The gate only applies to *ranked* *positive* *auras* — see
|
||||
// RequiredTargetLevel. Fully derivable from the client's Spell.dbc.
|
||||
//
|
||||
// (The explicit MinTargetLevel / MaxTargetLevel fields the server also
|
||||
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
|
||||
// the spellLevel rule is the only client-readable target-level mechanism.)
|
||||
|
||||
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
|
||||
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
|
||||
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
|
||||
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
|
||||
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
|
||||
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
|
||||
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
|
||||
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
|
||||
|
||||
int LocaleIndex() {
|
||||
return *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
}
|
||||
|
||||
// The friendly-target IDs the server's SelectAuraRankForLevel treats as
|
||||
// "positive" for rank selection: IsExplicitPositiveTarget (21/35/45/57/61)
|
||||
// plus IsAreaEffectPossitiveTarget (20/30/31/33/34/37/56/61). Narrower than
|
||||
// a general "is helpful" set — this is the exact gate the rank rule uses
|
||||
// (self-cast / pet targets don't count).
|
||||
bool IsPositiveRankTarget(uint32_t t) {
|
||||
switch (t) {
|
||||
case 21: // TARGET_UNIT_FRIEND
|
||||
case 35: // TARGET_UNIT_PARTY
|
||||
case 45: // TARGET_UNIT_FRIEND_CHAIN_HEAL
|
||||
case 57: // TARGET_UNIT_RAID
|
||||
case 61: // TARGET_UNIT_RAID_AND_CLASS
|
||||
case 20: // TARGET_ENUM_UNITS_PARTY_WITHIN_CASTER_RANGE
|
||||
case 30: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_SRC_LOC
|
||||
case 31: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_DEST_LOC
|
||||
case 33: // TARGET_ENUM_UNITS_PARTY_AOE_AT_SRC_LOC
|
||||
case 34: // TARGET_ENUM_UNITS_PARTY_AOE_AT_DEST_LOC
|
||||
case 37: // TARGET_UNIT_FRIEND_AND_PARTY
|
||||
case 56: // TARGET_ENUM_UNITS_RAID_WITHIN_CASTER_RANGE
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// True if the record carries a localized "Rank N" string — the client's
|
||||
// marker for a spell in a ranked line (single-rank spells leave it empty).
|
||||
// Proxy for the server's `GetSpellRank(id) != 0`, verified against
|
||||
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
|
||||
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
|
||||
bool HasRankString(const uint8_t *record) {
|
||||
const char *rank = *reinterpret_cast<const char *const *>(
|
||||
record + OFF_SPELL_RANK + LocaleIndex() * 4);
|
||||
return rank != nullptr && *rank != '\0';
|
||||
}
|
||||
|
||||
// Minimum target level required to apply this exact spell (rank), or 0 if
|
||||
// the spell isn't subject to the ranked-positive-aura target-level rule.
|
||||
int RequiredTargetLevel(const uint8_t *record) {
|
||||
const int spellLevel =
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
|
||||
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
|
||||
return 0;
|
||||
const uint32_t attr =
|
||||
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
|
||||
if (attr & SPELL_ATTR_PASSIVE)
|
||||
return 0;
|
||||
if (!HasRankString(record)) // single-rank spell → not gated
|
||||
return 0;
|
||||
|
||||
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
|
||||
auto *targetA =
|
||||
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
|
||||
bool positiveAura = false;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
|
||||
IsPositiveRankTarget(targetA[i])) ||
|
||||
effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) {
|
||||
positiveAura = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!positiveAura)
|
||||
return 0;
|
||||
return spellLevel - 10;
|
||||
}
|
||||
|
||||
int ArgSpellID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1))
|
||||
return 0;
|
||||
return static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
}
|
||||
|
||||
// `C_Spell.GetSpellLevelInfo(spellID)` → spellLevel, baseLevel, maxLevel.
|
||||
// Raw Spell.dbc level fields: spellLevel (this rank's effective level),
|
||||
// baseLevel, maxLevel (level at which scaling caps). nil for an unknown
|
||||
// spellID.
|
||||
int __fastcall Script_GetSpellLevelInfo(void *L) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(
|
||||
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
|
||||
return 3;
|
||||
}
|
||||
|
||||
// `GetSpellRequiredTargetLevel(spellID)` → the minimum level a target must
|
||||
// be for this spell (rank) to apply (0 = no restriction). nil for an
|
||||
// unknown spellID. Ranked beneficial auras only; see RequiredTargetLevel.
|
||||
int __fastcall Script_GetSpellRequiredTargetLevel(void *L) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
|
||||
if (record == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(RequiredTargetLevel(record)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_SpellBook.GetSpellLevelLearned(spellID)` — returns the level
|
||||
// at which a spell becomes available (the `BaseLevel` field in
|
||||
// Spell.dbc). Direct read off the record — no class/race filtering,
|
||||
@@ -315,6 +448,12 @@ void RegisterLuaFunctions() {
|
||||
&Script_GetSpellLevelLearned);
|
||||
Game::Lua::RegisterTableFunction("C_SpellBook", "GetCurrentLevelSpells",
|
||||
&Script_GetCurrentLevelSpells);
|
||||
Game::Lua::RegisterGlobalFunction("GetSpellRequiredTargetLevel",
|
||||
&Script_GetSpellRequiredTargetLevel);
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellLevelInfo",
|
||||
&Script_GetSpellLevelInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRequiredTargetLevel",
|
||||
&Script_GetSpellRequiredTargetLevel);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -29,6 +29,11 @@ namespace Table::Wipe {
|
||||
// guarantee holds in 1.12's Lua 5.0 (`luaH_next` at `0x006FA2A0`),
|
||||
// so the pattern ports verbatim.
|
||||
//
|
||||
// One divergence from the 3.3.5 source: 1.12's Lua 5.0 keeps a table's
|
||||
// `table.insert`/`getn` length out of band from its keys (see the
|
||||
// `luaL_setn` note below), so after the key-clear loop we must also
|
||||
// reset that counter — the 5.1 twipe had no such legacy to clear.
|
||||
//
|
||||
// Stack discipline matches 3.3.5 to the call:
|
||||
// 1. `lua_pushnil(L)` — initial "no key" sentinel for next
|
||||
// 2. `lua_next(L, 1)` — pops key, pushes (newKey, value)
|
||||
@@ -51,6 +56,17 @@ static int __fastcall Script_table_wipe(void *L) {
|
||||
Game::Lua::RawSet(L, 1); // t[key] = nil
|
||||
}
|
||||
|
||||
// Reset the Lua 5.0 length counter. In 5.0 a table's `table.insert`/
|
||||
// `getn` length is stored OUT of band from its keys (in a `t.n`
|
||||
// field or a weak `sizes[t]` registry entry), so the rawset(nil)
|
||||
// loop above — which only clears keys — leaves it at its old value.
|
||||
// Without this, `table.insert` after a wipe appends at
|
||||
// `getn(t)+1`, past the now-nil slots, and `t[1]` stays nil. 5.1's
|
||||
// wipe doesn't need this (no getn legacy), which is why the ported
|
||||
// logic looked complete. `luaL_setn(L, 1, 0)` puts it back to 0 so
|
||||
// inserts resume at `t[1]`.
|
||||
Game::Lua::SetN(L, 1, 0);
|
||||
|
||||
// The table is still at stack[1] and is the result we return.
|
||||
Game::Lua::SetTop(L, 1);
|
||||
return 1;
|
||||
|
||||
+139
-21
@@ -126,8 +126,9 @@ int SlotsForInvType(int invType, int *out) {
|
||||
// English fallback labels, used when the client doesn't define the
|
||||
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
|
||||
// mirrors pfUI's eqcompare so the deltas read consistently on this
|
||||
// client. Percent stats keep a "%" suffix since the values are native
|
||||
// vanilla percentages.
|
||||
// client. Percent stats carry NO "%" here — `IsPercentStat` appends the
|
||||
// "%" to the value ("-5% Block"), so the unit is correct regardless of
|
||||
// which label (Blizzard `_SHORT` or this fallback) is used.
|
||||
struct KeyLabel {
|
||||
const char *key;
|
||||
const char *label;
|
||||
@@ -149,25 +150,46 @@ constexpr KeyLabel kFallbackLabels[] = {
|
||||
{"RESISTANCE6_NAME", "Arcane Resistance"},
|
||||
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
|
||||
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
|
||||
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
|
||||
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
|
||||
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
|
||||
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
|
||||
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
|
||||
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
|
||||
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit"},
|
||||
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit"},
|
||||
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit"},
|
||||
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit"},
|
||||
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit"},
|
||||
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit"},
|
||||
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
|
||||
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
|
||||
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
|
||||
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
|
||||
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
|
||||
{"ITEM_MOD_PARRY_RATING", "Parry %"},
|
||||
{"ITEM_MOD_BLOCK_RATING", "Block %"},
|
||||
{"ITEM_MOD_DODGE_RATING", "Dodge"},
|
||||
{"ITEM_MOD_PARRY_RATING", "Parry"},
|
||||
{"ITEM_MOD_BLOCK_RATING", "Block"},
|
||||
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
|
||||
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
|
||||
{"ITEM_DELTA_DESCRIPTION",
|
||||
"If you replace this item, the following stat changes will occur:"},
|
||||
};
|
||||
|
||||
// Percent-valued stat keys: the block/dodge/parry *chances* and the
|
||||
// crit/hit percentages. Vanilla stores these as native percentages (a
|
||||
// value of 5 means 5%), so the delta must read "-5% Block", not "-5
|
||||
// Block". The "%" is attached to the NUMBER rather than the label, so it
|
||||
// stays correct in every locale and requires no `_SHORT` change.
|
||||
// NB: ITEM_MOD_DEFENSE_SKILL_RATING (skill points) and
|
||||
// ITEM_MOD_BLOCK_VALUE (flat block amount) are NOT percentages.
|
||||
bool IsPercentStat(const char *key) {
|
||||
static const char *const kPercent[] = {
|
||||
"ITEM_MOD_CRIT_MELEE_RATING", "ITEM_MOD_CRIT_RANGED_RATING",
|
||||
"ITEM_MOD_CRIT_SPELL_RATING", "ITEM_MOD_HIT_MELEE_RATING",
|
||||
"ITEM_MOD_HIT_RANGED_RATING", "ITEM_MOD_HIT_SPELL_RATING",
|
||||
"ITEM_MOD_DODGE_RATING", "ITEM_MOD_PARRY_RATING",
|
||||
"ITEM_MOD_BLOCK_RATING",
|
||||
};
|
||||
for (const char *k : kPercent)
|
||||
if (std::strcmp(k, key) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *FallbackLabel(const char *key) {
|
||||
for (const auto &e : kFallbackLabels)
|
||||
if (std::strcmp(e.key, key) == 0)
|
||||
@@ -175,23 +197,112 @@ const char *FallbackLabel(const char *key) {
|
||||
return key; // last resort — raw key beats crashing
|
||||
}
|
||||
|
||||
// Resolve a display label for `key` into `out`. Prefers the client's
|
||||
// `_G[key]` when it's a plain, non-empty string (respects localization /
|
||||
// server-defined labels), read via a direct globals-table access — no
|
||||
// pcall. Ignores format strings (a "%"-bearing global like "+%d Strength"
|
||||
// would render garbage), falling back to the built-in English label.
|
||||
void LabelFor(void *L, const char *key, char *out, int outSize) {
|
||||
// True if `s` contains a printf conversion (`%d`, `%s`, `%1$s`, …) as
|
||||
// opposed to a literal `%` used as a unit. A `%` at end-of-string, before
|
||||
// a space, or escaped as `%%` is treated as literal. Lets locale labels
|
||||
// like "Dodge %" through while still rejecting a format-string global
|
||||
// such as "+%d Strength" (which would render garbage via snprintf "%s").
|
||||
bool HasFormatSpecifier(const char *s) {
|
||||
for (const char *p = std::strchr(s, '%'); p != nullptr;
|
||||
p = std::strchr(p + 1, '%')) {
|
||||
const char n = p[1];
|
||||
if (n == '%') { // escaped "%%" — skip both, keep scanning
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
if (n != '\0' && n != ' ')
|
||||
return true; // "%d", "%s", "%1$..." → real conversion
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool EndsWithShort(const char *key) {
|
||||
const size_t n = std::strlen(key);
|
||||
return n >= 6 && std::strcmp(key + n - 6, "_SHORT") == 0;
|
||||
}
|
||||
|
||||
// Raw `_G[key]` string fetch — no format-specifier filtering. Direct
|
||||
// globals-table access, no pcall. Returns whether a non-empty string was
|
||||
// copied into `out`.
|
||||
bool TryGlobalRaw(void *L, const char *key, char *out, int outSize) {
|
||||
const int saved = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
|
||||
? Game::Lua::ToString(L, -1)
|
||||
: nullptr;
|
||||
if (g != nullptr && *g != '\0' && std::strchr(g, '%') == nullptr)
|
||||
const bool ok = (g != nullptr && *g != '\0');
|
||||
if (ok)
|
||||
std::snprintf(out, outSize, "%s", g);
|
||||
else
|
||||
std::snprintf(out, outSize, "%s", FallbackLabel(key));
|
||||
Game::Lua::SetTop(L, saved);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
|
||||
// string. Returns whether it took.
|
||||
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
|
||||
char tmp[192];
|
||||
if (!TryGlobalRaw(L, key, tmp, sizeof(tmp)) || HasFormatSpecifier(tmp))
|
||||
return false;
|
||||
std::snprintf(out, outSize, "%s", tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Formats a signed value token (e.g. "-5") as a localized percentage via
|
||||
// the PERCENTAGE_STRING global (typically "%s%%" → "-5%"). The locale
|
||||
// format is NEVER passed to snprintf — we substitute `num` into its first
|
||||
// conversion slot (`%s`/`%d`) by hand and collapse `%%` → `%`, so a
|
||||
// hostile or unexpected format can't misread the argument list. Falls back
|
||||
// to a plain "%" suffix when the global is absent.
|
||||
void FormatPercent(void *L, const char *num, char *out, int outSize) {
|
||||
char fmt[64];
|
||||
if (!TryGlobalRaw(L, "PERCENTAGE_STRING", fmt, sizeof(fmt))) {
|
||||
std::snprintf(out, outSize, "%s%%", num);
|
||||
return;
|
||||
}
|
||||
int o = 0;
|
||||
bool inserted = false;
|
||||
for (const char *p = fmt; *p != '\0' && o < outSize - 1; ++p) {
|
||||
if (p[0] == '%' && p[1] == '%') { // literal percent
|
||||
out[o++] = '%';
|
||||
++p;
|
||||
} else if (p[0] == '%' && p[1] != '\0' && !inserted) { // value slot
|
||||
for (const char *q = num; *q != '\0' && o < outSize - 1; ++q)
|
||||
out[o++] = *q;
|
||||
++p; // consume the conversion letter (s / d)
|
||||
inserted = true;
|
||||
} else {
|
||||
out[o++] = *p;
|
||||
}
|
||||
}
|
||||
out[o] = '\0';
|
||||
}
|
||||
|
||||
// Resolve a display label for a stat `key` into `out`, respecting
|
||||
// localization. The rating/regen tokens GetItemStats keys on
|
||||
// (`ITEM_MOD_CRIT_MELEE_RATING`, …) carry a "%s" *description* as their
|
||||
// own global; their `_SHORT` sibling is the compact label a delta wants.
|
||||
// So for a key that isn't already `_SHORT`, prefer `_G[key.."_SHORT"]`:
|
||||
// 1. `_G[key.."_SHORT"]` — the compact label (skipped if `key` already
|
||||
// ends in `_SHORT`, e.g. the primary stats, or has no `_SHORT`
|
||||
// variant like `RESISTANCE*_NAME` / `CURRENTLY_EQUIPPED`).
|
||||
// 2. `_G[key]` — the key itself (primary stats resolve here; rating
|
||||
// tokens only land here if no `_SHORT` global exists).
|
||||
// 3. Built-in English `FallbackLabel(key)`.
|
||||
// Preferring `_SHORT` first (rather than only when `_G[key]` fails) means
|
||||
// a server/addon defining the base rating token as a plain string can't
|
||||
// shadow the compact `_SHORT` label. StatAccum keys stay the canonical
|
||||
// GetItemStats tokens; this `_SHORT` mapping is display-only.
|
||||
void LabelFor(void *L, const char *key, char *out, int outSize) {
|
||||
if (!EndsWithShort(key)) {
|
||||
char shortKey[160];
|
||||
std::snprintf(shortKey, sizeof(shortKey), "%s_SHORT", key);
|
||||
if (TryGlobalLabel(L, shortKey, out, outSize))
|
||||
return;
|
||||
}
|
||||
if (TryGlobalLabel(L, key, out, outSize))
|
||||
return;
|
||||
std::snprintf(out, outSize, "%s", FallbackLabel(key));
|
||||
}
|
||||
|
||||
// Delta colors — the canonical GlobalColor.dbc tags modern WoW uses for
|
||||
@@ -237,7 +348,14 @@ void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
|
||||
if (v == 0)
|
||||
continue;
|
||||
LabelFor(L, acc[i].key, label, sizeof(label));
|
||||
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
|
||||
char num[48];
|
||||
std::snprintf(num, sizeof(num), "%s%ld", v > 0 ? "+" : "", v);
|
||||
if (IsPercentStat(acc[i].key)) {
|
||||
char pct[64];
|
||||
FormatPercent(L, num, pct, sizeof(pct));
|
||||
std::snprintf(num, sizeof(num), "%s", pct);
|
||||
}
|
||||
std::snprintf(line, sizeof(line), "%s %s", num, label);
|
||||
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Trade-skill *list* links (`|Htrade:...|h`) — the shareable "here's my
|
||||
// profession and which recipes I know" link. Vanilla 1.12 has NO such link
|
||||
// type (the engine's SetHyperlink parser only knows `item:` and `enchant:`);
|
||||
// 2.x introduced it. We backport the whole thing: the link is built entirely
|
||||
// in C here; only the click-to-display viewer lives in the embedded addon
|
||||
// (Util/TradeSkillLink.lua), since that's UI.
|
||||
//
|
||||
// Wire format (a ClassicAPI-private shape — nothing native emits/reads it, so
|
||||
// we're free to define it; modeled on the 2.x/3.3.5 `trade:` link, with the
|
||||
// linker's name in the slot 2.x used for the GUID):
|
||||
//
|
||||
// |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
|
||||
//
|
||||
// - skillLineID — SkillLine.dbc row (e.g. 164 = Blacksmithing)
|
||||
// - curRank/maxRank — the linker's skill level (cosmetic; viewer title)
|
||||
// - linkerName — the linking character's name
|
||||
// - base64 bits — a bitfield over the skill line's *canonical recipe list*:
|
||||
// one bit per possible recipe, set = the linker knows it.
|
||||
//
|
||||
// The canonical recipe list is every SkillLineAbility.dbc row for the skill
|
||||
// line whose spell actually produces a craft (see IsCraftRecipe — an item,
|
||||
// enchant, or Turtle LEARN_SPELL recipe; this excludes profession abilities
|
||||
// like Disenchant), in ascending record-ID order. That ordering is
|
||||
// deterministic and identical on every client with the same DBC, so builder
|
||||
// and reader agree on the bit-index space without any handshake. "Which
|
||||
// recipes does the linker know" comes from the player-spell bitmap
|
||||
// (VAR_PLAYER_SPELL_BITMAP — the same store IsPlayerSpell reads, which covers
|
||||
// profession recipes), NOT from the trade-skill window's server list.
|
||||
//
|
||||
// The TradeSkill and Craft windows are separate frames with separate storage
|
||||
// and can both be open at once, so each gets its own builder (matching
|
||||
// WotLK) — each reads only its own window's open-state global (both zeroed by
|
||||
// the engine on close), so "both open" is unambiguous and neither guesses:
|
||||
//
|
||||
// - `C_TradeSkillUI.GetTradeSkillListLink()` — the TradeSkill window
|
||||
// - `C_TradeSkillUI.GetCraftListLink()` — the Craft window (Enchanting)
|
||||
// - `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)` — decoder
|
||||
// for the viewer.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace TradeSkill::Link {
|
||||
|
||||
namespace {
|
||||
|
||||
// 6 recipes per character, LSB-first (mirrors 3.x's encoder).
|
||||
constexpr char kBase64[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
constexpr int kRecipesPerChar = 6;
|
||||
constexpr int kMaxRecipes = 1024; // largest vanilla skill line is ~340
|
||||
constexpr int kBitsBufSize = kMaxRecipes / kRecipesPerChar + 2;
|
||||
|
||||
int Base64Value(char c) {
|
||||
for (int i = 0; i < 64; ++i)
|
||||
if (kBase64[i] == c)
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// A spell is a craftable recipe (vs. a profession ability like Disenchant, or
|
||||
// a rank-up / skill-grant spell) iff one of its effects produces a craft: an
|
||||
// item (CREATE_ITEM), an enchant (ENCHANT_ITEM / _TEMPORARY), or Turtle's
|
||||
// custom recipes (LEARN_SPELL). Filtering on the skill-up threshold alone
|
||||
// wrongly kept Disenchant (which carries thresholds but isn't a recipe).
|
||||
bool IsCraftRecipe(int spellID) {
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
const int32_t *eff = reinterpret_cast<const int32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_EFFECT);
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
|
||||
const int e = eff[i];
|
||||
if (e == Offsets::SPELL_EFFECT_CREATE_ITEM ||
|
||||
e == Offsets::SPELL_EFFECT_LEARN_SPELL ||
|
||||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM ||
|
||||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fills `outSpell` with the recipe spellIDs for `skillLineID`, in canonical
|
||||
// (ascending record-ID) order, and — when non-null — `outGrey`/`outGreen` with
|
||||
// the skill levels the recipe turns grey / green at (its difficulty tiers; the
|
||||
// required-skill field 7 is uselessly 1 for nearly every trainer-taught
|
||||
// recipe). Returns the count (capped at `maxOut`). Same walk builder and reader
|
||||
// use, so the bit-index space is identical.
|
||||
int BuildRecipeList(uint32_t skillLineID, int *outSpell, int *outGrey,
|
||||
int *outGreen, int maxOut) {
|
||||
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
|
||||
if (records == nullptr || count <= 0)
|
||||
return 0;
|
||||
|
||||
int n = 0;
|
||||
for (int i = 1; i <= count && n < maxOut; ++i) {
|
||||
const uint8_t *rec = records[i];
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (static_cast<uint32_t>(*reinterpret_cast<const int *>(
|
||||
rec + Offsets::OFF_SLA_SKILL_ID)) != skillLineID)
|
||||
continue;
|
||||
const int spellID =
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SPELL_ID);
|
||||
if (!IsCraftRecipe(spellID))
|
||||
continue;
|
||||
if (outGrey != nullptr)
|
||||
outGrey[n] = *reinterpret_cast<const int *>(
|
||||
rec + Offsets::OFF_SLA_TRIVIAL_SKILL_HIGH);
|
||||
if (outGreen != nullptr)
|
||||
outGreen[n] = *reinterpret_cast<const int *>(
|
||||
rec + Offsets::OFF_SLA_TRIVIAL_SKILL_LOW);
|
||||
outSpell[n++] = spellID;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Same bit the engine (and our IsPlayerSpell) reads — covers profession
|
||||
// recipes, not just trained abilities.
|
||||
bool IsKnownSpell(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return false;
|
||||
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
if (bitmap == nullptr)
|
||||
return false;
|
||||
return (bitmap[static_cast<uint32_t>(spellID) >> 5] &
|
||||
(1u << (spellID & 31))) != 0;
|
||||
}
|
||||
|
||||
// Skill line (SkillLine.dbc row) of the first SLA row that teaches `spellID`,
|
||||
// or 0 if none. Used to identify the Craft window's profession, which — unlike
|
||||
// the TradeSkill window — has no skill-line global (the engine derives it from
|
||||
// the recipe, see `GetCraftDisplaySkillLine` at `0x004F6CB0`).
|
||||
uint32_t SkillForSpell(int spellID) {
|
||||
if (spellID <= 0)
|
||||
return 0;
|
||||
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
|
||||
if (records == nullptr || count <= 0)
|
||||
return 0;
|
||||
for (int i = 1; i <= count; ++i) {
|
||||
const uint8_t *rec = records[i];
|
||||
if (rec != nullptr &&
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SPELL_ID) ==
|
||||
spellID)
|
||||
return static_cast<uint32_t>(
|
||||
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SKILL_ID));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Skill line of the open Craft window (Enchanting, pet training) — derived
|
||||
// from its first real recipe entry, mirroring the engine. 0 if none open.
|
||||
uint32_t CraftSkillLine() {
|
||||
const int count = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CRAFT_COUNT));
|
||||
if (count <= 0)
|
||||
return 0;
|
||||
const int cap = (count < kMaxRecipes) ? count : kMaxRecipes;
|
||||
for (int slot = 0; slot < cap; ++slot) {
|
||||
const int spellID = Spell::Lookup::RecipeSlotSpellID(
|
||||
Offsets::VAR_CRAFT_ENTRIES, Offsets::VAR_CRAFT_COUNT, slot);
|
||||
if (spellID > 0) {
|
||||
const uint32_t skill = SkillForSpell(spellID);
|
||||
if (skill != 0)
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Encodes the local player's known-recipe bitfield for `skillLineID` into
|
||||
// `out` (must hold kBitsBufSize). Returns the recipe count, or -1 if the skill
|
||||
// line has no recipes.
|
||||
int EncodeKnownBits(uint32_t skillLineID, char *out) {
|
||||
int spells[kMaxRecipes];
|
||||
const int n = BuildRecipeList(skillLineID, spells, nullptr, nullptr,
|
||||
kMaxRecipes);
|
||||
if (n <= 0)
|
||||
return -1;
|
||||
|
||||
int nb = 0;
|
||||
unsigned acc = 0;
|
||||
int used = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (IsKnownSpell(spells[i]))
|
||||
acc |= (1u << used);
|
||||
if (++used == kRecipesPerChar) {
|
||||
out[nb++] = kBase64[acc];
|
||||
acc = 0;
|
||||
used = 0;
|
||||
}
|
||||
}
|
||||
if (used != 0)
|
||||
out[nb++] = kBase64[acc];
|
||||
out[nb] = '\0';
|
||||
return n;
|
||||
}
|
||||
|
||||
// Current / max skill rank for the local player in `skillLine`, read from the
|
||||
// CGPlayer `+0xE68` sub-struct exactly as the engine's own accessors do (see
|
||||
// the FUN_SKILL_LINE_TO_SLOT block in Offsets.h). Leaves 0/0 when the player
|
||||
// isn't resolvable or doesn't have the skill.
|
||||
void ReadPlayerSkillRank(int skillLine, int *outCur, int *outMax) {
|
||||
*outCur = 0;
|
||||
*outMax = 0;
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return;
|
||||
using SlotFn = int(__thiscall *)(const void *, int);
|
||||
const int slot = reinterpret_cast<SlotFn>(
|
||||
Offsets::FUN_SKILL_LINE_TO_SLOT)(player, skillLine);
|
||||
if (slot < 0)
|
||||
return;
|
||||
const uint8_t *base = *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_CGPLAYER_INFO);
|
||||
if (base == nullptr)
|
||||
return;
|
||||
const uint8_t *rec =
|
||||
base + Offsets::OFF_SKILL_INFO_TABLE + slot * Offsets::SKILL_INFO_STRIDE;
|
||||
uint32_t cur = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
|
||||
uint32_t mx = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_MAX);
|
||||
const uint32_t bonus =
|
||||
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
|
||||
if (cur != 0)
|
||||
cur += bonus;
|
||||
if (mx != 0)
|
||||
mx += bonus;
|
||||
*outCur = static_cast<int>(cur);
|
||||
*outMax = static_cast<int>(mx);
|
||||
}
|
||||
|
||||
// The local player's character name via the engine's object name getter.
|
||||
const char *PlayerCharacterName() {
|
||||
const uint8_t *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return "";
|
||||
using GetName_t = const char *(__thiscall *)(const void *, int *);
|
||||
const char *name = reinterpret_cast<GetName_t>(
|
||||
Offsets::FUN_OBJECT_GET_NAME)(player, nullptr);
|
||||
return (name != nullptr) ? name : "";
|
||||
}
|
||||
|
||||
// Builds the full `|Htrade:...|h` link for whichever window `useCraft`
|
||||
// selects. Pushes the link string (returns 1), or nothing (returns 0 → nil)
|
||||
// when that window is closed or has no recipes.
|
||||
int BuildLink(void *L, bool useCraft) {
|
||||
int skillLine;
|
||||
if (useCraft) {
|
||||
// Craft-window open-state flag; zeroed by the engine on close.
|
||||
if (*reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CRAFT_WINDOW_STATE)) == 0)
|
||||
return 0;
|
||||
skillLine = static_cast<int>(CraftSkillLine());
|
||||
} else {
|
||||
// TradeSkill skill-line global; zeroed by the engine on close.
|
||||
skillLine = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CURRENT_TRADESKILL_LINE));
|
||||
}
|
||||
if (skillLine <= 0)
|
||||
return 0;
|
||||
|
||||
char bits[kBitsBufSize];
|
||||
if (EncodeKnownBits(static_cast<uint32_t>(skillLine), bits) < 0)
|
||||
return 0;
|
||||
|
||||
const char *prof = DBC::LocalizedField(
|
||||
Offsets::VAR_SKILL_LINE_RECORDS, Offsets::VAR_SKILL_LINE_COUNT,
|
||||
static_cast<uint32_t>(skillLine), Offsets::OFF_SKILL_LINE_NAME);
|
||||
if (prof == nullptr || *prof == '\0')
|
||||
return 0;
|
||||
|
||||
int cur = 0, mx = 0;
|
||||
ReadPlayerSkillRank(skillLine, &cur, &mx);
|
||||
const char *who = PlayerCharacterName();
|
||||
|
||||
char link[512];
|
||||
std::snprintf(link, sizeof link,
|
||||
"|cffffd000|Htrade:%d:%d:%d:%s:%s|h[%s]|h|r", skillLine, cur,
|
||||
mx, who, bits, prof);
|
||||
Game::Lua::PushString(L, link);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_TradeSkillUI.GetTradeSkillListLink()` -> link for the open TradeSkill
|
||||
// window, or nil.
|
||||
int __fastcall Script_GetTradeSkillListLink(void *L) {
|
||||
return BuildLink(L, /*useCraft=*/false);
|
||||
}
|
||||
|
||||
// `C_TradeSkillUI.GetCraftListLink()` -> link for the open Craft window, or nil.
|
||||
int __fastcall Script_GetCraftListLink(void *L) {
|
||||
return BuildLink(L, /*useCraft=*/true);
|
||||
}
|
||||
|
||||
// `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)`
|
||||
// -> { { spellID, isKnown, trivialLevel, greenLevel, createdItem,
|
||||
// numMade, reagents = { { itemID, count }, ... } }, ... }
|
||||
// `trivialLevel` / `greenLevel` are the skill levels the recipe turns
|
||||
// grey / green at — its difficulty tiers, for sorting and colouring.
|
||||
// `createdItem` is the crafted item's ID (0 for enchants) — the viewer
|
||||
// derives its subclass filter from it. `numMade` is how many the recipe
|
||||
// yields (CREATE_ITEM base points + dice; 0 for enchants). `reagents` lists
|
||||
// each consumed item + required count in DBC slot order (stopping at the
|
||||
// first empty slot, the engine's own convention), for the detail pane.
|
||||
//
|
||||
// Rebuilds the canonical recipe list for `skillLineID` and decodes `bits`
|
||||
// against it. Recipes past the end of `bits` (shorter/garbled string) decode
|
||||
// as not-known rather than erroring. The Lua caller (the viewer) turns each
|
||||
// spellID into a name/icon via GetSpellInfo and each reagent itemID into
|
||||
// name/texture/on-hand-count via GetItemInfo / GetItemCount.
|
||||
//
|
||||
// The real 3.3.5 reagent API (GetTradeSkillReagentInfo etc.) can't serve this:
|
||||
// it's indexed by position in the player's *own* open trade window, so it
|
||||
// can't describe a linked recipe from someone else's profession. We read the
|
||||
// reagents straight from Spell.dbc instead.
|
||||
int __fastcall Script_GetTradeSkillListRecipes(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(
|
||||
L, "Usage: C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)");
|
||||
return 0;
|
||||
}
|
||||
const uint32_t skillLine =
|
||||
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
|
||||
const char *bits = Game::Lua::ToString(L, 2);
|
||||
const int blen = (bits != nullptr) ? static_cast<int>(std::strlen(bits)) : 0;
|
||||
|
||||
int spells[kMaxRecipes];
|
||||
int greys[kMaxRecipes];
|
||||
int greens[kMaxRecipes];
|
||||
const int n = BuildRecipeList(skillLine, spells, greys, greens, kMaxRecipes);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const int ci = i / kRecipesPerChar;
|
||||
const int bi = i % kRecipesPerChar;
|
||||
bool known = false;
|
||||
if (ci < blen) {
|
||||
const int v = Base64Value(bits[ci]);
|
||||
if (v >= 0)
|
||||
known = (v & (1 << bi)) != 0;
|
||||
}
|
||||
|
||||
// The recipe's crafted item (0 for enchants) — the viewer groups the
|
||||
// subclass filter by this item's subclass — plus its yield count.
|
||||
const uint8_t *srec = Spell::Lookup::RecordForID(spells[i]);
|
||||
int createdItem = 0;
|
||||
int numMade = 0;
|
||||
if (srec != nullptr) {
|
||||
createdItem = *reinterpret_cast<const int *>(
|
||||
srec + Offsets::OFF_SPELL_RECORD_CREATED_ITEM);
|
||||
// Yield: the CREATE_ITEM effect's magnitude (base points + dice),
|
||||
// mirroring the engine's item-creation math. 0 for enchants.
|
||||
const int32_t *eff = reinterpret_cast<const int32_t *>(
|
||||
srec + Offsets::OFF_SPELL_RECORD_EFFECT);
|
||||
const int32_t *basePts = reinterpret_cast<const int32_t *>(
|
||||
srec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
|
||||
const int32_t *baseDice = reinterpret_cast<const int32_t *>(
|
||||
srec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
|
||||
for (int e = 0; e < Offsets::SPELL_RECORD_EFFECT_COUNT; ++e) {
|
||||
if (eff[e] == Offsets::SPELL_EFFECT_CREATE_ITEM) {
|
||||
numMade = basePts[e] + baseDice[e];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(spells[i]));
|
||||
Game::Lua::SetFieldBool(L, "isKnown", known);
|
||||
Game::Lua::SetFieldNumber(L, "trivialLevel",
|
||||
static_cast<double>(greys[i]));
|
||||
Game::Lua::SetFieldNumber(L, "greenLevel",
|
||||
static_cast<double>(greens[i]));
|
||||
Game::Lua::SetFieldNumber(L, "createdItem",
|
||||
static_cast<double>(createdItem));
|
||||
Game::Lua::SetFieldNumber(L, "numMade", static_cast<double>(numMade));
|
||||
|
||||
// reagents = { {itemID, count}, ... } — nested array under "reagents"
|
||||
// (no SetField helper takes a table value, so push key + build + SetTable).
|
||||
Game::Lua::PushString(L, "reagents");
|
||||
Game::Lua::NewTable(L);
|
||||
if (srec != nullptr) {
|
||||
const int32_t *rids = reinterpret_cast<const int32_t *>(
|
||||
srec + Offsets::OFF_SPELL_REAGENT_ID);
|
||||
const int32_t *rcounts = reinterpret_cast<const int32_t *>(
|
||||
srec + Offsets::OFF_SPELL_REAGENT_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int r = 0; r < Offsets::SPELL_MAX_REAGENTS; ++r) {
|
||||
if (rids[r] == 0)
|
||||
break; // first empty slot ends the list
|
||||
Game::Lua::PushNumber(L, static_cast<double>(++outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "itemID",
|
||||
static_cast<double>(rids[r]));
|
||||
Game::Lua::SetFieldNumber(L, "count",
|
||||
static_cast<double>(rcounts[r]));
|
||||
Game::Lua::SetTable(L, -3); // reagents[outIdx] = {itemID,count}
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTable(L, -3); // entry.reagents = { ... }
|
||||
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetTradeSkillListLink",
|
||||
&Script_GetTradeSkillListLink);
|
||||
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetCraftListLink",
|
||||
&Script_GetCraftListLink);
|
||||
Game::Lua::RegisterTableFunction("C_TradeSkillUI",
|
||||
"GetTradeSkillListRecipes",
|
||||
&Script_GetTradeSkillListRecipes);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace TradeSkill::Link
|
||||
@@ -96,6 +96,37 @@ uint64_t PlayerGuid() {
|
||||
return g_charSelectGuid; // early-login fallback (0 until char-select)
|
||||
}
|
||||
|
||||
const uint8_t *PlayerObject() {
|
||||
// Resolve the player's CGObject straight from the object manager by GUID —
|
||||
// one hash lookup, no token-string parsing, and non-throwing: the resolver
|
||||
// returns null when the GUID isn't loaded (pre-world) instead of raising,
|
||||
// so this is safe from any context (world tick, packet send hook, Lua
|
||||
// callback). The GUID comes from PlayerGuid() — 0 before char-select, which
|
||||
// short-circuits to null here. NOT VAR_LOCAL_PLAYER_PTR: its +0xC0 is only
|
||||
// the GUID, not the canonical CGPlayer whose descriptor is readable.
|
||||
const uint64_t guid = PlayerGuid();
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
using ResolveByGuid_t = void *(__fastcall *)(uint32_t typeMask,
|
||||
const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int line);
|
||||
auto resolve =
|
||||
reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
return static_cast<const uint8_t *>(
|
||||
resolve(Offsets::OBJ_TYPE_PLAYER, "ClassicAPI",
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
|
||||
/*line*/ 0));
|
||||
}
|
||||
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
const uint8_t *player = PlayerObject();
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
}
|
||||
|
||||
uint64_t GuidForObject(const void *unitObject) {
|
||||
if (unitObject == nullptr)
|
||||
return 0;
|
||||
|
||||
@@ -28,6 +28,22 @@ namespace Unit::Identity {
|
||||
// re-dereferencing the globals inline.
|
||||
uint64_t PlayerGuid();
|
||||
|
||||
// The local player's in-world object pointer, resolved straight from the
|
||||
// object manager by GUID (`FUN_OBJECT_RESOLVE_BY_GUID`) — the fastest path we
|
||||
// have (one hash lookup, no token-string work) and naturally non-throwing:
|
||||
// null before the player object exists (pre-world) or before char-select,
|
||||
// never a raise. Safe from any context — Lua callback, world tick, packet
|
||||
// hook. Deliberately NOT `VAR_LOCAL_PLAYER_PTR`: that global carries the
|
||||
// player GUID at +0xC0 but is NOT the CGPlayer whose descriptor and
|
||||
// CGPlayer-local fields are readable (see Offsets.h).
|
||||
const uint8_t *PlayerObject();
|
||||
|
||||
// The local player's `m_objectFields` descriptor (`PlayerObject() +
|
||||
// OFF_UNIT_DESCRIPTOR`), or null. This is the broadcast UpdateField block
|
||||
// (health / power / flags / UNIT_FIELD_CHANNEL_SPELL / …). Prefer this over
|
||||
// re-deriving the player object + descriptor offset in each module.
|
||||
const uint8_t *PlayerDescriptor();
|
||||
|
||||
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
|
||||
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
|
||||
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Pet / minion classification of a unit:
|
||||
// - `UnitIsMinion(unit)` — modern backport: is the unit a
|
||||
// minion of a player (pet, guardian, totem, charmed creature)?
|
||||
// - `UnitIsOtherPlayersPet(unit)` — the real API: is the unit a pet
|
||||
// controlled by a player OTHER than you?
|
||||
//
|
||||
// Neither exists in vanilla 1.12 nor in our 3.3.5 reference client
|
||||
// (`UnitIsMinion` is a 12.0.0/5.5.4 addition; `UnitIsOtherPlayersPet`
|
||||
// is 5.0.4/1.13.2), so both are derived from vanilla primitives:
|
||||
// * `Unit::Flags::IsPlayerControlled` — UNIT_FLAG_PLAYER_CONTROLLED,
|
||||
// set on players and every unit a player controls (pets, guardians,
|
||||
// totems, charmed mobs).
|
||||
// * The unit's GUID type — vanilla packs the entity type in the GUID's
|
||||
// high bits (`Guid::Classify`): players `0x0000`, pets `0xF140`,
|
||||
// creatures `0xF130`. A pet-type GUID is intrinsically a player's pet.
|
||||
//
|
||||
// `UnitIsMinion` = owned by a player (its SummonedBy/CreatedBy/CharmedBy
|
||||
// GUID resolves to a player), OR player-controlled-and-not-a-player as a
|
||||
// fallback. Owner-based is primary because the player-controlled flag
|
||||
// alone misses summoned guardians — units shown as "X's Minion" that carry
|
||||
// an owner GUID but not the flag (a real hunter/warlock "X's Pet" has
|
||||
// both). The union keeps flagged pets working regardless of the owner
|
||||
// fields.
|
||||
//
|
||||
// `UnitIsOtherPlayersPet` is **owner-based**, matching 5.4.8's real
|
||||
// `Script_UnitIsOtherPlayersPet` (`0x0089FD70`): resolve the unit, read
|
||||
// its owner GUID (SummonedBy / CreatedBy / CharmedBy), and return true iff
|
||||
// that owner is a *player* other than you. Owner-based (not a pet-GUID
|
||||
// heuristic) so it covers pets, guardians, totems, and charmed units of
|
||||
// other players alike, and excludes *all* of your own minions (owner ==
|
||||
// you), not just the active pet. The owner-field offsets are byte-verified
|
||||
// / adjacency-derived — see `OFF_UNIT_FIELD_CHARMEDBY`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "unit/Flags.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Unit::Pet {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
// `int __fastcall(unit)` → 1 for a controllable pet, non-1 otherwise.
|
||||
// The engine's pet-vs-minion title discriminator. See
|
||||
// FUN_UNIT_PET_MINION_CLASS.
|
||||
using PetMinionClass_t = int(__fastcall *)(const void *unit);
|
||||
|
||||
const uint8_t *ResolveUnit(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return nullptr;
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(resolve(token));
|
||||
}
|
||||
|
||||
// The unit's owner/controller GUID: CharmedBy, else CreatedBy (the two
|
||||
// fields the engine itself uses for ownership). 0 for players, world
|
||||
// creatures, and anything unowned.
|
||||
uint64_t OwnerGuid(const uint8_t *unit) {
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
unit + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
const int owners[] = {
|
||||
Offsets::OFF_UNIT_FIELD_CHARMEDBY,
|
||||
Offsets::OFF_UNIT_FIELD_CREATEDBY,
|
||||
};
|
||||
for (int off : owners) {
|
||||
const uint64_t g = *reinterpret_cast<const uint64_t *>(desc + off);
|
||||
if (g != 0)
|
||||
return g;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `UnitIsMinion(unit)` — true iff the unit is a player's minion (pet,
|
||||
// guardian, totem, or charmed creature): player-controlled but not itself
|
||||
// a player. NPCs, world creatures, and players return false. A bad or
|
||||
// unresolved unit token returns false (no error), matching the tolerant
|
||||
// modern predicate convention.
|
||||
int __fastcall Script_UnitIsMinion(void *L) {
|
||||
const uint8_t *unit = ResolveUnit(L);
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
// Primary: owned by a player (pet, guardian, totem, charmed).
|
||||
bool minion = Guid::IsPlayer(OwnerGuid(unit));
|
||||
// Fallback: a player-controlled non-player with no owner GUID.
|
||||
if (!minion && Unit::Flags::IsPlayerControlled(unit))
|
||||
minion = !Guid::IsPlayer(Unit::Identity::GuidForObject(unit));
|
||||
Game::Lua::PushBool(L, minion);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `UnitIsPet(unit)` — true iff the unit is a **pet** of a player (as
|
||||
// opposed to a guardian/totem "minion"): owned by a player AND the
|
||||
// engine's pet/minion classifier `FUN_UNIT_PET_MINION_CLASS` returns 1.
|
||||
// That classifier — reading the creature *family*, not the GUID or the
|
||||
// player-controlled flag — is exactly what the engine uses to title a unit
|
||||
// "X's Pet" vs "X's Minion". On 1.12/Turtle the GUID can't be used (pets
|
||||
// and guardians share the `0xF14…` prefix) and the PLAYER_CONTROLLED flag
|
||||
// is unreliable (a "Minion" can carry it), so the family classifier is the
|
||||
// only correct signal. The owner-is-player gate also rules out wild
|
||||
// tameable beasts of the same family (which aren't anyone's pet).
|
||||
// `UnitIsMinion` is the broad umbrella (pet + guardian + totem); this is
|
||||
// the pet subset. A ClassicAPI extension — not a stock WoW global.
|
||||
int __fastcall Script_UnitIsPet(void *L) {
|
||||
const uint8_t *unit = ResolveUnit(L);
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
// Owned by a player (rules out wild same-family beasts), AND the
|
||||
// engine's own pet/minion classifier says PET (1). The classifier —
|
||||
// not the GUID, not the player-controlled flag — is what the engine
|
||||
// uses to title "X's Pet" vs "X's Minion".
|
||||
bool pet = false;
|
||||
if (Guid::IsPlayer(OwnerGuid(unit))) {
|
||||
auto classify = reinterpret_cast<PetMinionClass_t>(
|
||||
Offsets::FUN_UNIT_PET_MINION_CLASS);
|
||||
pet = (classify(unit) == 1);
|
||||
}
|
||||
Game::Lua::PushBool(L, pet);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `UnitIsOtherPlayersPet(unit)` — true iff the unit is a minion (pet,
|
||||
// guardian, totem, charmed) whose owner is a player other than you.
|
||||
// Owner-based, mirroring 5.4.8: read the unit's owner GUID, require it to
|
||||
// be a player, and require it to differ from the local player. World
|
||||
// creatures and players (no owner) return false; your own minions (owner
|
||||
// == you) return false.
|
||||
int __fastcall Script_UnitIsOtherPlayersPet(void *L) {
|
||||
const uint8_t *unit = ResolveUnit(L);
|
||||
if (unit == nullptr) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
const uint64_t owner = OwnerGuid(unit);
|
||||
const bool otherPlayers = Guid::IsPlayer(owner) &&
|
||||
owner != Unit::Identity::PlayerGuid();
|
||||
Game::Lua::PushBool(L, otherPlayers);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitIsMinion", &Script_UnitIsMinion);
|
||||
Game::Lua::RegisterGlobalFunction("UnitIsPet", &Script_UnitIsPet);
|
||||
Game::Lua::RegisterGlobalFunction("UnitIsOtherPlayersPet",
|
||||
&Script_UnitIsOtherPlayersPet);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Unit::Pet
|
||||
+62
-14
@@ -8,11 +8,21 @@
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
|
||||
// range check, modern's `UnitInRange` shape. Vanilla 1.12 doesn't
|
||||
// ship the function but has all the underlying machinery — the
|
||||
// same `GetPosition` virtual (vtable slot 5) `CheckInteractDistance`
|
||||
// uses, applied with a fixed 40-yard threshold.
|
||||
// Player↔unit proximity, modern's shape. Vanilla 1.12 ships neither
|
||||
// function but has all the underlying machinery — the object's
|
||||
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
|
||||
//
|
||||
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
|
||||
// range check (fixed threshold on the squared distance).
|
||||
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
|
||||
// the raw squared world distance player→unit. Squared because
|
||||
// nearly every consumer compares against a threshold (`distSq <=
|
||||
// range*range`) or ranks by nearest, neither of which needs the
|
||||
// sqrt; that's why retail exposes only the squared form (added
|
||||
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
|
||||
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
|
||||
// but self-contained (reads the position in C, no sibling-DLL
|
||||
// dependency, no clash with SuperWoW's own `UnitPosition`).
|
||||
|
||||
#include "Game.h"
|
||||
#include "unit/Position.h"
|
||||
@@ -26,6 +36,24 @@ namespace {
|
||||
constexpr float kRangeYards = 40.0f;
|
||||
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
|
||||
|
||||
// Squared world distance from the player to `token`'s unit. Returns true
|
||||
// and writes *outSq on success; false when either position is unavailable
|
||||
// (unresolved / absent token, or an object with no known position yet —
|
||||
// e.g. a party member outside the client's sync range). This false is
|
||||
// exactly the `checkedPosition = false` case retail flags.
|
||||
bool PlayerToUnitDistSq(const char *token, float *outSq) {
|
||||
float unitPos[3] = {};
|
||||
float playerPos[3] = {};
|
||||
if (!Unit::Position::ReadToken(token, unitPos) ||
|
||||
!Unit::Position::ReadToken("player", playerPos))
|
||||
return false;
|
||||
const float dx = unitPos[0] - playerPos[0];
|
||||
const float dy = unitPos[1] - playerPos[1];
|
||||
const float dz = unitPos[2] - playerPos[2];
|
||||
*outSq = dx * dx + dy * dy + dz * dz;
|
||||
return true;
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitInRange(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
|
||||
@@ -44,27 +72,47 @@ int __fastcall Script_UnitInRange(void *L) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
float unitPos[3] = {};
|
||||
float playerPos[3] = {};
|
||||
if (!Unit::Position::ReadToken(token, unitPos) ||
|
||||
!Unit::Position::ReadToken("player", playerPos)) {
|
||||
float distSq = 0.0f;
|
||||
if (!PlayerToUnitDistSq(token, &distSq)) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const float dx = unitPos[0] - playerPos[0];
|
||||
const float dy = unitPos[1] - playerPos[1];
|
||||
const float dz = unitPos[2] - playerPos[2];
|
||||
const float distSq = dx * dx + dy * dy + dz * dz;
|
||||
|
||||
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `UnitDistanceSquared("unit") → (distanceSquared, checkedPosition)`.
|
||||
// Unlike UnitInRange there's no self-quirk: `UnitDistanceSquared("player")`
|
||||
// is a legitimate `(0, true)`. On a position miss returns `(0, false)` —
|
||||
// matching retail's "always a number" shape; consumers must branch on
|
||||
// `checkedPosition`, since a real `0` (self, or exactly co-located units)
|
||||
// is indistinguishable from the miss placeholder by value alone.
|
||||
int __fastcall Script_UnitDistanceSquared(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: UnitDistanceSquared(\"unit\")");
|
||||
return 0;
|
||||
}
|
||||
const char *token = Game::Lua::ToString(L, 1);
|
||||
|
||||
float distSq = 0.0f;
|
||||
if (!PlayerToUnitDistSq(token, &distSq)) {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(distSq));
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("UnitInRange", &Script_UnitInRange);
|
||||
Game::Lua::RegisterGlobalFunction("UnitDistanceSquared",
|
||||
&Script_UnitDistanceSquared);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
+55
-51
@@ -21,6 +21,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "aura/Data.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -28,24 +29,10 @@ namespace Unit::State {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using CancelAuraSend_t = void(__fastcall *)(int spellID);
|
||||
|
||||
const uint8_t *Player() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
}
|
||||
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
auto *player = Player();
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
player + Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
}
|
||||
|
||||
uint32_t PlayerMovementFlags() {
|
||||
auto *player = Player();
|
||||
auto *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
@@ -59,11 +46,39 @@ uint32_t MountDisplayID(const uint8_t *desc) {
|
||||
desc + Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID);
|
||||
}
|
||||
|
||||
// Scans the player's helpful-aura (buff) slots for the first spell whose
|
||||
// Spell.dbc record lists `auraEffect` (a `SPELL_AURA_*` code) in any of
|
||||
// its three effect slots, and returns that spellID (0 if none). Used by
|
||||
// `Dismount` to find the active mount aura to cancel — the engine's
|
||||
// `CMSG_CANCEL_AURA` needs the spellID, not a slot index.
|
||||
uint32_t FindPlayerBuffWithEffect(int auraEffect) {
|
||||
auto *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
|
||||
if (!Aura::Data::IsSlotPopulated(player, slot))
|
||||
continue;
|
||||
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
|
||||
if (record == nullptr)
|
||||
continue;
|
||||
|
||||
auto *auraTypes = reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
|
||||
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
|
||||
if (auraTypes[eff] == auraEffect)
|
||||
return spellID;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `IsMounted()` — true iff the player is currently mounted. Reads
|
||||
// `UNIT_FIELD_MOUNTDISPLAYID` from the descriptor; non-zero means
|
||||
// the engine is rendering a mount under the player.
|
||||
int __fastcall Script_IsMounted(void *L) {
|
||||
auto *desc = PlayerDescriptor();
|
||||
auto *desc = Unit::Identity::PlayerDescriptor();
|
||||
Game::Lua::PushBool(L, MountDisplayID(desc) != 0);
|
||||
return 1;
|
||||
}
|
||||
@@ -80,50 +95,39 @@ int __fastcall Script_IsMounted(void *L) {
|
||||
// No-op if the player isn't mounted (early-outs on the
|
||||
// `UNIT_FIELD_MOUNTDISPLAYID` check before walking auras).
|
||||
int __fastcall Script_Dismount(void *L) {
|
||||
auto *desc = PlayerDescriptor();
|
||||
auto *desc = Unit::Identity::PlayerDescriptor();
|
||||
if (MountDisplayID(desc) == 0)
|
||||
return 0;
|
||||
|
||||
auto *player = Player();
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
|
||||
if (!Aura::Data::IsSlotPopulated(player, slot))
|
||||
continue;
|
||||
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
|
||||
if (record == nullptr)
|
||||
continue;
|
||||
|
||||
auto *auraTypes = reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
|
||||
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
|
||||
if (auraTypes[eff] != Offsets::SPELL_AURA_MOUNTED)
|
||||
continue;
|
||||
auto fn = reinterpret_cast<CancelAuraSend_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
|
||||
fn(static_cast<int>(spellID));
|
||||
return 0;
|
||||
}
|
||||
const uint32_t spellID = FindPlayerBuffWithEffect(Offsets::SPELL_AURA_MOUNTED);
|
||||
if (spellID != 0) {
|
||||
auto fn = reinterpret_cast<CancelAuraSend_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
|
||||
fn(static_cast<int>(spellID));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `IsStealthed()` — true iff the player is currently stealthed
|
||||
// (Rogue Stealth / Druid Prowl). Checks `PLAYER_FIELD_VIS_BYTES`
|
||||
// bit 0x02. Mount also sets that bit, so we AND-gate with
|
||||
// `mountDisplayID == 0` to disambiguate. See the comment on
|
||||
// `OFF_PLAYER_FIELD_VIS_BYTES` in Offsets.h for the full bit map
|
||||
// and known limitations (untested for shapeshift forms).
|
||||
// (Rogue Stealth / Druid Prowl / Night Elf Shadowmeld / etc.). Reads
|
||||
// the sneak flag in `UNIT_FIELD_BYTES_1` byte 3 (mask
|
||||
// `UNIT_BYTES_1_STEALTH_FLAG`) — the same broadcast bit the client's
|
||||
// render path uses to draw a stealthed unit semi-transparent.
|
||||
//
|
||||
// Derived via `_classicapi_DiffAll`: a Prowl toggle from a Cat-Form
|
||||
// baseline flips byte 3 `0x00 -> 0x02` and touches nothing else but
|
||||
// aura-array slot metadata, so this is the one standalone descriptor
|
||||
// bit that means "stealthed" (the earlier `0x17C & 0x02` read was
|
||||
// actually AURALEVELS and only worked by coincidence). Being a unit
|
||||
// anim-tier flag rather than a class field, it covers every stealth
|
||||
// source and rank without a spellID list or an aura scan.
|
||||
int __fastcall Script_IsStealthed(void *L) {
|
||||
auto *desc = PlayerDescriptor();
|
||||
auto *desc = Unit::Identity::PlayerDescriptor();
|
||||
bool stealthed = false;
|
||||
if (desc != nullptr) {
|
||||
const uint32_t visBytes = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_PLAYER_FIELD_VIS_BYTES);
|
||||
stealthed = (visBytes & Offsets::PLAYER_VIS_BIT_STEALTH) != 0
|
||||
&& MountDisplayID(desc) == 0;
|
||||
const uint32_t bytes1 = *reinterpret_cast<const uint32_t *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_BYTES_1);
|
||||
stealthed = (bytes1 & Offsets::UNIT_BYTES_1_STEALTH_FLAG) != 0;
|
||||
}
|
||||
Game::Lua::PushBool(L, stealthed);
|
||||
return 1;
|
||||
@@ -177,12 +181,12 @@ int __fastcall Script_IsSwimming(void *L) {
|
||||
// vanilla's summoning ritual has no UI surface, which is what
|
||||
// motivates this function.
|
||||
int __fastcall Script_IsAssistingRitual(void *L) {
|
||||
auto *player = Player();
|
||||
auto *player = Unit::Identity::PlayerObject();
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, 0);
|
||||
return 1;
|
||||
}
|
||||
auto *desc = PlayerDescriptor();
|
||||
auto *desc = Unit::Identity::PlayerDescriptor();
|
||||
if (desc == nullptr) {
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Text-to-speech — a Windows SAPI backend behind modern WoW's C_VoiceChat /
|
||||
// C_TTSSettings Lua surface. Ported from awesome_wotlk's VoiceChat.cpp (the
|
||||
// 3.3.5 implementation); the SAPI core is client-version-agnostic, so only
|
||||
// the three integration seams are ClassicAPI-native:
|
||||
// - namespaces → Game::Lua::RegisterTableFunction
|
||||
// - events → Event::Custom (AutoReserve + Fire)
|
||||
// - settings → CVar::Factory (ttsVoice / ttsSpeed / ttsVolume, persisted
|
||||
// to Config.wtf, clamped via change callbacks)
|
||||
//
|
||||
// Lua surface:
|
||||
// C_VoiceChat
|
||||
// GetTtsVoices() -> { {voiceID=, name=}, ... }
|
||||
// GetRemoteTtsVoices() -> same (vanilla has no real voice chat)
|
||||
// SpeakText(voiceID, text[, destination[, rate[, volume]]])
|
||||
// StopSpeakingText()
|
||||
// C_TTSSettings
|
||||
// GetSpeechRate() / GetSpeechVolume() / GetSpeechVoiceID()
|
||||
// GetVoiceOptionName()
|
||||
// SetDefaultSettings()
|
||||
// SetSpeechRate(v) / SetSpeechVolume(v)
|
||||
// SetVoiceOption(id) / SetVoiceOptionByName(name)
|
||||
// RefreshVoices()
|
||||
//
|
||||
// Events: VOICE_CHAT_TTS_PLAYBACK_STARTED/FINISHED/FAILED,
|
||||
// VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE (reserved, unused),
|
||||
// VOICE_CHAT_TTS_VOICES_UPDATE.
|
||||
//
|
||||
// Threading: SAPI is created on the main (game) thread under an STA, and
|
||||
// SetNotifyCallbackFunction marshals the notify back to that thread's message
|
||||
// pump (WoW pumps messages every frame), so OnSpeechNotify runs on the main
|
||||
// thread and may touch the Lua event dispatcher safely.
|
||||
//
|
||||
// Coexistence: the same TTS surface also ships as the standalone VanillaTTS
|
||||
// DLL (which adds a pluggable backend — software synth under Wine). When
|
||||
// that DLL is injected alongside us, this module stands down entirely — see
|
||||
// StandaloneTtsPresent below.
|
||||
|
||||
#include "Game.h"
|
||||
#include "cvar/Factory.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <initguid.h> // instantiate the SAPI GUIDs in this TU (no sapi.lib)
|
||||
#include <sapi.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cwctype>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace VoiceChat::Tts {
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone-DLL coexistence
|
||||
// ---------------------------------------------------------------------------
|
||||
// The dedicated VanillaTTS DLL provides this exact Lua surface (same
|
||||
// C_VoiceChat / C_TTSSettings functions, same VOICE_CHAT_TTS_* events, same
|
||||
// ttsVoice/ttsSpeed/ttsVolume cvars) with a more capable backend. If both
|
||||
// registered, the damage wouldn't be limited to "last one wins":
|
||||
// - Each DLL claims its OWN event-table slot for the same event name, so
|
||||
// the table ends up with duplicate-name entries. frame:RegisterEvent
|
||||
// binds listeners to whichever slot the engine's scan finds first, while
|
||||
// the other DLL fires the other slot — events silently lost depending on
|
||||
// load order.
|
||||
// - The cvar registrar dedups by name but REPLACES the change callback on
|
||||
// re-register, so one DLL's clamp callbacks (and userData) go dead.
|
||||
// - Both would init COM/SAPI and answer SpeakText → double playback risk.
|
||||
// So when VanillaTTS.dll is in the process, this module registers nothing —
|
||||
// no Lua functions, no cvars, no event reservations, no SAPI init.
|
||||
//
|
||||
// The check runs at RegisterLuaFunctions time (first login), which is
|
||||
// load-order-independent: VanillaFixes loads every DLL at process attach,
|
||||
// long before FUN_LOAD_SCRIPT_FUNCTIONS first fires. Latched once — modules
|
||||
// can't appear or vanish mid-session.
|
||||
bool StandaloneTtsPresent() {
|
||||
static const bool present = GetModuleHandleA("VanillaTTS.dll") != nullptr;
|
||||
return present;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event names — reserved lazily in RegisterLuaFunctions (NOT via file-scope
|
||||
// AutoReserve statics, which would run unconditionally at DLL load and claim
|
||||
// slots even when we're standing down for VanillaTTS).
|
||||
// ---------------------------------------------------------------------------
|
||||
constexpr const char *kEvtPlaybackFailed = "VOICE_CHAT_TTS_PLAYBACK_FAILED";
|
||||
constexpr const char *kEvtPlaybackFinished = "VOICE_CHAT_TTS_PLAYBACK_FINISHED";
|
||||
constexpr const char *kEvtPlaybackStarted = "VOICE_CHAT_TTS_PLAYBACK_STARTED";
|
||||
constexpr const char *kEvtSpeakTextUpdate = "VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE";
|
||||
constexpr const char *kEvtVoicesUpdate = "VOICE_CHAT_TTS_VOICES_UPDATE";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CVar names + handles (registered in RegisterLuaFunctions)
|
||||
// ---------------------------------------------------------------------------
|
||||
constexpr const char *kCVarVoice = "ttsVoice";
|
||||
constexpr const char *kCVarSpeed = "ttsSpeed";
|
||||
constexpr const char *kCVarVolume = "ttsVolume";
|
||||
|
||||
CVar::Factory::Handle g_cvarVoice = nullptr;
|
||||
CVar::Factory::Handle g_cvarSpeed = nullptr;
|
||||
CVar::Factory::Handle g_cvarVolume = nullptr;
|
||||
|
||||
// Destination constants (kept for API parity; no special handling — SAPI
|
||||
// queues FIFO either way).
|
||||
enum : int {
|
||||
DEST_LOCAL_PLAYBACK = 1,
|
||||
DEST_QUEUED_LOCAL_PLAYBACK = 4,
|
||||
};
|
||||
int ClampDestination(int d) {
|
||||
return d == DEST_QUEUED_LOCAL_PLAYBACK ? DEST_QUEUED_LOCAL_PLAYBACK
|
||||
: DEST_LOCAL_PLAYBACK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAPI state
|
||||
// ---------------------------------------------------------------------------
|
||||
ISpVoice *g_pVoice = nullptr;
|
||||
bool g_comInit = false;
|
||||
|
||||
int g_nextUtteranceID = 1;
|
||||
int NextUtteranceID() {
|
||||
if (g_nextUtteranceID >= 0x7fffffff)
|
||||
g_nextUtteranceID = 1;
|
||||
return g_nextUtteranceID++;
|
||||
}
|
||||
|
||||
struct UtteranceMeta {
|
||||
int id;
|
||||
int destination;
|
||||
bool startedEmitted;
|
||||
};
|
||||
std::mutex g_streamMx;
|
||||
std::unordered_map<ULONG, UtteranceMeta> g_streamMap;
|
||||
|
||||
struct VoiceEntry {
|
||||
int voiceID;
|
||||
std::wstring name;
|
||||
};
|
||||
std::vector<VoiceEntry> g_cachedVoices;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UTF-8 / wide conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
std::string WideToUtf8(const std::wstring &w) {
|
||||
if (w.empty())
|
||||
return {};
|
||||
int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr);
|
||||
if (need <= 1)
|
||||
return {};
|
||||
std::string out(static_cast<size_t>(need - 1), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &out[0], need - 1, nullptr, nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::wstring Utf8ToWide(const char *s) {
|
||||
if (s == nullptr || *s == '\0')
|
||||
return {};
|
||||
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
|
||||
if (need <= 1)
|
||||
return {};
|
||||
std::wstring out(static_cast<size_t>(need - 1), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event fire helpers (engine dispatcher via Event::Custom)
|
||||
// ---------------------------------------------------------------------------
|
||||
void FireVoicesUpdate() {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtVoicesUpdate), "");
|
||||
}
|
||||
void FirePlaybackStarted(int numConsumers, int utteranceID, int durationMS, int dest) {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackStarted), "%d%d%d%d",
|
||||
numConsumers, utteranceID, durationMS, dest);
|
||||
}
|
||||
void FirePlaybackFinished(int numConsumers, int utteranceID, int dest) {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFinished), "%d%d%d",
|
||||
numConsumers, utteranceID, dest);
|
||||
}
|
||||
void FirePlaybackFailed(const char *status, int utteranceID, int dest) {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFailed), "%s%d%d",
|
||||
status, utteranceID, dest);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COM / SAPI lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
void InitCom() {
|
||||
if (g_comInit)
|
||||
return;
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
// S_FALSE = already initialized on this thread; treat as success.
|
||||
if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE)
|
||||
g_comInit = true;
|
||||
}
|
||||
|
||||
void __stdcall OnSpeechNotify(WPARAM, LPARAM) {
|
||||
if (g_pVoice == nullptr)
|
||||
return;
|
||||
SPEVENT ev = {};
|
||||
ULONG fetched = 0;
|
||||
while (SUCCEEDED(g_pVoice->GetEvents(1, &ev, &fetched)) && fetched == 1) {
|
||||
if (ev.eEventId == SPEI_START_INPUT_STREAM) {
|
||||
std::lock_guard<std::mutex> lock(g_streamMx);
|
||||
auto it = g_streamMap.find(ev.ulStreamNum);
|
||||
if (it != g_streamMap.end() && !it->second.startedEmitted) {
|
||||
FirePlaybackStarted(1, it->second.id, /*durationMS*/ 0,
|
||||
it->second.destination);
|
||||
it->second.startedEmitted = true;
|
||||
}
|
||||
} else if (ev.eEventId == SPEI_END_INPUT_STREAM) {
|
||||
UtteranceMeta meta{0, DEST_LOCAL_PLAYBACK, false};
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_streamMx);
|
||||
auto it = g_streamMap.find(ev.ulStreamNum);
|
||||
if (it != g_streamMap.end()) {
|
||||
meta = it->second;
|
||||
g_streamMap.erase(it);
|
||||
}
|
||||
}
|
||||
FirePlaybackFinished(1, meta.id, meta.destination);
|
||||
}
|
||||
// Only START/END stream events are requested; neither carries an
|
||||
// allocated lParam, so no SpClearEvent (sphelper) is needed.
|
||||
ev = SPEVENT{};
|
||||
}
|
||||
}
|
||||
|
||||
void InitVoice() {
|
||||
if (g_pVoice != nullptr)
|
||||
return;
|
||||
InitCom();
|
||||
HRESULT hr = CoCreateInstance(CLSID_SpVoice, nullptr, CLSCTX_ALL,
|
||||
IID_ISpVoice, reinterpret_cast<void **>(&g_pVoice));
|
||||
if (FAILED(hr) || g_pVoice == nullptr) {
|
||||
g_pVoice = nullptr;
|
||||
return;
|
||||
}
|
||||
g_pVoice->SetNotifyCallbackFunction(&OnSpeechNotify, 0, 0);
|
||||
const ULONGLONG interest =
|
||||
SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM);
|
||||
g_pVoice->SetInterest(interest, interest);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice enumeration (CLSID_SpObjectTokenCategory; no sphelper)
|
||||
// ---------------------------------------------------------------------------
|
||||
std::vector<VoiceEntry> EnumerateVoices() {
|
||||
InitCom();
|
||||
std::vector<VoiceEntry> voices;
|
||||
|
||||
ISpObjectTokenCategory *cat = nullptr;
|
||||
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
|
||||
IID_ISpObjectTokenCategory,
|
||||
reinterpret_cast<void **>(&cat))) ||
|
||||
cat == nullptr)
|
||||
return voices;
|
||||
|
||||
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
|
||||
IEnumSpObjectTokens *en = nullptr;
|
||||
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
|
||||
ISpObjectToken *tok = nullptr;
|
||||
ULONG fetched = 0;
|
||||
int index = 0;
|
||||
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
|
||||
LPWSTR desc = nullptr;
|
||||
if (SUCCEEDED(tok->GetStringValue(nullptr, &desc)) && desc != nullptr) {
|
||||
voices.push_back({index, desc});
|
||||
CoTaskMemFree(desc);
|
||||
}
|
||||
tok->Release();
|
||||
++index;
|
||||
}
|
||||
en->Release();
|
||||
}
|
||||
}
|
||||
cat->Release();
|
||||
return voices;
|
||||
}
|
||||
|
||||
int VoiceCount() {
|
||||
return static_cast<int>(EnumerateVoices().size());
|
||||
}
|
||||
|
||||
void RefreshVoices() {
|
||||
auto fresh = EnumerateVoices();
|
||||
bool changed = fresh.size() != g_cachedVoices.size();
|
||||
if (!changed) {
|
||||
for (size_t i = 0; i < fresh.size(); ++i) {
|
||||
if (fresh[i].name != g_cachedVoices[i].name) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
g_cachedVoices = std::move(fresh);
|
||||
if (changed)
|
||||
FireVoicesUpdate();
|
||||
}
|
||||
|
||||
// Select the voice at enumeration index `voiceID` on g_pVoice.
|
||||
bool SelectVoice(int voiceID) {
|
||||
ISpObjectTokenCategory *cat = nullptr;
|
||||
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
|
||||
IID_ISpObjectTokenCategory,
|
||||
reinterpret_cast<void **>(&cat))) ||
|
||||
cat == nullptr)
|
||||
return false;
|
||||
|
||||
bool set = false;
|
||||
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
|
||||
IEnumSpObjectTokens *en = nullptr;
|
||||
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
|
||||
ISpObjectToken *tok = nullptr;
|
||||
ULONG fetched = 0;
|
||||
int index = 0;
|
||||
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
|
||||
if (index == voiceID) {
|
||||
set = SUCCEEDED(g_pVoice->SetVoice(tok));
|
||||
tok->Release();
|
||||
break;
|
||||
}
|
||||
tok->Release();
|
||||
++index;
|
||||
}
|
||||
en->Release();
|
||||
}
|
||||
}
|
||||
cat->Release();
|
||||
return set;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Speak / stop
|
||||
// ---------------------------------------------------------------------------
|
||||
void SpeakText(int voiceID, const std::wstring &text, int destination, int rate,
|
||||
int volume) {
|
||||
const int utteranceID = NextUtteranceID();
|
||||
const int dest = ClampDestination(destination);
|
||||
|
||||
InitVoice();
|
||||
if (g_pVoice == nullptr) {
|
||||
FirePlaybackFailed("EngineAllocationFailed", utteranceID, dest);
|
||||
return;
|
||||
}
|
||||
if (!SelectVoice(voiceID)) {
|
||||
FirePlaybackFailed("InternalError", utteranceID, dest);
|
||||
return;
|
||||
}
|
||||
|
||||
g_pVoice->SetRate(std::clamp(rate, -10, 10));
|
||||
g_pVoice->SetVolume(static_cast<USHORT>(std::clamp(volume, 0, 100)));
|
||||
|
||||
ULONG streamNum = 0;
|
||||
if (FAILED(g_pVoice->Speak(text.c_str(), SPF_ASYNC, &streamNum))) {
|
||||
FirePlaybackFailed("InternalError", utteranceID, dest);
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_streamMx);
|
||||
g_streamMap[streamNum] = UtteranceMeta{utteranceID, dest, false};
|
||||
}
|
||||
|
||||
void StopAll() {
|
||||
InitVoice();
|
||||
if (g_pVoice == nullptr)
|
||||
return;
|
||||
g_pVoice->Speak(nullptr, SPF_PURGEBEFORESPEAK, nullptr);
|
||||
std::lock_guard<std::mutex> lock(g_streamMx);
|
||||
g_streamMap.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lua helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
// Push a { {voiceID=, name=}, ... } array built from g_cachedVoices.
|
||||
void PushVoicesTable(void *L) {
|
||||
Game::Lua::NewTable(L); // outer array at top
|
||||
for (size_t i = 0; i < g_cachedVoices.size(); ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based array key
|
||||
Game::Lua::NewTable(L); // sub-table
|
||||
Game::Lua::SetFieldNumber(L, "voiceID", g_cachedVoices[i].voiceID);
|
||||
Game::Lua::SetFieldString(L, "name",
|
||||
WideToUtf8(g_cachedVoices[i].name).c_str());
|
||||
// stack: [outer, key, sub] → outer[key] = sub
|
||||
Game::Lua::RawSet(L, -3);
|
||||
}
|
||||
}
|
||||
|
||||
int OptInt(void *L, int idx, int fallback) {
|
||||
return Game::Lua::IsNumber(L, idx)
|
||||
? static_cast<int>(Game::Lua::ToNumber(L, idx))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C_VoiceChat
|
||||
// ---------------------------------------------------------------------------
|
||||
int __fastcall Script_GetTtsVoices(void *L) {
|
||||
RefreshVoices();
|
||||
PushVoicesTable(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetRemoteTtsVoices(void *L) {
|
||||
RefreshVoices();
|
||||
PushVoicesTable(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SpeakText(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: C_VoiceChat.SpeakText(voiceID, text "
|
||||
"[, destination, rate, volume])");
|
||||
return 0;
|
||||
}
|
||||
const int voiceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const std::wstring text = Utf8ToWide(Game::Lua::ToString(L, 2));
|
||||
const int dest = OptInt(L, 3, DEST_LOCAL_PLAYBACK);
|
||||
const int rate = OptInt(L, 4, 0);
|
||||
const int volume = OptInt(L, 5, 100);
|
||||
SpeakText(voiceID, text, dest, rate, volume);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_StopSpeakingText(void *) {
|
||||
StopAll();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C_TTSSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
int __fastcall Script_GetSpeechRate(void *L) {
|
||||
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarSpeed, 0));
|
||||
return 1;
|
||||
}
|
||||
int __fastcall Script_GetSpeechVolume(void *L) {
|
||||
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVolume, 100));
|
||||
return 1;
|
||||
}
|
||||
int __fastcall Script_GetSpeechVoiceID(void *L) {
|
||||
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVoice, 0));
|
||||
return 1;
|
||||
}
|
||||
int __fastcall Script_GetVoiceOptionName(void *L) {
|
||||
const int id = CVar::Factory::GetInt(g_cvarVoice, 0);
|
||||
auto voices = EnumerateVoices();
|
||||
if (id >= 0 && id < static_cast<int>(voices.size()))
|
||||
Game::Lua::PushString(L, WideToUtf8(voices[id].name).c_str());
|
||||
else
|
||||
Game::Lua::PushString(L, "");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetSpeechRate(void *L) {
|
||||
if (Game::Lua::IsNumber(L, 1))
|
||||
CVar::Factory::SetInt(g_cvarSpeed, static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_SetSpeechVolume(void *L) {
|
||||
if (Game::Lua::IsNumber(L, 1))
|
||||
CVar::Factory::SetInt(g_cvarVolume, static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_SetVoiceOption(void *L) {
|
||||
if (Game::Lua::IsNumber(L, 1))
|
||||
CVar::Factory::SetInt(g_cvarVoice, static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_SetVoiceOptionByName(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const std::wstring want = Utf8ToWide(Game::Lua::ToString(L, 1));
|
||||
auto voices = EnumerateVoices();
|
||||
for (const auto &v : voices) {
|
||||
if (v.name.size() == want.size()) {
|
||||
bool eq = true;
|
||||
for (size_t i = 0; i < want.size(); ++i) {
|
||||
if (towlower(v.name[i]) != towlower(want[i])) {
|
||||
eq = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eq) {
|
||||
CVar::Factory::SetInt(g_cvarVoice, v.voiceID);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_SetDefaultSettings(void *) {
|
||||
const int maxVoice = VoiceCount();
|
||||
CVar::Factory::SetInt(g_cvarVoice, maxVoice > 1 ? 1 : 0);
|
||||
CVar::Factory::SetInt(g_cvarSpeed, 0);
|
||||
CVar::Factory::SetInt(g_cvarVolume, 100);
|
||||
FireVoicesUpdate();
|
||||
return 0;
|
||||
}
|
||||
int __fastcall Script_RefreshVoices(void *) {
|
||||
RefreshVoices();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CVar clamp callbacks (re-entrant SetString to clamp; reject out-of-range)
|
||||
// ---------------------------------------------------------------------------
|
||||
int __fastcall ClampRange(CVar::Factory::Handle cvar, const char *, const char *next,
|
||||
int lo, int hi) {
|
||||
if (next == nullptr)
|
||||
return 1;
|
||||
const int v = std::atoi(next);
|
||||
const int c = std::clamp(v, lo, hi);
|
||||
if (c != v) {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof buf, "%d", c);
|
||||
CVar::Factory::SetString(cvar, buf);
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
int __fastcall OnSpeedChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
|
||||
return ClampRange(c, p, n, -10, 10);
|
||||
}
|
||||
int __fastcall OnVolumeChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
|
||||
return ClampRange(c, p, n, 0, 100);
|
||||
}
|
||||
int __fastcall OnVoiceChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
|
||||
const int maxIdx = VoiceCount() - 1;
|
||||
return ClampRange(c, p, n, 0, maxIdx < 0 ? 0 : maxIdx);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
void RegisterLuaFunctions() {
|
||||
if (StandaloneTtsPresent())
|
||||
return; // VanillaTTS owns the TTS surface this session — see above.
|
||||
|
||||
// Reserve the events on first (real) registration. AutoReserve only
|
||||
// appends to Event::Custom's reservation list — the actual slot claims
|
||||
// happen later via the usual RetryClaims walk — so reserving here instead
|
||||
// of at static-init time changes nothing but the conditionality.
|
||||
static const Event::Custom::AutoReserve _r1{kEvtPlaybackFailed};
|
||||
static const Event::Custom::AutoReserve _r2{kEvtPlaybackFinished};
|
||||
static const Event::Custom::AutoReserve _r3{kEvtPlaybackStarted};
|
||||
static const Event::Custom::AutoReserve _r4{kEvtSpeakTextUpdate};
|
||||
static const Event::Custom::AutoReserve _r5{kEvtVoicesUpdate};
|
||||
|
||||
g_cvarVoice = CVar::Factory::Register(kCVarVoice, "0", 0, &OnVoiceChanged);
|
||||
g_cvarSpeed = CVar::Factory::Register(kCVarSpeed, "0", 0, &OnSpeedChanged);
|
||||
g_cvarVolume = CVar::Factory::Register(kCVarVolume, "100", 0, &OnVolumeChanged);
|
||||
|
||||
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetTtsVoices", &Script_GetTtsVoices);
|
||||
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetRemoteTtsVoices", &Script_GetRemoteTtsVoices);
|
||||
Game::Lua::RegisterTableFunction("C_VoiceChat", "SpeakText", &Script_SpeakText);
|
||||
Game::Lua::RegisterTableFunction("C_VoiceChat", "StopSpeakingText", &Script_StopSpeakingText);
|
||||
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechRate", &Script_GetSpeechRate);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVolume", &Script_GetSpeechVolume);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVoiceID", &Script_GetSpeechVoiceID);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetVoiceOptionName", &Script_GetVoiceOptionName);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetDefaultSettings", &Script_SetDefaultSettings);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechRate", &Script_SetSpeechRate);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechVolume", &Script_SetSpeechVolume);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOption", &Script_SetVoiceOption);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOptionByName", &Script_SetVoiceOptionByName);
|
||||
Game::Lua::RegisterTableFunction("C_TTSSettings", "RefreshVoices", &Script_RefreshVoices);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace VoiceChat::Tts
|
||||
@@ -0,0 +1,232 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_XMLUtil.GetTemplates()` — enumerate every registered virtual XML frame
|
||||
// template as `{ name = <string>, type = <frameType> }`.
|
||||
//
|
||||
// The engine keeps virtual templates (the `<Frame virtual="true">` etc. that
|
||||
// `inherits=` targets) in a Storm hash table. The XML parser (FUN_006ede10)
|
||||
// registers each one via FUN_006ee500, and the inherits resolver reads the
|
||||
// same table via FUN_006ee6f0 — so it's the authoritative template store, and
|
||||
// walking it yields exactly what modern `C_XMLUtil.GetTemplates` returns.
|
||||
// Fonts are a separate registry and are (correctly) not included; non-virtual
|
||||
// named frames take a different instantiate path and never enter this table.
|
||||
//
|
||||
// Table shape (see Offsets.h): a POINTER to a bucket array at
|
||||
// VAR_XML_TEMPLATE_TABLE, a bucket-count mask at VAR_XML_TEMPLATE_MASK
|
||||
// (0xFFFFFFFF until the first template registers). Each bucket is a Storm
|
||||
// intrusive list; each node carries the template name (+0x14) and the parsed
|
||||
// definition node (+0x18), whose element tag string (+0x8) is the frame type.
|
||||
// Contents reflect the currently-loaded XML and are rebuilt on `/reload`.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace Xml::Templates {
|
||||
|
||||
namespace {
|
||||
|
||||
// ---- Node-DOM helpers (shared by GetTemplateInfo / DoesTemplateExist) -----
|
||||
|
||||
using TemplateLookup_t = const uint8_t *(__fastcall *)(const char *name);
|
||||
using GetAttr_t = const char *(__thiscall *)(const void *node, const char *name);
|
||||
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
|
||||
|
||||
// The engine's by-name template lookup (FUN_006ee6f0) — the same path
|
||||
// `inherits=` resolves through. Returns the parsed definition node, or null
|
||||
// if no template of that name is registered (case-insensitive, hashed).
|
||||
const uint8_t *LookupTemplate(const char *name) {
|
||||
return reinterpret_cast<TemplateLookup_t>(
|
||||
Offsets::FUN_XML_TEMPLATE_LOOKUP)(name);
|
||||
}
|
||||
|
||||
// Value of `node`'s attribute `name`, or null if absent.
|
||||
const char *Attr(const uint8_t *node, const char *name) {
|
||||
return reinterpret_cast<GetAttr_t>(Offsets::FUN_XML_NODE_GET_ATTRIBUTE)(
|
||||
node, name);
|
||||
}
|
||||
|
||||
const uint8_t *FirstChild(const uint8_t *node) {
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
node + Offsets::OFF_XML_NODE_CHILD);
|
||||
}
|
||||
const uint8_t *NextSibling(const uint8_t *node) {
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
node + Offsets::OFF_XML_NODE_SIBLING);
|
||||
}
|
||||
|
||||
// Case-insensitive tag match, using the engine's own comparator (the same one
|
||||
// FUN_006f1eb0 uses to recognize "AbsDimension" etc.).
|
||||
bool TagEquals(const uint8_t *node, const char *tag) {
|
||||
const char *t = *reinterpret_cast<const char *const *>(
|
||||
node + Offsets::OFF_XML_NODE_TAG);
|
||||
if (t == nullptr)
|
||||
return false;
|
||||
return reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I)(
|
||||
t, tag, 0x7FFFFFFF) == 0;
|
||||
}
|
||||
|
||||
const uint8_t *FindChild(const uint8_t *node, const char *tag) {
|
||||
for (const uint8_t *c = FirstChild(node); c != nullptr; c = NextSibling(c))
|
||||
if (TagEquals(c, tag))
|
||||
return c;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The statically-declared width/height from a `<Size>` child (0 if none).
|
||||
// Handles the inline `<Size x= y=>` form and the `<Size><AbsDimension x= y=>`
|
||||
// (or RelDimension) child form. Returns the RAW values from the XML — retail's
|
||||
// GetTemplateInfo reports the declared value, so unlike the engine's own Size
|
||||
// parser (FUN_006f1eb0) we deliberately skip the UI-scale conversion.
|
||||
void ReadSize(const uint8_t *node, double *outW, double *outH) {
|
||||
*outW = 0.0;
|
||||
*outH = 0.0;
|
||||
const uint8_t *size = FindChild(node, "Size");
|
||||
if (size == nullptr)
|
||||
return;
|
||||
const char *sx = Attr(size, "x");
|
||||
const char *sy = Attr(size, "y");
|
||||
const uint8_t *dim = FirstChild(size);
|
||||
if (dim != nullptr &&
|
||||
(TagEquals(dim, "AbsDimension") || TagEquals(dim, "RelDimension"))) {
|
||||
const char *dx = Attr(dim, "x");
|
||||
const char *dy = Attr(dim, "y");
|
||||
if (dx != nullptr && *dx != '\0')
|
||||
sx = dx;
|
||||
if (dy != nullptr && *dy != '\0')
|
||||
sy = dy;
|
||||
}
|
||||
if (sx != nullptr && *sx != '\0')
|
||||
*outW = std::strtod(sx, nullptr);
|
||||
if (sy != nullptr && *sy != '\0')
|
||||
*outH = std::strtod(sy, nullptr);
|
||||
}
|
||||
|
||||
// `C_XMLUtil.GetTemplates()` -> XMLTemplateListInfo[] of { name, type }.
|
||||
int __fastcall Script_GetTemplates(void *L) {
|
||||
Game::Lua::NewTable(L); // the result array
|
||||
|
||||
const uint32_t mask =
|
||||
*reinterpret_cast<const uint32_t *>(Offsets::VAR_XML_TEMPLATE_MASK);
|
||||
if (mask == 0xFFFFFFFFu)
|
||||
return 1; // no template has ever registered — empty array
|
||||
|
||||
const uint8_t *base = *reinterpret_cast<const uint8_t *const *>(
|
||||
Offsets::VAR_XML_TEMPLATE_TABLE);
|
||||
if (base == nullptr)
|
||||
return 1;
|
||||
|
||||
int outIdx = 0;
|
||||
for (uint32_t b = 0; b <= mask; ++b) {
|
||||
const uint8_t *bucket = base + b * Offsets::XML_TEMPLATE_BUCKET_STRIDE;
|
||||
const int linkOff = *reinterpret_cast<const int *>(
|
||||
bucket + Offsets::OFF_XML_TEMPLATE_BUCKET_LINKOFF);
|
||||
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
|
||||
bucket + Offsets::OFF_XML_TEMPLATE_BUCKET_HEAD);
|
||||
|
||||
// Walk the intrusive chain; the tail sentinel has its low bit set
|
||||
// (mirrors the engine's own traversal in FUN_006ee6f0).
|
||||
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
node + Offsets::OFF_XML_TEMPLATE_NODE_NAME);
|
||||
const uint8_t *def = *reinterpret_cast<const uint8_t *const *>(
|
||||
node + Offsets::OFF_XML_TEMPLATE_NODE_DEF);
|
||||
const char *type =
|
||||
(def != nullptr)
|
||||
? *reinterpret_cast<const char *const *>(
|
||||
def + Offsets::OFF_XML_NODE_TAG)
|
||||
: nullptr;
|
||||
|
||||
++outIdx;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "name", name != nullptr ? name : "");
|
||||
Game::Lua::SetFieldString(L, "type", type != nullptr ? type : "");
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
node = *reinterpret_cast<const uint8_t *const *>(node + linkOff + 4);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_XMLUtil.GetTemplateInfo(name)` -> XMLTemplateInfo table, or nil if the
|
||||
// template doesn't exist.
|
||||
int __fastcall Script_GetTemplateInfo(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_XMLUtil.GetTemplateInfo(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
const char *name = Game::Lua::ToString(L, 1);
|
||||
const uint8_t *node = (name != nullptr) ? LookupTemplate(name) : nullptr;
|
||||
if (node == nullptr)
|
||||
return 0; // nil — no such template
|
||||
|
||||
Game::Lua::NewTable(L); // XMLTemplateInfo
|
||||
|
||||
const char *type = *reinterpret_cast<const char *const *>(
|
||||
node + Offsets::OFF_XML_NODE_TAG);
|
||||
Game::Lua::SetFieldString(L, "type", type != nullptr ? type : "");
|
||||
|
||||
double w = 0.0, h = 0.0;
|
||||
ReadSize(node, &w, &h);
|
||||
Game::Lua::SetFieldNumber(L, "width", w);
|
||||
Game::Lua::SetFieldNumber(L, "height", h);
|
||||
|
||||
// keyValues: vanilla's XML schema has no <KeyValues> element, so this is
|
||||
// always an empty table (parity with retail's "empty if none defined").
|
||||
Game::Lua::PushString(L, "keyValues");
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
|
||||
// inherits: the comma-delimited `inherits=` attribute, or nil (field left
|
||||
// unset) when the template inherits nothing.
|
||||
const char *inherits = Attr(node, "inherits");
|
||||
if (inherits != nullptr && *inherits != '\0')
|
||||
Game::Lua::SetFieldString(L, "inherits", inherits);
|
||||
|
||||
// sourceLocation is intentionally omitted (nil): vanilla records no
|
||||
// file/line for XML nodes — a 10.2.0 addition gated behind the
|
||||
// enableSourceLocationLookup CVar even on retail.
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_XMLUtil.DoesTemplateExist(name)` -> bool.
|
||||
int __fastcall Script_DoesTemplateExist(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_XMLUtil.DoesTemplateExist(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
const char *name = Game::Lua::ToString(L, 1);
|
||||
const uint8_t *node = (name != nullptr) ? LookupTemplate(name) : nullptr;
|
||||
Game::Lua::PushBool(L, node != nullptr);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_XMLUtil", "GetTemplates",
|
||||
&Script_GetTemplates);
|
||||
Game::Lua::RegisterTableFunction("C_XMLUtil", "GetTemplateInfo",
|
||||
&Script_GetTemplateInfo);
|
||||
Game::Lua::RegisterTableFunction("C_XMLUtil", "DoesTemplateExist",
|
||||
&Script_DoesTemplateExist);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Xml::Templates
|
||||
Reference in New Issue
Block a user