Compare commits

...
16 Commits
Author SHA1 Message Date
Brues 8e9e30567c compat: hide HookScript on older pfUI forks' action bar buttons
Our DLL adds the modern HookScript widget method; older pfUI forks branch
on `if button.HookScript` and take a modern path they weren't written for
on pfActionBar buttons, breaking them. While the actionbar module builds,
wrap CreateFrame to shadow HookScript with a falsy instance field (nil
doesn't work — it's a metatable method, so the lookup falls through) so
those forks use their vanilla SetScript path. The maintained "brues" fork
is detected via the TOC X-Website tag and left alone; the wrapper is
removed once the actionbar module finishes loading.
2026-07-10 20:19:37 -05:00
Brues 8be78ad946 Fix trade skill link button positioning and add ATSW support
Make profession link button position responsive to title text changes and justification. The button now repositions whenever the title's text or visibility changes, using the title's string width and justification mode to calculate the correct anchor point. Also add support for AdvancedTradeSkillWindow and AdvancedTradeSkillWindow2 addons with appropriate link callbacks.

Closes #7
2026-07-10 18:22:25 -05:00
Brues 0dea869ab1 tradeskill: hide detail scrollbar track groove when not scrolling
The ClassTrainer detail template's decorative track pieces
($parentTop/$parentBottom) stayed visible when the slider was hidden,
leaving an empty scrollbar groove on recipes that fit. Toggle them with
the slider in UpdateDetailScrollbar (no-ops on Turtle's plain template).
2026-07-10 17:11:26 -05:00
Brues b4dffb75c0 tradeskill: reagent tooltip fires over the whole element
Extend each reagent button's hit rect rightward across the name plate
(capped short of the next column so hovers don't bleed across), so the
item tooltip shows anywhere on the element instead of only over the icon.
2026-07-10 16:56:42 -05:00
Brues 9445fd6333 tradeskill: native-style detail scroll, reagent overflow, separator
Rework the trade-skill link viewer's detail pane to match the native
1.12 TradeSkillFrame:

- Detail is now a real ScrollFrame (ClassTrainer templates on stock,
  plain on Turtle) whose scroll child holds the product header + reagent
  grid, so the whole pane pixel-scrolls and clips partial reagent rows
  instead of the old fixed 2-row faux-scroll that clipped many-reagent
  recipes. Slider is managed explicitly (shown only when content
  overflows; re-asserted a frame after show so the engine's layout pass
  can't leave a dead scrollbar on Turtle's roomy pane).
- Reagent buttons built on demand (any count) inside the scroll child.
- Add the recipe/detail separator (stock only) using the top-quarter rod
  of UI-ClassTrainer-HorizontalBar, stretched to width.
- Stock list/detail scrolls use the ClassTrainer scroll templates for the
  decorative UI-ClassTrainer-ScrollBar track; Turtle keeps plain ones.
2026-07-10 16:45:56 -05:00
Brues c8effdd6ec addons: fix crash in GetAddOnOptionalDependencies numeric path
FUN_ADDON_GET_BY_INDEX returns a name pointer, not the AddOnEntry
record, so reading the OptionalDeps offsets (+0x3C/+0x40) directly off
it landed in addon-name string memory and dereferenced garbage as an
array base -> ACCESS_VIOLATION (pfUI's addon list iterates by index and
hit it immediately). Route both numeric and string args through the
by-name hash resolver FUN_ADDON_RESOLVE_REQ_DEPS to reach the real
record, exactly as stock Script_GetAddOnDependencies does for both.
2026-07-10 02:33:14 -05:00
Brues b3ac1c07f7 addons: add C_AddOns.GetAddOnOptionalDependencies
Backport the optional-deps getter vanilla never exposed (it ships
GetAddOnDependencies for the required array only). Resolves the arg
the same way stock Script_GetAddOnDependencies does -- numeric via
FUN_ADDON_GET_BY_INDEX, string via the by-name hash lookup 0x0051E350
(returns record+0x48; subtract to recover the base) -- then walks the
OptionalDeps descriptor at record+0x38 (count +0x3C, data +0x40),
verified against the TOC parser FUN_0051C9B0. Arg/error behavior
matches stock: out-of-range index or a non-string/number arg errors;
an unknown name returns nothing.
2026-07-10 02:22:28 -05:00
Brues cfbc4f4e9c loot: add C_LootHistory (group-loot roll history backport)
Backport of the MoP C_LootHistory namespace. Vanilla 1.12 has no
loot-history store, but it receives the live group-loot roll traffic,
which the stock UI only turns into chat. This reconstructs the history
client-side (like Aura::Source): four co-hooks on the loot-roll packet
handlers accumulate a ring (128) of rolled items with per-player
Need/Greed/Pass results and the winner, exposed through the namespace.

Handlers, from the opcode dispatch FUN_005E6010:
  - START_ROLL  0x2A1 FUN_0061B310 (__fastcall(packet), RET 0)
  - ALL_PASSED  0x29E FUN_0061B640 (msg=EDX, RET 0)
  - ROLL_WON    0x29F FUN_0061B9E0 (ctx=ECX, msg=EDX, +stack, RET 4)
  - ROLL        0x2A2 FUN_0061C0B0 (msg=ECX, ctx=EDX, +stack, RET 4)
The per-handler ABI (message register, stack arg, RET) is load-bearing:
matching it exactly is what keeps the co-hooks from corrupting ESP.

Names and class come from the engine NameCache (FUN_0055F080, null-
callback read of a loaded entry), captured at roll time — so they
resolve even for a roller who has left the group by the time their roll
arrives. The item's random suffix is only in SMSG_LOOT_START_ROLL (the
result packets send 0), so it's read off the store-head node right after
that handler runs, giving a correct item:id:0:suffix:unique link.

Surface: C_LootHistory.GetNumItems / GetItem / GetPlayerInfo (name,
class token, rollType, roll, isWinner, isMe) + LOOT_HISTORY_ROLL_CHANGED
/ _ROLL_COMPLETE events (_FULL_UPDATE reserved). The MoP master-loot
functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are omitted —
they need server support 1.12 lacks. Documented in docs/API.md + README.
2026-07-10 01:59:52 -05:00
Brues bb9579c398 aura: flush source cache on map transition (stuck buffs after leaving BG)
Leaving a battleground (incl. the /afk teleport out while dead) bulk-
clears the player's aura descriptor WITHOUT firing per-aura
OnAuraRemoved, so Aura::Source entries for auras that are now gone
lingered and Aura::Data's descriptor-empty fallback kept surfacing them.
The tell from the report: a real buff (filling a descriptor slot)
temporarily masks the phantoms, removing it brings them back.

Can't evict on descriptor-empty — that's the exact state the fallback
must survive (rogue stealth, party-range drops). The clean distinguisher
is a map transition: a BG/instance/continent change moves the Map.dbc
ID, while stealth/range don't. OnWorldTick now polls VAR_CURRENT_MAP_ID
and flushes the whole cache on any change (no new hook). Every other
unit from the old map has despawned, and the player's real auras re-sync
from the descriptor / PLAYER_BUFF tables on entering the new world, so a
full flush loses nothing legitimate.

Reported via pfUI issue #20.
2026-07-09 23:55:12 -05:00
Brues 5642644833 add ClassicAPITradeSkillLinkFrame to UISpecialFrames 2026-07-09 23:13:42 -05:00
Brues 58e7e1a2c6 frame: add Frame:IsEventRegistered
Vanilla has RegisterEvent / UnregisterEvent as frame methods but not the
query IsEventRegistered (added in a later expansion). There's no engine
Script_* to delegate to, so we replicate the membership check already
embedded in Frame::RegisterEvent (FUN_00702140): find the event by name
in the live event table (SStrCmpI), then walk that entry's subscriber
chain (head@+0xC, next@+4, frame@+8, low-bit/null sentinel) for this
frame. self is resolved via Game::Lua::ResolveObject, yielding the same
CFrameScriptObject* the chain stores.

Registered on the Frame method registry (VAR_FRAME_METHOD_REGISTRY,
alongside RegisterEvent) so it resolves on any frame. Pure read, no new
hooks. Returns a boolean, matching the 3.3.5-era contract; verified
in-game (true for a registered event, false otherwise). Documented in
docs/API.md and README.md.
2026-07-09 18:08:17 -05:00
Brues 5339c97b1e tradeskill: native single-column link viewer layout on stock 1.12.1
The link viewer mirrored Turtle's HD 768x512 two-column TradeSkillFrame,
whose TW-TradeSkill art doesn't exist in the base 1.12.1 client — an
earlier stretch fallback smeared the vanilla art's internal borders.

Split every layout value that differs by client into an LO table gated on
TURTLE_WOW_VERSION. On stock clients the viewer now mirrors the native
Blizzard_TradeSkillUI geometry: 384x512, single column (list on top,
detail below), built from the vanilla ClassTrainer + UI-TradeSkill
quadrant art at native size, 8 visible rows, and the Exit button placed
exactly where the base TradeSkillCancelButton sits. Turtle keeps its
768-wide two-column layout unchanged.
2026-07-09 17:26:10 -05:00
Brues 7406ac5db1 tradeskill: add link buttons to TradeSkillFrame and CraftFrame
Backport of the WotLK "link this profession to chat" button. A
petition-icon button sits right of the window title; clicking inserts
the window's |Htrade:...|h link (C_TradeSkillUI.GetTradeSkillListLink /
GetCraftListLink) into the chat edit box, opening it if hidden. Other
players click the link to open the recipe viewer.

Both windows are LoadOnDemand addons on this client, so creation is
deferred via EventUtil.ContinueOnAddOnLoaded (fires immediately if
already loaded). Uses the native GossipFrame\PetitionGossipIcon since
the 3.3.5 UI-TradeSkill-LinkButton art doesn't exist in 1.12. Adds the
LINK_TRADESKILL_TOOLTIP locale string for the hover text.
2026-07-09 16:57:49 -05:00
Brues 1c263152e3 spell: fix negative cast/channel startTimeMs/endTimeMs on long-lived tick
C_Spell.UnitCastingInfo/UnitChannelInfo returned negative startTimeMs
and endTimeMs for users whose engine ms tick has crossed 2^31. The tick
(FUN_OS_TICKCOUNT_MS) has two backends: GetTickCount, or a scaled rdtsc.
The rdtsc value survives warm reboots (only a full power-off resets the
CPU TSC), so it can sit well past 2^31 even right after a "restart" --
which is why rebooting didn't clear the negatives. We stored the tick in
a signed int and pushed it straight to a double, so the high bit read as
the sign.

Script_GetTime (0x00515EA0) computes (double)(tick & 0xFFFFFFFF) * 0.001,
i.e. it masks the tick to the low 32 bits UNSIGNED. New PushMs helper
reinterprets our stored tick as uint32 before the double conversion,
reproducing GetTime()'s exact value so startTimeMs/endTimeMs stay
consistent with GetTime()*1000 across the whole range (both wrap together
at the ~49.7-day 2^32 boundary). Carrying full 64-bit precision would be
wrong -- it would diverge from GetTime() past 2^32 and break addons'
(now-start)/(end-start) math.

Routes all four timestamp pushes (cast + channel, local and remote)
through PushMs. The aura expirationTime path already handled the tick as
unsigned, so no change there.
2026-07-09 13:10:57 -05:00
Brues ab211962e5 unit: fix IsStealthed via UNIT_FIELD_BYTES_1 sneak flag
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.

_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.

Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
2026-07-09 11:53:58 -05:00
Brues 33a23c563c map: make GetMapOverlays overlay tiling HD-map-patch safe
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.

- Grid resolution detects the full-cell file size S (256*scale) and uses it
  as the partial-tile threshold instead of a literal 256, so HD edge tiles
  aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
  fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
  and needs no file-size guessing. Fixes overlays whose stock file was a 128
  cell (content <=128): the patch doubled them to 256, which the earlier
  S>=256 floor read as un-upscaled and halved their texcoord (the clipped
  dark patches).

Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
2026-07-09 11:18:27 -05:00
13 changed files with 1320 additions and 155 deletions
+28
View File
@@ -8,5 +8,33 @@ 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 string.find(GetAddOnMetadata("pfUI", "X-Website") or '', 'brues') then
local _createFrame = CreateFrame
CreateFrame = function(frameType, name, parent, template)
if frameType == "Button" and string.find(name or "", "pfActionBar") then
local frame = _createFrame(frameType, name, parent, template)
frame.HookScript = false
return frame
end
return _createFrame(frameType, name, parent, template)
end
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
if moduleName == "actionbar" then
CreateFrame = _createFrame
end
end)
end
end)
end
+313 -80
View File
@@ -11,12 +11,75 @@
-- 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 (Blizzard_TradeSkillUI):
-- 768x512, TW-TradeSkill quadrant art, 19 rows of 16px, list at (37,-98).
local FRAME_W, FRAME_H = 768, 512;
local NUM_ROWS = 19;
-- 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",
@@ -32,7 +95,7 @@ local PROFESSION_ICONS = {
[356] = "Trade_Fishing",
};
if TURTLE_WOW_VERSION then
if TURTLE then
PROFESSION_ICONS[142] = "Trade_Survival"
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
end
@@ -188,45 +251,57 @@ local function CreateFrame_TradeSkillLink()
f:SetPoint("CENTER", 0, 0);
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(2, 82, 2, 73);
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;
local function Quad(file, w, h, point)
for i = 1, table.getn(LO.quads) do
local q = LO.quads[i];
local tx = f:CreateTexture(nil, "BORDER");
tx:SetTexture("Interface\\TradeSkillFrame\\" .. file);
tx:SetSize(w, h);
tx:SetPoint(point);
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
Quad("TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT");
Quad("TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT");
Quad("TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT");
Quad("TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT");
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", -77, -8);
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint("CENTER", f, "BOTTOMRIGHT", -128, 92);
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(596, 15);
bar:SetPoint("TOPLEFT", 76, -44);
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);
@@ -236,7 +311,7 @@ local function CreateFrame_TradeSkillLink()
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(604, 22);
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
border:SetPoint("LEFT", -5, 1);
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
@@ -253,19 +328,19 @@ local function CreateFrame_TradeSkillLink()
f.barRank = barRank;
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
summary:SetPoint("TOPRIGHT", -110, -70);
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
f.summary = summary;
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
"UIDropDownMenuTemplate");
dropdown:SetPoint("TOPLEFT", 40, -60);
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
f.dropdown = dropdown;
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
f, "FauxScrollFrameTemplate");
scroll:SetSize(296, 330);
scroll:SetPoint("TOPLEFT", 37, -98);
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)
@@ -309,17 +384,54 @@ local function CreateFrame_TradeSkillLink()
f.rows[i] = row;
end
local NUM_REAGENTS = 8;
-- 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 prodIcon = CreateFrame("Button", nil, f);
prodIcon:SetSize(37, 37); -- TradeSkillSkillIcon is 37x37
prodIcon:SetPoint("TOPLEFT", 379, -113);
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 = prodIcon:CreateTexture(nil, "BACKGROUND");
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 = prodIcon:CreateTexture(nil, "BACKGROUND");
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
@@ -328,44 +440,65 @@ local function CreateFrame_TradeSkillLink()
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = prodIcon:CreateFontString(nil, "ARTWORK", "GameFontNormal");
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(240);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(280);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
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 = {};
for i = 1, NUM_REAGENTS do
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, f);
local btn = CreateFrame("Button", nil, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 147 - 5, -3 - rowN * 43);
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(128, 64);
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(90, 36);
nm:SetSize(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
@@ -377,8 +510,22 @@ local function CreateFrame_TradeSkillLink()
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
btn: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)
@@ -405,11 +552,9 @@ local function CreateFrame_TradeSkillLink()
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
end
if created > 0 then
self.prodDesc:SetText("");
else
self.prodDesc:SetText(C_Spell.GetSpellDescription(entry.spellID) or "");
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 "");
@@ -420,50 +565,66 @@ local function CreateFrame_TradeSkillLink()
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 {};
for i = 1, NUM_REAGENTS do
local btn = self.reagents[i];
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
if r then
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();
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.itemLink = nil;
btn:Hide();
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
end
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);
-- 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
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
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);
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
@@ -473,9 +634,14 @@ local function CreateFrame_TradeSkillLink()
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, NUM_REAGENTS do
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()
@@ -620,3 +786,70 @@ function SetItemRef(link, text, button, chatFrame)
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)
+1
View File
@@ -24,3 +24,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.";
+5 -2
View File
@@ -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,7 +33,7 @@ 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](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` |
@@ -43,6 +43,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [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` |
@@ -121,6 +122,8 @@ 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"`) |
+115 -4
View File
@@ -20,6 +20,7 @@ build instructions.
- [`C_AddOns.IsAddOnLoaded(indexOrName)`](#c_addonsisaddonloadedindexorname)
- [`C_AddOns.GetAddOnSecurity(indexOrName)`](#c_addonsgetaddonsecurityindexorname)
- [`C_AddOns.DoesAddOnExist(indexOrName)`](#c_addonsdoesaddonexistindexorname)
- [`C_AddOns.GetAddOnOptionalDependencies(indexOrName)`](#c_addonsgetaddonoptionaldependenciesindexorname)
- [CharacterList](#characterlist)
- [`GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only](#getsavedcharacterorderrealm--setsavedcharacterorderrealm-order--gluexml-only)
@@ -99,6 +100,7 @@ build instructions.
- [`EQUIPMENT_SWAP_PENDING` event](#equipment_swap_pending-event)
- [`EQUIPMENT_SWAP_FINISHED` event](#equipment_swap_finished-event)
- [`FACTION_STANDING_CHANGED` event](#faction_standing_changed-event)
- [`LOOT_HISTORY_ROLL_CHANGED` / `LOOT_HISTORY_ROLL_COMPLETE` / `LOOT_HISTORY_FULL_UPDATE` events](#loot_history_roll_changed--loot_history_roll_complete--loot_history_full_update-events)
- [`LOOT_SCAN_COMPLETED` event](#loot_scan_completed-event)
- [`LOSS_OF_CONTROL_ADDED` / `LOSS_OF_CONTROL_UPDATE` events](#loss_of_control_added--loss_of_control_update-events)
- [`MODIFIER_STATE_CHANGED` event](#modifier_state_changed-event)
@@ -137,6 +139,7 @@ build instructions.
- [`frame:SetShown(shown)`](#framesetshownshown)
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
- [`frame:HookScript(scriptType, handler)`](#framehookscriptscripttype-handler)
- [`frame:IsEventRegistered(event)`](#frameiseventregisteredevent)
- [FriendList](#friendlist)
- [`C_FriendList.SendWhoQueryByName(name)`](#c_friendlistsendwhoquerybynamename)
@@ -254,6 +257,11 @@ build instructions.
- [`C_Loot.IsScanInProgress()`](#c_lootisscaninprogress)
- [`C_Loot.GetLastScanResults()`](#c_lootgetlastscanresults)
- [LootHistory](#loothistory)
- [`C_LootHistory.GetNumItems()`](#c_loothistorygetnumitems)
- [`C_LootHistory.GetItem(itemIndex)`](#c_loothistorygetitemitemindex)
- [`C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)`](#c_loothistorygetplayerinfoitemindex-playerindex)
- [LossOfControl](#lossofcontrol)
- [`C_LossOfControl.GetActiveLossOfControlDataCount()`](#c_lossofcontrolgetactivelossofcontroldatacount)
- [`C_LossOfControl.GetActiveLossOfControlData(index)`](#c_lossofcontrolgetactivelossofcontroldataindex)
@@ -604,11 +612,12 @@ the comment in [src/action/Info.cpp](../src/action/Info.cpp).
## AddOns
Six modern `C_AddOns.*` getters that splat the legacy
Modern `C_AddOns.*` getters that splat the legacy
`GetAddOnInfo(arg)` 7-tuple `(name, title, notes, enabled,
loadable, reason, security)` into single-field accessors. Most
bypass `GetAddOnInfo` entirely and call the engine's per-field
helpers directly.
loadable, reason, security)` into single-field accessors, plus a
getter for the addon's optional dependencies. Most bypass
`GetAddOnInfo` entirely and call the engine's per-field helpers
directly.
All accept either a 1-based index (`1..GetNumAddOns()`) or an
addon directory name string.
@@ -725,6 +734,25 @@ C_AddOns.DoesAddOnExist("DebugTools") -- true
C_AddOns.DoesAddOnExist("garbage") -- false
```
### `C_AddOns.GetAddOnOptionalDependencies(indexOrName)`
Returns the addon's `## OptionalDeps:` names as multiple return
values, in declared order — the counterpart to the stock
`GetAddOnDependencies` (required deps), which vanilla exposes but
never gave an optional-deps equivalent. An addon with no
`## OptionalDeps:` field returns nothing.
Arg and error handling match stock `GetAddOnDependencies`: a
numeric index out of range (or a non-string/non-number argument)
raises the usage error; an unknown *name* string returns nothing
without erroring.
```lua
-- ## OptionalDeps: Atlas, pfQuest
C_AddOns.GetAddOnOptionalDependencies("MyAddon") -- "Atlas", "pfQuest"
C_AddOns.GetAddOnOptionalDependencies("Atlas") -- (nothing — no OptionalDeps)
```
## CharacterList
### `GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only
@@ -2294,6 +2322,22 @@ forwarding, which [`C_Reputation.GetLastStandingChange`](#c_reputationgetlaststa
exposes — so addons can read the structured payload from inside the
chat event without re-parsing the localized string.
### `LOOT_HISTORY_ROLL_CHANGED` / `LOOT_HISTORY_ROLL_COMPLETE` / `LOOT_HISTORY_FULL_UPDATE` events
Fire as group-loot rolls progress, so a loot-history UI can update without
polling. Register like any engine event
(`frame:RegisterEvent("LOOT_HISTORY_ROLL_COMPLETE")`), then read the item via
[`C_LootHistory.GetItem`](#c_loothistorygetitemitemindex) /
[`C_LootHistory.GetPlayerInfo`](#c_loothistorygetplayerinfoitemindex-playerindex).
| Event | Args | When |
|---|---|---|
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex`, `playerIndex` (numbers) | A player rolled or passed on an item. |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` (number) | The item was decided — won or all-passed. |
| `LOOT_HISTORY_FULL_UPDATE` | — | Reserved for API / `IsEventValid` completeness (the display frame fires it on open); the DLL doesn't dispatch it. |
See the [LootHistory](#loothistory) section for the reconstruction and read API.
### `LOOT_SCAN_COMPLETED` event
Fires (no payload) once a [`C_Loot.ScanNearbyLoot()`](#c_lootscannearbyloot)
@@ -3143,6 +3187,22 @@ button:HookScript("OnEnter", function(self)
end)
```
### `frame:IsEventRegistered(event)`
Vanilla ships `RegisterEvent` / `UnregisterEvent` but not the query.
Returns `true` if the frame is currently registered for `event`,
`false` otherwise (including for an unknown event name). Reuses the
engine's own subscriber-chain membership check — the same walk
`RegisterEvent` performs before appending — so the answer is exactly
what the event dispatcher sees. Works on any frame.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:IsEventRegistered("PLAYER_LOGIN") -- true
f:IsEventRegistered("PLAYER_LOGOUT") -- false
```
## FriendList
### `C_FriendList.SendWhoQueryByName(name)`
@@ -6108,6 +6168,57 @@ tooltip display, extract the payload form via
requires the literal `"item:"` prefix and rejects full `|cff...|Hitem...|h`
input.
## LootHistory
Backport of the MoP `C_LootHistory` namespace — group-loot roll history.
Vanilla 1.12 has no loot-history store, but it does receive the live
group-loot roll traffic (`SMSG_LOOT_ROLL` per player, `SMSG_LOOT_ROLL_WON`,
`SMSG_LOOT_ALL_PASSED`) — which the stock UI only turns into chat lines. This
reconstructs the history client-side: packet co-hooks accumulate a ring of
rolled items with per-player Need/Greed/Pass results and the winner, exposed
through the namespace. Rollers' **name and class come from the engine
NameCache** captured at roll time, so they resolve even for a player who has
left the group by the time their roll arrives.
History is in-memory (last 128 rolled items) and resets on `/reload` or logout.
The MoP master-loot management functions (`SetExpiration` / `GiveMasterLoot` /
`CanMasterLoot`) are intentionally omitted — they drive server support 1.12
doesn't have.
Progress is signalled by the `LOOT_HISTORY_ROLL_CHANGED` /
`LOOT_HISTORY_ROLL_COMPLETE` events — see the
[Events](#loot_history_roll_changed--loot_history_roll_complete--loot_history_full_update-events)
section.
### `C_LootHistory.GetNumItems()`
Returns the number of rolled items currently in the history.
### `C_LootHistory.GetItem(itemIndex)`
`1`-based. Returns `itemLink, numPlayers, isDone, winnerName`:
- `itemLink` — `item:<id>:0:<suffix>:<unique>` (valid for `GetItemInfo` /
`GameTooltip:SetHyperlink`), carrying the roll's random suffix so
"of the X" items keep their affix.
- `numPlayers` — how many players have rolled/passed so far.
- `isDone` — `true` once the item is decided (won or all-passed).
- `winnerName` — the winner's name, or `nil` if undecided / all passed.
Returns nothing for an out-of-range index.
### `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)`
Both `1`-based. Returns `name, class, rollType, roll, isWinner, isMe`:
- `name` — the roller's name (from the NameCache).
- `class` — the class token (`"WARRIOR"`, `"MAGE"`, …) for
`RAID_CLASS_COLORS`, or `nil` if unknown.
- `rollType` — `0` = pass, `1` = need, `2` = greed.
- `roll` — the `1`–`100` roll, or `0` if they passed.
- `isWinner` — `true` if this player won the item.
- `isMe` — `true` if this roller is the local player.
Returns nothing for an out-of-range item or player index.
## LossOfControl
Backports `C_LossOfControl` — the active crowd-control / interrupt effects on
+90 -17
View File
@@ -1218,6 +1218,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
@@ -2279,7 +2315,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
@@ -3452,6 +3499,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
@@ -3814,6 +3870,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
@@ -3880,23 +3960,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
@@ -4650,6 +4722,7 @@ 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,
+64
View File
@@ -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,
+30
View File
@@ -150,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)
+60
View File
@@ -186,6 +186,65 @@ int __fastcall Script_HookScript(void *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
@@ -222,6 +281,7 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
Offsets::FUN_SCRIPT_FRAME_HIDE>},
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
+466
View File
@@ -0,0 +1,466 @@
// 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);
}
// 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 {
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;
char winnerName[kNameLen];
bool hasWinner;
bool isDone;
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;
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) {
HistItem *e = Find(sourceGuid, slot);
if (e != nullptr) {
if (itemId != 0)
e->itemId = itemId;
return e;
}
e = &g_items[g_write];
g_write = (g_write + 1) % kMaxItems;
if (g_count < kMaxItems)
++g_count;
*e = HistItem{};
e->used = true;
e->sourceGuid = sourceGuid;
e->slot = slot;
e->itemId = itemId;
return e;
}
// 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);
int slotIdx = -1;
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == roller) {
slotIdx = i;
break;
}
}
if (slotIdx < 0 && e->playerCount < kMaxPlayers) {
slotIdx = e->playerCount++;
const uint8_t *rec = LookupPlayerRecord(roller); // cache loaded now
e->players[slotIdx].guid = roller;
CopyName(e->players[slotIdx].name, NameFromRecord(rec));
e->players[slotIdx].classId = ClassIdFromRecord(rec);
}
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;
CopyName(e->winnerName, NameFromRecord(LookupPlayerRecord(winner)));
e->hasWinner = true;
e->isDone = true;
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;
// 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).
void __fastcall Start_h(void *packet) {
g_origStart(packet);
RecordStart();
}
// 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).
void __fastcall Roll_h(void *msg, void *ctx, int a3) {
int itemIdx = 0, playerIdx = 0;
if (msg != nullptr)
itemIdx = RecordRoll(msg, &playerIdx);
g_origRoll(msg, ctx, a3);
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;
if (msg != nullptr)
itemIdx = RecordWinner(msg);
g_origWon(ctx, msg, a3);
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
void __fastcall Passed_h(void *ctx, void *msg) {
int itemIdx = 0;
if (msg != nullptr)
itemIdx = RecordAllPassed(msg);
g_origPassed(ctx, msg);
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)` -> itemLink, numPlayers, isDone, winner.
// 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; `winner` is the winner's name, or nil if undecided.
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::PushString(L, link); // 1 itemLink
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 2 numPlayers
Game::Lua::PushBool(L, e->isDone); // 3 isDone
if (e->hasWinner && e->winnerName[0] != '\0')
Game::Lua::PushString(L, e->winnerName); // 4 winner
else
Game::Lua::PushNil(L);
return 4;
}
// `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;
}
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);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Loot::History
+76 -17
View File
@@ -62,6 +62,19 @@
// 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:
@@ -102,6 +115,17 @@ using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line,
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 {
@@ -109,6 +133,7 @@ struct ResolvedOverlay {
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];
};
@@ -161,13 +186,29 @@ void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
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] < 256) {
if (out->fileW[i] < S) {
cols = i + 1;
widthEdge = true;
break;
@@ -179,7 +220,7 @@ void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
int rows = dbcRows;
bool heightEdge = false;
for (int i = 0; i < n; ++i) {
if (out->fileH[i] < 256) {
if (out->fileH[i] < S) {
rows = i / cols + 1;
heightEdge = true;
break;
@@ -304,27 +345,45 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
posX = offX + col * drawW;
posY = offY + row * drawH;
} else {
// Canonical 256px grid. Edge tiles draw their content pixels:
// from the DBC remainder when it's honest, else (sliver
// column the DBC rounded away) the whole file.
// 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];
int pixW = 256;
int pixH = 256;
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) {
pixW = dbcW - 256 * (r->cols - 1);
if (pixW <= 0 || pixW > fileW)
pixW = fileW;
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) {
pixH = dbcH - 256 * (r->rows - 1);
if (pixH <= 0 || pixH > fileH)
pixH = fileH;
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 = pixW;
drawH = pixH;
tcX = static_cast<double>(pixW) / fileW;
tcY = static_cast<double>(pixH) / fileH;
drawW = logicalW;
drawH = logicalH;
posX = offX + col * 256;
posY = offY + row * 256;
}
+24 -4
View File
@@ -133,6 +133,26 @@ void StampChannel(int spellID, int startMs, int endMs) {
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);
}
@@ -200,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
@@ -230,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);
+48 -31
View File
@@ -46,6 +46,34 @@ 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.
@@ -71,46 +99,35 @@ int __fastcall Script_Dismount(void *L) {
if (MountDisplayID(desc) == 0)
return 0;
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] != 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 = 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;