Compare commits

...
109 Commits
Author SHA1 Message Date
brues-codeandJared Reni 99de63755b CreateFrame("Cooldown") (#106)
* feat(frame): CreateFrame("Cooldown") builds vanilla's cooldown Model

Later clients have a native Cooldown frame type; vanilla draws cooldown
sweeps with a Model from FrameXML's CooldownFrameTemplate, driven by
CooldownFrame_SetTimer, and its CreateFrame rejects the type name
("Unknown frame type 'Cooldown'"). Ported code writes
CreateFrame("Cooldown", name, parent, "CooldownFrameTemplate") and then
calls CooldownFrame_SetTimer - Zygor Guides Viewer 2.0 does it while
building its guide window, which stopped there.

In the CreateFrame hook, build a "Cooldown" request as "Model", defaulting
the template to CooldownFrameTemplate when none is given.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
CreateFrame("Cooldown", nil, UIParent, "CooldownFrameTemplate") returns a
Model, and Zygor's guide window builds.

* feat(frame): give the Cooldown frame 4.3.4's methods

4.3.4's CGCooldown_Scripts registers SetCooldown, SetReverse,
GetReverse, SetDrawEdge and GetDrawEdge. The Cooldown frame built by
the CreateFrame hook now has the same five: SetCooldown takes start
and duration in seconds and errors as 4.3.4 does when either is not a
number, the setters read their value as 4.3.4's StringToBOOL does,
and both flags start false.

The hook marks each frame it builds as a Cooldown. The methods keep
their state per marked frame, SetCooldown drives the sweep through
CooldownFrame_SetTimer, and a marked frame reports its type as
"Cooldown" from GetObjectType / GetFrameType and answers
IsObjectType("Cooldown"). Every other frame delegates to the engine's
own type methods.

The reverse and draw-edge flags are stored and reported, but do not
change how the sweep draws: the sweep has no reversed form and no
edge. The five methods sit on the Model registry, so a Model that was
not built as a Cooldown raises the wrong-object-type error when one
is called on it.

---------

Co-authored-by: Jared Reni <jaredreni@gmail.com>
2026-10-18 16:39:34 -05:00
Brues 7cd891e86c fix(bindings): follow 4.3.4's bindings, modified clicks and key state
Modified clicks: IsModifiedClick, GetModifiedClick, SetModifiedClick,
GetNumModifiedClickActions and GetModifiedClickAction, with 4.3.4's
<ModifiedClick> Bindings.xml node and its 18 defaults. Each binding set
loads, saves and copies them through co-hooks on the engine's own
binding-set steps, in 4.3.4's "modifiedclick" file lines. [mod:]
asks IsModifiedClick, as 4.3.4's macro conditions do.

Direct actions run as 4.3.4's ExecKey runs them: SPELL / ITEM / MACRO
on key release through 1.12's spellbook, item and macro functions,
cast at you for SELFCAST and at your focus for FOCUSCAST; CLICK sends
the frame a mouse down and up, with 4.3.4's virtual-click button logic
built from the button's own methods. RunBinding no longer runs them.

While a key binding runs, its own modifiers count as released for
every modifier read, the engine's IsKeyDown included.

Overrides follow 4.3.4: two lists per key, checked inside the key
lookup at both of 1.12's lookup steps; GetBindingAction takes
checkOverride; isPriority is loose; "" removes; the owner must be a
frame.

ActionButtonUseKeyDown (default 0) and 4.3.4's action-button key
handlers, ported onto 1.12's bars.
2026-10-18 15:11:02 -05:00
Brues 6e50577565 fix(target): follow 4.3.4's TargetNearest and TargetDirection
TargetNearest, TargetNearestEnemyPlayer and TargetNearestFriendPlayer
now run the engine's own tab cycle (FUN_00493F60), which is 4.3.4's
TargetNearestEnemy: units in front first, the targetNearestDistance /
targetNearestDistanceRadius ranges, no critters or unseen units, and
the 3 s / 1 s snapshot shared with the native functions. A co-hook on
its predicate adds 4.3.4's types 0, 2 and 4 as three new modes.
TargetNearest now cycles any unit, as in 4.3.4.

TargetDirectionEnemy / TargetDirectionFriend are now 4.3.4's
CGGameUI::TargetDirection, built on 1.12's GetScreenCoordinates, the
camera's forward vector and the tab search's tests: facing is a screen
direction relative to the camera, the search starts at the target when
it is on screen, the cone is at least 22.5 degrees, and a hit locks
further calls until the new TargetDirectionFinished().
2026-10-18 14:20:26 -05:00
Brues 024c6d2021 feat(chat): prefix %t with the target's raid marker, as 4.3.4 does
4.3.4's chat parser prefixes both of its name codes, %f and %t / %n,
with {rtN} when the unit carries a raid marker and a name was found.
1.12's %t already resolves the name the same way, so the hook only
writes the marker in front of the %t pair and the engine still fills
in the name.

Rename Chat::FocusToken to Chat::ChatTokens; %f and %t share the name
lookup and the marker helper.
2026-10-18 13:56:54 -05:00
Brues 4b47059788 feat(debugtools): build /fstack with GameTooltip:SetFrameStack
Replace the Lua EnumerateFrames walk with the engine method, as
Blizzard_DebugTools does. /fstack also accepts "true" for hidden frames.
2026-10-18 13:38:14 -05:00
Brues 1aa5a07823 feat(tooltip): add GameTooltip:SetFrameStack, as 4.3.4 does
Mirror 4.3.4's CGTooltip::SetFrameStack: walk the engine frame list,
hit-test the layout rect against the cursor, name each frame the way
4.3.4 does, sort by strata and level, and fire the new
OnTooltipSetFrameStack script before Show. Every input already exists
in 1.12; the new offsets are verified against the binary.

GetMouseFoci now replays the engine's mouse-focus pass instead of
calling Lua getters: it walks the per-strata mouse input queues and
calls the engine's hit test, so hit-rect insets and scroll clipping
apply as they do for GetMouseFocus.

Also fix the FUN_REGION_GET_RECT comment: the rect is
{bottom, left, top, right}, not {top, left, bottom, right}.
2026-10-18 13:38:14 -05:00
Brues 00f32e0e2f feat(xml): load <Attributes> on frames, as 4.3.4 does
4.3.4's CSimpleFrame::LoadXML reads an <Attributes> child through
LoadXML_Attributes; 1.12's frame loader (FUN_XML_FRAME_LOAD) walks its
child elements and silently skips the unknown one, so ported templates
lost every attribute. Each <Attribute name= type= value=> is now set
the way 4.3.4 sets it: type nil/boolean/number/string (default),
converted with the loader's own helpers (FUN_XML_ATTR_TO_BOOL, new
FUN_SSTR_TO_FLOAT), stored without firing OnAttributeChanged (through
our SetAttributeNoHandler, so unit/type* behave as when set from Lua),
and malformed entries logged through the loader's status sink with
4.3.4's warning texts (VTBL_XML_STATUS_ADD, level 1).

FUN_XML_FRAME_LOAD was already hooked by Xml::ParentKey, and MinHook
takes one hook per address, so Xml::FrameLoad now owns it and fans out
after the engine loads each node (the RunnerHook / WorldTick pattern).
ParentKey's frame binds and the attribute loader subscribe; its node
helpers and loader-safe wrapper push moved there too.
OFF_COBJECT_LUA_REGISTRY_REF duplicated OFF_COBJECT_LUA_REF (both 0x08)
and is folded into it.

Verified in-game with a test addon: template attributes inherited and
overridden by the frame's own, number / boolean / nil types, a mixed-
case name, values present by OnLoad, no OnAttributeChanged on load, and
the three FrameXML.log warnings.
2026-10-18 05:17:22 -05:00
Brues 5537432662 docs(frame): describe 4.3.4's attribute and mouseover behavior
OnAttributeChanged takes (self, name, value) and nested changes fire
their handlers; mouseover follows SecureButton_GetModifiedUnit; the
3-arg GetAttribute and SetAttribute's required value. Drops the
implementation paragraphs.
2026-10-18 05:10:05 -05:00
Brues 5962ccc83d fix(frame): follow 4.3.4's attribute system and unit-frame mouseover
Compared against 4.3.4's CSimpleFrame attribute code and
CGWorldFrame::OnLayerUpdate:

- OnAttributeChanged gets (self, name, value) as real arguments, like
  4.3.4's RunOnAttributeChangedScript, plus the this/arg1/arg2 globals.
  It used to fire through the no-argument invoker with globals only, so
  a modern function(self, name, value) saw name == nil. The engine's
  runners only carry %d/%s values, so Frame::ScriptArgs gains
  RunWithValues for arguments already on the Lua stack.
- No recursion guard: a handler that sets attributes fires their
  handlers too, as in 4.3.4. The guard silenced every nested
  OnAttributeChanged on every frame.
- SetAttribute requires the value argument; the 3-arg GetAttribute form
  needs exactly four arguments and treats a nil prefix/suffix as empty;
  usage errors name the frame like the engine's (GetName ?: <unnamed>).
- The mouseover unit of a hovered frame comes from FrameXML's
  SecureButton_GetModifiedUnit, pcalled under the engine's error
  handler, as 4.3.4 does, so unitsuffix and modifier-qualified unit
  attributes count. 4.3.4 asks protected frames; with no protection in
  1.12 the analog is a frame that has attributes. The mouseover tooltip
  stays (4.3.4 shows it only over the world frame).
- SecureStateDriver.lua is 4.3.4's file again: it drives itself through
  SecureStateDriverManager attributes and reads OnUpdate's elapsed,
  both of which the guard and the old no-argument handlers had ruled
  out.
- Game::Lua::ObjectLuaRef / PushObject / ObjectDisplayName replace the
  copies in ScriptArgs and Modern.

Verified in-game: handler arguments and globals, a nested handler
firing, both usage errors, the nil-prefix lookup, mouseover following
shift-unit / unit, and a visibility state driver.
2026-10-18 05:09:25 -05:00
Brues f5acea6060 fix(coroutine): follow 4.3.4's coroutine library
Compared against 4.3.4's stock Lua 5.1 co_funcs (lbaselib.c), opened by
luaopen_base in FrameScript_Initialize:

- status: a coroutine mid-resume of another one is "normal", not
  "suspended". Test what the engine's own resume body (0x006F67B0)
  tests - ci > base_ci AND CI_YIELD on the CallInfo - instead of
  ci > base_ci alone. Resuming it now fails with 5.1's "cannot resume
  normal coroutine".
- running: push the thread with the exec-context field set, as every
  engine push does; it was left as whatever the stack slot held, which
  lua_pushvalue copies into the script context. The main thread is read
  from G->mainthread (+0x50, set by f_luaopen) instead of an L captured
  at registration.
- Registered on the glue state too.
- 5.1's error texts ("cannot resume %s coroutine"); too many arguments
  or results raise, and the results check reserves the slot for true.
- wrap re-raises a non-string error object unchanged through the raw
  lua_error (LUA_RAW_ERROR, 0x6F4440); a string still gets luaL_where.

Verified in-game: "normal" status and its resume error, the lifecycle
and running(), an error in the body is not read as a yield, and the
wrap error text.
2026-10-18 04:47:53 -05:00
Brues a86f1219c3 fix(frame): follow 4.3.4's HookScript, so a hooked handler keeps its args
HookScript's HookThunk was a C closure, which Frame::ScriptArgs treats as
wanting no positional args, and it called the old handler with none - so
a modern SetScript("OnShow", function(self) ... end) saw self == nil as
soon as anything hooked it. It also padded the hook to 9 args and only
detected OnEvent by exact case.

Follow 4.3.4's FrameScript_Object::HookScript instead:
- an unset script gets the hook itself, so GetScript returns it;
- a set one gets the same wrapper hooksecurefunc installs (4.3.4 uses
  FrameScript_HookWrapper for both), exposed via HookSecureFunc.h: the
  old handler and the hook get the script's own args, and a hook error
  goes through the engine's script error handler.
ScriptArgs' HandlerWantsArgs looks inside that wrapper (C function
compare, upvalues at CClosure +0x10 per lua_pushcclosure), so a hooked
script takes the positional path only when either side declares
parameters. The usage error names the frame as the engine does:
vtable+4 GetName, else its "<unnamed>".

Verified in-game: old and hook both get self; OnEvent gets (self, event)
with no padding; an unset script stores the hook; a three-deep chain;
a hook error is reported after the old handler ran; both error texts.
2026-10-18 04:34:15 -05:00
Brues 338c6777d6 feat(hooksecurefunc): follow 4.3.4's hooksecurefunc
Compared against 4.3.4's Script_hooksecurefunc / FrameScript_HookWrapper
(taint bookkeeping aside, which 1.12 has no counterpart for):

- The hook runs under lua_pcall with the engine's frame-script message
  handler as errfunc, so a broken hook is reported like any script
  error instead of being dropped silently. The original's results still
  reach the caller.
- The wrapper is installed with lua_rawset, so a target's __newindex
  never runs. The original is still read with lua_gettable, so an
  inherited method can be hooked.
- Registered on the glue state too: 4.3.4 registers it from
  FrameScript_Initialize, which runs for both states.
- 4.3.4's error texts, and its lua_isstring name check, so a number
  name works.
- _G is pushed and inserted at index 1, so both call forms share one
  layout. The comment saying pushvalue(GLOBALS_INDEX) crashes was
  written in the commit that fixed LUA_PUSH_VALUE (it pointed at
  lua_remove); the engine's lua_pushvalue resolves the pseudo-index.

The 11.0 unhookable-name list stays. lua_rawgeti moves into Game::Lua
now that ScriptArgs is not its only caller.

Verified in-game: a hook error is reported and T(3) still returns 3 6;
__newindex does not run and an __index method hooks; a number name
hooks; all three error texts.
2026-10-18 04:24:19 -05:00
Brues aeb3bdf07b feat(playercache): resolve GetPlayerInfoByName through the engine's name search
C_PlayerCache.GetPlayerInfoByName read only the persistent NameCache,
so it returned nothing for every name while that cache was off, even
for a player standing next to you.

It now resolves the name to a GUID first (Player::Info::GuidFromName):
the engine's own by-name search behind TargetByName (party and raid
members, including out-of-range ones, then every visible player), then
the friends list, then the persistent cache. The result is built from
that GUID by the same code as GetPlayerInfoByGUID.

The engine search is wrapped once as Unit::Identity::FindGuidByName,
which UnitTokenFromName now uses too.
2026-10-18 03:59:50 -05:00
Brues 1caf9fde18 feat(luasyntax): give strings 4.3.4's shared metatable
4.3.4 ships stock Lua 5.1, whose createmetatable gives every string one
metatable { __index = string } in G(L)->mt[LUA_TSTRING]. 1.12 differs in
one line of each reader: lua_getmetatable returns 0 for a non-table, and
luaT_gettmbyobj returns nil. Co-hook both so a string reads a per-state
metatable instead, and drop the index-continuation hook that answered
with the string table directly.

getmetatable("") now returns the metatable, so libraries that extend it
(StatLogic: getmetatable("").__index.trim = trim) work, and a replaced
or function __index resolves through the engine's own code. A method
lookup costs what 4.3.4's does: luaH_getstr for "__index", then the
lookup in string.

Verified in-game: getmetatable("").__index == string, methods, nil on a
missing key, extending __index, a function __index, the write / call /
arithmetic errors, tostring, and the kill switch.
2026-10-18 03:53:26 -05:00
SkeewirtandBrues d496ff2aa7 feat(string): string.trim and string.join, as later clients expose them (#94)
* feat(string): string.trim and string.join, as later clients expose them

Later clients put strtrim / strjoin on the string table too (string.split
was already registered), so (" x "):trim() works and libraries capture
them at file scope - AceConfigCmd-3.0 does strtrim = string.trim and then
calls it for every toggle/slash command ("attempt to call a nil value").

Not verified in-game yet: these register the existing strtrim / strjoin
functions a second time, under the string table. The testkit check for
them (string methods, the chars argument, the globals agreeing) runs at
the next login, and I'll add the result here.

* docs(string): document string.split and cite 4.3.4's compat.lua

string.split was registered but undocumented. 4.3.4's compat.lua aliases
all three str* globals onto the string table (str.trim / str.split /
str.join), so name it as the source and list the three the same way.
strsplit takes the separator first, so its method form is called on the
separator.

* fix(string): bind the str* globals to their string-table functions

Each Register* call mints its own closure, so registering one C function
as both strtrim and string.trim gave two values and strtrim ~= string.trim.
4.3.4's compat.lua binds each pair by assignment (gsub = str.gsub, strrev
= str.reverse, str.trim = strtrim, str.split = strsplit, str.join =
strjoin), so the globals now alias the string-table entries by value via
RegisterGlobalAlias, as tinsert already does. Verified in-game:
strtrim == string.trim and ClassicAPI.strtrim == strtrim.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-18 03:32:31 -05:00
Brues 8b96fccfe9 refactor(addons): pass an addon file's name and table as real arguments
4.3.4 hands each addon file (addonName, addonTable) as real call
arguments: LoadAddOn puts one table on the stack and
FrameScript_ExecuteBuffer's wrapper path pcalls every file of the addon
with the name and that table. We injected a source preamble instead,
`local arg={"<Name>", __addonns("<Name>"), n=2};`, backed by a gated
global.

An addon file that uses `...` now compiles as a vararg function, and
the file loader gets a C closure that calls it with the name and the
addon's table. The __addonns global and its one-shot gate are gone.
Only the immediate-run file funnel gets the closure, never loadstring,
whose chunkname is forgeable; GetAddOnLocalTable reads the same table.
2026-10-18 03:16:06 -05:00
Brues 9d3bdfb6ad feat(focus): follow 4.3.4's focus, and give every unit event to focus, nameplates and marks
Every unit event the engine fires now reaches focus, nameplateN and
markN, as 4.3.4 does for its focus: the unit gets a script-registration
bit, so the engine watches its fields, and whatever the engine fires
for the unit fires for the token too.

- A co-hook on the registration recompute (FUN_0060c520) keeps bit
  0x8000 on units with one of these tokens. Post-init runs it too, so a
  unit coming back into range is watched again. 0x2000 is SuperWoW's
  and WeirdUtils' sparkle bit on every unit.
- nampower replaces the broadcast (0x00515E50) and the combat feedback
  (0x00494600) with detours that never call the original, so neither
  can be co-hooked. Their 38 + 4 CALL/JMP sites, which no loaded DLL
  touches, are redirected to functions that call the original address
  and then fire for the unit's tokens. Sites are found by a .text scan
  and patched only when the counts match Ghidra's xrefs.
- The tokens gain UNIT_COMBAT, UNIT_NAME_UPDATE, UNIT_PORTRAIT_UPDATE,
  UNIT_MODEL_CHANGED, UNIT_CLASSIFICATION_CHANGED and group-stat
  updates. TokenObserver now serves only focustarget: with SuperWoW
  every unit is engine-watched, so an observer would double-fire.

Focus drop rules follow 4.3.4 where 1.12 has the data: out of range is
kept for you, your pet, a party or raid member or their pet (the
engine's own target rule); death while not friendly, or arrival already
dead and not friendly; becoming not selectable. Death and the flag act
on the change. 1.12 has no duel exception and no SMSG_BREAK_TARGET.

%f in sent chat (4.3.4's ChatParserParseText) becomes the focus's name,
with a {rtN} prefix when marked, or FOCUS_TOKEN_NOT_FOUND.

The .text bounds move into Offsets.h (shared with interface/Export).
The focus still survives /reload; focustarget events stay as a
ClassicAPI extension.
2026-10-18 02:54:02 -05:00
Brues 42d037f080 fix(text): lay out inline-icon lines the way 4.3.4 does
Chat lines with icons ran past the frame edge after 47b258a. An icon
now starts at the previous glyph's ink edge, but every measure counts
that glyph by its advance, so a glyph whose cell is wider than its step
("[" in chat) pushed each following icon and the rest of the line past
the wrap budget. Cap the ink edge at the glyph's base advance.

Match 4.3.4's CGxString layout for the rest:

- Every wrap sizes icons with the fontstring's layout scale, as 4.3.4
  passes m_layoutScale to each CalcWrapPoint. The height getter, the
  rebuild's truncation and the break array set a fontstring scope the
  wrap stepper reads (new co-hook on the break array).
- A line whose tallest icon passes the font height moves itself and the
  later lines down by the overflow (InitializeText), and the origin
  finalize justifies the block with the summed extra
  (InitializeViewTranslation), via a co-hook on FUN_005CDF70.
- GetStringHeight sums each wrapped line's overflow
  (GxuFontGetWrappedTextHeight) instead of adding the tallest icon once.
- A link's hit band grows toward where its icon reaches, by the vertical
  justify.
2026-10-18 01:26:55 -05:00
Brues af4a379311 feat(equipmentset): follow 4.3.4's equipment manager
UseEquipmentSet now behaves like 4.3.4's CGEquipmentManager:
- in combat only the weapon slots swap (one COMBAT_SWAP message);
- missing items and full bags report through UI_ERROR_MESSAGE;
- bank items are used only while the bank is open, and then equip
  straight from the bank;
- a swap in flight or an item on the cursor refuses the call.

EQUIPMENT_SWAP_FINISHED now fires when the swap settles instead of
during the call: success when the paperdoll matches, failure on an
SMSG_INVENTORY_CHANGE_FAILURE for one of the moved items, or when the
paperdoll stops changing short of the target. Its failure value is nil,
not 0. CanUseEquipmentSets and the set buttons are blocked meanwhile.

GetEquipmentSetInfo uses 4.3.4's GetSetStatus counts and strict
IsSetEquipped; GetItemIDs and GetItemLocations return all 19 slots with
the 0/1/-1 markers; set buttons also grey when a replaced item is
locked. Set names go through a port of ScrubString. Item tooltips get
the "Equipment Sets:" line (dontShowEquipmentSetsOnItems hides it).
2026-10-18 00:24:00 -05:00
Brues 2b9d0e7127 fix(player): fire HEARTHSTONE_BOUND on SMSG_PLAYERBOUND
The server re-sends SMSG_BINDPOINTUPDATE at login and on every map
change, which needed two gates to filter out. SMSG_PLAYERBOUND (0x158)
is sent only from the bind spell effect, right after the bind-point
update, so it needs no filtering. 1.12 handles it in the shared player
packet handler FUN_005E38C0.

Subscribe through Net::PacketDispatch instead of hooking the bind-point
handler, which drops one MinHook and the two unused offsets.
2026-10-17 23:46:39 -05:00
SkeewirtandBrues a68fb0a6be fix(frame): keep a scroll child clipped when its ScrollFrame moves (#60)
* fix(frame): keep a scroll child clipped when its ScrollFrame moves

A frame a ScrollFrame draws carries a "scrolled" flag (0x2000 at +0xB4):
the strata pass skips it and the ScrollFrame draws it inside a viewport
cut to its rect. SetScrollChild sets the flag through the setter at
0x0076A400, which also marks the scroll child (0x4000) and recurses into
child frames. SetParent sets a moved frame's flag from its new parent
through the same setter, and the recursion doesn't stop at a ScrollFrame,
so moving a frame that holds a ScrollFrame under an unscrolled parent
cleared the flag on the scroll child and everything in it. The strata
pass then drew them whole, past the ScrollFrame's edges.

AceGUI-3.0 builds each widget under UIParent and then moves it into its
container, so every AceGUI ScrollFrame spilled: AceConfigDialog-3.0's
pages drew below the Interface Options window (61 frames under Zygor
Guides Viewer 2.0's "Display" scroll child had lost the flag).

Hook the setter to keep a frame marked as a scroll child scrolled when a
value is only being passed down (third argument -1). SetScrollChild's
own calls are unchanged.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
AceConfigDialog-3.0's Interface Options pages clip and scroll inside
their box; on the "Display" page no frame under the scroll child lacks
the flag any more (61 did before), and a ScrollFrame built under
UIParent and then moved keeps its content, its content's children and
a child added afterwards scrolled.

* docs(frame): describe ScrollFrame clipping without version history

State what stays clipped and when, with AceGUI-3.0 as the case it
serves. Remove the vanilla comparison and the engine flag explanation;
the comment in src/frame/ScrollChildren.cpp already records them.

* fix(frame): skip scroll children the way 3.3.5's setter does

3.3.5's CSimpleFrame::SetBeingScrolled (0x00490F60) does not pass the
scrolled value into a child frame marked as the scroll child, and its
direct call is not gated: a scroll child that is itself given a new
parent takes the new parent's value. The hook forced any scroll child
scrolled on a pass-down call, which also kept a directly reparented
scroll child clipped to its old ScrollFrame.

Skip a scroll child only inside the setter's own recursion, told apart
by a depth counter (the setter is its only nested caller). SetParent's
and SetScrollChild's direct calls run unchanged.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 20:40:56 -05:00
SkeewirtandBrues 721f32ac77 feat(map): keep the closed world map on the player's zone (#71)
* feat(map): keep the closed world map on the player's zone

GetPlayerMapPosition("player") reads the map the world map is set to,
open or closed, and returns 0,0 off it. The 3.x FrameXML resets that
map to the player's zone in WorldMapFrame_OnHide and in WatchFrame's
PLAYER_ENTERING_WORLD and (map closed) ZONE_CHANGED_NEW_AREA handlers;
vanilla has neither, so after zoning the closed map stays on the old
zone. Do the same resets from the bundled addon. Zygor Guides Viewer's
travel steps read the player's position this way.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
after logging in, the closed world map is on the player's zone (Darkshore)
and GetPlayerMapPosition("player") returns the player's position there.
Not checked yet: changing zones with the map closed (the
ZONE_CHANGED_NEW_AREA reset) and closing the map on another zone (the
OnHide reset).

* docs(map): describe the closed world map without version history

State what the map does and when it goes back to the player's zone,
and add that a zone change does not move the map while it is open.
Remove the 3.x and vanilla comparison and the FrameXML handler names;
the code comment in Util/WorldMap.lua already records them.

* docs(readme): keep Highlights for major features

Single-behavior fixes (faction while loading, <Script file> paths, the
closed world map, the quest completed line, SetParent frame levels,
frame alpha, OnSizeChanged sizes) each had a row in the Modern client
behaviors table. Remove those rows and point to docs/API.md, where each
fix keeps its own section.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 20:26:57 -05:00
Brues fcdc24471c fix(spell): latch remote channels on +0x228 like the player's
The broadcast +0x228 field lags another unit's channel start by up to
~1 s, and UnitChannelInfo returned nothing until it caught up. So the
UNIT_SPELLCAST_CHANNEL_START it fires from the SMSG_SPELL_START stamp
disagreed with the getter, and cast bars that poll on the event (pfUI
nameplates) missed the channel whenever the field was late.

Mirror the player's confirmed latch per remote channel: the packet stamp
stands until the field has shown the spell, and a confirmed field that
clears before endMs is the channel ending early. That drops the entry and
fires CHANNEL_STOP, where it used to run out the full computed duration
(no packet reports a remote interrupt on Turtle).
2026-10-17 20:10:37 -05:00
Brues 58f5b4f78e fix(spell): keep remote channel times through the SPELL_GO cast-slot clear
The engine's SPELL_GO handler calls ClearCastingSpell on the caster for
every spell, including a channel at the moment it starts. Our co-hook
treated that as an abort and evicted the SMSG_SPELL_START cache entry, so
UnitChannelInfo never had start/end times for another unit's instant
channel (bandages). ClearCastingSpell only ends the cast slot; a channel's
lifetime is the +0x228 field UnitChannelInfo already gates on, so that
route no longer evicts channel entries. The failure packets still do.

Fixes #102
2026-10-17 20:08:18 -05:00
Brues 6be4aac9fb feat(macro): resolve every macro's display the way 3.3.5 does
Compared Macro::ShowTooltip against 3.3.5's decompiled MACRONODE::Parse,
FindSpellID and GetFullIconPath (WoW/Source/Ui/UIMacros.cpp) and fixed
the differences:

- Every macro body is parsed, directive or not. A question-mark macro
  with no #show now shows its first action's icon, and its button
  follows that action's cooldown, count and usable state; the tooltip
  stays the engine's (Kind::Implicit). #show counts anywhere in the
  body, not only on line 1.
- "#showtooltip none" stops the parse: the macro keeps its own icon and
  holds no spell, instead of greying out.
- /castrandom and /userandom show their first entry (3.3.5 cuts the
  value at the first comma); they used to grey the button.
- /equip and /equipslot lines are collected and resolve to items only.
- Bare numbers 1..23 are inventory slots, the equipped bags included.

Edits now always end in a repaint, as 3.3.5's EditMacro does: the engine
repaints a macro's buttons straight after its parse, before our rescan,
so a re-parsed body is marked and repainted on the next tick, and a
reader's catch-up defers its repaint instead of firing
ACTIONBAR_SLOT_CHANGED from inside a button handler.

Implicit macros are also parked across the unlearn sweep, so a
condition-picked value can't delete a macro from the bar.
2026-10-17 19:49:39 -05:00
Brues a6270469e1 refactor(chatbubble): move the bubble list offsets into Offsets.h
C_ChatBubbles.GetAllChatBubbles defined the engine's bubble list head
(0x00B6F038) and link offset (+0x318) as file-local constants. Move them
to Offsets.h as VAR_CHAT_BUBBLE_LIST_HEAD / OFF_CHAT_BUBBLE_LINK_NEXT,
documented as 3.3.5's CGChatBubbleFrame list and the engine walks they
mirror. No behavior change.
2026-10-17 19:30:16 -05:00
Brues a8f73623c4 perf(nameplate): enumerate the engine's shown-plate list, not every object
NamePlate::Walk walked the whole object hash table every frame (and on
every C_NamePlate.GetNamePlates call) to find units with a plate. 1.12
keeps the same shown-plate list as 3.3.5's s_namePlates (head
0x00C4D92C, next at plate+0x4E0), each plate carrying its unit's GUID
(+0x4E8). Walk that list and resolve each plate's unit by GUID, as the
engine's own NamePlateClearAll (FUN_0060F890) does; a plate still counts
only while unit+0xE60 points back at it.

PlateReassigned becomes one read of the plate's GUID instead of a scan of
the tick's snapshot. GetNamePlates now returns the engine's order
(farthest from the screen centre first).

The old comments claiming the list was unsafe to read and that release
callers "transiently zero +0xE60" are replaced: the list is re-sorted
only by main-thread engine code, every plate on it is bound, and every
FUN_00608A10 caller is a real release.
2026-10-17 19:27:45 -05:00
Brues 47b258ad95 fix(text): size, space and align inline icons by the engine's own rules
The inline-texture backport placed icons with hand-tuned constants: a
0.18 x font lead/trail pad, a 0.6 x font vertical centre, a -1px region
nudge, and written sizes taken as raw screen pixels. Replace all of them
with the rules from the 3.3.5 / 4.3.4 clients (decompiled; the size
formula's helpers confirmed against the real 4.3.4 binary):

- A written size multiplies by fontPx / (14 x ddcWidthScale), so it
  follows the text's size, the UI scale and the resolution, and then by
  the fontstring's layout scale, as 3.3.5 does. Size defaults follow
  ParseEmbeddedTexture: ":0:2" is a width ratio, ":20:0" is font-wide.
- An icon starts at the previous glyph's ink edge (bearing + cell width)
  plus the face's outline gap, found by replaying the run's placements
  with the emitter's own glyph helpers; the next glyph starts at the
  rounded icon edge.
- Vertical alignment follows the node's justify-V (node+0x50).
- Offsets move only the drawn icon; a positive offsetY now moves it up.

GetCoinTextureString now matches 3.3.5: default height 14, a space
between denominations, and retail's 2:0 coin offset in the strings.

The four Coordinate statics at 0x00832A40 get their real names
(VAR_COORD_DDC_WIDTH_SCALE and siblings). Known gap: the fs-level wrap
callers can't see the fontstring, so they budget icons at layout scale 1.
2026-10-17 19:14:17 -05:00
Brues ac6bd552d5 fix(addons): rebuild the file index with the engine's own clear on /reload
Rescan re-ran the boot indexer on two subtrees, which only adds files, and
skipped its whole run when the index's built latch was 0. The SavedVariables
unload pass clears that latch whenever it writes a new SV file, so on that
/reload no new folder, metadata change or display-array rebuild happened.
Call FUN_VFS_INDEX_CLEAR (0x00646FC0) instead - the clear the engine itself
uses after its own file writes - and drop the latch gate. The next lookup
rebuilds the index from disk, so deleted files drop out too.

AddOns::Toc::FindValue now matches lines the way the TOC parser does: skip
a UTF-8 BOM, break lines at CR or LF, and accept any spaces after "##"
before the key, which must be followed directly by ':'. Callers pass the
bare key.

Also correct the notes that said a first-ever SavedVariables file stays
unreadable until restart; the engine's unload pass already handles it.
2026-10-17 18:42:51 -05:00
SkeewirtandBrues 2a05baee8d feat(cvar): GetCVar returns nil for an unknown cvar (#82)
* feat(cvar): GetCVar returns nil for an unknown cvar

Later clients return nil from GetCVar for a name they do not know; vanilla
raises "Couldn't find CVar named '<name>'". Ported code uses the nil as
a feature probe - LibCamera-1.0 opens with
if not GetCVar("cameraYawE") then return end, which throws at file load
on vanilla.

Co-hook Script_GetCVar: a string name that FUN_FIND_CVAR (the lookup
Script_GetCVar and C_CVar.DoesCVarExist share) does not find yields nil.
Everything else, including non-string arguments, runs the engine function
unchanged. The glue state registers the same function, so both match.

Verified in-game (1.12.1 client via VanillaFixes): GetCVar with an unknown
name returns nil at file load, without an error.

* feat(cvar): GetCVarDefault stops raising for an unknown cvar

Script_GetCVarDefault has the same isstring -> tostring -> FUN_FIND_CVAR
-> raise shape as Script_GetCVar, so it gets the same co-hook. Both now
return no values for an unknown name, matching 1.15.9 (`/dump` prints
"empty result" for each) instead of a single nil.

Docs: describe behavior only, without version or vanilla-gap notes.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 18:26:46 -05:00
SkeewirtandBrues 80ef0e2988 feat(lua): give modern XML handler bodies their implicit parameters (#84)
* feat(lua): give modern XML handler bodies their implicit parameters

Later clients compile an XML handler body as a function whose parameters
name the script's arguments - self, then elapsed / button, down /
event, ... and so on. Vanilla compiles it as a zero-parameter chunk and
publishes the frame and arguments only through the this / argN globals,
so a ported <OnLoad>self:Foo()</OnLoad> indexes a nil global.
Zygor Guides Viewer 2.0 has 85 such bodies; 13 fail at load.

For a chunk compiled by the XML script funnel FUN_00704C70 (return
address 0x00704C8C, engine-built chunkname "<Frame>:<Script>") whose body
reads self - or reads one of its script's parameter names and none of
this / argN - wrap it as return function(self, <names>, ...) ... end,
reusing the vararg path's materialize-the-inner-function shape.
Frame::ScriptArgs already pushes (self, arg1..argN) in that order. Vanilla
bodies are untouched, so FrameXML and 1.12 addons keep their globals and
no names are shadowed.

`event` alone doesn't trigger the wrap. Vanilla sets a global `event`
during OnEvent dispatch, so FrameXML's own
<OnEvent>ActionButton_OnEvent(event)</OnEvent> bodies (about 250) already
work; a body that also reads self still gets (self, event, ...), which
binds the same value.

Adds RET_LUA_SCRIPT_COMPILE (found by scanning FUN_00704C70 for the E8
call to luaL_loadbuffer; the same scan reproduces RET_LUA_FILE_COMPILE),
an XmlHandlerArgs transpile toggle, and a 6th _classicapi_TranspileStats
return counting wrapped handlers.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
probe XML bodies see self in OnLoad, elapsed in OnUpdate, button in
OnClick ("LeftButton") and event in OnEvent; Zygor's bodies load without
an error, and 112 of the 36,627 chunks compiled that session were wrapped.

* fix(lua): match 3.3.5's handler parameters; treat ... as a modern body

OnSizeChanged binds (self, w, h), as 3.3.5's wrapper does, not
(self, width, height); the table now names its 3.3.5 source.

A handler body with a ... expression is wrapped with its named
parameters. Lua 5.0 can't compile one, so only a body written for later
clients has it, and the plain vararg wrap gave an OnEvent body's ...
(self, event, args) instead of the arguments after event.

The docs list each script's names and drop the version history, and say
the values need SetModernScriptArgs on.

* fix(frame): pass OnSizeChanged the size in GetWidth/GetHeight units

FUN_FRAME_ON_SIZE_CHANGED (0x0076B660), the only code that fires
OnSizeChanged, pushed the frame's screen-rect size in raw layout units:
a 20 x 10 frame's handler saw (0.0092, 0.0046). 3.3.5 divides out the
frame's layout scale (OnFrameSizeChanged, 0x0048FA20) and converts with
the chain 1.12's own GetWidth uses (Game::InternalToPixel) before the
script runs.

A co-hook now does both: convert, then divide by the frame's effective
scale (OFF_REGION_EFFECTIVE_SCALE). Verified in game at a 0.91 UI
scale: (19.99993, 9.99997) against GetWidth/GetHeight's (20, 10).
Converting alone gave (18.2, 9.1). The vmethod uses the values for
nothing else, and neither stock FrameXML nor any installed addon reads
OnSizeChanged's arguments.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 16:27:11 -05:00
SkeewirtandBrues 5df5ce00cf fix(xml): resolve <Script file> subdirectory paths relative to the XML file (#80)
* fix(xml): resolve <Script file> subdirectory paths relative to the XML file

The XML file processor joins <Include file> onto the including file's
directory, but <Script file> only when the value has no backslash
(strchr at 0x006EE06E); a value with one is loaded from the game root.
So <Script file="widgets\AceGUIWidget-Button.lua"/> inside
Libs\AceGUI-3.0\AceGUI-3.0.xml fails with "Error loading
widgets\AceGUIWidget-Button.lua" - all 23 AceGUI-3.0 widget files in
Zygor Guides Viewer 2.0, and every other addon that embeds AceGUI-3.0.
Later clients resolve it relative to the XML file.

Co-hook the XML file processor (FUN_XML_LOAD_FILE) to track the file being
processed, and at the <Script> call into FUN_LUA_LOAD_FILE (return address
RET_XML_SCRIPT_FILE_LOAD) retry a backslash path that does not exist as
given against that file's directory, only when the joined file exists.
Root-relative paths that load on vanilla are unchanged. FUN_LUA_LOAD_FILE
is already co-hooked by AddOns::SavedVarsFirst, which now calls
Xml::ScriptPath::Resolve first.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
a probe addon's <Script file="sub\File.lua"/> loads from its XML file's
folder, and Zygor's AceGUI-3.0 option pages, built from those widget
files, work.

* refactor(xml): share the Lua file-loader hook; collapse .. in Script paths

FUN_LUA_LOAD_FILE now has one owner, Script::LoadFile, which runs path
rewriters and then suppressors. Xml::ScriptPath is a rewriter and
AddOns::SavedVarsFirst a suppressor, so neither module reaches into the
other's hook.

Xml::ScriptPath joins onto the XML file's folder with each \..\
collapsed first, as FUN_XML_LOAD_FILE does before its own joins; the
tracked pointer is the caller's uncollapsed path, so an XML file reached
through <Include file="..\x.xml"/> joined onto a path with .. in it.

The user docs no longer name later clients or what 1.12 does.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:52:58 -05:00
SkeewirtandBrues 1297b9522a feat(macro): accept a macro name in GetMacroInfo/EditMacro/PickupMacro/DeleteMacro (#96)
* feat(macro): accept a macro name in GetMacroInfo/EditMacro/PickupMacro/DeleteMacro

2.0+ clients take `index or "name"` in all four; vanilla raises
"Usage: DeleteMacro(index)" (etc.) for a name. Co-hook each script
function: a non-numeric string first argument becomes the macro's
1-based index via FUN_MACRO_NAME_TO_SLOT (the GetMacroIndexByName
resolver), and an unknown name becomes 0, which each original treats
like any out-of-range index (GetMacroInfo returns nils, the others do
nothing). Zygor Guides Viewer clears its scratch macros with
DeleteMacro("ZygorGuidesMacro" .. i) every time a step starts.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
an unknown name is a no-op in all four, and Zygor's DeleteMacro calls on
every step start run without an error. The known-name path hasn't run
in-game yet: the test character has no macros.

* docs(macro): say what EditMacro returns for an unknown macro name

An unknown name leaves EditMacro returning the first empty slot's index
(or 0 when every slot is full), the same as an out-of-range index and as
3.3.5; the section said all three non-GetMacroInfo functions "do
nothing". Also drop the patch-version mention.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:37:48 -05:00
SkeewirtandBrues b3188ced2a feat(tradeskill): add GetTradeSkillRecipeLink and IsTradeSkillLinked (#75)
* feat(tradeskill): add GetTradeSkillRecipeLink and IsTradeSkillLinked

GetTradeSkillRecipeLink(index) links the recipe in a trade-skill row as
an enchant: link carrying the recipe's spell ID, in the form the client's
own GetCraftItemLink (0x004F72A0) gives an enchanting formula
(|cffffffff|Henchant:%d|h[%s]|h|r, format at 0x0084D410), with the
spell's name in the client's locale. Rows are read as the engine's row
functions read them, so a header row (spell ID -1) returns nothing.
Vanilla's window only links the item a recipe makes.

IsTradeSkillLinked() returns nil: on this client the trade-skill window
and its functions only describe the player's own professions, since a
received trade: link opens ClassicAPI's separate viewer.

Zygor Guides Viewer 2.0 calls both on TRADE_SKILL_SHOW to record the
recipes the player knows.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
in the First Aid and Cooking windows, all 15 recipe rows link the row's
own spell (checked against GetSpellInfo) and header rows return nothing;
IsTradeSkillLinked() returns nil.

* fix(tradeskill): match 3.3.5's GetTradeSkillRecipeLink and show its links

GetTradeSkillRecipeLink now builds 3.3.5's link: gold, "Profession:
Recipe" (the profession from the engine's SkillLineAbility lookup
FUN_006de040, for the player's race and class), gated on
SPELL_ATTR_TRADESPELL. Empty names are pushed as the record holds them,
and the index is checked as a double before the cast, the way the
engine truncates and compares unsigned. IsTradeSkillLinked returns
(nil, nil), as 3.3.5 does. Both globals get doc descriptors.

1.12's SetHyperlink accepts enchant: only for records with +0x0C == 3,
which no trade-skill recipe has, and raises "Unknown link type" for the
rest. Tooltip::Hyperlink now shows the spell tooltip for a trade-spell
enchant: link, as 3.3.5 does. That also fixes clicking SuperWoW's
GetTradeSkillItemLink(index, 1) links; its SetHyperlink patch at
0x00532210 runs after this co-hook and handles only quest:.

SPELL_ATTR_TRADESPELL moves to Offsets.h now that two files use it.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:30:20 -05:00
SkeewirtandBrues f55893fdf4 feat(frame): Button:Set/GetNormalFontObject and Frame:IsProtected (#93)
* feat(frame): Button:Set/GetNormalFontObject and Frame:IsProtected

- button:SetNormalFontObject / GetNormalFontObject: 3.0's names for
  vanilla's SetTextFontObject / GetTextFontObject; delegate to them.
  Zygor Guides Viewer 2.0 and AceGUI-3.0 call the new names.
- frame:IsProtected() returns false, false: vanilla has no protected
  frames (the secure system arrived in 2.0). AceHook-3.0 asks before
  hooking scripts.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
Zygor sets its guide window's tab fonts with SetNormalFontObject while it
builds, without an error. Not yet checked: that the font set reads back
through GetTextFontObject and the reverse, and IsProtected's two values;
the testkit check for those runs at the next login.

* docs(frame): drop version and implementation notes from the new methods

The SetNormalFontObject and IsProtected sections named the patches the
APIs came from, said what 1.12 lacks, and described how the methods are
implemented. User docs describe the contract only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:06:45 -05:00
SkeewirtandBrues e0b2e56070 feat(frame): multiply frame alpha into child frames, as 3.x does (#70)
* feat(frame): multiply frame alpha into child frames, as 3.x does

A frame now draws at its own alpha times its parent's drawn alpha, and
GetAlpha returns the frame's own alpha. Vanilla keeps one alpha per
frame and copies it into the children that exist when SetAlpha runs:

- an XML frame with alpha="0" drew its child frames at full alpha,
  because the attribute is applied before its <Frames> are built;
- a child's own alpha was overwritten whenever its parent's changed;
- a frame created or reparented under a transparent frame drew opaque.

Zygor Guides Viewer's border glow is the first case. It is an
alpha="0.0" frame whose child holds the glow textures, so on vanilla the
glow covered the guide frame from login until the first step flash.

Co-hooks the engine's alpha setter, SetParent, the frame destructor and
frame GetAlpha. Own alpha lives in a table keyed by frame. The engine's
byte holds the drawn value, so region recolors and the Model family's
model alpha read it unchanged. Children are recomputed through their
own vtable setter, as the engine's copy-down calls it.

Vanilla code that shows a child under a transparent parent
(parent:SetAlpha(0); child:SetAlpha(1)) now leaves the child hidden, as
3.x does.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
Zygor's guide frame no longer shows its border glow from login. With own
alphas 0.5 / 0.8 / 1.0, a child and a grandchild draw at 0.4; the child
of an XML frame with alpha="0" keeps its own 1.0 and draws at 0; a child
keeps its own 0.5 while its parent goes to 0.2 and back to 1; a frame
with its own 0.6 draws at 0.3 under a 0.5 parent and at 0.18 once moved
under a 0.3 one.

* fix(frame): match 3.3.5's effective-alpha math and read it from the byte

- Multiply child alpha by truncating (a*b/255), as 3.3.5's
  CSimpleFrame::UpdateAlpha and GetEffectiveAlpha do, instead of
  rounding.
- GetEffectiveAlpha reads the frame's alpha byte, which Frame::Alpha
  keeps at the drawn value, instead of multiplying GetAlpha up the parent
  chain. This is 3.3.5's Script_GetEffectiveAlpha. Drops the now-unused
  FUN_SCRIPT_REGION_GETPARENT.
- Drop the reload cleanup of the own-alpha table. The frame destructor
  hook already removes each frame's entry, and a frame that outlives a
  /reload would have lost its own alpha.
- Rewrite the API.md frame-alpha and GetEffectiveAlpha sections as
  user-facing behavior.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 14:58:24 -05:00
SkeewirtandBrues 20dc9fc962 fix(frame): move a frame's children along when SetParent sets its level (#61)
* fix(frame): move a frame's children along when SetParent sets its level

Vanilla's SetParent gives the frame its new parent's strata and level + 1,
but calls the level setter without its children flag, so the frames under
it keep the levels they had. A panel built on its own and then moved into a
raised window draws its contents behind that window: Zygor Guides Viewer
2.0's AceConfigDialog-3.0 panel in the Interface Options window had its
widgets at levels 1-4 under a window at level 5.

Hook the level setter (0x0076A4F0) and pass the children flag on
SetParent's two calls, recognized by their return addresses. The setter
then moves each same-strata child by the same amount, recursively.
Script SetFrameLevel and the setter's other callers are unchanged.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
the AceConfigDialog-3.0 panel's widgets draw above the Interface Options
window, and none of its 18 frames sit below the window's container any
more. A frame with a child and a grandchild moved under a level-20 frame
sits at 21/22/23, and at 0/1/2 once moved to no parent.

* refactor(frame): patch SetParent's children flag instead of hooking the setter

The two `push 0` that pass the level setter's children flag from SetParent
become `push 1`, which is what 3.3.5's SetParent passes. This drops the
MinHook on the level setter, which 11 call sites share, and the
return-address checks. Each site's bytes are checked before the write, as in
Quest::CompleteMessage.

Docs: describe only the current behavior in API.md and the README row.

Verified in-game: a frame with a child and a grandchild moved under a
level-20 frame sits at 21/22/23, and at 0/1/2 once moved to no parent.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 14:08:03 -05:00
SkeewirtandBrues 51d226f67d feat(tooltip): SetHyperlink takes full links and spell:/quest: links (#68)
* feat(tooltip): SetHyperlink takes full links and spell:/quest: links

Vanilla's GameTooltip:SetHyperlink matches only a bare `item:` or
`enchant:` payload and raises "Unknown link type" for anything else,
including the full |cff..|H..|h[..]|h|r links 2.0+ GetItemInfo and chat
hand out. Co-hook it:

- a full link is reduced to the payload between |H and |h;
- spell:<id> builds the SetSpellByID tooltip;
- quest:<id> shows the cached title and objectives text (tokens
  expanded by the engine's quest-text substituter); an uncached quest
  leaves the tooltip empty and queues the quest query once;
- everything else runs the engine function on the payload.

Quest::Cache::Lookup gains `unique`, the engine's own dedup flag (the
last _GetRecord argument, previously passed as an unused 0), so a
tooltip redrawn every frame waits on one queued callback.

Zygor Guides Viewer reads quest titles with
SetHyperlink("|Hquest:<id>:1|h[q]|h") and item names with full links.

Verified in-game (1.12.1 client via VanillaFixes): on a
GameTooltipTemplate tooltip, a full Hearthstone link shows Hearthstone's
tooltip, "spell:133" shows Fireball's, and "quest:957" shows Bashal'Aran's
title and objectives.

* refactor(tooltip): share the C tooltip line helpers; tidy SetHyperlink

- Add tooltip/Lines.h (Clear / Add / Show over the engine's tooltip clear,
  raw add-line and Script_Show) and use it in place of the four local
  copies in Hyperlink, CompareItem, totem/Tracker and equipmentset/Tooltip.
  EquipmentSet keeps its exact colors as packed constants.
- SetHyperlink quest colors come from UI::ColorData (HIGHLIGHT_FONT_COLOR,
  NORMAL_FONT_COLOR) instead of hand-packed doubles.
- spell:<bad id> now clears the tooltip, like quest:<bad id>, instead of
  leaving the previous tooltip's lines.
- Rewrite the SetHyperlink API.md section in plain English without version
  or vanilla-gap notes.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 13:53:22 -05:00
SkeewirtandBrues eb3c32094c feat(pvp): add IsActiveBattlefieldArena (#77)
* feat(pvp): add IsActiveBattlefieldArena

Stock 1.12 has no arenas, so it answers nil there. Turtle-lineage clients
add arena matches, run in instances IsInInstance reports as "pvp": in a
"pvp" instance their scoreboard asks for arena scores over the hidden
TW_ARENA channel, only an arena answers, and the scoreboard keeps the
reply in WorldStateScoreFrame.arenaData until the world states update
outside an instance. isArena follows that state, as the scoreboard's own
arena view does (Octo's FrameXML, patch-4.mpq: WorldStateFrame.lua and
Turtle_ArenaUI.lua). isRegistered is always nil: the reply has no rated
flag, and the client's queue window only joins skirmishes.

Zygor Guides Viewer 2.0 calls it, unguarded, to decide whether to set
its corpse arrow when the player is dead outside a battleground.

Verified in-game (OctoWoW 1.12.1 client via VanillaFixes): outside an
instance it returns nil, nil. The arena branch has not been exercised in
a match yet.

* refactor(pvp): answer IsActiveBattlefieldArena from DBC data in C

The Lua version read Turtle's scoreboard state (WorldStateScoreFrame.arenaData),
which is nil until the server's TW_ARENA reply lands and depends on Turtle's
FrameXML. The client's own data already marks an arena: a pvp-type map
(Map.dbc instanceType 3) whose area carries AreaTable flag 0x80, the flag
Script_GetZonePVPInfo reports as isArena. Check the zone as well as the
subzone, since the arena maps' areas are top-level zones. The type-3 gate
keeps the open-world rings (Gurubashi, The Maul) out.

Returns booleans, as retail does; isRegistered is always false.

Offsets.h: add OFF_AREATABLE_FLAGS, AREA_FLAG_ARENA and
VAR_PLAYER_SUBZONE_AREA_ID, and correct the zone-change handler's second
argument (the subzone ID, not the parent area ID).

* docs(pvp): note the server only ever creates arena map 26

tortoise-wow's GetBattleGrounMapIdByTypeId sends its one arena type,
BATTLEGROUND_BR, to map 26 (Blood Ring); map 28 (Lordaeron Arena) is
client data only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 13:41:25 -05:00
SkeewirtandBrues 26ca1b4e84 feat(quest): print "<quest> completed." for chain quests too (#76)
* feat(quest): print "<quest> completed." for chain quests too

The client prints ERR_QUEST_COMPLETE_S on SMSG_QUESTGIVER_QUEST_COMPLETE
only for a quest with no next quest in its chain. The handler (and the
callback it leaves for a quest not yet cached) skips the message when the
cached record's NextQuestInChain (+0x24) is set, so a turn-in in the
middle of a chain printed only the experience and money lines. Zygor
Guides Viewer 2.0 completes a turn-in step only on that line, so its steps
for chain quests stayed open until the next login.

NOP the two `jne` over the message (0x005DC4DC, 0x005DC756), after
checking the bytes, so every turn-in goes through the engine's own
message path: same text, sound and CHAT_MSG_SYSTEM chat type, ahead of
the experience and money lines.

That later clients print the line for every turn-in is an inference, not
read from a later client's binary: the 3.3.5a build of Zygor Guides
Viewer completes chain turn-ins on this line the same way.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
mid-chain turn-ins (The Harvest Golem Mystery 40470, The Red Crystal
4811, As Water Cascades 4812, Bashal'Aran 956, Buzzbox 323 1002) print
"<quest> completed." and Zygor completes the turn-in step at once; a
quest at the end of its chain prints it as before.

* docs(quest): the completed line's order depends on the quest cache

When the quest record is not cached at turn-in, the callback 0x005DC710
prints the line on the record's arrival: after the experience and money
lines, and after QUEST_TURNED_IN. Stop promising a fixed order.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 13:13:52 -05:00
SkeewirtandBrues 7331922070 fix(build): quote the /DEF path so source trees with spaces link (#59)
* fix(build): quote the /DEF path so source trees with spaces link

CMAKE_SHARED_LINKER_FLAGS passes /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def
unquoted, so a checkout under a path containing a space (e.g.
C:\Projects\Classic API\ClassicAPI) splits the argument and the link fails
with LNK1181: cannot open input file 'API\ClassicAPI\src\ClassicAPI.def'.
Quote the path; the DLL output is unchanged.

Verified: Release/Win32 build now links from a path with a space.

* fix(build): list the .def as a target source instead of a /DEF flag

CMake now emits a quoted /DEF: on every generator, so the hand-escaped
path in CMAKE_SHARED_LINKER_FLAGS goes away, and the .def becomes a link
dependency: editing an export relinks the DLL instead of needing a clean
build.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 02:19:51 -05:00
SkeewirtandBrues 27d8f90f03 fix(unit): UnitFactionGroup("player") answers while addons load (#79)
* fix(unit): UnitFactionGroup("player") answers while addons load

Script_UnitFactionGroup resolves "player" through the in-world unit
descriptor, so it returns nil at file scope, ADDON_LOADED and
VARIABLES_LOADED and only answers from PLAYER_LOGIN. UnitName, UnitRace
and UnitClass already answer then, and later clients answer
UnitFactionGroup too, so ported addons read it at file scope. AceDB-3.0
does exactly that to build its "<faction> - <realm>" profile key, and
fails to load on nil ("attempt to concatenate local 'factionKey'").

Co-hook the engine function: when it answers nothing for the "player"
token, fall back to the login-time race byte (VAR_PLAYER_RACE_BYTE) and
the same race -> faction-group chain its player branch uses. That chain
moves out of C_CreatureInfo.GetFactionInfo into a shared
Creature::Info::FactionGroupForRace; GetFactionInfo's behavior is
unchanged. Once the player object exists the engine answers by itself.

Adds FUN_SCRIPT_UNIT_FACTION_GROUP (slot 13 of table 0x00850438).

Measured in-game before the fix (Octo 1.18.1 client, Human):
UnitFactionGroup("player") = nil at file load / ADDON_LOADED /
VARIABLES_LOADED, "Alliance" from PLAYER_LOGIN; UnitName/UnitRace
answer at every stage.

Verified in-game after the fix (same client and character):
UnitFactionGroup("player") is "Alliance" at file load, ADDON_LOADED and
VARIABLES_LOADED, and AceDB-3.0 sets up Zygor Guides Viewer 2.0's saved
settings.

* fix(unit): keep UnitFactionGroup's in-world nil; describe both engine branches

Script_UnitFactionGroup reads the live faction template when the unit's
object is in the world, and only falls back to the name cache's race when
it isn't. The race fallback now runs only under that same condition (no
player object), so an in-world nil (e.g. charmed by a monster) stays nil
instead of turning into the race's faction. Comments and API.md now say
which engine branch the fallback mirrors.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 02:14:04 -05:00
SkeewirtandBrues 35ef1228eb feat(lua): give each pass of a for loop its own loop variables, as in 5.1 (#66)
* feat(lua): give each pass of a for loop its own loop variables, as in 5.1

Lua 5.0 declares a for loop's variables once for the whole loop (its
parser's forbody activates them before entering the loop block), so every
function made in the body shares one variable, which is nil once a
generic for ends. Lua 5.1 declares them per pass, and 5.1 code relies on
it. Zygor Guides Viewer 2.0's recent-guides menu gives each entry
`checked = function() return ... guide.full end`; on 1.12 it errored
with "attempt to index a nil value" whenever the menu opened.

A new transpile pass rewrites such a loop to the 5.1 manual's own
equivalent, `for __cf_k, __cf_v in e do local k, v = __cf_k, __cf_v;`,
on the same line. It applies only to loops whose body contains a
function, and not when the body assigns to a loop variable (5.0 carries
that into the next pass and 5.1 doesn't). Chunks whose blocks don't
balance are left alone. The trigger scan follows blocks and brackets
without allocating. Toggle: "ForLoopScopes".

Tested offline on the rewriter's own code: 21 targeted cases, and 1,417
addon and Classic Era FrameXML files (67.7 MB). 184 loops were rewritten,
and every rewritten file still compiles under Lua 5.1. The pass adds about
0.03-0.05 s over the whole 67.7 MB.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
the recent-guides menu opens without an error, and functions made in a
generic for, a numeric for and a pair of nested loops each return their
own pass's values.

* refactor(lua): close loop upvalues per pass in the compiler instead of rewriting source

Replace the token-scanning source rewrite with a hook on the parser's
forbody. Lua 5.0's loop variables are the loop's control registers, and
a function made in the body keeps them open until forstat's block ends.
Emitting one OP_CLOSE at the end of each pass, before OP_FORLOOP /
OP_TFORLOOP, gives each function its own pass's values.

The OP_CLOSE is emitted only when a nested function names a loop
variable. markupval already records that on forstat's block, so
nothing is guessed from tokens. A loop that captures nothing compiles
to the engine's exact bytecode.

This fixes the gaps in the source rewrite (indexed and dotted targets,
`function v()`, nested elseif, long target lists). It adds no locals,
so it can't push a function past the 200-local limit, and it adds no
scan to every chunk load. Assignment to a loop variable still steers
the loop, as stock 5.0 code may rely on. The ForLoopScopes toggle is
gone with the rewrite.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 01:58:02 -05:00
SkeewirtandBrues 76cc907bd0 feat(tooltip): add GameTooltip:AddTexture on the engine's own routine (#67)
* feat(tooltip): add GameTooltip:AddTexture on the engine's own routine

Vanilla has the 3.x tooltip-texture feature built in and unexposed:
GameTooltipTemplate declares <name>Texture1..3, the XML-OnLoad scan
binds them (tooltip+0x34C), FUN_005303D0 lays the next one out on the
last line (sized to the line, text shifted right) and the tooltip clear
undoes it. Nothing in the 1.12 binary calls that routine; expose it as
GameTooltip:AddTexture. The engine's limits stay: three textures per
tooltip, none on the header line. Zygor Guides Viewer adds guide
images to its tooltips this way.

Verified in-game (1.12.1 client via VanillaFixes): on a
GameTooltipTemplate tooltip with two lines,
AddTexture("Interface\\Icons\\INV_Misc_Bag_08") shows <name>Texture1
with that file, and ClearLines hides it again.

* fix(tooltip): repair AddTexture layout around the engine routine

The engine's texture routine was never called before, and its layout has
two defects. The tooltip clear re-anchors the line below a textured line
at (0, 0) instead of the template's 2px gap, so that line sat too close on
every later tooltip from the frame. And every re-anchor replaces the
line's offset, so a second texture on one line, or one on the line right
below a texture, broke the text alignment.

Tooltip::ClearItemGuid becomes Tooltip::Clear: the one clear co-hook now
also restores the gap below each textured line. AddTexture refuses a
texture on or right below a textured line, accepts only strings, and
returns whether it placed the texture. Correct the Offsets.h description
of the routine and keep the user doc to the contract.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 00:18:26 -05:00
SkeewirtandBrues e9e431e014 feat(frame): add SetClampRectInsets / GetClampRectInsets (#69)
* feat(frame): add SetClampRectInsets / GetClampRectInsets

Later clients let a frame that is clamped to the screen keep a different
rect on screen than its own: SetClampRectInsets(left, right, top, bottom)
grows or shrinks the rect the clamp uses. Zygor Guides Viewer 2.0 sets
them on its frames while building them (a negative top inset lets a
frame's top go off screen), and 3.3.5's own FrameXML uses them too.

The engine clamps a frame to the screen inside its rect calculation
(0x00767A20): it resolves the edges and, for a clamped frame, slides the
rect onto the screen. Hook that calculation: for a frame with insets, take
the unclamped rect, grow it by the insets (converted from the frame's
units to layout units with the inverse of Script GetLeft's scaling), slide
that onto the screen with the engine's own steps and order, and move the
frame's rect by the same amount. Frames without insets go through the
engine's clamp as before. SetClampRectInsets re-lays the frame out, as
SetClampedToScreen does, and (0, 0, 0, 0) clears the insets.

Sign convention from 3.3.5's own calls: WorldMapFrame (0, 0, 0, -60)
"don't overlap the xp/rep bars", chat frames (-35, 35, 26, -50).

Verified in-game (1.12.1 client via VanillaFixes): a clamped 100x100
frame placed 10 above the bottom of the screen stays at 10, moves to 60
with (0, 0, 0, -60) and back to 10 once cleared; a frame placed 20 over
the top is clamped to the edge and, with a top inset of -25, keeps its
20 over. GetClampRectInsets returns the insets set.

* refactor(frame): address review of SetClampRectInsets

Use the existing OFF_REGION_ANCHOR and UI coordinate globals instead of new
duplicate offsets, and move the pixel/anchor-unit conversion into one shared
Game::PixelToInternal / InternalToPixel pair (replacing the copies in
LinePool, Transform and Metrics). Restore only the clamp bit around the
unclamped rect calculation so other layout flag bits survive. Add doc
descriptors for both methods and drop version/implementation notes from
the user docs.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:56:17 -05:00
SkeewirtandBrues fbcc1c9ae1 feat(editbox): add SetCountInvisibleLetters / IsCountInvisibleLetters (#88)
* feat(xml): read parentKey, function= and button font style= from XML

Later clients' XML has three attributes vanilla's parser keeps on the
node but never reads. Ported UIs lean on all three: Zygor Guides Viewer
2.0 uses parentKey 69 times (its arrow, skin buttons and step frames),
binds its main frame's whole OnLoad with function=, and names button
fonts with style=.

All three hook the engine's per-object XML node loaders. Those run on
the object's layout sub-object ((object+0x24)->vtable[+8], called from
the frame builder at 0x006EE4D9 and the region factory at 0x006F2756),
so the object is `this - OFF_XML_LOADER_SUBOBJECT`. The loaders: frames
FUN_00769820 (every derived frame type's loader calls it first),
textures FUN_0076FE20, font strings FUN_00770F40, and Button's
FUN_XML_BUTTON_LOAD (CheckButton's calls it first). Each hook runs the
engine's loader, then:

- parentKey="Name" sets parent[Name] = object on the Lua wrappers, the
  parent being the object's script-region parent (OFF_REGION_PARENT,
  which GetParent reads). An unnamed element has no wrapper yet, so it
  is registered the way the engine's own lazy path does
  (ScriptRegister(object, NULL), as GetParent does); a named one is
  never registered here, since SetName already did that under its name.
  The builder loads content and child frames before it finalizes, so a
  parent's <OnLoad> already sees its keys, and keys inside a virtual
  template bind on every frame built from it.
- A script element with function="Name" becomes
  frame:SetScript(<element>, _G[Name]). The loader runs before the
  builder's finalize, so a bound OnLoad fires at the usual point.
- A NormalFont / HighlightFont / DisabledFont with style and no inherits
  is applied through the engine's own setter for that state with
  _G[style]. Vanilla reads only inherits there and otherwise leaves the
  button's embedded font empty, so its text had no font.

Adds FUN_XML_FRAME_LOAD / FUN_XML_TEXTURE_LOAD / FUN_XML_FONTSTRING_LOAD
/ FUN_XML_BUTTON_LOAD, OFF_XML_LOADER_SUBOBJECT and
OFF_SCRIPT_REGION_NAME, with their derivations.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
probe XML gets its parent keys on a Frame, a Texture, a FontString, a
button's NormalTexture and a frame inside an inherited template; an
<OnShow function="..."/> handler runs; a button with
<NormalFont style="GameFontHighlightSmall"/> reports that font object.
Zygor's frames build with their keys, its main frame's OnLoad runs, and
it loads without a Lua error.

* feat(editbox): add SetCountInvisibleLetters / IsCountInvisibleLetters

3.3.5's EditBox can count color codes and hyperlink markup toward its
SetMaxLetters limit: SetCountInvisibleLetters(true), or
countInvisibleLetters="true" in XML (its macro text, friends broadcast and
BNet report boxes). Off is the default. AceGUI-3.0's MultiLineEditBox
calls the setter while building.

1.12's SetMaxLetters limit (+0x340) counts only visible letters: its
counter (0x0077BC80) skips the markup classes in the edit box's per-byte
table (+0x330), so with a limit of 5 "|cffff0000abcdefg|r" keeps
"|cffff0000abcde" (checked in-game). That is 3.x's default (off). With the
flag on, the limit check's three counts (returns 0x0077C09F, 0x0077C0C1,
0x0077C0E9) count each markup token at its length instead. The counter's
other callers, the cursor movement and GetUTF8CursorPosition, are
unchanged.

For XML, hook the EditBox node loader (0x00779FB0, identified by the
"letters"/"autoFocus"/"multiLine" attributes it reads after calling the
frame loader) and, after it runs, read the attribute and parse it with
the engine's own boolean parser (0x006F1B30, as for autoFocus). The
loader follows the node-loader convention of the XML attributes change
(OFF_XML_LOADER_SUBOBJECT), which this builds on.

Verified in-game (1.12.1 client via VanillaFixes): with SetMaxLetters(12),
"|cffff0000abcdefg|r" is kept whole with the flag off and cut to
"|cffff0000ab" with it on; an XML EditBox with letters="12" and
countInvisibleLetters="true" reports the flag on and cuts the text the
same way.

* docs: correct GetNumLetters note, drop version comparisons

GetNumLetters counts the letters you see (FUN_007992C0 calls the same
counter as the limit check), not bytes. Remove the 3.3.5 / stock 1.12 /
later-client comparisons from the new XML and edit box sections.

* fix(frame): build extra templates' child frames in CreateFrame

The multi-template graft called the main vtable's +8 slot, which is the
Lua method lookup FUN_00778590, not a node applier, passing the XML node
as the Lua state. Its second call (the +0x24 sub-object's node loader)
applied the template's content, but <Frames> children are built only by
FUN_0076A060 from the finalize step, so an extra template's child frames
were never created.

Call the node loader, then FUN_XML_FRAME_LOAD_CHILD_FRAMES directly, so
OnLoad is not re-run. Correct the notes in Offsets.h and CreateFrame.cpp.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:42:22 -05:00
SkeewirtandBrues 142faafb13 feat(frame): add SetHyperlinksEnabled / GetHyperlinksEnabled (#92)
* feat(frame): add SetHyperlinksEnabled / GetHyperlinksEnabled

3.3.5 has SetHyperlinksEnabled/GetHyperlinksEnabled on ScrollingMessageFrame
and SimpleHTML only, so register them on those two types' method tables
(0x00CF5294, 0x00CF5328), and hook the three functions both types fire
OnHyperlinkEnter/Leave/Click through (0x007A3780, 0x007A37B0, 0x007A37E0)
to skip the script while a frame's hyperlinks are off. On stays the
default, which is vanilla's behavior.

Zygor Guides Viewer 2.0 turns hyperlinks off on the step lines it fills
with Gold Guide item lists, and back on when they show guide steps.

Verified in-game (1.12.1 client via VanillaFixes): both types report true
by default, false after SetHyperlinksEnabled(false) and true again after
SetHyperlinksEnabled(true), and a plain Frame has neither method, as in
3.3.5. Not yet checked: the skipped script itself (a click on a link with
hyperlinks off), which needs the mouse; that check runs next session.

* refactor(frame): gate hyperlink scripts via RunnerHook, keep Leave

Replace the three MinHooks on the per-type hyperlink fire functions with
a Frame::RunnerHook subscriber keyed on the script slot (+0x320 Enter,
+0x330 Click). OnHyperlinkLeave is never suppressed, so a tooltip shown
before hyperlinks were disabled still closes. Parse the argument with the
engine's loose boolean (default on), as 3.3.5's setter does.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:27:51 -05:00
GratisNinja86andBrues c243489916 Fixed bleed of druid "Pounce" spell not shown under nameplates (#105)
* Attribute Pounce Bleed to the caster

* fix(aura): attribute triggered auras from the Spell.dbc trigger link

Replace the hardcoded Pounce -> Pounce Bleed table with the DBC link:
any effect of type SPELL_EFFECT_TRIGGER_SPELL whose EffectTriggerSpell
applies an aura gets the trigger's caster.

- Arm the target and attribute on the application hook, so a hit by
  the trigger that the triggered aura never follows (bleed-immune
  target) creates no phantom entry. An existing instance from the same
  caster that the descriptor still lists is refreshed in place, since
  the server's re-apply fires no OnAuraAdded.
- Work for any caster, not only the local player.
- Share the target list (hit list, else the caster) and the duration
  helper with the cast's own aura path.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:12:12 -05:00
Brues 917b15640e feat(quest): add C_QuestLog.GetInfo and C_QuestLog.GetQuestLogTitle
Both run the engine's Script_GetQuestLogTitle (0x004DF930) and repackage
its returns, so title, level, header, collapse and complete/failed state
match the stock GetQuestLogTitle. Also registers Enum.QuestFrequency and
Enum.QuestClassification.

Corrects the Quest::Cache note on +0x14..+0x24: those slots are
RepObjectiveFaction, RepObjectiveValue, the two opposite-rep fields and
NextQuestInChain, not SuggestedPlayers and friends. The 1.12 wire carries
no suggested group size, so suggestedGroup is always 0.
2026-09-30 15:08:38 -05:00
Brues 7ccbbaaf68 fix(compat): stop Turtle's Toggle Movement button erroring
Turtle's Options frame sets GroupFrame_ToggleMovement(state) directly as
the button's OnClick. With modern script args the handler receives the
button as `state`, and `1 - state` errors (Turtle_GroupUI.lua:409). Wrap
the global so only a number reaches the original.
2026-09-30 14:58:58 -05:00
Brues 71805db62f docs(readme): list PickupEquipmentSet and WEAR_EQUIPMENT_SET 2026-09-27 00:07:57 -05:00
Brues 85619155f8 feat(equipmentset): add C_EquipmentSet.PickupEquipmentSet
Equipment sets become action-bar buttons, as in 3.3.5: action type
0x20000000 | setID and cursor type 13. The set can be picked up,
placed, dragged off, and swapped with other actions. The button shows
the set icon, name and tooltip, and grays out while a set item is
locked. A click fires WEAR_EQUIPMENT_SET(setID) and the embedded
EquipmentManager equips the set.

The server drops action type 0x20 without saving it, so placements are
kept in the per-character equipment-set file. The server is sent an
empty slot, and the saved buttons are merged into the
SMSG_ACTION_BUTTONS buffer at login. GetActionInfo and GetCursorInfo
report "equipmentset", name.
2026-09-27 00:05:29 -05:00
Brues 46dc198505 feat(tooltip): add GameTooltip:GetUnit
GetUnit() returns (name, unitToken). The engine drops the token after
SetUnit resolves it to a GUID, so it is kept where it enters:

- SetUnit is re-registered in front of the engine method and stages
  the token; it binds in the unit-builder co-hook that SetEvents
  already owns, before OnTooltipSetUnit fires.
- A build with no staged token is the world mouseover ("mouseover");
  the in-place refresh keeps the bound token.
- Unit-frame hover (Frame::Attributes) stages the frame's unit, since
  the engine resolves "mouseover" only for a live object and an
  offline or far group member has none.

The name follows Script_UnitName's order (live object, group roster
slot, name cache), so offline members resolve for GetUnit and
GetUnitGUID alike.
2026-09-26 22:33:13 -05:00
Brues 7707127d5f fix(macro): add the question-mark icon to the macro icon list
The stock 1.12 macro icon list holds only Ability_* and Spell_* icons,
so on a client without Turtle or pfUI the macro picker cannot select
INV_Misc_QuestionMark, and #showtooltip never gets a macro to take over.

Mirror 3.3.5's loader: seed the icon through pass 3's own append before
the walks, then move it to index 0 after the engine sorts and dedups.
2026-09-26 00:31:19 -05:00
Brues fa7d71435f fix(nameplate): retry CREATED and UNIT_ADDED until they fire
A plate was marked seen before NAME_PLATE_CREATED was sent, and a GUID
entered the tick snapshot before NAME_PLATE_UNIT_ADDED was sent. When a
fire was dropped because its event slot was not claimed yet, the plate
was never announced again until /reload cleared the diff state. pfUI
builds its overlay on CREATED and finds it on ADDED, so such a plate
kept Blizzard's look (brues-code/pfUI#59).

FireWithFrame now reports whether the event went out, and a plate is
marked seen only then. ADDED waits for its slot AND for its frame's
CREATED, so a handler never gets a unit for a plate it was not
introduced to. A deferred GUID gets no token slot and no observers, and
is left out of the snapshot, so the next tick retries it and no REMOVED
fires for an ADDED that never went out.

Not verified in game: the dropped-slot case cannot be forced here, so
only the normal path was built and deployed.
2026-09-25 10:34:40 -05:00
Brues 3467513df9 feat(container): group sorted gear by equip slot
Gear was one category per quality tier, ordered inside by class,
subclass and name, so a helm could sit between two pairs of boots and
an epic helm landed far from a green one. Gear is now one category,
ordered by class, then equip slot, then subclass, then quality (best
first), then name, so like pieces sit together.

The slot key ranks Item.dbc inventoryType through a table rather than
the raw INVTYPE number, which does not follow the character pane (robe
is 20, chest 5; cloak 16). The order is Baganator's: weapons by hand,
ranged and ammo, then armor with shields and off-hands first, head to
feet, then jewelry, shirt and tabard.
2026-09-25 10:19:16 -05:00
Brues 6000df8009 feat(cvar): add C_CVar.SetTempCVar and RemoveTempCVar
SetTempCVar changes a cvar for the session without saving it to
Config.wtf; RemoveTempCVar restores the value from before the first
temp set.

The temp set passes a6 = 0 to the engine setter, the only argument that
marks the config dirty, so it applies the value and runs the change
callback without asking for a save. When something else dirties the
config, a co-hook on the config writer (0x0063D980) points each temp
cvar's value at its saved string for the write and restores it after.
A plain SetCVar over a temp value wins: the entry drops itself, so the
user's set is saved and never rolled back.

Errors mirror Script_SetCVar. Registered in-game and on glue.
2026-09-25 09:59:27 -05:00
Brues fde3beca9d fix(table): keep the stored length on weak-valued tables
The getn heal read a nil at the stored last slot as proof that nobody
maintained the length. For a weak-VALUED table that is false: the
collector clears those slots behind the writer, so `luaL_setn` is still
exactly what the last append left and the holes below it come and go
between two reads.

Compost-2.0 is the casualty. `secondarycache` is `__mode = "v"`, filled
with `table.insert`; `GetTable` walks it with `pairs` and calls
`table.remove(cache, i)` on a live index. Once the GC clears slot 1 and
the stored last slot, the heal bisects to border 0, `luaB_tremove` takes
its `e == 0` empty-table early-out and returns NOTHING, and the next line
indexes with the nil it got back -- "table index is nil" at
Compost-2.0.lua:81, once per recycled table, in every addon that pulls
Compost in through AceLibrary.

The border search is what exposed it (5aa382b's table.insert switch is
not involved -- Compost never appends a nil, so no mark ever applied to
its tables). The walk-down it replaced returned the HIGHEST populated
slot, which is always at or above the live index the pairs loop holds, so
remove still found something; a bisect returns some border and probes its
way down to 0 through the collected slots. That tradeoff was known and
written down -- a weak cache read through table.remove is where it bites.

So gate the heal: `__mode` mentioning "v" returns the stored length
untouched, as rule 3, ahead of the mark. It costs one getmetatable and a
raw `__mode` fetch on the already-rare unhealthy path, so the bisect keeps
its win everywhere else, and the mode is read raw -- the same fetch the
collector's own check uses.

New plumbing: Game::Lua::GetMetatable, from the offset already catalogued
in LuaCAPI.md. LuaEnv::Fenv had rolled its own copy; it now shares this
one.

Verified in game: a three-slot weak table with slots 1 and 3 collected
reports getn 3 and hands back its live slot (0 and nothing before), and
the Compost errors are gone.
2026-09-17 23:39:43 -05:00
Brues 452f3c14fc fix(macro): keep the unlearn sweep from deleting #showtooltip macros
The spell-removal handler FUN_005E9FE0 calls FUN_004E5E20(spellID) right
after the unlearn writer. That sweep walks all 0x78 action slots, resolves
each through FUN_ACTION_SLOT_TO_SPELL -- which is macro-aware, so a macro
slot answers with the primary-spell cache this module writes -- and clears
every slot whose spell matches. The clear notifies with sendToServer = 1,
so CMSG_SET_ACTION_BUTTON goes out and the server persists it: the macro is
gone for good, not until the next reload.

That rule is sound for a cache the engine filled from a /cast line and
wrong for one filled from a display directive, which names an icon rather
than what the button does. `#showtooltip [known:18223] 18223` over a /cast
the player can still perform would lose the whole macro on a respec, and a
conditional directive makes it arbitrary besides -- the cached value is
whichever clause happened to match on the last evaluation.

Co-hook the sweep and park every managed macro's cache at 0 for its
duration, restoring afterwards with a plain field write (nothing we own
changed, so no repaint is owed; the slots the engine really did clear were
announced by its own notify). A BusyScope wraps it because the sweep's
ACTIONBAR_SLOT_CHANGED handlers reach Lookup, whose catch-up would
otherwise re-apply the parked values mid-sweep for slots the loop has yet
to reach. Macros with no directive are untouched.

Consequence: a plain #showtooltip macro whose /cast line names the
unlearned spell now also survives, where stock 1.12 removed it.
2026-09-17 14:04:59 -05:00
Brues 6bb12c3c12 ci: drop the Gitea mirror entirely
octowow.st's Gitea instance would not accept a git push under any
credential scheme, converting the repo off "mirror" status didn't
change that, and its own pull-mirror sync was already unreliable
before any of that -- three commits of narrowing down the actual cause
without ever reaching a fix. The repo is being removed from Gitea, so
there is nothing left to mirror to.

Removes mirror-release.yml, the diagnose-mirror-auth.yml scratch
workflow, and the `mirror` job in build.yml that called it.
2026-09-17 11:49:21 -05:00
Brues 7a08199ba5 ci: replace the status-code probe with a real end-to-end push test
Every scheme in the first diagnostic came back 200 on the GET leg,
which does not actually clear anything -- a bare status code can't tell
a real git advertisement apart from a 200 that's something else, and
`git push` also has a second POST leg the GET-only probe never touched.

Run the real thing instead: `git push --dry-run` end to end against a
disposable ref, with real credentials, under the three most plausible
schemes -- captures git's own diagnostic output instead of inferring
from a client-side parse error that looks the same for a 401, a 403,
and a malformed 200.
2026-09-17 11:41:31 -05:00
Brues a5bb3823fb ci: add a one-shot diagnostic for the mirror git-http auth scheme
Converting the Gitea repo out of mirror status did not fix the push
(same client-side error), which rules out `repo.IsMirror` gating and
means the two prior fixes were both guesses at a credential scheme with
no way to verify which one, if any, the deployed Gitea actually accepts
-- a 401 and a 403 look identical from the git client's side.

Tests six candidate schemes against the real git-receive-pack endpoint
with the real MIRROR_TOKEN, printing only the resulting HTTP status per
scheme -- never the token or a response body. Delete this file once the
working scheme is found and applied to mirror-release.yml.
2026-09-17 11:37:22 -05:00
Brues 8c941c757d fix(ci): authenticate the mirror push with a token header, not oauth2 basic auth
The direct push added in 2fff956 failed every run: git reported
"could not determine hash algorithm; is this a git repository?" against
the Gitea push URL. Reproduced the exact message locally by hitting the
same endpoint with a deliberately bad token -- it is git's confusing
rendering of a plain 401, not a routing or protocol problem (the same
endpoint serves a clean smart-HTTP advertisement for an unauthenticated
fetch).

`oauth2:<token>` as the HTTP Basic auth username is GitLab's convention,
not Gitea's, and Gitea rejected it. Switch to the `Authorization: token
X` header instead -- the exact scheme this same workflow's curl calls
already use successfully against this server -- rather than guess at a
different Basic-auth username convention.
2026-09-17 11:21:27 -05:00
Brues 2fff9564b2 fix(ci): push release tags to the Gitea mirror directly
Gitea's pull-mirror sync is a background queue job: POSTing mirror-sync
only enqueues it, and a failure on Gitea's end (stale pull credentials,
a stuck queue) is invisible from here -- the workflow would just poll
for the tag for 5 minutes and time out, so releases never made it
across.

Replace the ask-and-poll with a direct push: fetch the release tag from
GitHub (shallow, depth 1) and push it straight to the mirror with the
existing MIRROR_TOKEN, which already has write access. A git push either
lands or fails loudly on the spot -- nothing to wait on. Gitea's own
pull-mirror can keep doing whatever it does for everyday commits and
branches; this workflow no longer depends on it working.
2026-09-17 11:09:11 -05:00
Brues bbefdb847d fix(aura): include the player's pet in the PLAYER filter
Blizzard defines the token as "Include only auras that were cast by the
player, or by the player's pet or vehicle"
(AuraUtil.AuraFilters.Player). There are no vehicles here, so the caster
set is player + pet -- but all three comparisons tested
`casterGuid == PlayerGuid()` and nothing else.

A pet's cast carries the PET's guid in the Aura::Source cache, since
that is who SMSG_SPELL_GO names, so "HARMFUL|PLAYER" silently dropped
every aura the pet applied. `!PLAYER` had the mirror bug: it returned
the pet's auras as though a third party cast them.

Centralize on one IsPlayerOrPetCaster predicate rather than fixing each
site -- IsPlayerCast, GroupIsPlayerCast and CachedMatches had already
grown three separate copies of the comparison, which is how they would
drift again. The pet guid comes from VAR_PET_GUID, a live engine global,
so the predicate stays two compares with no object resolve; it runs per
aura slot. A caster of 0 (cache miss) still matches nothing.

Unrelated and untouched: the `isFromPlayerOrPlayerPet` aura field, which
means "applied by ANY player or a player's pet" and is answered from the
caster's HIGHGUID prefix.

Verified in-game: a Hunter pet's Scorpid Poison now returns from
C_UnitAuras.GetAuraDataByIndex("target", 1, "HARMFUL|PLAYER").
2026-09-16 23:32:16 -05:00
Brues 56d2dbbc27 fix(player): file WEAPON_SLOT_CHANGED under PaperDollInfo
Blizzard's generated API docs put all three of this file's events in the
PaperDollInfo system (PaperDollInfoDocumentation.lua), so the descriptor
said the wrong system and would have invented a stray "Item" grouping in
the /classicapi browser. Filing it correctly also lines up
PLAYER_EQUIPMENT_CHANGED and UPDATE_INVENTORY_DURABILITY for whenever
they get descriptors of their own.

Same source confirms the payload: the entry carries no Payload field, so
no arguments is right. It is also flagged UniqueEvent where the two
per-slot events are SynchronousEvent -- recorded in the header comment,
since that makes the per-frame coalescing the event's own shape rather
than a local preference.
2026-09-16 22:25:44 -05:00
Brues 0296e0e5a4 feat(player): add WEAPON_SLOT_CHANGED
Fires with no payload when the item in inventory slot 16, 17 or 18
changes -- equip, unequip and swap alike. The narrow sibling of
PLAYER_EQUIPMENT_CHANGED that swing-timer consumers want, so they no
longer filter three slots out of a 19-slot event in Lua on every gear
change. Blizzard's own Blizzard_SwingTimer registers this event.

Rides the descriptor-field observers Player::Equipment already installs
for PLAYER_EQUIPMENT_CHANGED -- the weapon slots are three of the 19 --
so there is no new hook, no second registration and no polling. The
dispatcher invokes the callback only when a slot's item GUID actually
changed, and seeds each node's mirror from live at registration, so
login and /reload produce no fire.

Coalesced to at most one fire per frame via a dirty flag drained on
WorldTick: one action can change two weapon slots (a two-hander
replacing a one-hander plus an off-hand) and the per-slot observer would
otherwise fire twice for it. The drain checks the reservation slot
before clearing the flag, so a change marked while the reservation is
still unclaimed is held rather than swallowed.

Slot 18 is exempted for the classes whose slot 18 is a relic slot -- a
Libram/Idol/Totem is a passive stat item, not something you swing.
Gated on the same data Script_UnitHasRelicSlot (0x00519E50) reads rather
than a hardcoded class list: ChrClasses.dbc[class] + 0x40, verified in
the disassembly at 0x00519EAE-0x00519EC0 and against the client's own
ChrClasses.dbc, where that column is 1 for PALADIN/SHAMAN/DRUID and 0
for the other six. Read live, since the class-byte global is
repopulated per character-enter.

Not verified in-game yet: built and deployed only.
2026-09-16 20:30:17 -05:00
Brues 6812771a89 fix(event): raise the reservation cap past the 64-slot overflow
MAX_RESERVED was 64 while the codebase carries 65 AutoReserve
instantiations: 60 at file scope plus the 5 lazy function-local ones in
voicechat/Tts.cpp, which construct on first registration and only when
VanillaTTS is absent.

The constructor silently returns once g_reservedCount hits the cap, and
the file-scope 60 all run at static init, so the TTS five land last and
the 65th -- VOICE_CHAT_TTS_VOICES_UPDATE -- was dropped: Slot() stayed
-1 and Fire() no-opped, so the event never fired on any client without
VanillaTTS.

Bump to 96 and record the incident; the comment's "~40 reservations"
figure was stale from the previous overflow at 32.
2026-09-16 20:28:25 -05:00
Brues 4d835aa674 feat(combat): add PLAYER_SWING / PLAYER_SWING_RANGE_UPDATE / C_SwingTimer
Backports the Forever beta swing-timer API. PLAYER_SWING and
PLAYER_SWING_RANGE_UPDATE fire off the packets the engine already sends
for melee/ranged auto-attacks, mirrored as state (parry haste, extra
attacks, on-next-swing abilities, cast-time resets) rather than relayed
1:1, so N packets for one logical reset collapse into one event.

Two bugs surfaced only by in-game testing, not by reading the
disassembly:

- FUN_UNIT_CAN_ATTACK (0x00606980) is __thiscall with the second unit
  pushed on the stack, not __fastcall(ecx,edx) -- Ghidra's decompile of
  the one caller renders it as a plain two-arg call either way, so the
  wrong convention crashed on a bogus ecx read off [ebp+8] garbage.
  Same trap as FUN_SET_CVAR_VALUE.

- The generic engine range core (FUN_006e3480, what
  C_Spell.IsSpellInRange already uses) computes full 3D distance, but
  the server's actual melee-attack gate
  (Unit::CanReachWithMeleeAutoAttackAtPosition,
  WorldObject::CanReachWithMeleeSpellAttack) is explicitly 2D --
  verified by landing hits at 5.08yd 3D distance that the 3D check
  rejected. Melee now gets its own 2D check
  (Combat::Swing::InMeleeRange), reading the same live engine reach
  constants; ranged keeps the 3D core, which matches the server's own
  (also 3D) generic spell-range gate.

Also: attack-start no longer resets the off-hand timer unless the
target is actually in range. Unit::Attack's off-hand reset fires the
instant an attack is declared, even out of range, and the server just
silently re-arms it every 100ms until you're close enough -- firing
PLAYER_SWING there promised a swing time that was not real.

Slam is special-cased for Turtle: every rank in Turtle's own client
Spell.dbc carries AttributesEx2's swing-reset exemption, but packet
timestamps show Turtle's server does not honor it for Slam -- both
melee timers delay after casting it, contradicting both the client
data and the public tortoise-wow reference source.
Combat::Swing::AutoSwingResetOverride is a small extension point
(mirroring Turtle::ComboDuration's Rip override) for this kind of
server/client divergence; src/turtle/SwingReset.cpp registers the
known Slam ranks.

Verified in-game: swing detection (dual-wield, extra attacks
coalescing to one event, cast-time resets), the melee range fix, and
the attack-start range gate. The Slam override has not been
rebuilt/retested yet.
2026-09-16 17:50:13 -05:00
Brues dfbba225d2 fix(macro): correct the loose/mpq split in the icon enumeration
The three macro-icon scan callbacks were named and tagged for the wrong
walker. FUN_LOAD_MACRO_ICONS hands 0x004F0220 to FUN_MPQ_ENUM_FILES (the
archive listfile walk) and 0x004F0350 / 0x004F04F0 to the disk walker,
but Icons.cpp had it exactly inverted: the archive pass was tagged
SRC_LOOSE and both disk passes SRC_MPQ. So GetMacroIcons /
GetMacroItemIcons returned the loose on-disk icons -- nothing at all on a
stock install -- while GetLooseMacroIcons / GetLooseMacroItemIcons
returned the archive set. It went unnoticed because the only consumer,
IconDataProviderMixin, calls all four and concatenates, so only the
ordering within each list changed and never the content.

Tags corrected, and the offsets renamed to what the loader actually hands
them to, with each one's filter recorded since that is the load-bearing
difference between the two disk passes:

  FUN_MACRO_ICON_CB_DISK        -> FUN_MACRO_ICON_CB_MPQ
  FUN_MACRO_ICON_CB_USER_MPQ    -> FUN_MACRO_ICON_CB_DISK_PREFIXED
  FUN_MACRO_ICON_CB_INSTALL_MPQ -> FUN_MACRO_ICON_CB_DISK_ANY

Also replaces a wrong guess in the Offsets.h block. It claimed a filter
inside the SStrDup/array-append helpers downstream of all three callbacks
was what kept INV_* out of the engine's own list. Decompiling the loader
and all three callbacks shows there is no shared downstream helper at
all: the grow-and-append code is inlined identically into each callback,
so the only filtering is what each one does itself. The list is
Ability_*/Spell_* only because passes 1-2 prefix-filter and pass 3 -- the
one that does not -- is a disk walk over a folder that ships empty. A
loose INV_*.blp dropped into Interface\Icons\ does enter the list,
through pass 3 alone. The block now also records the qsort + adjacent
SStrCmpI dedup, that GetMacroIconInfo pushes the empty string rather than
nil for a bad index, and that only GetNumMacroIcons triggers the lazy
build.

Verified in-game.
2026-09-15 10:54:59 -05:00
Brues 930b0c6492 Add C_LossOfControl.GetSchoolLockout
Serving the school-interrupt lockout through the generic list costs a caller
two full BuildList passes -- GetActiveLossOfControlDataCount runs one and
GetActiveLossOfControlData runs another -- plus a Lua table per entry, and
BuildList classifies up to 16 debuff slots with RecordForID on each. All of
that aura work is waste for a caller that only wants the lockout, and a macro
conditional reading it every frame pays it every frame.

The state behind it is g_schoolLock, seven pairs of ticks. Read it directly:
no table, no debuff scan, one pass.

Return a mask rather than a school. Each SMSG_SPELL_COOLDOWN batch locks one
school, so being kicked on fire and then frost leaves two entries active, and
a caller walking the list and stopping at the first SCHOOL_INTERRUPT drops the
rest. filterMask narrows the scan, which is how a caller gets one school's own
remaining time instead of the aggregate.

Also document that the ADDED/UPDATE events diff which effects are active, so
re-locking an already-locked school extends endMs without firing either one --
that expiry cannot be cached on the events.
2026-09-15 10:18:36 -05:00
Brues 428c3308c6 docs(todo): add #101 Frame:SetOnUpdateMode
Records the engine trail found while evaluating it: the per-frame
dispatcher FUN_0076B2C0 (vtable +0x38, __thiscall(frame, float elapsed),
fires the OnUpdate slot at +0x128) and the walk FUN_00765650, which
iterates nine strata buckets of the frame+0x310 intrusive list with NO
visibility check in either place. Visibility is list membership, and that
list is the render list -- which is what splits the enum: Disabled /
RunWhenVisible / RunWhenVisibleOnce need only a co-hook on the
dispatcher, while RunOnce / RunAlways have to be driven from the
already-owned WorldTick because a hidden frame is not in the list at all
and forcing it in would try to draw it.

Also notes the open question (which elapsed the self-driven modes pass,
and whether WorldTick runs before or after the UI pass), that no loaded
DLL references either function, and that RunAlways is the only genuinely
new capability -- the rest is parity with existing idioms.
2026-09-15 02:16:45 -05:00
Brues a33146241e feat(macro): make [button:N] read the click it is running inside
The condition existed but was a stub: it matched only "1" / "leftbutton"
and always passed, because nothing on this client could say which mouse
button had driven the current click. GetMouseButtonClicked can now answer
that, so the condition asks it.

Numbers use the modern API order -- 1 left, 2 RIGHT, 3 middle, 4, 5 --
which is deliberately NOT the order of the engine's internal click
bitmask (where 2 is the middle button); that mask never reaches Lua. A
name is matched as written, so [button:rightbutton] and [button:2] are
the same test, case-insensitively.

Outside a click the answer stays "left button". That is not a cosmetic
default: Macro::ShowTooltip re-evaluates conditional directives off the
world tick with no click in progress, so a nil-means-no-match rule would
change which icon a `/cast [button:2] X; Y` macro displays at rest. The
engine's own no-argument Button:Click() means the left button, so it is
also the honest resting answer for a state-driver poll.

Verified in-game with all five buttons, both directly from OnClick and
through a real macro body -- click -> attribute macro ->
EXECUTE_CHAT_LINE -> SlashCmdList -> SecureCmdOptionParse, which
confirms the click context survives the whole chain -- plus the name
form, the btn alias, and the no-click resting answer.
2026-09-15 01:38:38 -05:00
Brues c7af8d396e feat(frame): accept AnyUp / AnyDown in Button:RegisterForClicks
The engine's RegisterForClicks (0x00782490) SStrCmpI's each argument
against exactly ten literals -- LeftButtonDown 0x1 / LeftButtonUp 0x100,
Middle 0x2 / 0x200, Right 0x4 / 0x400, Button4 0x8 / 0x800, Button5
0x10 / 0x1000 -- treats anything else as contributing nothing, and
ASSIGNS the OR to the click mask. So the collective names later clients
take, "AnyUp" and "AnyDown", did not fall back to the default here: they
zeroed the mask and the button silently stopped answering every real
click, while Button:Click (which ignores the mask) kept working and hid
it. That is what broke the first click-bracket harness yesterday.

Frame::ClickMask re-registers RegisterForClicks on the Button method
registry (0x00CF4E14, new VAR_BUTTON_METHOD_REGISTRY; most recent
registration wins, CheckButton inherits through the type walk). It does
not reimplement the parser: for each AnyUp / AnyDown argument it appends
the five names that collective stands for onto the Lua stack and
tail-calls the engine's own function, which then does the self
typecheck, the parse, the setter and any error text itself. The
collective string is left in place -- the engine's unknown-name -> 0
rule already makes it inert -- so the reshape is purely additive and
the engine's loop terminates exactly as before.

Verified in-game with left, middle and right: AnyUp fires OnClick on
release; AnyDown on press; "anyup" + "LeftButtonDown" (case check) fires
left on both press and release and the others on release only; a
LeftButtonUp control is unchanged; "Fnord" alone still yields no clicks.
2026-09-15 01:04:10 -05:00
Brues a2a2ccb804 feat(frame): bracket the click at the engine's click vmethod
PreClick/PostClick now fire on every click, with or without an OnClick
handler, and GetMouseButtonClicked is live inside OnMouseDown and
OnMouseUp on any frame -- the two things 3.3.5 does that the previous
runner-only bracket could not.

3.3.5 (CSimpleButton::Click FUN_0096fd70 -> FUN_0096f090) fires PreClick,
OnClick and PostClick as three independent `if (slot)` blocks, so
Pre/Post never depended on OnClick. And the frame-manager field behind
GetMouseButtonClicked is written by exactly four dispatches -- that
click, the double-click, and CSimpleFrame::OnMouseDown FUN_0048fc30 /
OnMouseUp FUN_0048fce0 on every frame type -- not by OnDragStart.
Confirmed live on 3.3.5: `down LeftButton LeftButton`, `up LeftButton
LeftButton`, `drag LeftButton nil`. 7a4ab29 had misread +0x16c/+0x174 as
OnClick/OnDoubleClick and made OnMouseDown/OnMouseUp nil; corrected.

1.12 has the same two decision points. The runner interceptor now gates
on all four mouse-button script slots (OnClick +0x4CC, OnDoubleClick
+0x4D4, OnMouseDown +0x150, OnMouseUp +0x158 -- the CSimpleFrame
resolver FUN_0076A0D0's table is recorded in Offsets.h) and scopes the
name from the fire's own "%s" argument. PreClick/PostClick move to a
co-hook on the Button click vmethod FUN_00779540, which every
Lua-creatable button reaches (Button directly, CheckButton's override
toggles then chains to it). The detour fires them through the engine's
own variadic forwarder FUN_007026F0 with the engine's own "%s" literal
and its own name literal for the button mask, so the path and the arg1
pointer are identical to the engine's OnClick fire; the whole click is
scoped, as in 3.3.5. `down` is not supplied -- 1.12's vmethod never
learns press-vs-release. 3.3.5's same-button re-entry guard drops the
whole nested click; 1.12's engine recurses, so this only skips our own
Pre/Post on that re-entry. Net: one new hook.

The previous limitation was justified by a note that SuperWoW
inline-hooks FUN_00779540 and a second MinHook faults (ERROR #132). A
literal scan of every DLL the client loads finds no reference to that
address in any of them, while SuperWoWhook's real targets all appear as
plain literals. The #132 sighting is kept on record; its trigger was not
this function. Both Offsets.h notes rewritten; FUN_BUTTON_CLICK, which
existed only as prose, is now defined.

Verified in-game across real and programmatic clicks: Pre/Post with no
OnClick, full bracket with nested different-button click and restore
(LeftButton / RightButton / UNKNOWN outers), self-click yielding one
extra OnClick and no extra PreClick, CheckButton toggled before
PreClick, plain-Frame OnMouseDown/OnMouseUp reading the button with
OnDragStart nil, double-click without Pre/Post, an OnUpdate watcher
never seeing a value, and /reload -- all with the full DLL stack loaded.
2026-09-15 00:53:17 -05:00
Brues 7a4ab29cb0 fix(input): scope GetMouseButtonClicked to the click dispatch
It read a button captured from the WH_GETMESSAGE mouse-message hook and
evicted two world ticks later, so it reported a button for as long as one
was held, and for a couple of frames after every click -- useless as the
"am I inside a click handler" test it exists to be.

3.3.5 does none of that. The getter (FUN_0050f950) pushes a frame-manager
field that only the four click dispatches ever write -- button OnClick
FUN_0048fc30, OnDoubleClick FUN_0048fce0, and the two secure click paths
FUN_0096fd70/FUN_0096fdd0 that wrap PreClick -> OnClick -> PostClick --
each saving the old value on the C stack, setting its own button argument,
running the handler, then restoring. That C-stack save is what makes a
nested click shadow the outer one and restore on unwind.

1.12 has no such field but the same bracket point: FUN_00779540 (OnClick)
and FUN_00779650 (OnDoubleClick) fire their handler through the runner
Frame::ClickEvents already intercepts, passing the button name as the
fire's only "%s" argument. Mirror the engine's bracket there and read the
name out of the fire, which also inherits the engine's own naming:
Button:Click (FUN_007826C0) folds its optional string down to a numeric
mask, so a bare Click() arrives as "LeftButton" and an unrecognized name
as "UNKNOWN".

GetMouseButtonClicked moves to Frame::ClickEvents with the dispatch it
describes. IsMouseButtonDown and GLOBAL_MOUSE_* keep the message hook,
which is the right source for a device query. The recursion guard now
covers only the PreClick/PostClick pair, so a nested click still gets its
own button scope.

It now returns nil in OnMouseDown / OnMouseUp / OnDragStart, matching
3.3.5, where those paths never touch the field.

Verified in-game across all four dispatches: real and programmatic clicks,
OnDoubleClick, PreClick/PostClick, the nested inner/outer restore, Click()
argument folding, and an OnUpdate watcher that never saw a value outside a
handler.
2026-09-14 22:37:45 -05:00
Brues ef8ea710d4 feat(frame): guard GetPoint against dangling anchor targets
An anchor node (region+0x28 + point*4, vtable 0x0081C44C) stores its
relativeTo as a raw pointer at +0x0C with no weak reference. When the
target frame is freed the pointer is never cleared, so Script_GetPoint
still reads the target's refcount at [relativeTo-0x24+4] and faults --
ERROR #132, ACCESS_VIOLATION at 0x007A2452.

Re-register GetPoint on the Region method registry (front-of-bucket
wins, no MinHook -- the Texture::SetDesaturated mechanism). The
replacement SEH-probes each anchor's target header and hands dead ones
to the engine's own FUN_00767E70, which frees the node and NULLs the
slot by pointer comparison without ever dereferencing the dead target,
then delegates to the stock GetPoint.

Delete rather than NULL the relativeTo. 3.3.5 restructured CFramePoint
as a 16-byte POD with a flags word and invalidates in place (bit 0x800,
gated on by every reader), but the 1.12 node has no flags word, so its
only representation for a dead anchor is the empty slot -- exactly what
FUN_00767E70 produces. A live 0x44C node with relativeTo == 0 is a
state the engine never creates, and the layout readers would fault on
[0 + 0x3C].

Add _classicapi_ArmGetPointCrash to arm the condition deterministically
(decommitted page / low address / null relativeTo). No pure-Lua path
can produce it -- CreateFrame frames are never freed on this client.

Scope: covers the GetPoint reader only. The layout readers GetWidth and
GetHeight (0x007A2FB8) deref the same field unguarded, and nothing here
addresses whatever leaves the pointer dangling in the first place.
2026-09-14 21:37:43 -05:00
Brues a2d7d4e9e6 feat(classes): add C_ClassColor.GetClassColor
Returns a class's color as a ColorMixin, nil for anything that is not a
class name.

There is no engine source for the colors on this client: ChrClasses.dbc
has 17 columns and none of them is a color, and the binary carries no
class-color table (searched it for the class float triples and for the
hex strings, no hits). RAID_CLASS_COLORS is what the client actually
has -- FrameXML builds it in Fonts.xml, Util/Color*.lua gives it the
mixin, and every UI colors classes from it -- so the function reads
that table instead of carrying a second copy of the nine colors. Same
call C_UnitAuras.GetAuraDispelTypeColor makes with the
DEBUFF_TYPE_*_COLOR globals: one source of truth, one shared object per
class, and a class another addon recolors stays recolored here.

The lookup runs metamethods, so the pfUI shim in Util/AddOnCompat.lua
keeps answering grey for unknown keys.

Verified in-game: "WARRIOR" returns 0.78/0.61/0.43 with hex, colorStr
and the mixin methods; an unknown name returns nil; the result is
identical to GetClassColorObj for the same class.
2026-09-14 13:12:25 -05:00
Brues 449841b4eb fix(debugtools): draw the title bars above the backdrop, fit the scroll bar
The Backdrop of each frame draws its background and border into the
BACKGROUND layer, and it is applied after the textures declared in the
XML, so a title bar in that layer ends up underneath it. On
ScriptErrorsFrame, whose backdrop is the 32px dialog border, the header
came out behind the window edge; on EventTraceFrame and
TableInspectorFrame the bar is a translucent one inside the frame, so it
was buried rather than misplaced. Every Blizzard frame that uses
UI-DialogBox-Header puts it in ARTWORK. The title text of all three is in
OVERLAY and still reads over the top.

ScriptErrorsFrame is also the only frame here that takes its scroll bar
from UIPanelScrollFrameTemplate, which hangs the bar off the right edge
of the scroll frame rather than inside it, so the bar ran past the
border. Narrow the scroll frame, and its child edit box with it, to end
where the Close button does. Blizzard's own measurements do not carry
over: its ScriptErrorsFrame inherits UIPanelDialogTemplate, a thin
GearManager border, not this backdrop. The other two frames place their
sliders by hand and already sat inside.
2026-09-14 01:56:48 -05:00
Brues 89c705b922 docs: describe the four macro commands added today
Covers /castsequence, /castrandom, /userandom and /stopmacro in the Lua
reference, plus StopMacro beside GetMacroSpell, and updates the README
groups and counts.

Two caveats are worth having written down rather than found: a list names
no single action, so the random commands give the action button nothing
to show, and being static lines they end the #showtooltip collection --
a /castrandom above a /cast takes the icon from it.
2026-09-14 01:38:13 -05:00
Brues bf79ee1d93 feat(macro): add /stopmacro
Stops the rest of the macro body, split the way 3.3.5 splits it: the
command is addon Lua, and the flag belongs to the body runner, which is
what makes "stop the rest of THIS macro" mean anything.

The runner clears the flag for each body it starts and restores the
previous value when that body ends, so a line that runs another macro
re-enters the runner and the inner /stopmacro ends the inner body only.
A stray StopMacro() outside a macro leaves nothing armed for the next
one. Each line is dispatched synchronously through ChatEdit_SendText
into its handler, so the flag is already set when the fire returns.

The command binds ClassicAPI.StopMacro rather than the bare global.
StopMacro is a plain name in a crowded space, and the global is only
ours until an addon declares one of its own -- after which the command
would set THEIR flag and the macro would keep running with nothing to
show for it. The mirror captured our function at registration.
2026-09-14 01:33:36 -05:00
Brues 4f99a1ed95 feat(macro): add /castsequence, /castrandom and /userandom
Ports the three remaining cast-family commands from FrameXML, including
the macro icon, which is half the feature: a sequence whose button does
not show the current step is barely usable.

The sequence state lives in Lua and the icon asks for it by name. That
is Blizzard's own split -- 3.3.5's macro code calls the Lua global
QueryCastSequence through lua_pcall and takes the item over the spell --
so execution and display read one index and cannot disagree about which
step is current. Macro::ShowTooltip mirrors that call for any option
line that came from a castsequence command.

The random pair is faithful to 3.3.5 rather than nicer than it: the
binary has no query beside QueryCastSequence, so a comma list reaches
the resolver whole, names nothing, and greys the button. Being a static
line it also ends the collection, so a /castrandom above a /cast takes
the icon. #showtooltip gives one of these macros an icon.

QueryCastSequence stops answering if the contested-command pass hands
/castsequence to another macro addon: our table only advances while our
handler runs, so a frozen step one on a sequence somebody else drives
would be worse than no icon at all.

Three adaptations the 1.12 client forces: item detection uses the
carried-item test, since there is no name to item lookup here; the cast
events carry the spell in different argument positions per event; and
CastSpellByName takes no unit, so a targeted step routes through
C_Spell.CastAtUnit like /cast already does. /cast, /use and both random
commands now share one action dispatch instead of four copies of it.
2026-09-14 01:21:12 -05:00
Brues 298eff528c fix(quest): fire QUEST_ACCEPTED from the engine's own accept gate
The client announces "Quest accepted" from a per-quest-slot descriptor
observer, while the quest log is rebuilt from a separate one. Slot 0's
announcer shares a descriptor anchor with the rebuild node and is
registered first, so accepting into an empty quest log announced a
quest no quest-log API could see yet.

Rebuild the log at the top of the observer, as 3.3.5 does, and source
QUEST_ACCEPTED / QUEST_REMOVED from the engine's own per-slot gates
instead of a log diff. That drops the set-diff login heuristic, catches
the re-announce when a quest goes complete to incomplete, and makes the
message precede the event on every path. A cold quest cache rides the
engine's own callback queue so the deferred announce still comes first.
2026-09-13 23:35:05 -05:00
Brues b8b9da2adb ci(release): mirror releases to the Gitea instance
A Gitea pull mirror replicates commits and tags only. A release is a
database record with its own attachments, so it never crosses: the
mirror's Releases page shows bare tags with nothing to download.

Push them over through Gitea's API instead. The new workflow ensures the
tag is on the mirror (forcing a mirror-sync and waiting if it is not),
then creates the release -- or promotes the bare tag record Gitea's own
sync left behind -- and uploads each asset, replacing one whose size no
longer matches. The files come from the GitHub release rather than a
rebuild, so both hosts serve identical bytes.

`Build` calls it after every release build. Running it from the Actions
UI with a tag redoes one release; with the tag blank it backfills every
release on GitHub. A `release: published` trigger cannot work here --
releases created with the default GITHUB_TOKEN do not start another
workflow -- hence the `needs: build` job.

Endpoints, payload fields and status codes verified against the
instance's own swagger (Gitea 1.26.4).

Needs a MIRROR_TOKEN secret holding a mirror token with `repository`
write. Without it the mirror job fails with that instruction, leaving
the already-published GitHub release untouched.
2026-09-13 21:34:16 -05:00
Brues 40abcabe9d fix(unit): read the creature id from the unit's live entry, not the GUID
UnitCreatureID and C_CreatureInfo.GetCreatureID extracted the template from
GUID bits 24-47. On the (v)mangos family that is the spawn row's FIRST
creature id: Creature::CreateFromProto builds the GUID from creature_id[0]
and applies the rolled template through UpdateEntry -> SetEntry only, and a
respawn re-rolls. Turtle's creature table has up to four ids per row (6,935
of 79,773 rows use more than one), so mobs with different names reported the
same id — every Gordunni ogre from a {5236, 5238, 5239} row read 5236.

New Unit::CreatureID reads the entry the engine keys the creature NAME query
on: the instance block at [[unit+0x08]+0x0C] (FUN_00604600's own read), with
the GUID bits only as the out-of-view fallback for creature GUIDs. The
descriptor's OBJECT_FIELD_ENTRY slot reads 0 for creatures on this client,
as it does for items, so it is documented as unusable. Pet GUIDs pack the pet
number, not a template, so Guid::CreatureEntry now returns 0 for them; pets
in view report their real template from the block.

Verified in-game on Turtle: Gordunni Shaman / Battlemaster / Mage-Lord read
5236 / 5238 / 5239 while all three carry 5236 in the GUID.
2026-09-12 23:47:08 -05:00
Brues 108a91a843 feat(api): describe the API in the code, browse it with /classicapi
docs/API.md is 18.7k lines and its anchors are signature slugs, so editing a
signature breaks the 281 internal and 88 README links into it. Blizzard
solved the same problem by shipping the API as data plus a browser addon,
and warcraft.wiki.gg renders those same tables. One set of descriptors can
feed all three.

Each registration now carries one next to it: typed arguments and returns,
nilability, defaults, and a sentence. Events, enumerations and returned table
shapes are described the same way. No Script_* body changed.

Game.h gains namespace Game::Doc (Field with Req/Opt/Vararg, FieldList,
Function, Method, Structure, Event) and a trailing descriptor argument on all
six Game::Lua::Register* plus Event::Custom::AutoReserve.

api/Documentation.cpp records every registration on the first pass of each
Lua state and seals it, so /reload re-registers but records nothing and the
environment comes from which pass saw the name. Export is lazy: nothing is
materialized until C_APIDocumentation.GetSystem asks for one system, because
eager export would build thousands of tables per reload for a slash command.
_classicapi_UndocumentedAPI lists what still has no descriptor.

AddOns/!!!ClassicAPI/APIDocumentation/ ports Blizzard_APIDocumentation's eight
files. Named /classicapi, not /api: the browser holds only what ClassicAPI
adds, so /api would have implied the whole 1.12 surface. Two deliberate
differences from the retail source, both noted in the file headers: a system
search also matches its namespace, since that is the name people type; and an
undocumented function says so, because an empty signature otherwise reads as
a real one.

LinkUtil gains the SetItemRef to LinkUtil.ProcessLink dispatch. That handler
registry was dead code; a new link type now registers a handler instead of
stacking another SetItemRef override. Also defines assertsafe there, which
was undefined and only mattered once the function went live.

Documented: the C_Spell and C_SpellBook surface, the spell globals, both
spellbook enumerations, the 13 UNIT_SPELLCAST_* events, GameTooltip as the
widget-method example and C_Glue as the glue and mixed-environment example.
66 of 692 function registrations. tools/New-ApiDocSkeletons.ps1 emits
paste-ready skeletons to make the rest of the sweep tractable.

Verified in game: the browser lists, searches, and prints detail; links
render and click through; right-click copies and shift-click dumps.
2026-09-12 23:20:46 -05:00
Brues b4e5f4f2bb feat(spell): fill notInterruptible in UnitCastingInfo / UnitChannelInfo
Mirror the 3.3.5 client's own predicate on 1.12 data instead of pushing a
placeholder false. The value is relative to the local player: the spellbook
is folded into school masks of INTERRUPT_CAST spells and MOD_SILENCE spells
(cached on Player::StatSignal), a cast is not interruptible when its
PreventionType is not SILENCE or when the caster's immunity auras (effect /
state / school / mechanic immunity) strip every mask, and the flag is never
reported while the player knows no interrupt or silence at all.

1.12 differences, each documented in Spell::Interruptible: the vanilla
server's interrupt effect also needs InterruptFlags 0x02 (channels:
ChannelInterruptFlags 0x04), which silence ignores; SMSG_SPELL_START carries
no immunity block, so creature_template interrupt immunity stays invisible;
Feral Charge's interrupt lives on its triggered spell, so the capability walk
follows one TRIGGER_SPELL hop.

Verified in-game: a paladin's Holy Light reads false, flips to true while
Divine Shield is up, and back to false when it drops.

Also centralize SPELL_EFFECT_APPLY_AURA in Offsets.h (was local to Level.cpp).
2026-09-12 22:21:16 -05:00
Brues 390eec4749 docs(nameplate): correct stale frame-identity and token-shift prose
The API.md nameplate prose contradicted the code in two places, and
both were resolved in the code's favor after verifying it.

Frame identity: the prose claimed default engine plates get a fresh
wrapper table per call with unstable identity. UI::FrameObject::Push
has one path for both plate kinds — a registry ref at +0x08 of <= 0
delegates to the engine's ScriptRegister, which builds the wrapper and
writes the ref back, so every later call rawgeti's that same cached
slot. Identity is stable for both kinds and addon-set fields survive.
Dropped the registry/metatable mechanics that only existed to justify
the wrong claim; kept the pooled-frame caveat, restated with its real
reason.

Token stability: the NAME_PLATE_UNIT_ADDED note said "nameplate3" can
resolve elsewhere after a slot "vacates and shifts". AssignSlot never
shifts an entry and removal frees the slot in place. Kept the advice
to store the GUID, fixed the reason: slots are reused, never shifted.

Also fixed the iteration example one code block above the slot rules,
which broke at the first vacant slot and so missed every plate past a
gap in the sparse slot array.
2026-09-12 21:58:03 -05:00
Brues f86893c8ef fix(spell): make IsUsableSpell the engine's own usability verdict
IsUsableSpell hand-rolled five checks (known, alive, off cooldown,
power, reagents) and diverged from IsUsableAction exactly where the
engine does more or less: it never tested stance, so Whirlwind in
Battle Stance reported usable, and it tested cooldown first, so a
0-rage Whirlwind on cooldown reported (nil, nil) instead of (nil, 1).

IsUsableAction reads a per-slot cache that FUN_004E5050 fills by
calling FUN_SPELL_IS_USABLE (0x006E3D60) on a player spell's record,
or by comparing the pet's power against the spell cost for a pet
spell. Run those same two paths live, plus the knowledge gate the
helper lacks. Cooldown is not part of usability: the helper has no
cooldown branch and FrameXML greys and swipes as separate states.
Item::Usable had been calling the helper all along.

A pet-book spell given by ID now takes the pet path instead of
always reporting not usable.

Offsets.h: add VAR_PET_GUID and FUN_PET_ACTIONS_USABLE (verified via
Script_HasPetUI, Script_GetPetActionsUsable and the recompute's pet
branch); replace the FUN_SPELL_IS_USABLE comment with the decompiled
check list.

Fixes #51
2026-09-12 09:12:48 -05:00
Brues d4ac5dc210 fix(slash): only give back a command that is still ours
The contested-command heal cleared any key we added whose command string
another addon also claimed. Both halves of that test read the live table a
frame later, so an addon that takes one of our keys OVER broke it.

SuperCleveRoidMacros registers into our own `CANCELAURA` key: it replaces
the handler with its conditional one and writes `SLASH_CANCELAURA2 =
"/unbuff"` over our alias. The heal then tested `/unbuff` -- a command we
never registered -- and on a hit cleared the key, deleting that addon's
`/cancelaura` rather than handing ours back. `/unbuff` kept working through
its own owner, so the symptom was `/cancelaura` alone falling through to
`ChatEdit_ParseText`'s "Type '/help'" message.

Snapshot the handler and the command strings at file scope, while the table
holds exactly what this file registered, and skip a key whose handler is no
longer ours. Taking a key over is already how an addon wins a command, so
there is nothing left to resolve. The `/petattack` case that motivated the
heal is unchanged: that addon registers a rival key beside ours, our handler
stays in our key, and the contested string is one we registered.

Reported downstream as brues-code/SuperCleveRoidMacros#6.
2026-09-12 08:35:38 -05:00
Brues b0585f5628 feat(sound): add C_Sound.PlayVocalErrorSound and Enum.Vocalerrorsounds
Plays the player character's spoken complaint for an error ("my bags are
full") in their race's and sex's voice.

This one maps exactly rather than approximately: VocalUISounds.dbc's
second column IS the modern Enum.Vocalerrorsounds value, and this build
carries every one of the 68 values (0..67) across 11 races. Verified by
resolving a row's sound ids to their SoundEntries names — row
{9, 0, 1, 1875, 1999, ...} is error 0 for race 1, whose normal sounds are
HumanMale_InventoryFull and HumanFemale_InventoryFull, which also pins
the column layout {id, enum, race, normal[male,female],
pissed[male,female]}.

The row lookup is ours because the engine has none: it loads the table
and never reads it, the only xrefs to its records/count globals being the
loader's own writes. Only the indexing is reimplemented — the same
records[id] walk every other DBC reader here performs — while the play
goes through the engine's by-id player like every other sound.

Race and sex come from the login-time bytes rather than the unit
descriptor, so this resolves from addon-load onward, before the in-world
player object exists.

The "pissed" column is deliberately unused: many of the ids it names
match no SoundEntries row. Combinations with no recording play nothing
and raise nothing, which is data rather than an error.

Verified in game on a Night Elf female: Inventoryfull, Outofammo and
Nomana each played, and GetRecentSoundFiles showed
Sound\Character\NightElf\NightElfFemaleErrorMessages\... — right race and
sex — with varying file suffixes, so the engine's weighted variant pick
is running too.
2026-09-12 08:04:16 -05:00
Brues 6013d8b937 feat(sound): add C_Sound.PlaySoundWithOptions and GetSoundScaledVolume
The table form of PlaySound, and the volume reader that falls out of it.
Four of the params' six fields map onto something real here.

volumeOverride is the one that needed finding. FUN_00458DA0(row, stream,
scale) computes the row's own volume times `scale` and hands it to
FUN_007A5DC0, which stores it on the stream and pushes it to FMOD. The
by-id player already calls that with a scale of 1.0 immediately after
starting a sound, and the setter writes rather than accumulates, so
calling it again with another scale simply replaces the volume — no
touching FMOD directly, and no reimplementing the row's volume rules.
Resolving the row needs FUN_0045CDA0, since the applicator is a method on
it.

GetSoundScaledVolume then comes free: that same setter writes the result
to stream + 0x74, so the field IS the scaled volume. Read only off a
stream still in the engine's live list, because a finished stream's
memory returns to a pool.

soundKitID and runFinishCallback were already supported. uiSoundSubType,
forceNoDuplicates and overridePriority are accepted and ignored, and the
docs say so rather than implying they work.

Params are read with GetTable rather than a raw read, so a table built
from a mixin still resolves.

Verified in game: volumeOverride 0.1 is audibly quiet against 1.0,
runFinishCallback still fires SOUNDKIT_FINISHED through this path, and
GetSoundScaledVolume returned 0.276 for a volumeOverride of 0.4 — the
row's own 0.69 scaled, which is what confirms the multiplier semantics
the docs describe.
2026-09-12 07:46:05 -05:00
Brues 27277b11aa feat(sound): fire SOUNDKIT_FINISHED when an opted-in sound ends
Reports the handle of a finished sound, and only for sounds played with
`runFinishCallback` — the documented contract, and what makes it
affordable: without the opt-in every footstep and UI click would dispatch
a Lua event.

Detected on the world tick, NOT from FMOD's end callback. That callback
(FUN_007A53C0) runs on the STREAMER thread — it opens with
EnterCriticalSection and only moves the stream onto a deferred list — so
firing an event there would call into the Lua VM off the main thread. The
engine reclaims those streams in FUN_007A4B60, from the per-frame sound
update that ends in FSOUND_Update, so noticing completion on the main
thread is what the engine itself does.

A tracked entry keeps the stream's path hash (+0x80, stamped at creation
by FUN_007A51D0) beside the handle. Stream objects come from a pool and
their addresses are reused, so "still in the live list" is not enough on
its own — a later sound can land on the same address and the event would
be deferred to the wrong one. Comparing the hash narrows that to the same
file replaying at the same address, for one extra dword read of a node
already found alive.

C_Sound.PlaySound takes the remaining modern arguments: channel,
forceNoDuplicates and runFinishCallback. forceNoDuplicates is accepted
and ignored — the engine's by-path dedup is bit 0 of a flag word the row
itself supplies, and the by-id player ORs in only bits 1, 2 and 4 from
its arguments, so asking for it would mean bypassing that player and
re-deriving its weighted file pick.

The global PlaySound deliberately does NOT take those: it gains the
SoundKitID form and nothing else, staying as close to the global it
replaces as the extension allows.
2026-09-12 07:40:51 -05:00
Brues ccfdb8309c feat(sound): add C_Sound.PlayItemSound and Enum.ItemSoundType
Plays the noise an item makes when handled, without performing the
action. Almost all of it is the engine's: FUN_00457FF0 takes a sound type
and an ItemDisplayInfo id, walks that row's ItemGroupSounds id at +0x2C,
and plays the SoundEntries id at row + 0x04 + soundType * 4. Those four
columns are in the modern Enum.ItemSoundType order (Pickup, Drop, Use,
Close), which makes this a backport rather than a reconstruction — the
module only turns an item argument into a display id and calls it.

The item argument takes an ItemLocation table and the other forms this
project's item APIs accept (GUID string, itemID, link, carried item
name), through the shared ResolveItemArgOrLocation. Sound type is read
off the stack before resolving, since that resolver drives PackBagSlot
and stomps the stack.

Only Pickup and Drop play on a stock client, and that is the data rather
than a limit of the code: of ItemGroupSounds' 24 rows, the 18 any display
row points at (~29,000 displays) populate columns 1 and 2 only. The six
rows naming a Use sound are unreachable or broken — groups 1 and 2 have
no displays, and the ids they name (273/274/275) match no SoundEntries
row — and the Close column is zero in all 24. Use and Close fall through
the engine's own bounds checks silently, which is why no item can test
them; documented so nobody hunts for one.

The type stays a pass-through that indexes the row rather than selecting
a branch here, so a server filling those columns in gets Use and Close
working with no change on this side.

Sound keys off the item's display, not its itemID, so items that look
alike sound alike — the engine's grouping, and why every item has a sound
without one being recorded per item.

Verified in game: Pickup and Drop play, and differ by material.
2026-09-12 07:21:49 -05:00
Brues be7a8a0dd7 feat(sound): mute sound files, play by SoundKitID, and report what played
Five additions, one hook between them.

MuteSoundFile / UnmuteSoundFile stop a given audio file from playing.
They co-hook FUN_007A5450, the single funnel every sound passes through:
this client links only FMOD's FSOUND_Stream_* API, so even a one-shot UI
click is a stream opened by path, and the six callers of that function
are the whole surface (PlaySoundFile, the SoundEntries player behind
PlaySound and every DBC-driven spell/creature/UI sound, and four
music/ambience paths). A muted path returns NULL, which is the engine's
own "did not play" answer — its pre-play gate FUN_007A66A0 already
returns that at a category's concurrency cap, so every caller handles it
and PlaySoundFile already reports it to Lua as nil. Suppressing the open
rather than muting an opened stream means nothing is decoded for a sound
nobody hears; the only visible difference is PlaySoundFile returning nil
instead of 1 for a muted file.

C_Sound.GetRecentSoundFiles reports the last 64 DISTINCT files played,
newest first, with a timestamp and a muted flag — the other half of
muting, since hearing a sound tells you nothing about which of ~9,800
files it was. Always on: the hook exists for muting anyway, recording is
one bounded copy into a preallocated slot on a call that is about to open
and decompress an archive file, and a switch would have to be armed
before hearing the sound you wanted to identify.

C_Sound.PlaySound plays a SoundKitID and returns willPlay, soundHandle. A
SoundKitID is a SoundEntries row id — the same ids the modern client's
SOUNDKIT constants carry, verified against this build's table (850
igMainMenuOpen, 8959 RaidWarning, 8960 ReadyCheck). The engine has always
played by id internally; the only Lua door was the row's NAME string, so
addons written against SOUNDKIT ids could not play a sound here. The
global PlaySound is therefore widened to a strict superset: a number
plays by id, anything else tail-calls the engine with the stack untouched
so the name form is unchanged. It discriminates on lua_type rather than
lua_isnumber, which would also accept a numeric string and reroute
PlaySound("123") away from the name lookup.

C_Sound.IsPlaying validates a handle by walking the engine's live stream
list rather than dereferencing it, since a stream object is freed when
the sound ends, with a step guard so a corrupt list cannot spin inside a
Lua call. Handles are pooled and reused, which the docs call out.

FUN_0045CE60's ABI came from the disassembly, not the decompile — Ghidra
maps its arguments inconsistently against the call site. Re-registering
the PlaySound global was contention-checked first: no sibling DLL
references Script_PlaySound (UnitXP_SP3's "PlaySound" string is the winmm
PlaySoundW import), and the one addon that reassigns the global does a
temporary swap and restore around its own calls.

Verified in game: play by id and by name, mute and unmute across both
entry points, IsPlaying true while a sound runs, and the recent list
filling from sounds the player never triggered (zone music, quest log,
enter world) — which is what shows the funnel sees everything.
2026-09-12 06:59:37 -05:00
Brues f9167bac06 feat(export): add ExportSoundFiles, and sweep every archive when enumerating
ExportSoundFiles is a sibling of ExportDBCFiles that dumps the MPQ
`Sound\` tree to `BlizzardSound\`, with an optional subpath prefix (the
full tree is ~3.5 GB and the command runs synchronously). All 8803
SoundEntries rows have a `Sound\...` DirectoryBase, so that one root is
the whole corpus.

Writing it exposed a bug in the existing enumeration. FUN_00401470's
`archiveSelector` is not an archive SET, as the old comment assumed — it
is an INDEX into a fixed 10-entry table built by the mount function
FUN_00403740 (model, texture, terrain, wmo, sound, misc, interface,
fonts, speech, dbc). Each call walks the primary patch archives plus
exactly ONE of those ten. We passed 6, copied from the macro-icon
loader, so ExportInterfaceFiles was complete only by coincidence —
interface.MPQ IS selector 6 — while the sound export opened neither
sound.MPQ nor speech.MPQ and silently lost 6,561 of 9,535 indexed files.
EnumAllArchives now sweeps all ten, deduped; ExportInterfaceFiles and
ExportDBCFiles go through it too.

The sound export also unions the archive index with SoundEntries.dbc,
because each source holds files the other lacks: an MPQ resolves a read
by hashing the path, so the index is optional and 299 shipped files are
named by no index at all, while ~300 more (character combat grunts,
doodad audio referenced from model data) appear in no SoundEntries row.
Same shape ExportDBCFiles already uses to union the index with its .text
path-getter scan.

Failed reads and writes are now counted and logged rather than silently
skipped. A path only reaches that point because an index or the sound
table named it, so a failure means the two disagree — which is worth
seeing, and was invisible before: the console line gains "(N skipped)"
and each path goes to Logs\classicapi_debug.log.

Also: no extension filter (the tree is all audio, and the table carries
typo'd names like Kill3.wac a whitelist would drop), and the destination
is BlizzardSound\ rather than Sound\ — the loose-file VFS index resolves
loose files ahead of archives, so exporting into Sound\ would shadow the
MPQs with gigabytes of duplicates re-indexed at every launch.

Verified on the 1.18.1 build across both fixes: 3,184 files with the
original selector, 9,526 once SoundEntries was unioned in, 9,818 with
the full archive sweep, and no zero-byte writes. ExportInterfaceFiles
holds at 340/340 and reports nothing skipped, so neither it nor
ExportDBCFiles was ever affected.

Two residuals on this Turtle build, both logged rather than hidden. The
12 `Sound\Creature\Elekk\*` + 4 `Sound\Creature\ElekkPet\*` reads fail
because SoundEntries names a folder the archive does not use (the files
sit flat in `Sound\Creature\`) — a data inconsistency, not a code fault.
Separately, patch-7's 14 flat `Sound\Creature\Elekk_*.wav` index entries
do not reach disk, and that one is NOT explained: patch-7's listfile is
demonstrably walked (its 4 exclusive interface-code files exported, and
interface has no DBC fallback), yet those entries produced neither a file
nor a logged failure.
2026-09-12 06:31:47 -05:00
Brues 7d82ae8e92 docs: record where hooksecurefunc's unhookable list comes from
`kUnhookableNames` is patch 11.0.0's own refusal list, verbatim — the 23
taint-relevant names, not "the Lua base library". Nothing said so, and
with the base-library luaL_reg table (0x00811E28, 36 entries) freshly
catalogued the two looked like they should be one thing. They must not
be: 11.0 leaves tostring / error / loadstring / assert / collectgarbage
hookable and includes secure-family names that are no base function, so
deriving the list from the engine table would move it away from the
client. No earlier client on this machine (3.3.5, 4.3.4, 5.4.8) has the
restriction at all.

Say so in the source comment and the TODO section, and list the names in
the API reference, which documented every other hooksecurefunc error but
this one. Comment-only in the source; no behavior change.
2026-09-12 04:22:14 -05:00
Brues ed7e7628c7 docs: catalogue the Lua base library the batch scan never saw
BlizzardScriptAPI.md and raw_globals.txt were built by walking the 54
FrameScript_RegisterFunction batches, so the Lua standard libraries —
bound by luaL_openlib from luaL_reg tables at state creation — were
missing entirely. That gap read as "1.12 has no debugprofilestart", which
is wrong: the pair is stock since 1.7.0, in the base-library table at
0x00811E28 (36 entries: setglobal … debugstack).

Add the table to both files, with the mechanism (FUN_00703B80 runs the
lib open and the alias snippet at .data 0x008722E8 right before
FUN_LOAD_SCRIPT_FUNCTIONS), the debugprofile pair's clock (rdtsc delta
over a boot-calibrated ticks-per-second, milliseconds as a double), and
the rule this taught: check these tables or /dump the name before
declaring a global absent.
2026-09-12 04:15:44 -05:00
Brues a562573559 perf(table): bisect to a border in the getn heal instead of walking down
The heal's fallback walked down from the stale stored length one rawgeti
per slot — O(stale gap) — and since a length read never writes back, it
did so on EVERY luaL_getn read until the next append re-synced the
length. A table cleared by `pairs` and then polled through getn / concat
/ sort / unpack paid the full walk each time.

The search is now the doubling-then-bisect that luaH_getn uses for 5.1's
`#`, in one shared header (table/Border.h) that both `__len` and the heal
call; the copy that lived in Transpile.cpp is gone. The mark gate is
unchanged in meaning: `n - b == 1` said "slot n-1 is populated", so it is
now that one probe, tested before the registry lookup. For a fully
cleared table and a table stale by one the answer is identical; a stale
table with holes gets some border rather than the highest one, which is
what 5.1 itself returns for that table.

Verified in game: the reserved slot with a hole present (5, "c"), the
stale-by-one recycle (7, 2), the full clear (9, 1), 1,000 getn reads on a
cleared 20,000-slot table with no hitch, and `#` through the shared
search.
2026-09-12 04:05:49 -05:00
Brues 5aa382bbb1 refactor(table): register the marking table.insert instead of hooking it
Table::Length recorded deliberate `table.insert(t, nil)` appends (#36) with
a MinHook on the engine's `luaB_tinsert`. The hook is unnecessary: that
function has exactly one xref — the table-lib luaL_reg entry — so no engine
C code calls it, and every call arrives through one of two Lua values:
`table.insert` (lib open) and the global `tinsert`, which the engine's
embedded compat snippet binds by value (`tinsert = tab.insert`, .data
0x008722E8) from FUN_00703b80 right before FUN_LOAD_SCRIPT_FUNCTIONS
(calls at 0x0048fe97 / 0x0048fe9c; on glue 0x0046a87b / 0x0046a880).

So the marking function is now registered over `table.insert` and calls
`luaB_tinsert` directly, and `tinsert` is re-bound to the same closure by
value through a new `Game::Lua::RegisterGlobalAlias` — the snippet's own
idiom, so `tinsert == table.insert` stays true. Both Lua states get the
pair; the `luaL_getn` heal is unchanged. One MinHook fewer, on a hot Lua
function.

Verified in game: the identity triple (`tinsert == table.insert` and both
`_G.ClassicAPI` mirrors), the #36 reserved slot through both names, the
#39 heal, the positional form, and the error path through the new frame.
2026-09-12 03:49:43 -05:00
Brues d98d3a9fd5 perf(luasyntax): decide with one allocation-free lex before tokenizing a chunk
The load hook gated on raw bytes — memchr for `#`/`%` and three scalar
scans for `...`, `0x` and `[=` — then tokenized the whole chunk, and
RewriteAll threw the token vector away when no operator token existed.
A `%` in a format string or a `...` in prose was enough, so nearly every
addon file and every SavedVariables file paid for a token stream it did
not need: on the dev install, 723 of 1391 addon files (101.6 of 160 MB
on disk) and 39 of 301 SV files (16.7 of 16.9 MB).

The lexer is now `Lex<Sink>`, one body with two sinks. RunPasses runs it
first with `FlagSink`, which records only whether `#`, `%`, `...`, a hex
number or a leveled long bracket occurred outside a string or comment,
and tokenizes only when one did. The byte gates are gone (the flag lex
replaces all five) and RewriteAll takes the flags instead of rescanning
the tokens. A chunk with no real 5.1 syntax costs one pass and no
allocation, and the string/comment skipping stays single-sourced.

Measured in game after the change: 40,921 chunks / 19.1 MB per login,
112 chunks / 2.8 MB tokenized, 39 ms inside the rewrite in total.
`_classicapi_TranspileStats()` exposes those counters.
2026-09-11 21:05:38 -05:00
Brues 3d91ae0f91 feat(macro): show a spell named by ID even when it is not learned
`#showtooltip 1539` resolved its value through the engine's name resolver,
which maps a name -- or an ID through its name -- to a spellbook SLOT. An
unlearned spell has no slot, so the directive matched nothing and the button
greyed with no icon and no tooltip. That constraint is right for `/cast`,
which needs the slot, but an icon and a tooltip need only the spell record.

A value the directive names itself is the author saying what to display, so
it now falls back to `Spell.dbc` when the spellbook has no slot for it. The
button still greys, because the spell still cannot be cast, which is the
honest result: the macro shows what it is about and says you cannot use it.

`spell:N` is accepted as the display counterpart of `item:N`, and skips the
spellbook unconditionally -- nobody writes that form except to name a spell
for display. A bare number skips it only for an explicitly named value; one
lifted off a `/cast` line resolves exactly as far as that cast would, which
keeps the bare form honest about what the macro will actually do. A spell
NAME cannot be rescued either way, since 1.12 has no name-keyed spell index.

The name path still runs first, so a learned spell keeps its real spellbook
resolution including the pet-book flag; the record fallback only sees what
the spellbook rejected, and forces `isPet = 0` there, a pet slot being
exactly what just failed to resolve. The 1..19 inventory-slot rule stays
ahead of it, so `#show 13` is still slot 13 rather than spell 13.

A published value counts as explicit for the same reason a directive's does.
2026-09-11 17:10:51 -05:00
Brues fedccf5b95 fix(macro): give a contested slash command back to whoever owned it
Vanilla has no `/petattack`, so the conditional slash-command registry ADDS
it. SuperCleveRoidMacros ships the same command under its own SlashCmdList
key -- `PETATTACK` where we register `PET_ATTACK` -- and both set a
`SLASH_*` string of "/petattack". Neither ever overwrites the other, so
loading first decides nothing: `ChatEdit_ParseText` walks SlashCmdList with
`pairs` and takes the first match, making the winner hash order.

When ours won, `/petattack [hastarget,alive,harm]` fed that addon's
conditions to `SecureCmdOptionParse`, which does not know `hastarget` or
`alive`, so no clause matched and the line did nothing.

Rejecting a condition we cannot evaluate is right by itself -- retail does
the same -- so the fault is owning the command at all. A command we add and
someone else already binds is handed back. Splitting one command across two
condition parsers would be worse than one owner: the dialects disagree, and
ours deliberately differs from 3.3.5 for an `[@unit]`-only group.

Only added commands are given up. One we replace (`/cast`, `/target`) has no
second entry to lose to, and an addon that wraps ours still chains through
it.

The check runs a frame after load, not at file scope -- this addon loads
first, so nothing is registered to detect yet -- and not on PLAYER_LOGIN,
which has already fired during a `/reload` and would run the check
immediately to the same empty result. The addon load pass is synchronous, so
one frame is enough on both paths. A command registered by a load-on-demand
addon later keeps its own entry, and its coin flip.
2026-09-11 16:50:10 -05:00
Brues 0ba73b8457 fix(macro): leave GetMacroInfo alone and expose the shown icon instead
`Macro::Info` re-registered `GetMacroInfo` to answer with the icon a `?`
macro's `#showtooltip` resolved to, so the Macro UI's list would match the
action button. But the Macro UI seeds its icon selector from that same
return -- `MacroFrame_Update` sets `MacroPopupFrame.selectedIconTexture`
from it, and `MacroPopupFrame_Update` checks the grid button whose name
matches -- so opening the selector on such a macro pre-selected an icon the
player never picked, in the one place that edits the stored icon. Saving was
never at risk (an unclicked selector leaves `selectedIcon` nil and
`EditMacro` then leaves the icon alone), but the display was wrong.

One return cannot serve both readers, and the one that edits state has the
stronger claim, so `GetMacroInfo` goes back to the engine's: it is the read
side of the icon `C_Macro.EditMacro` writes, and every addon that captures
it now gets the stored icon.

The icon a macro SHOWS becomes something a caller asks for, through
`C_Macro.GetMacroIcon`. It reads the macro icon getter this DLL already
hooks, so the `?` rule and the active-icon choice stay in one place and the
answer matches what the action button and the drag cursor get.
Util/MacroIconSelection.lua paints the Macro UI's list buttons from it after
the stock update, which is additive -- an empty slot or a macro with a
chosen icon answers with what stock already drew.

Dropping the re-registration also takes a commonly captured global out of
the override chain.
2026-09-11 16:34:11 -05:00
Brues d9565fbe1f fix(macro): correct macro display on a client that owns the macro tooltip
SuperWoW detours the engine's macro tooltip builder and reimplements
3.3.5's version of it: build the cached spell's tooltip when the macro's
primary-spell field is non-zero, else add the macro's name. We write that
field on purpose -- it is what makes a macro button's icon, cooldown,
range, usable and auto-repeat state correct -- so every display fault here
traced back to what we put in it.

Four things were wrong:

- `MaintainExternal` re-derived "the publisher had a value" from
  `kind != None`, but `Publish` marks a claimed macro `ShowTooltip` either
  way. One tick after `SetMacroDisplay(i, false)` it turned the
  publisher's "nothing matched" (cache 0, button usable) into "named an
  unknown spell" (the grey-out sentinel), and then stuck, because the next
  pass saw the sentinel already in place. The button greyed and the
  tooltip came up empty -- a spell tooltip for -1 builds nothing and skips
  the name. `matched` is stored on the entry now.

- A publisher's claim outlived the publisher. `PrepareForReload` never
  cleared the entries and `Rescan` deliberately never re-reads a claimed
  body, so an addon disabled between reloads left every macro it had ever
  claimed frozen on its last answer. Claims are dropped on reload, each
  one handed back to the engine's own parse first.

- `LookupEntries` scanned all 36 entries for a matching macroID. That is
  not sound: `FUN_MACRO_CREATE` bumps one of two per-scope counters, so a
  general and a per-character macro can share an id, and an entry whose
  slot-map index shifted keeps its old id until the next rescan. Either
  way another macro's resolution could answer. It resolves through the
  slot map now and requires the entry to still agree.

- `#show` is icon-only, but the tooltip is not ours to delegate on this
  client: the field we fill for the icon is the same one the detoured
  builder reads, and it cannot carry the distinction. `#show` builds the
  macro's name through `Script_SetText`, which runs the identical
  sequence. `Publish` takes the kind from the body for the same reason, so
  a claim can no longer promote `#show` to `#showtooltip`.

Also: a resolved spellID with no Spell.dbc row goes back to the engine
rather than rendering an empty frame, matching the fallback 3.3.5's own
macro tooltip ends with; and a conditional directive keeps its tooltip
refreshing while hovered, which is 3.3.5's `dynamic | built` return.
2026-09-11 16:03:27 -05:00
Brues 61db68dd7f fix(lua): gate frame-method self with the engine's own type check
Every engine frame method validates its `self` in three steps before
touching it: lua_type == table, resolve to the CFrameScriptObject, then
the IsA(typeId) vmethod at vtable+0x10. We did the first two and skipped
the third, so ResolveObject handed back a pointer for ANY frame and the
method cast it to whatever type it expected.

That was reachable from plain Lua, not just through a mirror or a hook.
The per-type method dispatcher is an __index handler that rawgeti's the
method closure out of the Lua registry, so frame methods are ordinary
first-class values, and

    GameTooltip.SetSpellByID(ShapeshiftButton1, 133)

reached the tooltip builder with a Button, writing tooltip fields through
Button memory. The engine refuses that same call with "Wrong object type
for member function".

Game::Lua::ResolveTypedObject runs the engine's prologue and raises the
engine's own three error strings, read from .data rather than reinvented.
FrameScriptTypeId mirrors the lazy id assignment, so a check is valid
before any stock method of the type has run. Seven per-type wrappers stop
a call site pairing a method with the wrong class's id, and
ResolveTypedObjectAny covers a method that more than one type reaches.

45 call sites across 19 files now gate self. model/DisplayInfo.cpp had
already worked this out for Model alone and loses its private copy.

Type ids are per CLASS, one shared by all of that class's methods. Each
was read from the prologue of a method that can only belong to its class:
a base-class method registered into several tables carries the BASE id, so
sampling GetObjectType or GetDrawLayer would have produced a gate that
accepts unrelated types. Frame comes from Script_RegisterEvent and Texture
from Script_SetDesaturated, both cross-checked against the first entry of
their table.

Deliberately left ungated: the binding `owner` in bindings/Api.cpp and the
three co-hook sites in texture/Atlas.cpp. Each only uses the pointer as an
identity key and never casts it, so a gate would tighten the contract for
no safety gain -- and raising inside a co-hook would fire before the
original ran.

Verified in-game: the Button-as-tooltip call now raises the engine's
error; a Button still reaches a Frame-registry method
(button:SetAttribute round-trips), so IsA does accept derived types; and
fontstring:GetRotation() still answers -- that reader is registered on
both the Texture and FontString registries and needs the id pair rather
than the Texture id alone.
2026-09-11 14:12:40 -05:00
Brues f895d38f76 refactor(time): drop C_DateAndTime.GetServerTime for the ClassicAPI mirror
The alias existed because FrameXML takes the GetServerTime global on
some clients, and an addon needs some reachable name for the epoch.
ClassicAPI.GetServerTime now is that name, so the hand-made one is
redundant surface.

Docs now point the GetServerTime caveat at the mirror.
2026-09-11 12:37:38 -05:00
Brues 804ed61c76 feat(lua): mirror every registration under a ClassicAPI namespace
Registering a Lua name is not the same as owning it. The engine's
registrars write _G, FrameXML and every addon load after that, and
whoever writes last wins with no error and no way for the loser to
notice. GetServerTime is the case on record: a client whose GameTime.lua
declares its own replaced ours on every login, and no addon could reach
the C function again.

Every name RegisterGlobalFunction, RegisterGlueFunction and
RegisterTableFunction bind is now also bound under _G.ClassicAPI, which
nothing else writes -- ClassicAPI.GetServerTime,
ClassicAPI.C_Item.IsBound, namespaces kept as subtables. All three
registrars are single chokepoints, so this is one helper covering 637
in-game registrations plus 26 glue ones, and anything added later needs
no extra call.

Two properties, both of which depend on doing this in the registrar
rather than from addon Lua:

  - It runs at registration, so the mirror captures OUR function. A
    Lua-side `ClassicAPI.X = X` in the embedded addon would run after
    FrameXML and faithfully copy the clobbered version.
  - It mirrors by VALUE, reading the name back rather than making a
    second closure over the same C function. So `ClassicAPI.X == X`
    holds until something replaces the global, and the pair doubles as a
    clobber check:

        if GetServerTime ~= ClassicAPI.GetServerTime then ... end

EnsureSubTable uses raw get/set so a namespace carrying a metatable
cannot divert either side, and MirrorRegistration restores the stack via
an absolute gettop rather than counting pops.

Not covered, and documented as such: frame methods, which live in the
per-frame-type registry rather than _G, and RegisterIntegerEnum values,
where a mirror would be a stale copy rather than a reference.

Verified in-game: ClassicAPI.GetServerTime == GetServerTime is false on
a client whose FrameXML takes that global, while
ClassicAPI.GetTimeCached == GetTimeCached is true.
2026-09-11 12:35:36 -05:00
Brues aacace1645 fix(time): take the server clock from the engine's own time sync
`GetServerTime` had two separate problems.

The global is not ours to keep. Registration happens inside
FUN_LOAD_SCRIPT_FUNCTIONS and FrameXML loads after that, so a client
whose GameTime.lua declares its own `function GetServerTime()` replaces
it on every login and every /reload, and no addon can reach the C
function again. Turtle's version returns (serverHour, serverMinute) --
which is itself just the player's own machine clock, since it only
diverges from date() if the realm answers a TW_TIME broadcast, and
nothing in the public core does. Add C_DateAndTime.GetServerTime: the
same C function under a name FrameXML never touches.

The value was also the wrong clock. It was rebuilt from the broadcast
gametime struct, which carries minute granularity, no timezone, and
whatever per-map time offset the realm folded in before sending. The
engine already has a better source: FUN_004DE430 (quest-log enter-world
setup) sends CMSG_QUERY_TIME unconditionally, and the handler caches
localEpoch - serverEpoch at VAR_SERVER_TIME_DELTA, refreshed hourly. The
reply is a raw time(nullptr), so the skew yields a true UTC epoch with
real seconds, free of both the timezone and the map offset. The sign is
confirmed by the engine's own use of it -- the timed-quest expiry check
in FUN_004DE510 converts a server timestamp into local-clock space by
ADDING the delta.

The two clocks answer different questions, so Time::Server exposes both
and each function takes the one its contract names:

  CurrentEpoch()     the instant  GetServerTime, GetSecondsUntilDailyReset
  RealmClockEpoch()  the display  GetCurrentCalendarTime, GetServerTimeLocal

GetServerTimeLocal is "the epoch offset by the server's timezone", which
RealmClockEpoch already is by construction, so its round-trip through
the *player's* timezone is gone. GetSecondsUntilDailyReset moves the
other way: a realm buckets its day over a raw time(nullptr), so the
boundary is UTC midnight rather than the displayed midnight.

Verified in-game across an Azeroth -> Kalimdor port on two realms, one
on UTC and one on UTC-5. The instant stays continuous (+51 s across the
port, matching real elapsed time) while GetGameTime jumps 12 hours;
GetServerTimeLocal tracks the realm's displayed clock and rolls the date
with it; GetCurrentCalendarTime's hour matches GetGameTime in both zones.

Fixes #50
2026-09-11 12:17:37 -05:00
233 changed files with 24571 additions and 5969 deletions
+3
View File
@@ -53,6 +53,9 @@ jobs:
build:
needs: pick-runner
runs-on: ${{ needs.pick-runner.outputs.label }}
outputs:
tag: ${{ steps.ver.outputs.tag }}
is_release: ${{ steps.ver.outputs.is_release }}
# Windows PowerShell 5.1 is always present; the runner's default `pwsh`
# (PowerShell Core) is not installed by default, and `shell: bash` on a
# self-hosted box resolves to the WSL launcher in System32.
+12
View File
@@ -45,9 +45,21 @@ Util\Vector3D.lua
Util\Vector4D.lua
Util\UiMapPoint.lua
Util\UIParent.lua
Util\ActionButton.lua
Util\WorldMap.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
Util\SecureStateDriver.lua
Util\MacroIconSelection.lua
APIDocumentation\BaseAPIMixin.lua
APIDocumentation\FieldsAPIMixin.lua
APIDocumentation\FunctionsAPIMixin.lua
APIDocumentation\SystemsAPIMixin.lua
APIDocumentation\TablesAPIMixin.lua
APIDocumentation\EventsAPIMixin.lua
APIDocumentation\APIDocumentation.lua
APIDocumentation\APIDocumentation_Bootstrap.lua
@@ -0,0 +1,421 @@
-- Backport of Blizzard_APIDocumentation/Blizzard_APIDocumentation.lua to
-- vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the retail source:
-- - Lua 5.0 has no length operator (#); sequence length comes from
-- table.getn(t), matching the rest of this addon.
-- - Lua 5.0 has no `...` inside a function body; WriteLineF takes its
-- extra values from the implicit `arg` table.
-- - ChatFrameUtil does not exist. HandleOpenDump uses this client's
-- ChatFrame_OpenChat, which applies the text from ChatEdit_OnUpdate
-- rather than immediately -- so the cursor is placed one frame later,
-- after that text has landed.
local tGetN = table.getn;
APIDocumentationMixin = {};
-- "public"
function APIDocumentationMixin:OnLoad()
self.tables = {};
self.functions = {};
self.systems = {};
self.fields = {};
self.events = {};
self.callbacks = {};
self.Commands = {
Default = 1,
CopyAPI = 2,
OpenDump = 3,
};
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:SetMaxLines(2000);
end
end
function APIDocumentationMixin:HandleSlashCommand(command)
local commands = { (" "):split(command) };
if commands[1] == "?" or commands[1] == "help" or commands[1] == "" then
self:OutputUsage();
elseif commands[1] == "stats" then
self:OutputStats();
elseif commands[1] == "system" then
if commands[2] == "list" then
self:OutputAllSystems();
else
self:OutputUsage();
end
elseif commands[1] == "s" or commands[1] == "search" then
self:OutputAllAPIMatches(unpack(commands, 2));
elseif commands[1] then
self:TryHandlingSystemSearchCommand(unpack(commands));
else
self:OutputUsage();
end
end
function APIDocumentationMixin:HandleAPILink(link, command)
local _, type, name, parentName = (":"):split(link);
local apiInfo = self:FindAPIByName(type, name, parentName);
if apiInfo then
if command == self.Commands.CopyAPI then
self:HandleCopyAPI(apiInfo);
elseif command == self.Commands.OpenDump then
self:HandleOpenDump(apiInfo);
else
self:HandleDefaultCommand(apiInfo);
end
end
end
function APIDocumentationMixin:HandleDefaultCommand(apiInfo)
self:WriteLine(" ");
self:WriteAllLines(apiInfo:GetDetailedOutputLines());
end
function APIDocumentationMixin:HandleCopyAPI(apiInfo)
local clipboardString = apiInfo:GetClipboardString();
CopyToClipboard(clipboardString);
self:WriteLineF("Copied to clipboard: %s", clipboardString);
end
function APIDocumentationMixin:HandleOpenDump(apiInfo)
if apiInfo.Type == "Function" then
local dumpString;
local systemNamespace = apiInfo.System and apiInfo.System:GetNamespaceName() or nil;
if systemNamespace and systemNamespace ~= "" then
dumpString = ("/dump %s.%s()"):format(systemNamespace, apiInfo.Name);
else
dumpString = ("/dump %s()"):format(apiInfo.Name);
end
local desiredCursorPosition = string.len(dumpString) - 1;
ChatFrame_OpenChat(dumpString);
RunNextFrame(function()
if ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:SetCursorPosition(desiredCursorPosition);
end
end);
else
self:WriteLine("Can only /dump functions");
end
end
function APIDocumentationMixin:FindAPIByName(apiType, name, parentName)
local apiTable = self:GetAPITableByTypeName(apiType);
if apiTable then
for i, apiInfo in ipairs(apiTable) do
if apiInfo:MatchesName(name, parentName) then
return apiInfo;
end
end
end
return nil;
end
function APIDocumentationMixin:GetAPITableByTypeName(apiType)
if apiType == "function" then
return self.functions;
elseif apiType == "table" then
return self.tables;
elseif apiType == "system" then
return self.systems;
elseif apiType == "field" then
return self.fields;
elseif apiType == "event" then
return self.events;
elseif apiType == "callback" then
return self.callbacks;
end
return nil;
end
function APIDocumentationMixin:OutputUsage()
self:WriteLine("Usage:");
self:WriteLine("Search for API");
self:WriteLine(self:GetIndentString() .. "/classicapi search <api name>");
self:WriteLine(self:GetIndentString() .. "or");
self:WriteLine(self:GetIndentString() .. "/classicapi s <api name>");
self:WriteLine(self:GetIndentString() .. "Example: /classicapi search spell");
self:WriteLine(" ");
self:WriteLine("List all systems");
self:WriteLine(self:GetIndentString() .. "/classicapi system list");
self:WriteLine(" ");
self:WriteLine("Search system for API");
self:WriteLine(self:GetIndentString() .. "/classicapi <system name> search <api name>");
self:WriteLine(self:GetIndentString() .. "or");
self:WriteLine(self:GetIndentString() .. "/classicapi <system name> s <api name>");
self:WriteLine(self:GetIndentString() .. "Example: /classicapi spell search cooldown");
self:WriteLine(" ");
self:WriteLine("List all API in a system");
self:WriteLine(self:GetIndentString() .. "/classicapi <system name> list");
self:WriteLine(self:GetIndentString() .. "Example: /classicapi spell list");
self:WriteLine(" ");
self:WriteLine("All searches support Lua patterns.");
end
function APIDocumentationMixin:OutputStats()
self:WriteLine("Stats:");
self:WriteLineF("Total systems: %d", tGetN(self.systems));
local totalFunctions = 0;
local totalEvents = 0;
local totalTables = 0;
for i, systemInfo in ipairs(self.systems) do
totalFunctions = totalFunctions + systemInfo:GetNumFunctions();
totalEvents = totalEvents + systemInfo:GetNumEvents();
totalTables = totalTables + systemInfo:GetNumTables();
end
self:WriteLineF("Total functions: %d", totalFunctions);
self:WriteLineF("Total events: %d", totalEvents);
self:WriteLineF("Total tables: %d", totalTables);
end
function APIDocumentationMixin:OutputAllSystems()
self:WriteLineF("All systems (%d):", tGetN(self.systems));
for i, systemInfo in ipairs(self.systems) do
self:WriteLine(systemInfo:GetSingleOutputLine());
end
end
function APIDocumentationMixin:TryHandlingSystemSearchCommand(systemName, subCommand, apiToSearchFor)
local system = self:FindSystemByName(systemName);
if system then
if subCommand == nil then
self:WriteLine(system:GetSingleOutputLine());
elseif subCommand == "s" or subCommand == "search" and apiToSearchFor then
self:OutputAllSystemAPIMatches(system, apiToSearchFor);
elseif subCommand == "list" then
self:OutputAllSystemAPI(system);
else
self:OutputUsage();
end
else
self:WriteLineF("No system found (%s)", tostring(systemName));
end
end
function APIDocumentationMixin:OutputAPIMatches(apiMatches, headerName)
if apiMatches and tGetN(apiMatches) > 0 then
self:WriteLineF("Found %d %s", tGetN(apiMatches), headerName);
for i, api in ipairs(apiMatches) do
self:WriteLine(self:GetIndentString() .. api:GetSingleOutputLine());
end
end
end
function APIDocumentationMixin:OutputAllAPIMatches(apiToSearchFor)
if not apiToSearchFor or apiToSearchFor == "" then
self:OutputUsage();
return;
end
self:WriteLine(" ");
local apiMatches = self:FindAllAPIMatches(apiToSearchFor);
if apiMatches then
local total = tGetN(apiMatches.tables) + tGetN(apiMatches.functions) + tGetN(apiMatches.events) + tGetN(apiMatches.systems) + tGetN(apiMatches.callbacks);
assert(total > 0);
self:WriteLineF("Found %d API that matches %q", total, apiToSearchFor);
self:OutputAPIMatches(apiMatches.systems, "system(s)");
self:OutputAPIMatches(apiMatches.functions, "function(s)");
self:OutputAPIMatches(apiMatches.events, "events(s)");
self:OutputAPIMatches(apiMatches.tables, "table(s)");
self:OutputAPIMatches(apiMatches.callbacks, "callback(s)");
else
self:WriteLineF("No API found that matches %q", apiToSearchFor);
end
end
function APIDocumentationMixin:OutputAllSystemAPIMatches(system, apiToSearchFor)
local apiMatches = system:FindAllAPIMatches(apiToSearchFor);
if apiMatches then
local total = tGetN(apiMatches.tables) + tGetN(apiMatches.functions) + tGetN(apiMatches.events);
assert(total > 0);
self:WriteLineF("Found %d API that matches %q", total, apiToSearchFor);
self:OutputAPIMatches(apiMatches.functions, "function(s)");
self:OutputAPIMatches(apiMatches.events, "events(s)");
self:OutputAPIMatches(apiMatches.tables, "table(s)");
else
self:WriteLineF("No API found that matches %q in %s", apiToSearchFor, system:GenerateAPILink());
end
end
function APIDocumentationMixin:OutputAllSystemAPI(system)
local apiMatches = system:ListAllAPI();
if apiMatches then
self:WriteLineF("All API in %s", system:GenerateAPILink());
self:OutputAPIMatches(apiMatches.functions, "function(s)");
self:OutputAPIMatches(apiMatches.events, "events(s)");
self:OutputAPIMatches(apiMatches.tables, "table(s)");
else
self:WriteLineF("No API found in %s", system:GenerateAPILink());
end
end
--[[static]] function APIDocumentationMixin:AddAllMatches(apiContainer, matchesContainer, apiToSearchFor)
for i, apiInfo in ipairs(apiContainer) do
if apiInfo:MatchesSearchString(apiToSearchFor) then
table.insert(matchesContainer, apiInfo);
end
end
end
function APIDocumentationMixin:FindAllAPIMatches(apiToSearchFor)
apiToSearchFor = apiToSearchFor:lower();
local matches = {
tables = {},
functions = {},
events = {},
systems = {},
callbacks = {},
};
self:AddAllMatches(self.tables, matches.tables, apiToSearchFor);
self:AddAllMatches(self.functions, matches.functions, apiToSearchFor);
self:AddAllMatches(self.systems, matches.systems, apiToSearchFor);
self:AddAllMatches(self.events, matches.events, apiToSearchFor);
self:AddAllMatches(self.callbacks, matches.callbacks, apiToSearchFor);
-- Only return something if we matched anything
for name, subTable in pairs(matches) do
if tGetN(subTable) > 0 then
return matches;
end
end
return nil;
end
function APIDocumentationMixin:FindSystemByName(systemName)
systemName = systemName:lower();
for i, systemInfo in ipairs(self.systems) do
if systemInfo:MatchesNameCaseInsenstive(systemName) then
return systemInfo;
end
end
return nil;
end
function APIDocumentationMixin:AddDocumentationTable(documentationInfo)
if documentationInfo.Name then
self:AddSystem(documentationInfo);
else
for i, tableInfo in ipairs(documentationInfo.Tables) do
self:AddTable(tableInfo);
end
end
end
function APIDocumentationMixin:WriteLine(message)
if DEFAULT_CHAT_FRAME then
local info = ChatTypeInfo["SYSTEM"];
DEFAULT_CHAT_FRAME:AddMessage(message, info.r, info.g, info.b, info.id);
else
print(message);
end
end
-- The `...` in the parameter list is what creates `arg`; a Lua 5.0 function
-- declared without it has no `arg` at all.
function APIDocumentationMixin:WriteLineF(format, ...)
self:WriteLine(string.format(format, unpack(arg)));
end
function APIDocumentationMixin:WriteAllLines(lines)
for i, line in ipairs(lines) do
self:WriteLine(line);
end
end
function APIDocumentationMixin:GetIndentString(numIndent)
return (" "):rep(numIndent or 1);
end
-- "private"
function APIDocumentationMixin:AddTable(documentationInfo)
Mixin(documentationInfo, TablesAPIMixin);
table.insert(self.tables, documentationInfo);
if documentationInfo.Fields then
for i, field in ipairs(documentationInfo.Fields) do
field.Table = documentationInfo;
self:AddField(field);
end
end
end
function APIDocumentationMixin:AddFunction(documentationInfo)
Mixin(documentationInfo, FunctionsAPIMixin);
table.insert(self.functions, documentationInfo);
if documentationInfo.Arguments then
for i, field in ipairs(documentationInfo.Arguments) do
field.Function = documentationInfo;
self:AddField(field);
end
end
if documentationInfo.Returns then
for i, field in ipairs(documentationInfo.Returns) do
field.Function = documentationInfo;
self:AddField(field);
end
end
end
function APIDocumentationMixin:AddEvent(documentationInfo)
Mixin(documentationInfo, EventsAPIMixin);
table.insert(self.events, documentationInfo);
if documentationInfo.Payload then
for i, field in ipairs(documentationInfo.Payload) do
field.Event = documentationInfo;
self:AddField(field);
end
end
end
function APIDocumentationMixin:AddField(documentationInfo)
Mixin(documentationInfo, FieldsAPIMixin);
table.insert(self.fields, documentationInfo);
end
function APIDocumentationMixin:AddSystem(documentationInfo)
Mixin(documentationInfo, SystemsAPIMixin);
table.insert(self.systems, documentationInfo);
for i, functionInfo in ipairs(documentationInfo.Functions) do
functionInfo.System = documentationInfo;
self:AddFunction(functionInfo);
end
for i, eventInfo in ipairs(documentationInfo.Events) do
eventInfo.System = documentationInfo;
self:AddEvent(eventInfo);
end
for i, tableInfo in ipairs(documentationInfo.Tables) do
tableInfo.System = documentationInfo;
self:AddTable(tableInfo);
end
end
APIDocumentation = CreateFromMixins(APIDocumentationMixin);
APIDocumentation:OnLoad();
@@ -0,0 +1,66 @@
-- Backport of Blizzard_APIDocumentation/Blizzard_APIDocumentation_Bootstrap.lua
-- to vanilla 1.12 / Lua 5.0, plus the `/api` command and `api` link handler
-- that retail keeps in Blizzard_ChatFrameBase and Blizzard_UIPanels_Game.
--
-- Implementation differences from the retail source:
-- - Retail loads a generated data addon on demand
-- (LoadAddOnWithErrorHandling). ClassicAPI keeps the documentation in the
-- DLL instead, so APIDocumentation_LoadUI reads it through
-- C_APIDocumentation and feeds each system to AddDocumentationTable. The
-- load still happens on first use, so a session that never types /api
-- builds no tables at all.
-- - IsModifiedClick does not exist on this client. CHATLINK is Shift by
-- default, so a shift-click stands in for it.
-- - Lua 5.0 has no length operator (#); table.getn(t) gives the count.
local loaded = false;
-- Fetches every documented system from the DLL. Returns true once the
-- documentation is in place, false when the DLL is absent (the addon can be
-- installed on its own).
function APIDocumentation_LoadUI()
if loaded then
return true;
end
-- Latch BEFORE the fetch: a system table the browser chokes on must not
-- raise the same error on every later /api.
loaded = true;
if not C_APIDocumentation then
APIDocumentation:WriteLine("API documentation is not available (C_APIDocumentation is missing).");
return false;
end
-- ipairs, not a length call: it walks 1..nil whatever the table's internal
-- shape, which is how every other consumer reads an array the DLL built.
for i, name in ipairs(C_APIDocumentation.GetSystems()) do
local system = C_APIDocumentation.GetSystem(name);
if system then
APIDocumentation:AddDocumentationTable(system);
end
end
return true;
end
-- Retail calls this `/api`. Here the browser holds only what ClassicAPI adds,
-- not the 1.12 API the client already ships, so it is named for what it
-- documents. `/capi` is the short form.
SLASH_CLASSICAPI1 = "/classicapi";
SLASH_CLASSICAPI2 = "/capi";
SlashCmdList["CLASSICAPI"] = function(msg)
APIDocumentation_LoadUI();
APIDocumentation:HandleSlashCommand(msg or "");
end
LinkUtil.RegisterLinkHandler(LinkTypes.APIDocumentation, function(link, text, linkData, contextData)
APIDocumentation_LoadUI();
local command = APIDocumentation.Commands.Default;
if contextData and contextData.button == "RightButton" then
command = APIDocumentation.Commands.CopyAPI;
elseif IsShiftKeyDown() then
command = APIDocumentation.Commands.OpenDump;
end
APIDocumentation:HandleAPILink(link, command);
end);
@@ -0,0 +1,115 @@
-- Backport of Blizzard_APIDocumentation/BaseAPIMixin.lua to vanilla 1.12 /
-- Lua 5.0.
--
-- Implementation differences from the retail source: none. Every construct
-- here (method calls on string literals, string.format's %s, table.insert,
-- ipairs, CreateFromMixins) exists on this client.
BaseAPIMixin = {};
function BaseAPIMixin:GetType()
return assert(false);
end
function BaseAPIMixin:GetPrettyType()
return self:GetType();
end
function BaseAPIMixin:GetLinkHexColor()
return "ffffff";
end
function BaseAPIMixin:GetName()
return self.Name;
end
function BaseAPIMixin:GetFullName()
return self:GetName();
end
function BaseAPIMixin:GetParentName()
return "";
end
function BaseAPIMixin:GetLoweredParentName()
if not self.loweredParentName then
self.loweredParentName = self:GetParentName():lower();
end
return self.loweredParentName;
end
function BaseAPIMixin:GetLoweredName()
if not self.loweredName then
self.loweredName = self:GetName():lower();
end
return self.loweredName;
end
function BaseAPIMixin:GetClipboardString()
return self:GetFullName();
end
function BaseAPIMixin:GenerateAPILink()
return ("|cff%s|Hapi:%s:%s:%s|h%s|h|r"):format(self:GetLinkHexColor(), self:GetType(), self:GetName(), self:GetParentName(), self:GetFullName());
end
function BaseAPIMixin:GetSingleOutputLine()
return ("%s %s"):format(self:GetPrettyType(), self:GenerateAPILink());
end
function BaseAPIMixin:GetDetailedOutputLines()
return { self:GetSingleOutputLine() };
end
function BaseAPIMixin:MatchesSearchString(searchString)
return false;
end
function BaseAPIMixin:MatchesName(name, parentName)
if name == self:GetName() then
return not parentName or parentName == self:GetParentName();
end
return false;
end
function BaseAPIMixin:MatchesNameCaseInsenstive(name, parentName)
if name == self:GetLoweredName() then
return not parentName or parentName == self:GetLoweredParentName();
end
return false;
end
function BaseAPIMixin:MatchesAnyAPI(apiTable, searchString)
if apiTable then
for i, apiInfo in ipairs(apiTable) do
if apiInfo:MatchesSearchString(searchString) then
return true;
end
end
end
return false;
end
function BaseAPIMixin:MatchesAnyDocumentation(searchString)
if self.Documentation then
for i, documentation in ipairs(self.Documentation) do
if documentation:lower():match(searchString) then
return true;
end
end
end
end
function BaseAPIMixin:AddDocumentationTags(lines)
if self.Documentation then
for i, documentation in ipairs(self.Documentation) do
table.insert(lines, APIDocumentation:GetIndentString() .. documentation);
end
end
end
function BaseAPIMixin:AddSystemTag(lines)
if self.System then
table.insert(lines, APIDocumentation:GetIndentString() .. ("Part of the %s system"):format(self.System:GenerateAPILink()));
end
end
@@ -0,0 +1,104 @@
-- Backport of Blizzard_APIDocumentation/EventsAPIMixin.lua to vanilla 1.12 /
-- Lua 5.0.
--
-- Implementation differences from the retail source:
-- - The clipboard snippet uses this client's event convention. Handlers
-- here read the `event` and `arg1`..`argN` globals rather than a vararg
-- list, so the copied body declares those instead of `local a = ...`.
EventsAPIMixin = CreateFromMixins(BaseAPIMixin);
function EventsAPIMixin:GetParentName() -- override
if self.System then
return self.System:GetName();
end
return "";
end
function EventsAPIMixin:GetType() -- override
return "event";
end
function EventsAPIMixin:GetLinkHexColor()
return "77ff22";
end
function EventsAPIMixin:GetClipboardString()
local lines = {};
table.insert(lines, ([[if event == "%s" then]]):format(self.LiteralName));
if self.Payload then
local names = {};
for i, payloadInfo in ipairs(self.Payload) do
table.insert(names, ("local %s = arg%d;"):format(payloadInfo:GetName(), i));
end
table.insert(lines, "\t" .. table.concat(names, " "));
end
table.insert(lines, "end");
return table.concat(lines, "\r\n");
end
function EventsAPIMixin:GetFullName(decorateOptionals, includeColorCodes) -- override
if self.System then
return ("Event.%s.%s -> %s"):format(self.System:GetName(), self:GetName(), self:GetPayloadString(decorateOptionals, includeColorCodes));
end
return ("Event.%s -> %s"):format(self:GetName(), self:GetPayloadString(decorateOptionals, includeColorCodes));
end
function EventsAPIMixin:MatchesSearchString(searchString) -- override
if self:GetLoweredName():match(searchString) then
return true;
end
if self.System and self.System:GetLoweredName():match(searchString) then
return true;
end
if self.LiteralName:lower():match(searchString) then
return true;
end
if self:MatchesAnyDocumentation(searchString) then
return true;
end
if self:MatchesAnyAPI(self.Payload, searchString) then
return true
end
return false;
end
function EventsAPIMixin:GetPayloadString(decorateOptionals, includeColorCodes) -- override
if self.Payload then
local values = {};
for i, payloadInfo in ipairs(self.Payload) do
if includeColorCodes ~= false then
table.insert(values, ("%s|cff%s"):format(payloadInfo:GetPayloadString(decorateOptionals, includeColorCodes), self:GetLinkHexColor()));
else
table.insert(values, payloadInfo:GetPayloadString(decorateOptionals, includeColorCodes));
end
end
return table.concat(values, ", ");
end
return "";
end
function EventsAPIMixin:GetDetailedOutputLines() -- override
local lines = {};
table.insert(lines, self:GetSingleOutputLine());
self:AddSystemTag(lines);
self:AddDocumentationTags(lines);
table.insert(lines, APIDocumentation:GetIndentString() .. "Literal Name: \"" .. self.LiteralName .. "\"");
if self.Payload then
table.insert(lines, APIDocumentation:GetIndentString() .. "Payload");
for i, payloadInfo in ipairs(self.Payload) do
table.insert(lines, APIDocumentation:GetIndentString(2) .. ("%d. %s"):format(i, payloadInfo:GetSingleOutputLine()));
end
end
return lines;
end
@@ -0,0 +1,90 @@
-- Backport of Blizzard_APIDocumentation/FieldsAPIMixin.lua to vanilla 1.12 /
-- Lua 5.0.
--
-- Implementation differences from the retail source: none.
FieldsAPIMixin = CreateFromMixins(BaseAPIMixin);
function FieldsAPIMixin:GetParentName()
if self.Function then
return self.Function:GetName();
end
if self.Table then
return self.Table:GetName();
end
return "";
end
function FieldsAPIMixin:GetType()
return "field";
end
function FieldsAPIMixin:GetLinkHexColor()
return "ffdd55";
end
function FieldsAPIMixin:MatchesSearchString(searchString)
if self:GetLoweredName():match(searchString) then
return true;
end
if self:MatchesAnyDocumentation(searchString) then
return true;
end
return false;
end
function FieldsAPIMixin:GetLuaType()
if self.InnerType then
local complexType = APIDocumentation:FindAPIByName("table", self.InnerType);
return ("%s of %s"):format(self.Type, complexType and complexType:GenerateAPILink() or self.InnerType)
end
if self.EnumValue then
return self.EnumValue;
end
local complexType = APIDocumentation:FindAPIByName("table", self.Type);
return complexType and complexType:GenerateAPILink() or self.Type;
end
function FieldsAPIMixin:GetStrideIndex()
return self.StrideIndex;
end
function FieldsAPIMixin:IsOptional()
return self.Default ~= nil or self.Nilable;
end
function FieldsAPIMixin:GetSingleOutputLine()
local optionalString = "";
if self:IsOptional() then
if self.Default ~= nil then
optionalString = ("(default:%s) "):format(tostring(self.Default));
else
optionalString = "(optional) ";
end
end
if self.Documentation then
return ("%s%s %s - %s"):format(optionalString, self:GetLuaType(), self:GenerateAPILink(), table.concat(self.Documentation, " "));
end
return ("%s%s %s"):format(optionalString, self:GetLuaType(), self:GenerateAPILink());
end
function FieldsAPIMixin:GetArgumentString(decorateOptionals, includeColorCodes)
local prefix = decorateOptionals ~= false and self:IsOptional() and "optional " or "";
if includeColorCodes ~= false then
return ("|cff%s%s%s|r"):format(self:GetLinkHexColor(), prefix, self:GetName());
end
return ("%s%s"):format(prefix, self:GetName());
end
function FieldsAPIMixin:GetReturnString(decorateOptionals, includeColorCodes)
return self:GetArgumentString(decorateOptionals, includeColorCodes); -- Nothing special currently
end
function FieldsAPIMixin:GetPayloadString(decorateOptionals, includeColorCodes)
return self:GetReturnString(decorateOptionals, includeColorCodes); -- Nothing special currently
end
@@ -0,0 +1,121 @@
-- Backport of Blizzard_APIDocumentation/FunctionsAPIMixin.lua to vanilla 1.12
-- / Lua 5.0.
--
-- Implementation differences from the retail source: none.
--
-- A widget-method system (Type "ScriptObject") carries no Namespace, so
-- GetFullName prints its methods bare -- "SetSpellByID(spellID)" -- exactly as
-- retail prints its own widget API.
FunctionsAPIMixin = CreateFromMixins(BaseAPIMixin);
function FunctionsAPIMixin:GetParentName()
if self.System then
return self.System:GetName();
end
return "";
end
function FunctionsAPIMixin:GetType()
return "function";
end
function FunctionsAPIMixin:GetLinkHexColor()
return "55ddff";
end
function FunctionsAPIMixin:GetFullName(decorateOptionals, includeColorCodes)
if self.System and self.System:GetNamespaceName() ~= "" then
return ("%s.%s(%s)"):format(self.System:GetNamespaceName(), self:GetName(), self:GetArgumentString(decorateOptionals, includeColorCodes));
end
return ("%s(%s)"):format(self:GetName(), self:GetArgumentString(decorateOptionals, includeColorCodes));
end
function FunctionsAPIMixin:MatchesSearchString(searchString)
if self:GetLoweredName():match(searchString) then
return true;
end
if self:MatchesAnyDocumentation(searchString) then
return true;
end
if self:MatchesAnyAPI(self.Arguments, searchString) then
return true
end
if self:MatchesAnyAPI(self.Returns, searchString) then
return true
end
return false;
end
function FunctionsAPIMixin:GetArgumentString(decorateOptionals, includeColorCodes)
if self.Arguments then
local values = {};
for i, argumentInfo in ipairs(self.Arguments) do
if includeColorCodes ~= false then
table.insert(values, ("%s|cff%s"):format(argumentInfo:GetArgumentString(decorateOptionals, includeColorCodes), self:GetLinkHexColor()));
else
table.insert(values, argumentInfo:GetArgumentString(decorateOptionals, includeColorCodes));
end
end
return table.concat(values, ", ");
end
return "";
end
function FunctionsAPIMixin:GetReturnString(decorateOptionals, includeColorCodes)
if self.Returns then
local values = {};
for i, returnInfo in ipairs(self.Returns) do
if includeColorCodes ~= false then
table.insert(values, ("%s|cff%s"):format(returnInfo:GetReturnString(decorateOptionals, includeColorCodes), self:GetLinkHexColor()));
else
table.insert(values, returnInfo:GetReturnString(decorateOptionals, includeColorCodes));
end
end
return table.concat(values, ", ");
end
return "";
end
function FunctionsAPIMixin:GetClipboardString()
if self.Returns then
return ("local %s = %s"):format(self:GetReturnString(false, false), self:GetFullName(false, false));
end
return self:GetFullName(false, false);
end
function FunctionsAPIMixin:GetDetailedOutputLines()
local lines = {};
table.insert(lines, self:GetSingleOutputLine());
self:AddSystemTag(lines);
self:AddDocumentationTags(lines);
if self.Arguments then
table.insert(lines, APIDocumentation:GetIndentString() .. "Arguments");
for i, argumentInfo in ipairs(self.Arguments) do
if argumentInfo:GetStrideIndex() == 1 then
table.insert(lines, APIDocumentation:GetIndentString(2) .. "(Variable arguments)");
end
table.insert(lines, APIDocumentation:GetIndentString(2) .. ("%d. %s"):format(i, argumentInfo:GetSingleOutputLine()));
end
end
if self.Returns then
table.insert(lines, APIDocumentation:GetIndentString() .. "Returns");
for i, returnInfo in ipairs(self.Returns) do
if returnInfo:GetStrideIndex() == 1 then
table.insert(lines, APIDocumentation:GetIndentString(2) .. "(Variable returns)");
end
table.insert(lines, APIDocumentation:GetIndentString(2) .. ("%d. %s"):format(i, returnInfo:GetSingleOutputLine()));
end
end
return lines;
end
@@ -0,0 +1,159 @@
-- Backport of Blizzard_APIDocumentation/SystemsAPIMixin.lua to vanilla 1.12 /
-- Lua 5.0.
--
-- Implementation differences from the retail source:
-- - Lua 5.0 has no length operator (#); sequence length comes from
-- table.getn(t), matching the rest of this addon.
-- - A search also matches a system's namespace. The namespace is the name
-- people type, and retail's version checks only the short name, so
-- "search C_Spell" found nothing while "search spell" found the system.
local tGetN = table.getn;
SystemsAPIMixin = CreateFromMixins(BaseAPIMixin);
function SystemsAPIMixin:GetType()
return "system";
end
function SystemsAPIMixin:GetLinkHexColor()
return "ff55dd";
end
function SystemsAPIMixin:GetNamespaceName()
return self.Namespace or "";
end
function SystemsAPIMixin:GetLoweredNamespaceName()
if not self.loweredNamespaceName then
self.loweredNamespaceName = self:GetNamespaceName():lower();
end
return self.loweredNamespaceName;
end
function SystemsAPIMixin:MatchesSearchString(searchString)
if self:GetLoweredName():match(searchString) then
return true;
end
local namespace = self:GetLoweredNamespaceName();
if namespace ~= "" and namespace:match(searchString) then
return true;
end
if self:MatchesAnyDocumentation(searchString) then
return true;
end
return false;
end
function SystemsAPIMixin:GetSingleOutputLine()
if self.Namespace then
return ("%s %s (%s)"):format(self:GetPrettyType(), self:GenerateAPILink(), self.Namespace);
end
return BaseAPIMixin.GetSingleOutputLine(self);
end
function SystemsAPIMixin:GetDetailedOutputLines()
local lines = {};
table.insert(lines, self:GetSingleOutputLine());
self:AddDocumentationTags(lines);
if self.Functions and tGetN(self.Functions) > 0 then
table.insert(lines, APIDocumentation:GetIndentString() .. "Functions");
for i, functionInfo in ipairs(self.Functions) do
table.insert(lines, APIDocumentation:GetIndentString(2) .. functionInfo:GetSingleOutputLine());
end
end
if self.Events and tGetN(self.Events) > 0 then
table.insert(lines, APIDocumentation:GetIndentString() .. "Events");
for i, eventInfo in ipairs(self.Events) do
table.insert(lines, APIDocumentation:GetIndentString(2) .. eventInfo:GetSingleOutputLine());
end
end
if self.Tables and tGetN(self.Tables) > 0 then
table.insert(lines, APIDocumentation:GetIndentString() .. "Tables");
for i, tableInfo in ipairs(self.Tables) do
table.insert(lines, APIDocumentation:GetIndentString(2) .. tableInfo:GetSingleOutputLine());
end
end
return lines;
end
function SystemsAPIMixin:MatchesName(name, parentName)
if BaseAPIMixin.MatchesName(self, name, parentName) then
return true;
end
return name == self:GetNamespaceName();
end
function SystemsAPIMixin:MatchesNameCaseInsenstive(name, parentName)
if BaseAPIMixin.MatchesNameCaseInsenstive(self, name, parentName) then
return true;
end
return name == self:GetLoweredNamespaceName();
end
function SystemsAPIMixin:FindAllAPIMatches(apiToSearchFor)
apiToSearchFor = apiToSearchFor:lower();
local matches = {
tables = {},
functions = {},
events = {},
};
APIDocumentationMixin:AddAllMatches(self.Tables, matches.tables, apiToSearchFor);
APIDocumentationMixin:AddAllMatches(self.Functions, matches.functions, apiToSearchFor);
APIDocumentationMixin:AddAllMatches(self.Events, matches.events, apiToSearchFor);
-- Only return something if we matched anything
for name, subTable in pairs(matches) do
if tGetN(subTable) > 0 then
return matches;
end
end
return nil;
end
local function AddAll(apiContainer, matchesContainer)
if apiContainer then
for i, apiInfo in ipairs(apiContainer) do
table.insert(matchesContainer, apiInfo);
end
end
end
function SystemsAPIMixin:ListAllAPI()
local allAPI = {
tables = {},
functions = {},
events = {},
};
AddAll(self.Tables, allAPI.tables);
AddAll(self.Functions, allAPI.functions);
AddAll(self.Events, allAPI.events);
return allAPI;
end
function SystemsAPIMixin:GetNumTables()
return self.Tables and tGetN(self.Tables) or 0;
end
function SystemsAPIMixin:GetNumFunctions()
return self.Functions and tGetN(self.Functions) or 0;
end
function SystemsAPIMixin:GetNumEvents()
return self.Events and tGetN(self.Events) or 0;
end
@@ -0,0 +1,74 @@
-- Backport of Blizzard_APIDocumentation/TablesAPIMixin.lua to vanilla 1.12 /
-- Lua 5.0.
--
-- Implementation differences from the retail source: none.
TablesAPIMixin = CreateFromMixins(BaseAPIMixin);
function TablesAPIMixin:GetParentName()
if self.System then
return self.System:GetName();
end
return "";
end
function TablesAPIMixin:GetType()
return "table";
end
function TablesAPIMixin:GetPrettyType()
return self.Type:lower();
end
function TablesAPIMixin:GetLinkHexColor()
return "55ffdd";
end
function TablesAPIMixin:GetFullName() -- override
if self.Type == "Enumeration" then
return ("Enum.%s"):format(self:GetName());
end
return BaseAPIMixin.GetFullName(self);
end
function TablesAPIMixin:MatchesSearchString(searchString)
if self:GetLoweredName():match(searchString) then
return true;
end
if self:MatchesAnyDocumentation(searchString) then
return true;
end
if self:MatchesAnyAPI(self.Fields, searchString) then
return true
end
return false;
end
function TablesAPIMixin:GetDetailedOutputLines()
local lines = {};
table.insert(lines, self:GetSingleOutputLine());
self:AddSystemTag(lines);
self:AddDocumentationTags(lines);
if self.Fields then
if self.Type == "Enumeration" then
table.insert(lines, APIDocumentation:GetIndentString() .. "Num Values: " .. self.NumValues);
table.insert(lines, APIDocumentation:GetIndentString() .. "Min Value: " .. self.MinValue);
table.insert(lines, APIDocumentation:GetIndentString() .. "Max Value: " .. self.MaxValue);
table.insert(lines, APIDocumentation:GetIndentString() .. "Values");
else
table.insert(lines, APIDocumentation:GetIndentString() .. "Fields");
end
for i, fieldInfo in ipairs(self.Fields) do
table.insert(lines, APIDocumentation:GetIndentString(2) .. fieldInfo:GetSingleOutputLine());
end
end
return lines;
end
@@ -0,0 +1,98 @@
-- ActionButtonUseKeyDown: action-bar key bindings act on the key press when
-- the cvar is set, on the release otherwise -- 4.3.4's FrameXML handlers
-- (ActionButton.lua, MultiActionBars.lua, PetActionBarFrame.lua). Each keeps
-- 1.12's own body; 4.3.4 adds only the cvar test: the press always shows the
-- button pushed and acts when the cvar is set, the release shows it normal
-- and acts when it is not. The engine registers the cvar (default 0, so the
-- release stays the default).
local GetCVarBool = ClassicAPI.C_CVar.GetCVarBool;
local function UseKeyDown()
return GetCVarBool("ActionButtonUseKeyDown");
end
-- 1.12's ActionButtonUp / MultiActionButtonUp body.
local function UseActionButton(button, onSelf)
if ( MacroFrame_SaveMacro ) then
MacroFrame_SaveMacro();
end
UseAction(ActionButton_GetPagedID(button), 0, onSelf);
if ( IsCurrentAction(ActionButton_GetPagedID(button)) ) then
button:SetChecked(1);
else
button:SetChecked(0);
end
end
local function MainBarButton(id)
if ( BonusActionBarFrame:IsShown() ) then
return getglobal("BonusActionButton"..id), true;
end
return getglobal("ActionButton"..id), false;
end
function ActionButtonDown(id)
local button = MainBarButton(id);
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED");
end
if ( UseKeyDown() ) then
UseActionButton(button);
end
end
function ActionButtonUp(id, onSelf)
local button, bonus = MainBarButton(id);
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL");
if ( not UseKeyDown() ) then
-- 1.12's bonus-bar branch uses the action without onSelf.
if ( bonus ) then
UseActionButton(button);
else
UseActionButton(button, onSelf);
end
end
end
end
function MultiActionButtonDown(bar, id)
local button = getglobal(bar.."Button"..id);
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED");
end
if ( UseKeyDown() ) then
UseActionButton(button);
end
end
function MultiActionButtonUp(bar, id, onSelf)
local button = getglobal(bar.."Button"..id);
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL");
if ( not UseKeyDown() ) then
UseActionButton(button, onSelf);
end
end
end
function PetActionButtonDown(id)
local button = getglobal("PetActionButton"..id);
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED");
end
if ( UseKeyDown() ) then
CastPetAction(id);
end
end
function PetActionButtonUp(id)
local button = getglobal("PetActionButton"..id);
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL");
if ( not UseKeyDown() ) then
CastPetAction(id);
end
end
end
+19
View File
@@ -127,6 +127,25 @@ if C_AddOns.DoesAddOnExist("LunaUnitFrames") then
end)
end
-- Turtle's Options frame sets `GroupFrame_ToggleMovement(state)` as the
-- "Toggle Movement" button's OnClick (FrameXML\OptionsFrame.lua), while the
-- group dropdown and the addon itself call it directly with 0 or 1. As a
-- handler it expects `state` to be nil so it toggles from the current mouse
-- state. Given `self` instead, it does `1 - state` on the button and errors
-- (Turtle_GroupUI.lua:409). The button is reused and its script re-set on every
-- redraw, so wrap the global it reads rather than the frame: only a number
-- passes through.
if C_AddOns.DoesAddOnExist("Turtle_GroupUI") then
EventUtil.ContinueOnAddOnLoaded("Turtle_GroupUI", function()
local original = GroupFrame_ToggleMovement
if not original then return end
GroupFrame_ToggleMovement = function(state)
if type(state) ~= "number" then state = nil end
return original(state)
end
end)
end
if C_AddOns.DoesAddOnExist("Puppeteer") then
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
@@ -27,3 +27,18 @@ function EquipmentManager_GetLocationData(location)
return locationData;
end
function EquipmentManager_EquipSet(setID)
if ( C_EquipmentSet.EquipmentSetContainsLockedItems(setID) or C_Spell.UnitCastingInfo("player") ) then
UIErrorsFrame:AddMessage(ERR_CLIENT_LOCKED_OUT, 1.0, 0.1, 0.1, 1.0);
return;
end
C_EquipmentSet.UseEquipmentSet(setID);
end
-- An equipment-set action button fires WEAR_EQUIPMENT_SET; equipping is the UI's job.
local EquipmentManagerFrame = CreateFrame("Frame");
EquipmentManagerFrame:SetScript("OnEvent", function()
EquipmentManager_EquipSet(arg1);
end);
EquipmentManagerFrame:RegisterEvent("WEAR_EQUIPMENT_SET");
+34
View File
@@ -61,6 +61,18 @@ LinkTypes = {
LinkUtil = {};
-- `assertsafe` isn't a global in stock 1.12; defined as a local fallback so
-- this file stays self-contained, the same way FunctionUtil.lua and Pools.lua
-- do. Only fires when `condition` is falsy, matching Blizzard's semantics.
local function assertsafe(condition, msg)
if not condition then
local handler = geterrorhandler and geterrorhandler();
if handler then
handler(msg or "assertion failed!");
end
end
end
function LinkUtil.FormatLink(linkType, linkDisplayText, ...)
local linkFormatTable = { string.format("|H%s", linkType) };
for i = 1, arg.n do
@@ -185,3 +197,25 @@ do
s_linkHandlerFunctions[linkType] = handlerFunction;
end
end
------------------------------------------------------------------------------
-- Click dispatch. Retail's SetItemRef opens with exactly this: build the
-- context, offer the link to the registered handlers, and stop if one took it.
-- 1.12's SetItemRef knows only item and player links and would reject anything
-- else, so the dispatch wraps it here instead of living in the engine.
--
-- The chat renderer itself accepts any |H<type>:...|h[text]|h and makes it
-- clickable, so a handler is all a new link type needs.
--
-- Capture-and-replace, not hooksecurefunc: a post-hook cannot stop the engine
-- from rejecting the link first. 1.12 passes (link, text, button); `link` is
-- the payload without the |H and |h wrappers, `text` the whole decorated link.
------------------------------------------------------------------------------
local originalSetItemRef = SetItemRef;
function SetItemRef(link, text, button, chatFrame)
local contextData = { button = button, frame = chatFrame };
if LinkUtil.ProcessLink(link, text, contextData) == LinkProcessorResponse.Handled then
return;
end
return originalSetItemRef(link, text, button, chatFrame);
end
@@ -0,0 +1,52 @@
-- MacroIconSelection.lua -- the Macro UI's macro list shows the icon a
-- `#showtooltip` resolved to, the way the action button does.
--
-- `GetMacroInfo` reports the icon the macro STORES, and it has to keep
-- reporting that one: `MacroFrame_Update` seeds the icon selector from that
-- return, so answering with a resolved icon there would pre-select an icon the
-- player never picked, in the one place that edits the stored icon.
--
-- That leaves the list buttons showing the question mark for a macro whose
-- action button shows a spell. `C_Macro.GetMacroIcon` returns the icon a macro
-- currently shows, so paint the buttons from it once the stock update has
-- drawn them. An empty slot and a macro with a chosen icon both answer with
-- what stock already drew, so those are left alone.
local function ApplyIcon(texture, macroSlot)
if not texture then
return
end
local shown = C_Macro.GetMacroIcon(macroSlot)
if shown then
texture:SetTexture(shown)
end
end
local function PaintMacroList()
if not (MacroFrame and MacroFrame.macroBase) then
return
end
for i = 1, MAX_MACROS do
ApplyIcon(_G["MacroButton" .. i .. "Icon"], MacroFrame.macroBase + i)
end
if MacroFrame.selectedMacro then
ApplyIcon(MacroFrameSelectedMacroButtonIcon, MacroFrame.selectedMacro)
end
end
local function WrapMacroFrameUpdate()
if not MacroFrame_Update then
return
end
local original = MacroFrame_Update
-- No `...`: `MacroFrame_Update` takes no arguments, and Lua 5.0 would
-- build an `arg` table for every vararg call.
MacroFrame_Update = function()
original()
PaintMacroList()
end
end
-- Blizzard_MacroUI loads on demand.
EventUtil.ContinueOnAddOnLoaded("Blizzard_MacroUI", WrapMacroFrameUpdate)
+32 -14
View File
@@ -24,8 +24,9 @@
-- one ClassicAPI extension: [known:spellID] / [known:name].
-- Where a keyword's underlying state does not exist on 1.12 (flying, flyable,
-- vehicleui, unithasvehicleui) the condition is a constant false; where 1.12
-- only has a partial answer (spec is always primary, button/actionbar default
-- to the resting state) the closest honest mapping is used. An UNKNOWN keyword
-- only has a partial answer (spec is always primary; actionbar defaults to the
-- resting state, as does button when no click is running) the closest honest
-- mapping is used. An UNKNOWN keyword
-- is a hard false with a one-time warning -- never a silent pass -- so a typo
-- can't wrongly show a frame. See docs/API.md for the full contract.
--
@@ -217,29 +218,46 @@ CONDITIONS.spec = function(target, args)
return true;
end
-- [mod:X/Y] passes when any value is a held modified click: a modifier string
-- ("shift", "lctrl", "alt-shift") or a modified-click action ("SELFCAST"), as
-- 4.3.4's EvaluateModifier asks IsModifiedClick. Bare [mod] is any of them.
local IsModifiedClick = ClassicAPI.IsModifiedClick;
CONDITIONS.modifier = function(target, args)
if args then
for i = 1, args.n do
local a = strlower(args[i]);
if a == "shift" then
if IsShiftKeyDown() then return true; end
elseif a == "ctrl" then
if IsControlKeyDown() then return true; end
elseif a == "alt" then
if IsAltKeyDown() then return true; end
end
if IsModifiedClick(args[i]) then return true; end
end
return false;
end
return IsShiftKeyDown() or IsControlKeyDown() or IsAltKeyDown();
return IsModifiedClick() ~= nil;
end
-- Outside a click context the "current" button is the left button.
-- [button:N] numbers the mouse buttons the way the rest of the modern API
-- does -- 1 left, 2 RIGHT, 3 middle, 4, 5. That is deliberately not the order
-- of the engine's internal click bitmask (where 2 is the middle button), which
-- never reaches Lua. A name is matched as written, so a button clicked with an
-- explicit name (`someButton:Click("LeftButton")`) answers [button:leftbutton]
-- as well as [button:1].
local BUTTON_NUMBER_NAMES = {
["1"] = "leftbutton",
["2"] = "rightbutton",
["3"] = "middlebutton",
["4"] = "button4",
["5"] = "button5",
};
CONDITIONS.button = function(target, args)
if args then
-- GetMouseButtonClicked() is the button of the click this macro is
-- running inside. It reads nil when no click is running -- a state
-- driver poll, or the re-evaluation behind a macro's #showtooltip
-- display -- and the engine's own no-argument Button:Click() means the
-- left button, so that is the resting answer.
local current = strlower(GetMouseButtonClicked() or "LeftButton");
for i = 1, args.n do
local a = args[i];
if a == "1" or strlower(a) == "leftbutton" then return true; end
local a = strlower(args[i]);
if (BUTTON_NUMBER_NAMES[a] or a) == current then return true; end
end
return false;
end
+182 -185
View File
@@ -1,222 +1,219 @@
-- SecureStateDriver: automatic frame state / visibility from macro conditionals.
--
-- Backports FrameXML's SecureStateDriverManager. A registered driver re-runs
-- SecureCmdOptionParse (Util/MacroOptions.lua) on a 0.2s poll (plus an instant
-- rescan on the events below) and applies the result:
-- * RegisterStateDriver(frame, "visibility", "[combat] hide; show") -> the
-- value "show"/"hide" drives frame:Show()/Hide();
-- * RegisterStateDriver / RegisterAttributeDriver for any other state ->
-- frame:SetAttribute("state-"..state, value), so an OnAttributeChanged
-- handler reacts to it;
-- * RegisterUnitWatch(frame [, asState]) -> shows/hides (or sets
-- "state-unitexists") as the frame's `unit` attribute comes and goes.
-- SecureStateDriverManager
-- Automatically sets states based on macro options for state driver frames
-- Also handled showing/hiding frames based on unit existence (code originally by Tem)
--
-- Base: 3.3.5 FrameXML/SecureStateDriver.lua, with the anniversary refinements
-- grafted in (the resolveDriver() consolidation, tonumber() coercion of driver
-- values, the literal string 'nil' -> nil, and the "statehidden" attribute).
-- 4.3.4 FrameXML/SecureStateDriver.lua, with three changes this client needs:
-- * Each handler falls back to the vanilla globals (`this`, `arg1`, `arg2`)
-- when called without arguments, so it keeps working while modern script
-- arguments are switched off (SetModernScriptArgs(false)).
-- * `string.match` instead of `strmatch`, which this client does not define.
-- * Events this client does not define (UPDATE_STEALTH, ...) are skipped
-- instead of registered, and UPDATE_SHAPESHIFT_FORMS (the 1.12 name) is
-- registered alongside UPDATE_SHAPESHIFT_FORM.
-- SecureCmdOptionParse is Util/MacroOptions.lua; SecureButton_GetUnit is
-- SecureTemplates.lua (loaded earlier).
-- Register a frame attribute to be set automatically with changes in game state
function RegisterAttributeDriver(frame, attribute, values)
if ( attribute and values and attribute:sub(1, 1) ~= "_" ) then
SecureStateDriverManager:SetAttribute("setframe", frame);
SecureStateDriverManager:SetAttribute("setstate", attribute.." "..values);
end
end
-- Unregister a frame from the state driver manager.
function UnregisterAttributeDriver(frame, attribute)
if ( attribute ) then
SecureStateDriverManager:SetAttribute("setframe", frame);
SecureStateDriverManager:SetAttribute("setstate", attribute);
else
SecureStateDriverManager:SetAttribute("delframe", frame);
end
end
-- Bridge functions for compatibility
function RegisterStateDriver(frame, state, values)
return RegisterAttributeDriver(frame, "state-"..state, values);
end
function UnregisterStateDriver(frame, state)
return UnregisterAttributeDriver(frame, "state-"..state);
end
-- Register a frame to be notified when a unit's existence changes, the
-- unit is obtained from the frame's attributes. If asState is true then
-- notification is via the 'state-unitexists' attribute with values
-- true and false. Otherwise it's via :Show() and :Hide()
function RegisterUnitWatch(frame, asState)
if ( asState ) then
SecureStateDriverManager:SetAttribute("addwatchstate", frame);
else
SecureStateDriverManager:SetAttribute("addwatch", frame);
end
end
-- Unregister a frame from the unit existence monitor.
function UnregisterUnitWatch(frame)
SecureStateDriverManager:SetAttribute("removewatch", frame);
end
--
-- Three deliberate deviations from upstream, all because 1.12 has no taint and
-- our attribute system fires OnAttributeChanged SYNCHRONOUSLY:
-- 1. No attribute-bootstrap manager. Upstream drives itself through
-- SecureStateDriverManager:SetAttribute("setframe"/"setstate"/...) -- a
-- taint-boundary trick. Our SetAttribute has a recursion guard (a nested
-- SetAttribute stores the value but does NOT fire the handler, see
-- src/frame/Attributes.cpp), so doing the registration-time resolve from
-- inside that handler would silently swallow the target frame's own
-- OnAttributeChanged. Instead the public Register* functions write plain
-- local tables and resolve immediately at top level. Public signatures are
-- unchanged from retail.
-- 2. GetTime()-deadline throttle instead of the OnUpdate `elapsed` arg (1.12
-- handlers take no parameters).
-- 3. UpdateUnitWatch guards its "statehidden"/"state-unitexists" writes on a
-- real change, so a watched frame's handler is not fired at 5 Hz.
-- ---------------------------------------------------------------------------
-- SecureButton_* (minimal -- no taint, no modifier/button suffix machinery)
-- ---------------------------------------------------------------------------
-- SecureButton_GetAttribute and SecureButton_GetUnit are defined in
-- SecureTemplates.lua (loaded earlier via SecureTemplates.xml).
-- ---------------------------------------------------------------------------
-- Private state
-- ---------------------------------------------------------------------------
local secureAttributeDrivers = {}; -- [frame][attribute] = optionsString
local unitExistsWatchers = {}; -- [frame] = doState (boolean)
local unitExistsCache = setmetatable({}, {
__index = function(t, k)
local v = (UnitExists(k) and true) or false;
t[k] = v;
return v;
end,
});
-- Private implementation
--
local secureAttributeDrivers = {};
local unitExistsWatchers = {};
local unitExistsCache = setmetatable({},
{ __index = function(t,k)
local v = UnitExists(k) or false;
t[k] = v;
return v;
end
});
local STATE_DRIVER_UPDATE_THROTTLE = 0.2;
local nextUpdate = 0;
local timer = 0;
local wipe = table.wipe;
-- Check to see if a frame is registered
function UnitWatchRegistered(frame)
return not (unitExistsWatchers[frame] == nil);
end
local function SecureStateDriverManager_UpdateUnitWatch(frame, doState)
local unit = SecureButton_GetUnit(frame);
local exists = (unit and unitExistsCache[unit]);
if ( doState ) then
local attr = exists or false;
if ( frame:GetAttribute("state-unitexists") ~= attr ) then
frame:SetAttribute("state-unitexists", attr);
end
else
if ( exists ) then
frame:Show();
frame:SetAttribute("statehidden", nil);
else
frame:Hide();
frame:SetAttribute("statehidden", true);
end
end
end
local pairs = pairs;
function UnitWatchRegistered(frame)
return unitExistsWatchers[frame] ~= nil;
end
-- ---------------------------------------------------------------------------
-- Resolvers
-- ---------------------------------------------------------------------------
local function UpdateUnitWatch(frame, doState)
local unit = SecureButton_GetUnit(frame);
local exists = (unit and unitExistsCache[unit]) or false;
if doState then
if frame:GetAttribute("state-unitexists") ~= exists then
frame:SetAttribute("state-unitexists", exists);
end
else
if exists then
frame:Show();
else
frame:Hide();
end
local hidden = (not exists) or nil;
if frame:GetAttribute("statehidden") ~= hidden then
frame:SetAttribute("statehidden", hidden);
end
end
end
-- consolidate duplicated code for footprint and maintainability
local function resolveDriver(frame, attribute, values)
local newValue = SecureCmdOptionParse(values);
local newValue = SecureCmdOptionParse(values);
if attribute == "state-visibility" then
if newValue == "show" then
frame:Show();
if frame:GetAttribute("statehidden") ~= nil then
frame:SetAttribute("statehidden", nil);
end
elseif newValue == "hide" then
frame:Hide();
if frame:GetAttribute("statehidden") ~= true then
frame:SetAttribute("statehidden", true);
if ( attribute == "state-visibility" ) then
if ( newValue == "show" ) then
frame:Show();
frame:SetAttribute("statehidden", nil);
elseif ( newValue == "hide" ) then
frame:Hide();
frame:SetAttribute("statehidden", true);
end
elseif ( newValue ) then
if ( newValue == 'nil' ) then
newValue = nil;
else
newValue = tonumber(newValue) or newValue;
end
local oldValue = frame:GetAttribute(attribute);
if ( newValue ~= oldValue ) then
frame:SetAttribute(attribute, newValue);
end
end
end
local function SecureStateDriverManager_OnUpdate(self,elapsed)
elapsed = elapsed or arg1;
timer = timer - elapsed;
if ( timer <= 0 ) then
timer = STATE_DRIVER_UPDATE_THROTTLE;
-- Handle state driver updates
for frame,drivers in pairs(secureAttributeDrivers) do
for attribute,values in pairs(drivers) do
resolveDriver(frame, attribute, values);
end
end
elseif newValue then
if newValue == "nil" then
newValue = nil;
else
newValue = tonumber(newValue) or newValue;
-- Handle unit existence changes
wipe(unitExistsCache);
for k in pairs(unitExistsCache) do
unitExistsCache[k] = nil;
end
if frame:GetAttribute(attribute) ~= newValue then
frame:SetAttribute(attribute, newValue);
for frame,doState in pairs(unitExistsWatchers) do
SecureStateDriverManager_UpdateUnitWatch(frame, doState);
end
end
end
-- ---------------------------------------------------------------------------
-- Manager frame
-- ---------------------------------------------------------------------------
local function SecureStateDriverManager_OnEvent(self, event)
timer = 0;
end
SecureStateDriverManager = CreateFrame("Frame", "SecureStateDriverManager");
SecureStateDriverManager:Hide(); -- hidden -> no OnUpdate until a driver exists
SecureStateDriverManager:SetScript("OnUpdate", function()
local now = GetTime();
if now < nextUpdate then
local function SecureStateDriverManager_OnAttributeChanged(self, name, value)
if ( not self ) then
self, name, value = this, arg1, arg2;
end
if ( not value ) then
return;
end
nextUpdate = now + STATE_DRIVER_UPDATE_THROTTLE;
for frame, drivers in pairs(secureAttributeDrivers) do
for attribute, values in pairs(drivers) do
if ( name == "setframe" ) then
if ( not secureAttributeDrivers[value] ) then
secureAttributeDrivers[value] = {};
end
SecureStateDriverManager:Show();
elseif ( name == "delframe" ) then
secureAttributeDrivers[value] = nil;
elseif ( name == "setstate" ) then
local frame = self:GetAttribute("setframe");
local attribute, values = string.match(value, "^(%S+)%s*(.*)$");
if ( values == "" ) then
secureAttributeDrivers[frame][attribute] = nil;
else
secureAttributeDrivers[frame][attribute] = values;
resolveDriver(frame, attribute, values);
end
elseif ( name == "addwatch" or name == "addwatchstate" ) then
local doState = (name == "addwatchstate");
unitExistsWatchers[value] = doState;
SecureStateDriverManager:Show();
SecureStateDriverManager_UpdateUnitWatch(value, doState);
elseif ( name == "removewatch" ) then
unitExistsWatchers[value] = nil;
elseif ( name == "updatetime" ) then
STATE_DRIVER_UPDATE_THROTTLE = value;
end
end
for k in pairs(unitExistsCache) do
unitExistsCache[k] = nil;
end
for frame, doState in pairs(unitExistsWatchers) do
UpdateUnitWatch(frame, doState);
end
end);
SecureStateDriverManager = CreateFrame("Frame", "SecureStateDriverManager", nil, "SecureFrameTemplate");
SecureStateDriverManager:Hide();
SecureStateDriverManager:SetScript("OnUpdate", SecureStateDriverManager_OnUpdate);
SecureStateDriverManager:SetScript("OnEvent", SecureStateDriverManager_OnEvent);
SecureStateDriverManager:SetScript("OnAttributeChanged", SecureStateDriverManager_OnAttributeChanged);
SecureStateDriverManager:SetScript("OnEvent", function()
nextUpdate = 0; -- force a rescan on the next frame
end);
SecureStateDriverManager:SetScript("OnAttributeChanged", function()
if arg1 == "updatetime" and tonumber(arg2) then
STATE_DRIVER_UPDATE_THROTTLE = tonumber(arg2);
end
end);
-- Events that warrant an early rescan. Each is guarded so a name this client
-- doesn't define (e.g. UPDATE_STEALTH) is simply skipped.
local rescanEvents = {
"MODIFIER_STATE_CHANGED", -- ClassicAPI-fired
"PLAYER_FOCUS_CHANGED", -- ClassicAPI-fired
"UPDATE_SHAPESHIFT_FORM", -- ClassicAPI-fired (singular)
"UPDATE_SHAPESHIFT_FORMS", -- native 1.12 (plural)
-- Events that trigger early rescans
for _, event in ipairs({
"MODIFIER_STATE_CHANGED",
"ACTIONBAR_PAGE_CHANGED",
"UPDATE_BONUS_ACTIONBAR",
"PLAYER_ENTERING_WORLD",
"UPDATE_SHAPESHIFT_FORM",
"UPDATE_SHAPESHIFT_FORMS",
"UPDATE_STEALTH",
"PLAYER_TARGET_CHANGED",
"PLAYER_FOCUS_CHANGED",
"PLAYER_REGEN_DISABLED",
"PLAYER_REGEN_ENABLED",
"UNIT_PET",
"RAID_ROSTER_UPDATE",
"PARTY_MEMBERS_CHANGED",
};
for _, event in ipairs(rescanEvents) do
if C_EventUtils.IsEventValid(event) then
}) do
if ( C_EventUtils.IsEventValid(event) ) then
SecureStateDriverManager:RegisterEvent(event);
end
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
function RegisterAttributeDriver(frame, attribute, values)
if attribute and values and string.sub(attribute, 1, 1) ~= "_" then
local drivers = secureAttributeDrivers[frame];
if not drivers then
drivers = {};
secureAttributeDrivers[frame] = drivers;
end
drivers[attribute] = values;
SecureStateDriverManager:Show();
resolveDriver(frame, attribute, values);
end
end
function UnregisterAttributeDriver(frame, attribute)
if attribute then
local drivers = secureAttributeDrivers[frame];
if drivers then
drivers[attribute] = nil;
end
else
secureAttributeDrivers[frame] = nil;
end
end
function RegisterStateDriver(frame, state, values)
RegisterAttributeDriver(frame, "state-" .. state, values);
end
function UnregisterStateDriver(frame, state)
UnregisterAttributeDriver(frame, state and ("state-" .. state));
end
function RegisterUnitWatch(frame, asState)
local doState = (asState and true) or false;
unitExistsWatchers[frame] = doState;
SecureStateDriverManager:Show();
UpdateUnitWatch(frame, doState);
end
function UnregisterUnitWatch(frame)
unitExistsWatchers[frame] = nil;
end
-- Deliberately ignoring mouseover and others' target changes because they change so much
@@ -6,6 +6,13 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
SlashCmdList[name] = callback;
end
-- Keys that exist before this file runs, so the block at the bottom can tell
-- the commands we ADD from the ones we replace.
local preexistingKeys = {};
for key in pairs(SlashCmdList) do
preexistingKeys[key] = true;
end
-- `/cast` and `/use` (the FrameXML ChatFrame.lua shape): conditionals through
-- SecureCmdOptionParse, items before spells, `[@unit]` cast targets.
-- A bare number is an inventory slot only when it is one (1..19); any other
@@ -120,32 +127,12 @@ function SecureCmdUseItem(name, bag, slot, target)
end
end
local function SecureCmdCast(msg)
local action, target = SecureCmdOptionParse(msg);
if ( not action or action == "" ) then
return;
end
-- `!Name` asks for the spell to be started but never turned off, for the
-- abilities that toggle: auto-repeat (Shoot, Auto Shot) and the self-auras
-- (stances, aspects, seals, forms, tracking). Strip the prefix here and
-- cast through `CastSpellNoToggle`, which asks the engine whether the
-- ability is already up before it casts.
local noToggle;
if ( string.sub(action, 1, 1) == "!" ) then
noToggle = true;
action = string.sub(action, 2);
if ( action == "" ) then
return;
end
end
target = SecureCmdNormalizeTarget(target);
if ( target == false ) then
return; -- a named unit with nobody around to match it
end
local name, bag, slot = SecureCmdItemParse(action);
if ( slot or (name and not IsBareNumber(action) and C_Item.GetItemCount(name) > 0) ) then
SecureCmdUseItem(name, bag, slot, target);
elseif ( target == "cursor" ) then
-- Cast one resolved spell at one normalized target. Split out of
-- `SecureCmdCast` so `/castsequence` performs its step under exactly the same
-- rules: 1.12's `CastSpellByName` takes no unit, so every targeted form has to
-- go through `C_Spell.CastAtUnit`, and a sequence must not reinvent that.
local function SecureCmdPerformSpell(action, noToggle, target)
if ( target == "cursor" ) then
C_Spell.CastAtCursor(action);
elseif ( not target or target == "target" ) then
if ( noToggle ) then
@@ -169,6 +156,43 @@ local function SecureCmdCast(msg)
end
end
-- Perform one action at one normalized target: an item the line names, else a
-- spell. `/cast`, `/use` and the random commands all land here, so the
-- item-before-spell rule and the `!` prefix are decided in one place.
local function SecureCmdPerformAction(action, target)
-- `!Name` asks for the spell to be started but never turned off, for the
-- abilities that toggle: auto-repeat (Shoot, Auto Shot) and the self-auras
-- (stances, aspects, seals, forms, tracking). Strip the prefix here and
-- cast through `CastSpellNoToggle`, which asks the engine whether the
-- ability is already up before it casts.
local noToggle;
if ( string.sub(action, 1, 1) == "!" ) then
noToggle = true;
action = string.sub(action, 2);
if ( action == "" ) then
return;
end
end
local name, bag, slot = SecureCmdItemParse(action);
if ( slot or (name and not IsBareNumber(action) and C_Item.GetItemCount(name) > 0) ) then
SecureCmdUseItem(name, bag, slot, target);
else
SecureCmdPerformSpell(action, noToggle, target);
end
end
local function SecureCmdCast(msg)
local action, target = SecureCmdOptionParse(msg);
if ( not action or action == "" ) then
return;
end
target = SecureCmdNormalizeTarget(target);
if ( target == false ) then
return; -- a named unit with nobody around to match it
end
SecureCmdPerformAction(action, target);
end
SlashCmdList["CAST"] = SecureCmdCast;
SlashCmdList["USE"] = SecureCmdCast;
@@ -562,3 +586,515 @@ end
SlashCmdList["PET_AUTOCASTTOGGLE"] = function(msg)
SecureCmdPetAutocast(msg, nil);
end
-- ---------------------------------------------------------------------
-- /castsequence
--
-- One entry per sequence STRING (the text after the conditions), holding the
-- step index and the parsed action list. The string is the key on purpose:
-- the same sequence typed in two macros advances as one, which is how
-- FrameXML behaves and what lets the macro icon ask about a sequence it has
-- only the text of.
--
-- The icon side reads this through `QueryCastSequence`, which the macro
-- display calls whenever it needs the current step. That is Blizzard's own
-- split -- there the C macro code calls the same global by name -- so the
-- index lives here and nowhere else, and execution and display can never
-- disagree about which step is current.
local CastSequenceManager;
local CastSequenceTable = {};
local CastSequenceFreeList = {};
-- Cleared by the command-surrender pass at the bottom of this file if another
-- addon owns `/castsequence`. Our table only advances while OUR handler runs,
-- so once the command is theirs every index we hold is frozen at step one --
-- and the macro icon asks us, not them. Answering then would paint a
-- confidently wrong step on a sequence somebody else is advancing, so stop
-- answering instead and let the icon fall back to unresolved.
local castSequenceOwned = true;
local function Trim(text)
return (string.gsub(text or "", "^%s*(.-)%s*$", "%1"));
end
-- The comma-separated pieces of a sequence, empty pieces included: a step
-- that resolves to nothing still occupies an index and is skipped at cast
-- time, so dropping it here would renumber the sequence.
local function SplitSequence(text)
local pieces = {};
for piece in string.gmatch(text..",", "([^,]*),") do
table.insert(pieces, Trim(piece));
end
return pieces;
end
-- An action is an item when it names a bag or inventory slot, or when the
-- player is carrying one by that name. Same test `/cast` uses, and the same
-- limit: 1.12 can only look an item up by name among the ones you hold.
local function SequenceActionIsItem(action)
local name, _, slot = SecureCmdItemParse(action);
return slot ~= nil
or (name ~= nil and not IsBareNumber(action) and C_Item.GetItemCount(name) > 0);
end
local function CreateCanonicalActions(entry, actions)
entry.spells = {};
entry.spellNames = {};
entry.items = {};
for i = 1, table.getn(actions) do
local action = strlower(actions[i]);
if ( SequenceActionIsItem(action) ) then
entry.items[i] = action;
entry.spells[i] = strlower(C_Item.GetItemSpell(action) or "");
entry.spellNames[i] = entry.spells[i];
else
entry.spells[i] = action;
-- `!Name` is a cast rule, not part of the name the cast events
-- report back, so the matcher compares against the bare name.
entry.spellNames[i] = string.gsub(action, "^!*(.*)$", "%1");
end
end
entry.count = table.getn(actions);
end
local function SetCastSequenceIndex(entry, index)
entry.index = index;
entry.pending = nil;
end
local function ResetCastSequence(sequence, entry)
SetCastSequenceIndex(entry, 1);
CastSequenceFreeList[sequence] = entry;
CastSequenceTable[sequence] = nil;
end
local function SetNextCastSequence(sequence, entry)
if ( entry.index >= entry.count ) then
ResetCastSequence(sequence, entry);
else
SetCastSequenceIndex(entry, entry.index + 1);
end
end
-- A sequence restarts when the player holds the modifier its `reset=` names.
local function CastSequenceModifierReset(entry)
return (IsShiftKeyDown() and string.find(entry.reset, "shift", 1, true))
or (IsControlKeyDown() and string.find(entry.reset, "ctrl", 1, true))
or (IsAltKeyDown() and string.find(entry.reset, "alt", 1, true));
end
local function ParseCastSequence(sequence)
local reset, spells = string.match(sequence, "^reset=([^%s]+)%s*(.*)$");
if ( not reset ) then
spells = sequence;
end
local entry = {};
CreateCanonicalActions(entry, SplitSequence(spells));
entry.reset = strlower(reset or "");
return entry;
end
-- The cast events carry the spell in different argument positions depending on
-- the event, so read the name and rank per event rather than by one offset.
local function CastEventSpell()
if ( event == "UNIT_SPELLCAST_SENT" ) then
return arg1, arg5, arg6; -- unit, target, castGUID, spellID, name, rank
end
return arg1, arg4, arg5; -- unit, castGUID, spellID, name, rank
end
local function CastSequenceManager_OnEvent()
-- Death restarts every sequence.
if ( event == "PLAYER_DEAD" ) then
for sequence, entry in pairs(CastSequenceTable) do
ResetCastSequence(sequence, entry);
end
return;
end
if ( event == "UNIT_SPELLCAST_SENT"
or event == "UNIT_SPELLCAST_SUCCEEDED"
or event == "UNIT_SPELLCAST_INTERRUPTED"
or event == "UNIT_SPELLCAST_FAILED"
or event == "UNIT_SPELLCAST_FAILED_QUIET" ) then
local unit, name, rank = CastEventSpell();
if ( not name ) then
-- A server-side spell with no name of its own. Nothing in any
-- sequence can match it, so leave every index alone.
return;
end
if ( unit == "player" or unit == "pet" ) then
name, rank = strlower(name), strlower(rank or "");
local nameplus = name.."()";
local fullname = name.."("..rank..")";
for sequence, entry in pairs(CastSequenceTable) do
local entryName = entry.spellNames[entry.index];
if ( entryName == name or entryName == nameplus or entryName == fullname ) then
if ( event == "UNIT_SPELLCAST_SENT" ) then
-- In flight. Hold the index so a second press
-- cannot skip a step the server has not answered.
entry.pending = 1;
else
entry.pending = nil;
if ( event == "UNIT_SPELLCAST_SUCCEEDED" ) then
SetNextCastSequence(sequence, entry);
end
end
end
end
end
return;
end
local reset = "";
if ( event == "PLAYER_TARGET_CHANGED" ) then
reset = "target";
elseif ( event == "PLAYER_REGEN_ENABLED" ) then
reset = "combat";
end
for sequence, entry in pairs(CastSequenceTable) do
if ( string.find(entry.reset, reset, 1, true) ) then
ResetCastSequence(sequence, entry);
end
end
end
local function CastSequenceManager_OnUpdate()
local elapsed = CastSequenceManager.elapsed + (arg1 or 0);
if ( elapsed < 1 ) then
CastSequenceManager.elapsed = elapsed;
return;
end
for sequence, entry in pairs(CastSequenceTable) do
if ( entry.timeout ) then
if ( elapsed >= entry.timeout ) then
ResetCastSequence(sequence, entry);
else
entry.timeout = entry.timeout - elapsed;
end
end
end
CastSequenceManager.elapsed = 0;
end
local function CastSequenceEntry(sequence)
local entry = CastSequenceTable[sequence];
if ( entry ) then
return entry;
end
entry = CastSequenceFreeList[sequence] or ParseCastSequence(sequence);
CastSequenceTable[sequence] = entry;
entry.index = entry.index or 1;
return entry;
end
local function ExecuteCastSequence(sequence, target)
if ( not CastSequenceManager ) then
CastSequenceManager = CreateFrame("Frame");
CastSequenceManager.elapsed = 0;
CastSequenceManager:RegisterEvent("PLAYER_DEAD");
CastSequenceManager:RegisterEvent("UNIT_SPELLCAST_SENT");
CastSequenceManager:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED");
CastSequenceManager:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED");
CastSequenceManager:RegisterEvent("UNIT_SPELLCAST_FAILED");
CastSequenceManager:RegisterEvent("UNIT_SPELLCAST_FAILED_QUIET");
CastSequenceManager:RegisterEvent("PLAYER_TARGET_CHANGED");
CastSequenceManager:RegisterEvent("PLAYER_REGEN_ENABLED");
CastSequenceManager:SetScript("OnEvent", CastSequenceManager_OnEvent);
CastSequenceManager:SetScript("OnUpdate", CastSequenceManager_OnUpdate);
end
local entry = CastSequenceEntry(sequence);
-- A step already sent and not yet answered keeps the sequence where it is.
if ( entry.pending ) then
return;
end
if ( CastSequenceModifierReset(entry) ) then
SetCastSequenceIndex(entry, 1);
end
-- The timeout counts from the last use, so every press renews it.
local timeout = string.match(entry.reset, "(%d+)");
if ( timeout ) then
entry.timeout = CastSequenceManager.elapsed + tonumber(timeout);
end
local item, spell = entry.items[entry.index], entry.spells[entry.index];
if ( item ) then
local name, bag, slot = SecureCmdItemParse(item);
if ( slot ) then
-- A slot names whatever is in it right now, so the spell the
-- matcher waits for is re-read on every use.
spell = name and strlower(C_Item.GetItemSpell(name) or "") or "";
entry.spellNames[entry.index] = spell;
end
if ( C_Item.IsEquippableItem(name) and not C_Item.IsEquippedItem(name) ) then
C_Item.EquipItemByName(name);
else
SecureCmdUseItem(name, bag, slot, target);
end
else
local noToggle;
if ( string.sub(spell or "", 1, 1) == "!" ) then
noToggle = true;
spell = string.sub(spell, 2);
end
SecureCmdPerformSpell(spell, noToggle, target);
end
if ( spell == "" ) then
-- Nothing castable at this step: step past it so the sequence cannot
-- stall on an item the player no longer carries.
SetNextCastSequence(sequence, entry);
end
end
-- The step a sequence is on, without advancing it. Returns the index, the
-- item the step names (nil for a spell), and the spell name. The macro
-- display calls this by name.
function QueryCastSequence(sequence)
if ( not castSequenceOwned ) then
return;
end
local index = 1;
local item, spell;
local entry = CastSequenceTable[sequence];
if ( entry ) then
if ( not CastSequenceModifierReset(entry) ) then
index = entry.index;
end
item, spell = entry.items[index], entry.spells[index];
else
entry = CastSequenceFreeList[sequence];
if ( not entry ) then
-- Never used yet: parse it so the icon shows step one instead of
-- the question mark, but do not make it live.
entry = ParseCastSequence(sequence);
end
item, spell = entry.items[index], entry.spells[index];
end
if ( item ) then
local name, _, slot = SecureCmdItemParse(item);
if ( slot ) then
spell = name and strlower(C_Item.GetItemSpell(name) or "") or "";
end
end
return index, item, spell;
end
SlashCmdList["CASTSEQUENCE"] = function(msg)
local sequence, target = SecureCmdOptionParse(msg);
if ( not sequence or sequence == "" ) then
return;
end
target = SecureCmdNormalizeTarget(target);
if ( target == false ) then
return; -- a named unit with nobody around to match it
end
ExecuteCastSequence(sequence, target);
end
SLASH_CASTSEQUENCE1 = "/castsequence";
-- ---------------------------------------------------------------------
-- /castrandom and /userandom
--
-- One pick per action list, held until a cast SUCCEEDS. A failed or
-- interrupted cast keeps the same pick, so a spell that was out of range or
-- out of mana is retried rather than swapped for another one -- pressing the
-- button again means "try that again", not "roll again". `/userandom` is the
-- same command as `/castrandom`, the way `/use` is the same as `/cast`: both
-- perform whatever the chosen action turns out to be.
local CastRandomManager;
local CastRandomTable = {};
local function CastRandomManager_OnEvent()
local unit, name, rank = CastEventSpell();
if ( not name or unit ~= "player" ) then
return;
end
name, rank = strlower(name), strlower(rank or "");
local nameplus = name.."()";
local fullname = name.."("..rank..")";
for _, entry in pairs(CastRandomTable) do
if ( entry.pending and entry.value ) then
local entryName = strlower(entry.value);
if ( entryName == name or entryName == nameplus or entryName == fullname ) then
entry.pending = nil;
if ( event == "UNIT_SPELLCAST_SUCCEEDED" ) then
entry.value = nil; -- rolled again on the next press
end
end
end
end
end
-- The action this press uses. Nil when the list holds nothing to pick from.
local function ExecuteCastRandom(actions)
if ( not CastRandomManager ) then
CastRandomManager = CreateFrame("Frame");
CastRandomManager:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED");
CastRandomManager:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED");
CastRandomManager:RegisterEvent("UNIT_SPELLCAST_FAILED");
CastRandomManager:RegisterEvent("UNIT_SPELLCAST_FAILED_QUIET");
CastRandomManager:SetScript("OnEvent", CastRandomManager_OnEvent);
end
local entry = CastRandomTable[actions];
if ( not entry ) then
-- Only the raw pieces are kept. The matcher compares the chosen text
-- against the cast events, so the canonical item/spell split the
-- sequences build has nothing to do here.
entry = { list = SplitSequence(actions) };
CastRandomTable[actions] = entry;
end
if ( not entry.value ) then
local count = table.getn(entry.list);
if ( count == 0 ) then
return nil;
end
entry.value = entry.list[math.random(count)];
end
entry.pending = true;
return entry.value;
end
local function SecureCmdCastRandom(msg)
local actions, target = SecureCmdOptionParse(msg);
if ( not actions or actions == "" ) then
return;
end
target = SecureCmdNormalizeTarget(target);
if ( target == false ) then
return; -- a named unit with nobody around to match it
end
local action = ExecuteCastRandom(actions);
if ( action and action ~= "" ) then
SecureCmdPerformAction(action, target);
end
end
SlashCmdList["CASTRANDOM"] = SecureCmdCastRandom;
SLASH_CASTRANDOM1 = "/castrandom";
SlashCmdList["USERANDOM"] = SecureCmdCastRandom;
SLASH_USERANDOM1 = "/userandom";
-- `/stopmacro [conditions]` -- stop before the rest of the body runs. The flag
-- belongs to the macro runner, which clears it for each body it starts, so a
-- bare call outside a macro does nothing and cannot leave one armed.
--
-- Through the mirror, not the bare global. `StopMacro` is a plain name in a
-- crowded space, and the global is only ours until an addon declares one of
-- its own -- at which point this command would set THEIR flag, ours would
-- stay clear, and the rest of the macro would keep running with nothing to
-- show for it. The mirror captured our function at registration, before
-- FrameXML and every addon.
local StopMacroSelf = ClassicAPI.StopMacro;
SlashCmdList["STOPMACRO"] = function(msg)
if ( SecureCmdOptionParse(msg) ) then
StopMacroSelf();
end
end
SLASH_STOPMACRO1 = "/stopmacro";
-- ---------------------------------------------------------------------
-- Give a contested command back to whoever already owned it
--
-- Vanilla has no `/petattack`, so the entry above ADDS it. A macro addon can
-- ship the same command under its own SlashCmdList key -- SuperCleveRoidMacros
-- registers `PETATTACK` where we register `PET_ATTACK`, both with a
-- `SLASH_*` string of "/petattack". `ChatEdit_ParseText` walks SlashCmdList
-- with `pairs` and takes the first match, so two keys claiming one command
-- make the winner hash order: a coin flip. When ours won, it fed
-- `[hastarget,alive,harm]` to SecureCmdOptionParse, which does not know that
-- addon's conditions, so the line matched nothing and the macro did nothing.
--
-- Rejecting a condition we cannot evaluate is right on its own -- retail does
-- the same -- so the error is owning the command at all. Hand it back. One
-- command split across two condition dialects would be worse than one owner,
-- because the dialects disagree: our `[@unit]`-only group passes only while
-- that unit exists, where 3.3.5's passes unconditionally.
--
-- Only commands we ADD are given up. A command we replace (`/cast`, `/target`)
-- has no second entry to lose to, and an addon that wraps ours afterwards
-- chains through us as it always did.
--
-- And only while the entry is still ours to give. Both halves of the decision
-- are snapshotted below at file scope, while the table holds exactly what this
-- file registered:
--
-- * The handler, so a key another addon has since taken OVER is left alone.
-- SuperCleveRoidMacros writes its own conditional handler into our
-- `SlashCmdList.CANCELAURA`, so clearing the key a frame later would delete
-- that addon's command instead of handing ours back. Taking a key over is
-- already how an addon wins a command; there is nothing left to resolve.
-- * The command strings, because an addon that takes a key over also writes
-- its own aliases into the same `SLASH_<key><i>` globals -- SCRM's
-- `SLASH_CANCELAURA2 = "/unbuff"` replaces ours. Read a frame later, the
-- test would run against a command we never registered, and a third addon
-- owning that command would make us surrender an entry over a collision
-- that is none of our business.
--
-- Resolved a frame later, not at file scope: this addon loads first by design,
-- so while it runs the other addon has not registered yet and there is nothing
-- to detect. It cannot wait on PLAYER_LOGIN either -- that has already fired
-- on a `/reload`, so `ContinueOnPlayerLogin` would run the check immediately
-- and see the same empty table. The addon load pass is synchronous, so the
-- next frame is after every non-demand addon has registered, on a cold login
-- and on a reload alike. An addon that loads on demand later keeps its own
-- entry and the coin flip with it.
-- The command strings a SlashCmdList key answers to, upper-cased the way
-- `ChatEdit_ParseText` compares them.
local function CommandStrings(key)
local commands = {};
local i = 1;
while ( _G["SLASH_"..key..i] ) do
commands[strupper(_G["SLASH_"..key..i])] = true;
i = i + 1;
end
return commands;
end
local addedKeys = {};
local addedHandlers = {};
local addedCommands = {};
for key, handler in pairs(SlashCmdList) do
if ( not preexistingKeys[key] ) then
addedKeys[key] = true;
addedHandlers[key] = handler;
addedCommands[key] = CommandStrings(key);
end
end
local function ClaimedElsewhere(key, commands)
for otherKey in pairs(SlashCmdList) do
if ( otherKey ~= key and not addedKeys[otherKey] ) then
local i = 1;
while ( _G["SLASH_"..otherKey..i] ) do
if ( commands[strupper(_G["SLASH_"..otherKey..i])] ) then
return true;
end
i = i + 1;
end
end
end
return false;
end
RunNextFrame(function()
for key in pairs(addedKeys) do
if ( SlashCmdList[key] == addedHandlers[key]
and ClaimedElsewhere(key, addedCommands[key]) ) then
SlashCmdList[key] = nil;
if ( key == "CASTSEQUENCE" ) then
-- The sequence is theirs to advance now, so stop reporting a
-- step to the macro icon that we no longer track.
castSequenceOwned = false;
end
end
end
end)
+31
View File
@@ -0,0 +1,31 @@
-- The closed world map follows the player's zone, as the 3.x UI keeps it.
--
-- GetPlayerMapPosition("player") answers for whichever map the world map is
-- set to, open or not, and returns 0,0 when the player isn't on that map.
-- The 3.x FrameXML puts the map back on the player's zone whenever it is out
-- of view: WorldMapFrame_OnHide ends with SetMapToCurrentZone(), and
-- WatchFrame calls it on PLAYER_ENTERING_WORLD and, while the map is closed,
-- on ZONE_CHANGED_NEW_AREA. Vanilla has no WatchFrame and doesn't reset the
-- map when it closes, so after the player changes zones - or browses another
-- zone and closes the map - the closed map stays on that other zone, and code
-- written for 3.x that reads the player's position without setting the map
-- first gets 0,0 (Zygor Guides Viewer's travel steps never complete).
--
-- WatchFrame skips the zone-change reset while "Show quest objectives on the
-- map" is off; vanilla has no such option (the reset fed the tracker's POIs),
-- so here it always runs.
local frame = CreateFrame("Frame")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
frame:SetScript("OnEvent", function(self, ev)
ev = ev or event
if ev == "ZONE_CHANGED_NEW_AREA" and WorldMapFrame:IsShown() then
return
end
SetMapToCurrentZone()
end)
WorldMapFrame:HookScript("OnHide", function()
SetMapToCurrentZone()
end)
+6 -4
View File
@@ -4,7 +4,7 @@ BINDING_NAME_TARGETFOCUS = "Target Focus";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:2:3:0:1|t";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
@@ -28,12 +28,13 @@ ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
CLEAR_FOCUS = "Clear Focus";
SET_FOCUS = "Set Focus";
FOCUS_TOKEN_NOT_FOUND = "<no focus>";
FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:0:1:0:1|t";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
@@ -75,10 +76,11 @@ MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
PERCENTAGE_STRING = "%d%%";
PLAYER_LIST_DELIMITER = ", ";
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:1:2:0:1|t";
SLASH_CANCELAURA1 = "/cancelaura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/cancelform";
+5 -71
View File
@@ -6,10 +6,8 @@
-- explicit (this, event, arg1..arg9) parameters from XML inline scripts.
-- * RegisterAllEvents is used when available; the curated default list is
-- a fallback if the client doesn't expose it.
-- * No GameTooltip:SetFrameStack -- /framestack walks the engine's
-- frame list via EnumerateFrames(prev) and renders into our own
-- FrameStackTooltip instead. Catches anonymous frames the modern
-- engine method shows but a _G walk would miss.
-- * /fstack accepts "true" (as on 3.3.5) or "hidden" to list hidden
-- frames too.
-- * No UIPanelDialogTemplate, no parentKey, no clampedToScreen, no
-- OnSizeChanged in vanilla -- frames have fixed dimensions.
-- * No debuglocals
@@ -980,70 +978,6 @@ function DebugTooltip_OnLoad(self)
end
end
local _STRATA_RANK = {
BACKGROUND = 1, LOW = 2, MEDIUM = 3, HIGH = 4,
DIALOG = 5, FULLSCREEN = 6, FULLSCREEN_DIALOG = 7, TOOLTIP = 8,
};
-- Walks every frame the engine knows about via EnumerateFrames (the
-- engine-internal frame iterator). Beats a getfenv(0) walk on two
-- counts: faster (the engine list is much shorter than _G), and
-- includes anonymous frames that never appear in globals (parented
-- templates, runtime `CreateFrame` with no `name` arg, etc.). The
-- iteration order itself is engine-implementation detail; we re-
-- sort by strata + level below either way.
local function _CollectFramesUnderMouse(showHidden)
local matches = {};
local frame = EnumerateFrames(nil);
while (frame) do
local visOK, visible = pcall(frame.IsVisible, frame);
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
local nameOK, name = pcall(frame.GetName, frame);
if (not nameOK or not name) then name = "<anonymous>"; end
table.insert(matches, { name = name, frame = frame });
end
frame = EnumerateFrames(frame);
end
table.sort(matches, function(a, b)
local sa = _STRATA_RANK[a.frame:GetFrameStrata()] or 0;
local sb = _STRATA_RANK[b.frame:GetFrameStrata()] or 0;
if (sa ~= sb) then return sa > sb; end
local la = a.frame:GetFrameLevel() or 0;
local lb = b.frame:GetFrameLevel() or 0;
return la > lb;
end);
return matches;
end
local function _UpdateFrameStackTooltip(tooltip, showHidden)
tooltip:ClearLines();
tooltip:AddLine(DEBUG_FRAMESTACK or "Frame Stack", 1, 1, 1);
local matches = _CollectFramesUnderMouse(showHidden);
local count = table.getn(matches);
if (count == 0) then
tooltip:AddLine("(no frames under cursor)", .7, .7, .7);
else
local shown = 0;
for i = 1, count do
local m = matches[i];
local strata = m.frame:GetFrameStrata() or "?";
local level = m.frame:GetFrameLevel() or 0;
local label = string.format("%s |cff888888[%s:%d]|r",
m.name, strata, level);
tooltip:AddLine(label, 1, .82, 0);
shown = shown + 1;
if (shown >= 30) then
tooltip:AddLine(string.format("|cffff8888...%d more|r",
count - shown),
1, 1, 1);
break;
end
end
end
tooltip:Show();
end
function FrameStackTooltip_Toggle(showHidden)
local tooltip = FrameStackTooltip;
if (tooltip:IsVisible()) then
@@ -1053,7 +987,7 @@ function FrameStackTooltip_Toggle(showHidden)
tooltip:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -32, 96);
tooltip.default = 1;
tooltip.showHidden = showHidden;
_UpdateFrameStackTooltip(tooltip, showHidden);
tooltip:SetFrameStack(showHidden);
end
end
@@ -1064,7 +998,7 @@ function FrameStackTooltip_OnUpdate(self, elapsed)
_frameStackAccum = _frameStackAccum + elapsed;
if (_frameStackAccum >= FRAMESTACK_UPDATE_TIME) then
_frameStackAccum = 0;
_UpdateFrameStackTooltip(self, self.showHidden);
self:SetFrameStack(self.showHidden);
end
end
@@ -1095,7 +1029,7 @@ FrameStackTooltip_OnEnter = FrameStackTooltip_OnShow;
function FrameStackTooltip_HandleSlashCmd(msg)
if (msg == nil) then msg = ""; end
local showHidden = (string.find(msg, "hidden") ~= nil);
local showHidden = (msg == "true" or string.find(msg, "hidden") ~= nil);
FrameStackTooltip_Toggle(showHidden);
end
+24 -5
View File
@@ -60,7 +60,11 @@
</EdgeSize>
</Backdrop>
<Layers>
<Layer level="BACKGROUND">
<!-- ARTWORK, not BACKGROUND: the Backdrop draws its own
background into BACKGROUND after the textures declared here,
which would bury this bar. The title text is in OVERLAY, so
it still reads over the top. -->
<Layer level="ARTWORK">
<Texture name="$parentTitleBG" file="Interface\QuestFrame\UI-QuestLogTitleHighlight">
<Size x="280" y="20"/>
<Anchors>
@@ -278,7 +282,12 @@
</EdgeSize>
</Backdrop>
<Layers>
<Layer level="BACKGROUND">
<!-- ARTWORK, not BACKGROUND: the Backdrop above draws its
background and border into BACKGROUND, and it is applied
after the textures declared here, so a header in that layer
ends up behind the window border. Every Blizzard frame that
uses UI-DialogBox-Header puts it in ARTWORK for this reason. -->
<Layer level="ARTWORK">
<Texture name="$parentTitleBG" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size x="256" y="64"/>
<Anchors>
@@ -322,8 +331,16 @@
<OnDragStop>ScriptErrorsFrame_TitleButton_OnDragStop();</OnDragStop>
</Scripts>
</Frame>
<!-- Width leaves room for the scroll bar inside the border.
UIPanelScrollFrameTemplate hangs its bar off this frame's
right edge (+6, 16 wide, button art a little wider), so the
bar ends at about right + 23. The frame's interior margin is
368, where the Close button ends, which puts the widest this
can be at 328. Blizzard's own numbers do not carry over here:
its ScriptErrorsFrame inherits UIPanelDialogTemplate, a thin
GearManager border, not this 32px dialog backdrop. -->
<ScrollFrame name="$parentScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size x="343" y="170"/>
<Size x="328" y="170"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="16" y="-32"/>
@@ -331,7 +348,7 @@
</Anchors>
<ScrollChild>
<EditBox name="$parentText" multiLine="true" letters="4000" autoFocus="false">
<Size x="343" y="170"/>
<Size x="328" y="170"/>
<Scripts>
<OnEscapePressed>this:ClearFocus();</OnEscapePressed>
<OnEditFocusGained>this:HighlightText(0);</OnEditFocusGained>
@@ -437,7 +454,9 @@
</EdgeSize>
</Backdrop>
<Layers>
<Layer level="BACKGROUND">
<!-- ARTWORK, not BACKGROUND: see the same note on
EventTraceFrame above. -->
<Layer level="ARTWORK">
<Texture name="$parentTitleBG" file="Interface\QuestFrame\UI-QuestLogTitleHighlight">
<Size x="454" y="20"/>
<Anchors>
+7 -4
View File
@@ -140,6 +140,12 @@ configure_file(
@ONLY)
target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# The .def exports `Load` (VanillaFixes' post-injection entry point)
# undecorated so its GetProcAddress(module, "Load") resolves. See the
# loader-lock rationale in src/DllMain.cpp. Listed as a source so CMake emits
# a quoted /DEF: on every generator and relinks when the file changes.
target_sources(${PROJECT_NAME} PRIVATE src/ClassicAPI.def)
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
@@ -178,8 +184,5 @@ set(CMAKE_SHARED_LINKER_FLAGS
# identical-COMDAT fold); they are listed explicitly below so they stay ON
# and the shipped DLL does not grow. (/OPT:ICF folds identical functions, so
# a few map symbols share an address — harmless.)
# /DEF exports `Load` (VanillaFixes' post-injection entry point) undecorated
# so its GetProcAddress(module, "Load") resolves. See src/ClassicAPI.def and
# the loader-lock rationale in src/DllMain.cpp.
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEBUG /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEBUG"
)
+36 -17
View File
@@ -23,8 +23,10 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [Upvalue limit](docs/API.md#upvalue-limit) | A function can use up to 60 upvalues, the outer locals it refers to, matching Lua 5.1. Lua 5.0 stops at 32 and rejects the whole file, so a large handler that reads many file-level locals can be valid 5.1 and still fail to load here. Nothing changes for a function within 32. |
| [`for` loop variables](docs/API.md#for-loop-variables-per-pass) | Each pass of a `for` loop has its own loop variables, as in Lua 5.1, so a function made in the loop keeps that pass's values. On Lua 5.0 every such function sees the loop's last value, which is `nil` for a `for ... in` loop. |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
| [XML script bodies](docs/API.md#xml-script-bodies-see-self-and-their-arguments) | An XML script body written for later clients, such as `<OnLoad>self:Init()</OnLoad>`, sees `self` and its script's arguments by name (`elapsed`, `button`, ...). Bodies that read `this` / `arg1` compile as before. |
### Modern client behaviors
@@ -32,6 +34,7 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, and a button's `<NormalFont style="..."/>` sets its font. |
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows what the macro does. The icon, tooltip, cooldown, range, usable state and auto-repeat glow all follow the spell or item that the directive resolves to, conditions included. They update as the conditions change. The result goes into the engine field that action-bar addons already read, so they need no code of their own. An addon with its own macro parser can publish its result through `C_Macro.SetMacroDisplay`. It then does not need to replace the action globals. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
@@ -41,6 +44,9 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| [Launch switches](docs/API.md#launch) | Options you add to the shortcut that starts the game. `-config <name>` reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. `-gluescript` and `-gluescriptFile` run Lua at the login and character-select screens, every time those screens appear. `-gamescript` and `-gamescriptFile` run it one time, after you enter the world. Each script runs after the interface has loaded, so it can call interface functions and use frames. |
| [Any texture size](docs/API.md#texture-size-and-shape) | Textures load at any size and any shape. Width and height do not need to be powers of two, and there is no fixed upper limit. The only limit is the maximum texture size of your graphics card. This applies to every texture path: `SetTexture`, sprite sheets, inline textures, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one, so small textures cost nothing extra. |
Smaller behavior fixes, such as frame alpha and `SetParent` frame levels, are
in **[docs/API.md](docs/API.md)** with the API that they change.
## Full API reference
Everything ClassicAPI adds, in full. Each section is collapsed — click a
@@ -54,40 +60,42 @@ reference in **[docs/API.md](docs/API.md)**.
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings`, `GetBindingAction` (`checkOverride`), `IsModifiedClick`, `GetModifiedClick`, `SetModifiedClick`, `GetNumModifiedClickActions`, `GetModifiedClickAction`, `ActionButtonUseKeyDown` (cvar) |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ClassColor](docs/API.md#classcolor) | `C_ClassColor.GetClassColor` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.SetCVarBitfield` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.RemoveTempCVar`, `C_CVar.SetCVarBitfield`, `C_CVar.SetTempCVar`, `GetCVar`, `GetCVarDefault` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.PickupEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `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`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetAtlas`, `texture:GetAtlas`, `texture:ResetTexCoord`, `texture:SetSpriteSheetCell`, `texture:SetDesaturation`, `texture:GetDesaturation`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:RegisterUnitEvent`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `CreateFrame("Cooldown")`, `cooldown:SetCooldown`, `cooldown:SetReverse`, `cooldown:GetReverse`, `cooldown:SetDrawEdge`, `cooldown:GetDrawEdge`, `frame:SetShown`, `frame:SetClampRectInsets`, `frame:GetClampRectInsets`, `smf:SetHyperlinksEnabled`, `smf:GetHyperlinksEnabled`, `editBox:SetCountInvisibleLetters`, `editBox:IsCountInvisibleLetters`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetAtlas`, `texture:GetAtlas`, `texture:ResetTexCoord`, `texture:SetSpriteSheetCell`, `texture:SetDesaturation`, `texture:GetDesaturation`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsProtected`, `button:SetNormalFontObject`, `button:GetNormalFontObject`, `frame:IsEventRegistered`, `frame:RegisterUnitEvent`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `button:RegisterForClicks("AnyUp"/"AnyDown")`, `GetClickFrame` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:AddTexture`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnit`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetFrameStack`, `GameTooltip:SetHyperlink` (full links, `spell:`, `quest:`), `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject`, `OnTooltipSetFrameStack` (scripts) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo`, `IsActiveBattlefieldArena` |
| [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.GetItemInfo`, `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.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `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`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `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.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount`, `C_LossOfControl.GetSchoolLockout` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `StopMacro` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
@@ -96,19 +104,21 @@ reference in **[docs/API.md](docs/API.md)**.
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Sound](docs/API.md#sound) | `C_Sound.GetRecentSoundFiles`, `C_Sound.GetSoundScaledVolume`, `C_Sound.IsPlaying`, `C_Sound.PlayItemSound`, `C_Sound.PlaySound`, `C_Sound.PlaySoundWithOptions`, `C_Sound.PlayVocalErrorSound`, `MuteSoundFile`, `PlaySound`, `UnmuteSoundFile` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [SwingTimer](docs/API.md#swingtimer) | `C_SwingTimer.EnableRangeCheck`, `C_SwingTimer.IsTargetWithinSwingRange` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
@@ -131,7 +141,7 @@ the glue state because GlueXML had no way to reach them otherwise.
|-------|-------|
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool`, `C_CVar.SetTempCVar`, `C_CVar.RemoveTempCVar` (storage is process-global — writes from glue are visible in-world and vice versa) |
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
@@ -148,7 +158,7 @@ details.
| Form | What it does |
|------|--------------|
| `#showtooltip [conditions] Value` | First line of a macro. Sets what the button shows. Bare `#showtooltip` follows the first `/cast` or `/use` line instead. `#show` is the same form. |
| `[conditions]` and `@unit` | Accepted by 38 commands. The slash-command list below names them. |
| `[conditions]` and `@unit` | Accepted by 42 commands. The slash-command list below names them. |
| `!Name` | Starts an ability but never turns it off. Use it for auto-repeat shots and for the self-buffs (stance, aspect, seal, form, tracking). |
| A line that starts with `#` | A comment. The line never reaches chat. |
| `/cast <spellID>` | For a spell you know, `/cast 5019` casts it by ID. The macro slot also tags correctly for an action-bar addon. |
@@ -158,17 +168,19 @@ details.
</details>
<details>
<summary><b>Slash commands</b> — <code>[conditions]</code> on 38 commands</summary>
<summary><b>Slash commands</b> — <code>[conditions]</code> on 42 commands</summary>
The bundled addon registers these commands. Each one takes the same
`[conditions]` and `@unit` syntax as `/cast`. Each client language has its
own command names next to the English ones. See
[More commands with `[conditions]`](docs/API.md#more-commands-with-conditions)
for what each command does.
for what each command does, and
[`/castsequence`](docs/API.md#castsequence) and
[`/castrandom`](docs/API.md#castrandom-and-userandom) for the list forms.
| Group | Commands |
|-------|----------|
| Casting | `/cast`, `/use`, `/stopcasting`, `/cancelaura`, `/cancelform`, `/dismount` |
| Casting | `/cast`, `/use`, `/castsequence`, `/castrandom`, `/userandom`, `/stopcasting`, `/stopmacro`, `/cancelaura`, `/cancelform`, `/dismount` |
| Targeting | `/target`, `/targetexact`, `/cleartarget`, `/targetlasttarget`, `/targetlastenemy`, `/targetenemy`, `/targetfriend`, `/targetenemyplayer`, `/targetfriendplayer`, `/targetparty`, `/targetraid`, `/assist`, `/follow`, `/focus`, `/clearfocus`, `/startattack`, `/stopattack` |
| Equipment | `/equip`, `/equipslot`, `/equipset` |
| Action bars | `/changeactionbar`, `/swapactionbar`, `/click` |
@@ -188,6 +200,7 @@ when launching with `-console`), not as Lua functions. See the
| `ExportInterfaceFiles code` | Extracts Blizzard's UI source (`.lua`/`.xml`/`.toc`/`.xsd`) from the MPQs to `BlizzardInterfaceCode\` |
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
| `ExportSoundFiles [subpath]` | Extracts the client's sound files from the MPQs to `BlizzardSound\`; the optional subpath narrows it (the full tree is ~1 GB) |
</details>
@@ -225,11 +238,14 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_SWING` | `swingDuration, swingType` |
| `PLAYER_SWING_RANGE_UPDATE` | `swingType, isInRange, checksRange` |
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `SOUNDKIT_FINISHED` | `soundHandle` (only for sounds played with `runFinishCallback`) |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
@@ -250,6 +266,8 @@ when launching with `-console`), not as Lua functions. See the
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
| `WEAPON_SLOT_CHANGED` | *(none)* |
| `WEAR_EQUIPMENT_SET` | `setID` |
</details>
@@ -270,6 +288,7 @@ when launching with `-console`), not as Lua functions. See the
| Cursor type | `Enum.UICursorType.{Default,Item,Money,Spell,PetAction,Merchant,Macro,Pet,…}` (0–20; this client yields 0–5, 7 and 9) |
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
| Player swing type | `Enum.PlayerSwingType.{MainHand,OffHand,Ranged}` (0–2) |
</details>
+184 -125
View File
@@ -951,88 +951,15 @@ See [src/unit/Tooltip.cpp](src/unit/Tooltip.cpp) and the
`FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_*` block in
[src/Offsets.h](src/Offsets.h).
## 46. `GameTooltip:SetFrameStack([showHidden])` — deferred (Lua-side fix in place)
## ~~46. `GameTooltip:SetFrameStack([showHidden])`~~ — DONE
Renders a tooltip listing the chain of frames at the mouse cursor,
sorted by strata + level. The 3.0+ debug-tooling primitive used by
Blizzard's `Blizzard_DebugTools` and every layout-debugging addon.
### Current state in this project
Our bundled `DebugTools/` backport ships its own `FrameStackTooltip`
in [DebugTools/DebugTools.lua](DebugTools/DebugTools.lua), driven
by `_CollectFramesUnderMouse`. As of the EnumerateFrames switch,
the iterator walks the engine's own frame list — fast (typically
~hundreds of entries) and includes anonymous frames the engine
sees but a `_G` walk would miss.
What's still missing relative to the real engine `SetFrameStack`:
- **Anonymous-frame display name.** We currently show
`<anonymous>` for frames where `frame:GetName()` returns nil.
The engine's method shows the frame's pointer + the Lua-side
registry-table address. To get there we'd need a Lua-callable
shim that exposes a numeric handle per frame (vftable + Lua-ref
lookup), which is non-trivial.
- **Single-tooltip semantics.** Modern addons can call
`GameTooltip:SetFrameStack(...)` directly on any tooltip;
ours requires the `FrameStackTooltip` we ship in `DebugTools/`.
Most consumers (`Blizzard_DebugTools` ports, layout addons via
`/framestack`) don't care, but a strict polyfill would.
### C++ port from 3.3.5 — kept as a research note
What `Script_GameTooltip_SetFrameStack`
actually does in 3.3.5 (Frostmourne client, registration entry at
VA 0x00AD2CF0 → function VA 0x00626560 → inner builder
`FUN_006230d0`):
- Pulls cursor (in frame space) via `FUN_0047BFF0` from a render-
state global at `[DAT_00B499A8 + 0x1224 / +0x1228]`.
- Walks an intrusive linked list at `[DAT_00B499A8 + 0xCD4]`. Each
frame node reads:
- `[+0x00]` — vftable; `vftable[+0x10]` = "is the frame visible
enough to hit-test" predicate.
- `[+0x0E0]` (`node[0x38]`) = strata; gates `(strata != 0 ||
showHidden)`.
- `[+0x0D0]..[+0x0DC]` (`node[0x34..0x37]`) = frame rect for
hit-test.
- `[+0x0D8]` (`node[0x36]`) flags, bit 2 → visible.
- `[+0x288]` (`node[0xA2]`) = next pointer.
- Per-frame name via `vftable[+0x04]` (a `GetDebugName()`-style
virtual). If the frame is Lua-side (`vftable[+0x10]` returns
type code 5), an extra `lua_rawgeti(L, REGISTRY, frame->luaRef)`
pulls the Lua-table reference for the `"%s: %p (%s)"` format;
otherwise it's `"%s: %p"`.
- Builds into a scratch sort array at `DAT_00C5D370` (entry stride
`0x410`), `qsort` against comparator `FUN_0061A5B0`.
- Header line via `FUN_00819D40("DEBUG_FRAMESTACK", ...)` (Lua
`GetGlobalString`) then per-entry `FUN_0061FEC0` (tooltip
`AddLine` equivalent, takes color codes from a 4-byte-stride
table at `0xAD2DC0` / `0xAD2DB8` / `0xAD2DB0` for
visible/hidden/special states).
For a 1.12 port we'd need to derive:
- The frame-manager global (3.3.5's `DAT_00B499A8` — different VA
in 1.12, different field offsets likely).
- Frame struct offsets for rect, strata, flags, next, name vftable
slot — the layout shifted between 1.12 and 3.3.5 (same way unit
field offsets did, per the `OFF_UNIT_FIELD_HEALTH` discovery in
CLAUDE.md).
- AddTooltipLine engine address (1.12 has it — every
`GameTooltip:SetX` method calls it internally).
- Lua-side reference lookup helper (whatever 1.12 uses to map
frame → Lua table; the `FrameScript_GetObject` chain at
`0x6F3740` is part of this).
### Recommendation
The Lua-side iterator switch is done. The C++ port stays deferred
unless we need the engine frame list for something else
(`GetMouseFocus` polyfill, anonymous-frame inspector with extra
fields beyond what Lua exposes, single-tooltip strict polyfill,
etc.) that would amortize the RE cost.
A mirror of 4.3.4's CGTooltip::SetFrameStack in
[src/tooltip/FrameStack.cpp](src/tooltip/FrameStack.cpp), plus the
`OnTooltipSetFrameStack` script in Tooltip::SetEvents. Every input exists in
1.12: the CSimpleTop frame list, the NDC cursor + NDCToDDC, CLayoutFrame
GetRect, the frame's visible / enabled-input / strata / level fields,
FrameStrataToString and the two default tooltip colors (all in Offsets.h).
The `/fstack` UI itself is addon Lua and can now call the method.
## ~~47. `IsPlayerSpell(spellID)`~~ — DONE
@@ -1120,51 +1047,50 @@ spell-API functions use. Returns `(1, nil)` / `(nil, 1)` /
`(nil, nil)` per legacy convention. `C_Spell.IsSpellUsable` is the
modern shape — same logic, returns proper booleans.
**Two engine-cache approaches investigated and discarded:**
**Reworked for issue #51 (2026-09-12) — it now IS the engine's
verdict.** `IsUsableAction` reads the per-slot cache
(`0x00BC6B60` usable / `0x00BC67A0` noMana, reader `FUN_004E5230`),
which the recompute `FUN_004E5050` fills per slot. For a player spell
slot that recompute is one call: `FUN_SPELL_IS_USABLE(record,
&noMana)` (`0x006E3D60`). For a pet spell slot it is `FUN_PET_ACTIONS_
USABLE()` then the pet's power vs `FUN_GET_SPELL_COST(spellID, 0)`.
`Spell::Usable` now runs exactly those two paths live (plus a
knowledge gate on `VAR_PLAYER_SPELL_BITMAP`, since the helper is also
fed item on-use spells and never checks knowledge). Verified check
list of the helper is on `FUN_SPELL_IS_USABLE` in `Offsets.h`.
**The first version hand-rolled five checks instead** (known, alive,
off cooldown, power, reagents) and diverged from `IsUsableAction`
exactly where the helper does more or less — the issue #51 repro
(Warrior, Whirlwind):
| State | `IsUsableAction` | old `IsUsableSpell` |
|---|---|---|
| Berserker, 0 rage, on CD | `nil, 1` | `nil, nil` (we tested cooldown first — the engine never does) |
| Battle Stance, 25 rage | `nil, nil` | `1, nil` (we never tested stance) |
Cooldown is deliberately NOT part of usability: FrameXML greys
(`IsUsableAction`) and swipes (`GetActionCooldown`) as two separate
states, and the engine helper has no cooldown branch. Rule #1 case:
`Item::Usable` had been calling the helper all along.
**Two engine-cache approaches investigated and discarded before the
first version** (still true, kept for the record):
1. **Per-spell cache at `0x004F0F40` returning fields at +0x564
/ +0x568** — initially looked promising (the action-bar
dispatcher at `0x004E5BA0` reads these), but the writers
(`0x004EFF17` cluster) showed they're parsed config integers,
not real-time usability bools. False trail.
not real-time usability bools. False trail. (Later identified as
the macro entry's cached primary spell — see the `#showtooltip`
notes.)
2. **Action-bar per-slot caches at `0x00BC67A0` (noMana) and
`0x00BC6B60` (usable)** — these ARE real-time, but only updated
for the 120 action-bar slots. Spells off the bar stay
uninitialized. Action-bar-only is the wrong abstraction since
modern `IsUsableSpell(spellID)` is action-bar-agnostic.
Settled on a **manual five-check approach**:
1. Player knows the spell (`VAR_PLAYER_SPELL_BITMAP` lookup —
covers profession recipes, talents, racials, etc.).
2. Player is alive (`HEALTH > 0`).
3. Spell is not on cooldown (`FUN_SPELL_QUERY_COOLDOWN` —
the engine helper `Script_GetSpellCooldown` calls internally
after slot resolution, queried with `bookType=0` for player).
4. Player has ≥ `ManaCost` of the spell's `PowerType` —
only this failure flips `noMana=true`.
5. Player has all required reagents in bags
(`Spell.dbc` Reagent[8] / ReagentCount[8] at `+0x110` /
`+0x130`, walked via `Item::Location::ResolveBag`).
**Not checked** (deliberate, different concerns): silence, GCD,
stance/form, range, target type, line-of-sight, casting state.
**Verified on Turtle WoW:**
- Mana check: Renew rank 3 (cost 105) reports usable at 144 mana
and unusable at 39 mana, transitioning exactly at the cost
boundary.
- Reagent check: SHIPPED BUT UNVERIFIED — the Reagent[8] /
ReagentCount[8] offsets at `+0x110` / `+0x130` are the
CMaNGOS-documented vanilla layout, and we know spell records
match CMaNGOS docs (PowerType=+0x7C and ManaCost=+0x80 both
match), so high confidence — but no in-game verification yet.
Should test with a reagent spell (e.g., Mage Teleport spell 3565
needs Rune of Teleportation 17031). Failure mode if offsets are
wrong: reagent check becomes a no-op (always passes).
- Cooldown check: SHIPPED BUT UNVERIFIED in this implementation —
the helper signature was traced via `Script_GetSpellCooldown` at
`0x004B40A0` but not exercised in-game. Should test with a
cooldown'd spell.
modern `IsUsableSpell(spellID)` is action-bar-agnostic. The fix
above sidesteps this by calling what FILLS the cache, not the
cache.
**Important offset correction along the way.** Initial implementation
read POWER1 at descriptor `+0x5C` based on the CMaNGOS-documented
@@ -1741,7 +1667,14 @@ is a thin wrapper. If they're inlined, we'd either need to skip
the clear ourselves (call after some no-op state setup) or
manually call the line-emission primitives.
## ~~61. `GameTooltip:GetItem()` / `GetSpell()` / `GetUnit()`~~ — `GetItem` + `GetSpell` DONE; `GetUnit` skipped
## ~~61. `GameTooltip:GetItem()` / `GetSpell()` / `GetUnit()`~~ — DONE
`GetUnit` shipped later in [src/unit/Tooltip.cpp](src/unit/Tooltip.cpp):
`SetUnit` is re-registered in front of the engine's (no MinHook) to
stage the token, and the token binds in the unit-builder co-hook that
`Tooltip::SetEvents` already owned. The builder has three callers:
SetUnit, the world-mouseover setter (→ `"mouseover"`), and an in-place
refresh (keeps the token). The notes below are the original skip.
Modern query methods that return what the tooltip is currently
showing:
@@ -1847,10 +1780,17 @@ are supported.
### Unhookable globals
Modern WoW rejects `hooksecurefunc("<core lua/secure fn>", ...)` outright
to keep callers from clobbering language primitives or breaking taint
propagation. We mirror that with a name blacklist in `Script_HookSecureFunc`
that fires `lua_error` with `"hooksecurefunc: function is unhookable"`
Patch 11.0.0 (The War Within, 2024-07-23) made `hooksecurefunc` refuse a
fixed set of names with a "Cannot hook function" error, to keep callers
from clobbering language primitives or breaking taint propagation. No
earlier client has the restriction (3.3.5, 4.3.4 and 5.4.8 carry no such
string), so 11.0's published list is the reference — and it is NOT the
engine's base-library table (`0x00811E28`, 36 entries, catalogued in
`BlizzardScriptAPI.md` §3): 11.0 leaves `tostring`/`error`/`loadstring`
hookable and includes secure-family names that are not base functions. We
mirror the 11.0 list verbatim with a name blacklist in
`Script_HookSecureFunc` that fires `lua_error` with
`"hooksecurefunc: function is unhookable"`
when the two-arg form targets one of: `getfenv`, `getmetatable`,
`hooksecurefunc`, `ipairs`, `issecurevalue`, `issecurevariable`, `next`,
`pairs`, `pcall`, `pcallwithenv`, `rawget`, `rawset`, `scrub`,
@@ -3827,9 +3767,10 @@ private namespace table **only** when BOTH hold, else `nil`:
boolean flag; approach (a) from the notes below — no parser hook).
The two paths stay separate, as required:
- **Internal** `__addonns` (the `local name, ns = ...` preamble) stays
**ungated** — an addon always gets its own namespace. Still in
`luasyntax/Transpile.cpp`.
- **Internal**: each addon file is called with `(name, ns)` as real
arguments, as 4.3.4 does (`Script_RunAddonFile` in
`luasyntax/Transpile.cpp`) — **ungated**, an addon always gets its own
namespace. (Replaced the `__addonns` global preamble.)
- **Public** `C_AddOns.GetAddOnLocalTable` carries the gate, in
`addons/LocalTable.cpp`.
@@ -3854,3 +3795,121 @@ Not shipped by default: no addon opts in yet. The embedded `!!!ClassicAPI`
addon could add `## AllowAddOnTableAccess: 1` to its `.toc` to expose its own
namespace as a worked example, but that's a product/security decision left to
the maintainer.
## 100. Self-documenting API + `/classicapi` browser — SHIPPED (first slice)
`docs/API.md` is 18.7k lines and its anchors are signature slugs, so a
signature edit breaks the 281 internal + 88 README links into it. Blizzard
solved the same problem by shipping the API as DATA
(`Blizzard_APIDocumentationGenerated`: one table per system with typed
`Functions` / `Events` / `Tables`) plus a browser addon
(`Blizzard_APIDocumentation`, the `/api` command). warcraft.wiki.gg's
Arguments/Returns blocks are those same tables rendered by a bot — so one set
of descriptors feeds the in-game browser, a page generator, and a drift check.
**Shipped in this round**
- `namespace Game::Doc` in [src/Game.h](src/Game.h) — `Field` +
`Req`/`Opt`/`Vararg`, `FieldList`, `Function`, `Method`, `Structure`,
`Event`. Constant-initialized `const` objects; no heap, no dynamic init.
- A trailing descriptor argument on all six `Game::Lua::Register*`, and on
`Event::Custom::AutoReserve`. Zero changes inside any `Script_*`.
- [src/api/Documentation.cpp](src/api/Documentation.cpp) — records every
registration on the first pass of each Lua state, derives systems, and
exports `C_APIDocumentation.GetSystems()` / `GetSystem(name)` lazily in
Blizzard's exact table shape, plus `_classicapi_UndocumentedAPI()`.
- `AddOns/!!!ClassicAPI/APIDocumentation/` — the 8-file port of
`Blizzard_APIDocumentation`, driven by `/classicapi` (short `/capi`).
Named for what it documents: the browser holds only ClassicAPI's own
surface, so `/api` would have over-promised.
- `Util/LinkUtil.lua` — the `SetItemRef` → `LinkUtil.ProcessLink` dispatch,
wiring up a `RegisterLinkHandler` registry that had been dead code. Any
future link type now registers a handler instead of stacking another
`SetItemRef` override. Also fixed `assertsafe` being undefined there.
- Documented: the whole `C_Spell` / `C_SpellBook` surface, the spell globals
(`SpellGlobals`), the two SpellBook enums, the 13 `UNIT_SPELLCAST_*`
events, `GameTooltipAPI` (frame-method example), and `C_Glue` (glue and
mixed-environment example).
**The remaining sweep.** `_classicapi_UndocumentedAPI()` is the worklist —
~700 registrations across ~46 namespaces. Do it a namespace at a time; the
descriptor goes beside the registration and must be written against the
`Script_*` BODY, not against `docs/API.md` (which is already stale in
places — `IsHarmfulSpell` / `IsHelpfulSpell` describe an `AttributesEx` bit
read that the code does not do; it walks the effects' implicit targets).
`tools/New-ApiDocSkeletons.ps1` emits paste-ready skeletons from the 207
single-line `Usage:` strings + the API.md headings, with `// TODO` on every
guess — a starting point to verify, never to paste blind.
When the sweep finishes, delete the `= nullptr` / `= 0` defaults from the six
registrar parameters: a registration without a descriptor then fails to
compile, which is the whole point of putting the descriptor at the call site.
**Then, not before:** an `ExportAPIDocumentation` console command (glue-
registered like `ExportInterfaceFiles`, writing one Blizzard-format `.lua`
per system plus `Undocumented.txt`), a CI diff of the committed copies to
catch descriptor drift, and a Markdown/wiki generator to replace the
hand-maintained `docs/API.md` — the reason the descriptors exist at all.
## 101. `Frame:SetOnUpdateMode(mode)` + `Enum.OnUpdateMode` — medium
```
Frame:SetOnUpdateMode(onUpdateMode) -- Enum.OnUpdateMode
0 Disabled no OnUpdate, visible or not
1 RunWhenVisible the vanilla behaviour (default)
2 RunWhenVisibleOnce run once while visible; resets to Disabled BEFORE running
3 RunOnce run once regardless of visibility; resets to Disabled BEFORE running
4 RunAlways run regardless of visibility
```
"Resets before running" is load-bearing: the handler re-arms by calling
`SetOnUpdateMode` again from inside itself.
**Engine (verified 2026-09-15).** The per-frame dispatcher is `FUN_0076B2C0`,
`__thiscall(frame, float elapsed)` at vtable `+0x38`, present in every frame
vtable with subclass overrides chaining into it (same shape as the click
vmethod `FUN_00779540`):
```c
if (frame[0x128] != 0) // the OnUpdate slot
FUN_007026F0(frame, frame + 0x128, "%f", elapsed); // fmt @0x00835160
```
The walk is `FUN_00765650`: nine strata buckets of an intrusive list
(`frame+0x310` = next, saved into the bucket before each call so a handler may
unlink itself), calling `vtable+0x38` per node — with **no visibility check in
the loop and none in the dispatcher**. Visibility is therefore *list
membership*, not a test, and that list is the per-strata render list, so a
hidden frame is simply absent. Contention: a literal scan of every DLL in
`dll\` + `dll_local\` finds no reference to `FUN_0076B2C0` or `FUN_00765650`.
**Design.** Modes 0/1/2 need only a co-hook on `FUN_0076B2C0` — mode 0 returns
without calling the original, mode 2 sets Disabled then calls it, mode 1 is the
engine unchanged. Guard the detour on an empty mode map so non-users pay a
compare.
Modes 3/4 cannot come from that hook: the frame is not in the walk's list while
hidden, and forcing membership would put it in the RENDER list (it would try to
draw). Drive them from `Tick::WorldTick` instead — an already-owned hook, so no
second MinHook — calling the engine's own `FUN_0076B2C0(frame, elapsed)` so the
handler sees exactly the fire it normally would, with a per-tick mark set by the
hook so a *visible* `RunAlways` frame is not dispatched twice.
**Open question before writing it:** the `elapsed` for the self-driven modes.
The walk receives it from its caller; cleanest is to capture it in the hook (one
value per pass, shared by every frame) and reuse it, falling back to a
`Time::Clock` delta when nothing visible ran that frame. Needs checking whether
`Tick::WorldTick` (`FUN_0066FD50`, the world subsystem) runs before or after the
UI pass, which decides whether the captured value is this frame's or last
frame's.
**Cost:** one new MinHook (uncontended, guarded to ~zero when unused), one
WorldTick subscriber, a frame-keyed mode map, `Enum.OnUpdateMode`, and
`SetOnUpdateMode` / `GetOnUpdateMode`.
**Value, honestly.** `RunAlways` is the only genuinely new capability — a hidden
frame currently cannot run OnUpdate at all, which is why addons keep a
permanently-shown dummy frame for their tickers. `Disabled` duplicates
`SetScript("OnUpdate", nil)` and `RunOnce` overlaps `C_Timer.After(0, …)`, so
the rest is API parity. Worth it for the one capability plus a clean enum, not
as a performance feature.
+2367 -584
View File
File diff suppressed because it is too large Load Diff
+58 -1
View File
@@ -9,7 +9,8 @@ ImageBase `0x00400000`. All VAs in this doc use that base.
## How Blizzard wires Lua
There are two registries.
There are two Blizzard registries, plus the Lua standard libraries, which are
bound a third way (§3) and are NOT in the batch counts below.
### 1. Global functions — `FrameScript_RegisterFunction` at `0x00704120`
@@ -57,6 +58,62 @@ C3 ret
23 tables found, **481 methods total**. The runtime dispatcher at `0x005368D0`
resolves `frame:method(...)` by looking the name up in the matching context.
### 3. Lua standard libraries — `luaL_openlib` tables
The Lua 5.0 standard libraries, and Blizzard's replacements inside them, are
bound by `luaL_openlib` walking one `luaL_reg { char *name; lua_CFunction
func; }[]` table per library when the Lua state is created (`FUN_00703B80`,
called immediately BEFORE `FUN_LOAD_SCRIPT_FUNCTIONS`). They never pass
through `FrameScript_RegisterFunction`, so none of them appear in the 54
batches above or in the loop scan behind `raw_globals.txt` — which is how
`debugprofilestart` / `debugprofilestop` (stock since 1.7.0) went
uncatalogued here until 2026-09. **Before declaring a global absent, check
these tables or `/dump` the name in game.**
**Base library** — table `[0x00811E28]`, 36 entries, `.rdata`. Blizzard's
own base library: the stock 5.0 set minus `dofile`/`loadfile`/`require`/
`print` (`print` is a ClassicAPI backport), plus the `debug*` /
`*errorhandler` / `date` / `time` additions.
```
0x00702770 setglobal 0x00702DF0 type
0x007027B0 getglobal 0x00702E30 tonumber
0x007027E0 debugload 0x00702F40 tostring
0x007027F0 debuginfo 0x007030A0 unpack
0x00702800 debugprint 0x007030F0 rawequal
0x00702810 debugdump 0x00703130 rawget
0x00702820 debugbreak 0x00703160 rawset
0x00702830 debugtimestamp 0x007031A0 assert
0x00702840 debugprofilestart 0x00703200 gcinfo
0x00702860 debugprofilestop 0x00703250 collectgarbage
0x00702900 seterrorhandler 0x00703280 loadstring
0x00702950 geterrorhandler 0x007032F0 pcall
0x00702970 error 0x00703340 xpcall
0x007029F0 getmetatable 0x007033A0 date
0x00702A40 setmetatable 0x007035A0 time
0x00702AC0 getfenv 0x00703760 debugstack
0x00702BE0 setfenv
0x00702C90 next
0x00702CD0 ipairs
0x00702DA0 pairs
```
`debugprofilestart` stores `rdtsc` in `0x00CEEB58` (u64);
`debugprofilestop` pushes `|rdtsc − start| / (ticksPerSecond / 1000)` as a
double, where `ticksPerSecond` is `FUN_0042C020` = `(int64)(1000.0 /
[0x008332C8])` — a TSC scale calibrated at boot (the image holds `1.0`). So:
milliseconds with a sub-microsecond fraction, from the CPU counter, unrelated
to the engine's millisecond tick and to `GetTime()`.
The `table`, `string` and `math` libraries have the same shape (the `table`
library's `luaL_reg` is at `0x00822D70` — see `Offsets.h`
`FUN_LUA_TABLE_INSERT`) and are not enumerated here. The short global
aliases (`tinsert`, `getn`, `strlen`, `format`, `floor`, …) are not registered
at all: an embedded Lua chunk at `.data 0x008722E8`, run by the same state
init, assigns them from the library tables (`tinsert = tab.insert`). A module
that registers over a library function must therefore re-bind its alias —
`Game::Lua::RegisterGlobalAlias`.
## Scope
The 54 global-function batches split into two phases:
+84 -26
View File
@@ -69,20 +69,19 @@ The DLL hooks the engine's own text pipeline — no companion addon:
earlier `0xFF` alpha force at emit was a misdiagnosis of a bubble that built
while faded out; it froze icon-line text fully opaque while everything
around it faded.
- **Outlined fonts** — outline ink extends past the glyph advances; the
engine itself grows line height by exactly 4.0 / 2.0 pen px for thick / thin
outline (`face+0x180` bits 3 / 0, `.rdata` constants). `OutlineInkPen` reads
the same flags and constants, and half the ink leads each icon, half trails
— threaded through the shared advance helper so the emitter advance, the
justify pre-shift, and the wrap-stepper probes all agree. Without it, coins
clipped into outlined digits the moment the exact K removed the old
miscalibration's accidental slack (`968335d`).
- **Size, spacing and alignment are 4.3.4's rules** (decompiled source in
`C:\Git\wowdecomp15595`; 3.3.5 has the same). See "Icon geometry" below.
Every hand-tuned constant this module once carried (a 0.18 × font lead/trail
pad, a 0.6 × font vertical centre, a −1px region nudge, a size multiplier)
is gone.
- **Measure** — three cold FontString-level co-hooks make measure match the
drawn result: width (`FUN_00772890`), height (`FUN_007729B0`), and wrap
(`FUN_005C7260`). `GetStringWidth`, `fontstring:GetStringHeight`, wrap
breaks, and line height all count the icon.
- **Tall icons** — an icon taller than the font grows its line height, so it
does not overlap its neighbors, and it centers in the grown line. A hyperlink
- **Tall icons** — a line holding an icon taller than the font moves itself
and the lines after it down by the overflow, as 4.3.4 does, so it does not
overlap its neighbors; the icon aligns by the line's vertical justification
(below). A hyperlink
that contains a tall icon is hoverable across the whole icon
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
@@ -258,15 +257,76 @@ per build — on the first wrapped line, detected by `text == [node+0x48]` (the
node's text start) — and icons ACCUMULATE across lines. Multiple icons per line
fall out of the segmentation loop naturally.
### Vertical centring — font-relative
### Icon geometry — 4.3.4's rules, ported
`penXYZ[1]` sits near the text TOP, so the icon centre = `penY + fontHeight *
g_centerFrac` (0.6, calibrated in-game across chat + pfUI's bubble). Font height
comes from the engine's own `FUN_005c6fa0(flag, [node+0x1c])` (the emitter's
glyph-sizing call), so centring holds across font sizes without a fixed pixel
nudge. A TALL icon (height > font) centres in its GROWN line (see the tall-icon
sections in Current design) — the string-height co-hook adds the overflow, so
the extra room sits half above and half below the text.
**Size** (`ParseEmbeddedTexture`, 0x00617BA0 in 4.3.4). A WRITTEN size is not
pixels: it multiplies by `fontPx / (14 × ddcWidthScale)`, the reduction of the
engine's `windowPx / (aspectComp × 1024) × fontHeight / NDCToDDCWidth(14 /
(aspectComp × 1024))` (the call list of the real apwow.exe function confirms
`CoordinateGetAspectCompensation` + `NDCToDDCWidth`). So sizes are relative to
a 14-unit font and follow the text's size, UI scale and resolution. 1.12 holds
ddcWidthScale in the same Coordinate static (`VAR_COORD_DDC_WIDTH_SCALE`,
0x00832A44 — the four statics sit at 0x00832A40 with 4.3.4's exact initial
values). On top of that, every size multiplies by the fontstring's layout
scale (`fs+0x7C`) — 3.3.5's `CGxString::m_scale`, applied again although the
font height already includes it. 4.3.4 dropped this factor; 3.3.5's was kept
after a side-by-side look in game. Every wrap gets the same scale, as 4.3.4
passes `m_layoutScale` to every `CalcWrapPoint`: the draw builder through the
node's owner, and the three fs-level callers (the height getter, the
display-text truncation inside the rebuild, the break array) through a
fontstring scope their co-hooks set (`WrapLayoutScale`). Size cases: height ≤ 0 → font height; width absent → the height;
width > 0 → width × scale, or a RATIO of the height when the height came from
the font (`:0:2` is twice as wide); width ≤ 0 → the font height. Offsets are
screen pixels and move only the drawn texture (+x right, +y UP), never the
layout. `ResolveIconSize` is the one implementation.
**Spacing** (`InitializeTextLine`, case CODE_TEXTURESTART). An icon starts at
the previous glyph's INK edge — bearing + cell width, clamped to ≥ 0 and
capped at the glyph's own advance (`FUN_TEXT_GLYPH_BASE_ADVANCE`) — plus
the face's outline ink (4 × scale thick / 2 × scale thin, `face+0x180` bits 3 /
0; the same constants the 1.12 emitter grows its line by); with no glyph
since the line start or the last icon it starts at the pen, no gap. The next
glyph starts at `floor(iconRight + 0.5)` with no kerning against the icon.
1.12's emitter doesn't report its last glyph's position (its pen is lazy), so
`drawRun` replays the run's placements with the emitter's own helpers — the
pair advance, the snap rounding (`FADD 0.5; __ftol`), `FUN_TEXT_GLYPH_DESC`
(`CGxFont::NewCodeDesc`) and `FUN_TEXT_GLYPH_BEARING` — and floors the cell
width in snap mode exactly as the emitter draws the quad. Measures count an
icon as its width plus the outline gap when a glyph precedes it
(`SumIconAdvances`); the engine measures width alone, but counting the gap
keeps measure and render in step for tooltips and right-justified text. The
advance cap is ClassicAPI's: every measure counts the preceding glyph by its
advance, so an ink edge past the advance (a glyph cell wider than its step —
seen on "[" in chat) pushed each following icon and the rest of the line past
the measured width, and wrapped chat lines overran the frame edge.
**Vertical** (same case + `CreateGeometry`). By the node's vertical justify
(`OFF_TEXT_NODE_JUSTIFY_V`, node+0x50: 0 top / 1 middle / 2 bottom — the
branch of the origin finalize `FUN_005cdf70`): top → the icon's top on the line
top; bottom → its bottom on the baseline; middle → centred on the font height
below the line top. The baseline is `penXYZ[1]` and the line top is the draw
builder's `linkState[1]` (baseline + font height + spacing), 4.3.4's
m_baseLineHeight / m_lineHeight.
**Tall lines** (`CGxString::InitializeText` + `InitializeViewTranslation` +
`GxuFontGetWrappedTextHeight`), mirrored step for step:
- A line whose tallest icon reaches past the font height moves down by that
overflow, and every later line with it: all of it before the line for
bottom justify, all after for top, `floor(overflow / 2)` before and after
for middle. The emitter co-hook moves the builder's pen y and link band
(`penXYZ[1]` = its `local_48`, `linkState[1]/[3]` = its `local_74[0]/[2]`),
which the builder lowers per line, so the move carries forward.
- The node's summed overflow (`m_extraLineHeight`) joins the text height the
origin finalize justifies by: the `FUN_TEXT_NODE_FINALIZE_ORIGIN` co-hook
raises the node position y (`+0x24`) by half / all of it for middle /
bottom justify for the call, so the engine's own math and rounding produce
4.3.4's origin. It also runs on every reposition (`FUN_005ccb10`).
- `GetStringHeight` adds the same per-line overflows, summed. The wrap
stepper reports each line's overflow during the engine's own height pass,
so the lines are the engine's breaks; the sum is kept per fontstring while
the engine serves its cached height.
A link's hit band grows toward where its icon reaches (below for top, above
for bottom, half each way for middle); 4.3.4 keeps the band at the text.
### Measure / wrap — co-hook the tokenizer
@@ -417,11 +477,11 @@ formula.)
**Still icon-blind (accepted residuals):**
- Sub-pixel only for a string-final icon: the width hooks report its INK edge
(advance minus the trail half-pad/ink — `TrailingIconTrimPen`), matching the
engine's own last-glyph convention (the gxu width loop ends on ink, not
advance — the source of the old −0.8px measurement, whose previous-glyph
advance treatment is semantically CORRECT once an icon renders after it).
- Sub-pixel only for an icon after a glyph: layout starts it at that glyph's
INK edge while measures count the glyph's advance, so the two differ by the
glyph's right bearing — the same discrepancy 4.3.4 has between its own
layout and measure loops. A string-final icon measures exactly (the gxu
width loop ends on the last glyph's ink, which is where the icon starts).
What remains is the icon region's ±0.5px placement rounding and the base
loop's ~0 contribution for the payload-0 icon token.
- An fs whose `fs+0xFC` cache was filled while the feature was toggled
@@ -544,9 +604,7 @@ backend note.)
- **Still icon-blind** (accepted): the hyperlink hit-test past a tall icon
OUTSIDE the link on the same line. The substring measure `FUN_00772AE0` was
hooked once its real consumer surfaced (the tooltip auto-size measures
wrapped SEGMENTS with it), and the trailing-icon width residual is trimmed
to sub-pixel (`TrailingIconTrimPen` — a string-final icon reports its ink
edge, not its advance, per the engine's own last-glyph convention).
wrapped SEGMENTS with it).
## Goal & spec
+38
View File
@@ -1259,4 +1259,42 @@
2: 0x00706040 CreateFont
3: 0x007060B0 CreateFrame
=== luaL_openlib base library Table 0x00811E28 Count=36 (not a FrameScript_RegisterFunction batch; see BlizzardScriptAPI.md §3) ===
0: 0x00702770 setglobal
1: 0x007027B0 getglobal
2: 0x007027E0 debugload
3: 0x007027F0 debuginfo
4: 0x00702800 debugprint
5: 0x00702810 debugdump
6: 0x00702820 debugbreak
7: 0x00702830 debugtimestamp
8: 0x00702840 debugprofilestart
9: 0x00702860 debugprofilestop
10: 0x00702900 seterrorhandler
11: 0x00702950 geterrorhandler
12: 0x00702970 error
13: 0x007029F0 getmetatable
14: 0x00702A40 setmetatable
15: 0x00702AC0 getfenv
16: 0x00702BE0 setfenv
17: 0x00702C90 next
18: 0x00702CD0 ipairs
19: 0x00702DA0 pairs
20: 0x00702DF0 type
21: 0x00702E30 tonumber
22: 0x00702F40 tostring
23: 0x007030A0 unpack
24: 0x007030F0 rawequal
25: 0x00703130 rawget
26: 0x00703160 rawset
27: 0x007031A0 assert
28: 0x00703200 gcinfo
29: 0x00703250 collectgarbage
30: 0x00703280 loadstring
31: 0x007032F0 pcall
32: 0x00703340 xpcall
33: 0x007033A0 date
34: 0x007035A0 time
35: 0x00703760 debugstack
+251 -18
View File
@@ -14,6 +14,7 @@
#include "Game.h"
#include "MinHook.h"
#include "Offsets.h"
#include "api/Documentation.h"
namespace Game {
@@ -23,6 +24,22 @@ void *ResolveUnitToken(const char *token) {
return fn(token);
}
float PixelToInternal(float px) {
const float mul = Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE);
const float div = Read<float>(Offsets::VAR_COORD_ASPECT_COMPENSATION);
if (div == 0.0f)
return 0.0f;
return px * mul / (div * static_cast<float>(Offsets::UI_COORD_SCALE_UNIT));
}
double InternalToPixel(float internal) {
const float mul = Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE);
const float div = Read<float>(Offsets::VAR_COORD_ASPECT_COMPENSATION);
if (mul == 0.0f)
return 0.0;
return static_cast<double>(internal) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
}
namespace Lua {
// Each entry binds a typed function pointer in `Game::Lua::` to the
// corresponding raw VA in `Offsets`. The X-macro keeps the column-aligned
@@ -44,8 +61,10 @@ namespace Lua {
F(NewTable, lua_newtable, LUA_NEW_TABLE) \
F(GetTable, lua_gettable, LUA_GET_TABLE) \
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(RawGetI, lua_rawgeti, LUA_RAWGETI) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(GetMetatable,lua_getmetatable,LUA_GET_METATABLE)\
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
@@ -55,6 +74,7 @@ namespace Lua {
F(PCall, lua_pcall, LUA_PCALL) \
F(Next, lua_next, LUA_NEXT) \
F(Type, lua_type, LUA_TYPE) \
F(TypeName, lua_typename, LUA_TYPE_NAME) \
F(Error, lua_error, LUA_ERROR) \
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
@@ -99,26 +119,104 @@ void *ResolveObject(void *L, int idx) {
return result;
}
void RegisterGlobalFunction(const char *name, CFunction func) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
int ObjectLuaRef(void *object) {
using ScriptRegister_t = void(__thiscall *)(void *object, void *nameOrNull);
if (Game::Read<int>(object, Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
reinterpret_cast<ScriptRegister_t>(
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(object, nullptr);
return Game::Read<int>(object, Offsets::OFF_COBJECT_LUA_REF);
}
// Aliased to the same engine entry; the engine reads VAR_LUA_STATE
// internally to decide which state to write to, and the glue hook
// runs while that pointer is set to the glue state. Wrapped as a
// named function (rather than `using RegisterGlueFunction = ...`) so
// callers express intent at the call site.
void RegisterGlueFunction(const char *name, CFunction func) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
void PushObject(void *L, void *object) {
RawGetI(L, REGISTRY_INDEX, ObjectLuaRef(object));
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
const char *ObjectDisplayName(void *object) {
using GetName_t = const char *(__thiscall *)(void *);
const void *vtable = Game::Read<const void *>(object, 0);
const char *name = Game::Read<GetName_t>(
vtable, Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(object);
return name != nullptr ? name
: reinterpret_cast<const char *>(Offsets::VAR_STR_UNNAMED);
}
namespace {
using FrameScriptIsA_t = char(__thiscall *)(void *self, int typeId);
const char *EngineString(uintptr_t address) {
return reinterpret_cast<const char *>(address);
}
// Shared body: the engine's prologue with N accepted type ids.
void *ResolveChecked(void *L, int idx, const uintptr_t *typeIdVars, int count,
bool raiseError) {
if (Type(L, idx) != TYPE_TABLE) {
if (raiseError)
Error(L, EngineString(Offsets::STR_FS_THIS_NON_TABLE));
return nullptr;
}
void *obj = ResolveObject(L, idx);
if (obj == nullptr) {
if (raiseError)
Error(L, EngineString(Offsets::STR_FS_THIS_NON_OBJECT));
return nullptr;
}
for (int i = 0; i < count; ++i) {
if (IsA(obj, typeIdVars[i]))
return obj;
}
if (raiseError)
Error(L, EngineString(Offsets::STR_FS_WRONG_OBJECT_TYPE));
return nullptr;
}
} // namespace
int FrameScriptTypeId(uintptr_t typeIdVar) {
auto &typeId = Game::Ref<int>(typeIdVar);
if (typeId == 0) {
auto &counter = Game::Ref<int>(Offsets::VAR_FRAMESCRIPT_TYPE_ID_COUNTER);
counter += 1;
typeId = counter;
}
return typeId;
}
bool IsA(void *object, uintptr_t typeIdVar) {
auto **vtable = *reinterpret_cast<void ***>(object);
auto isA = reinterpret_cast<FrameScriptIsA_t>(
vtable[Offsets::OFF_VMT_FRAMESCRIPT_ISA / sizeof(void *)]);
return isA(object, FrameScriptTypeId(typeIdVar)) != 0;
}
void *ResolveTypedObject(void *L, int idx, uintptr_t typeIdVar, bool raiseError) {
return ResolveChecked(L, idx, &typeIdVar, 1, raiseError);
}
void *ResolveTypedObjectAny(void *L, int idx, const uintptr_t *typeIdVars,
int count, bool raiseError) {
return ResolveChecked(L, idx, typeIdVars, count, raiseError);
}
void *ResolveTooltip(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_GAMETOOLTIP_LUA_TYPE_ID, raiseError);
}
void *ResolveFrame(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_FRAME_LUA_TYPE_ID, raiseError);
}
void *ResolveRegion(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_REGION_LUA_TYPE_ID, raiseError);
}
void *ResolveTexture(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_TEXTURE_LUA_TYPE_ID, raiseError);
}
void *ResolveFontString(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_FONTSTRING_LUA_TYPE_ID, raiseError);
}
void *ResolveEditBox(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_EDITBOX_LUA_TYPE_ID, raiseError);
}
void *ResolveModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_MODEL_LUA_TYPE_ID, raiseError);
}
// Looks up `_G[name]`. If absent, creates a fresh table and binds it.
@@ -136,11 +234,110 @@ void EnsureGlobalTable(void *L, const char *name) {
Insert(L, -2); // [tbl, name, tbl]
SetTable(L, GLOBALS_INDEX); // _G[name] = tbl; pops k+v. [tbl]
}
// Same, one level down: ensures `parent[name]` is a table, with `parent`
// on top of the stack. Leaves `[parent, sub]`. Raw access, so a namespace
// carrying a metatable can't divert the read or the write.
void EnsureSubTable(void *L, const char *name) {
PushString(L, name); // [parent, name]
RawGet(L, -2); // [parent, parent[name]]
if (Type(L, -1) == TYPE_TABLE)
return;
SetTop(L, -2); // pop the non-table. [parent]
NewTable(L); // [parent, sub]
PushString(L, name); // [parent, sub, name]
PushValue(L, -2); // [parent, sub, name, sub]
RawSet(L, -4); // parent[name] = sub. [parent, sub]
}
// The escape-hatch namespace. Registering a name is not the same as owning
// it: the engine's registrars write `_G`, FrameXML and every addon load
// after that, and whoever writes last wins with no error and no way for the
// loser to notice. `GetServerTime` is the case on record — a client whose
// GameTime.lua declares its own replaced ours on every login.
//
// So every name the registrars below bind is also bound under
// `_G.ClassicAPI`, which nothing else writes. Two properties earn their
// keep, and both depend on doing this HERE rather than from Lua:
//
// - It runs at registration, so the mirror captures OUR function. A
// Lua-side `ClassicAPI.X = X` in the embedded addon would run after
// FrameXML and faithfully copy the clobbered version.
// - It mirrors by VALUE, reading the name back instead of making a
// second closure over the same C function. So `ClassicAPI.X == X`
// holds until something replaces the global, and the pair doubles as
// a clobber check for addons and for us.
//
// Not covered: frame methods (`obj:Method()` lives in the per-frame-type
// registry, not `_G`) and `RegisterIntegerEnum` values (a mirrored number
// is a copy, not a reference).
constexpr const char *kMirrorTable = "ClassicAPI";
void MirrorRegistration(const char *tableName, const char *name) {
void *L = State();
if (L == nullptr)
return;
const int top = GetTop(L);
// Read back whatever the registration actually bound.
if (tableName != nullptr) {
EnsureGlobalTable(L, tableName); // [src]
PushString(L, name); // [src, name]
RawGet(L, -2); // [src, value]
} else {
PushString(L, name); // [name]
GetTable(L, GLOBALS_INDEX); // [value]
}
if (Type(L, -1) != TYPE_FUNCTION) {
SetTop(L, top); // registration didn't land; nothing to mirror
return;
}
const int valueIdx = GetTop(L); // absolute, so the pushes below can't skew it
EnsureGlobalTable(L, kMirrorTable); // [..., mirror]
if (tableName != nullptr)
EnsureSubTable(L, tableName); // [..., mirror, sub]
PushString(L, name);
PushValue(L, valueIdx);
RawSet(L, -3); // target[name] = value
SetTop(L, top);
}
} // namespace
void RegisterGlobalFunction(const char *name, CFunction func, const Doc::Function *doc) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
MirrorRegistration(nullptr, name);
Api::Documentation::RecordGlobal(name, doc);
}
// Aliased to the same engine entry; the engine reads VAR_LUA_STATE
// internally to decide which state to write to, and the glue hook
// runs while that pointer is set to the glue state. Wrapped as a
// named function (rather than `using RegisterGlueFunction = ...`) so
// callers express intent at the call site.
void RegisterGlueFunction(const char *name, CFunction func, const Doc::Function *doc) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
// `State()` is the glue state here for the same reason the engine
// registrar writes there, so the mirror lands on the glue `_G`.
MirrorRegistration(nullptr, name);
Api::Documentation::RecordGlobal(name, doc);
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs, int docCount) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
Api::Documentation::RecordFrameMethods(context, table, count, docs, docCount);
}
// Registers `func` at `_G[tableName][methodName]`. If the namespace
// doesn't already exist, creates an empty table for it.
void RegisterTableFunction(const char *tableName, const char *methodName, CFunction func) {
void RegisterTableFunction(const char *tableName, const char *methodName, CFunction func,
const Doc::Function *doc) {
void *L = State();
if (L == nullptr)
return;
@@ -149,10 +346,41 @@ void RegisterTableFunction(const char *tableName, const char *methodName, CFunct
PushCClosure(L, func, 0); // [tbl, methodName, closure]
SetTable(L, -3); // tbl[m]=c; pops k+v. [tbl]
SetTop(L, -2); // pop tbl. []
MirrorRegistration(tableName, methodName);
Api::Documentation::RecordTable(tableName, methodName, doc);
}
// `_G[alias] = _G[tableName][methodName]`, by VALUE. The engine binds its
// short aliases this way (`tinsert = tab.insert` in the compat snippet it
// runs at Lua init), so a module that registers over a library function has
// to re-bind the alias to the same closure — that keeps `tinsert ==
// table.insert` true, as the engine left it, instead of minting a second
// closure. A plain raw write, exactly what the snippet's assignment did. No-op
// if the source is not a function (nothing was registered to alias).
void RegisterGlobalAlias(const char *alias, const char *tableName, const char *methodName,
const Doc::Function *doc) {
void *L = State();
if (L == nullptr)
return;
const int top = GetTop(L);
EnsureGlobalTable(L, tableName); // [tbl]
PushString(L, methodName); // [tbl, m]
RawGet(L, -2); // [tbl, value]
if (Type(L, -1) != TYPE_FUNCTION) {
SetTop(L, top);
return;
}
PushString(L, alias); // [tbl, value, alias]
PushValue(L, -2); // [tbl, value, alias, value]
RawSet(L, GLOBALS_INDEX); // _G[alias] = value. [tbl, value]
SetTop(L, top);
MirrorRegistration(nullptr, alias);
Api::Documentation::RecordGlobal(alias, doc);
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count) {
const EnumIntegerEntry *entries, int count,
const char *docSystem) {
void *L = State();
if (L == nullptr)
return;
@@ -166,6 +394,7 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
}
SetTable(L, -3); // parentTbl[sub] = subTbl
SetTop(L, -2); // pop parentTbl
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
}
void SetFieldNumber(void *L, const char *key, double value) {
@@ -303,8 +532,10 @@ ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
}
void RunModuleRegistrations() {
Api::Documentation::BeginPass(Api::Documentation::ENV_GAME);
for (auto *node = g_moduleHead; node != nullptr; node = node->next)
node->fn();
Api::Documentation::EndPass();
}
GlueModuleAutoRegister::GlueModuleAutoRegister(Fn f)
@@ -313,8 +544,10 @@ GlueModuleAutoRegister::GlueModuleAutoRegister(Fn f)
}
void RunGlueModuleRegistrations() {
Api::Documentation::BeginPass(Api::Documentation::ENV_GLUE);
for (auto *node = g_glueModuleHead; node != nullptr; node = node->next)
node->fn();
Api::Documentation::EndPass();
}
ReloadAutoRegister::ReloadAutoRegister(Fn f) : fn(f), next(g_reloadHead) {
+223 -6
View File
@@ -13,6 +13,7 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace Game {
@@ -70,6 +71,15 @@ inline T &Ref(uintptr_t address) {
// across a dozen modules.
void *ResolveUnitToken(const char *token);
// UI pixels <-> internal layout (anchor) units. Script_SetPoint converts its
// pixel offsets with `px * [VAR_COORD_DDC_WIDTH_SCALE] / ([VAR_COORD_ASPECT_COMPENSATION]
// * UI_COORD_SCALE_UNIT)`; Script_GetLeft and Script_GetStringWidth push the
// inverse. A region's LOCAL pixels must be multiplied by its effective scale
// (OFF_REGION_EFFECTIVE_SCALE) first. Both return 0 while the scale globals
// are unset, so an early caller gets a zero offset rather than a wild one.
float PixelToInternal(float px);
double InternalToPixel(float internal);
using FrameScript_Initialize_t = bool(__fastcall *)();
using LoadScriptFunctions_t = void(__fastcall *)();
@@ -78,6 +88,113 @@ using LoadScriptFunctions_t = void(__fastcall *)();
// registering its 109 glue functions. See FUN_LOAD_GLUE_SCRIPT_FUNCTIONS.
using LoadGlueScriptFunctions_t = void(__stdcall *)();
// --- API documentation descriptors -----------------------------------------
//
// Every Lua registration (see the `Register*` functions in `Game::Lua`) can
// carry a descriptor: the signature the function presents to Lua — typed
// arguments and returns, nilability, defaults — plus a one-sentence summary.
// The descriptors are the single source of truth for the API's SHAPE.
// `src/api/Documentation.cpp` records each registration on the first pass of
// each Lua state and exports the result on demand as Blizzard
// `APIDocumentation` tables (`C_APIDocumentation.GetSystems` / `GetSystem`),
// which the embedded addon's `/api` browser renders. Prose beyond one
// sentence stays in the docs; a descriptor is shape, not manual.
//
// Rules:
// - One descriptor per REGISTERED NAME, not per C function. The same
// `Script_*` bound twice with different return conventions
// (`IsUsableSpell` → 1/nil pairs, `C_Spell.IsSpellUsable` → booleans)
// gets two descriptors; a pair with the same shape may share one.
// - `type` uses Blizzard's spellings — "number", "string", "bool",
// "table", "luaIndex", "UnitToken", "SpellIdentifier", … — or the Name
// of a `Structure` / enumeration declared elsewhere, which the browser
// renders as a link.
// - `Function::system` names the System a GLOBAL belongs to; convention
// `<Area>Globals` ("SpellGlobals", "ItemGlobals"). Table functions
// derive theirs from the table ("C_Spell" → System "Spell", Namespace
// "C_Spell"); frame methods from the registry. A global's system must
// not reuse a namespaced System's Name — the browser would prefix it.
// - Descriptors are `const` objects with constant initializers (string
// literals + file-scope arrays): no heap, no dynamic init.
// - Summaries are user-facing text: one sentence, plain words, present
// tense, no patch versions.
namespace Doc {
struct Field {
const char *name;
const char *type;
bool nilable;
const char *defaultValue; // Lua literal ("false", "0", "\"player\""), or nullptr
const char *doc; // one sentence, or nullptr
};
// Required argument / always-present return.
constexpr Field Req(const char *name, const char *type, const char *doc = nullptr) {
return {name, type, false, nullptr, doc};
}
// Optional: nilable when no default is given, otherwise non-nil with that
// default (Blizzard marks defaulted arguments `Nilable = false` + `Default`).
constexpr Field Opt(const char *name, const char *type, const char *dflt = nullptr,
const char *doc = nullptr) {
return {name, type, dflt == nullptr, dflt, doc};
}
// Variable arguments / returns.
constexpr Field Vararg(const char *type, const char *doc = nullptr) {
return {"...", type, true, nullptr, doc};
}
// A view over a file-scope `const Field[]`; `{}` means none.
struct FieldList {
const Field *data = nullptr;
int count = 0;
constexpr FieldList() = default;
template <size_t N>
constexpr FieldList(const Field (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
// A registered function. Positional init: `{summary, args, rets}` for a
// table function, `{summary, args, rets, "XGlobals"}` for a global, and
// `{summary, args, rets, nullptr, true}` for a namespaced ClassicAPI
// extension.
struct Function {
const char *summary;
FieldList args;
FieldList rets;
const char *system; // globals / glue / aliases only; see the rules above
bool extension; // a ClassicAPI-original API, not a Blizzard one
};
// Pairs a frame method with its descriptor BY NAME (the engine's method
// tables are name-keyed; `SetShown` exists on four registries with four
// function pointers). Order is free; unmatched names are reported.
struct Method {
const char *name;
const Function *doc;
};
// A table shape a function returns or accepts → `Tables{Type="Structure"}`
// in `system`. Self-registering: nothing binds a structure to Lua, so it
// chains onto a static list at construction. Declare it next to the
// function that returns it.
struct Structure {
Structure(const char *name, const char *system, FieldList fields,
const char *summary = nullptr);
const char *name;
const char *system;
FieldList fields;
const char *summary;
const Structure *next;
};
// A custom event's payload, attached to its `Event::Custom::AutoReserve`.
struct Event {
const char *system;
const char *summary;
FieldList payload;
};
} // namespace Doc
namespace Lua {
using CFunction = int(__fastcall *)(void *L);
@@ -121,8 +238,14 @@ using lua_pushcclosure_t = void(__fastcall *)(void *L, CFunction fn, int upvals)
using lua_newtable_t = void(__fastcall *)(void *L);
using lua_gettable_t = void(__fastcall *)(void *L, int idx);
using lua_rawget_t = void(__fastcall *)(void *L, int idx);
// Pushes t[n] for the table at idx, raw (no metamethods). With idx =
// REGISTRY_INDEX it pushes a value stored under an integer ref.
using lua_rawgeti_t = void(__fastcall *)(void *L, int idx, int n);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pushes the metatable of the value at idx and returns 1, or pushes
// nothing and returns 0 when it has none.
using lua_getmetatable_t = int(__fastcall *)(void *L, int idx);
// Pops the table at the top and installs it as the metatable of the value
// at idx; returns 1.
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
@@ -134,6 +257,7 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
using lua_next_t = int(__fastcall *)(void *L, int idx);
using lua_type_t = int(__fastcall *)(void *L, int index);
using lua_typename_t = const char *(__fastcall *)(void *L, int type);
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
// and throws. Existing callers pass a single literal (no `%`), which
@@ -179,8 +303,10 @@ extern const lua_pushcclosure_t PushCClosure;
extern const lua_newtable_t NewTable;
extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_rawgeti_t RawGetI;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_getmetatable_t GetMetatable;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
@@ -190,6 +316,7 @@ extern const lua_call_t Call;
extern const lua_pcall_t PCall;
extern const lua_next_t Next;
extern const lua_type_t Type;
extern const lua_typename_t TypeName;
extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
@@ -216,9 +343,84 @@ void *State();
// the `self` slot of any `frame:method(...)` invocation.
void *ResolveObject(void *L, int idx);
// The inverse: the object's Lua-registry ref, created on first use exactly as
// the engine does it (GetMouseFocus and the script runners register an
// object with FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER when its refcount is 0).
int ObjectLuaRef(void *object);
// Pushes the object's Lua table (registry[ObjectLuaRef(object)]).
void PushObject(void *L, void *object);
// How the engine names an object in its frame-script usage errors:
// `GetName() ?: "<unnamed>"` (VTBL_SCRIPTOBJECT_GET_NAME).
const char *ObjectDisplayName(void *object);
// Same resolve, then the type gate every engine frame method applies to its
// `self` before touching it: `lua_type == table`, resolve to the object, then
// the `IsA(typeId)` vmethod at vtable+0x10. `ResolveObject` alone is not
// enough for a method that casts the result — it hands back a
// `CFrameScriptObject *` for ANY frame, so `GameTooltip.SetSpellByID(button)`
// would reach the tooltip builder with a Button. Frame methods are ordinary
// first-class Lua values (the per-type dispatcher is an `__index` handler
// that returns the method closure), so that call is reachable from plain Lua
// and the gate is not optional.
//
// `typeIdVar` is the class's `VAR_*_LUA_TYPE_ID`, which must match the
// registry the method was registered on. The id is assigned lazily by
// whichever method of the class runs first, so these mirror that assignment
// and work even before any stock method of the type has been called.
//
// Raises the engine's own three errors and returns null. Pass
// `raiseError = false` for an optional object ARGUMENT, where the caller
// wants a silent null rather than a "this" error naming the wrong slot.
// Returns null either way, so callers always check.
void *ResolveTypedObject(void *L, int idx, uintptr_t typeIdVar,
bool raiseError = true);
// For a method registered on SEVERAL registries, where any of those types is
// a legitimate `self`. Accepts the object if `IsA` passes for any of the
// listed ids — which is exactly what being in all of those registries means,
// and avoids having to pin down the shared base class.
void *ResolveTypedObjectAny(void *L, int idx, const uintptr_t *typeIdVars,
int count, bool raiseError = true);
// The class's live type id, assigning it if the engine has not yet. Needed
// directly only when calling a type-specific vtable slot.
int FrameScriptTypeId(uintptr_t typeIdVar);
// The object's `IsA(typeId)` vmethod for a class's `VAR_*_LUA_TYPE_ID`.
bool IsA(void *object, uintptr_t typeIdVar);
// Per-type sugar over `ResolveTypedObject`, so a call site cannot pair a
// method with the wrong class's id. `idx` defaults to the `self` slot.
void *ResolveTooltip(void *L, int idx = 1, bool raiseError = true);
void *ResolveFrame(void *L, int idx = 1, bool raiseError = true);
void *ResolveRegion(void *L, int idx = 1, bool raiseError = true);
void *ResolveTexture(void *L, int idx = 1, bool raiseError = true);
void *ResolveFontString(void *L, int idx = 1, bool raiseError = true);
void *ResolveEditBox(void *L, int idx = 1, bool raiseError = true);
void *ResolveModel(void *L, int idx = 1, bool raiseError = true);
// Every name these registrars bind is ALSO bound under `_G.ClassicAPI` —
// `ClassicAPI.GetSpellInfo`, `ClassicAPI.C_Item.IsBound` — as an escape
// hatch for names something else replaces later, and by value so
// `ClassicAPI.X == X` until that happens. Automatic; a new module needs no
// extra call. See `MirrorRegistration` in Game.cpp for why it lives here
// and what it does not cover.
//
// Every registrar also RECORDS the registration for the API documentation
// (`src/api/Documentation.cpp`): kind, table, name, registry, and which Lua
// state's pass it ran in (in-game / glue / both). Only the FIRST pass of
// each state records — later passes (every `/reload`) are no-ops — and a
// registration made outside a `Run*Registrations` pass is not recorded at
// all. The trailing `doc` argument is the descriptor (see `Game::Doc`
// above); pass `nullptr` for a not-yet-documented registration and
// `_classicapi_UndocumentedAPI()` lists it. The defaults exist only until
// the sweep finishes; then they go and a missing descriptor stops compiling.
// Registers a single global Lua function (e.g. `GetSpellInfo`). The function
// must use the WoW Lua C function ABI: `int __fastcall(void *L)`.
void RegisterGlobalFunction(const char *name, CFunction func);
void RegisterGlobalFunction(const char *name, CFunction func,
const Doc::Function *doc = nullptr);
// Glue-state equivalent of `RegisterGlobalFunction`. Identical wire
// (calls `FrameScript_RegisterFunction`); the engine routes the
@@ -228,7 +430,8 @@ void RegisterGlobalFunction(const char *name, CFunction func);
// its own 109 glue functions, and `VAR_LUA_STATE` still points at the
// glue state. Calling outside that window would silently target the
// wrong state.
void RegisterGlueFunction(const char *name, CFunction func);
void RegisterGlueFunction(const char *name, CFunction func,
const Doc::Function *doc = nullptr);
// Frame-method registration entry: { name, func } pairs walked by the engine's
// per-frame-type method-table iterator. Layout matches what the engine expects
@@ -240,15 +443,26 @@ struct FrameMethodEntry {
// Registers a batch of methods on a per-frame-type registry (e.g.
// GameTooltipMethodRegistry for `tooltip:Foo()` calls). `context` is the
// registry address — see Offsets::VAR_*_METHOD_REGISTRY.
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count);
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `docs` pairs
// descriptors to the entries by NAME (any order, any subset).
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs = nullptr, int docCount = 0);
// Registers `func` at `_G[tableName][methodName]`, creating the namespace
// table if it doesn't already exist. This is how modern WoW C_*-style APIs
// are bound — the engine has no built-in support for table-bound Lua
// functions, so we manipulate the globals table directly via the Lua C API.
void RegisterTableFunction(const char *tableName, const char *methodName,
CFunction func);
CFunction func, const Doc::Function *doc = nullptr);
// Binds `_G[alias]` to the SAME closure as `_G[tableName][methodName]`, by
// value — the way the engine's own Lua-init snippet binds its short aliases
// (`tinsert = table.insert`, `strlen = string.len`, …). Use it after
// registering over a library function whose alias the snippet has already
// captured, so the alias follows the replacement and `alias == table.method`
// stays true. Mirrored under `_G.ClassicAPI` like every other registration.
void RegisterGlobalAlias(const char *alias, const char *tableName, const char *methodName,
const Doc::Function *doc = nullptr);
// Key/value pair for `RegisterIntegerEnum`. `key` becomes a field name
// (PascalCase, matching Blizzard's `Enum.*` naming) and `value` is the
@@ -261,8 +475,11 @@ struct EnumIntegerEntry {
// Registers `_G[parent][sub] = { entries }` as an integer-valued enum
// table, creating `_G[parent]` if needed. Used for Blizzard-style
// `Enum.AddOnSecurityStatus = { Secure=0, Insecure=1, ... }` shapes.
// `docSystem` is the documentation System the enumeration belongs to
// ("SpellBook", "Item", …); the entries themselves are the documentation.
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count);
const EnumIntegerEntry *entries, int count,
const char *docSystem = nullptr);
// Set `t[key] = value` on the table currently at stack[-1] (the most
// common shape used when populating a struct-style table mid-build).
+99 -110
View File
@@ -11,7 +11,10 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "HookSecureFunc.h"
#include "Game.h"
#include "Offsets.h"
#include <cstring>
@@ -19,14 +22,19 @@ namespace HookSecureFunc {
namespace {
// Names that must not be hooked when targeting `_G`. Hooking any of these
// would either break taint propagation (the "secure" functions) or replace
// core Lua language primitives whose behavior the engine depends on
// internally — e.g. `pairs` is called from C in the iterator path; if Lua
// `pairs` is replaced with a wrapper closure, the engine still gets the
// original via its own table-method dispatch, but Lua-side iteration goes
// through our wrapper, and the two diverging is a footgun. Modern WoW
// rejects the hook outright; we mirror that. Sorted alphabetically.
// Names that must not be hooked when targeting `_G`. This is the client's
// own list, VERBATIM: patch 11.0.0 (The War Within, 2024-07-23) made
// `hooksecurefunc` refuse exactly these 23 names with a "Cannot hook
// function" error — the taint-relevant primitives (the "secure" family plus
// the Lua primitives secure code paths run through). It is deliberately NOT
// "every base-library function": 11.0 leaves `tostring`, `error`,
// `loadstring`, `assert`, `collectgarbage`, … hookable, and includes names
// that are not base-library functions at all. So do not derive this from
// the engine's `luaopen_base` table (`0x00811E28`, 36 entries — see
// BlizzardScriptAPI.md §3); that is a different set with a different
// purpose. Names with no 1.12 counterpart (`wipe` is `table.wipe` here; the
// secure family does not exist) stay for parity of the error an addon
// sees. Sorted alphabetically.
const char *const kUnhookableNames[] = {
"getfenv", "getmetatable", "hooksecurefunc", "ipairs", "issecurevalue",
"issecurevariable", "next", "pairs", "pcall", "pcallwithenv", "rawget",
@@ -45,138 +53,113 @@ bool IsUnhookable(const char *name) {
return false;
}
} // namespace
// Wrapper closure body — called when the hooked function is invoked.
// Stack contains the wrapper's args (`[arg1..argN]`); the closure's
// upvalues hold `(orig, callback)`.
// upvalues hold `(orig, callback)`. 4.3.4's FrameScript_HookWrapper, less
// its taint bookkeeping (1.12 has no taint):
//
// Modern `hooksecurefunc` semantics: original runs first, callback
// runs after with the same args (returns ignored), original's
// results propagate to the caller. No cap on return count. A buggy
// callback must NOT prevent the original's results from reaching
// the caller — that's why step (3) uses pcall.
//
// Stack-shuffling mirrors 3.3.5's `FUN_00817050` so the args sit at
// the top for both calls without copying them twice; results sink
// to the bottom and end up as the wrapper's natural return frame.
int __fastcall HookWrapper(void *L) {
// 1. orig(...) with lua_call — an error in the original propagates to
// the caller, as it would unhooked.
// 2. Results sink below the args, so they end up as the wrapper's
// return frame without being copied again.
// 3. callback(...) through lua_pcall with the engine's frame-script
// message handler as errfunc: a broken hook is REPORTED the way any
// script error is (the error frame / geterrorhandler), and the
// original's results still reach the caller.
int __fastcall Wrapper(void *L) {
const int nargs = Game::Lua::GetTop(L);
// (1) orig(...) — pops orig + nargs args (copies), pushes results.
// Stack after: `[arg1..argN, res1..resM]`.
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
// (1) Stack after: `[arg1..argN, res1..resM]`.
for (int i = 1; i <= nargs; ++i)
Game::Lua::PushValue(L, i);
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
Game::Lua::Insert(L, nargs + 1);
Game::Lua::Call(L, nargs, Game::Lua::MULTRET);
const int nresults = Game::Lua::GetTop(L) - nargs;
// (2) Shuffle results below the saved args. Each `Insert(L, 1)`
// moves the current top down to position 1; running it
// nresults times rotates the results block beneath the args.
// (2) Each `Insert(L, 1)` moves the top value to the bottom; nresults
// of them rotate the results block beneath the args.
// Stack after: `[res1..resM, arg1..argN]`.
for (int i = 0; i < nresults; ++i)
Game::Lua::Insert(L, 1);
// (3) callback(...) via pcall — errors are caught and dropped so
// the original's return values always reach the caller. This
// is the load-bearing safety property of hooksecurefunc on
// 3.x; on 1.12 it just means a buggy hook can't break the
// hooked path. errfunc=0 → no traceback (3.3.5 uses
// `registry["_TRACEBACK"]`, not a 1.12 convention; we'd be
// discarding the formatted message anyway).
// (3) Stack: `[res1..resM, errfunc, callback, arg1..argN]`. The
// handler ref is the one every engine script pcall passes; 0 (not
// yet set) falls back to no handler rather than a bad index.
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2));
Game::Lua::Insert(L, nresults + 1);
if (Game::Lua::PCall(L, nargs, 0, 0) != 0) {
// pcall failure leaves the error message on top; drop it.
Game::Lua::Remove(L, -1);
int errIdx = 0;
const int errRef = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
if (errRef > 0) {
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, errRef);
Game::Lua::Insert(L, nresults + 1);
errIdx = nresults + 1;
}
Game::Lua::PCall(L, nargs, 0, errIdx);
// Stack: `[res1..resM]` — return the original's results verbatim.
// Drop the handler and any error value — `[res1..resM]` remains.
Game::Lua::SetTop(L, nresults);
return nresults;
}
// `hooksecurefunc(name, callback)` — hooks a global by name, target
// table is `_G`.
// `hooksecurefunc(table, name, callback)` — hooks `table[name]`.
//
// In both cases the original function runs first and its results
// propagate; `callback` runs after with the same args (returns
// discarded). The "secure" label refers to taint propagation in
// 3.x+; on 1.12 it's just a post-hook with return preservation.
void PushWrapper(void *L) {
Game::Lua::PushCClosure(L, &Wrapper, 2);
}
namespace {
// `hooksecurefunc([table,] "function", hookfunc)` — replaces
// `table[name]` (default `_G`) with a wrapper that runs the original, then
// `hookfunc` with the same args, and returns the original's results.
// 4.3.4's Script_hooksecurefunc, less taint:
// * a non-table first argument means `_G` is inserted at index 1, so
// both forms share one index layout;
// * the name only has to pass lua_isstring, so a number works (and is
// looked up as its string, the same as lua_tostring makes it);
// * the original is read with lua_gettable (inherited methods resolve
// through __index) but the wrapper is written with lua_rawset, so a
// target's __newindex never runs;
// * the error texts are 4.3.4's.
// The one addition is the 11.0 unhookable-name check for the `_G` form.
int __fastcall Script_HookSecureFunc(void *L) {
int targetIdx, nameIdx, callbackIdx;
const int t1 = Game::Lua::Type(L, 1);
const bool implicitGlobals = Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE;
if (implicitGlobals) {
Game::Lua::PushValue(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::Insert(L, 1);
}
if (t1 == Game::Lua::TYPE_TABLE) {
// Three-arg form: (table, name, callback)
targetIdx = 1;
nameIdx = 2;
callbackIdx = 3;
} else if (t1 == Game::Lua::TYPE_STRING) {
// Two-arg form: (name, callback) — target = _G via pseudo-
// index. Don't push _G with `lua_pushvalue(GLOBALS_INDEX)`:
// it crashes (the pushvalue → setobj2s chain dereferences a
// bad TValue pointer for that pseudo-index). `lua_gettable` /
// `lua_settable` accept GLOBALS_INDEX as the table arg
// directly, so we use it that way without pushing.
targetIdx = Game::Lua::GLOBALS_INDEX;
nameIdx = 1;
callbackIdx = 2;
} else {
Game::Lua::Error(L,
"hooksecurefunc(table, name, callback) or hooksecurefunc(name, callback)");
if (!Game::Lua::IsString(L, 2) ||
Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: hooksecurefunc([table,] \"function\", hookfunc)");
return 0;
}
Game::Lua::SetTop(L, 3); // [t, name, hookfunc]
// Converts a number name to its string in place; the string stays on
// the stack, so the pointer outlives every use below.
const char *name = Game::Lua::ToString(L, 2);
// Block the hook when targeting `_G` implicitly and the name is in the
// unhookable set. Not applied to the three-arg form (table targets):
// hooking `MyLib.pairs` is fine even if Lua's global `pairs` is not,
// and a caller who passes `_G` explicitly is opting in deliberately.
if (implicitGlobals && IsUnhookable(name)) {
Game::Lua::Error(L, "hooksecurefunc: function is unhookable");
return 0;
}
if (Game::Lua::Type(L, nameIdx) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "hooksecurefunc: name must be a string");
return 0;
}
if (Game::Lua::Type(L, callbackIdx) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "hooksecurefunc: callback must be a function");
return 0;
}
// Block the hook when targeting `_G` and the name is in the unhookable
// set. We don't apply this to the three-arg form (table targets):
// hooking `MyLib.pairs` is fine even if Lua's global `pairs` is not —
// the blacklist is specifically about `_G[name]`. A user who passes
// `_G` explicitly as the table target is opting in deliberately and we
// don't second-guess it.
if (targetIdx == Game::Lua::GLOBALS_INDEX) {
const char *name = Game::Lua::ToString(L, nameIdx);
if (IsUnhookable(name)) {
Game::Lua::Error(L,
"hooksecurefunc: function is unhookable");
return 0;
}
}
// Fetch target[name] — the original function. `gettable` uses
// metamethods, so frame methods (resolved via the frame
// metatable's __index) come back as callable closures, exactly
// what we want to wrap.
Game::Lua::PushValue(L, nameIdx);
Game::Lua::GetTable(L, targetIdx);
Game::Lua::PushValue(L, 2);
Game::Lua::GetTable(L, 1); // [t, name, hookfunc, orig]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "hooksecurefunc: target field is not a function");
Game::Lua::Error(L, "hooksecurefunc(): %s is not a function", name);
return 0;
}
// Push callback as the second upvalue; orig is already on top
// from the GetTable above.
Game::Lua::PushValue(L, callbackIdx);
// Build the wrapper as a C closure with (orig, callback) as the
// two upvalues. PushCClosure pops the upvalues from the stack
// and pushes the closure.
Game::Lua::PushCClosure(L, &HookWrapper, 2);
// Install: target[name] = wrapper. SetTable expects the value
// on top and the key just below; push them in that order.
Game::Lua::PushValue(L, nameIdx);
Game::Lua::PushValue(L, -2); // duplicate the closure
Game::Lua::SetTable(L, targetIdx);
Game::Lua::Insert(L, 3); // [t, name, orig, hookfunc]
PushWrapper(L); // [t, name, wrapper]
Game::Lua::RawSet(L, 1); // t[name] = wrapper
return 0;
}
@@ -185,7 +168,13 @@ int __fastcall Script_HookSecureFunc(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("hooksecurefunc", &Script_HookSecureFunc);
}
// 4.3.4 registers it from FrameScript_Initialize, which runs for the glue
// state (Client.cpp) as well as the world (GameUI.cpp).
static void RegisterGlueFunctions() {
Game::Lua::RegisterGlueFunction("hooksecurefunc", &Script_HookSecureFunc);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlueFunctions};
} // namespace HookSecureFunc
+32
View File
@@ -0,0 +1,32 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// The one hook wrapper `hooksecurefunc` and `Frame:HookScript` both install —
// 4.3.4 uses its FrameScript_HookWrapper for both, so a hooked function and a
// hooked script behave the same way.
namespace HookSecureFunc {
// The wrapper closure body. Upvalue 1 = the original, upvalue 2 = the hook.
// Runs the original with the wrapper's args (its errors propagate), then the
// hook with the same args under the engine's script error handler, and
// returns the original's results. Exposed so readers can recognize the
// closure by its C function.
int __fastcall Wrapper(void *L);
// Pops the hook (top) and the original (below it) and pushes the wrapper
// closure over them.
void PushWrapper(void *L);
} // namespace HookSecureFunc
+1861 -224
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -25,6 +25,7 @@
// macro: "macro", macroIndex (1-based)
// bag item: "item", nil (see note below)
// item-by-id: "item", itemID
// equipment set: "equipmentset", setName
//
// The subType is always "spell" for spell-type entries — the engine
// helper hardcodes pet=0 for entries on this table. Pet-bar actions
@@ -48,6 +49,7 @@
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "equipmentset/Data.h"
#include <cstdint>
@@ -126,6 +128,20 @@ int __fastcall Script_GetActionInfo(void *L) {
return 2;
}
if (type == Offsets::ACTION_TYPE_EQUIPMENT_SET) {
// 3.3.5 `Script_GetActionInfo` (`FUN_005a8f10`): the type and the
// set's name.
const EquipmentSet::Set *set = EquipmentSet::Data::FindByID(
entry & ~static_cast<uint32_t>(Offsets::ACTION_TYPE_EQUIPMENT_SET));
if (set == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, "equipmentset");
Game::Lua::PushString(L, set->name.c_str());
return 2;
}
// Unknown type tag — surface as nil so callers don't get garbage.
Game::Lua::PushNil(L);
return 1;
+33
View File
@@ -0,0 +1,33 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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/>.
// The `ActionButtonUseKeyDown` cvar — registered by 4.3.4's engine
// (GameUI_CVar.cpp, "Activate the action button on a keydown") and read only
// by FrameXML's action-button key handlers, which this project's addon ports
// (Util/ActionButton.lua). The engine half is the registration. The default
// is 0, keeping 1.12's action buttons firing on key release; 4.3.4 shipped 1.
#include "Game.h"
#include "cvar/Factory.h"
namespace Action::UseKeyDown {
namespace {
void Register() { CVar::Factory::Register("ActionButtonUseKeyDown", "0"); }
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::UseKeyDown
+1 -1
View File
@@ -137,7 +137,7 @@ bool ExtractTocVersion(const char *content, size_t size,
size_t n = 0;
if (outSize == 0)
return false;
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
if (!AddOns::Toc::FindValue(content, size, "Version", &v, &n) || n == 0) {
out[0] = '\0';
return false;
}
+6 -6
View File
@@ -12,17 +12,17 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AddOns.GetAddOnLocalTable(name)` — the public, gated view of an addon's
// private namespace table (the `local _, addonTable = ...` second vararg the
// transpiler injects; see luasyntax/AddonNamespace.h).
// private namespace table (the `local _, addonTable = ...` second vararg each
// addon file is called with; see luasyntax/AddonNamespace.h).
//
// Retail added this in 11.0 alongside the `## AllowAddOnTableAccess` TOC
// directive: one addon can read another's local table ONLY when the target
// opts in. We mirror that gate:
// 1. the named addon is currently LOADED, and
// 2. its `.toc` declares `## AllowAddOnTableAccess: 1`.
// Either check failing returns nil. The internal preamble path (an addon
// receiving its OWN table) is a different, ungated function (`__addonns`) —
// an addon never needs permission for its own namespace.
// Either check failing returns nil. An addon receiving its OWN table is a
// different, ungated path (its files are called with it at load) — an addon
// never needs permission for its own namespace.
//
// Directive spelling verified against the 1.15.8 Classic Era binary (both the
// `AllowAddOnTableAccess:` TOC key and the `GetAddOnLocalTable` name are
@@ -77,7 +77,7 @@ bool TocAllowsTableAccess(const char *name) {
size_t n = 0;
bool allowed = false;
if (AddOns::Toc::FindValue(static_cast<const char *>(buf), size,
"## AllowAddOnTableAccess:", &v, &n)) {
"AllowAddOnTableAccess", &v, &n)) {
int value = 0;
bool anyDigit = false;
for (size_t k = 0; k < n && v[k] >= '0' && v[k] <= '9'; ++k) {
+21 -63
View File
@@ -16,16 +16,15 @@
// a stock 1.12 client, each fixed here with the engine's own machinery:
//
// 1. FILE VISIBILITY. Every relative-path read resolves through a
// loose-file hash index built ONCE at boot (see Offsets.h,
// `FUN_VFS_INDEX_SUBTREE`); files created after boot — a new
// addon's TOC, a new .lua in an existing addon, a freshly written
// SavedVariables file — are invisible until restart. The boot
// indexer is dedup-safe and works on subtrees, so we re-run it per
// /reload on the only two subtrees whose contents can change what
// a /reload loads: `Interface\AddOns` and `WTF\Account`. New files
// in EXISTING addons need nothing more — the per-addon loader
// `FUN_0051F240` re-reads the TOC from disk on every load pass, so
// once the files are visible they load.
// loose-file hash index (see Offsets.h, `FUN_VFS_INDEX_CLEAR`);
// a file created after the index was built — a new addon's TOC, a
// new .lua in an existing addon — is invisible until it is rebuilt.
// We call the engine's own clear, the same one the engine calls
// after writing a new SavedVariables file: the next lookup rebuilds
// the index from disk as boot does, so new files appear and deleted
// ones drop out. New files in EXISTING addons need nothing more —
// the per-addon loader `FUN_0051F240` re-reads the TOC from disk on
// every load pass, so once the files are visible they load.
//
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
// new folders are never walked. We replay the login scan's disk
@@ -78,10 +77,9 @@
// registration gets. New entries come from the same parser call
// `Addons::Embedded` has exercised every login.
//
// Thread note: the loose-file index has no lock, but the engine itself
// builds it lazily on first access and we mutate it only during the
// reload loading screen on the main thread — the same context in which
// the engine does all its own file work.
// Thread note: the loose-file index has no lock in 1.12. We clear it on
// the main thread during the reload loading screen — the same context
// in which the engine's own unload pass clears it.
#include "Game.h"
#include "Offsets.h"
@@ -102,11 +100,7 @@ namespace {
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
using FindOpen_t = void *(__stdcall *)(const char *dirPath);
using FindClose_t = void(__stdcall *)(void *findBlock);
using IndexSubtree_t = void(__fastcall *)(const char *basePath,
const char *relSubdir,
void *findHandle);
using IndexClear_t = void(__cdecl *)();
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
const char *pattern, void *callback,
void *userParam, int includeHidden);
@@ -163,45 +157,9 @@ uintptr_t ResolveEntryByName(const char *name) {
// ── Step 1: loose-file index refresh ──────────────────────────────────
// Register new on-disk files under `relSubdir` with the frozen index,
// exactly as the boot walk would have (the indexer skips every file
// already present). No-op when the directory doesn't exist —
// `FUN_VFS_FIND_OPEN` requires an existing directory and returns NULL.
void ReindexSubtree(const char *base, const char *relSubdir) {
char dir[260];
std::snprintf(dir, sizeof(dir), "%s\\%s", base, relSubdir);
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
void *handle = findOpen(dir);
if (handle == nullptr)
return;
auto index =
reinterpret_cast<IndexSubtree_t>(Offsets::FUN_VFS_INDEX_SUBTREE);
index(base, relSubdir, handle);
findClose(handle);
}
// Drop the loose-file index; the next file lookup rebuilds it from disk.
void RefreshLooseFileIndex() {
// The boot walk's base choice (`FUN_00646EA0`): the recorded game
// dir if it opens as a directory, else ".". Index keys are relative
// to this, so it must match or the new keys would never be hit.
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
const char *base =
reinterpret_cast<const char *>(Offsets::VAR_VFS_BASE_PATH);
void *probe = findOpen(base);
if (probe != nullptr)
findClose(probe);
else
base = ".";
// The only subtrees whose post-boot changes affect what a /reload
// loads: addon content, and the SavedVariables the unload pass
// wrote moments ago (a first-ever SV file is otherwise invisible —
// the stock client loses a freshly installed addon's settings on
// /reload for exactly this reason).
ReindexSubtree(base, "Interface\\AddOns");
ReindexSubtree(base, "WTF\\Account");
reinterpret_cast<IndexClear_t>(Offsets::FUN_VFS_INDEX_CLEAR)();
}
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
@@ -437,13 +395,13 @@ void RebuildDisplayArray() {
}
void Run() {
// Registry populated (login scan ran) and loose index built — both
// always true by the first in-world LoadScriptFunctions, but these
// are the states the steps below mutate, so gate explicitly.
// Registry populated (login scan ran) — always true by the first
// in-world LoadScriptFunctions, but it is the state the steps below
// mutate, so gate explicitly. No gate on the index being built: the
// unload pass clears it whenever it wrote a new SV file, and the
// clear below is safe either way.
if (*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0 ||
*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_VFS_INDEX_READY)) == 0)
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0)
return;
RefreshLooseFileIndex();
+26 -34
View File
@@ -35,18 +35,19 @@
// The engine STILL runs its own SavedVariables load at the normal step, right
// after the files. Left alone, that re-load would overwrite any value the
// addon wrote to its SavedVariable at file scope before ADDON_LOADED. So we
// also co-hook FUN_LUA_LOAD_FILE and suppress that one redundant re-load: each
// path we pre-load is remembered, and the engine's imminent load of that exact
// path is skipped once. This makes the behavior match true
// LoadSavedVariablesFirst — file-scope reads AND writes both survive. Our own
// early load calls the trampoline directly, so it is never self-suppressed.
// FUN_LUA_LOAD_FILE loads FILES only (not RunScript string execution), so the
// extra hook is a cold, addon-load-time path.
// also suppress that one redundant re-load, through Script::LoadFile's shared
// FUN_LUA_LOAD_FILE hook: each path we pre-load is remembered, and the
// engine's imminent load of that exact path is skipped once. This makes the
// behavior match true LoadSavedVariablesFirst — file-scope reads AND writes
// both survive. Our own early load calls Script::LoadFile::Original, so it is
// never self-suppressed. FUN_LUA_LOAD_FILE loads FILES only (not RunScript
// string execution), so this is a cold, addon-load-time path.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "script/LoadFile.h"
#include <cstddef>
#include <cstdint>
@@ -67,14 +68,12 @@ using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
using LuaLoadFileFn = uint32_t(__fastcall *)(const char *path, void *a2, void *a3);
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
LoadTocFilesFn g_orig = nullptr;
LuaLoadFileFn g_origLuaLoad = nullptr;
// Full SV paths we pre-loaded whose imminent engine re-load must be skipped
// once. Populated in LoadSavedVarsEarly, drained by LuaLoad_h. Only ever holds
// once. Populated in LoadSavedVarsEarly, drained by SuppressReload. Only ever holds
// a handful of entries (one addon's SV files, briefly). Main-thread only.
std::vector<std::string> g_pendingSuppress;
@@ -148,18 +147,18 @@ SvFlags ReadSvFlags(const char *tocPath) {
const char *v = nullptr;
size_t n = 0;
if (AddOns::Toc::FindValue(b, size, "## LoadSavedVariablesFirst:", &v, &n))
if (AddOns::Toc::FindValue(b, size, "LoadSavedVariablesFirst", &v, &n))
f.first = NonzeroFlag(v, n);
// Gate each SV file on the DECLARATION, not mere file existence — mirroring
// the engine, which loads the account file iff `## SavedVariables` is
// declared and the per-char file iff `## SavedVariablesPerCharacter` is.
// (The trailing `:` keeps "## SavedVariables:" from matching the
// "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// (FindValue requires `:` right after the key, so "SavedVariables" never
// matches the "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// scope the addon no longer declares would be executed early — Lua the
// engine never runs — and queued for a suppression the engine never drains.
f.account = AddOns::Toc::FindValue(b, size, "## SavedVariables:", &v, &n) && n > 0;
f.account = AddOns::Toc::FindValue(b, size, "SavedVariables", &v, &n) && n > 0;
f.perChar =
AddOns::Toc::FindValue(b, size, "## SavedVariablesPerCharacter:", &v, &n) && n > 0;
AddOns::Toc::FindValue(b, size, "SavedVariablesPerCharacter", &v, &n) && n > 0;
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
@@ -173,12 +172,10 @@ bool FileExists(const char *path) {
// Load a SavedVariables file we found, then mark its path so the engine's own
// re-load of it (right after the addon's files) is suppressed — preserving any
// file-scope write. Calls the trampoline directly so this load is never itself
// suppressed by our LuaLoad_h hook.
// file-scope write. Calls the engine loader directly so this load is never
// itself suppressed by SuppressReload.
void RunLuaFile(const char *path) {
if (g_origLuaLoad == nullptr)
return;
g_origLuaLoad(path, nullptr, nullptr);
Script::LoadFile::Original(path, nullptr, nullptr);
g_pendingSuppress.emplace_back(path);
}
@@ -241,18 +238,16 @@ uint32_t __fastcall LoadTocFiles_h(char *tocPath, int *a2, int *a3) {
// Skip the engine's one redundant SavedVariables re-load per pre-loaded path.
// Every other file load passes straight through. Match is by full path
// (case-insensitive); a path is suppressed exactly once, so nothing else is
// affected. Our own early load bypasses this via the trampoline.
uint32_t __fastcall LuaLoad_h(const char *path, void *a2, void *a3) {
if (path != nullptr) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return 0; // suppress — the addon's file-scope SV writes survive
}
// affected. Our own early load bypasses this through Original.
bool SuppressReload(const char *path) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return true; // suppress — the addon's file-scope SV writes survive
}
}
return g_origLuaLoad(path, a2, a3);
return false;
}
const Game::HookAutoRegister _hook{
@@ -260,10 +255,7 @@ const Game::HookAutoRegister _hook{
reinterpret_cast<void *>(&LoadTocFiles_h),
reinterpret_cast<void **>(&g_orig)};
const Game::HookAutoRegister _hookLua{
Offsets::FUN_LUA_LOAD_FILE,
reinterpret_cast<void *>(&LuaLoad_h),
reinterpret_cast<void **>(&g_origLuaLoad)};
const Script::LoadFile::AutoSuppress _suppress{&SuppressReload};
} // namespace
+50 -27
View File
@@ -33,35 +33,58 @@ inline bool EqCI(const char *s, size_t n, const char *lit) {
return lit[n] == '\0';
}
// Scans an in-memory TOC buffer for `directive` (the full line prefix, e.g.
// "## Version:") at a line start, case-insensitively, and returns the value
// that follows via out-params: [*valStart, *valStart + *valLen), trimmed of
// leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// Returns false (out-params untouched) when the directive is absent. Pure —
// reads no files; the caller owns the buffer (via FUN_FILE_READ, or an embedded
// copy). Shared by addons/LocalTable.cpp (AllowAddOnTableAccess) and
// addons/Embedded.cpp (Version) so the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *directive,
// Scans an in-memory TOC buffer for the `## <key>:` directive and returns the
// first one's value via out-params: [*valStart, *valStart + *valLen), trimmed
// of leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// `key` is the bare name ("Version", "SavedVariables"). Returns false
// (out-params untouched) when the directive is absent.
//
// Matches lines the way the engine's TOC parser `FUN_TOC_PARSER` (0x0051C9B0)
// does, so a directive the engine reads is never one we miss: a UTF-8 BOM at
// the start of the file is skipped; lines break at CR or LF; a line counts
// when it starts with "##", then any number of spaces, then `key`
// case-insensitively, IMMEDIATELY followed by ':' (the engine compares
// against literals like "SavedVariables:", which is also why
// "SavedVariables" never matches a "SavedVariablesPerCharacter:" line).
//
// Pure — reads no files; the caller owns the buffer (via FUN_FILE_READ, or an
// embedded copy). Shared by every addon module that reads a TOC directive so
// the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *key,
const char **valStart, size_t *valLen) {
const size_t dlen = std::strlen(directive);
if (dlen == 0)
const size_t klen = std::strlen(key);
if (klen == 0)
return false;
for (size_t i = 0; i + dlen <= size; ++i) {
const bool atLineStart = (i == 0) || buf[i - 1] == '\n';
if (!atLineStart || _strnicmp(buf + i, directive, dlen) != 0)
continue;
const char *p = buf + i + dlen;
const char *end = buf + size;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
while (p < end && *p != '\r' && *p != '\n')
++p;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
const char *end = buf + size;
const char *line = buf;
if (size >= 3 && static_cast<unsigned char>(buf[0]) == 0xEF &&
static_cast<unsigned char>(buf[1]) == 0xBB &&
static_cast<unsigned char>(buf[2]) == 0xBF)
line += 3;
while (line < end) {
const char *eol = line;
while (eol < end && *eol != '\r' && *eol != '\n')
++eol;
const char *p = line;
if (eol - p >= 2 && p[0] == '#' && p[1] == '#') {
p += 2;
while (p < eol && *p == ' ')
++p;
if (static_cast<size_t>(eol - p) > klen && EqCI(p, klen, key) &&
p[klen] == ':') {
p += klen + 1;
while (p < eol && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
p = eol;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
}
}
line = eol + 1;
}
return false;
}
+690
View File
@@ -0,0 +1,690 @@
// 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/>.
// API documentation: the record of every Lua registration plus its
// descriptor, exported on demand as Blizzard `APIDocumentation` tables.
//
// C_APIDocumentation.GetSystems() -> sorted array of system Names
// C_APIDocumentation.GetSystem(name) -> one System table (Blizzard shape), or nil
// _classicapi_UndocumentedAPI() -> array of registrations lacking a
// descriptor, and its count
//
// The registrars in Game.cpp call `Record*` (see Documentation.h). Recording
// runs once per Lua state — the first in-game pass and the first glue pass —
// because the set of registrations is static: every module registers the
// same names on every `/reload`. The recorded list is never materialized
// into Lua wholesale; `GetSystem` builds ONE system's table per call, which
// is how Blizzard's browser loads too (its generated data addon is
// LoadOnDemand). Eager export would create thousands of tables on every
// `/reload` for a feature used from a slash command.
//
// System grouping is derived, never authored, except for two strings:
// - `C_Spell.X` -> System "Spell", Namespace "C_Spell" (strip `C_`)
// - `table.insert` -> System "table", Namespace "table"
// - a global -> `doc->system` ("SpellGlobals"); a global whose
// descriptor names no system has no home and is
// listed by the coverage function
// - a frame method -> Blizzard's widget system for its registry
// ("SimpleTextureAPI", Type "ScriptObject")
// - an enum / structure /
// event -> the System named in its declaration
// A system's `Environment` is "All" when its functions were recorded in both
// Lua states, else "Game" or "Glue"; a function whose own environment is
// narrower than its system's gets a Documentation line saying so.
#include "api/Documentation.h"
#include "Game.h"
#include "Offsets.h"
#include "debug/Log.h"
#include "event/Custom.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace Api::Documentation {
using Game::Doc::Field;
using Game::Doc::FieldList;
using Game::Doc::Function;
using Game::Doc::Method;
using Game::Doc::Structure;
// NOT `using Game::Doc::Event` — that name would shadow the global `Event`
// namespace for the whole file, so `Event::Custom::…` would resolve to the
// type. Same trap as `Time::Tick` vs the `Tick` namespace (see CLAUDE.md).
using EventDoc = Game::Doc::Event;
// Head of the self-registering `Structure` list. Constant-initialized, so it
// is valid before any `Structure` constructor runs, whatever the cross-TU
// static-init order.
const Structure *g_structureHead = nullptr;
namespace {
enum Kind : uint8_t {
KIND_GLOBAL, // RegisterGlobalFunction / RegisterGlueFunction / RegisterGlobalAlias
KIND_TABLE, // RegisterTableFunction
KIND_FRAME_METHOD, // RegisterFrameMethods, one per entry
KIND_ENUM, // RegisterIntegerEnum
KIND_ORPHAN_METHOD_DOC // a Doc::Method naming no entry in its table
};
struct Entry {
Kind kind;
unsigned env; // Env bits the registration was recorded in
const char *table; // KIND_TABLE: the namespace; KIND_ENUM: the parent ("Enum")
const char *name; // registered name / method name / enum sub-name
const void *registry; // frame methods only
const Function *doc; // nullptr = undocumented
const char *enumSystem;
const Game::Lua::EnumIntegerEntry *enumEntries;
int enumCount;
};
std::vector<Entry> g_entries;
Env g_pass = ENV_NONE;
unsigned g_sealed = 0;
int g_recordedThisPass = 0;
constexpr const char *kDebugPrefix = "_classicapi_";
bool IsDebugName(const char *name) {
return std::strncmp(name, kDebugPrefix, std::strlen(kDebugPrefix)) == 0;
}
bool SameText(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return a == b;
return std::strcmp(a, b) == 0;
}
bool SameTextNoCase(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return a == b;
return _stricmp(a, b) == 0;
}
Entry *Find(Kind kind, const char *table, const char *name, const void *registry) {
for (Entry &e : g_entries) {
if (e.kind == kind && e.registry == registry && SameText(e.table, table) &&
SameText(e.name, name))
return &e;
}
return nullptr;
}
void Record(Kind kind, const char *table, const char *name, const void *registry,
const Function *doc) {
if (g_pass == ENV_NONE || name == nullptr)
return;
++g_recordedThisPass;
if (Entry *e = Find(kind, table, name, registry)) {
e->env |= g_pass;
if (e->doc == nullptr)
e->doc = doc;
return;
}
Entry e{};
e.kind = kind;
e.env = g_pass;
e.table = table;
e.name = name;
e.registry = registry;
e.doc = doc;
g_entries.push_back(e);
}
// ---- System resolution ------------------------------------------------------
// Blizzard's names for the widget-method systems, keyed by the engine
// registry each `FrameMethodEntry[]` is bound to.
struct WidgetSystem {
uintptr_t registry;
const char *system;
};
const WidgetSystem kWidgetSystems[] = {
{Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY, "GameTooltipAPI"},
{Offsets::VAR_REGION_METHOD_REGISTRY, "ScriptRegionAPI"},
{Offsets::VAR_FRAME_METHOD_REGISTRY, "SimpleFrameAPI"},
{Offsets::VAR_TEXTURE_METHOD_REGISTRY, "SimpleTextureAPI"},
{Offsets::VAR_FONTSTRING_METHOD_REGISTRY, "SimpleFontStringAPI"},
{Offsets::VAR_EDITBOX_METHOD_REGISTRY, "SimpleEditBoxAPI"},
{Offsets::VAR_MODEL_METHOD_REGISTRY, "SimpleModelAPI"},
};
const char *WidgetSystemFor(const void *registry) {
for (const WidgetSystem &w : kWidgetSystems)
if (reinterpret_cast<const void *>(w.registry) == registry)
return w.system;
return nullptr;
}
// "C_Spell" -> "Spell"; anything else is its own system name.
const char *SystemNameForTable(const char *table) {
if (table[0] == 'C' && table[1] == '_' && table[2] != '\0')
return table + 2;
return table;
}
// The System an entry contributes a function to, or nullptr when it has
// none (an undocumented global, a frame method on an unmapped registry).
const char *SystemOf(const Entry &e) {
switch (e.kind) {
case KIND_TABLE:
return SystemNameForTable(e.table);
case KIND_GLOBAL:
return e.doc != nullptr ? e.doc->system : nullptr;
case KIND_FRAME_METHOD:
return WidgetSystemFor(e.registry);
case KIND_ENUM:
return e.enumSystem;
default:
return nullptr;
}
}
struct SystemInfo {
const char *name;
const char *ns; // nullptr for global and widget systems
const char *type; // "System" or "ScriptObject"
unsigned env; // Env bits; 0 when nothing recorded (structures only)
};
// Merge one entry's contribution into the system list.
void AddSystem(std::vector<SystemInfo> &systems, const char *name, const char *ns,
const char *type, unsigned env) {
if (name == nullptr)
return;
for (SystemInfo &s : systems) {
if (SameTextNoCase(s.name, name)) {
if (s.ns == nullptr)
s.ns = ns;
if (type != nullptr)
s.type = type;
s.env |= env;
return;
}
}
systems.push_back(SystemInfo{name, ns, type != nullptr ? type : "System", env});
}
std::vector<SystemInfo> CollectSystems() {
std::vector<SystemInfo> systems;
for (const Entry &e : g_entries) {
switch (e.kind) {
case KIND_TABLE:
AddSystem(systems, SystemNameForTable(e.table), e.table, "System", e.env);
break;
case KIND_GLOBAL:
AddSystem(systems, SystemOf(e), nullptr, "System", e.env);
break;
case KIND_FRAME_METHOD:
AddSystem(systems, SystemOf(e), nullptr, "ScriptObject", e.env);
break;
case KIND_ENUM:
AddSystem(systems, e.enumSystem, nullptr, nullptr, 0);
break;
default:
break;
}
}
for (const Structure *s = g_structureHead; s != nullptr; s = s->next)
AddSystem(systems, s->system, nullptr, nullptr, 0);
const int events = Event::Custom::ReservedCount();
for (int i = 0; i < events; ++i) {
const EventDoc *doc = Event::Custom::ReservedDoc(i);
if (doc != nullptr)
AddSystem(systems, doc->system, nullptr, nullptr, 0);
}
return systems;
}
const SystemInfo *FindSystem(const std::vector<SystemInfo> &systems, const char *query) {
for (const SystemInfo &s : systems)
if (SameTextNoCase(s.name, query) || SameTextNoCase(s.ns, query))
return &s;
return nullptr;
}
const char *EnvironmentName(unsigned env) {
if (env == (ENV_GAME | ENV_GLUE) || env == 0)
return "All";
return env == ENV_GLUE ? "Glue" : "Game";
}
// ---- Export builders (each leaves exactly what its comment says) -------------
namespace Lua = Game::Lua;
// Sets `Documentation = { line1 [, line2 [, line3]] }` on the table at
// stack[-1]; no-op when every line is null.
void SetDocumentation(void *L, const char *l1, const char *l2 = nullptr,
const char *l3 = nullptr) {
if (l1 == nullptr && l2 == nullptr && l3 == nullptr)
return;
Lua::PushString(L, "Documentation");
Lua::NewTable(L);
int n = 0;
for (const char *line : {l1, l2, l3}) {
if (line == nullptr)
continue;
Lua::PushNumber(L, static_cast<double>(++n));
Lua::PushString(L, line);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// A Lua literal from a descriptor's `defaultValue`, pushed as the value it
// denotes: `true`/`false` -> boolean, a quoted string -> string, else number.
void PushDefault(void *L, const char *literal) {
if (std::strcmp(literal, "true") == 0) {
Lua::PushBool(L, true);
} else if (std::strcmp(literal, "false") == 0) {
Lua::PushBool(L, false);
} else if (literal[0] == '"') {
const size_t len = std::strlen(literal);
Lua::PushLString(L, literal + 1, static_cast<unsigned>(len >= 2 ? len - 2 : 0));
} else {
Lua::PushNumber(L, std::strtod(literal, nullptr));
}
}
// Pushes one field record: { Name, Type, Nilable [, Default] [, Documentation] }.
void PushField(void *L, const Field &f) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", f.name);
Lua::SetFieldString(L, "Type", f.type);
Lua::SetFieldBool(L, "Nilable", f.nilable);
if (f.defaultValue != nullptr) {
Lua::PushString(L, "Default");
PushDefault(L, f.defaultValue);
Lua::SetTable(L, -3);
}
SetDocumentation(L, f.doc);
}
// Sets `stack[-1][key] = { field records }`; no-op for an empty list.
void SetFieldArray(void *L, const char *key, FieldList list) {
if (list.count <= 0)
return;
Lua::PushString(L, key);
Lua::NewTable(L);
for (int i = 0; i < list.count; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
PushField(L, list.data[i]);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
constexpr const char *kExtensionNote = "ClassicAPI extension - not part of the Blizzard API.";
constexpr const char *kGlueOnlyNote = "Available only on the glue screen.";
constexpr const char *kGameOnlyNote = "Available only in-game.";
// An undocumented function lists with no arguments, which is indistinguishable
// from a function that genuinely takes none. Say which it is, so an empty
// signature is never read as a real one.
constexpr const char *kUndocumentedNote =
"Not documented yet. Its arguments and return values are not listed here.";
// Pushes one function record. An undocumented entry still lists by name so
// `/classicapi <System> list` shows the whole surface; its shape arrives with
// the descriptor.
void PushFunction(void *L, const Entry &e, unsigned systemEnv) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.name);
Lua::SetFieldString(L, "Type", "Function");
const char *envNote = nullptr;
if (e.env != systemEnv && e.env != 0)
envNote = (e.env == ENV_GLUE) ? kGlueOnlyNote : kGameOnlyNote;
if (e.doc == nullptr) {
SetDocumentation(L, kUndocumentedNote, envNote);
return;
}
SetDocumentation(L, e.doc->summary, e.doc->extension ? kExtensionNote : nullptr, envNote);
SetFieldArray(L, "Arguments", e.doc->args);
SetFieldArray(L, "Returns", e.doc->rets);
}
// Pushes { Name, Type = "Enumeration", NumValues, MinValue, MaxValue,
// Fields = { { Name, Type = <enum>, EnumValue } } }.
void PushEnumeration(void *L, const Entry &e) {
int minValue = 0, maxValue = 0;
for (int i = 0; i < e.enumCount; ++i) {
const int v = e.enumEntries[i].value;
if (i == 0 || v < minValue)
minValue = v;
if (i == 0 || v > maxValue)
maxValue = v;
}
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.name);
Lua::SetFieldString(L, "Type", "Enumeration");
Lua::SetFieldNumber(L, "NumValues", e.enumCount);
Lua::SetFieldNumber(L, "MinValue", minValue);
Lua::SetFieldNumber(L, "MaxValue", maxValue);
Lua::PushString(L, "Fields");
Lua::NewTable(L);
for (int i = 0; i < e.enumCount; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.enumEntries[i].key);
Lua::SetFieldString(L, "Type", e.name);
Lua::SetFieldNumber(L, "EnumValue", e.enumEntries[i].value);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// Pushes { Name, Type = "Structure" [, Documentation], Fields = { … } }.
void PushStructure(void *L, const Structure &s) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", s.name);
Lua::SetFieldString(L, "Type", "Structure");
SetDocumentation(L, s.summary);
SetFieldArray(L, "Fields", s.fields);
}
// "LOSS_OF_CONTROL_ADDED" -> "LossOfControlAdded" (Blizzard's event Name).
void PascalCase(const char *literal, char *out, size_t cap) {
size_t o = 0;
bool upper = true;
for (const char *p = literal; *p != '\0' && o + 1 < cap; ++p) {
if (*p == '_') {
upper = true;
continue;
}
const unsigned char c = static_cast<unsigned char>(*p);
out[o++] = static_cast<char>(upper ? std::toupper(c) : std::tolower(c));
upper = false;
}
out[o] = '\0';
}
// Pushes { Name, Type = "Event", LiteralName [, Documentation] [, Payload] }.
void PushEvent(void *L, const char *literal, const EventDoc &doc) {
char name[128];
PascalCase(literal, name, sizeof name);
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", name);
Lua::SetFieldString(L, "Type", "Event");
Lua::SetFieldString(L, "LiteralName", literal);
SetDocumentation(L, doc.summary);
SetFieldArray(L, "Payload", doc.payload);
}
// Pushes the whole System table for `sys`.
void PushSystem(void *L, const SystemInfo &sys) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", sys.name);
Lua::SetFieldString(L, "Type", sys.type);
if (sys.ns != nullptr)
Lua::SetFieldString(L, "Namespace", sys.ns);
Lua::SetFieldString(L, "Environment", EnvironmentName(sys.env));
Lua::PushString(L, "Functions");
Lua::NewTable(L);
int n = 0;
for (const Entry &e : g_entries) {
if (e.kind != KIND_GLOBAL && e.kind != KIND_TABLE && e.kind != KIND_FRAME_METHOD)
continue;
if (!SameTextNoCase(SystemOf(e), sys.name) || IsDebugName(e.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushFunction(L, e, sys.env);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
Lua::PushString(L, "Events");
Lua::NewTable(L);
n = 0;
const int events = Event::Custom::ReservedCount();
for (int i = 0; i < events; ++i) {
const EventDoc *doc = Event::Custom::ReservedDoc(i);
if (doc == nullptr || !SameTextNoCase(doc->system, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushEvent(L, Event::Custom::ReservedName(i), *doc);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
Lua::PushString(L, "Tables");
Lua::NewTable(L);
n = 0;
for (const Structure *s = g_structureHead; s != nullptr; s = s->next) {
if (!SameTextNoCase(s->system, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushStructure(L, *s);
Lua::SetTable(L, -3);
}
for (const Entry &e : g_entries) {
if (e.kind != KIND_ENUM || !SameTextNoCase(e.enumSystem, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushEnumeration(L, e);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// ---- Coverage ---------------------------------------------------------------
std::vector<std::string> Undocumented() {
std::vector<std::string> out;
const std::vector<SystemInfo> systems = CollectSystems();
for (const Entry &e : g_entries) {
if (IsDebugName(e.name))
continue;
switch (e.kind) {
case KIND_GLOBAL:
if (e.doc == nullptr) {
out.push_back(e.name);
} else if (e.doc->system == nullptr) {
out.push_back(std::string(e.name) + " (doc has no system)");
} else if (const SystemInfo *s = FindSystem(systems, e.doc->system);
s != nullptr && s->ns != nullptr) {
out.push_back(std::string(e.name) + " (system '" + e.doc->system +
"' is namespaced)");
}
break;
case KIND_TABLE:
if (e.doc == nullptr)
out.push_back(std::string(e.table) + "." + e.name);
break;
case KIND_FRAME_METHOD: {
const char *sys = WidgetSystemFor(e.registry);
if (sys == nullptr)
out.push_back(std::string(e.name) + " (method registry not mapped)");
else if (e.doc == nullptr)
out.push_back(std::string(sys) + ":" + e.name);
break;
}
case KIND_ORPHAN_METHOD_DOC: {
const char *sys = WidgetSystemFor(e.registry);
out.push_back(std::string(sys != nullptr ? sys : "?") + ":" + e.name +
" (doc for unregistered method)");
break;
}
case KIND_ENUM:
if (e.enumSystem == nullptr)
out.push_back(std::string(e.table) + "." + e.name);
break;
}
}
const int events = Event::Custom::ReservedCount();
for (int i = 0; i < events; ++i)
if (Event::Custom::ReservedDoc(i) == nullptr)
out.push_back(std::string("event ") + Event::Custom::ReservedName(i));
return out;
}
// ---- Lua surface ------------------------------------------------------------
int __fastcall Script_GetSystems(void *L) {
std::vector<SystemInfo> systems = CollectSystems();
std::sort(systems.begin(), systems.end(), [](const SystemInfo &a, const SystemInfo &b) {
return _stricmp(a.name, b.name) < 0;
});
Lua::NewTable(L);
for (size_t i = 0; i < systems.size(); ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::PushString(L, systems[i].name);
Lua::SetTable(L, -3);
}
return 1;
}
int __fastcall Script_GetSystem(void *L) {
if (!Lua::IsString(L, 1)) {
Lua::Error(L, "Usage: C_APIDocumentation.GetSystem(name)");
return 0;
}
const char *query = Lua::ToString(L, 1);
const std::vector<SystemInfo> systems = CollectSystems();
const SystemInfo *sys = FindSystem(systems, query);
if (sys == nullptr)
return 0; // nil
Lua::CheckStack(L, 32);
PushSystem(L, *sys);
return 1;
}
int __fastcall Script_UndocumentedAPI(void *L) {
const std::vector<std::string> names = Undocumented();
Lua::NewTable(L);
for (size_t i = 0; i < names.size(); ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::PushString(L, names[i].c_str());
Lua::SetTable(L, -3);
}
Lua::PushNumber(L, static_cast<double>(names.size()));
return 2;
}
const Field kGetSystemsRets[] = {
Game::Doc::Req("systems", "table", "Array of system Names, sorted without regard to case."),
};
const Function kGetSystems{
"Returns the name of every documented system.",
{}, kGetSystemsRets, nullptr, true};
const Field kGetSystemArgs[] = {
Game::Doc::Req("system", "string", "A system Name or Namespace; case does not matter."),
};
const Field kGetSystemRets[] = {
Game::Doc::Opt("system", "table", nullptr,
"The system in Blizzard's APIDocumentation table shape, or nil when unknown."),
};
const Function kGetSystem{
"Returns one system's documentation table.",
kGetSystemArgs, kGetSystemRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_APIDocumentation", "GetSystems", &Script_GetSystems,
&kGetSystems);
Game::Lua::RegisterTableFunction("C_APIDocumentation", "GetSystem", &Script_GetSystem,
&kGetSystem);
Game::Lua::RegisterGlobalFunction("_classicapi_UndocumentedAPI", &Script_UndocumentedAPI);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
// ---- Recording (called from Game.cpp) ----------------------------------------
void BeginPass(Env env) {
g_pass = (g_sealed & env) != 0 ? ENV_NONE : env;
g_recordedThisPass = 0;
}
void EndPass() {
if (g_pass == ENV_NONE)
return;
g_sealed |= g_pass;
const Env done = g_pass;
g_pass = ENV_NONE;
const std::vector<std::string> missing = Undocumented();
Debug::Log::Printf("[doc] %s pass: %d registrations recorded, %d undocumented",
done == ENV_GLUE ? "glue" : "game", g_recordedThisPass,
static_cast<int>(missing.size()));
}
void RecordGlobal(const char *name, const Function *doc) {
Record(KIND_GLOBAL, nullptr, name, nullptr, doc);
}
void RecordTable(const char *table, const char *name, const Function *doc) {
Record(KIND_TABLE, table, name, nullptr, doc);
}
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Method *docs, int docCount) {
if (g_pass == ENV_NONE)
return;
for (int i = 0; i < count; ++i) {
const Function *doc = nullptr;
for (int d = 0; d < docCount; ++d) {
if (std::strcmp(docs[d].name, table[i].name) == 0) {
doc = docs[d].doc;
break;
}
}
Record(KIND_FRAME_METHOD, nullptr, table[i].name, registry, doc);
}
for (int d = 0; d < docCount; ++d) {
bool matched = false;
for (int i = 0; i < count && !matched; ++i)
matched = std::strcmp(docs[d].name, table[i].name) == 0;
if (!matched)
Record(KIND_ORPHAN_METHOD_DOC, nullptr, docs[d].name, registry, docs[d].doc);
}
}
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
if (g_pass == ENV_NONE || sub == nullptr)
return;
++g_recordedThisPass;
if (Entry *e = Find(KIND_ENUM, parent, sub, nullptr)) {
e->env |= g_pass;
if (e->enumSystem == nullptr)
e->enumSystem = system;
return;
}
Entry e{};
e.kind = KIND_ENUM;
e.env = g_pass;
e.table = parent;
e.name = sub;
e.enumSystem = system;
e.enumEntries = entries;
e.enumCount = count;
g_entries.push_back(e);
}
} // namespace Api::Documentation
// The Structure constructor lives here so the list head is private to this
// TU; declared in Game.h beside the other descriptor types.
Game::Doc::Structure::Structure(const char *n, const char *s, FieldList f, const char *sum)
: name(n), system(s), fields(f), summary(sum), next(Api::Documentation::g_structureHead) {
Api::Documentation::g_structureHead = this;
}
+40
View File
@@ -0,0 +1,40 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Game.h"
// Recording side of the API documentation. The registrars in Game.cpp call
// these; `Documentation.cpp` keeps the list, resolves systems, and exports
// Blizzard-shaped `APIDocumentation` tables through `C_APIDocumentation`.
// The descriptor model is the `Game::Doc` block in Game.h.
namespace Api::Documentation {
enum Env : unsigned { ENV_NONE = 0, ENV_GAME = 1, ENV_GLUE = 2 };
// Bracket one `Run*Registrations()` pass. Recording happens only inside a
// pass, and only the FIRST pass per environment records — every later pass
// (each `/reload`, each logout→login) is a no-op. `EndPass` seals the
// environment and logs one summary line to the debug log.
void BeginPass(Env env);
void EndPass();
void RecordGlobal(const char *name, const Game::Doc::Function *doc);
void RecordTable(const char *table, const char *name, const Game::Doc::Function *doc);
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Game::Doc::Method *docs, int docCount);
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system);
} // namespace Api::Documentation
+2 -1
View File
@@ -23,7 +23,8 @@
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
// server encodes it — see `Aura::Data::IsSlotHarmful`), `PLAYER` /
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
// `!PLAYER` (caster is / isn't the local player or their pet, from the
// Aura::Source cache),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
// Poison, matching the server's DISPEL_ALL_MASK), and `CROWD_CONTROL` /
+26 -8
View File
@@ -361,16 +361,36 @@ int SlotInFilterOrder(Filter filter, int i) {
return i;
}
// The caster set the `PLAYER` filter selects: the player AND the player's
// pet. Modern defines the token as "auras that were cast by the player, or
// by the player's pet or vehicle" (`AuraUtil.AuraFilters.Player`) — there
// are no vehicles here, so the pair is player + pet. A pet's cast carries
// the PET's guid in the Aura::Source cache (that is who SMSG_SPELL_GO
// names), so a plain player compare drops e.g. a Hunter's own Serpent
// Sting off a `HARMFUL|PLAYER` query. The pet guid is a live engine global,
// so this stays two compares with no object resolve — it runs per slot.
// A caster of 0 is a cache miss, never a match (see CasterMode).
bool IsPlayerOrPetCaster(uint64_t casterGuid) {
if (casterGuid == 0)
return false;
if (casterGuid == Unit::Identity::PlayerGuid())
return true;
const uint64_t pet =
Game::Read<uint64_t>(static_cast<uintptr_t>(Offsets::VAR_PET_GUID));
return pet != 0 && casterGuid == pet;
}
bool IsPlayerCast(const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
if (spellID == 0)
return false;
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 && Attribute(UnitGuid(unit), spellID, slot).caster == player;
return IsPlayerOrPetCaster(
Attribute(UnitGuid(unit), spellID, slot).caster);
}
// Applies the PLAYER / !PLAYER caster restriction. `isPlayerCast` is the
// per-aura "was this cast by the local player" answer (false on a cache miss).
// per-aura "was this cast by the player or their pet" answer (false on a
// cache miss).
bool CasterMatches(CasterMode caster, bool isPlayerCast) {
switch (caster) {
case CasterMode::PlayerOnly: return isPlayerCast;
@@ -444,9 +464,8 @@ bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
// A miss counts as "not the player" (same as IsPlayerCast).
bool GroupIsPlayerCast(uint64_t guid, uint32_t spellID) {
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 &&
Attribute(guid, spellID, Aura::Source::SLOT_UNBOUND).caster == player;
return IsPlayerOrPetCaster(
Attribute(guid, spellID, Aura::Source::SLOT_UNBOUND).caster);
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
@@ -847,8 +866,7 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
// Applies `match` to a cache entry. The entry carries its caster, so the caster
// test is a plain GUID compare — no scan, nothing to defer.
bool CachedMatches(const Aura::Source::CachedAura &c, const Match &match) {
return MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(),
c.spellId);
return MatchesAura(match, IsPlayerOrPetCaster(c.casterGuid), c.spellId);
}
// The eligible cache-fallback entries for `unit` under `filter`, in `Enumerate`
+14 -10
View File
@@ -63,11 +63,13 @@ enum class Filter { Helpful, Harmful };
enum class Emit { Table, Positional };
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast
// (`PLAYER`); `NotPlayer` = only auras NOT cast by the local player
// (`!PLAYER`). Caster attribution comes from the `Aura::Source` cache; a
// cache miss counts as "not the player" (so `PlayerOnly` excludes it and
// `NotPlayer` includes it — matching `IsPlayerCast`'s miss semantics).
// `Any` = no restriction; `PlayerOnly` = only auras the local player OR the
// player's pet cast (`PLAYER`); `NotPlayer` = the complement (`!PLAYER`).
// The pet is part of the set by definition — modern documents the token as
// "cast by the player, or by the player's pet or vehicle". Caster
// attribution comes from the `Aura::Source` cache; a cache miss counts as
// "not the player" (so `PlayerOnly` excludes it and `NotPlayer` includes
// it — matching `IsPlayerCast`'s miss semantics).
enum class CasterMode { Any, PlayerOnly, NotPlayer };
// Restriction from the `DISPELLABLE` / `!DISPELLABLE` aura filter tokens.
@@ -148,13 +150,15 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, Match match = {});
// True iff the aura at `slot` was cast by the local player, per the
// `Aura::Source` cache. False on a cache miss (caster unknown) — so a
// PLAYER-filtered query excludes auras whose cast we didn't observe.
// True iff the aura at `slot` was cast by the local player or by the
// player's pet, per the `Aura::Source` cache. False on a cache miss (caster
// unknown) — so a PLAYER-filtered query excludes auras whose cast we didn't
// observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// Applies a `CasterMode` to a per-aura "was cast by the local player" answer.
// `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its negation.
// Applies a `CasterMode` to a per-aura "was cast by the player or their pet"
// answer. `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its
// negation.
bool CasterMatches(CasterMode caster, bool isPlayerCast);
// True iff the spell's dispel type is one a dispel/purge/steal can remove —
+136 -20
View File
@@ -28,6 +28,7 @@
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "totem/Tracker.h"
#include "unit/TokenEvents.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -638,11 +639,9 @@ void SignalAuraChanged(uint64_t guid) {
}
void FlushAuraSignals() {
using Broadcast_t = void(__fastcall *)(uint64_t *guid, uint32_t eventCode);
auto broadcast = reinterpret_cast<Broadcast_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_EVENT_BROADCAST));
// The engine broadcast plus ClassicAPI's tokens (see Unit::TokenEvents).
for (int i = 0; i < g_pendingSignalCount; ++i)
broadcast(&g_pendingSignals[i], Offsets::UNIT_EVENT_UNIT_AURA);
Unit::TokenEvents::Broadcast(&g_pendingSignals[i], Offsets::UNIT_EVENT_UNIT_AURA);
g_pendingSignalCount = 0;
}
@@ -1406,6 +1405,114 @@ int32_t ConsumeTriggeredApplication(uint64_t victim, const uint8_t *rec,
// keeps the parse short and robust.
constexpr int kMaxTargets = 16;
// ---- Triggered auras (SPELL_EFFECT_TRIGGER_SPELL) -------------------------
//
// An effect of type TRIGGER_SPELL makes the server cast EffectTriggerSpell[i]
// as a side effect of the cast — Pounce 9005 → Pounce Bleed 9007 (Spell.dbc:
// Effect = {6, 64, 80}, EffectTriggerSpell = {0, 9007, 0}). When the
// triggered spell's own SMSG_SPELL_GO never reaches the client, its aura is
// seated caster-less by OnAuraAdded. The trigger's SPELL_GO names the caster,
// so attribution goes through it, read off the DBC link rather than a table.
//
// A hit by the TRIGGER doesn't prove the triggered aura landed: a target
// immune to bleed takes Pounce's stun but not its bleed. So a fresh
// application is ARMED, and only the application hook — the descriptor proof
// that it landed — consumes the arm (same shape as the judgement arm above).
// An instance this caster already holds on the target is refreshed in place
// as well: the server's re-apply reuses the descriptor slot, so no
// OnAuraAdded fires (the remove+add RefreshTriggeredExisting mirrors).
//
// Any caster: nothing here needs the caster's talents, and a remote caster
// gets the base duration exactly as their directly cast auras do.
struct TrigAuraArm {
uint64_t victim; // 0 = empty
uint64_t caster;
uint32_t spellId; // the triggered aura
uint32_t untilMs;
};
constexpr int kTrigAuraArmMax = 8;
constexpr uint32_t kTrigAuraArmTtlMs = 2000;
TrigAuraArm g_trigAuraArms[kTrigAuraArmMax];
void ArmTriggeredAura(uint64_t victim, uint32_t spellId, uint64_t caster,
uint32_t now) {
TrigAuraArm *slot = nullptr;
for (auto &a : g_trigAuraArms) {
if (a.victim == victim && a.spellId == spellId && a.caster == caster) {
slot = &a; // re-trigger inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_trigAuraArms[0]; // all live — steal the first
*slot = {victim, caster, spellId, now + kTrigAuraArmTtlMs};
}
// The caster armed for `spellId` landing on `victim`, or 0. One-shot.
uint64_t ConsumeTriggeredAura(uint64_t victim, uint32_t spellId, uint32_t now) {
if (victim == 0)
return 0;
for (auto &a : g_trigAuraArms) {
if (a.victim != victim || a.spellId != spellId ||
Time::Clock::Reached(now, a.untilMs))
continue;
const uint64_t caster = a.caster;
a = {};
return caster;
}
return 0;
}
// Duration of an aura cast by `caster`. Combo-point finishers (Rupture,
// Kidney Shot, …) have their real duration computed at cast time from the
// combo points spent — the base-duration helper can't know it.
// ComboDuration mirrors the server's computation from the CP snapshot its
// send hook captured; 0 means "not combo-scaled", fall through to the base
// path. 0 overall = unknown.
uint32_t CastDurationMs(const uint8_t *rec, uint32_t spellId,
bool casterIsPlayer) {
const uint32_t comboMs =
casterIsPlayer ? ComboDuration::TryComboScaledMs(rec, spellId) : 0;
return comboMs != 0 ? comboMs : SpellDurationMs(rec, casterIsPlayer);
}
void ApplyTriggeredAuras(uint64_t caster, uint32_t spellId, const uint8_t *rec,
const uint64_t *targets, int numTargets) {
if (rec == nullptr)
return;
const auto *effects = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT);
const auto *triggered = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_TRIGGER_SPELL);
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
const uint32_t now = NowMs();
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (effects[i] != Offsets::SPELL_EFFECT_TRIGGER_SPELL ||
triggered[i] <= 0)
continue;
const auto auraId = static_cast<uint32_t>(triggered[i]);
if (auraId == spellId)
continue;
const uint8_t *auraRec = Spell::Lookup::RecordForID(triggered[i]);
if (!SpellAppliesAura(auraRec))
continue;
const uint32_t durationMs =
CastDurationMs(auraRec, auraId, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? now + durationMs : 0;
for (int t = 0; t < numTargets; ++t) {
ArmTriggeredAura(targets[t], auraId, caster, now);
if (FindByCaster(targets[t], auraId, caster) != nullptr &&
DescriptorListsAura(targets[t], auraId))
StoreFromCast(targets[t], auraId, caster, expirationMs,
durationMs);
}
}
}
// The self-contained SPELL_GO processing (succeeded events, server-side
// duration edits, aura-source caching). None of it reads engine descriptor
// state, so it's order-independent of the engine's own SPELL_GO handler —
@@ -1455,19 +1562,23 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
for (int i = 0; i < numTargets; ++i)
ArmTriggeredApplications(targets[i], spellId, rec);
// The units this cast's auras land on: the hit list, or the caster itself
// when the packet carries none (self-cast with a caster-implicit target).
// Shared by the triggered auras and the cast's own aura below.
const uint64_t *auraTargets = targets;
int numAuraTargets = numTargets;
if (numTargets == 0) {
auraTargets = &caster;
numAuraTargets = 1;
}
ApplyTriggeredAuras(caster, spellId, rec, auraTargets, numAuraTargets);
if (!SpellAppliesAura(rec))
return;
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
// Combo-point finishers (Rupture, Kidney Shot, …) have their real
// duration computed at cast time from the combo points spent — the
// base-duration helper can't know it. ComboDuration mirrors the
// server's computation from the CP snapshot its send hook captured;
// 0 means "not combo-scaled", fall through to the base path.
uint32_t durationMs =
casterIsPlayer ? ComboDuration::TryComboScaledMs(rec, spellId) : 0;
if (durationMs == 0)
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t durationMs = CastDurationMs(rec, spellId, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
// Let the application hooks recover the player-talented duration for this
@@ -1476,13 +1587,8 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
if (casterIsPlayer)
RememberPlayerCast(spellId);
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
StoreFromCast(caster, spellId, caster, expirationMs, durationMs);
return;
}
for (int i = 0; i < numTargets; ++i)
StoreFromCast(targets[i], spellId, caster, expirationMs, durationMs);
for (int i = 0; i < numAuraTargets; ++i)
StoreFromCast(auraTargets[i], spellId, caster, expirationMs, durationMs);
}
// SMSG_SPELL_GO parse (funnel subscriber). At the leaf handler the engine has
@@ -1565,6 +1671,16 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
durationMs = 1;
}
}
// A triggered-aura arm (Pounce → Pounce Bleed): the server cast this aura
// off a trigger whose SPELL_GO named the caster, and this application is
// the proof it landed. Target-scoped, so it goes before the spell-scoped
// recent-cast window below, for the same reason as the nettle arm.
if (caster == 0) {
caster = ConsumeTriggeredAura(unitGuid, spellId, NowMs());
if (caster != 0)
durationMs =
SpellDurationMs(rec, caster == Unit::Identity::PlayerGuid());
}
if (caster == 0) {
const bool byPlayer = WasRecentPlayerCast(spellId);
durationMs = SpellDurationMs(rec, byPlayer);
+16 -8
View File
@@ -389,15 +389,23 @@ void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
// The bare `gsub` global (1.12 exposes the string functions as globals
// too) must match `string.gsub`, so addons calling either form get the
// table-replacement upgrade.
Game::Lua::RegisterGlobalFunction("gsub", &Script_string_gsub);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strrev =
// str.reverse`, `str.trim = strtrim`, `str.split = strsplit`, `str.join =
// strjoin`), so `strtrim == string.trim`. A second Register* call would
// mint a second closure over the same C function, so the globals alias
// the table entries by value instead. The string-table half is what lets
// `(" x "):trim()` work and libraries capture it at file scope
// (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub` gets the
// table-replacement upgrade by being the same function.
Game::Lua::RegisterGlobalAlias("gsub", "string", "gsub");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");
Game::Lua::RegisterGlobalAlias("strtrim", "string", "trim");
Game::Lua::RegisterGlobalAlias("strrev", "string", "reverse");
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
+217 -499
View File
@@ -12,391 +12,189 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Backports the direct-action and override binding APIs introduced in 2.0.
// Normal bindings are stored by vanilla's SetBinding implementation using the
// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
// hook executes those four command families; every native binding command is
// passed through unchanged.
// The binding API 4.3.4 has and 1.12 lacks (decomp Ui/UIBindings.cpp,
// UIBindings_Scripts.cpp):
//
// Override bindings live in a separate owner-scoped layer in front of the
// native key lookup. They are deliberately never written to the saved binding
// table, so clearing an owner immediately reveals the next override or the
// unchanged native binding underneath it.
// SetBindingSpell / Item / Macro / Click — "SPELL x" etc. into the
// current set through the engine's own SetBinding.
// SetOverrideBinding[Spell / Item / Macro / Click], ClearOverrideBindings
// — owner-scoped bindings in front of the saved ones, never saved.
// GetBindingAction(key [, checkOverride]) — sees overrides when asked.
//
// Overrides — 4.3.4's OVERRIDEKEYBINDING: per key (case-insensitive), two
// lists (normal, priority) of {owner, command}; setting an owner's binding
// unlinks its old one from both and links the new one at the head of its
// priority's list, so the newest priority binding wins, then the newest
// normal one. 4.3.4 consults them inside its key lookup, before the saved
// binding, for every key it tries. 1.12's key dispatch (FUN_BINDING_KEY_DISPATCH)
// tries two keys — the pressed one, then the pressed one minus its first
// modifier — so the co-hook tries an override, then the saved binding
// (FUN_BINDINGS_GET_COMMAND_ACTION), for each in turn, and lets the engine
// run a saved binding it finds.
//
// Around whatever runs, the key-state override (Input::Modifier) hides the
// matched key's own modifiers, as 4.3.4's ExecKey does. Direct actions
// (Bindings::Command) run only for a key: the command co-hook acts while the
// manager's executing flag — set by the key dispatch, not by RunBinding — is
// up, which is 4.3.4's ExecKey / ExecCommand split.
#include "Game.h"
#include "Offsets.h"
#include "macro/Execute.h"
#include "bindings/Command.h"
#include "input/Modifier.h"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <list>
#include <string>
#include <unordered_map>
#include <vector>
namespace Bindings::Api {
namespace {
constexpr const char kSpellPrefix[] = "SPELL ";
constexpr const char kItemPrefix[] = "ITEM ";
constexpr const char kMacroPrefix[] = "MACRO ";
constexpr const char kClickPrefix[] = "CLICK ";
constexpr const char kDefaultMouseButton[] = "LeftButton";
using KeyDispatch_t = int(__fastcall *)(void *mgr, void *edx, const char *key, int isDown);
using CommandExecute_t = int(__fastcall *)(void *mgr, void *edx, const char *cmd, int isDown);
using GetCommandAction_t = const char *(__thiscall *)(void *mgr, const char *key);
using ScriptFn_t = int(__fastcall *)(void *L);
using ToBool_t = int(__fastcall *)(void *L, int idx, int dflt);
using ScriptSetBinding_t = int(__fastcall *)(void *L);
using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
KeyDispatch_t g_keyDispatchOriginal = nullptr;
CommandExecute_t g_commandExecuteOriginal = nullptr;
KeyDispatch_t KeyDispatch_o = nullptr;
CommandExecute_t CommandExecute_o = nullptr;
// --- override table (OVERRIDEKEYBINDING) ------------------------------------
enum class CommandType {
None,
Spell,
Item,
Macro,
Click,
};
struct ParsedCommand {
CommandType type = CommandType::None;
const char *argument = nullptr;
};
struct OverrideEntry {
const void *owner = nullptr;
std::string key;
struct Entry {
const void *owner;
std::string command;
bool priority = false;
uint64_t sequence = 0;
};
std::vector<OverrideEntry> g_overrides;
std::unordered_map<std::string, std::string> g_activeOverrides;
uint64_t g_nextSequence = 0;
struct OverrideKey {
std::string key;
std::list<Entry> lists[2]; // [0] normal, [1] priority; head = newest
};
// Normalizes a binding key to the engine's own canonical form so a stored
// override matches the string FUN_BINDING_KEY_DISPATCH builds at keypress. The
// engine's key-string builder (FUN_004b6630) always emits modifiers in the
// fixed order ALT-CTRL-SHIFT- ahead of the base key, regardless of the order
// the caller passed them; vanilla SetBinding never reorders, so the override
// layer does it here. Both the store and the lookup run through this, so they
// stay consistent while also matching the engine's keypress form.
std::string CanonicalKey(const char *key) {
std::string result = key ? key : "";
for (char &character : result) {
if (character >= 'a' && character <= 'z')
character = static_cast<char>(character - 'a' + 'A');
std::vector<OverrideKey> g_overrides;
OverrideKey *FindKey(const char *key) {
for (OverrideKey &entry : g_overrides)
if (_stricmp(entry.key.c_str(), key) == 0)
return &entry;
return nullptr;
}
// OVERRIDEKEYBINDING::SetOverrideBinding.
void SetOverride(OverrideKey &binding, const void *owner, bool priority, const char *command) {
for (auto &list : binding.lists)
list.remove_if([owner](const Entry &entry) { return entry.owner == owner; });
if (command != nullptr && *command != '\0')
binding.lists[priority ? 1 : 0].push_front(Entry{owner, command});
}
// OVERRIDEKEYBINDING::GetActiveBinding.
const char *ActiveOverride(const char *key) {
const OverrideKey *binding = FindKey(key);
if (binding == nullptr)
return nullptr;
for (int i = 1; i >= 0; --i)
if (!binding->lists[i].empty())
return binding->lists[i].front().command.c_str();
return nullptr;
}
// CGUIBindings::SetOverrideBinding.
void SetOverrideBinding(const void *owner, bool priority, const char *key, const char *command) {
OverrideKey *binding = FindKey(key);
if (binding == nullptr) {
g_overrides.push_back(OverrideKey{key, {}});
binding = &g_overrides.back();
}
// Strip the three modifier prefixes in whatever order/repetition they
// appear, then re-emit them in the engine's fixed order. Prefix-stripping
// (rather than splitting on '-') keeps the minus base key correct, e.g.
// "SHIFT--" -> modifier SHIFT + base "-".
bool alt = false, ctrl = false, shift = false;
size_t base = 0;
for (bool matched = true; matched;) {
matched = false;
if (result.compare(base, 4, "ALT-") == 0) {
alt = true;
base += 4;
matched = true;
} else if (result.compare(base, 5, "CTRL-") == 0) {
ctrl = true;
base += 5;
matched = true;
} else if (result.compare(base, 6, "SHIFT-") == 0) {
shift = true;
base += 6;
matched = true;
}
}
if (!alt && !ctrl && !shift)
return result;
std::string canonical;
if (alt)
canonical += "ALT-";
if (ctrl)
canonical += "CTRL-";
if (shift)
canonical += "SHIFT-";
canonical += result.substr(base);
return canonical;
SetOverride(*binding, owner, priority, command);
}
std::string MakeCommand(const char *prefix, const char *arg) {
std::string cmd = prefix;
cmd += arg;
return cmd;
// --- key dispatch -------------------------------------------------------------
void *Manager() { return Game::Read<void *>(Offsets::VAR_BINDINGS_MANAGER); }
const char *SavedCommand(void *mgr, const char *key) {
return reinterpret_cast<GetCommandAction_t>(Offsets::FUN_BINDINGS_GET_COMMAND_ACTION)(mgr,
key);
}
std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
std::string cmd = MakeCommand(kClickPrefix, btnName);
if (mouseBtn != nullptr) {
cmd += ':';
cmd += mouseBtn;
}
return cmd;
int ExecuteCommand(void *mgr, const char *command, int isDown) {
if (Command::IsDirectAction(command))
return Command::RunKey(command, isDown);
return g_commandExecuteOriginal(mgr, nullptr, command, isDown);
}
ParsedCommand ParseCommand(const char *cmd) {
if (cmd == nullptr)
return {};
struct PrefixEntry {
const char *prefix;
size_t length;
CommandType type;
};
static constexpr PrefixEntry prefixes[] = {
{kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
{kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
{kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
{kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
};
for (const PrefixEntry &entry : prefixes) {
if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
return {entry.type, cmd + entry.length};
}
return {};
}
bool CallGlobalString(void *L, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, func)) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, table);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return false;
}
const int tableIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, func);
Game::Lua::GetTable(L, tableIndex);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLString(L, btnName, static_cast<unsigned int>(btnNameLength));
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return;
}
const int frameIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "Click");
Game::Lua::GetTable(L, frameIndex);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, frameIndex);
Game::Lua::PushString(L, mouseBtn);
Game::Lua::PCall(L, 2, 0, 0);
}
Game::Lua::SetTop(L, top);
}
void ExecuteClick(void *L, const char *arg) {
const char *separator = std::strrchr(arg, ':');
if (separator == arg || (separator != nullptr && separator[1] == '\0'))
return;
const char *mouseBtn = kDefaultMouseButton;
size_t btnNameLength = std::strlen(arg);
if (separator != nullptr) {
btnNameLength = static_cast<size_t>(separator - arg);
mouseBtn = separator + 1;
}
if (btnNameLength == 0)
return;
InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
}
// Returns true for every recognized direct-action command, including malformed
// or stale commands. Vanilla cannot execute these namespaces, so a bad command
// is consumed as a no-op instead of falling through to an unrelated handler.
bool ExecuteSpecialCommand(const char *cmd, int isDown) {
const ParsedCommand parsed = ParseCommand(cmd);
if (parsed.type == CommandType::None)
return false;
if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
return true;
void *L = Game::Lua::State();
if (L == nullptr)
return true;
switch (parsed.type) {
case CommandType::Spell:
CallGlobalString(L, "CastSpellByName", parsed.argument);
break;
case CommandType::Item:
CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
break;
case CommandType::Macro:
Macro::Execute::Saved(L, parsed.argument);
break;
case CommandType::Click:
ExecuteClick(L, parsed.argument);
break;
default:
break;
}
return true;
}
const OverrideEntry *FindEffectiveOverride(const std::string &key) {
const OverrideEntry *best = nullptr;
for (const OverrideEntry &entry : g_overrides) {
if (entry.key != key)
continue;
if (best == nullptr || entry.priority > best->priority ||
(entry.priority == best->priority && entry.sequence > best->sequence))
best = &entry;
}
return best;
}
void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
auto existing =
std::find_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner && entry.key == key;
});
if (existing == g_overrides.end()) {
g_overrides.push_back(
{owner, key, cmd, priority, ++g_nextSequence});
return;
}
existing->command = cmd;
existing->priority = priority;
existing->sequence = ++g_nextSequence;
}
void RemoveOverride(const void *owner, const std::string &key) {
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner &&
entry.key == key;
}),
g_overrides.end());
}
int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
if (ExecuteSpecialCommand(cmd, isDown))
return 1;
return CommandExecute_o(mgr, cmd, isDown);
}
int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
return ExecuteCommand(mgr, cmd, isDown);
}
// Runs an override-resolved command with the same execution context the engine
// wraps around a native keypress. FUN_BINDING_KEY_DISPATCH saves the frame-
// script exec-context, zeroes it for the nested command, marks the manager as
// mid-command (`+0xD8`), then restores the context and floors the nesting
// depth. Native-table bindings already run inside this (the engine sets it up
// before calling FUN_BINDING_COMMAND_EXECUTE, which we co-hook); overrides
// bypass that path, so we mirror it here. Without it, a native Bindings.xml
// command dispatched through an override would run without the context its
// handler expects. Special SPELL/ITEM/MACRO/CLICK commands run harmlessly
// inside the same wrapper.
int DispatchOverrideCommand(void *mgr, const char *cmd, int isDown) {
volatile uint32_t *context =
reinterpret_cast<volatile uint32_t *>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
volatile uint32_t *depth = reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
volatile uint32_t *executing =
Game::Ptr<volatile uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
const uint32_t savedContext = *context;
*depth = *depth + 1;
if (*depth != 0)
*context = 0;
*executing = 1;
const int result = ExecuteCommand(mgr, cmd, isDown);
*executing = 0;
if (*depth != 0)
*context = savedContext;
if (static_cast<int32_t>(*depth) - 1 > 0)
*depth = *depth - 1;
else
*depth = 0;
// An override's command, run in the frame the engine dispatch gives a saved
// one: exec context saved and cleared, depth bumped, the manager's executing
// flag up (FUN_BINDING_KEY_DISPATCH, see Offsets.h).
int RunOverride(void *mgr, const char *command, int isDown) {
const std::string copy = command; // the command may clear its own override
auto &context = Game::Ref<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
auto &depth = Game::Ref<int32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
auto &executing = Game::Ref<uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
const uint32_t saved = context;
if (++depth != 0)
context = 0;
executing = 1;
const int result = ExecuteCommand(mgr, copy.c_str(), isDown);
executing = 0;
if (depth != 0)
context = saved;
depth = depth - 1 > 0 ? depth - 1 : 0;
return result;
}
int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
const std::string canonicalKey = CanonicalKey(key);
int __fastcall KeyDispatch_h(void *mgr, void *edx, const char *key, int isDown) {
if (key == nullptr || *key == '\0')
return g_keyDispatchOriginal(mgr, edx, key, isDown);
// Keep a key-down paired with its override command even if that override is
// cleared while the command executes. The newly revealed native binding
// must not receive only the key-up half of the same physical press.
if (!isDown) {
auto active = g_activeOverrides.find(canonicalKey);
if (active != g_activeOverrides.end()) {
DispatchOverrideCommand(mgr, active->second.c_str(), 0);
g_activeOverrides.erase(active);
return 1;
// The engine's two keys: as pressed, then without its first modifier.
const char *dash = std::strchr(key, '-');
const char *candidates[2] = {key, (dash != nullptr && dash[1] != '\0') ? dash + 1 : nullptr};
for (const char *candidate : candidates) {
if (candidate == nullptr)
break;
if (const char *command = ActiveOverride(candidate)) {
const Input::Modifier::KeyStateOverride keyState(candidate);
return RunOverride(mgr, command, isDown);
}
if (SavedCommand(mgr, candidate) != nullptr) {
const Input::Modifier::KeyStateOverride keyState(candidate);
return g_keyDispatchOriginal(mgr, edx, key, isDown);
}
}
return g_keyDispatchOriginal(mgr, edx, key, isDown);
}
const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
if (entry == nullptr)
return KeyDispatch_o(mgr, key, isDown);
// Direct actions only for a key (executing flag up); RunBinding reaches the
// engine's command table, which has none of them — as in 4.3.4.
int __fastcall CommandExecute_h(void *mgr, void *edx, const char *command, int isDown) {
if (Game::Read<uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING) != 0 &&
Command::IsDirectAction(command))
return Command::RunKey(command, isDown);
return g_commandExecuteOriginal(mgr, edx, command, isDown);
}
if (isDown)
g_activeOverrides[canonicalKey] = entry->command;
// --- Lua ----------------------------------------------------------------------
return DispatchOverrideCommand(mgr, entry->command.c_str(), isDown);
bool LooseBool(void *L, int idx) {
return reinterpret_cast<ToBool_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE)(L, idx, 0) != 0;
}
const char *OptString(void *L, int idx) {
return Game::Lua::IsString(L, idx) ? Game::Lua::ToString(L, idx) : nullptr;
}
// SetBinding(key, command) through the engine's own Script_SetBinding: its
// key normalization, current-set write, UPDATE_BINDINGS and 1/nil result.
int SetBinding(void *L, const std::string &key, const std::string &command) {
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
return reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_SET_BINDING)(L);
}
int SetBindingCommand(void *L, const char *prefix, const char *usage) {
@@ -404,216 +202,136 @@ int SetBindingCommand(void *L, const char *prefix, const char *usage) {
Game::Lua::Error(L, usage);
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
// Preserve the stock SetBinding return value, binding-set selection,
// key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
}
const char *OptionalMouseButton(void *L, int index, const char *usage) {
if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
return nullptr;
if (!Game::Lua::IsString(L, index)) {
Game::Lua::Error(L, usage);
return nullptr;
}
return Game::Lua::ToString(L, index);
return SetBinding(L, Game::Lua::ToString(L, 1),
std::string(prefix) + Game::Lua::ToString(L, 2));
}
int __fastcall Script_SetBindingSpell(void *L) {
return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
return SetBindingCommand(L, "SPELL ", "Usage: SetBindingSpell(\"KEY\", \"spellname\")");
}
int __fastcall Script_SetBindingItem(void *L) {
return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
return SetBindingCommand(L, "ITEM ", "Usage: SetBindingItem(\"KEY\", \"itemname\")");
}
int __fastcall Script_SetBindingMacro(void *L) {
return SetBindingCommand(L, "MACRO ",
"Usage: SetBindingMacro(\"KEY\", \"macroname\"|macroid)");
}
int __fastcall Script_SetBindingMacro(void *L) {
return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
std::string ClickCommand(const char *button, const char *mouseButton) {
return std::string("CLICK ") + button + ":" +
(mouseButton != nullptr ? mouseButton : "LeftButton");
}
int __fastcall Script_SetBindingClick(void *L) {
constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, usage);
Game::Lua::Error(
L, "Usage: SetBindingClick(\"KEY\", \"buttonName\"[, \"mouseButton\"])");
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const char *buttonName = Game::Lua::ToString(L, 2);
const char *mouseButton = OptionalMouseButton(L, 3, usage);
const std::string command = MakeClickCommand(buttonName, mouseButton);
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
return SetBinding(L, Game::Lua::ToString(L, 1),
ClickCommand(Game::Lua::ToString(L, 2), OptString(L, 3)));
}
bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
bool *priority, std::string *key) {
*owner = Game::Lua::ResolveObject(L, 1);
if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
!Game::Lua::IsString(L, 3)) {
Game::Lua::Error(L, usage);
return false;
}
// The owner argument: a frame, as 4.3.4 requires.
const void *Owner(void *L) { return Game::Lua::ResolveFrame(L, 1, /*raiseError*/ false); }
*priority = Game::Lua::ToBoolean(L, 2) != 0;
*key = CanonicalKey(Game::Lua::ToString(L, 3));
return true;
}
int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
int __fastcall Script_SetOverrideBinding(void *L) {
const void *owner = Owner(L);
if (owner == nullptr || !Game::Lua::IsString(L, 3)) {
Game::Lua::Error(L, "Usage: SetOverrideBinding(owner, isPriority, \"KEY\"[, \"COMMAND\"])");
return 0;
}
SetOverride(owner, priority, key,
MakeCommand(prefix, Game::Lua::ToString(L, 4)));
SetOverrideBinding(owner, LooseBool(L, 2), Game::Lua::ToString(L, 3), OptString(L, 4));
return 0;
}
int __fastcall Script_SetOverrideBinding(void *L) {
constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (Game::Lua::GetTop(L) < 4 ||
Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
RemoveOverride(owner, key);
return 0;
}
if (!Game::Lua::IsString(L, 4)) {
int SetOverrideCommand(void *L, const char *prefix, const char *usage) {
const void *owner = Owner(L);
if (owner == nullptr || !Game::Lua::IsString(L, 3) || !Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
const std::string command = std::string(prefix) + Game::Lua::ToString(L, 4);
SetOverrideBinding(owner, LooseBool(L, 2), Game::Lua::ToString(L, 3), command.c_str());
return 0;
}
int __fastcall Script_SetOverrideBindingSpell(void *L) {
return SetOverrideBindingCommand(
L, kSpellPrefix,
"Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
return SetOverrideCommand(
L, "SPELL ", "Usage: SetOverrideBindingSpell(owner, isPriority, \"KEY\", \"spellname\")");
}
int __fastcall Script_SetOverrideBindingItem(void *L) {
return SetOverrideBindingCommand(
L, kItemPrefix,
"Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
return SetOverrideCommand(
L, "ITEM ", "Usage: SetOverrideBindingItem(owner, isPriority, \"KEY\", \"itemname\")");
}
int __fastcall Script_SetOverrideBindingMacro(void *L) {
return SetOverrideBindingCommand(
L, kMacroPrefix,
"Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
return SetOverrideCommand(
L, "MACRO ",
"Usage: SetOverrideBindingMacro(owner, isPriority, \"KEY\", \"macroname\"|macroid)");
}
int __fastcall Script_SetOverrideBindingClick(void *L) {
constexpr const char *usage =
"Usage: SetOverrideBindingClick(owner, "
"isPriority, key, buttonName [, mouseButton])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
const void *owner = Owner(L);
if (owner == nullptr || !Game::Lua::IsString(L, 3) || !Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, "Usage: SetOverrideBindingClick(owner, isPriority, \"KEY\", "
"\"buttonName\"[, \"mouseButton\"])");
return 0;
}
const char *buttonName = Game::Lua::ToString(L, 4);
const char *mouseButton = OptionalMouseButton(L, 5, usage);
SetOverride(owner, priority, key,
MakeClickCommand(buttonName, mouseButton));
const std::string command = ClickCommand(Game::Lua::ToString(L, 4), OptString(L, 5));
SetOverrideBinding(owner, LooseBool(L, 2), Game::Lua::ToString(L, 3), command.c_str());
return 0;
}
int __fastcall Script_ClearOverrideBindings(void *L) {
const void *owner = Game::Lua::ResolveObject(L, 1);
const void *owner = Owner(L);
if (owner == nullptr) {
Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
return 0;
}
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner;
}),
g_overrides.end());
for (OverrideKey &binding : g_overrides)
SetOverride(binding, owner, false, nullptr);
return 0;
}
void RegisterLuaFunctions() {
// A fresh FrameScript state makes every owner pointer from the previous
// state stale. Permanent bindings remain in vanilla's binding table.
g_overrides.clear();
g_activeOverrides.clear();
g_nextSequence = 0;
// GetBindingAction(key [, checkOverride]): 4.3.4's GetCommandAction checks the
// override first when asked; the saved binding is the engine's own answer.
int __fastcall Script_GetBindingAction(void *L) {
if (Game::Lua::IsString(L, 1) && LooseBool(L, 2)) {
if (const char *command = ActiveOverride(Game::Lua::ToString(L, 1))) {
Game::Lua::PushString(L, command);
return 1;
}
}
return reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_GET_BINDING_ACTION)(L);
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
&Script_SetOverrideBinding);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
&Script_SetOverrideBindingSpell);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
&Script_SetOverrideBindingItem);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
&Script_SetOverrideBindingMacro);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
&Script_SetOverrideBindingClick);
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
&Script_ClearOverrideBindings);
Game::Lua::RegisterGlobalFunction("SetOverrideBinding", &Script_SetOverrideBinding);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell", &Script_SetOverrideBindingSpell);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem", &Script_SetOverrideBindingItem);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro", &Script_SetOverrideBindingMacro);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick", &Script_SetOverrideBindingClick);
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings", &Script_ClearOverrideBindings);
Game::Lua::RegisterGlobalFunction("GetBindingAction", &Script_GetBindingAction);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::HookAutoRegister _hookKeyDispatch{
Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast<void *>(&KeyDispatch_h),
reinterpret_cast<void **>(&KeyDispatch_o)};
// Owners are frames of the Lua state being torn down; their overrides go with it.
void PrepareForReload() { g_overrides.clear(); }
const Game::ReloadAutoRegister _reload{&PrepareForReload};
const Game::HookAutoRegister _hookKeyDispatch{Offsets::FUN_BINDING_KEY_DISPATCH,
reinterpret_cast<void *>(&KeyDispatch_h),
reinterpret_cast<void **>(&g_keyDispatchOriginal)};
const Game::HookAutoRegister _hookCommandExecute{
Offsets::FUN_BINDING_COMMAND_EXECUTE,
reinterpret_cast<void *>(&CommandExecute_h),
reinterpret_cast<void **>(&CommandExecute_o)};
Offsets::FUN_BINDING_COMMAND_EXECUTE, reinterpret_cast<void *>(&CommandExecute_h),
reinterpret_cast<void **>(&g_commandExecuteOriginal)};
} // namespace
+20
View File
@@ -15,4 +15,24 @@
<Binding name="AOELOOT">
C_Loot.LootAllCorpses()
</Binding>
<!-- 4.3.4's modified-click actions and defaults (FrameXML\Bindings.xml).
Read by src/bindings/ModifiedClick.cpp. -->
<ModifiedClick action="SELFCAST" default="ALT"/>
<ModifiedClick action="FOCUSCAST" default="NONE"/>
<ModifiedClick action="AUTOLOOTTOGGLE" default="SHIFT"/>
<ModifiedClick action="MAILAUTOLOOTTOGGLE" default="SHIFT"/>
<ModifiedClick action="STICKYCAMERA" default="CTRL"/>
<ModifiedClick action="CHATLINK" default="SHIFT-BUTTON1"/>
<ModifiedClick action="DRESSUP" default="CTRL-BUTTON1"/>
<ModifiedClick action="SOCKETITEM" default="SHIFT-BUTTON2"/>
<ModifiedClick action="SPLITSTACK" default="SHIFT"/>
<ModifiedClick action="PICKUPACTION" default="SHIFT"/>
<ModifiedClick action="PICKUPITEM" default="SHIFT"/>
<ModifiedClick action="COMPAREITEMS" default="SHIFT"/>
<ModifiedClick action="OPENALLBAGS" default="SHIFT"/>
<ModifiedClick action="QUESTWATCHTOGGLE" default="SHIFT"/>
<ModifiedClick action="TOKENWATCHTOGGLE" default="SHIFT"/>
<ModifiedClick action="SHOWITEMFLYOUT" default="ALT"/>
<ModifiedClick action="SHOWMULTICASTFLYOUT" default="ALT"/>
<ModifiedClick action="TRADESEARCHADD" default="SHIFT"/>
</Bindings>
+252
View File
@@ -0,0 +1,252 @@
// 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/>.
// The SPELL / ITEM / MACRO / CLICK binding commands — 4.3.4's
// CGUIBindings::ExecKey branches (decomp UIBindings.cpp), on 1.12's own
// primitives:
//
// SPELL x on release: the spellbook slot named x (FUN_RESOLVE_SPELL_NAME_TO_SLOT)
// cast through FUN_SPELL_CAST_DISPATCH at you when SELFCAST is held,
// at your focus when FOCUSCAST is, else with no explicit target.
// ITEM x on release: the item named x you carry (equipment, then bags)
// used through FUN_ITEM_USE with the same target.
// MACRO x on release: macro index x when x is all digits, else the macro
// named x, run through FUN_MACRO_RUN_BODY.
// CLICK f:b press and release: the frame named f gets a mouse down and a
// mouse up for button b.
//
// The release timing is 4.3.4's (and vanilla action buttons'): the command
// runs when the key comes back up.
//
// CLICK: 4.3.4's frames take a `btn` argument on OnLayerMouseDown / Up that
// marks a virtual click — no hit test, and a button clicks for ANY press
// (down) or release (up) bit it is registered for. 1.12's take only the mouse
// event and hit-test it, so the virtual branch is built from the button's own
// pieces, in 4.3.4's order: the frame's OnMouseDown / OnMouseUp script with
// the button name, then the Button state machine (click / double-click
// vmethods, SetButtonState). Only frames that are Buttons get the second part,
// as only CSimpleButton overrides the handlers 4.3.4 runs.
#include "bindings/Command.h"
#include "Game.h"
#include "Offsets.h"
#include "bindings/ModifiedClick.h"
#include "frame/ClickFrame.h"
#include "item/Arg.h"
#include "item/Location.h"
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
namespace Bindings::Command {
namespace {
constexpr const char kSpell[] = "SPELL ";
constexpr const char kItem[] = "ITEM ";
constexpr const char kMacro[] = "MACRO ";
constexpr const char kClick[] = "CLICK ";
bool HasPrefix(const char *command, const char *prefix) {
return _strnicmp(command, prefix, std::strlen(prefix)) == 0;
}
// The target 4.3.4 picks for SPELL / ITEM: you for SELFCAST, your focus for
// FOCUSCAST, else none (the engine then uses your target).
uint64_t ModifiedTarget() {
if (ModifiedClick::IsModifiedClick("SELFCAST"))
return Unit::Identity::PlayerGuid();
if (ModifiedClick::IsModifiedClick("FOCUSCAST"))
return Unit::Focus::Get();
return 0;
}
void CastSpell(const char *name) {
using NameToSlot_t = int(__fastcall *)(const char *name, int *outBookType);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType, unsigned guidLo,
unsigned guidHi);
int book = 0;
const int slot =
reinterpret_cast<NameToSlot_t>(Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT)(name, &book);
if (slot < 0)
return;
const uint64_t target = ModifiedTarget();
reinterpret_cast<CastDispatch_t>(Offsets::FUN_SPELL_CAST_DISPATCH)(
static_cast<unsigned>(slot), book, static_cast<unsigned>(target),
static_cast<unsigned>(target >> 32));
}
void UseItem(const char *name) {
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
const Item::Arg::Resolved byName{0, 0, name};
Item::Location::ByGUIDResult found{};
if (!Item::Location::FindByArgNoLua(byName, &found) || found.item == nullptr)
return;
const uint64_t target = ModifiedTarget();
reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE)(found.item, &target, 0);
}
void RunMacro(const char *name) {
using SlotToEntry_t = const uint8_t *(__fastcall *)(unsigned slot);
using NameToSlot_t = uint32_t(__fastcall *)(const char *name);
using RunBody_t = void(__fastcall *)(const uint8_t *entry);
const char *digit = name;
while (*digit >= '0' && *digit <= '9')
++digit;
const uint32_t slot =
(*name != '\0' && *digit == '\0')
? static_cast<uint32_t>(std::atoi(name) - 1)
: reinterpret_cast<NameToSlot_t>(Offsets::FUN_MACRO_NAME_TO_SLOT)(name);
const uint8_t *entry =
reinterpret_cast<SlotToEntry_t>(Offsets::FUN_MACRO_SLOT_TO_ENTRY)(slot);
if (entry != nullptr)
reinterpret_cast<RunBody_t>(Offsets::FUN_MACRO_RUN_BODY)(entry);
}
// StringToMouseButton: the click mask for a button name, 0 for any other
// (the click vmethod then names it "UNKNOWN").
uint32_t MaskForButton(const char *button) {
static const struct {
uintptr_t name;
uint32_t mask;
} kButtons[] = {
{Offsets::VAR_BUTTON_NAME_LEFT, 0x01}, {Offsets::VAR_BUTTON_NAME_RIGHT, 0x04},
{Offsets::VAR_BUTTON_NAME_MIDDLE, 0x02}, {Offsets::VAR_BUTTON_NAME_4, 0x08},
{Offsets::VAR_BUTTON_NAME_5, 0x10},
};
for (const auto &entry : kButtons)
if (_stricmp(reinterpret_cast<const char *>(entry.name), button) == 0)
return entry.mask;
return 0;
}
// The frame's OnMouseDown / OnMouseUp, fired as the engine fires them.
void FireMouseScript(void *frame, uintptr_t slotOffset, const char *button) {
using FireVariadic_t = void(__cdecl *)(void *frame, uint32_t *slotPtr, const char *fmt, ...);
uint32_t *slot = Game::Ptr<uint32_t>(frame, slotOffset);
if (slot[0] == 0)
return;
void *L = Game::Lua::State();
const int top = L != nullptr ? Game::Lua::GetTop(L) : 0;
reinterpret_cast<FireVariadic_t>(Offsets::FUN_FRAME_RUN_SCRIPT_VARIADIC)(
frame, slot, reinterpret_cast<const char *>(Offsets::VAR_SCRIPT_FMT_S), button);
if (L != nullptr)
Game::Lua::SetTop(L, top);
}
using ButtonMethod_t = void(__thiscall *)(void *button, uint32_t arg, int flag);
void CallButton(void *button, uintptr_t slot, uint32_t arg) {
auto *vtable = Game::Read<uintptr_t *>(button, 0);
reinterpret_cast<ButtonMethod_t>(vtable[slot / sizeof(uintptr_t)])(button, arg, 0);
}
int &ButtonState(void *button) { return Game::Ref<int>(button, Offsets::OFF_BUTTON_STATE); }
bool StateLocked(void *button) {
return Game::Read<int>(button, Offsets::OFF_BUTTON_STATE_LOCKED) != 0;
}
// The button's click under the engine's "in click" flag.
template <typename Fn> void InClick(Fn fn) {
auto &flag = Game::Ref<int>(Offsets::VAR_BUTTON_IN_CLICK);
flag = 1;
fn();
flag = 0;
}
// CSimpleButton::OnLayerMouseDown with a `btn`.
void VirtualMouseDown(void *frame, const char *button) {
FireMouseScript(frame, Offsets::OFF_FRAME_ONMOUSEDOWN_SLOT, button);
if (!Game::Lua::IsA(frame, Offsets::VAR_BUTTON_LUA_TYPE_ID) ||
ButtonState(frame) == Offsets::BUTTON_STATE_DISABLED)
return;
if (Game::Read<uint32_t>(frame, Offsets::OFF_BUTTON_CLICK_MASK) & 0xFF)
InClick([&] { CallButton(frame, Offsets::VTBL_BUTTON_CLICK, MaskForButton(button)); });
if (!StateLocked(frame) && ButtonState(frame) != Offsets::BUTTON_STATE_DISABLED)
CallButton(frame, Offsets::VTBL_BUTTON_SET_STATE, Offsets::BUTTON_STATE_PUSHED);
}
// CSimpleButton::OnLayerMouseUp with a `btn`.
void VirtualMouseUp(void *frame, const char *button) {
FireMouseScript(frame, Offsets::OFF_FRAME_ONMOUSEUP_SLOT, button);
if (!Game::Lua::IsA(frame, Offsets::VAR_BUTTON_LUA_TYPE_ID) ||
ButtonState(frame) != Offsets::BUTTON_STATE_PUSHED)
return;
if (Game::Read<uint32_t>(frame, Offsets::OFF_BUTTON_CLICK_MASK) & 0xFF00) {
InClick([&] {
const uint32_t now = Time::Clock::NowMs();
auto &last = Game::Ref<uint32_t>(frame, Offsets::OFF_BUTTON_LAST_CLICK_MS);
const uint32_t mask = MaskForButton(button);
if (Game::Read<uint32_t>(frame, Offsets::OFF_BUTTON_ONDOUBLECLICK_HANDLER) != 0 &&
last != 0 && Time::Clock::Elapsed(last, now) <= Offsets::BUTTON_DOUBLE_CLICK_MS) {
CallButton(frame, Offsets::VTBL_BUTTON_DOUBLE_CLICK, mask);
last = 0;
} else {
CallButton(frame, Offsets::VTBL_BUTTON_CLICK, mask);
last = now;
}
});
}
if (!StateLocked(frame) && ButtonState(frame) != Offsets::BUTTON_STATE_DISABLED)
CallButton(frame, Offsets::VTBL_BUTTON_SET_STATE, Offsets::BUTTON_STATE_NORMAL);
}
int Click(const char *target, int isDown) {
const char *separator = std::strchr(target, ':');
if (separator == nullptr)
return 0;
void *frame = Frame::ClickFrame::Find(Game::Lua::State(), target,
static_cast<size_t>(separator - target));
if (frame == nullptr || frame == Game::Read<void *>(Offsets::VAR_GAME_STATE_PTR))
return 0; // no such frame, or the world frame
const char *button = separator + 1;
if (isDown)
VirtualMouseDown(frame, button);
else
VirtualMouseUp(frame, button);
return 1;
}
} // namespace
bool IsDirectAction(const char *command) {
return command != nullptr && (HasPrefix(command, kSpell) || HasPrefix(command, kItem) ||
HasPrefix(command, kMacro) || HasPrefix(command, kClick));
}
int RunKey(const char *command, int isDown) {
if (HasPrefix(command, kSpell)) {
if (!isDown)
CastSpell(command + sizeof kSpell - 1);
return 1;
}
if (HasPrefix(command, kItem)) {
if (!isDown)
UseItem(command + sizeof kItem - 1);
return 1;
}
if (HasPrefix(command, kMacro)) {
if (!isDown)
RunMacro(command + sizeof kMacro - 1);
return 1;
}
return Click(command + sizeof kClick - 1, isDown);
}
} // namespace Bindings::Command
+29
View File
@@ -0,0 +1,29 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Bindings::Command {
// True for the direct-action command families a key binding can hold:
// "SPELL x", "ITEM x", "MACRO x", "CLICK frame:button" (case-insensitive
// prefix, as 4.3.4 tests them).
bool IsDirectAction(const char *command);
// Runs a direct-action command for a key press or release, as 4.3.4's
// CGUIBindings::ExecKey does. Returns the engine's handled flag: 1 for SPELL,
// ITEM and MACRO, 1 for CLICK when the frame exists, else 0. Only call it for
// IsDirectAction commands.
int RunKey(const char *command, int isDown);
} // namespace Bindings::Command
+456
View File
@@ -0,0 +1,456 @@
// 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/>.
// Modified clicks — 4.3.4's MODIFIEDCLICK system (decomp Ui/MODIFIEDCLICK.cpp,
// UIBindings.cpp, UIBindings_Scripts.cpp), which 1.12's binding manager lacks.
//
// An action ("SELFCAST", "CHATLINK", …) is bound to a modifier set and/or a
// mouse button ("ALT", "SHIFT-BUTTON1", "NONE"), once per binding set (0
// default, 1 account, 2 character, 3 current), exactly like key bindings. 1.12
// has 4.3.4's binding sets, file format and load / save / copy steps, so each
// gains the modified-click half 4.3.4 has, through a co-hook on the step:
//
// - Load (Bindings.xml): `<ModifiedClick action="X" default="Y"/>` defines
// an action and its default. The engine loader warns on the node it does
// not know; we register the actions from the same file first.
// - LoadBindings (a binding file into a set): the set starts from the
// defaults, then `modifiedclick BINDING ACTION` lines apply.
// - SaveBindings: `modifiedclick` lines for every action not left at its
// default.
// - CopyBindings: the set's bindings are copied with the key bindings.
//
// The actions live as long as the binding manager: the engine builds it on
// every world load and /reload, so the table is cleared with the Lua state.
// Key state comes from Input::Modifier (the left/right bits 4.3.4's
// CGGameUI::IsKeyDown reads) and the clicked button from Frame::ClickEvents
// (4.3.4's CSimpleTop::m_mouseButtonName).
#include "bindings/ModifiedClick.h"
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "frame/ClickEvents.h"
#include "input/Modifier.h"
#include "xml/FrameLoad.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace Bindings::ModifiedClick {
namespace {
// 4.3.4's modifier bits, = Input::Modifier's left/right key bits (and what
// Input::Modifier::ParseModifiers returns).
constexpr uint32_t kLShift = 0x01, kRShift = 0x02, kLCtrl = 0x04, kRCtrl = 0x08,
kLAlt = 0x10, kRAlt = 0x20;
constexpr int kKeyCount = 6;
// MODIFIEDCLICK_DATA. Flag 1 = the set holds the default (never saved).
constexpr uint32_t kFlagDefault = 0x1;
struct Data {
uint32_t flags = 0;
uint32_t modifiers = 0;
const char *button = nullptr;
};
struct Click {
std::string action;
Data data[Offsets::BINDING_SET_COUNT];
};
std::vector<Click> g_clicks;
Click *Find(const char *action) {
if (action == nullptr)
return nullptr;
for (Click &click : g_clicks)
if (_stricmp(click.action.c_str(), action) == 0)
return &click;
return nullptr;
}
bool KeyDown(int key) { return (Input::Modifier::CurrentMask() & (1u << key)) != 0; }
bool PrefixIs(const char *s, const char *prefix) {
return _strnicmp(s, prefix, std::strlen(prefix)) == 0;
}
// CGUIBindings::AddMetaPrefix, appended to `out`.
void AddMetaPrefix(uint32_t modifiers, std::string &out) {
const auto side = [&](uint32_t left, uint32_t right, const char *both, const char *l,
const char *r) {
if ((modifiers & (left | right)) == (left | right))
out += both;
else if (modifiers & left)
out += l;
else if (modifiers & right)
out += r;
};
side(kLAlt, kRAlt, "ALT-", "LALT-", "RALT-");
side(kLCtrl, kRCtrl, "CTRL-", "LCTRL-", "RCTRL-");
side(kLShift, kRShift, "SHIFT-", "LSHIFT-", "RSHIFT-");
}
// BUTTONn <-> mouse-button name (IndexToMouseButton / MouseButtonToIndex).
// 1..5 are the engine's own name literals, the strings GetMouseButtonClicked
// and the click scope hold.
const char *const kExtraButtons[] = {"Button6", "Button7", "Button8", "Button9"};
const char *ButtonForIndex(int index) {
switch (index) {
case 1: return reinterpret_cast<const char *>(Offsets::VAR_BUTTON_NAME_LEFT);
case 2: return reinterpret_cast<const char *>(Offsets::VAR_BUTTON_NAME_RIGHT);
case 3: return reinterpret_cast<const char *>(Offsets::VAR_BUTTON_NAME_MIDDLE);
case 4: return reinterpret_cast<const char *>(Offsets::VAR_BUTTON_NAME_4);
case 5: return reinterpret_cast<const char *>(Offsets::VAR_BUTTON_NAME_5);
case 6: case 7: case 8: case 9: return kExtraButtons[index - 6];
default: return nullptr;
}
}
int IndexForButton(const char *button) {
for (int index = 1; index <= 9; ++index)
if (_stricmp(ButtonForIndex(index), button) == 0)
return index;
return 0;
}
// MODIFIEDCLICK::SetBinding.
bool SetBinding(Click &click, int set, const char *binding) {
const char *button = nullptr;
uint32_t modifiers = 0;
if (binding != nullptr && *binding != '\0' && _stricmp(binding, "NONE") != 0) {
modifiers = Input::Modifier::ParseModifiers(binding);
if (PrefixIs(binding, "BUTTON")) {
binding += 6;
button = ButtonForIndex(std::atoi(binding));
++binding;
}
if (*binding != '\0')
return false;
}
Data &data = click.data[set];
if (set != Offsets::BINDING_SET_DEFAULT)
data.flags &= ~kFlagDefault;
else
data.flags |= kFlagDefault;
data.button = button;
data.modifiers = modifiers;
return true;
}
// MODIFIEDCLICK::GetBinding.
std::string GetBinding(const Click &click, int set) {
const Data &data = click.data[set];
if (data.modifiers == 0 && data.button == nullptr)
return "NONE";
std::string out;
AddMetaPrefix(data.modifiers, out);
if (data.button != nullptr)
out += "BUTTON" + std::to_string(IndexForButton(data.button));
else
out.pop_back(); // the prefix's trailing '-'
return out;
}
// MODIFIEDCLICK::IsActive.
bool IsActive(const Click &click, int set) {
const Data &data = click.data[set];
if (data.modifiers != 0) {
int key = 0;
while (!((1u << key) & data.modifiers) || !KeyDown(key)) {
if (++key >= kKeyCount)
return false;
}
if (data.button == nullptr)
return true;
} else if (data.button == nullptr) {
return false;
}
const char *current = Frame::ClickEvents::CurrentButton();
return current == nullptr || std::strcmp(current, data.button) == 0;
}
// One side of a modifier test: left only, right only, or either.
bool SideHeld(uint32_t modifiers, uint32_t left, uint32_t right, int leftKey, int rightKey) {
const uint32_t bits = modifiers & (left | right);
if (bits == left)
return KeyDown(leftKey);
if (bits == right || (bits == (left | right) && !KeyDown(leftKey)))
return KeyDown(rightKey);
return true;
}
// --- Bindings.xml: <ModifiedClick action default> ---------------------------
using LoadFile_t = int(__fastcall *)(void *mgr, void *edx, const char *path, void *md5,
void *status);
LoadFile_t g_loadFileOriginal = nullptr;
using TreeLoad_t = void *(__fastcall *)(const char *data, uint32_t size);
using TreeFree_t = void(__fastcall *)(void *tree);
// CGUIBindings::LoadModifiedClick for every root node of the file.
void LoadModifiedClicks(const char *path, void *status) {
void *data = nullptr;
size_t size = 0;
if (!reinterpret_cast<AddOns::EngineIO::FileReadFn>(Offsets::FUN_FILE_READ)(0, path, &data,
&size, 0, 1, 0))
return;
void *tree = reinterpret_cast<TreeLoad_t>(Offsets::FUN_XML_TREE_LOAD)(
static_cast<const char *>(data), static_cast<uint32_t>(size));
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE)(data, __FILE__,
__LINE__, 0);
if (tree == nullptr)
return; // the engine loader reports the parse error
const void *root = Game::Read<const void *>(tree, 0);
for (const void *node = root ? Xml::FirstChild(root) : nullptr; node != nullptr;
node = Xml::NextSibling(node)) {
if (!Xml::TagIs(node, "ModifiedClick"))
continue;
const char *action = Xml::Attr(node, "action");
if (action == nullptr || *action == '\0') {
Xml::Warn(status, "Found modified click with no action in %s", path);
continue;
}
if (Find(action) != nullptr) {
Xml::Warn(status, "Modified click %s is defined more than once in %s", action, path);
continue;
}
g_clicks.push_back(Click{action, {}});
const char *binding = Xml::Attr(node, "default");
if (binding != nullptr && *binding != '\0')
SetBinding(g_clicks.back(), Offsets::BINDING_SET_DEFAULT, binding);
}
reinterpret_cast<TreeFree_t>(Offsets::FUN_XML_TREE_FREE)(tree);
}
int __fastcall LoadFile_h(void *mgr, void *edx, const char *path, void *md5, void *status) {
if (path != nullptr)
LoadModifiedClicks(path, status != nullptr
? status
: reinterpret_cast<void *>(Offsets::VAR_BINDINGS_LOAD_STATUS));
return g_loadFileOriginal(mgr, edx, path, md5, status);
}
// --- binding files: modifiedclick lines -----------------------------------
using LoadSet_t = void(__fastcall *)(int set, const char *text);
LoadSet_t g_loadSetOriginal = nullptr;
using Tokenize_t = void(__stdcall *)(const char **cursor, char *out, unsigned outSize,
const char *delims, int *outQuoted);
// The modified-click half of 4.3.4's LoadBindings: start from the defaults,
// then apply `modifiedclick BINDING ACTION` lines.
void __fastcall LoadSet_h(int set, const char *text) {
g_loadSetOriginal(set, text);
if (set < 0 || set >= Offsets::BINDING_SET_COUNT)
return;
if (set != Offsets::BINDING_SET_DEFAULT)
for (Click &click : g_clicks)
click.data[set] = click.data[Offsets::BINDING_SET_DEFAULT];
auto tokenize = reinterpret_cast<Tokenize_t>(Offsets::FUN_STORM_STR_TOKENIZE);
const char *lineDelims = reinterpret_cast<const char *>(Offsets::VAR_MACRO_LINE_DELIMS);
const char *spaceDelims = reinterpret_cast<const char *>(Offsets::VAR_STR_SPACE_DELIM);
char line[1024];
char key[32];
const char *cursor = text;
while (cursor != nullptr && *cursor != '\0') {
tokenize(&cursor, line, sizeof line, lineDelims, nullptr);
const char *p = line;
while (*p == ' ' || *p == '\t')
++p;
if (!PrefixIs(p, "modifiedclick "))
continue;
const char *action = p + 14;
tokenize(&action, key, sizeof key, spaceDelims, nullptr);
if (action != nullptr)
if (Click *click = Find(action))
SetBinding(*click, set, key);
}
}
using SaveSet_t = void(__fastcall *)(void *mgr, void *edx, int set, void *file);
SaveSet_t g_saveSetOriginal = nullptr;
using WriteFile_t = int(__fastcall *)(void *file, const void *buffer, uint32_t size,
uint32_t *written);
void __fastcall SaveSet_h(void *mgr, void *edx, int set, void *file) {
g_saveSetOriginal(mgr, edx, set, file);
if (set < 0 || set >= Offsets::BINDING_SET_COUNT)
return;
auto write = reinterpret_cast<WriteFile_t>(Offsets::FUN_OS_WRITE_FILE);
for (const Click &click : g_clicks) {
if (click.data[set].flags & kFlagDefault)
continue;
const std::string line =
"modifiedclick " + GetBinding(click, set) + " " + click.action + "\r\n";
uint32_t written = 0;
write(file, line.data(), static_cast<uint32_t>(line.size()), &written);
}
}
using CopySet_t = void(__fastcall *)(void *mgr, void *edx, int source, int dest);
CopySet_t g_copySetOriginal = nullptr;
void __fastcall CopySet_h(void *mgr, void *edx, int source, int dest) {
g_copySetOriginal(mgr, edx, source, dest);
if (source < 0 || source >= Offsets::BINDING_SET_COUNT || dest < 0 ||
dest >= Offsets::BINDING_SET_COUNT)
return;
for (Click &click : g_clicks)
click.data[dest] = click.data[source];
}
const Game::HookAutoRegister _loadFileHook{Offsets::FUN_BINDINGS_LOAD_FILE,
reinterpret_cast<void *>(&LoadFile_h),
reinterpret_cast<void **>(&g_loadFileOriginal)};
const Game::HookAutoRegister _loadSetHook{Offsets::FUN_BINDINGS_LOAD_SET,
reinterpret_cast<void *>(&LoadSet_h),
reinterpret_cast<void **>(&g_loadSetOriginal)};
const Game::HookAutoRegister _saveSetHook{Offsets::FUN_BINDINGS_SAVE_SET,
reinterpret_cast<void *>(&SaveSet_h),
reinterpret_cast<void **>(&g_saveSetOriginal)};
const Game::HookAutoRegister _copySetHook{Offsets::FUN_BINDINGS_COPY_SET,
reinterpret_cast<void *>(&CopySet_h),
reinterpret_cast<void **>(&g_copySetOriginal)};
// The binding manager is rebuilt with every Lua state; so are its actions.
void PrepareForReload() { g_clicks.clear(); }
const Game::ReloadAutoRegister _reload{&PrepareForReload};
// --- Lua --------------------------------------------------------------------
int __fastcall Script_GetNumModifiedClickActions(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(g_clicks.size()));
return 1;
}
int __fastcall Script_GetModifiedClickAction(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetModifiedClickAction(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
Game::Lua::PushString(L, index >= 0 && index < static_cast<int>(g_clicks.size())
? g_clicks[index].action.c_str()
: nullptr);
return 1;
}
int __fastcall Script_SetModifiedClick(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: SetModifiedClick(\"action\", \"binding\")");
return 0;
}
const char *action = Game::Lua::ToString(L, 1);
const char *binding = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
Click *click = Find(action);
if (click == nullptr || !SetBinding(*click, Offsets::BINDING_SET_CURRENT, binding)) {
char message[256];
std::snprintf(message, sizeof message,
"SetModifiedClick(): Unknown action (%s) or binding (%s)", action,
binding != nullptr ? binding : "(null)");
Game::Lua::Error(L, "%s", message);
}
return 0;
}
int __fastcall Script_GetModifiedClick(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: GetModifiedClick(\"action\")");
return 0;
}
const Click *click = Find(Game::Lua::ToString(L, 1));
if (click == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, GetBinding(*click, Offsets::BINDING_SET_CURRENT).c_str());
return 1;
}
int __fastcall Script_IsModifiedClick(void *L) {
const char *action = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
if (IsModifiedClick(action))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
const Game::Doc::Field kIndexArgs[] = {Game::Doc::Req("index", "luaIndex")};
const Game::Doc::Field kActionArgs[] = {Game::Doc::Req("action", "string")};
const Game::Doc::Field kSetArgs[] = {Game::Doc::Req("action", "string"),
Game::Doc::Opt("binding", "string")};
const Game::Doc::Field kIsArgs[] = {Game::Doc::Opt("action", "string")};
const Game::Doc::Field kCountRets[] = {Game::Doc::Req("count", "number")};
const Game::Doc::Field kActionRets[] = {Game::Doc::Opt("action", "string")};
const Game::Doc::Field kBindingRets[] = {Game::Doc::Opt("binding", "string")};
const Game::Doc::Field kIsRets[] = {Game::Doc::Opt("isModified", "number")};
const Game::Doc::Function kGetNum{"Returns the number of modified-click actions.", {},
kCountRets, "BindingsGlobals"};
const Game::Doc::Function kGetAction{"Returns the name of the modified-click action at an index.",
kIndexArgs, kActionRets, "BindingsGlobals"};
const Game::Doc::Function kSet{"Sets the modifier keys and mouse button for an action.",
kSetArgs, {}, "BindingsGlobals"};
const Game::Doc::Function kGet{"Returns the modifier keys and mouse button set for an action.",
kActionArgs, kBindingRets, "BindingsGlobals"};
const Game::Doc::Function kIs{"Returns 1 when the keys and mouse button for an action are held.",
kIsArgs, kIsRets, "BindingsGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetNumModifiedClickActions",
&Script_GetNumModifiedClickActions, &kGetNum);
Game::Lua::RegisterGlobalFunction("GetModifiedClickAction", &Script_GetModifiedClickAction,
&kGetAction);
Game::Lua::RegisterGlobalFunction("SetModifiedClick", &Script_SetModifiedClick, &kSet);
Game::Lua::RegisterGlobalFunction("GetModifiedClick", &Script_GetModifiedClick, &kGet);
Game::Lua::RegisterGlobalFunction("IsModifiedClick", &Script_IsModifiedClick, &kIs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
bool IsModifiedClick(const char *action) {
if (action != nullptr) {
const char *rest = action;
const uint32_t modifiers = Input::Modifier::ParseModifiers(rest);
if (modifiers == 0) {
const Click *click = Find(action);
return click != nullptr && IsActive(*click, Offsets::BINDING_SET_CURRENT);
}
return SideHeld(modifiers, kLShift, kRShift, 0, 1) &&
SideHeld(modifiers, kLCtrl, kRCtrl, 2, 3) &&
SideHeld(modifiers, kLAlt, kRAlt, 4, 5);
}
if ((Input::Modifier::CurrentMask() & 0x3F) != 0)
return true;
for (const Click &click : g_clicks)
if (IsActive(click, Offsets::BINDING_SET_CURRENT))
return true;
return false;
}
} // namespace Bindings::ModifiedClick
+24
View File
@@ -0,0 +1,24 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Bindings::ModifiedClick {
// 4.3.4's CGUIBindings::IsModifiedClick. `action` null: any modifier key is
// held or any modified-click action is active. A modifier string ("SHIFT",
// "LCTRL-ALT"): those keys are held. Otherwise: the named action's binding in
// the current set is active.
bool IsModifiedClick(const char *action);
} // namespace Bindings::ModifiedClick
+153
View File
@@ -0,0 +1,153 @@
// 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/>.
// The `%` codes in sent chat, brought to 4.3.4's set.
//
// SendChatMessage runs its text through FUN_CHAT_PARSE_TEXT (4.3.4's
// ChatParserParseText) before the drunk filter. 1.12's knows `%t` / `%n`
// only: the target's name — the unit object's name, else its name-cache
// name, else TARGET_TOKEN_NOT_FOUND. 4.3.4's (QuestTextParser.cpp) has two
// cases, both resolved the same way and both prefixed `{rtN}` when the unit
// carries a raid marker and a name was found:
//
// - `%t` / `%T` / `%n` / `%N` — the target (1.12 lacks only the prefix);
// - `%f` / `%F` — the focus, or FOCUS_TOKEN_NOT_FOUND (1.12 lacks the code).
//
// The co-hook rewrites a copy of the text and hands it to the original: `%f`
// becomes the focus text, and a `%t` / `%n` pair gets the target's `{rtN}` in
// front of it, so the engine still produces the target's name. The copy walks
// `%` codes the way the original does — a code pair stays together, any other
// `%` consumes only itself — so text after a `%` is never read differently.
// The `{rtN}` prefix renders through Chat::RaidMarkers.
#include "Game.h"
#include "Offsets.h"
#include "object/Resolve.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include "unit/RaidTarget.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
namespace Chat::ChatTokens {
namespace {
using Parse_t = bool(__fastcall *)(const char *text, char *out, int size);
Parse_t g_parseOriginal = nullptr;
const char *GlobalText(const char *name) {
using GetText_t = const char *(__fastcall *)(const char *name, int edx, int gender);
return reinterpret_cast<GetText_t>(
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_GET_TEXT))(name, -1, 0);
}
// A unit's name the way the `%t` case finds the target's: the unit object's
// name, else the name cache. Null when neither has one.
const char *UnitName(uint64_t guid) {
if (guid == 0)
return nullptr;
if (void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0)) {
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
const char *name =
reinterpret_cast<GetName_t>(static_cast<uintptr_t>(Offsets::FUN_OBJECT_GET_NAME))(
unit, nullptr);
if (name != nullptr && *name != '\0')
return name;
}
return Unit::Identity::NameForGuid(guid);
}
// `{rtN}` for a unit that carries raid marker N, else nothing.
void AppendMarker(std::string &out, uint64_t guid) {
for (int mark = 1; mark <= Unit::RaidTarget::kMarkCount; ++mark) {
if (Unit::RaidTarget::GetGUIDByMark(mark) == guid) {
char prefix[8];
std::snprintf(prefix, sizeof prefix, "{rt%d}", mark);
out += prefix;
return;
}
}
}
bool IsMarked(uint64_t guid) {
if (guid == 0)
return false;
for (int mark = 1; mark <= Unit::RaidTarget::kMarkCount; ++mark)
if (Unit::RaidTarget::GetGUIDByMark(mark) == guid)
return true;
return false;
}
bool IsFocusCode(char c) { return c == 'f' || c == 'F'; }
bool IsTargetCode(char c) { return c == 't' || c == 'T' || c == 'n' || c == 'N'; }
uint64_t TargetGuid() {
return Game::Read<uint64_t>(Offsets::VAR_CURRENT_SELECTION_GUID_LO);
}
bool __fastcall Parse_h(const char *text, char *out, int size) {
if (text == nullptr)
return g_parseOriginal(text, out, size);
// Leave the original's input untouched unless a code needs us: any `%f`,
// or a `%t` / `%n` while the target is marked and named.
const uint64_t target = TargetGuid();
const bool targetPrefix = IsMarked(target) && UnitName(target) != nullptr;
bool any = false;
for (const char *p = std::strchr(text, '%'); p != nullptr; p = std::strchr(p + 1, '%'))
if (IsFocusCode(p[1]) || (targetPrefix && IsTargetCode(p[1]))) {
any = true;
break;
}
if (!any)
return g_parseOriginal(text, out, size);
std::string s;
for (const char *p = text; *p != '\0';) {
if (*p != '%') {
s += *p++;
continue;
}
const char c = p[1];
if (IsFocusCode(c)) {
const uint64_t focus = Unit::Focus::Get();
if (const char *name = UnitName(focus)) {
AppendMarker(s, focus);
s += name;
} else {
s += GlobalText("FOCUS_TOKEN_NOT_FOUND");
}
p += 2;
} else if (IsTargetCode(c)) {
if (targetPrefix)
AppendMarker(s, target);
s.append(p, 2); // the original's own code — keep the pair together
p += 2;
} else {
s += *p++; // the original consumes only the '%' here
}
}
return g_parseOriginal(s.c_str(), out, size);
}
const Game::HookAutoRegister _parseHook{Offsets::FUN_CHAT_PARSE_TEXT,
reinterpret_cast<void *>(&Parse_h),
reinterpret_cast<void **>(&g_parseOriginal)};
} // namespace
} // namespace Chat::ChatTokens
+7 -16
View File
@@ -29,10 +29,9 @@
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
// wiki describes, but handed the exact set instead of the whole world.
//
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
// link sits at `+0x318` and the list terminator carries the low-bit
// sentinel. We mirror the engine's own walk in the per-frame update
// The engine keeps active bubbles on an intrusive Storm `TSList`
// (`VAR_CHAT_BUBBLE_LIST_HEAD` / `OFF_CHAT_BUBBLE_LINK_NEXT` in Offsets.h);
// the list terminator carries the low-bit sentinel. We mirror the engine's own walk in the per-frame update
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
// unit has despawned are pruned on the engine's next update tick, so a
// just-orphaned bubble can linger one frame — harmless, and addons that
@@ -42,32 +41,24 @@
// frames, so every bubble is returned regardless.
#include "Game.h"
#include "Offsets.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace ChatBubble::Info {
namespace {
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
// forward link, and the end-of-list low-bit sentinel — see the engine
// walks at 0x004b1060 / 0x004b0f80.
constexpr uintptr_t kListHead = 0x00B6F038;
constexpr int kOffNextLink = 0x318;
} // namespace
static int __fastcall Script_GetAllChatBubbles(void *L) {
Game::Lua::NewTable(L);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(
static_cast<uintptr_t>(Offsets::VAR_CHAT_BUBBLE_LIST_HEAD));
int nextIndex = 1;
while (node != 0 && (node & 1) == 0) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
node = *reinterpret_cast<const uintptr_t *>(node + Offsets::OFF_CHAT_BUBBLE_LINK_NEXT);
}
return 1;
}
+84
View File
@@ -0,0 +1,84 @@
// 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_ClassColor.GetClassColor(className)` — a class's color as a ColorMixin.
//
// Modern clients keep the class colors in the engine and let FrameXML read
// them out through this function. Neither half of that exists here: 1.12's
// `ChrClasses.dbc` has no color column (17 columns, all accounted for), and
// the binary carries no class-color table — searched it for the class float
// triples and for the hex strings, no hits.
//
// What this client does have is `RAID_CLASS_COLORS`, defined by FrameXML in
// `Fonts.xml` and given the mixin by `!!!ClassicAPI/Util/Color*.lua`. Every
// UI on the client already colors classes from it, so this reads that table
// instead of carrying a second copy of the nine colors — the same choice
// `C_UnitAuras.GetAuraDispelTypeColor` makes with the `DEBUFF_TYPE_*_COLOR`
// globals. One source of truth, one shared object per class, and a class
// another addon recolors stays recolored here (this client's stand-in for
// CUSTOM_CLASS_COLORS).
//
// The lookup goes through metamethods, so a `RAID_CLASS_COLORS` that answers
// for unknown keys answers here too — the pfUI shim in `Util/AddOnCompat.lua`
// hands back grey. A name that is not a string, and a missing table, both
// return nil.
#include "Game.h"
namespace Classes::Color {
namespace {
int __fastcall Script_GetClassColor(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_STRING) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, "RAID_CLASS_COLORS");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::PushNil(L);
return 1;
}
// Index with the argument itself rather than with a `const char *` read
// off the stack: the string stays anchored at index 1, so nothing it
// points at can be collected while we look it up.
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, -2);
return 1;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("className", "string", "A class file name, such as \"WARRIOR\"."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Opt("classColor", "colorRGB", nullptr,
"The color for that class, or nil when the name is not a class."),
};
const Game::Doc::Function kGetClassColor{
"The color a class is drawn in.", kArgs, kRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ClassColor", "GetClassColor",
&Script_GetClassColor, &kGetClassColor);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Classes::Color
+522
View File
@@ -0,0 +1,522 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `PLAYER_SWING(swingDuration, swingType)` and `Enum.PlayerSwingType` — the
// Classic-beta swing-timer events, backported from packet data 1.12 already
// receives. See CLAUDE.md's "Backport the Classic swing-timer API" entry for
// the full derivation; short version here.
//
// This module keeps its own swing-timer STATE rather than relaying packets
// 1:1, because the server mutates the swing timer from several places that
// don't map to one packet each:
//
// - a white hit lands -> SMSG_ATTACKERSTATEUPDATE, attacker == us
// - an on-next-swing ability replaces it -> SMSG_SPELL_GO, Attributes & ON_NEXT_SWING
// - a cast-time spell is cast -> SMSG_SPELL_GO, InterruptFlags & AUTOATTACK
// - autorepeat (Auto Shot / Shoot) fires -> SMSG_SPELL_GO, AttributesEx2 & AUTOREPEAT
// - starting a fresh melee attack -> SMSG_ATTACKSTART (off-hand only)
// - a weapon is swapped mid-combat -> no packet; polled off the invMgr GUIDs
// - the LOCAL PLAYER parries an attack -> SMSG_ATTACKERSTATEUPDATE, victim == us,
// targetState == PARRY (no separate packet —
// the server just clips OUR remaining time)
//
// Extra attacks (Windfury, Sword Specialization) send N packets for one
// logical reset; firing straight off the packet would emit N `PLAYER_SWING`s
// for the same instant. Instead every mutation above just marks a swing type
// "dirty" and a WorldTick subscriber fires at most one event per type per
// frame, with the CURRENT remaining time — so N packets in one frame still
// produce exactly one event, and a parry-haste correction that lands in the
// same frame as the original reset collapses into it for free.
#include "combat/Swing.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include "item/Record.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include "unit/Position.h"
#include <cstdint>
namespace Combat::Swing {
namespace {
using Net::CDataStore;
using Time::Clock::NowMs;
// ---- Per-swing-type state -----------------------------------------------
struct State {
uint32_t endMs = 0;
bool active = false; // at least one reset has happened this session
bool dirty = false; // needs a PLAYER_SWING fire on the next WorldTick
};
State g_swing[3];
void MarkReset(SwingType type, uint32_t durationMs) {
if (durationMs == 0)
return; // no weapon for this hand right now -- nothing to report
State &s = g_swing[type];
s.endMs = NowMs() + durationMs;
s.active = true;
s.dirty = true;
}
bool PlayerInCombat() {
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (desc == nullptr)
return false;
const uint32_t flags =
*reinterpret_cast<const uint32_t *>(desc + Offsets::OFF_UNIT_FIELD_FLAGS);
return (flags & Offsets::UNIT_FLAG_IN_COMBAT) != 0;
}
} // namespace
uint32_t AttackTimeMs(SwingType type) {
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (desc == nullptr)
return 0;
const int off = type == MAIN_HAND
? Offsets::OFF_UNIT_FIELD_BASEATTACKTIME
: type == OFF_HAND
? Offsets::OFF_UNIT_FIELD_OFFHANDATTACKTIME
: Offsets::OFF_UNIT_FIELD_RANGEDATTACKTIME;
return *reinterpret_cast<const uint32_t *>(desc + off);
}
namespace {
// Reads a unit's bounding radius (the same field `Unit::Range`'s
// `UnitInRange`/`UnitDistanceSquared` and `Combat::SwingRange` read) off its
// descriptor. 0 for a null object or an unpopulated descriptor.
float BoundingRadius(void *obj) {
if (obj == nullptr)
return 0.0f;
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(obj) + Offsets::OFF_UNIT_DESCRIPTOR);
if (fields == nullptr)
return 0.0f;
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
} // namespace
bool InMeleeRange(void *player, void *target) {
float playerPos[3];
float targetPos[3];
if (!Unit::Position::Read(player, playerPos) || !Unit::Position::Read(target, targetPos))
return false;
const float leeway =
Game::Read<float>(static_cast<uintptr_t>(Offsets::VAR_MELEE_REACH_LEEWAY));
const float floorRange =
Game::Read<float>(static_cast<uintptr_t>(Offsets::VAR_MELEE_REACH_MIN));
float maxRange = BoundingRadius(player) + BoundingRadius(target) + leeway;
if (maxRange < floorRange)
maxRange = floorRange;
const float dx = playerPos[0] - targetPos[0];
const float dy = playerPos[1] - targetPos[1];
return (dx * dx + dy * dy) < (maxRange * maxRange);
}
namespace {
AutoSwingResetOverride *g_overrides = nullptr;
} // namespace
AutoSwingResetOverride::AutoSwingResetOverride(SwingResetOverride fn)
: fn(fn), next(g_overrides) {
g_overrides = this;
}
namespace {
// ---- Non-triggered-cast gate ---------------------------------------------
//
// The server's swing-reset rule is `!triggered && InterruptFlags & AUTOATTACK`.
// SMSG_SPELL_GO carries no triggered bit, so we approximate "not triggered"
// by "we ourselves sent CMSG_CAST_SPELL for this id recently" — a proc/aura
// trigger never originates from a client packet. Mirrors the capture-ring
// pattern in `Aura::ComboDuration` (co-hooking the same NetClient send
// funnel via `Net::SendObserver`), simplified: presence within a TTL is
// all that's needed here, no payload to carry.
struct SentCast {
uint32_t spellId;
uint32_t tMs;
};
constexpr int kSentCastCount = 8;
SentCast g_sentCasts[kSentCastCount];
int g_sentCastCursor = 0;
constexpr uint32_t kSentCastTtlMs = 3000; // worst-case send -> SPELL_GO roundtrip
void RememberSentCast(uint32_t spellId) {
g_sentCasts[g_sentCastCursor] = {spellId, NowMs()};
g_sentCastCursor = (g_sentCastCursor + 1) % kSentCastCount;
}
bool WasNonTriggeredCast(uint32_t spellId) {
const uint32_t now = NowMs();
for (auto &c : g_sentCasts) {
if (c.spellId == spellId && now - c.tMs < kSentCastTtlMs) {
c.spellId = 0; // consume -- a queued re-cast needs its own capture
return true;
}
}
return false;
}
void OnSend(uint32_t opcode, CDataStore *packet) {
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
return;
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
RememberSentCast(spellId);
}
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// ---- Parry haste ----------------------------------------------------------
constexpr uint32_t kDefaultUnarmedDelayMs = 2000;
constexpr int kInvSlotMainHand1Based = 16;
constexpr int kInvSlotOffHand1Based = 17;
// The UNHASTED weapon swing time for a hand. The server's own
// `Unit::GetAttackTime()` unwinds the live percent-mod to recover this, but
// the percent-mod factor never crosses the wire — the descriptor field only
// ever carries the CURRENT (already-hasted) value. Read it from the weapon's
// own item data instead: a static property of the item, independent of any
// haste tracking. `slot1Based` is 16 (main hand) or 17 (off hand); on an
// item-cache miss (rare — see the item-cache-race notes in CLAUDE.md) we
// fall back to the live descriptor value rather than guessing.
uint32_t UnhastedWeaponDelayMs(int slot1Based, SwingType fallbackType) {
const uint8_t *cgItem = Item::Location::ResolveEquipmentSlot(slot1Based);
if (cgItem != nullptr) {
const uint8_t *instance = Item::InstanceBlock(cgItem);
if (instance != nullptr) {
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_ITEM_ID);
const uint8_t *stats = Item::PeekRecord(itemID);
if (stats != nullptr) {
const uint32_t delay = *reinterpret_cast<const uint32_t *>(
stats + Offsets::OFF_ITEMSTATS_DELAY);
if (delay > 0)
return delay;
}
}
const uint32_t live = AttackTimeMs(fallbackType);
if (live > 0)
return live;
}
return kDefaultUnarmedDelayMs;
}
// Mirrors the server's parry-haste correction (tortoise-wow's
// `VICTIMSTATE_PARRY` block, reached from the ATTACKER's damage path when
// WE are the victim and parried): whichever of our hands has less time
// remaining is cut to 20% of its unhasted weapon delay if it was between
// 20% and 60% remaining, or has 40% of the delay subtracted if it was past
// 60% remaining. Under 20% remaining, the hand is left alone (already about
// to swing) — matches the server's `if/else if`, no `else` branch.
void ApplyParryHaste() {
State &main = g_swing[MAIN_HAND];
State &off = g_swing[OFF_HAND];
const uint32_t now = NowMs();
const uint32_t mainRemaining = Time::Clock::Remaining(now, main.endMs);
const uint32_t offRemaining = Time::Clock::Remaining(now, off.endMs);
const bool useOff = off.active && offRemaining < mainRemaining;
State &target = useOff ? off : main;
if (!target.active)
return;
const uint32_t remaining = useOff ? offRemaining : mainRemaining;
const uint32_t unhastedDelay = UnhastedWeaponDelayMs(
useOff ? kInvSlotOffHand1Based : kInvSlotMainHand1Based,
useOff ? OFF_HAND : MAIN_HAND);
const float p20 = static_cast<float>(unhastedDelay) * 0.20f;
const float p60 = 3.0f * p20;
const auto r = static_cast<float>(remaining);
uint32_t newRemaining = remaining;
if (r > p20 && r <= p60)
newRemaining = static_cast<uint32_t>(p20);
else if (r > p60)
newRemaining = static_cast<uint32_t>(r - 2.0f * p20);
if (newRemaining != remaining) {
target.endMs = now + newRemaining;
target.dirty = true;
}
}
// ---- Ranged wind-up compensation ------------------------------------------
using GetCastTime_t = int(__fastcall *)(int spellID, int unit, int flag);
// Auto Shot / Shoot are re-cast by the server as TRIGGERED spells, so the
// per-shot SMSG_SPELL_START is skipped and SMSG_SPELL_GO is the only packet
// — arriving at the END of the ~0.5s wind-up, well after the server actually
// reset the timer. Subtract the engine's own cast-time helper (the same one
// `Spell::Cast` uses for the cast bar) so the ranged bar still ends on time
// instead of running ~0.5s long.
void HandleAutorepeatReset(uint32_t spellId) {
const uint32_t rangedTime = AttackTimeMs(RANGED);
if (rangedTime == 0)
return;
auto getCastTime = reinterpret_cast<GetCastTime_t>(Offsets::FUN_GET_CAST_TIME);
int castTime = getCastTime(static_cast<int>(spellId), 0, 0);
if (castTime < 0)
castTime = 0;
const auto windUp = static_cast<uint32_t>(castTime);
MarkReset(RANGED, windUp >= rangedTime ? 0 : rangedTime - windUp);
}
// ---- SMSG_SPELL_GO dispatch -----------------------------------------------
bool ForcedSwingReset(uint32_t spellId) {
for (auto *r = g_overrides; r != nullptr; r = r->next)
if (r->fn(spellId))
return true;
return false;
}
void OnSpellGo(uint32_t spellId) {
if (spellId == 0)
return;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT, spellId);
if (rec == nullptr)
return;
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & Offsets::SPELL_ATTR_ON_NEXT_SWING) {
// Replaces the next white hit -- not also evaluated as a cast-reset
// spell below (an on-next-swing ability's InterruptFlags is unrelated).
MarkReset(MAIN_HAND, AttackTimeMs(MAIN_HAND));
return;
}
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
if (attrEx2 & Offsets::SPELL_ATTR_EX2_AUTOREPEAT_FLAG) {
HandleAutorepeatReset(spellId);
return;
}
const uint32_t interrupt = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_INTERRUPT_FLAGS);
if ((interrupt & Offsets::SPELL_INTERRUPT_FLAG_AUTOATTACK) &&
(ForcedSwingReset(spellId) ||
!(attrEx2 & Offsets::SPELL_ATTR_EX2_NOT_RESET_AUTO_ACTIONS)) &&
WasNonTriggeredCast(spellId)) {
MarkReset(MAIN_HAND, AttackTimeMs(MAIN_HAND));
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND));
}
}
// ---- Incoming packets -------------------------------------------------
void OnPacket(uint32_t opcode, CDataStore *packet) {
if (packet == nullptr)
return;
const uint64_t player = Unit::Identity::PlayerGuid();
if (player == 0)
return;
if (opcode == Offsets::SMSG_ATTACKERSTATEUPDATE) {
const uint32_t hitInfo = Net::Read<uint32_t>(packet);
const uint64_t attacker = Net::ReadPackedGuid(packet);
const uint64_t victim = Net::ReadPackedGuid(packet);
Net::Read<uint32_t>(packet); // totalDamage -- unused here
const uint8_t subCount = Net::Read<uint8_t>(packet);
for (uint8_t i = 0; i < subCount; ++i) {
Net::Read<uint32_t>(packet); // school
Net::Read<float>(packet); // coefficient
Net::Read<uint32_t>(packet); // damage
Net::Read<uint32_t>(packet); // absorb
Net::Read<int32_t>(packet); // resist
}
const uint32_t targetState = Net::Read<uint32_t>(packet);
if (attacker == player) {
const SwingType hand =
(hitInfo & Offsets::HITINFO_LEFTSWING) ? OFF_HAND : MAIN_HAND;
MarkReset(hand, AttackTimeMs(hand));
} else if (victim == player && targetState == Offsets::VICTIMSTATE_PARRY) {
ApplyParryHaste();
}
return;
}
if (opcode == Offsets::SMSG_ATTACKSTART) {
const uint64_t attacker = Net::Read<uint64_t>(packet);
const uint64_t victim = Net::Read<uint64_t>(packet);
if (attacker == player) {
// Unit::Attack() resets the off-hand timer the INSTANT an
// attack is declared, even out of range -- the server just
// silently re-arms it every 100ms (DelayAutoAttacks(), no
// packet) until the target is actually reachable. Firing here
// unconditionally would promise "swing lands in Xs" while
// stuck out of range, which never comes true until the real
// first swing's SMSG_ATTACKERSTATEUPDATE resets it for real.
// Gate on actually being in melee range so this only fires
// when the promised time is meaningful.
auto *victimObj = Object::ByGuid(
Offsets::TYPEMASK_UNIT | Offsets::TYPEMASK_PLAYER, victim);
auto *playerObj = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (InMeleeRange(playerObj, victimObj))
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND));
}
return;
}
if (opcode == Offsets::SMSG_SPELL_GO) {
Net::ReadPackedGuid(packet); // itemGuid -- unused here
const uint64_t caster = Net::ReadPackedGuid(packet);
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (caster == player)
OnSpellGo(spellId);
return;
}
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
// ---- Weapon swap mid-combat (polled -- no packet, no observer needed) ---
//
// A weapon equipped while `IsInCombat()` resets that hand's timer
// server-side (`Player::_ApplyWeaponDependentAuraMods` -> `SetAttackTime`
// with `resetTimer = IsInCombat()`). There's no dedicated packet for this;
// `Player::Equipment` already tracks the same invMgr GUIDs via a real
// descriptor observer for `PLAYER_EQUIPMENT_CHANGED`, but that's a one-way
// Lua event fire with nothing to subscribe to in C++. Only 3 slots matter
// here, so a plain 3-GUID compare on the existing WorldTick pass is simpler
// than standing up a second observer registration for the same fields.
uint64_t g_lastMainGuid = 0;
uint64_t g_lastOffGuid = 0;
uint64_t g_lastRangedGuid = 0;
bool g_weaponSnapValid = false;
void PollWeaponSwap() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr) {
g_weaponSnapValid = false;
return;
}
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const uint32_t count =
*reinterpret_cast<const uint32_t *>(invMgr + Offsets::OFF_INVMGR_SLOT_COUNT);
if (count < 18) {
g_weaponSnapValid = false;
return;
}
const uint64_t *guids = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr) {
g_weaponSnapValid = false;
return;
}
// 0-based invMgr slots: 15 = main hand (Lua 16), 16 = off hand (17),
// 17 = ranged (18) -- same `slot0 = luaSlot - 1` convention documented
// in Player::Equipment.
const uint64_t mainGuid = guids[15];
const uint64_t offGuid = guids[16];
const uint64_t rangedGuid = guids[17];
if (g_weaponSnapValid && PlayerInCombat()) {
if (mainGuid != g_lastMainGuid)
MarkReset(MAIN_HAND, AttackTimeMs(MAIN_HAND));
if (offGuid != g_lastOffGuid)
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND));
if (rangedGuid != g_lastRangedGuid)
MarkReset(RANGED, AttackTimeMs(RANGED));
}
g_lastMainGuid = mainGuid;
g_lastOffGuid = offGuid;
g_lastRangedGuid = rangedGuid;
g_weaponSnapValid = true;
}
// ---- PLAYER_SWING fire (WorldTick, at most once per type per frame) -----
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("swingDuration", "number",
"Seconds from now until this swing type's next attack."),
Game::Doc::Req("swingType", "PlayerSwingType", "Which attack this reset applies to."),
};
const Game::Doc::Event kPlayerSwingDoc{
"SwingTimer",
"Fires whenever the player's melee or ranged auto-attack timer resets, "
"with the seconds remaining until the next swing.",
kPayload};
const Event::Custom::AutoReserve _evt{"PLAYER_SWING", &kPlayerSwingDoc};
void OnWorldTick() {
PollWeaponSwap();
const int slot = _evt.Slot();
const uint32_t now = NowMs();
for (int t = 0; t < 3; ++t) {
State &s = g_swing[t];
if (!s.dirty)
continue;
s.dirty = false;
if (slot < 0)
continue;
const uint32_t remaining = Time::Clock::Remaining(now, s.endMs);
if (remaining == 0)
continue; // elapsed before we got to fire it -- nothing to show
Event::Custom::Fire(slot, "%f%d", static_cast<double>(remaining) * 0.001, t);
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// ---- Enum.PlayerSwingType --------------------------------------------------
const Game::Lua::EnumIntegerEntry kSwingTypeEntries[] = {
{"MainHand", MAIN_HAND},
{"OffHand", OFF_HAND},
{"Ranged", RANGED},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "PlayerSwingType", kSwingTypeEntries,
sizeof(kSwingTypeEntries) / sizeof(kSwingTypeEntries[0]), "SwingTimer");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Combat::Swing
+71
View File
@@ -0,0 +1,71 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// `Enum.PlayerSwingType` values, matching the `_classic_beta_`
// `SwingTimerDocumentation.lua` contract exactly (MainHand=0, OffHand=1,
// Ranged=2). Shared between `Combat::Swing` (PLAYER_SWING) and
// `Combat::SwingRange` (C_SwingTimer / PLAYER_SWING_RANGE_UPDATE).
namespace Combat::Swing {
enum SwingType : int { MAIN_HAND = 0, OFF_HAND = 1, RANGED = 2 };
// Live read of the player's current (already-hasted) swing time for `type`,
// in ms — `UNIT_FIELD_BASEATTACKTIME` / `_OFFHAND` / `UNIT_FIELD_RANGEDATTACKTIME`
// off the player's own descriptor, the same fields `UnitAttackSpeed` /
// `UnitRangedDamage` read. 0 when the player object isn't resolvable or no
// weapon is equipped for that hand (no off-hand weapon, no ranged weapon) —
// callers treat 0 as "this swing type doesn't apply right now".
uint32_t AttackTimeMs(SwingType type);
// True if `player` and `target` (raw CGUnit* object pointers, e.g. from
// `Unit::Position::ResolveToken` or `Object::ByGuid`) are within pure 2D
// (X/Y) melee reach of each other -- the server's own auto-attack distance
// test (`Unit::CanReachWithMeleeAutoAttackAtPosition` /
// `WorldObject::CanReachWithMeleeSpellAttack`, both explicitly 2D; see
// `Offsets::VAR_MELEE_REACH_LEEWAY`'s comment for why the generic 3D spell-
// range core can't be reused here). False if either pointer is null or
// either position can't be read. Shared by `Combat::Swing` (to decide
// whether an attack-start reset is close enough to promise a real swing
// time) and `Combat::SwingRange` (the melee `C_SwingTimer` check).
bool InMeleeRange(void *player, void *target);
// Extension point for server-specific exceptions to the "does casting this
// spell reset the caster's melee swing timer" rule. The stock rule is
// data-driven off the client's own `Spell.dbc` (`InterruptFlags &
// SPELL_INTERRUPT_FLAG_AUTOATTACK`, suppressed by `AttributesEx2 &
// SPELL_ATTR_EX2_NOT_RESET_AUTO_ACTIONS`) — see `src/turtle/SwingReset.cpp`
// for the motivating case: every rank of Slam in Turtle's OWN client DBC
// carries the suppression flag, but Turtle's server doesn't honor it for
// Slam (verified in-game by packet timestamps: both melee timers delayed
// after a Slam cast), contradicting both the client data and the public
// tortoise-wow reference source. A registered predicate returning true
// forces the reset regardless of the `AttributesEx2` suppression bit — it
// does NOT bypass the `InterruptFlags` gate itself, since every known case
// so far already has that bit set.
//
// Declare a file-scope `static const Combat::Swing::AutoSwingResetOverride
// _r{&Predicate};` — same static-init chaining as every other
// `AutoSubscribe`-style registrar in this codebase.
using SwingResetOverride = bool (*)(uint32_t spellId);
struct AutoSwingResetOverride {
explicit AutoSwingResetOverride(SwingResetOverride fn);
SwingResetOverride fn;
AutoSwingResetOverride *next;
};
} // namespace Combat::Swing
+260
View File
@@ -0,0 +1,260 @@
// 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_SwingTimer.EnableRangeCheck` / `IsTargetWithinSwingRange` and
// `PLAYER_SWING_RANGE_UPDATE(swingType, isInRange, checksRange)`.
//
// RANGED reuses the engine's generic range core: `Spell::Range::PlayerVsUnit`
// wraps `FUN_SPELL_RANGE_CHECK` -> `FUN_006e3480`, feeding it Auto Shot/Shoot
// as a pure geometry probe (it doesn't check spell knowledge). That core
// computes full 3D (X/Y/Z) distance, which matches the SERVER's generic
// spell-range gate (`Spell::CheckRange`'s non-combat-range path ->
// `WorldObject::GetCombatDistance`, also 3D) closely enough for ranged.
//
// MELEE does NOT reuse it — verified wrong in-game (rejected a swing at
// 5.08yd 3D distance from a stationary target's center while hits were
// landing). The server's actual melee-attack gate
// (`Unit::CanReachWithMeleeAutoAttackAtPosition`,
// `WorldObject::CanReachWithMeleeSpellAttack`) is explicitly 2D — its own
// source comment says "melee spells ignore Z-axis checks" — so any Z offset
// between the two units (sloped ground, stairs, model-origin differences)
// eats into a distance budget the server never charges for. The GENERIC
// engine range core was never asked to gate a real auto-attack decision
// before this feature existed, so there's no existing client mechanism for
// that exact semantic to mirror; `Combat::Swing::InMeleeRange` reproduces the
// SERVER's formula instead (using the engine's own live reach constants
// rather than hardcoding them) — shared with `Combat::Swing`, which also
// needs it to decide whether an attack-start off-hand reset is close enough
// to promise a real swing time.
//
// What this module adds on top of the geometry: the "no weapon for this
// swing type" and "target can't be attacked" gates Blizzard's contract also
// requires (both report as `isInRange == nil`, i.e. `checksRange == false`),
// and turning the per-frame poll into a change-driven
// `PLAYER_SWING_RANGE_UPDATE`.
#include "combat/Swing.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include "item/Record.h"
#include "spell/Range.h"
#include "tick/WorldTick.h"
#include "unit/Position.h"
#include <cstdint>
namespace Combat::SwingRange {
namespace {
using Combat::Swing::SwingType;
using Combat::Swing::RANGED;
enum class RangeState { NoCheck, InRange, OutOfRange };
bool g_enabled[3] = {false, false, false};
RangeState g_lastState[3] = {RangeState::NoCheck, RangeState::NoCheck, RangeState::NoCheck};
using UnitCanAttack_t = bool(__thiscall *)(void *attacker, void *target);
// Pure geometry probe for Ranged -- the two ranged-weapon families.
constexpr int kAutoShotSpellId = 75; // bow/gun/crossbow, RangeIndex 114 (8-35yd)
constexpr int kShootSpellId = 5019; // wand, RangeIndex 4 (0-30yd)
// While actively auto-shooting, the exact spell in flight is the truest
// probe; otherwise pick by the equipped ranged weapon's ammo type (bows/
// guns/crossbows consume ammo, wands don't -- the same distinction the
// server itself uses to route SPELL_ATTR_EX2_AUTOREPEAT_FLAG casts).
int RangedProbeSpellId() {
const auto active = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_ACTIVE_AUTO_REPEAT_SPELL));
if (active != 0)
return static_cast<int>(active);
const uint8_t *cgItem = Item::Location::ResolveEquipmentSlot(18); // ranged slot
if (cgItem != nullptr) {
const uint8_t *instance = Item::InstanceBlock(cgItem);
if (instance != nullptr) {
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_ITEM_ID);
const uint8_t *stats = Item::PeekRecord(itemID);
if (stats != nullptr) {
const uint32_t ammoType = *reinterpret_cast<const uint32_t *>(
stats + Offsets::OFF_ITEMSTATS_AMMO_TYPE);
return ammoType != 0 ? kAutoShotSpellId : kShootSpellId;
}
}
}
return kAutoShotSpellId;
}
RangeState Evaluate(SwingType type) {
if (Combat::Swing::AttackTimeMs(type) == 0)
return RangeState::NoCheck; // no weapon equipped for this swing type
void *player = Unit::Position::ResolveToken("player");
void *target = Unit::Position::ResolveToken("target");
if (player == nullptr || target == nullptr)
return RangeState::NoCheck; // no current target
auto canAttack = reinterpret_cast<UnitCanAttack_t>(Offsets::FUN_UNIT_CAN_ATTACK);
if (!canAttack(player, target))
return RangeState::NoCheck; // target can't be attacked
if (type != RANGED)
return Combat::Swing::InMeleeRange(player, target) ? RangeState::InRange
: RangeState::OutOfRange;
const int result = Spell::Range::PlayerVsUnit(RangedProbeSpellId(), "target");
if (result < 0)
return RangeState::NoCheck;
return result != 0 ? RangeState::InRange : RangeState::OutOfRange;
}
// --- C_SwingTimer.EnableRangeCheck(swingType, enable) ----------------------
int __fastcall Script_EnableRangeCheck(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_SwingTimer.EnableRangeCheck(swingType, enable)");
return 0;
}
const int type = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (type < 0 || type > 2)
return 0;
const bool enable = Game::Lua::ToBoolean(L, 2) != 0;
g_enabled[type] = enable;
// Seed the change-detector without firing -- callers are expected to
// read the current state via IsTargetWithinSwingRange right after
// enabling (exactly what SwingTimerMixin:UpdateRangeState does), not
// wait for a synthetic first event.
g_lastState[type] = enable ? Evaluate(static_cast<SwingType>(type)) : RangeState::NoCheck;
return 0;
}
// --- C_SwingTimer.IsTargetWithinSwingRange(swingType) -> isInRange ---------
int __fastcall Script_IsTargetWithinSwingRange(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_SwingTimer.IsTargetWithinSwingRange(swingType)");
return 0;
}
const int type = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (type < 0 || type > 2) {
Game::Lua::PushNil(L);
return 1;
}
const RangeState s = Evaluate(static_cast<SwingType>(type));
if (s == RangeState::NoCheck)
Game::Lua::PushNil(L);
else
Game::Lua::PushBool(L, s == RangeState::InRange);
return 1;
}
// --- PLAYER_SWING_RANGE_UPDATE(swingType, isInRange, checksRange) ----------
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("swingType", "PlayerSwingType", "Which attack's range changed."),
Game::Doc::Req("isInRange", "bool",
"Whether the current target is within auto attack range. "
"Should not be used if checksRange is false."),
Game::Doc::Req("checksRange", "bool",
"False when no range check could be made, for example there "
"is no current target."),
};
const Game::Doc::Event kRangeUpdateDoc{
"SwingTimer",
"Fires when the current target moves in or out of range for a swing type "
"that has EnableRangeCheck on.",
kPayload};
const Event::Custom::AutoReserve _evt{"PLAYER_SWING_RANGE_UPDATE", &kRangeUpdateDoc};
// The engine's printf-style event dispatcher has no `%b` and a plain `%d`
// with `0` pushes the NUMBER 0 (truthy in Lua) -- so each bool picks
// between `%d`+`1` and `%s`+NULL (tail-jumps to `lua_pushnil`), the same
// 1/nil idiom `Event::Custom::FireIdSuccess` uses. `isInRange` is only ever
// true when `checksRange` is also true (see `Evaluate`), so three shapes
// cover every reachable state.
void FireRangeUpdate(int slot, int type, bool isInRange, bool checksRange) {
if (isInRange)
Event::Custom::Fire(slot, "%d%d%d", type, 1, 1);
else if (checksRange)
Event::Custom::Fire(slot, "%d%s%d", type, static_cast<const char *>(nullptr), 1);
else
Event::Custom::Fire(slot, "%d%s%s", type, static_cast<const char *>(nullptr),
static_cast<const char *>(nullptr));
}
void OnWorldTick() {
const int slot = _evt.Slot();
for (int t = 0; t < 3; ++t) {
if (!g_enabled[t])
continue;
const RangeState cur = Evaluate(static_cast<SwingType>(t));
if (cur == g_lastState[t])
continue;
g_lastState[t] = cur;
if (slot < 0)
continue;
FireRangeUpdate(slot, t, cur == RangeState::InRange, cur != RangeState::NoCheck);
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// --- Registration -----------------------------------------------------------
const Game::Doc::Field kEnableArgs[] = {
Game::Doc::Req("swingType", "PlayerSwingType"),
Game::Doc::Req("enable", "bool",
"True if changes in range for the swing type should dispatch "
"PLAYER_SWING_RANGE_UPDATE. False if the swing type no longer "
"needs the event."),
};
const Game::Doc::Function kEnableDoc{
"Used with PLAYER_SWING_RANGE_UPDATE to be informed when the current target "
"enters or leaves range for a swing type.",
kEnableArgs};
const Game::Doc::Field kIsInRangeArgs[] = {
Game::Doc::Req("swingType", "PlayerSwingType"),
};
const Game::Doc::Field kIsInRangeRets[] = {
Game::Doc::Opt("isInRange", "bool", nullptr,
"Nil when no range check could be made, for example there is "
"no target, the target cannot be attacked, or no weapon is "
"equipped for the swing type. Nil must not be treated as out "
"of range."),
};
const Game::Doc::Function kIsInRangeDoc{
"Whether the current target is within range of the player's auto attack for "
"the given swing type. Auto attacks only ever apply to the current target, "
"so no other unit can be queried.",
kIsInRangeArgs, kIsInRangeRets};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_SwingTimer", "EnableRangeCheck",
&Script_EnableRangeCheck, &kEnableDoc);
Game::Lua::RegisterTableFunction("C_SwingTimer", "IsTargetWithinSwingRange",
&Script_IsTargetWithinSwingRange, &kIsInRangeDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Combat::SwingRange
+6 -19
View File
@@ -24,30 +24,25 @@
// reserved empty / ignored slot markers can never match a real GUID,
// and `Data::SetsContainingItem` rejects them outright.
//
// `setList` is the set names joined with `", "`, in set order, which is
// the shape a caller formats straight into a tooltip line. It is `nil`
// `setList` is 4.3.4's `GetSetList`: the set names in set order, joined by
// PLAYER_LIST_DELIMITER — the shape a caller formats straight into a
// tooltip line. It is `nil`
// rather than an empty string when the item is in no set, so the usual
// `if inSet then … setList … end` guard is all a caller needs.
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Data.h"
#include "equipmentset/Set.h"
#include "equipmentset/Text.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
#include <string>
#include <vector>
namespace Container::EquipmentSetInfo {
namespace {
// The separator between names in `setList`. One place, so the string a
// caller splits on is stated once.
constexpr const char *kSeparator = ", ";
uint64_t ItemGuid(const uint8_t *cgItem) {
auto *instance = Item::InstanceBlock(cgItem);
if (instance == nullptr)
@@ -71,23 +66,15 @@ int __fastcall Script_C_Container_GetContainerItemEquipmentSetInfo(void *L) {
const uint64_t guid =
ItemGuid(Item::Location::ResolveBag(L, bagID, slotIndex));
const std::vector<const EquipmentSet::Set *> sets =
EquipmentSet::Data::SetsContainingItem(guid);
const std::string setList = EquipmentSet::Text::SetList(guid);
Game::Lua::SetTop(L, 0);
if (sets.empty()) {
if (setList.empty()) {
Game::Lua::PushBool(L, false);
Game::Lua::PushNil(L);
return 2;
}
std::string setList;
for (size_t i = 0; i < sets.size(); ++i) {
if (i > 0)
setList += kSeparator;
setList += sets[i]->name;
}
Game::Lua::PushBool(L, true);
Game::Lua::PushString(L, setList.c_str());
return 2;
+39 -21
View File
@@ -41,10 +41,11 @@
// THE ORDERING IS OURS. Retail computes it in code we cannot read, and
// its categories (per-bag assignment flags, expansion filters) have no
// counterpart here, so this is a defensible order rather than a
// reproduction of Blizzard's: hearthstone, gear by descending quality,
// consumables, reagents, trade goods, quest items, everything else by
// quality, junk last. Within a category, by item class, subclass, name,
// then fuller stacks first.
// reproduction of Blizzard's: hearthstone, gear, consumables, reagents,
// trade goods, quest items, everything else by quality, junk last. Within
// a category, by item class, subclass, name, then fuller stacks first.
// Gear adds two keys so like pieces sit together: the equip slot right
// after the class (kSlotOrder), and quality, best first, before the name.
//
// An item whose data has NOT arrived is pinned: left where it is, with
// its slot withheld from the destination pool so nothing else is planned
@@ -133,10 +134,7 @@ constexpr uint32_t kQualityEpic = 4;
// Primary key. Lower sorts nearer the first slot.
enum Category : uint8_t {
kCatHearthstone = 0,
kCatGearEpic,
kCatGearRare,
kCatGearUncommon,
kCatGearPlain,
kCatGear,
kCatConsumable,
kCatReagent,
kCatTradeGoods,
@@ -153,15 +151,8 @@ uint8_t CategoryFor(uint32_t itemID, uint32_t itemClass, uint32_t quality) {
return kCatHearthstone;
if (quality == kQualityPoor)
return kCatJunk;
if (itemClass == kClassWeapon || itemClass == kClassArmor) {
if (quality >= kQualityEpic)
return kCatGearEpic;
if (quality == kQualityRare)
return kCatGearRare;
if (quality == kQualityUncommon)
return kCatGearUncommon;
return kCatGearPlain;
}
if (itemClass == kClassWeapon || itemClass == kClassArmor)
return kCatGear;
if (itemClass == kClassConsumable)
return kCatConsumable;
if (itemClass == kClassReagent)
@@ -179,6 +170,26 @@ uint8_t CategoryFor(uint32_t itemID, uint32_t itemClass, uint32_t quality) {
return kCatOtherPlain;
}
// Gear's slot key: Item.dbc `m_inventoryType` (INVTYPE_*) in the order its
// pieces sort. Not the raw number, which does not follow the character
// pane: robe (20) belongs beside chest (5), and cloak (16) beside
// shoulder. Not the equipment slot either, which lumps shields, off-hand
// weapons and held items into one. The order is Baganator's (its
// Sorting/ItemFields.lua): weapons by hand, then ranged and ammo, then
// armor head to foot, then jewelry, shirt and tabard.
constexpr uint8_t kSlotOrder[] = {
17, 13, 21, 14, 23, 26, 22, 15, 25, 24, 27, 28, // weapons, ranged, relic
1, 3, 16, 5, 20, 9, 10, 6, 7, 8, // head .. feet
2, 11, 12, 4, 19, 18, 0, // jewelry, shirt, tabard, bag, none
};
uint8_t SlotRank(uint32_t inventoryType) {
for (uint8_t i = 0; i < sizeof kSlotOrder; ++i)
if (kSlotOrder[i] == inventoryType)
return i;
return static_cast<uint8_t>(sizeof kSlotOrder); // unknown: after all known
}
struct Entry {
const void *item; // CGItem — stable across the swap batch
int curBag, curSlot; // live position, updated as swaps fire
@@ -191,9 +202,10 @@ struct Entry {
int destBag, destSlot;
uint32_t itemID;
uint32_t family; // the item's own bag family (0 = fits anywhere)
uint32_t itemClass, subClass, count;
uint32_t itemClass, subClass, quality, count;
const char *name;
uint8_t category;
uint8_t slotRank; // SlotRank(m_inventoryType)
};
struct Cell {
@@ -230,8 +242,13 @@ bool Precedes(const Entry &a, const Entry &b) {
return a.category < b.category;
if (a.itemClass != b.itemClass)
return a.itemClass < b.itemClass;
const bool gear = a.category == kCatGear;
if (gear && a.slotRank != b.slotRank)
return a.slotRank < b.slotRank;
if (a.subClass != b.subClass)
return a.subClass < b.subClass;
if (gear && a.quality != b.quality)
return a.quality > b.quality; // best first
const int byName = CompareNames(a.name, b.name);
if (byName != 0)
return byName < 0;
@@ -587,12 +604,13 @@ void Place(const int *bags, int bagCount) {
e.family = Item::BagFamily::BitmaskForRecord(rec);
e.itemClass = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_CLASS);
e.subClass = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_SUBCLASS);
e.quality = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_QUALITY);
e.slotRank = SlotRank(
Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE));
e.count = StackCount(item);
e.name = *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_ITEMSTATS_NAME);
e.category = CategoryFor(
e.itemID, e.itemClass,
Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_QUALITY));
e.category = CategoryFor(e.itemID, e.itemClass, e.quality);
items.push_back(e);
}
}
+110 -152
View File
@@ -11,120 +11,99 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
//
// Restores Lua 5.0's stripped `coroutine.*` library to the in-game Lua
// state. WoW 1.12.1 ships with the C-level coroutine machinery linked
// in (`lua_resume`, `lua_yield`, `lua_newthread`, threads as a real
// type tag 8) but the `coroutine` global table was never registered.
// The five trampolines below mirror Lua 5.0's `lbaselib.c` shapes,
// cross-checked against 5.4.8's wired-up library via Ghidra (see the
// "Cross-binary reference" technique in CLAUDE.md).
// The `coroutine` library, as 4.3.4 ships it (stock Lua 5.1's lbaselib.c
// co_funcs, opened by luaopen_base in FrameScript_Initialize for the glue AND
// the world state). WoW 1.12.1 links the C-level machinery (`lua_resume`,
// `lua_yield`, `lua_newthread`, threads as type tag 8) but never registers
// the table, so the six functions below are 5.1's, built on the engine's own
// 5.0 primitives. Where this build's 5.0 differs from 5.1, the engine's own
// predicate is mirrored (see CoStatus / AuxResume).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Coroutine {
// Lua 5.0 result codes returned by `lua_resume`.
static constexpr int LUA_OK = 0;
static constexpr int LUA_YIELD = 1;
// Field offsets in `lua_State`, confirmed from `lua_resume`'s error
// cleanup path (`L->ci = L->base_ci; L->base = base_ci->base`):
// *(L+0x14) = *(L+0x28)
// *(L+0x0C) = **(L+0x28)
// lua_State / CallInfo / global_State fields — all single-consumer, local:
// L->top @ +0x08, L->l_G @ +0x10, L->ci @ +0x14, L->base_ci @ +0x28;
// CallInfo->state @ +0x08, CI_YIELD = 0x10 — the bit lua_yield
// (0x006F6860) ORs in and the resume body (0x006F67B0) tests;
// G->mainthread @ +0x50 — f_luaopen (0x006F6D70) stores the opening L.
static constexpr uintptr_t OFF_L_TOP = 0x08;
static constexpr uintptr_t OFF_L_G = 0x10;
static constexpr uintptr_t OFF_L_CI = 0x14;
static constexpr uintptr_t OFF_L_BASE_CI = 0x28;
static constexpr uintptr_t OFF_CI_STATE = 0x08;
static constexpr uint32_t CI_YIELD = 0x10;
static constexpr uintptr_t OFF_G_MAINTHREAD = 0x50;
// Reads `L->ci` (the current CallInfo). When greater than `L->base_ci`
// the coroutine's body function is mid-execution (paused at a yield);
// when equal, no Lua function is on this thread's call stack.
static void *LuaCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_CI);
}
static void *LuaBaseCI(void *L) {
return *reinterpret_cast<void **>(reinterpret_cast<uintptr_t>(L) + OFF_L_BASE_CI);
}
// This build's TValue: {tt @ +0, ctx @ +4, value @ +8}, 16 bytes.
static constexpr uintptr_t kTValueSize = 0x10;
// Coroutine status codes returned by `CoStatus` below — fed into the
// `kStatusNames` table for the `status` API. The dense 0..3 encoding
// also lets us label "unsuspended" states for resume's error path.
static constexpr int CO_RUNNING = 0;
static constexpr int CO_SUSPENDED = 1;
static constexpr int CO_NORMAL = 2;
static constexpr int CO_RUN = 0;
static constexpr int CO_SUS = 1;
static constexpr int CO_NOR = 2; // resumed another coroutine
static constexpr int CO_DEAD = 3;
static const char *const kStatusNames[] = {
"running",
"suspended",
"normal",
"dead",
};
static const char *const kStatusNames[] = {"running", "suspended", "normal", "dead"};
// 5.1's `"cannot resume %s coroutine"` with each status name, indexed the
// same way (never used for CO_SUS).
static const char *const kCannotResume[] = {
"cannot resume running coroutine", nullptr,
"cannot resume normal coroutine", "cannot resume dead coroutine"};
// Inner status classifier. Equivalent to Lua 5.0's `auxstatus` from
// lbaselib.c, but we can't rely on `L->status` here: this build's
// `lua_yield` doesn't write status (it sets a CI state flag bit
// `0x10` instead), so the canonical "ttype(status) == LUA_YIELD"
// check fails. We use the `ci > base_ci` signal instead — true iff
// a Lua function is currently paused on this thread's call stack.
//
// State map:
// co->ci > co->base_ci → SUSPENDED (yielded; body paused)
// co->ci == co->base_ci, top==0 → DEAD (returned, all consumed)
// co->ci == co->base_ci, top>0 → SUSPENDED (initial: fn pushed,
// never resumed)
// After AuxResume's XMove cycle, both "returned" and "yielded" leave
// co's stack empty; the ci comparison is what distinguishes them.
// "Normal" (coroutine that resumed a deeper coroutine — appears as
// the parent in the call chain) is rare and would require walking
// the global thread list; we collapse it into "suspended".
// 5.1's costatus, on this build's 5.0 state. 5.1 asks `lua_status(co) ==
// LUA_YIELD`; this build's lua_yield never writes L->status, it sets CI_YIELD
// on the current CallInfo, so ask exactly what the engine's resume body asks:
// ci == base_ci, empty stack -> dead (returned, or errored and unwound)
// ci == base_ci, function set -> suspended (created, never resumed)
// ci > base_ci, CI_YIELD -> suspended (paused in yield)
// ci > base_ci, no CI_YIELD -> normal (mid-resume of another coroutine)
static bool HasFrames(void *co) {
return Game::Read<const void *>(co, OFF_L_CI) !=
Game::Read<const void *>(co, OFF_L_BASE_CI);
}
static bool IsYielded(void *co) {
return HasFrames(co) &&
(Game::Read<uint32_t>(Game::Read<const void *>(co, OFF_L_CI), OFF_CI_STATE) &
CI_YIELD) != 0;
}
static int CoStatus(void *L, void *co) {
if (co == L)
return CO_RUNNING;
if (LuaCI(co) > LuaBaseCI(co))
return CO_SUSPENDED;
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUSPENDED;
return CO_RUN;
if (!HasFrames(co))
return Game::Lua::GetTop(co) == 0 ? CO_DEAD : CO_SUS;
return IsYielded(co) ? CO_SUS : CO_NOR;
}
// Common back-end for resume / wrap. Returns nres ≥ 0 on success
// (with values already xmove'd onto L), or -1 on error (with the
// error message left on L's stack).
// 5.1's auxresume. Returns the result count (moved onto L) or -1 with the
// error message on top of L.
//
// WoW's modified Lua doesn't follow the standard `lua_resume` status
// contract: it returns `0` for normal completion and `1` for both
// "yielded" AND "errored mid-execution". We disambiguate the two
// `s==1` cases by checking the call-stack depth — a yielded
// coroutine still has its body paused (`ci > base_ci`), while an
// errored coroutine has been unwound back to `base_ci`. Errors hit
// from the "dead/already-resumed" early-out (e.g. the inner
// `cannot resume dead coroutine` path) also land in `s != 0` with
// `ci == base_ci`, so the same branch covers them.
static int AuxResume(void *L, void *co, int nargs) {
const int st = CoStatus(L, co);
if (st != CO_SUSPENDED) {
Game::Lua::PushString(L, st == CO_DEAD
? "cannot resume dead coroutine"
: "cannot resume non-suspended coroutine");
// This build's lua_resume returns 1 for BOTH a yield and a caught error (5.1
// separates them), so the yield is recognized by IsYielded: a yielded body
// is still on co's call stack with CI_YIELD set, an errored one has been
// unwound to base_ci (leaving the message on the stack, which CoStatus
// alone would mistake for a never-resumed coroutine).
static int AuxResume(void *L, void *co, int narg) {
const int status = CoStatus(L, co);
if (Game::Lua::CheckStack(co, narg) == 0)
Game::Lua::Error(L, "too many arguments to resume");
if (status != CO_SUS) {
Game::Lua::PushString(L, kCannotResume[status]);
return -1;
}
if (Game::Lua::CheckStack(co, nargs) == 0) {
Game::Lua::PushString(L, "too many arguments to resume coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
Game::Lua::XMove(L, co, narg);
const int s = Game::Lua::Resume(co, narg);
if (s == 0 || IsYielded(co)) {
const int nres = Game::Lua::GetTop(co);
if (Game::Lua::CheckStack(L, nres) == 0) {
Game::Lua::PushString(L, "too many results to resume coroutine");
return -1;
}
if (Game::Lua::CheckStack(L, nres + 1) == 0)
Game::Lua::Error(L, "too many results to resume");
Game::Lua::XMove(co, L, nres);
return nres;
}
// Error path: engine left a single error message on co's stack.
Game::Lua::XMove(co, L, 1);
Game::Lua::XMove(co, L, 1); // the error message
return -1;
}
@@ -133,14 +112,10 @@ static int __fastcall Script_Coroutine_Create(void *L) {
Game::Lua::ArgError(L, 1, "Lua function expected");
return 0; // unreachable
}
Game::Lua::NewThread(L); // [arg1, NL]
Game::Lua::NewThread(L); // [arg1, NL]
void *NL = Game::Lua::ToThread(L, -1);
if (NL == nullptr) {
Game::Lua::Error(L, "coroutine.create: failed to allocate thread");
return 0; // unreachable
}
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
Game::Lua::PushValue(L, 1); // [arg1, NL, fn]
Game::Lua::XMove(L, NL, 1); // [arg1, NL]; NL has fn
return 1;
}
@@ -149,106 +124,89 @@ static int __fastcall Script_Coroutine_Yield(void *L) {
}
static int __fastcall Script_Coroutine_Resume(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
void *co = Game::Lua::Type(L, 1) == Game::Lua::TYPE_THREAD
? Game::Lua::ToThread(L, 1)
: nullptr;
if (co == nullptr) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
const int nargs = Game::Lua::GetTop(L) - 1;
const int r = AuxResume(L, co, nargs);
const int r = AuxResume(L, co, Game::Lua::GetTop(L) - 1);
if (r < 0) {
// Error message is on top of L's stack. Push `false` and
// shuffle it below the message: [..., msg] -> [..., false, msg].
Game::Lua::PushBool(L, false);
Game::Lua::Insert(L, -2);
return 2;
return 2; // false + error message
}
// Success: `r` values on top of L's stack. Push `true` and slide
// it into position so returns are [..., true, v1, v2, ..., vr].
Game::Lua::PushBool(L, true);
Game::Lua::Insert(L, -(r + 1));
return r + 1;
return r + 1; // true + resume's returns
}
static int __fastcall Script_Coroutine_Status(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_THREAD) {
void *co = Game::Lua::Type(L, 1) == Game::Lua::TYPE_THREAD
? Game::Lua::ToThread(L, 1)
: nullptr;
if (co == nullptr) {
Game::Lua::ArgError(L, 1, "coroutine expected");
return 0; // unreachable
}
void *co = Game::Lua::ToThread(L, 1);
Game::Lua::PushString(L, kStatusNames[CoStatus(L, co)]);
return 1;
}
// `coroutine.wrap`'s returned closure. The captured coroutine lives
// in upvalue 1; each invocation forwards args via resume and either
// returns the yielded/returned values directly (no { ok, ... }
// wrapping like resume does) or re-raises on error.
// 5.1's auxwrap: a string error gets the caller's position prefixed
// (luaL_where(L, 1)) — which is what LUA_ERROR, really luaL_error, does —
// and any other error object is re-raised untouched with the raw lua_error.
static int __fastcall WrapAux(void *L) {
void *co = Game::Lua::ToThread(L, Game::Lua::UpvalueIndex(1));
if (co == nullptr) {
Game::Lua::Error(L, "coroutine.wrap: missing thread upvalue");
return 0; // unreachable
}
const int nargs = Game::Lua::GetTop(L);
const int r = AuxResume(L, co, nargs);
const int r = AuxResume(L, co, Game::Lua::GetTop(L));
if (r < 0) {
// The error message is at -1. `lua_error`'s 1.12 wrapper takes
// a printf-format string + va args; we pass `%s` so any `%`
// characters in the message are treated as literal.
const char *msg = Game::Lua::ToString(L, -1);
Game::Lua::Error(L, "%s", msg != nullptr ? msg : "coroutine error");
if (Game::Lua::IsString(L, -1))
Game::Lua::Error(L, "%s", Game::Lua::ToString(L, -1));
using RawError_t = int(__fastcall *)(void *L);
reinterpret_cast<RawError_t>(Offsets::LUA_RAW_ERROR)(L);
return 0; // unreachable
}
return r;
}
static int __fastcall Script_Coroutine_Wrap(void *L) {
// Reuse cocreate to build the thread (validates arg, allocates,
// moves fn into the new state). Leaves the thread on top of L.
Script_Coroutine_Create(L);
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures thread as upvalue 1
Script_Coroutine_Create(L); // leaves the thread on top
Game::Lua::PushCClosure(L, &WrapAux, 1); // captures it as upvalue 1
return 1;
}
// `coroutine.running()` — Lua 5.1 addition: the running coroutine, or
// nil when called from the main thread. The 5.0 C API has no
// `lua_pushthread` (also a 5.1 addition), so the thread TValue is
// written to the stack raw: this build's TValue is 16 bytes with
// {tt @ +0, value @ +8} (see LuaSyntax::StringMethods for the layout
// derivation), tag 8 = thread, and the value is the running `lua_State`
// itself — exactly what `lua_newthread` stores and what LUA_TO_POINTER
// reads back at tvalue[2]. No stack-overflow check needed: every C
// function is guaranteed LUA_MINSTACK free slots and this pushes one.
// The running thread is trivially GC-alive, so no anchoring either.
// Main-thread detection: module callbacks run on the state's main
// thread, so the L captured at registration IS the main thread;
// coroutine bodies run on their own lua_State.
static void *g_mainL = nullptr;
static constexpr uintptr_t OFF_L_TOP = 0x8; // L->top (push helpers' [ecx+8])
// 5.1's corunning: the running coroutine, or nil on the main thread. 5.0's
// C API has no lua_pushthread, so the thread is pushed the way the engine's
// own pushes write a TValue: {tt = thread, ctx = the current exec context,
// value = L}, then top += one slot. Every C function has LUA_MINSTACK free
// slots, and the running thread is trivially alive.
static int __fastcall Script_Coroutine_Running(void *L) {
if (L == g_mainL) {
const void *G = Game::Read<const void *>(L, OFF_L_G);
if (L == Game::Read<const void *>(G, OFF_G_MAINTHREAD)) {
Game::Lua::PushNil(L);
return 1;
}
uint8_t *top = Game::Read<uint8_t *>(L, OFF_L_TOP);
*reinterpret_cast<int *>(top) = Game::Lua::TYPE_THREAD;
*reinterpret_cast<void **>(top + 8) = L;
Game::Ref<uint8_t *>(L, OFF_L_TOP) = top + 16;
Game::Ref<int>(top, 0) = Game::Lua::TYPE_THREAD;
Game::Ref<int>(top, 4) = Game::Read<int>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
Game::Ref<void *>(top, 8) = L;
Game::Ref<uint8_t *>(L, OFF_L_TOP) = top + kTValueSize;
return 1;
}
static void RegisterLuaFunctions() {
g_mainL = Game::Lua::State(); // the state's main thread (see Running)
// Both states: 4.3.4 opens the library from FrameScript_Initialize, which
// runs for the glue state (Client.cpp) and the world (GameUI.cpp).
static void RegisterFunctions() {
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
Game::Lua::RegisterTableFunction("coroutine", "running", &Script_Coroutine_Running);
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
Game::Lua::RegisterTableFunction("coroutine", "running", &Script_Coroutine_Running);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Game::ModuleAutoRegister _autoreg{&RegisterFunctions};
static const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFunctions};
} // namespace Coroutine
+58 -49
View File
@@ -242,56 +242,15 @@ int __fastcall Script_GetFactionInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (raceID <= 0)
return 0;
const char *english = nullptr;
const char *localized = nullptr;
if (!FactionGroupForRace(raceID, &english, &localized))
return 0; // unknown race or no named group -> nil
const uint8_t *crRec = DBC::Record(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID));
if (crRec == nullptr)
return 0; // unknown race
const int ftId = Game::Read<int>(
crRec, Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
if (ftId <= 0)
return 0;
const uint8_t *ftRec = DBC::Record(Offsets::VAR_FACTIONTEMPLATE_RECORDS,
Offsets::VAR_FACTIONTEMPLATE_COUNT,
static_cast<uint32_t>(ftId));
if (ftRec == nullptr)
return 0;
const uint32_t mask = Game::Read<uint32_t>(
ftRec, Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
// FactionGroup is stored contiguously (records-base + i*stride), not as a
// pointer array — iterate by hand rather than via DBC::Record.
auto *fgBase = Game::Read<const uint8_t *>(
Offsets::VAR_FACTIONGROUP_RECORDS);
const int fgCount =
Game::Read<int>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
if (fgBase == nullptr)
return 0;
for (int i = 0; i < fgCount; ++i) {
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
const int bit = Game::Read<int>(
row, Offsets::OFF_FACTIONGROUP_BIT);
if ((mask & (1u << (bit & 0x1F))) == 0)
continue;
const char *loc = Game::Read<const char *>(
row, Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
if (loc == nullptr || *loc == '\0')
continue; // "Player" / "Monster" have empty localized names
const char *eng = Game::Read<const char *>(
row, Offsets::OFF_FACTIONGROUP_ENGLISH);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", loc);
Game::Lua::SetFieldString(L, "groupTag", eng);
return 1;
}
return 0; // no named group -> nil
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", localized);
Game::Lua::SetFieldString(L, "groupTag", english);
return 1;
}
// `C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)` — CreatureTypeInfo
@@ -368,6 +327,56 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
bool FactionGroupForRace(int raceID, const char **english, const char **localized) {
if (raceID <= 0)
return false;
const uint8_t *crRec = DBC::Record(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID));
if (crRec == nullptr)
return false; // unknown race
const int ftId = Game::Read<int>(
crRec, Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
if (ftId <= 0)
return false;
const uint8_t *ftRec = DBC::Record(Offsets::VAR_FACTIONTEMPLATE_RECORDS,
Offsets::VAR_FACTIONTEMPLATE_COUNT,
static_cast<uint32_t>(ftId));
if (ftRec == nullptr)
return false;
const uint32_t mask = Game::Read<uint32_t>(
ftRec, Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
// FactionGroup is stored contiguously (records-base + i*stride), not as a
// pointer array — iterate by hand rather than via DBC::Record.
auto *fgBase = Game::Read<const uint8_t *>(
Offsets::VAR_FACTIONGROUP_RECORDS);
const int fgCount =
Game::Read<int>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
if (fgBase == nullptr)
return false;
for (int i = 0; i < fgCount; ++i) {
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
const int bit = Game::Read<int>(
row, Offsets::OFF_FACTIONGROUP_BIT);
if ((mask & (1u << (bit & 0x1F))) == 0)
continue;
const char *loc = Game::Read<const char *>(
row, Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
if (loc == nullptr || *loc == '\0')
continue; // "Player" / "Monster" have empty localized names
*english = Game::Read<const char *>(
row, Offsets::OFF_FACTIONGROUP_ENGLISH);
*localized = loc;
return true;
}
return false; // no named group
}
// Public accessor for Model::DisplayInfo's SetCreature — the same peek
// GetCreatureInfoByID uses, reduced to the display-ID field. 0 = uncached.
uint32_t DisplayID(uint32_t creatureID) {
+12
View File
@@ -24,4 +24,16 @@ namespace Creature::Info {
// entry-ID → model mapping vanilla has no DBC for.
uint32_t DisplayID(uint32_t creatureID);
// The faction group a player race belongs to, resolved the way
// `Script_UnitFactionGroup` does it for a player whose object isn't in the
// world (its name-cache branch, which reads the race): ChrRaces -> FactionTemplate
// group mask -> the first FactionGroup row whose bit is set and whose
// localized name is non-empty (skipping "Player" / "Monster"). On success
// `english` is the locale-independent tag ("Alliance") and `localized` the
// display name; both point into DBC string storage. False for an unknown or
// non-positive race, or a race with no named group. Backs
// `C_CreatureInfo.GetFactionInfo` and `UnitFactionGroup("player")` before the
// player object spawns.
bool FactionGroupForRace(int raceID, const char **english, const char **localized);
} // namespace Creature::Info
+21 -25
View File
@@ -15,10 +15,11 @@
// money string (e.g. "12<gold> 34<silver> 56<copper>"), a C++ backport of the
// retail C function.
//
// Behaviour derived from 3.3.5's `Script_GetCoinTextureString` (FUN_00510d00 →
// formatter FUN_007e7e10): split the copper amount into gold / silver / copper,
// emit only the non-zero denominations in that order, space-separated; a zero
// amount shows "0" copper. Each denomination is built from a localized
// Behaviour mirrors 3.3.5's `Script_GetCoinTextureString` (FUN_00510d00 →
// formatter FUN_007e7e10, `GetCurrencyTextureString` in the decomp): the icon
// height defaults to 14 and is taken as given otherwise; split the copper
// amount into gold / silver / copper, emit only the non-zero denominations in
// that order, separated by one space; a zero amount shows "0" copper. Each denomination is built from a localized
// GlobalString — `GOLD_AMOUNT_TEXTURE` / `SILVER_AMOUNT_TEXTURE` /
// `COPPER_AMOUNT_TEXTURE` — exactly as the engine does (its formatter
// `FUN_0076f070` is a C-format over the same GlobalString). Those strings don't
@@ -43,38 +44,36 @@ namespace Currency::CoinText {
namespace {
// Icon size. Retail defaults fontHeight to 0 = "use the display font's height",
// and the inline-texture renderer now honours 0 (it resolves the coin to the
// line's font height at draw time), so coins match the surrounding text instead
// of a fixed size. Callers may still pass an explicit height.
constexpr int kDefaultHeight = 0;
// Icon size when the caller passes none — 3.3.5's `int32_t height = 14`. The
// inline-texture renderer scales a written size by the line's font against a
// 14-unit reference (4.3.4's ParseEmbeddedTexture), so this follows the text.
constexpr int kDefaultHeight = 14;
// Fallbacks byte-identical to the backported GlobalStrings, used only if the
// locale layer somehow isn't loaded when this is first called.
// Format: count |T sheet : height=0 : width=0 : offsetX=0 : offsetY=0 : texW=4 :
// Format: count |T sheet : height : width : offsetX=2 : offsetY=0 : texW=4 :
// texH=1 : left : right : top=0 : bottom=1 |t (left:right selects the coin cell).
// height/width 0 = size to the font. offsetX stays 0: the inline-texture
// renderer's font-relative pad spaces the coin from its digits (the historical
// "coin sits on the digit" bug was the emitter's lazy pen read-back, fixed in
// the DLL). Never grow a per-coin offset here.
// offsetX 2 is retail's (3.3.5 / 4.3.4 GlobalStrings: `UI-GoldIcon:%d:%d:2:0`):
// it shifts only the drawn coin, leaving room against the digits before it.
constexpr const char *kGoldFallback =
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:0:1:0:1|t";
constexpr const char *kSilverFallback =
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:1:2:0:1|t";
// The three coin arts occupy IDENTICAL pixels within their sheet cells (x 0-14,
// y 1-14 of 16 — confirmed by decoding the DXT3 alpha of the exported
// UI-MoneyIcons.blp); only the left:right cell select differs (gold 0:1,
// silver 1:2, copper 2:3).
constexpr const char *kCopperFallback =
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:2:0:4:1:2:3:0:1|t";
// Formats one denomination from its localized GlobalString and appends it to
// `out`. Reads `_G[globalName]` the way retail reads the coin GlobalStrings; the
// format consumes (count, iconHeight, iconWidth). No separator between
// denominations — the renderer's symmetric per-icon pad already spaces each coin
// from the surrounding digits, so a space here would double the gap.
// format consumes (count, iconHeight, iconWidth). Denominations are separated
// by one space, as the engine's formatter packs them.
void AppendDenom(void *L, std::string &out, const char *globalName, const char *fallback,
int count, int height) {
if (!out.empty())
out += ' ';
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedString(L, globalName, fallback); // [.., fmt]
const char *fmt = Game::Lua::ToString(L, -1);
@@ -105,11 +104,8 @@ int __fastcall Script_GetCoinTextureString(void *L) {
amount = kMaxCopper;
int height = kDefaultHeight;
if (Game::Lua::IsNumber(L, 2)) {
const int h = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (h > 0)
height = h;
}
if (Game::Lua::IsNumber(L, 2))
height = static_cast<int>(Game::Lua::ToNumber(L, 2));
const int gold = static_cast<int>(amount / 10000);
const int silver = static_cast<int>((amount % 10000) / 100);
+23 -2
View File
@@ -49,6 +49,8 @@
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Action.h"
#include "equipmentset/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
@@ -116,6 +118,7 @@ constexpr int UI_PET_ACTION = 4;
constexpr int UI_MERCHANT = 5;
constexpr int UI_MACRO = 7;
constexpr int UI_PET = 9;
constexpr int UI_EQUIPMENT_SET = 12;
constexpr uint32_t GUID_TYPE_FILTER_ITEM = 0x2;
@@ -275,6 +278,18 @@ int PushMerchantCursor(void *L) {
return 2;
}
// 3.3.5 `Script_GetCursorInfo` (`FUN_00515200`) case 0xD: the type and
// the set's name.
int PushEquipmentSetCursor(void *L) {
const EquipmentSet::Set *set =
EquipmentSet::Data::FindByID(EquipmentSet::Action::CursorSetID());
if (set == nullptr)
return 0;
Game::Lua::PushString(L, "equipmentset");
Game::Lua::PushString(L, set->name.c_str());
return 2;
}
int __fastcall Script_GetCursorInfo(void *L) {
const uint32_t type = ReadVar(Offsets::VAR_CURSOR_TYPE);
if (type >= TYPE_GENERIC_ITEM_LO && type <= TYPE_GENERIC_ITEM_HI)
@@ -286,6 +301,8 @@ int __fastcall Script_GetCursorInfo(void *L) {
case TYPE_MONEY: return PushMoneyCursor(L);
case TYPE_SPELL: return PushSpellCursor(L);
case TYPE_MACRO: return PushMacroCursor(L);
case Offsets::CURSOR_TYPE_EQUIPMENT_SET:
return PushEquipmentSetCursor(L);
case TYPE_EMPTY:
default: return 0;
}
@@ -341,6 +358,8 @@ State Current() {
return {UI_PET_ACTION, 0};
case TYPE_STABLE_PET:
return {UI_PET, 0};
case Offsets::CURSOR_TYPE_EQUIPMENT_SET:
return {UI_EQUIPMENT_SET, EquipmentSet::Action::CursorSetID()};
case TYPE_EMPTY:
default:
break;
@@ -355,13 +374,15 @@ Raw ReadRaw() {
ReadVar(Offsets::VAR_CURSOR_GENERIC_SLOT),
ReadVar(Offsets::VAR_CURSOR_MONEY_COPPER),
ReadVar(Offsets::VAR_CURSOR_SPELL_ID),
ReadVar(Offsets::VAR_CURSOR_MACRO_ID)};
ReadVar(Offsets::VAR_CURSOR_MACRO_ID),
EquipmentSet::Action::CursorSetID()};
}
bool Same(const Raw &a, const Raw &b) {
return a.type == b.type && a.guidLo == b.guidLo && a.guidHi == b.guidHi &&
a.genericSlot == b.genericSlot && a.money == b.money &&
a.spellID == b.spellID && a.macroID == b.macroID;
a.spellID == b.spellID && a.macroID == b.macroID &&
a.equipmentSetID == b.equipmentSetID;
}
} // namespace Cursor::Info
+3 -1
View File
@@ -26,7 +26,8 @@ struct State {
// `PetAction` / `Pet` here while `GetCursorInfo` stays nil.
int uiType;
// The held content's identifying number: itemID, spellID, 1-based
// macro slot, 1-based merchant slot, or a money amount in copper. 0
// macro slot, 1-based merchant slot, equipment setID, or a money
// amount in copper. 0
// for an empty cursor and for the two pet cursors, whose payload the
// engine does not keep anywhere readable.
uint32_t virtualID;
@@ -52,6 +53,7 @@ struct Raw {
uint32_t money;
uint32_t spellID;
uint32_t macroID; // the stored macroID, not the slot — cheaper here
uint32_t equipmentSetID;
};
Raw ReadRaw();
+2 -2
View File
@@ -91,11 +91,11 @@ int GetInt(Handle cvar, int fallback) {
return std::atoi(value);
}
void SetString(Handle cvar, const char *value) {
void SetString(Handle cvar, const char *value, bool persist) {
if (cvar == nullptr || value == nullptr)
return;
auto set = reinterpret_cast<SetCVarValue_t>(Offsets::FUN_SET_CVAR_VALUE);
set(cvar, value, 1, 0, 0, 1);
set(cvar, value, 1, 0, 0, persist ? 1 : 0);
}
void SetInt(Handle cvar, int value) {
+6 -1
View File
@@ -80,7 +80,12 @@ int GetInt(Handle cvar, int fallback = 0);
// Set the cvar's value (fires its change callback). No-op if `cvar` is
// null. `value` is copied by the engine into the cvar's inline buffer.
void SetString(Handle cvar, const char *value);
//
// `persist` is the setter's last argument, the only thing that marks the
// config dirty (FUN_0063E0B0 sets VAR_CVAR_CONFIG_DIRTY only when it is
// nonzero). False applies the value for this session without asking the
// config writer to run; `CVar::Temp` relies on that.
void SetString(Handle cvar, const char *value, bool persist = true);
// Convenience: set from an integer.
//
+217
View File
@@ -0,0 +1,217 @@
// 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_CVar.SetTempCVar(name, value)` / `C_CVar.RemoveTempCVar(name)` — change a
// cvar for this session without the change reaching Config.wtf, then put the
// saved value back. Upstream's gamepad mode flips two dozen camera and
// soft-target cvars this way so the user's own settings survive it.
//
// Two engine facts make this nearly free:
//
// 1. The setter only marks the config dirty when its last argument is
// nonzero (FUN_0063E0B0: `if (a6) VAR_CVAR_CONFIG_DIRTY = 1`). A temp set
// passes 0, so it applies the value and fires the change callback exactly
// as SetCVar does, but never asks the config writer to run.
// 2. The config writer (FUN_CVAR_CONFIG_WRITE) reads each cvar's live value
// pointer and nothing else of ours. When something ELSE dirtied the config
// and the writer does run, a co-hook points each temp cvar's value back at
// its saved string for the duration of the write and restores it after. So
// the file gets exactly the line the engine would have written had the
// temp value never been set — including no line at all when the saved
// value is the default.
//
// A plain SetCVar on a temp cvar wins: the entry notices the live value is no
// longer the one it set and drops itself, both in the writer hook and in
// RemoveTempCVar, so the user's own set is saved and never rolled back.
//
// Validation mirrors Script_SetCVar (FUN_00488C10) word for word: a usage
// error, `Couldn't find CVar named '%s'`, and `"%s" is read only`. The lookup
// is FUN_FIND_CVAR, so a cvar Lua cannot see is refused as unknown, which is
// the contract's "valid and public". Nothing here is secure, the remaining
// documented requirement. A nil value becomes "", as it does for SetCVar.
#include "Factory.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
namespace CVar::Temp {
namespace {
struct Entry {
CVar::Factory::Handle cvar;
std::string saved; // the value before the first temp set
std::string temp; // the value that landed (after the change callback)
};
std::vector<Entry> g_entries;
Entry *FindEntry(CVar::Factory::Handle cvar) {
for (Entry &e : g_entries)
if (e.cvar == cvar)
return &e;
return nullptr;
}
void EraseEntry(const Entry *entry) {
g_entries.erase(g_entries.begin() + (entry - g_entries.data()));
}
const char *LiveValue(CVar::Factory::Handle cvar) {
const char *value = CVar::Factory::GetString(cvar);
return value != nullptr ? value : "";
}
// True while the live value is still the one SetTempCVar put there.
bool StillTemp(const Entry &e) {
return e.temp == LiveValue(e.cvar);
}
const char **ValueSlot(CVar::Factory::Handle cvar) {
return reinterpret_cast<const char **>(static_cast<uint8_t *>(cvar) +
Offsets::OFF_CVAR_VALUE_STR);
}
// Script_SetCVar's own lookup and checks, with its error text.
CVar::Factory::Handle Resolve(void *L, const char *usage) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "%s", usage);
return nullptr;
}
const char *name = Game::Lua::ToString(L, 1);
CVar::Factory::Handle cvar = CVar::Factory::Find(name);
if (cvar == nullptr) {
Game::Lua::Error(L, "Couldn't find CVar named '%s'", name);
return nullptr;
}
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
static_cast<const uint8_t *>(cvar) + Offsets::OFF_CVAR_FLAGS);
if ((flags & Offsets::CVAR_FLAG_READ_ONLY) != 0) {
Game::Lua::Error(L, "\"%s\" is read only", name);
return nullptr;
}
return cvar;
}
int __fastcall Script_SetTempCVar(void *L) {
CVar::Factory::Handle cvar =
Resolve(L, "Usage: C_CVar.SetTempCVar(\"cvar\", value)");
if (cvar == nullptr)
return 0;
const char *value = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "";
Entry *entry = FindEntry(cvar);
if (entry != nullptr && !StillTemp(*entry)) {
// A plain SetCVar replaced the temp value; that is the saved value now.
entry->saved = LiveValue(cvar);
} else if (entry == nullptr) {
g_entries.push_back({cvar, LiveValue(cvar), {}});
entry = &g_entries.back();
}
CVar::Factory::SetString(cvar, value, /*persist*/ false);
// Record what landed, not what was asked: a change callback may clamp.
// Re-find rather than reuse `entry`, since the callback runs Lua.
if (Entry *landed = FindEntry(cvar))
landed->temp = LiveValue(cvar);
return 0;
}
int __fastcall Script_RemoveTempCVar(void *L) {
CVar::Factory::Handle cvar =
Resolve(L, "Usage: C_CVar.RemoveTempCVar(\"cvar\")");
if (cvar == nullptr)
return 0;
Entry *entry = FindEntry(cvar);
if (entry == nullptr)
return 0;
if (StillTemp(*entry)) {
// Copy first: the setter's change callback could, in principle, reach
// back in here and move the vector.
const std::string saved = entry->saved;
EraseEntry(entry);
CVar::Factory::SetString(cvar, saved.c_str(), /*persist*/ false);
} else {
EraseEntry(entry);
}
return 0;
}
// --- Config writer co-hook ------------------------------------------------
using ConfigWrite_t = int(__cdecl *)();
ConfigWrite_t s_configWrite_o = nullptr;
int __cdecl ConfigWrite_h() {
// Only a dirty config gets written; skip the swap work otherwise.
if (g_entries.empty() ||
*reinterpret_cast<const uint8_t *>(Offsets::VAR_CVAR_CONFIG_DIRTY) == 0)
return s_configWrite_o();
for (size_t i = g_entries.size(); i-- > 0;)
if (!StillTemp(g_entries[i]))
g_entries.erase(g_entries.begin() + i);
std::vector<const char *> live(g_entries.size());
for (size_t i = 0; i < g_entries.size(); ++i) {
const char **slot = ValueSlot(g_entries[i].cvar);
live[i] = *slot;
*slot = g_entries[i].saved.c_str();
}
const int result = s_configWrite_o();
for (size_t i = 0; i < g_entries.size(); ++i)
*ValueSlot(g_entries[i].cvar) = live[i];
return result;
}
const Game::HookAutoRegister _hookConfigWrite{
Offsets::FUN_CVAR_CONFIG_WRITE,
reinterpret_cast<void *>(&ConfigWrite_h),
reinterpret_cast<void **>(&s_configWrite_o)};
// --- Registration ---------------------------------------------------------
const Game::Doc::Field kSetArgs[] = {
Game::Doc::Req("name", "cstring"),
Game::Doc::Opt("value", "cstring"),
};
const Game::Doc::Function kSetTempCVar{
"Sets a console variable for this session only; the change is not saved.",
kSetArgs, {}};
const Game::Doc::Field kRemoveArgs[] = {
Game::Doc::Req("name", "cstring"),
};
const Game::Doc::Function kRemoveTempCVar{
"Restores the value a console variable had before SetTempCVar.",
kRemoveArgs, {}};
void Register() {
Game::Lua::RegisterTableFunction("C_CVar", "SetTempCVar", &Script_SetTempCVar,
&kSetTempCVar);
Game::Lua::RegisterTableFunction("C_CVar", "RemoveTempCVar", &Script_RemoveTempCVar,
&kRemoveTempCVar);
}
// Cvar storage is process-global, so the same surface works pre-login.
const Game::ModuleAutoRegister _autoreg{&Register};
const Game::GlueModuleAutoRegister _glueAutoreg{&Register};
} // namespace
} // namespace CVar::Temp
+84
View File
@@ -0,0 +1,84 @@
// 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/>.
// `GetCVar(name)` / `GetCVarDefault(name)` stop raising for an unknown cvar,
// as later clients do.
//
// Vanilla's Script_GetCVar (0x00488BA0) and Script_GetCVarDefault
// (0x00488CF0) share one shape: lua_isstring(1) -> lua_tostring(1) ->
// FUN_FIND_CVAR -> on NULL raise "Couldn't find CVar named '<name>'". Later
// clients return instead, and ported code relies on it as a feature probe:
// LibCamera-1.0 opens with `if not GetCVar("cameraYawE") then return end`,
// which on vanilla throws at file load instead of returning.
//
// Both return NO values, not nil - 1.15.9 `/dump GetCVar("asd")` and
// `/dump C_CVar.GetCVarDefault("asd")` each print "empty result" - so the
// hooks return 0.
//
// Co-hooks: when the argument is a string that FUN_FIND_CVAR - the same
// filtering lookup both engine functions and C_CVar.DoesCVarExist use - does
// not find, skip the engine function. Every other call, including a
// non-string argument, runs the engine function unchanged, so known cvars
// read exactly as before. The glue state registers the same functions
// (cvar/Glue.cpp), so both states get the same behavior.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace CVar::Unknown {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
using FindCVar_t = const uint8_t *(__fastcall *)(const char *name);
ScriptFn_t g_origGetCVar = nullptr;
ScriptFn_t g_origGetCVarDefault = nullptr;
// True when argument 1 is a string naming no cvar Lua can see - the exact
// case the engine functions raise for.
bool IsUnknownName(void *L) {
if (!Game::Lua::IsString(L, 1))
return false;
const char *name = Game::Lua::ToString(L, 1);
return name != nullptr &&
reinterpret_cast<FindCVar_t>(Offsets::FUN_FIND_CVAR)(name) == nullptr;
}
int __fastcall GetCVar_h(void *L) {
if (IsUnknownName(L))
return 0;
return g_origGetCVar(L);
}
int __fastcall GetCVarDefault_h(void *L) {
if (IsUnknownName(L))
return 0;
return g_origGetCVarDefault(L);
}
const Game::HookAutoRegister _getCVarHook{
Offsets::FUN_SCRIPT_GET_CVAR,
reinterpret_cast<void *>(&GetCVar_h),
reinterpret_cast<void **>(&g_origGetCVar)};
const Game::HookAutoRegister _getCVarDefaultHook{
Offsets::FUN_SCRIPT_GET_CVAR_DEFAULT,
reinterpret_cast<void *>(&GetCVarDefault_h),
reinterpret_cast<void **>(&g_origGetCVarDefault)};
} // namespace
} // namespace CVar::Unknown
+162
View File
@@ -0,0 +1,162 @@
// 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/>.
//
// Deterministic reproducer for the notorious 1.12.1 `GetPoint` crash
// (ERROR #132, ACCESS_VIOLATION at 0x007A2452). Debug-only.
//
// The crash: `Script_GetPoint` (Region method registry idx 15, VA
// 0x007A2340) walks a frame's anchor nodes at `object + 0x28` (9 slots of
// 4 bytes). Each populated slot is a 0x14-byte node
// `{vtable=0x0081C44C, xOfs@+0x04, yOfs@+0x08, relativeTo@+0x0C, relPoint@+0x10}`.
// `relativeTo` is the anchor TARGET's CLayoutFrame-inner pointer
// (= target CFrame base + 0x24), stored RAW with no weak reference. When
// the target frame is freed the pointer is never cleared, and GetPoint does:
//
// 007a2429 CALL [EAX+0xC] ; node vtable[3] -> returns node.relativeTo
// 007a2430 ADD EAX,-0x24 ; EAX = relativeTo - 0x24 = target CFrame base
// 007a244f MOV EBX,[EBP-4] ; EBX = target CFrame base (DANGLING)
// 007a2452 MOV EAX,[EBX+4] ; <-- reads refcount at base+4 -> #132
//
// The fault address is always `relativeTo - 0x20`. This cannot be produced
// from pure Lua on a stock 1.12 client, because `CreateFrame` frames are
// never freed — the dangling pointer only arises when the engine frees a
// frame across a world transition (e.g. UIParent recreation) or another
// DLL frees one. So we arm the exact condition from C: take a frame that
// already has a live anchor (call `frame:SetPoint(...)` first), and rewrite
// that node's `relativeTo` to a pointer whose `-0x20` is unreadable.
//
// Usage — arm and fire in ONE macro, on a HIDDEN frame:
// /run TestF=CreateFrame("Frame","TestF",UIParent); TestF:Hide(); TestF:SetPoint("CENTER"); _classicapi_ArmGetPointCrash(TestF); print(TestF:GetPoint(1))
//
// Both halves of that matter. The armed `relativeTo` is read by MORE than
// GetPoint: the same node's vtable[1]/[2] (`GetWidth`/`GetHeight`,
// FUN_007A2F90 / FUN_007A3070) deref it at `[relativeTo + 0x3C]` during the
// layout pass, with no guard of their own. Arming a VISIBLE frame and then
// firing from a second macro lets the render pass reach the anchor first —
// it crashes at 0x007A2FB8 (layout) instead of 0x007A2452 (GetPoint),
// testing the wrong reader. Hiding the frame keeps it out of the layout
// pass, and keeping arm+GetPoint in one macro leaves no frame boundary
// between them.
//
// Modes (optional 2nd arg, default 0):
// 0 decommitted page — reads a reserved-but-uncommitted page; reproduces
// the exact #132 signature (fault addr ends ...FFE8,
// like the field dumps' 0x16C0FFE8).
// 1 low address — relativeTo = 0x24 -> reads 0x00000004 (the other
// common #132 flavor, "referenced memory at 0x4").
// 2 null relativeTo — sets relativeTo = 0; GetPoint takes its own safe
// "no relativeTo" branch. Does NOT crash even
// unpatched — the control case that proves a valid
// no-anchor-target read still returns cleanly.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <windows.h>
namespace Debug::GetPointCrash {
namespace {
// Anchor node layout — see the file header. The vtable constant is the
// engine's anchor-object vtable in .rdata; checking it guards against
// corrupting a slot that somehow isn't a real anchor node.
constexpr uintptr_t kAnchorVtable = 0x0081C44C;
constexpr int kAnchorArrayOffset = 0x28; // object + 0x28 = slot[0]
constexpr int kAnchorSlots = 9;
constexpr int kNodeRelativeTo = 0x0C;
// Reserved-but-uncommitted address space, allocated once and reused. Any
// read into it faults with ACCESS_VIOLATION, exactly like the decommitted
// CFrame page in the real crash. 64KB granularity means the base ends in
// 0x0000, so placing the synthetic target base at +0xFFE4 makes GetPoint's
// `[relativeTo-0x20]` read land at ...FFE8 — matching the dumps.
void *g_reserved = nullptr;
uintptr_t DecommittedInner() {
if (g_reserved == nullptr)
g_reserved = VirtualAlloc(nullptr, 0x11000, MEM_RESERVE, PAGE_NOACCESS);
if (g_reserved == nullptr)
return 0x24; // fall back to the low-address flavor if reservation fails
// target CFrame base = reserved + 0xFFE4; inner = base + 0x24.
// GetPoint reads [inner - 0x24 + 4] = [base + 4] = reserved + 0xFFE8.
return reinterpret_cast<uintptr_t>(g_reserved) + 0xFFE4 + 0x24;
}
// `_classicapi_ArmGetPointCrash(frame [, mode])` — arms `frame`'s first
// live anchor node so the next `frame:GetPoint(index)` faults. Returns
// (faultAddress, armedSlotIndex). Raises a Lua error (no crash) if the
// frame has no anchor yet or the slot isn't a recognizable anchor node.
int __fastcall Script_ArmGetPointCrash(void *L) {
void *obj = Game::Lua::ResolveRegion(L, 1); // raises if arg1 isn't a region
if (obj == nullptr)
return 0;
const int mode =
Game::Lua::IsNumber(L, 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
auto *base = static_cast<uint8_t *>(obj);
uint8_t *node = nullptr;
int slotIdx = -1;
for (int i = 0; i < kAnchorSlots; ++i) {
auto *n = Game::Read<uint8_t *>(base, kAnchorArrayOffset + i * 4);
if (n != nullptr) {
node = n;
slotIdx = i;
break;
}
}
if (node == nullptr) {
Game::Lua::Error(
L, "_classicapi_ArmGetPointCrash: frame has no anchor; call SetPoint first");
return 0;
}
if (Game::Read<uintptr_t>(node, 0) != kAnchorVtable) {
Game::Lua::Error(L,
"_classicapi_ArmGetPointCrash: slot is not an anchor node");
return 0;
}
uintptr_t badInner;
switch (mode) {
case 1:
badInner = 0x24; // reads [0x24 - 0x20] = 0x00000004
break;
case 2:
badInner = 0; // safe "no relativeTo" branch — control case, no crash
break;
default:
badInner = DecommittedInner();
break;
}
Game::Ref<uintptr_t>(node, kNodeRelativeTo) = badInner;
// Report the address the next GetPoint will dereference (relativeTo - 0x20).
const double faultAddr = badInner ? static_cast<double>(badInner - 0x20) : 0.0;
Game::Lua::PushNumber(L, faultAddr);
Game::Lua::PushNumber(L, static_cast<double>(slotIdx));
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("_classicapi_ArmGetPointCrash",
&Script_ArmGetPointCrash);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Debug::GetPointCrash
+148
View File
@@ -0,0 +1,148 @@
// 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/>.
// `editBox:SetCountInvisibleLetters(count)` / `editBox:IsCountInvisibleLetters()`.
//
// An edit box's SetMaxLetters limit counts the letters you can see. Color
// codes and a hyperlink's hidden `|H...|h` part don't count: with a limit of
// 5, `|cffff0000abcdefg|r` keeps `|cffff0000abcde`. Later clients let an edit
// box count those characters too, for text whose real limit is in bytes: 3.3.5
// does it for the macro text and the friends broadcast. Off is the default,
// as in their XML schema, and is how vanilla already counts. AceGUI-3.0's
// MultiLineEditBox turns it off explicitly.
//
// With it on, the letter-limit check counts each markup token by its length
// instead of skipping it. The engine counts through FUN_EDITBOX_COUNT_CHARS,
// which its cursor movement also uses to turn byte offsets into character
// positions, so only the limit check's three calls, recognized by their
// return addresses, get the new count. The cursor stays where it was.
//
// XML sets it with the EditBox attribute `countInvisibleLetters="true"`, read
// after the engine's own EditBox loader and parsed the way it parses
// `autoFocus`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <intrin.h> // _ReturnAddress
#include <unordered_set>
namespace EditBox::CountInvisibleLetters {
namespace {
// Edit boxes that count invisible letters. Every object is rebuilt on
// /reload, so the set is cleared then.
std::unordered_set<void *> g_counting;
using CountChars_t = int(__thiscall *)(void *editBox, int start, int count);
CountChars_t g_origCountChars = nullptr;
bool FromLetterLimit(uintptr_t from) {
return from == Offsets::RET_EDITBOX_MAX_LETTERS_COUNT_1 || from == Offsets::RET_EDITBOX_MAX_LETTERS_COUNT_2 ||
from == Offsets::RET_EDITBOX_MAX_LETTERS_COUNT_3;
}
// The engine's count over [start, start + count), with markup tokens counted
// at their length rather than skipped.
int CountAll(void *editBox, int start, int count) {
const auto *classes = Game::Read<const uint32_t *>(editBox, Offsets::OFF_EDITBOX_CHAR_CLASS);
if (classes == nullptr)
return 0;
int letters = 0;
for (int i = start; i < start + count; ++i) {
const uint32_t entry = classes[i];
if (entry == 0)
continue; // not the first byte of a token
const uint32_t cls = (entry >> 16) & 0xFF;
if (cls == 2 || cls == 3 || cls == 6)
letters += 1; // a letter you see, as the engine counts it
else
letters += static_cast<int>(entry & 0xFFFF); // markup: every character of it
}
return letters;
}
int __fastcall CountChars_h(void *editBox, void * /*edx*/, int start, int count) {
if (!g_counting.empty() && FromLetterLimit(reinterpret_cast<uintptr_t>(_ReturnAddress())) &&
g_counting.count(editBox) != 0)
return CountAll(editBox, start, count);
return g_origCountChars(editBox, start, count);
}
const Game::HookAutoRegister _countHook{Offsets::FUN_EDITBOX_COUNT_CHARS, reinterpret_cast<void *>(&CountChars_h),
reinterpret_cast<void **>(&g_origCountChars)};
using Load_t = uintptr_t(__thiscall *)(void *self, const void *node, void *status);
using GetAttr_t = const char *(__thiscall *)(const void *node, const char *name);
using AttrToBool_t = int(__fastcall *)(const char *text);
Load_t g_origEditBoxLoad = nullptr;
// `self` is the loader sub-object inside the edit box.
uintptr_t __fastcall EditBoxLoad_h(void *self, void * /*edx*/, const void *node, void *status) {
const uintptr_t result = g_origEditBoxLoad(self, node, status);
if (node == nullptr)
return result;
const char *value =
reinterpret_cast<GetAttr_t>(Offsets::FUN_XML_NODE_GET_ATTRIBUTE)(node, "countInvisibleLetters");
if (value == nullptr || *value == '\0')
return result;
void *editBox = static_cast<uint8_t *>(self) - Offsets::OFF_XML_LOADER_SUBOBJECT;
if (reinterpret_cast<AttrToBool_t>(Offsets::FUN_XML_ATTR_TO_BOOL)(value))
g_counting.insert(editBox);
else
g_counting.erase(editBox);
return result;
}
const Game::HookAutoRegister _loadHook{Offsets::FUN_XML_EDITBOX_LOAD, reinterpret_cast<void *>(&EditBoxLoad_h),
reinterpret_cast<void **>(&g_origEditBoxLoad)};
int __fastcall Script_SetCountInvisibleLetters(void *L) {
void *editBox = Game::Lua::ResolveEditBox(L);
if (editBox == nullptr)
return 0;
if (Game::Lua::ToBoolean(L, 2))
g_counting.insert(editBox);
else
g_counting.erase(editBox);
return 0;
}
int __fastcall Script_IsCountInvisibleLetters(void *L) {
void *editBox = Game::Lua::ResolveEditBox(L);
if (editBox == nullptr)
return 0;
Game::Lua::PushBool(L, g_counting.count(editBox) != 0);
return 1;
}
void PrepareForReload() { g_counting.clear(); }
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetCountInvisibleLetters", &Script_SetCountInvisibleLetters},
{"IsCountInvisibleLetters", &Script_IsCountInvisibleLetters},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ReloadAutoRegister _reload{&PrepareForReload};
} // namespace
} // namespace EditBox::CountInvisibleLetters
+9 -9
View File
@@ -64,7 +64,7 @@ uint32_t ColorByte(void *L, int idx, double dflt) {
}
int __fastcall Script_SetCursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: EditBox:SetCursorPosition(position)");
return 0;
@@ -82,7 +82,7 @@ int __fastcall Script_SetCursorPosition(void *L) {
}
int __fastcall Script_GetCursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetCursorPosition()");
return 0;
@@ -96,7 +96,7 @@ int __fastcall Script_GetCursorPosition(void *L) {
// vs GetCursorPosition's byte offset. Equal for ASCII; differs once the text
// holds multibyte characters.
int __fastcall Script_GetUTF8CursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetUTF8CursorPosition()");
return 0;
@@ -113,7 +113,7 @@ int __fastcall Script_GetUTF8CursorPosition(void *L) {
// helper does; vanilla can only clear a selection via HighlightText(0, 0),
// which yanks it to offset 0 instead of leaving the caret in place.
int __fastcall Script_ClearHighlightText(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:ClearHighlightText()");
return 0;
@@ -128,7 +128,7 @@ int __fastcall Script_ClearHighlightText(void *L) {
// focused box in one global (written by SetFocus / cleared by ClearFocus), so
// focus is just pointer identity against it.
int __fastcall Script_HasFocus(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
@@ -140,7 +140,7 @@ int __fastcall Script_HasFocus(void *L) {
// True when the box holds any text (its byte length is non-zero).
int __fastcall Script_HasText(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
@@ -156,7 +156,7 @@ int __fastcall Script_HasText(void *L) {
// it on all three via the engine's own region color setter. Colors are 0..1;
// alpha defaults to 1. Takes effect on the next selection paint.
int __fastcall Script_SetHighlightColor(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
Game::Lua::Error(L, "Usage: EditBox:SetHighlightColor(r, g, b [, a])");
@@ -181,7 +181,7 @@ int __fastcall Script_SetHighlightColor(void *L) {
// GetHighlightColor() -> r, g, b, a (each 0..1). Reads the color off the first
// highlight region; a region with no explicit color reads back white (opaque).
int __fastcall Script_GetHighlightColor(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetHighlightColor()");
return 0;
@@ -205,7 +205,7 @@ int __fastcall Script_GetHighlightColor(void *L) {
// drops every entry and resets the write position. No-op when history was
// never enabled (max 0).
int __fastcall Script_ClearHistory(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
void *eb = Game::Lua::ResolveEditBox(L);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:ClearHistory()");
return 0;
+396
View File
@@ -0,0 +1,396 @@
// 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/>.
// Equipment sets on the action bar — `C_EquipmentSet.PickupEquipmentSet`
// and everything it takes for the button to behave like 3.3.5's. There an
// equipment set is simply a fourth action type beside spell / macro /
// item: `PickupEquipmentSet` (`FUN_005af2c0`) calls the cursor pickup
// `FUN_00520dc0`, the place / pickup / use / texture / text / usable /
// tooltip readers each have an `0x20000000 | setID` branch, and a click
// fires `WEAR_EQUIPMENT_SET` (`FUN_005ae660`, event 0x276) for FrameXML's
// EquipmentManager to equip, rather than swapping in C.
//
// 1.12's readers know none of it, so each gets that one branch from a
// co-hook in front of the engine's code, which still runs for every other
// slot. Where the engine is right anyway — the spell resolver answers 0
// for the tag, `HasAction` answers true — nothing is hooked.
//
// pickup (cursor) Pickup — the macro pickup `FUN_00494F80` line for line
// (clear, type, payload, grab sound, drag icon,
// ACTIONBAR_SHOWGRID). Only the payload lives here:
// 1.12 has no global for it.
// place FUN_ACTION_PLACE. The engine never sees our cursor,
// and when it overwrites a set it can only clear the
// cursor, so the displaced set is picked up after it.
// pickup (slot) FUN_ACTION_PICKUP.
// use Script_UseAction — nampower detours the inner
// `CGActionBar_UseAction`, so the branch sits on its
// only caller.
// texture / text / usable — the per-slot readers behind
// GetActionTexture / GetActionText / IsUsableAction.
//
// Persistence. The server's CMSG_SET_ACTION_BUTTON handler whitelists
// spell / macro / item and drops type 0x20 without a word (tortoise-wow
// `HandleSetActionButtonOpcode`), keeping whatever the slot held before.
// So the one place the engine announces a player-made change — the
// notifier's `sendToServer` branch — records the placement with the set
// (`Data::SetActionSlot`) and tells the server the slot is EMPTY. At login
// the saved sets are merged into the empty slots of the SMSG_ACTION_BUTTONS
// buffer, and the engine's own enter-world loop applies them exactly as it
// would a server-sent button. A slot the server reports non-empty was
// changed where ClassicAPI wasn't running; the server wins.
#include "Action.h"
#include "Data.h"
#include "Game.h"
#include "Locations.h"
#include "Offsets.h"
#include "Pending.h"
#include "event/Custom.h"
#include <cstdio>
#include <vector>
namespace EquipmentSet::Action {
namespace {
using CursorClear_t = void(__fastcall *)(int returnItem, int fireMoneyEvent);
using CursorSetTexture_t = void(__fastcall *)(const char *path);
using PlaySound_t = void(__fastcall *)(const char *soundName);
using ShowGrid_t = void(__cdecl *)();
using EditAllowed_t = int(__fastcall *)(int kind);
using SlotFn_t = void(__fastcall *)(uint32_t slot0);
using Notify_t = void(__fastcall *)(uint32_t slot0, int sendToServer, int quiet);
using Usable_t = uint32_t(__fastcall *)(uint32_t slot0, int *outNoMana);
using Texture_t = const char *(__fastcall *)(uint32_t slot0);
using Script_t = int(__fastcall *)(void *L);
using LooseBool_t = int(__fastcall *)(void *L, int idx, int defaultValue);
// `__stdcall(a, packet)`, RET 8 — declared `__fastcall` with the two
// register slots passed through untouched, which is the same frame.
using ActionButtons_t = int(__fastcall *)(void *ecx, void *edx, void *a, void *packet);
constexpr uint32_t kTag = Offsets::ACTION_TYPE_EQUIPMENT_SET;
constexpr uint32_t kSlots = Offsets::ACTION_TABLE_MAX_SLOTS;
const Game::Doc::Field kWearPayload[] = {
Game::Doc::Req("setID", "number"),
};
const Game::Doc::Event kWearDoc{
"EquipmentSet",
"Fires when an equipment-set action button is used. The default UI equips the set.",
kWearPayload};
const Event::Custom::AutoReserve kEvtWear{"WEAR_EQUIPMENT_SET", &kWearDoc};
uint32_t g_cursorSetID = 0;
char g_texture[260];
Notify_t g_origNotify = nullptr;
SlotFn_t g_origPlace = nullptr;
SlotFn_t g_origPickup = nullptr;
Usable_t g_origUsable = nullptr;
Texture_t g_origTexture = nullptr;
Script_t g_origUseAction = nullptr;
Script_t g_origGetActionText = nullptr;
ActionButtons_t g_origActionButtons = nullptr;
uint32_t *Table() {
return reinterpret_cast<uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE));
}
uint32_t SetIDOf(uint32_t entry) {
if (entry == 0 || (entry & Offsets::ACTION_TYPE_MASK) != kTag)
return 0;
return entry & ~kTag;
}
uint32_t SetIDAt(uint32_t slot0) {
return slot0 < kSlots ? SetIDOf(Table()[slot0]) : 0;
}
void ClearCursor() {
reinterpret_cast<CursorClear_t>(Offsets::FUN_CURSOR_CLEAR)(1, 1);
}
void NotifySlot(uint32_t slot0, int sendToServer) {
reinterpret_cast<Notify_t>(Offsets::FUN_ACTION_SLOT_CHANGED_NOTIFY)(slot0, sendToServer, 0);
}
// The gate FUN_ACTION_PICKUP and `CGActionBar_UseAction` open with: a held
// spell, bag item, macro or type-7 action item makes them place instead.
// Anything else on the cursor they ignore.
bool EngineWouldPlace() {
const uint32_t type = Game::Read<uint32_t>(Offsets::VAR_CURSOR_TYPE);
return Game::Read<uint32_t>(Offsets::VAR_CURSOR_SPELL_ID) != 0 ||
(type == 1 && (Game::Read<uint32_t>(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
Game::Read<uint32_t>(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0)) ||
Game::Read<uint32_t>(Offsets::VAR_CURSOR_MACRO_ID) != 0 ||
(type == 7 && Game::Read<uint32_t>(Offsets::VAR_CURSOR_GENERIC_SLOT) != 0);
}
// Drop the cursor's set into `slot0` in FUN_ACTION_PLACE's shape: pick up
// what the slot held (FUN_ACTION_PICKUP knows every type, once our cursor
// is out of its way), write the entry, notify with `sendToServer` so the
// notifier persists it.
void PlaceSet(uint32_t slot0, uint32_t setID) {
if (reinterpret_cast<EditAllowed_t>(Offsets::FUN_CURSOR_EDIT_ALLOWED)(9) == 0)
return;
const uint32_t entry = kTag | setID;
if (Data::FindByID(setID) == nullptr || Table()[slot0] == entry) {
ClearCursor();
return;
}
ClearCursor();
reinterpret_cast<SlotFn_t>(Offsets::FUN_ACTION_PICKUP)(slot0);
Table()[slot0] = entry;
NotifySlot(slot0, 1);
}
void __fastcall Place_h(uint32_t slot0) {
if (slot0 >= kSlots)
return g_origPlace(slot0);
if (const uint32_t id = CursorSetID())
return PlaceSet(slot0, id);
const uint32_t before = Table()[slot0];
g_origPlace(slot0);
// The engine clears the cursor for a type it doesn't know and then
// overwrites; 3.3.5 hands the displaced set to the cursor instead.
const uint32_t displaced = SetIDOf(before);
if (displaced != 0 && Table()[slot0] != before &&
Game::Read<uint32_t>(Offsets::VAR_CURSOR_TYPE) == 0)
Pickup(displaced);
}
void __fastcall Pickup_h(uint32_t slot0) {
if (slot0 >= kSlots)
return g_origPickup(slot0);
if (const uint32_t id = CursorSetID())
return PlaceSet(slot0, id);
const uint32_t setID = SetIDAt(slot0);
if (setID == 0 || EngineWouldPlace())
return g_origPickup(slot0);
// 3.3.5 `FUN_005abe70`: pick the set up if it still exists, then clear
// the slot either way.
Pickup(setID);
reinterpret_cast<SlotFn_t>(Offsets::FUN_ACTION_SLOT_CLEAR)(slot0);
}
int __fastcall UseAction_h(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return g_origUseAction(L); // the engine's usage error
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot < 0 || slot >= static_cast<int>(kSlots))
return g_origUseAction(L);
const uint32_t slot0 = static_cast<uint32_t>(slot);
if (reinterpret_cast<LooseBool_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE)(L, 2, 0) != 0) {
if (const uint32_t id = CursorSetID()) {
PlaceSet(slot0, id);
return 0;
}
if (EngineWouldPlace())
return g_origUseAction(L);
}
const uint32_t setID = SetIDAt(slot0);
if (setID == 0)
return g_origUseAction(L);
if (Data::FindByID(setID) != nullptr) {
const int evt = kEvtWear.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d", static_cast<int>(setID));
}
return 0;
}
const char *__fastcall Texture_h(uint32_t slot0) {
const uint32_t setID = SetIDAt(slot0);
if (setID == 0)
return g_origTexture(slot0);
const Set *s = Data::FindByID(setID);
if (s == nullptr || !IconPath(*s, g_texture, sizeof(g_texture)))
return nullptr;
return g_texture;
}
int __fastcall GetActionText_h(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return g_origGetActionText(L);
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
const uint32_t setID = slot >= 0 ? SetIDAt(static_cast<uint32_t>(slot)) : 0;
if (setID == 0)
return g_origGetActionText(L);
const Set *s = Data::FindByID(setID);
if (s != nullptr)
Game::Lua::PushString(L, s->name.c_str());
else
Game::Lua::PushNil(L);
return 1;
}
// 4.3.4 `CGActionBar::UpdateUsableAction`'s set branch: unusable while a
// swap is in flight, the set is gone, one of its items is mid-transaction,
// or an item it would replace is.
uint32_t __fastcall Usable_h(uint32_t slot0, int *outNoMana) {
const uint32_t setID = SetIDAt(slot0);
if (setID == 0)
return g_origUsable(slot0, outNoMana);
*outNoMana = 0;
if (!Pending::CanUse())
return 0;
const Set *s = Data::FindByID(setID);
return (s != nullptr && !Locations::ContainsLockedItems(*s) &&
!Locations::UsesLockedSlots(*s))
? 1
: 0;
}
void __fastcall Notify_h(uint32_t slot0, int sendToServer, int quiet) {
if (sendToServer == 0 || slot0 >= kSlots)
return g_origNotify(slot0, sendToServer, quiet);
const uint32_t entry = Table()[slot0];
const uint32_t setID = SetIDOf(entry);
Data::SetActionSlot(static_cast<int>(slot0), setID);
if (setID == 0)
return g_origNotify(slot0, sendToServer, quiet);
// Send the slot as empty (quietly), then notify the real entry locally.
Table()[slot0] = 0;
g_origNotify(slot0, 1, 1);
Table()[slot0] = entry;
g_origNotify(slot0, 0, quiet);
}
int __fastcall ActionButtons_h(void *ecx, void *edx, void *a, void *packet) {
const int result = g_origActionButtons(ecx, edx, a, packet);
auto *pending = reinterpret_cast<uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_BUTTONS_PENDING));
std::vector<int> stale;
for (const Set &s : Data::All()) {
for (const int slot0 : s.actionSlots) {
if (pending[slot0] == 0)
pending[slot0] = kTag | s.setID;
else
stale.push_back(slot0);
}
}
for (const int slot0 : stale)
Data::SetActionSlot(slot0, 0);
return result;
}
int __fastcall Script_PickupEquipmentSet(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_EquipmentSet.PickupEquipmentSet(setID)");
return 0;
}
const double id = Game::Lua::ToNumber(L, 1);
if (id >= 1.0)
Pickup(static_cast<uint32_t>(id));
return 0;
}
const Game::Doc::Field kPickupArgs[] = {
Game::Doc::Req("setID", "number"),
};
const Game::Doc::Function kPickupDoc{
"Puts an equipment set on the cursor, so it can be placed on an action bar.",
kPickupArgs,
{}};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_EquipmentSet", "PickupEquipmentSet",
&Script_PickupEquipmentSet, &kPickupDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::HookAutoRegister _hookNotify{
Offsets::FUN_ACTION_SLOT_CHANGED_NOTIFY, reinterpret_cast<void *>(&Notify_h),
reinterpret_cast<void **>(&g_origNotify)};
const Game::HookAutoRegister _hookPlace{
Offsets::FUN_ACTION_PLACE, reinterpret_cast<void *>(&Place_h),
reinterpret_cast<void **>(&g_origPlace)};
const Game::HookAutoRegister _hookPickup{
Offsets::FUN_ACTION_PICKUP, reinterpret_cast<void *>(&Pickup_h),
reinterpret_cast<void **>(&g_origPickup)};
const Game::HookAutoRegister _hookUsable{
Offsets::FUN_ACTION_SLOT_USABLE, reinterpret_cast<void *>(&Usable_h),
reinterpret_cast<void **>(&g_origUsable)};
const Game::HookAutoRegister _hookTexture{
Offsets::FUN_ACTION_SLOT_TEXTURE, reinterpret_cast<void *>(&Texture_h),
reinterpret_cast<void **>(&g_origTexture)};
const Game::HookAutoRegister _hookUseAction{
Offsets::FUN_SCRIPT_USE_ACTION, reinterpret_cast<void *>(&UseAction_h),
reinterpret_cast<void **>(&g_origUseAction)};
const Game::HookAutoRegister _hookActionText{
Offsets::FUN_SCRIPT_GET_ACTION_TEXT, reinterpret_cast<void *>(&GetActionText_h),
reinterpret_cast<void **>(&g_origGetActionText)};
const Game::HookAutoRegister _hookActionButtons{
Offsets::FUN_SMSG_ACTION_BUTTONS, reinterpret_cast<void *>(&ActionButtons_h),
reinterpret_cast<void **>(&g_origActionButtons)};
} // namespace
uint32_t CursorSetID() {
if (Game::Read<uint32_t>(Offsets::VAR_CURSOR_TYPE) != Offsets::CURSOR_TYPE_EQUIPMENT_SET)
return 0;
return g_cursorSetID;
}
uint32_t SlotSetID(uint32_t slot0) {
return SetIDAt(slot0);
}
bool Pickup(uint32_t setID) {
const Set *s = Data::FindByID(setID);
if (s == nullptr)
return false;
char path[260];
if (!IconPath(*s, path, sizeof(path)))
return false;
ClearCursor();
Game::Ref<uint32_t>(Offsets::VAR_CURSOR_TYPE) = Offsets::CURSOR_TYPE_EQUIPMENT_SET;
g_cursorSetID = setID;
reinterpret_cast<PlaySound_t>(Offsets::FUN_PLAY_SOUND_BY_NAME)(
"INTERFACESOUND_CURSORGRABOBJECT");
reinterpret_cast<CursorSetTexture_t>(Offsets::FUN_CURSOR_SET_TEXTURE)(path);
reinterpret_cast<ShowGrid_t>(Offsets::FUN_ACTIONBAR_SHOW_GRID)();
Game::Ref<uint32_t>(Offsets::VAR_CURSOR_SHOWS_ACTION_GRID) = 1;
return true;
}
bool IconPath(const Set &set, char *out, size_t outSize) {
// Stored as given to Create/SaveEquipmentSet — a bare name or a path.
const char *base = set.icon.c_str();
for (const char *p = base; *p != '\0'; ++p) {
if (*p == '\\' || *p == '/')
base = p + 1;
}
const int n = std::snprintf(out, outSize, "Interface\\Icons\\%s", base);
return n > 0 && static_cast<size_t>(n) < outSize;
}
void Repaint(uint32_t setID) {
for (uint32_t slot0 = 0; slot0 < kSlots; ++slot0) {
if (SetIDAt(slot0) == setID)
NotifySlot(slot0, 0);
}
}
void ClearButtons(uint32_t setID) {
for (uint32_t slot0 = 0; slot0 < kSlots; ++slot0) {
if (SetIDAt(slot0) == setID)
reinterpret_cast<SlotFn_t>(Offsets::FUN_ACTION_SLOT_CLEAR)(slot0);
}
}
} // namespace EquipmentSet::Action
+45
View File
@@ -0,0 +1,45 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Set.h"
#include <cstddef>
#include <cstdint>
// Equipment sets as action-bar buttons — the 3.3.5 action type
// `0x20000000 | setID` (`Offsets::ACTION_TYPE_EQUIPMENT_SET`) and cursor
// type 13 (`Offsets::CURSOR_TYPE_EQUIPMENT_SET`).
namespace EquipmentSet::Action {
// The set on the cursor, or 0 when the cursor holds anything else.
uint32_t CursorSetID();
// The set on 0-based action slot `slot0`, or 0 when it holds anything else.
uint32_t SlotSetID(uint32_t slot0);
// Puts `setID` on the cursor, the way the engine picks up a macro. False
// when there is no such set.
bool Pickup(uint32_t setID);
// "Interface\Icons\<icon>" for `set`. False when it doesn't fit `out`.
bool IconPath(const Set &set, char *out, size_t outSize);
// Name / icon changed — repaint every button holding `setID`.
void Repaint(uint32_t setID);
// Set gone — clear every button holding `setID`.
void ClearButtons(uint32_t setID);
} // namespace EquipmentSet::Action
+220 -258
View File
@@ -19,28 +19,31 @@
// the same way `VanillaMinimapTracking` keeps its tracking config.
//
// Identity is by item GUID — `SaveEquipmentSet` snapshots the GUIDs
// currently worn, and `UseEquipmentSet` searches every player-owned
// container for those GUIDs at use time. The GUID search reads
// invMgr GUID arrays directly (same trick `C_Item.GetItemCount`
// uses), so bank items resolve without the bank window being open.
// currently worn, and `UseEquipmentSet` searches the player's bags (and
// the bank, while it is open) for those GUIDs at use time. Behavior
// follows 4.3.4's native equipment manager (`CGEquipmentManager`).
//
// Modern Classic Era's signatures use a numeric `iconFileID`; we
// accept icon paths (e.g. `"INV_Shield_06"`) instead because vanilla
// has no fileDataID system. Same string-or-default fallback semantic
// as 4.3.4's native `SaveEquipmentSet`.
#include "Action.h"
#include "Data.h"
#include "Game.h"
#include "Locations.h"
#include "Offsets.h"
#include "Pending.h"
#include "Set.h"
#include "event/Custom.h"
#include "Text.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include "item/Swap.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
#include <string>
namespace EquipmentSet::Api {
@@ -86,11 +89,9 @@ void PushTableEntry(void *L, int key, double value) {
Game::Lua::SetTable(L, -3);
}
// 1.12 has no concept of "uncomingable equipment-set support", so
// `CanUseEquipmentSets` is always true (we mirror what Classic Era
// 1.15 does — it returns true unconditionally there as well).
// 4.3.4 `CanUseEquipmentSets`: false while a swap is in flight.
int __fastcall Script_CanUseEquipmentSets(void *L) {
Game::Lua::PushBool(L, 1);
Game::Lua::PushBool(L, Pending::CanUse());
return 1;
}
@@ -130,55 +131,25 @@ int __fastcall Script_GetEquipmentSetID(void *L) {
// Returns the 9-tuple Blizzard ships:
// name, icon, setID, isEquipped, numItems, numEquipped,
// numInInventory, numMissing, numIgnored
//
// `numItems` excludes ignored slots — matches modern semantics where
// an ignored slot is "not part of the set", not "part of the set but
// unfilled". `isEquipped` is the boolean equivalent of
// `numEquipped == numItems`; modern flips it true even when missing
// items exist, as long as everything currently *resolvable* is in its
// target slot. We follow that — strict-equipped semantics break in
// useful cases (e.g. you're at a bank without your bank-stored cloak).
// — 4.3.4 `Script_GetEquipmentSetInfo`: the counts are
// `Locations::GetStatus`, `isEquipped` is the strict
// `Locations::IsEquipped`.
int __fastcall Script_GetEquipmentSetInfo(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr)
return 0;
int numItems = 0;
int numEquipped = 0;
int numInInventory = 0;
int numMissing = 0;
int numIgnored = 0;
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = s->items[i];
if (g == GUID_IGNORED) {
++numIgnored;
continue;
}
if (g == GUID_EMPTY)
continue;
++numItems;
const int loc = Locations::FindGUID(g);
if (loc == 0) {
++numMissing;
} else if ((loc & LOC_BAGS) == 0 && (loc & LOC_BANK) == 0 &&
(loc & LOC_SLOT_MASK) == (i + 1)) {
++numEquipped;
} else {
++numInInventory;
}
}
const Locations::Status st = Locations::GetStatus(*s);
Game::Lua::PushString(L, s->name.c_str());
Game::Lua::PushString(L, s->icon.c_str());
Game::Lua::PushNumber(L, static_cast<double>(s->setID));
Game::Lua::PushBoolean(L, (numItems > 0 && numEquipped + numMissing == numItems &&
numInInventory == 0));
Game::Lua::PushNumber(L, static_cast<double>(numItems));
Game::Lua::PushNumber(L, static_cast<double>(numEquipped));
Game::Lua::PushNumber(L, static_cast<double>(numInInventory));
Game::Lua::PushNumber(L, static_cast<double>(numMissing));
Game::Lua::PushNumber(L, static_cast<double>(numIgnored));
Game::Lua::PushBoolean(L, Locations::IsEquipped(*s) ? 1 : 0);
Game::Lua::PushNumber(L, static_cast<double>(st.total));
Game::Lua::PushNumber(L, static_cast<double>(st.equipped));
Game::Lua::PushNumber(L, static_cast<double>(st.inInventory));
Game::Lua::PushNumber(L, static_cast<double>(st.missing));
Game::Lua::PushNumber(L, static_cast<double>(st.ignored));
return 9;
}
@@ -200,10 +171,10 @@ int __fastcall Script_GetIgnoredSlots(void *L) {
return 1;
}
// Modern returns a hash table { [slot] = itemID } with one entry per
// non-empty, non-ignored slot. Missing items still get an entry — but
// since we can't recover an itemID from a GUID alone after the item
// has left the client (e.g. mailed away), missing slots are omitted.
// 4.3.4 `Script_GetEquipmentSetItemIDs`: { [slot] = value } for all 19
// slots — 0 empty, 1 ignored, else the itemID, or -1 when it can't be
// resolved. An item no longer in the client still has the itemID saved
// with the set, so -1 is left for sets saved before that was recorded.
int __fastcall Script_GetItemIDs(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
@@ -213,39 +184,39 @@ int __fastcall Script_GetItemIDs(void *L) {
Game::Lua::NewTable(L);
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
const uint8_t *item = Locations::ResolveItemByGUID(g);
const int itemID = ResolveItemID(item);
if (itemID == 0)
continue;
PushTableEntry(L, i + 1, static_cast<double>(itemID));
int value = static_cast<int>(g); // GUID_EMPTY 0 / GUID_IGNORED 1
if (g != GUID_EMPTY && g != GUID_IGNORED) {
value = ResolveItemID(Locations::ResolveItemByGUID(g));
if (value == 0)
value = static_cast<int>(s->itemIDs[i]);
if (value == 0)
value = -1;
}
PushTableEntry(L, i + 1, static_cast<double>(value));
}
return 1;
}
// Hash table { [slot] = locationCode } — same encoding modern uses,
// see Set.h's pack helpers. Ignored slots produce `LOC_IGNORED`,
// missing items produce `LOC_MISSING`, found items produce the
// packed `LOC_PLAYER|...|slot` code. Empty (uninitialized) slots
// don't appear in the table.
// 4.3.4 `Script_GetEquipmentSetLocations`: { [slot] = locationCode } for
// all 19 slots — 0 empty, `LOC_IGNORED`, the packed location (see Set.h),
// or `LOC_MISSING`. Bank items count only while the bank is open, the
// same rule `UseEquipmentSet` swaps by.
int __fastcall Script_GetItemLocations(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr)
return 0;
const bool bank = Locations::BankerOpen();
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY)
continue;
int loc;
int loc = 0;
if (g == GUID_IGNORED) {
loc = LOC_IGNORED;
} else {
loc = Locations::FindGUID(g);
} else if (g != GUID_EMPTY) {
loc = Locations::FindGUID(g, bank);
if (loc == 0)
loc = LOC_MISSING;
}
@@ -254,14 +225,27 @@ int __fastcall Script_GetItemLocations(void *L) {
return 1;
}
// The name argument at `idx`, scrubbed as 4.3.4 does. Raises its
// "Invalid string for setName" error and returns false when the scrub
// rejects it.
bool ArgName(void *L, int idx, std::string *out) {
if (Data::ScrubName(ArgString(L, idx), out))
return true;
Game::Lua::Error(L, "Invalid string for setName");
return false;
}
int __fastcall Script_CreateEquipmentSet(void *L) {
const char *name = ArgString(L, 1);
if (name == nullptr || name[0] == '\0') {
const char *raw = ArgString(L, 1);
if (raw == nullptr || raw[0] == '\0') {
Game::Lua::Error(L, "Usage: C_EquipmentSet.CreateEquipmentSet(name [, icon])");
return 0;
}
std::string name;
if (!ArgName(L, 1, &name))
return 0;
const char *icon = ArgString(L, 2);
const uint32_t setID = Data::Create(name, icon);
const uint32_t setID = Data::Create(name.c_str(), icon);
if (setID == 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(setID));
@@ -275,19 +259,24 @@ int __fastcall Script_SaveEquipmentSet(void *L) {
return 0;
}
const char *icon = ArgString(L, 2);
Data::SaveExisting(setID, icon);
if (Data::SaveExisting(setID, icon))
Action::Repaint(setID); // the icon may have changed
return 0;
}
int __fastcall Script_ModifyEquipmentSet(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const char *newName = ArgString(L, 2);
if (setID == 0 || newName == nullptr || newName[0] == '\0') {
const char *raw = ArgString(L, 2);
if (setID == 0 || raw == nullptr || raw[0] == '\0') {
Game::Lua::Error(L,
"Usage: C_EquipmentSet.ModifyEquipmentSet(setID, newName)");
return 0;
}
Data::Rename(setID, newName);
std::string name;
if (!ArgName(L, 2, &name))
return 0;
if (Data::Rename(setID, name.c_str()))
Action::Repaint(setID); // buttons show the name
return 0;
}
@@ -297,7 +286,8 @@ int __fastcall Script_DeleteEquipmentSet(void *L) {
Game::Lua::Error(L, "Usage: C_EquipmentSet.DeleteEquipmentSet(setID)");
return 0;
}
Data::Delete(setID);
if (Data::Delete(setID))
Action::ClearButtons(setID);
return 0;
}
@@ -325,254 +315,226 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
return 0;
}
// Returns true if any item in the set currently sits in the
// client-side "in-transaction" lock state (a pending swap, mail-
// attach, trade-attach, or cursor pickup that our equip dispatch
// would race with). Reads the per-CGItem instance flag at
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
// and the engine's own pickup/unlock paths manipulate.
//
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
// for items the player has deleted/mailed/traded away, the engine's
// CGItem cache often retains a stale entry that `ResolveByGUID`
// still hands back, but the inventory walk no longer finds the GUID
// anywhere. Reading the lock flag off the stale CGItem reflects
// whatever bits were last set on it (frequently the "locked"
// in-transaction state from when it was being deleted/swapped),
// which would falsely report the set as "contains locked items"
// and prevent `UseEquipmentSet` from running through addon
// wrappers that gate on this check. The inventory presence check
// matches modern semantics: a missing item is *missing*, not
// *locked*.
// Whether a swap would race an item already in transit — see
// `Locations::ContainsLockedItems`.
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr)
return 0;
bool any = false;
for (int i = 0; i < SLOT_COUNT && !any; ++i) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (Locations::FindGUID(g) == 0)
continue; // not in inventory — treat as missing, not locked
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
item + Offsets::OFF_ITEM_CLIENT_LOCK);
if (flags & Offsets::ITEM_CLIENT_LOCK_BIT)
any = true;
}
Game::Lua::PushBool(L, any);
Game::Lua::PushBool(L, Locations::ContainsLockedItems(*s));
return 1;
}
// Walks the set and dispatches one atomic server-side swap per item
// that isn't already in its target slot. Items currently in the main
// bank or bank bags are skipped silently — vanilla rejects equip
// from bank slots server-side, so the user needs to retrieve them
// first.
// 4.3.4 `CGEquipmentManager::UseEquipmentSet`, over 1.12's per-item swap
// packets instead of its single CMSG_EQUIPMENT_SET_USE:
// - refused (ERR_CLIENT_LOCKED_OUT, the same "You can't do that right
// now." text as 4.3.4's SPELL_FAILED_CANT_DO_THAT_RIGHT_NOW) while a
// swap is in flight or the cursor holds a bag item;
// - in combat only the weapon slots swap, with one COMBAT_SWAP message
// when another slot would have changed;
// - an item that can't be found is skipped, with one MISSING_ITEM
// message;
// - bank items are used only while the bank is open (the server refuses
// them otherwise);
// - the result arrives later as EQUIPMENT_SWAP_FINISHED (see
// `EquipmentSet::Pending`).
//
// Cycle handling: with cursor-based pickup+equip, swap cycles (e.g.
// rings A↔B wanting each other's slots) needed a temp-bag-slot
// shuffle. We now call the engine's own `FUN_INVENTORY_SWAP` per
// item, which sends opcode 0x10D / 0x10C through the engine's
// packet pipeline — an atomic server-side swap. A two-cycle resolves
// in one call (server swaps both slots from one packet); the cursor
// is never touched. See TODO #59 for the primitive's decoding and
// TODO #73 for the cycle-resolution rationale.
// Each move is the engine's own `FUN_INVENTORY_SWAP` (opcodes 0x10D /
// 0x10C), an atomic server-side swap that never touches the cursor, so a
// two-item cycle (rings A<->B) resolves in one packet. Sources are resolved
// against the state BEFORE anything is sent: the engine's inventory only
// changes when the server answers, so a lookup between packets would see
// stale locations. A virtual paperdoll tracks the moves instead.
bool CursorHoldsBagItem() {
return Game::Read<uint32_t>(Offsets::VAR_CURSOR_TYPE) == 1 &&
(Game::Read<uint32_t>(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
Game::Read<uint32_t>(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0);
}
bool PlayerInCombat() {
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
return desc != nullptr &&
(Game::Read<uint32_t>(desc, Offsets::OFF_UNIT_FIELD_FLAGS) &
Offsets::UNIT_FLAG_IN_COMBAT) != 0;
}
// The Lua bagID and 1-based slot of a bag or bank location code (see
// Set.h's pack helpers): 0..4 player bags, -1 main bank, 5..10 bank bags.
void UnpackContainer(int loc, int *bagID, int *slot) {
*slot = loc & LOC_SLOT_MASK;
const int bag = (loc >> LOC_BAG_SHIFT) & 0xFF;
if ((loc & LOC_BANK) == 0)
*bagID = bag;
else if ((loc & LOC_BAGS) == 0)
*bagID = -1;
else
*bagID = bag + BANK_BAG_OFFSET;
}
int __fastcall Script_UseEquipmentSet(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr) {
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 0, static_cast<int>(setID));
Game::Lua::PushBoolean(L, 0);
return 1;
}
if (!Pending::CanUse() || CursorHoldsBagItem()) {
Text::UIError("ERR_CLIENT_LOCKED_OUT", "You can't do that right now.");
Game::Lua::PushBoolean(L, 0);
return 1;
}
// Fire PENDING right after the set-exists check, before any of
// the swap work. Addon UI can use this to gate swap-in-progress
// visuals.
const int pendingEvt = kEvtSwapPending.Slot();
if (pendingEvt >= 0)
Event::Custom::Fire(pendingEvt, "%d", static_cast<int>(setID));
// If the cursor is holding anything, return it to its source slot
// and unlock it server-side BEFORE our swap chain runs. The
// engine's FUN_INVENTORY_SWAP ends each call with a cursor-state
// cleanup that clears the LOCAL cursor view, but never sends the
// server-side cancel-pickup packet — so a held item would stay
// server-locked until relog. ClearCursor sends the right packet.
// No-op when the cursor is idle (still fires one CURSOR_UPDATE,
// negligible).
Game::Lua::SetTop(L, 0);
reinterpret_cast<int(__fastcall *)(void *)>(
Offsets::FUN_SCRIPT_CLEAR_CURSOR)(L);
// Snapshot the set's items into a local — Locations::FindGUID
// can shuffle the Lua stack, and we've already passed validation.
// Copy what the swap needs: the free-slot scan below stomps the Lua
// stack, and the error messages run Lua handlers.
const std::string name = s->name;
uint64_t guids[SLOT_COUNT];
std::memcpy(guids, s->items, sizeof(guids));
// Snapshot empty bag slots up front. The CGItem state doesn't
// update between packet sends (server has to acknowledge each
// swap), so iterating the bag walk fresh per unequip would
// repeatedly pick the same "first empty" slot — second packet
// collides with what packet 1 just put there, and the server
// tries to swap that item back onto the wrong paperdoll slot
// → "This item cannot be equipped".
Pending::Request req{};
req.setID = setID;
auto noteMoved = [&req](uint64_t g) {
if (g != 0)
req.moved[req.movedCount++] = g;
};
uint64_t virtualPaperdoll[SLOT_COUNT];
Locations::SnapshotPaperdoll(virtualPaperdoll);
// Decide every slot against the original state.
const bool inCombat = PlayerInCombat();
const bool bank = Locations::BankerOpen();
bool combatWarned = false;
bool missingWarned = false;
int sourceLoc[SLOT_COUNT] = {};
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = guids[i];
if (g == GUID_IGNORED)
continue;
if (inCombat && !SwapsInCombat(i + 1)) {
if (!combatWarned && g != virtualPaperdoll[i]) {
Text::UIError("ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S",
"Some items from set %s cannot be swapped because "
"you are in combat.",
name.c_str());
combatWarned = true;
}
continue;
}
if (g != GUID_EMPTY) {
sourceLoc[i] = Locations::FindGUID(g, bank);
if (sourceLoc[i] == 0) {
if (!missingWarned) {
Text::UIError("ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S",
"One or more items from set %s were missing and "
"could not be equipped.",
name.c_str());
missingWarned = true;
}
continue;
}
}
req.want[i] = g;
req.cares[i] = true;
}
// Free bag slots for phase 3, snapshotted up front for the same
// stale-state reason: a fresh scan per unequip would keep finding the
// same "first empty" slot. At most every slot comes off.
struct FreeSlot {
int bag;
int slot;
};
FreeSlot freeSlots[64];
FreeSlot freeSlots[SLOT_COUNT];
int freeSlotCount = 0;
for (int bag = 0; bag <= 4 && freeSlotCount < 64; ++bag) {
for (int bag = 0; bag <= 4 && freeSlotCount < SLOT_COUNT; ++bag) {
const int slots = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= slots && freeSlotCount < 64; ++slot) {
for (int slot = 1; slot <= slots && freeSlotCount < SLOT_COUNT; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr)
freeSlots[freeSlotCount++] = {bag, slot};
}
}
int nextFreeSlot = 0;
// Pre-snapshot each set item's location against the ORIGINAL
// state, before any swap packets fly. This mirrors how 4.3.4's
// `Script_UseEquipmentSet` (`0x009A15D0`) builds its action list:
// resolve all 19 source locations up front, then execute. The
// engine's CGItem / InvMgr state lags behind our packet sends
// (waits for server ACK), so any `FindGUID` mid-execution would
// return stale answers and we'd re-resolve items into the wrong
// place.
int sourceLoc[SLOT_COUNT];
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED) {
sourceLoc[i] = 0;
continue;
}
sourceLoc[i] = Locations::FindGUID(g);
}
// Virtual paperdoll occupant model — seeded from the engine's
// initial paperdoll GUID array, then updated in-place after each
// swap so phase 1 can chain rotations and phase 3 can spot
// already-empty slots.
uint64_t virtualPaperdoll[SLOT_COUNT];
Locations::SnapshotPaperdoll(virtualPaperdoll);
// Locates the current virtual slot of a GUID on the paperdoll,
// or 0 if it's not currently there. Used during phase 1 so
// chained paperdoll-to-paperdoll moves operate on the *current*
// virtual position, not the stale pre-snapshot.
auto currentPaperdollSlot = [&](uint64_t g) -> int {
if (g == 0)
return 0;
for (int slot = 1; slot <= SLOT_COUNT; ++slot) {
for (int slot = 1; slot <= SLOT_COUNT; ++slot)
if (virtualPaperdoll[slot - 1] == g)
return slot;
}
return 0;
};
// ── Phase 1: paperdoll → paperdoll ─────────────────────────────
// Move items already on the paperdoll into their target slots.
// Source and destination are both paperdoll, so atomic swaps
// never touch bag state — phase 2's bag lookups stay accurate.
// Phase 1: paperdoll -> paperdoll. Neither side is a bag, so phase 2's
// bag sources stay valid.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
if (!req.cares[i] || g == GUID_EMPTY ||
(sourceLoc[i] & (LOC_BAGS | LOC_BANK)) != 0)
continue;
const int loc = sourceLoc[i];
if (loc == 0)
continue;
if (loc & LOC_BANK)
continue; // server rejects equip-from-bank
if (loc & LOC_BAGS)
continue; // phase 2
const int srcSlot = currentPaperdollSlot(g);
if (srcSlot == 0 || srcSlot == targetSlot)
continue; // missing or already there
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
if (Item::Swap::FromPaperdoll(item, srcSlot, targetSlot)) {
if (Item::Swap::FromPaperdoll(Locations::ResolveItemByGUID(g), srcSlot,
targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
virtualPaperdoll[srcSlot - 1] = displaced;
noteMoved(g);
noteMoved(displaced);
} else {
req.cares[i] = false;
}
}
// ── Phase 2: bag → paperdoll ───────────────────────────────────
// Move items from bag slots onto the paperdoll. Each atomic swap
// displaces the current paperdoll occupant into the bag slot the
// incoming item vacated. Anything we still wanted on the paperdoll
// was already placed by phase 1, so phase 2 displacements can't
// strand a future set item in a bag.
// Phase 2: bag / bank -> paperdoll. The paperdoll occupant lands where
// the incoming item was. Anything the set still wanted worn was placed
// by phase 1, so a displacement here can't strand a set item.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
const uint64_t g = guids[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
if (!req.cares[i] || g == GUID_EMPTY ||
(sourceLoc[i] & (LOC_BAGS | LOC_BANK)) == 0)
continue;
if (virtualPaperdoll[targetSlot - 1] == g)
continue; // already at target (probably by phase 1)
const int loc = sourceLoc[i];
if (loc == 0)
continue;
if (loc & LOC_BANK)
continue;
if ((loc & LOC_BAGS) == 0)
continue; // not from a bag — phase 1 handled, or item missing
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
const int srcBag = (loc >> LOC_BAG_SHIFT) & 0xFF;
const int srcSlot = loc & LOC_SLOT_MASK;
if (Item::Swap::FromBag(item, srcBag, srcSlot, targetSlot)) {
int bagID = 0;
int slot = 0;
UnpackContainer(sourceLoc[i], &bagID, &slot);
const uint64_t displaced = virtualPaperdoll[targetSlot - 1];
if (Item::Swap::FromBag(Locations::ResolveItemByGUID(g), bagID, slot,
targetSlot)) {
virtualPaperdoll[targetSlot - 1] = g;
// The displaced item lands in (srcBag, srcSlot). We
// don't track bag-side virtual state — by phase 3 we
// only care about the paperdoll occupant.
noteMoved(g);
noteMoved(displaced);
} else {
req.cares[i] = false;
}
}
// ── Phase 3: unequip slots the set wants empty ─────────────────
// For each `GUID_EMPTY` slot, send the current paperdoll occupant
// (if any) to a pre-snapshotted free bag slot. Runs last so any
// displaced items from phases 1/2 that landed in the slot get
// unequipped here too.
// Phase 3: take off what the set wants empty. Runs last so an item
// phases 1/2 displaced into such a slot comes off too. No free bag slot
// is 4.3.4's bags-full result.
for (int i = 0; i < SLOT_COUNT; ++i) {
const int targetSlot = i + 1;
if (guids[i] != GUID_EMPTY)
continue;
const uint64_t occupant = virtualPaperdoll[targetSlot - 1];
if (occupant == 0)
if (!req.cares[i] || guids[i] != GUID_EMPTY || occupant == 0)
continue;
if (nextFreeSlot >= freeSlotCount)
continue;
const uint8_t *equipped = Locations::ResolveItemByGUID(occupant);
if (equipped == nullptr)
if (nextFreeSlot >= freeSlotCount) {
req.bagsFull = true;
req.cares[i] = false;
continue;
}
const FreeSlot &fs = freeSlots[nextFreeSlot++];
if (Item::Swap::ToBag(equipped, targetSlot, fs.bag, fs.slot))
if (Item::Swap::ToBag(Locations::ResolveItemByGUID(occupant), targetSlot,
fs.bag, fs.slot)) {
virtualPaperdoll[targetSlot - 1] = 0;
noteMoved(occupant);
} else {
req.cares[i] = false;
}
}
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
Pending::Begin(req);
Game::Lua::SetTop(L, 0);
Game::Lua::PushBoolean(L, 1);
return 1;
}
+74 -4
View File
@@ -85,6 +85,29 @@ void Persist() {
Storage::Save(g_path, g_sets);
}
// 4.3.4's `ValidateStringIsUTF8` rejects a malformed sequence: a lead
// byte without its continuation bytes, or a stray continuation byte.
bool ValidUtf8(const char *s) {
for (auto *p = reinterpret_cast<const unsigned char *>(s); *p != 0;) {
int extra = 0;
if (*p < 0x80)
extra = 0;
else if ((*p & 0xE0) == 0xC0)
extra = 1;
else if ((*p & 0xF0) == 0xE0)
extra = 2;
else if ((*p & 0xF8) == 0xF0)
extra = 3;
else
return false;
++p;
for (int i = 0; i < extra; ++i, ++p)
if ((*p & 0xC0) != 0x80)
return false;
}
return true;
}
} // namespace
void EnsureLoaded() {
@@ -162,6 +185,28 @@ std::vector<const Set *> SetsContainingItem(uint64_t itemGuid) {
return found;
}
bool ScrubName(const char *src, std::string *out) {
out->clear();
if (src == nullptr)
return true;
bool valid = ValidUtf8(src);
for (const char *c = src; *c != '\0'; ++c) {
// Line break: a real CR/LF, or the escaped `\n` / `|n` forms.
if (*c == '\r' || *c == '\n' || ((*c == '\\' || *c == '|') && c[1] == 'n'))
return false;
if (*c != '|')
out->push_back(*c);
}
int chars = 0;
for (size_t i = 0; i < out->size(); ++i) {
if ((static_cast<unsigned char>((*out)[i]) & 0xC0) != 0x80 && ++chars == MAX_NAME_CHARS) {
out->resize(i);
return false;
}
}
return valid;
}
uint32_t Create(const char *name, const char *icon) {
EnsureLoaded();
if (g_path.empty() || name == nullptr || name[0] == '\0')
@@ -171,8 +216,6 @@ uint32_t Create(const char *name, const char *icon) {
Set s;
s.setID = NextAvailableID();
s.name = name;
if (s.name.size() > MAX_NAME_LEN)
s.name.resize(MAX_NAME_LEN);
s.icon = (icon != nullptr && icon[0] != '\0') ? icon : DefaultIcon();
PopulateFromEquipped(&s);
g_sets.push_back(std::move(s));
@@ -209,8 +252,6 @@ bool Rename(uint32_t setID, const char *newName) {
if (clash != nullptr && clash->setID != setID)
return false;
g_sets[idx].name = newName;
if (g_sets[idx].name.size() > MAX_NAME_LEN)
g_sets[idx].name.resize(MAX_NAME_LEN);
Persist();
FireChanged();
return true;
@@ -229,6 +270,35 @@ bool Delete(uint32_t setID) {
return true;
}
void SetActionSlot(int slot0, uint32_t setID) {
EnsureLoaded();
if (g_path.empty())
return;
bool changed = false;
for (Set &s : g_sets) {
auto &slots = s.actionSlots;
for (auto it = slots.begin(); it != slots.end();) {
if (*it == slot0 && s.setID != setID) {
it = slots.erase(it);
changed = true;
} else {
++it;
}
}
if (s.setID == setID) {
bool present = false;
for (const int v : slots)
present = present || v == slot0;
if (!present) {
slots.push_back(slot0);
changed = true;
}
}
}
if (changed)
Persist();
}
void IgnoreSlot(int slot1Based) {
if (slot1Based < 1 || slot1Based > SLOT_COUNT)
return;
+12 -1
View File
@@ -43,15 +43,26 @@ int IndexOf(uint32_t setID); // 0-based; -1 if missing
// until the next mutation.
std::vector<const Set *> SetsContainingItem(uint64_t itemGuid);
// 4.3.4 `ScrubString(src, dest, MAX_NAME_CHARS, …, flags 1)`: drops `|`,
// cuts at the first line break (`\r`, `\n`, or a literal `\n` / `|n`), and
// cuts at the MAX_NAME_CHARS-th character. Writes what survives to `out`
// and returns false when anything was cut or `src` isn't valid UTF-8 —
// the caller's "Invalid string for setName" error.
bool ScrubName(const char *src, std::string *out);
// Mutations — bump-and-save semantics. Each returns the affected
// set's ID (or 0 on failure), persists to disk, and fires
// `EQUIPMENT_SETS_CHANGED`. Callers don't need to manage the file
// directly.
// directly. Names must already be scrubbed (`ScrubName`).
uint32_t Create(const char *name, const char *icon);
bool SaveExisting(uint32_t setID, const char *icon);
bool Rename(uint32_t setID, const char *newName);
bool Delete(uint32_t setID);
// Records that 0-based action slot `slot0` holds `setID` (0 = holds no
// set). Drops the slot from whichever set had it; persists on change.
void SetActionSlot(int slot0, uint32_t setID);
// Per-session "skip this slot on next save" state. Survives until the
// addon clears it or the game session ends; not persisted.
void IgnoreSlot(int slot1Based);
+84 -9
View File
@@ -13,6 +13,7 @@
#include "Locations.h"
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "object/Resolve.h"
@@ -88,7 +89,11 @@ int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
} // namespace
int FindGUID(uint64_t targetGuid) {
bool BankerOpen() {
return Game::Read<uint64_t>(Offsets::VAR_BANK_GATE_GUID) != 0;
}
int FindGUID(uint64_t targetGuid, bool includeBank) {
if (targetGuid == GUID_EMPTY || targetGuid == GUID_IGNORED)
return 0;
@@ -110,14 +115,6 @@ int FindGUID(uint64_t targetGuid) {
return PackBag(0, s - LINEAR_BACKPACK_FIRST + 1);
}
// Main bank (linear 39..62 → slots 1..24). Direct read; the bank
// gate at `VAR_BANK_GATE_GUID` doesn't affect the underlying data.
for (int s = Offsets::INVMGR_BANK_MAIN_FIRST_SLOT;
s <= Offsets::INVMGR_BANK_MAIN_LAST_SLOT; ++s) {
if (guids[s] == targetGuid)
return PackMainBank(s - Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1);
}
// Player bags 1..4 — bag containers live at linear 19..22 of the
// player invMgr; their contents live in each bag's own invMgr.
for (int bag = 1; bag <= 4; ++bag) {
@@ -127,6 +124,17 @@ int FindGUID(uint64_t targetGuid) {
return PackBag(bag, slot);
}
if (!includeBank)
return 0;
// Main bank (linear 39..62 → slots 1..24). Direct read; the bank
// gate at `VAR_BANK_GATE_GUID` doesn't affect the underlying data.
for (int s = Offsets::INVMGR_BANK_MAIN_FIRST_SLOT;
s <= Offsets::INVMGR_BANK_MAIN_LAST_SLOT; ++s) {
if (guids[s] == targetGuid)
return PackMainBank(s - Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1);
}
// Bank bags 5..10 — bag containers at linear 63..68. Same walk as
// player bags; works without the bank window being open.
for (int linear = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
@@ -158,4 +166,71 @@ void SnapshotPaperdoll(uint64_t *out) {
out[i] = guids[i];
}
Status GetStatus(const Set &set) {
Status st{};
const bool bank = BankerOpen();
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = set.items[i];
if (g == GUID_EMPTY)
continue;
if (g == GUID_IGNORED) {
++st.ignored;
continue;
}
const int loc = FindGUID(g, bank);
if (loc == 0)
st.missingSlots[st.missing++] = i + 1;
else if ((loc & (LOC_BAGS | LOC_BANK)) == 0)
++st.equipped;
else
++st.inInventory;
}
st.total = st.equipped + st.inInventory + st.missing;
return st;
}
bool IsEquipped(const Set &set) {
uint64_t worn[SLOT_COUNT];
SnapshotPaperdoll(worn);
for (int i = 0; i < SLOT_COUNT; ++i) {
if (set.items[i] != GUID_IGNORED && set.items[i] != worn[i])
return false;
}
return true;
}
namespace {
bool IsLocked(uint64_t guid) {
const uint8_t *item = ResolveItemByGUID(guid);
if (item == nullptr)
return false;
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
item + Offsets::OFF_ITEM_CLIENT_LOCK);
return (flags & Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
}
} // namespace
bool ContainsLockedItems(const Set &set) {
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = set.items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (FindGUID(g, true) != 0 && IsLocked(g))
return true;
}
return false;
}
bool UsesLockedSlots(const Set &set) {
uint64_t worn[SLOT_COUNT];
SnapshotPaperdoll(worn);
for (int i = 0; i < SLOT_COUNT; ++i) {
if (set.items[i] != GUID_IGNORED && worn[i] != 0 && IsLocked(worn[i]))
return true;
}
return false;
}
} // namespace EquipmentSet::Locations
+55 -16
View File
@@ -19,23 +19,45 @@
namespace EquipmentSet::Locations {
// Searches every location the local player owns for an item whose
// GUID matches `targetGuid`, and returns the packed location code
// (see Set.h's `PackEquipped`/`PackBag`/`PackMainBank`/`PackBankBag`).
// Returns 0 if the GUID isn't found anywhere.
// True while the bank window is open (the engine's banker GUID at
// `VAR_BANK_GATE_GUID` is set) — 4.3.4's `CGBankInfo::m_banker`. The
// server only lets an item leave the bank while a banker is in range
// (`HandleSwapItem`'s `CanUseBank`), so a set treats bank items as
// usable only then.
bool BankerOpen();
// Searches the local player's paperdoll, backpack and bags — and the
// bank when `includeBank` — for an item whose GUID matches `targetGuid`,
// and returns the packed location code (see Set.h's
// `PackEquipped`/`PackBag`/`PackMainBank`/`PackBankBag`). Returns 0 if
// the GUID isn't found.
//
// Coverage — ALL locations are read via the player invMgr's flat
// GUID array at `+0x04` and (for bag contents) via each bag's own
// invMgr reached through the bag's vtable `+0x10`. None of this
// goes through the engine's `GetItemBySlot` / `PackBagSlot` path,
// so:
// - no Lua stack is touched (safe to call from any callback)
// - bank items resolve without the bank window ever being open
// (the GUIDs are populated from server data at login; only
// `GetItemBySlot` is gated by `VAR_BANK_GATE_GUID`, the
// underlying data is always present — same trick
// `C_Item.GetItemCount` uses)
int FindGUID(uint64_t targetGuid);
// Every location is read via the player invMgr's flat GUID array at
// `+0x04` and (for bag contents) via each bag's own invMgr reached
// through the bag's vtable `+0x10`. None of this goes through the
// engine's `GetItemBySlot` / `PackBagSlot` path, so no Lua stack is
// touched (safe to call from any callback), and bank GUIDs read even
// with the window closed (populated from server data at login).
int FindGUID(uint64_t targetGuid, bool includeBank);
// 4.3.4 `CGEquipmentManager::GetSetStatus`. Each non-empty slot counts
// once: ignored, worn (in ANY equipment slot), carried (bags, and the
// bank only while `BankerOpen`), else missing. `total` excludes
// ignored slots. `missingSlots[0..missing)` are 1-based slots.
struct Status {
int total;
int equipped;
int inInventory;
int missing;
int ignored;
int missingSlots[SLOT_COUNT];
};
Status GetStatus(const Set &set);
// 4.3.4 `CGEquipmentManager::IsSetEquipped`: every slot the set doesn't
// ignore holds exactly what the set saved — including empty for a slot
// saved empty.
bool IsEquipped(const Set &set);
// Resolves a GUID to a `CGItem*` via the engine's GUID→CObject
// resolver (`FUN_OBJECT_RESOLVE_BY_GUID`). Returns null if the item
@@ -54,4 +76,21 @@ const uint8_t *ResolveItemByGUID(uint64_t guid);
// pre-swap locations. Tracking moves locally bypasses that lag.
void SnapshotPaperdoll(uint64_t *out);
// True when any item of `set` the player still carries sits in the
// client-side in-transaction lock (a pending swap, mail / trade attach,
// or cursor pickup that an equip would race). Reads the CGItem instance
// flag `OFF_ITEM_CLIENT_LOCK` bit 0 — the bit `C_Item.IsLocked` checks.
//
// Gated on `FindGUID` BEFORE the lock flag: an item the player deleted,
// mailed or traded away can linger in the engine's CGItem cache with
// whatever lock bits it last had (often the in-transaction state of the
// delete itself), so reading it would report the set locked forever. A
// missing item is missing, not locked.
bool ContainsLockedItems(const Set &set);
// 4.3.4 `CGEquipmentManager::SetUsesLockedSlots`: whether an item worn in
// a slot the set would change (any slot it doesn't ignore) is locked. The
// action bar greys a set button on this as well as `ContainsLockedItems`.
bool UsesLockedSlots(const Set &set);
} // namespace EquipmentSet::Locations
+141
View File
@@ -0,0 +1,141 @@
// 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/>.
#include "Pending.h"
#include "Game.h"
#include "Locations.h"
#include "Offsets.h"
#include "Text.h"
#include "event/Custom.h"
#include "net/PacketDispatch.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include <cstring>
namespace EquipmentSet::Pending {
namespace {
// How long the paperdoll may sit unchanged before a swap that never reached
// its target is given up on. Each change restarts the wait, so this only
// has to cover one server round trip, not the whole swap.
constexpr uint32_t kSettleMs = 3000;
bool g_active = false;
bool g_refused = false;
Request g_req;
uint64_t g_lastWorn[SLOT_COUNT];
uint32_t g_deadline = 0;
// 4.3.4 calls `CGActionBar::UpdateUsable` whenever the pending state flips,
// so set buttons grey out and come back.
void UpdateUsable() {
reinterpret_cast<void(__cdecl *)()>(Offsets::FUN_ACTION_UPDATE_USABLE)();
}
// 4.3.4 `OnEquipmentSetResult`: the event, then the bags-full message, then
// the action bar.
void Finish(bool success) {
g_active = false;
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0) {
const int id = static_cast<int>(g_req.setID);
if (success)
Event::Custom::Fire(evt, "%d%d", 1, id);
else
Event::Custom::Fire(evt, "%s%d", static_cast<const char *>(nullptr), id);
}
if (g_req.bagsFull)
Text::UIError("ERR_EQUIPMENT_MANAGER_BAGS_FULL",
"Equipment swap failed. Inventory is full.");
UpdateUsable();
}
void OnTick() {
if (!g_active)
return;
if (g_refused)
return Finish(false);
uint64_t worn[SLOT_COUNT];
Locations::SnapshotPaperdoll(worn);
bool done = true;
bool changed = false;
for (int i = 0; i < SLOT_COUNT; ++i) {
changed = changed || worn[i] != g_lastWorn[i];
done = done && (!g_req.cares[i] || worn[i] == g_req.want[i]);
}
if (done)
return Finish(!g_req.bagsFull);
const uint32_t now = Time::Clock::NowMs();
if (changed) {
std::memcpy(g_lastWorn, worn, sizeof(worn));
g_deadline = now + kSettleMs;
} else if (Time::Clock::Reached(now, g_deadline)) {
Finish(false);
}
}
bool Moved(uint64_t guid) {
for (int i = 0; i < g_req.movedCount; ++i)
if (g_req.moved[i] == guid)
return true;
return false;
}
// SMSG_INVENTORY_CHANGE_FAILURE — see Offsets.h for the body. The engine's
// own handler shows the message; we only note that the swap failed, and
// settle on the next tick rather than firing Lua from the packet funnel.
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_INVENTORY_CHANGE_FAILURE || !g_active)
return;
const uint8_t error = Net::Read<uint8_t>(packet);
if (error == 0)
return;
if (error == 1)
Net::Read<uint32_t>(packet); // required level
const uint64_t item1 = Net::Read<uint64_t>(packet);
const uint64_t item2 = Net::Read<uint64_t>(packet);
if ((item1 != 0 && Moved(item1)) || (item2 != 0 && Moved(item2)))
g_refused = true;
}
// 4.3.4 clears the pending swap on enter-world; a login or /reload is the
// point nothing can still be in flight for the UI that started it.
void OnInGameInit() { g_active = false; }
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
const Net::PacketDispatch::AutoSubscribe _packet{&OnPacket};
const Game::ModuleAutoRegister _init{&OnInGameInit};
} // namespace
bool CanUse() { return !g_active; }
void Begin(const Request &req) {
g_req = req;
g_active = true;
g_refused = false;
Locations::SnapshotPaperdoll(g_lastWorn);
g_deadline = Time::Clock::NowMs() + kSettleMs;
const int evt = kEvtSwapPending.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d", static_cast<int>(req.setID));
UpdateUsable();
}
} // namespace EquipmentSet::Pending
+57
View File
@@ -0,0 +1,57 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Set.h"
#include <cstdint>
// The swap in flight — 4.3.4's `m_swapPending` / `m_pendingSetID`.
//
// 4.3.4 sends one CMSG_EQUIPMENT_SET_USE and waits for
// SMSG_USE_EQUIPMENT_SET_RESULT. 1.12 has neither: a set swap is a series of
// ordinary CMSG_SWAP_ITEMs, and only a refused one answers
// (SMSG_INVENTORY_CHANGE_FAILURE). So the result is read off the engine:
// success — the paperdoll holds what the swap asked for;
// failure — the server refused a move that touched one of our items, or
// the swap found no room for an item it had to take off;
// neither — nothing more will arrive, because a move can wait on the
// player (the engine parks a bind-on-equip item behind
// EQUIP_BIND_CONFIRM, and a declined prompt sends nothing) or on
// an item-cache query. The swap then ends as a failure once the
// paperdoll has stopped changing for kSettleMs.
namespace EquipmentSet::Pending {
// 4.3.4 `CanUseEquipmentSets`: false while a swap is in flight.
bool CanUse();
// What one swap asked for. `want[i]` is what slot i+1 must end up holding
// (0 = empty); `cares[i]` is false for slots the swap left alone (ignored,
// combat-locked, missing items, or a move that could not be sent). `moved`
// lists every item GUID a move touched — set items and the items they
// displaced — so a refusal can be matched to this swap.
struct Request {
uint32_t setID;
uint64_t want[SLOT_COUNT];
bool cares[SLOT_COUNT];
uint64_t moved[SLOT_COUNT * 2];
int movedCount;
bool bagsFull; // an item to take off had no free bag slot
};
// Starts tracking `req`, fires EQUIPMENT_SWAP_PENDING and greys the set
// buttons. Settles on a later world tick (see above).
void Begin(const Request &req);
} // namespace EquipmentSet::Pending
+20 -16
View File
@@ -17,6 +17,7 @@
#include <cstdint>
#include <string>
#include <vector>
namespace EquipmentSet {
@@ -48,15 +49,26 @@ struct Set {
// and entries loaded from pre-itemID-format files (the slot line
// had no `item=` field).
uint32_t itemIDs[SLOT_COUNT];
// 0-based action-bar slots this set sits on. The server won't save an
// equipment-set button (see `Offsets::ACTION_TYPE_EQUIPMENT_SET`), so
// the placement lives here, with the set: deleting the set drops it.
std::vector<int> actionSlots;
};
// Engine convention for SaveEquipmentSet-style "no icon given": modern
// FrameXML defaults to the question-mark icon when callers pass nil.
inline const char *DefaultIcon() { return "INV_Misc_QuestionMark"; }
// Vanilla 1.12 engine accepts equipment-set names up to 32 chars
// (1.15.x is also 32). 4.3.4 used the same buffer size.
constexpr int MAX_NAME_LEN = 31;
// 4.3.4's `Script_SaveEquipmentSet` / `Script_ModifyEquipmentSet` scrub the
// name with `ScrubString(name, dest, 32, …)`, which rejects a name that
// reaches 32 characters (so 31 is the longest that passes). Its 10-set cap
// (`CanCreateSet`) sized a server-side buffer we don't have, so there is
// no set limit here.
constexpr int MAX_NAME_CHARS = 32;
// 4.3.4 `UseEquipmentSet`: in combat only these slots swap (0-based 15..17 —
// main hand, off hand, ranged); every other slot keeps what it wears.
inline bool SwapsInCombat(int slot1Based) { return slot1Based >= 16 && slot1Based <= 18; }
// `GetItemLocations` encoding — matches Blizzard FrameXML's
// `EquipmentManager_UnpackLocation` (constants are in
@@ -119,20 +131,12 @@ constexpr int LOC_IGNORED = 1;
// (create / save / modify / delete /
// ignore-slot change). Addon UI re-reads
// its list / button state on this.
// EQUIPMENT_SWAP_PENDING — `(setID: int)`; fires at the START of
// `UseEquipmentSet`, right after the set
// exists check passes. Modern addons
// gate "swap-in-progress" UI state on
// this (grey out the set button until
// FINISHED arrives).
// EQUIPMENT_SWAP_PENDING — `(setID: int)`; fires once
// `UseEquipmentSet` has sent its swaps.
// EQUIPMENT_SWAP_FINISHED — `(success: bool, setID: int)`; fires
// at the end of `UseEquipmentSet`.
// `success` is false when the set
// doesn't exist, true otherwise (we
// don't probe whether each individual
// pickup→equip pair actually completed,
// matching modern's "we dispatched"
// definition of success).
// when the swap settles — see
// `EquipmentSet::Pending`. `success` is
// 1 or nil, never 0 (a Lua 0 is true).
inline const Event::Custom::AutoReserve kEvtSetsChanged{"EQUIPMENT_SETS_CHANGED"};
inline const Event::Custom::AutoReserve kEvtSwapPending{"EQUIPMENT_SWAP_PENDING"};
inline const Event::Custom::AutoReserve kEvtSwapFinished{"EQUIPMENT_SWAP_FINISHED"};
+11 -2
View File
@@ -13,6 +13,7 @@
#include "Storage.h"
#include "Offsets.h"
#include "settings/Paths.h"
#include <cstdint>
@@ -100,8 +101,6 @@ bool Load(const std::string &path, std::vector<Set> *outSets) {
std::string value;
if (ParseField(line, "name", &value)) {
if (value.size() > MAX_NAME_LEN)
value.resize(MAX_NAME_LEN);
current->name = std::move(value);
continue;
}
@@ -110,6 +109,14 @@ bool Load(const std::string &path, std::vector<Set> *outSets) {
continue;
}
// `action N` — the set sits on 1-based action-bar slot N.
if (line.rfind("action ", 0) == 0) {
const long slot1Based = std::strtol(line.c_str() + 7, nullptr, 10);
if (slot1Based >= 1 && slot1Based <= Offsets::ACTION_TABLE_MAX_SLOTS)
current->actionSlots.push_back(static_cast<int>(slot1Based) - 1);
continue;
}
// Slot line: `slot N guid=0xHEX [item=ITEMID]` or `slot N ignored`.
// The `item=` field is optional — pre-itemID files don't have it.
// Backward compat: missing `item=` leaves itemIDs[idx] = 0, which
@@ -160,6 +167,8 @@ bool Save(const std::string &path, const std::vector<Set> &sets) {
out << "set " << s.setID << "\n";
out << " name=" << s.name << "\n";
out << " icon=" << s.icon << "\n";
for (const int slot0 : s.actionSlots)
out << " action " << (slot0 + 1) << "\n";
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = s.items[i];
if (g == GUID_EMPTY)
+68
View File
@@ -0,0 +1,68 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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/>.
#include "Text.h"
#include "Data.h"
#include "Game.h"
#include "event/Custom.h"
namespace EquipmentSet::Text {
std::string Global(const char *name, const char *fallback) {
void *L = Game::Lua::State();
if (L == nullptr)
return fallback;
// Raw read: callers include the world tick, where no `__index` may run.
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, name);
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
std::string out = fallback;
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, -1);
if (s != nullptr)
out = s;
}
Game::Lua::SetTop(L, top);
return out;
}
std::string FormatS(const std::string &format, const std::string &arg) {
const size_t at = format.find("%s");
if (at == std::string::npos)
return format;
return format.substr(0, at) + arg + format.substr(at + 2);
}
void UIError(const char *globalName, const char *fallback, const char *setName) {
const int evt = Event::Custom::LookupByName("UI_ERROR_MESSAGE");
if (evt < 0)
return;
std::string text = Global(globalName, fallback);
if (setName != nullptr)
text = FormatS(text, setName);
Event::Custom::Fire(evt, "%s", text.c_str());
}
std::string SetList(uint64_t itemGuid) {
std::string list;
const std::string delimiter = Global("PLAYER_LIST_DELIMITER", ", ");
for (const Set *s : Data::SetsContainingItem(itemGuid)) {
if (!list.empty())
list += delimiter;
list += s->name;
}
return list;
}
} // namespace EquipmentSet::Text
+40
View File
@@ -0,0 +1,40 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
#include <string>
// The equipment manager's player-facing text: localized globals, the error
// messages 4.3.4 shows through UI_ERROR_MESSAGE, and the set-name list its
// item tooltips and `GetContainerItemEquipmentSetInfo` share.
namespace EquipmentSet::Text {
// `_G[name]` when it is a string, else `fallback` (4.3.4 FrameScript_GetText).
std::string Global(const char *name, const char *fallback);
// Fires UI_ERROR_MESSAGE with `_G[globalName]` (or `fallback`), its first
// `%s` replaced by `setName` when one is given — 4.3.4's
// `FrameScript_SignalEvent(UI_ERROR_MESSAGE, "%s", text)`.
void UIError(const char *globalName, const char *fallback, const char *setName = nullptr);
// 4.3.4 `CGEquipmentManager::GetSetList`: the names of every set holding the
// item instance `itemGuid`, joined by PLAYER_LIST_DELIMITER. Empty when none.
std::string SetList(uint64_t itemGuid);
// `format` with its first `%s` replaced by `arg`. By hand, not printf: the
// format is a Lua global any addon can reassign.
std::string FormatS(const std::string &format, const std::string &arg);
} // namespace EquipmentSet::Text
+58 -86
View File
@@ -26,10 +26,8 @@
//
// We back the implementation with the same equipment-set storage
// `C_EquipmentSet.*` already uses (`EquipmentSet::Data`) — no new data
// model needed. Item classification reuses `Locations::FindGUID`,
// which walks the player's flat inventory + bag invMgrs the same way
// `GetItemCount` does, so bank items count as "in inventory" without
// needing the bank window open.
// model needed. The counts are `Locations::GetStatus`, 4.3.4's
// `GetSetStatus`, shared with `C_EquipmentSet.GetEquipmentSetInfo`.
//
// Stock 4.3.4 enumerates each missing item by name (looks up the
// itemID via the engine's full item-cache name resolver). Our storage
@@ -47,101 +45,49 @@
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
// anchor then calls SetEquipmentSet with no trailing :Show().
#include "equipmentset/Tooltip.h"
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
#include "equipmentset/Data.h"
#include "equipmentset/Locations.h"
#include "equipmentset/Set.h"
#include "equipmentset/Text.h"
#include "item/Record.h"
#include "tooltip/Lines.h"
#include "ui/ColorData.h"
#include <cstdint>
#include <string>
namespace EquipmentSet::Tooltip {
namespace {
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
// line-color setter reads. Alpha is always opaque.
uint32_t Pack(double r, double g, double b) {
auto ch = [](double v) {
const int i = static_cast<int>(v * 255.0 + 0.5);
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
};
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
}
// Left-aligned colored line via the engine's raw add-line. The packed
// color is a local so its address stays valid across the call.
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
const uint32_t color = Pack(r, g, b);
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// Line colors, packed 0xAARRGGBB as ::Tooltip::Lines::Add wants.
constexpr uint32_t kWhite = 0xFFFFFFFFu;
constexpr uint32_t kGreen = 0xFF00FF00u;
constexpr uint32_t kGray = 0xFF808080u;
constexpr uint32_t kRed = 0xFFFF0000u;
// 4.3.4's tooltip title color, which its "Equipment Sets:" line uses.
constexpr uint32_t kNormal = UI::ColorData::ByTag("NORMAL_FONT_COLOR");
// Adds one localized "N unit" line: `string.format(_G[globalName] or
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
// with the C `fallback` for servers stripped of the standard strings),
// then the native colored add-line.
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
const char *fallback, int n, double r, double g, double b) {
const char *fallback, int n, const uint32_t &color) {
const int savedTop = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
const char *text = Game::Lua::ToString(L, -1);
if (text != nullptr)
AddColoredLine(self, text, r, g, b);
::Tooltip::Lines::Add(self, text, color);
Game::Lua::SetTop(L, savedTop);
}
struct Tally {
int equipped;
int inInventory;
int ignored;
int missing;
// ItemIDs of slots classified as missing. Indexed 0..missing-1.
// Capacity matches SLOT_COUNT (worst case: every slot is missing).
// Stays 0 for entries loaded from pre-itemID-format files.
uint32_t missingItemIDs[SLOT_COUNT];
};
// Walks a set's slots, classifying each non-empty entry into the
// equipped / in-inventory / ignored / missing buckets via
// `Locations::FindGUID`. For missing slots, also records the saved
// itemID so the tooltip can resolve names. Stack-free.
Tally TallySet(const Set &s) {
Tally t{};
for (int i = 0; i < SLOT_COUNT; ++i) {
const uint64_t g = s.items[i];
if (g == GUID_IGNORED) {
t.ignored++;
continue;
}
if (g == GUID_EMPTY)
continue;
const int loc = Locations::FindGUID(g);
if (loc == 0) {
// Live GUID isn't in the player's object map. The saved
// itemID (if any) is the only handle we have for naming.
t.missingItemIDs[t.missing++] = s.itemIDs[i];
continue;
}
// LOC_BAGS covers backpack / player bags / bank bags; LOC_BANK
// alone (no LOC_BAGS) covers main bank slots. Both count as
// "in inventory" for the tooltip summary — matches the modern
// category where everything-but-equipped is one bucket.
if ((loc & (LOC_BAGS | LOC_BANK)) != 0) {
t.inInventory++;
} else {
t.equipped++;
}
}
return t;
}
} // namespace
int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
@@ -160,17 +106,17 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
if (s == nullptr)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
void *self = Game::Lua::ResolveTooltip(L);
if (self == nullptr)
return 0;
const Tally t = TallySet(*s);
const int totalSlots = t.equipped + t.inInventory + t.missing;
const Locations::Status t = Locations::GetStatus(*s);
const int totalSlots = t.total;
// Header — set name, white. Clear first so it lands as line 0
// (the clear resets the line count the add-line body indexes from).
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
::Tooltip::Lines::Clear(self);
::Tooltip::Lines::Add(self, s->name.c_str(), kWhite);
// Body lines — try Blizzard's localized FORMAT_* globals first.
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
@@ -178,19 +124,19 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
// override by reassigning `_G[name]` at runtime. The fallbacks
// only fire on servers stripped of the standard GlobalStrings.
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
totalSlots, 1.0, 1.0, 1.0);
totalSlots, kWhite);
if (t.equipped > 0) {
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
t.equipped, 0.0, 1.0, 0.0); // green
t.equipped, kGreen);
}
if (t.inInventory > 0) {
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
t.inInventory, 1.0, 1.0, 1.0);
t.inInventory, kWhite);
}
if (t.ignored > 0) {
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
t.ignored, 0.5, 0.5, 0.5); // gray
t.ignored, kGray);
}
// Missing slots: list each by name using the itemID we stored at
// save time, then look the name up in the item cache. Matches the
@@ -200,7 +146,7 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
// a `(N unnamed missing)` line below.
int unnamedMissing = 0;
for (int i = 0; i < t.missing; ++i) {
const uint32_t id = t.missingItemIDs[i];
const uint32_t id = s->itemIDs[t.missingSlots[i] - 1];
if (id == 0) {
unnamedMissing++;
continue;
@@ -227,7 +173,7 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
Game::Lua::Call(L, 2, 1);
const char *line = Game::Lua::ToString(L, -1);
if (line != nullptr)
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
::Tooltip::Lines::Add(self, line, kRed);
Game::Lua::SetTop(L, savedTop);
}
if (unnamedMissing > 0) {
@@ -235,15 +181,39 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
// No Blizzard format global maps cleanly here; addons can
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
"%d missing", unnamedMissing, kRed);
}
// Show ourselves — self is still at Lua stack index 1 (Script_Show
// reads it there). Retail's SetEquipmentSet shows internally too.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
::Tooltip::Lines::Show(L);
return 0;
}
namespace {
CVar::Factory::Handle g_hideSetsCVar = nullptr;
} // namespace
void AfterItemBuild(void *self, const void *itemGuidPtr) {
if (itemGuidPtr == nullptr || CVar::Factory::GetInt(g_hideSetsCVar) != 0)
return;
const uint64_t itemGuid = *static_cast<const uint64_t *>(itemGuidPtr);
// The builder adds no lines when it bailed on an item-cache miss; it
// runs again once the item arrives.
if (itemGuid == 0 || Game::Read<int>(self, Offsets::OFF_GAMETOOLTIP_NUM_LINES) == 0)
return;
const std::string list = Text::SetList(itemGuid);
if (list.empty())
return;
const std::string line = Text::FormatS(
Text::Global("EQUIPMENT_SETS", "Equipment Sets: |cFFFFFFFF%s|r"), list);
::Tooltip::Lines::Add(self, line.c_str(), kNormal, /*wrap*/ true);
}
namespace {
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetEquipmentSet", &Script_GameTooltipSetEquipmentSet},
};
@@ -251,6 +221,8 @@ const Game::Lua::FrameMethodEntry g_methods[] = {
} // namespace
static void RegisterLuaFunctions() {
// 4.3.4's switch for the item-tooltip line; dedups by name on /reload.
g_hideSetsCVar = CVar::Factory::Register("dontShowEquipmentSetsOnItems", "0");
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY),
g_methods,
+30
View File
@@ -0,0 +1,30 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace EquipmentSet::Tooltip {
// `GameTooltip:SetEquipmentSet("setName")` — self at stack[1], the name at
// stack[2]. Exposed so `GameTooltip:SetAction` can show an equipment-set
// button the same way.
int __fastcall Script_GameTooltipSetEquipmentSet(void *L);
// 4.3.4 `CGTooltip::SetItem`'s "Equipment Sets: A, B" line: appended to an
// item tooltip just built for the item instance at `itemGuidPtr` (the item
// builder's second GUID argument; may be null), unless the
// `dontShowEquipmentSetsOnItems` cvar is set. Called by
// `Tooltip::SetEvents`' item-builder co-hook, before OnTooltipSetItem.
void AfterItemBuild(void *self, const void *itemGuidPtr);
} // namespace EquipmentSet::Tooltip
+27 -4
View File
@@ -38,12 +38,19 @@ namespace {
// reservations meant a shifting handful (e.g. ITEM_DATA_LOAD_RESULT /
// GET_ITEM_INFO_RECEIVED) never claimed a slot. Grep `AutoReserve` before
// bumping the reservation count near this ceiling.
constexpr int MAX_RESERVED = 64;
struct ReservedName {
//
// Hit a SECOND time at 64: the codebase reached 65 reservations (60 at file
// scope + the 5 lazy TTS ones), so on any client without VanillaTTS — where
// the TTS five DO construct, and construct last — the 65th
// (VOICE_CHAT_TTS_VOICES_UPDATE) was dropped and its event never fired.
// Count as of the WEAPON_SLOT_CHANGED addition: 66.
constexpr int MAX_RESERVED = 96;
struct Reservation {
const char *name;
int slot; // -1 until claimed
const Game::Doc::Event *doc; // API documentation; nullptr = undocumented
};
ReservedName g_reserved[MAX_RESERVED];
Reservation g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
bool g_writesEnabled = false;
// One-shot latch so a genuinely full event table (no NULL slot left to
@@ -243,13 +250,18 @@ void Grow() {
} // namespace
AutoReserve::AutoReserve(const char *name) {
AutoReserve::AutoReserve(const char *name, const Game::Doc::Event *doc) {
if (name == nullptr)
return;
// Dedup: a name reserved twice resolves both instances to the one entry.
// A descriptor on either instance documents the one reservation (an event
// fired from several modules is declared in a shared header, but a second
// declaration must not silently drop its documentation).
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0) {
index_ = i;
if (g_reserved[i].doc == nullptr)
g_reserved[i].doc = doc;
return;
}
}
@@ -257,6 +269,7 @@ AutoReserve::AutoReserve(const char *name) {
return; // overflow — index_ stays -1, Slot() reports unclaimed
g_reserved[g_reservedCount].name = name;
g_reserved[g_reservedCount].slot = -1;
g_reserved[g_reservedCount].doc = doc;
index_ = g_reservedCount;
++g_reservedCount;
}
@@ -265,6 +278,16 @@ int AutoReserve::Slot() const {
return index_ >= 0 ? g_reserved[index_].slot : -1;
}
int ReservedCount() { return g_reservedCount; }
const char *ReservedName(int index) {
return (index >= 0 && index < g_reservedCount) ? g_reserved[index].name : nullptr;
}
const Game::Doc::Event *ReservedDoc(int index) {
return (index >= 0 && index < g_reservedCount) ? g_reserved[index].doc : nullptr;
}
int LookupByName(const char *name) {
if (name == nullptr)
return -1;
+15 -1
View File
@@ -13,6 +13,7 @@
#pragma once
#include "Game.h"
#include "Offsets.h"
namespace Event::Custom {
@@ -50,14 +51,27 @@ namespace Event::Custom {
// `RegisterEvent` triggers `RetryClaims`), which `Fire` treats as a no-op.
// Do NOT cache the return across frames — read it at fire time (the read
// is two loads; there is nothing to save).
//
// `doc` is the event's API documentation (its System, a summary, and the
// payload fields — see `Game::Doc::Event`); `Api::Documentation` exports it
// under the System's `Events`. A reservation without one is listed by
// `_classicapi_UndocumentedAPI()`.
struct AutoReserve {
explicit AutoReserve(const char *name);
explicit AutoReserve(const char *name, const Game::Doc::Event *doc = nullptr);
int Slot() const;
private:
int index_ = -1; // into the reservation table; -1 = dropped (overflow)
};
// Read access to the reservation table for the API documentation: the
// number of reserved names, the literal name of the i-th (0-based), and its
// descriptor (nullptr when undocumented). Indices are stable for the
// process lifetime (reservations are only ever appended).
int ReservedCount();
const char *ReservedName(int index);
const Game::Doc::Event *ReservedDoc(int index);
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
+8 -20
View File
@@ -113,18 +113,6 @@ void InvalidateLayout(void *fs) {
region, nullptr, 0);
}
// Anchor units → UI pixels: `FUN_0041AE40(FUN_0041AD70() × DAT_007FFD68 × v)`
// = v × [VAR_UI_COORD_SCALE_DIV] × 1024 / [VAR_UI_COORD_SCALE_MUL] — the exact
// push chain Script_GetStringWidth uses (the inverse of
// Tooltip::LinePool::PixelToInternal).
double InternalToPixel(float v) {
const float mul = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_MUL);
const float div = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_DIV);
if (mul == 0.0f)
return 0.0;
return static_cast<double>(v) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
}
// The fs's wrap budget in the units FUN_FONTSTRING_BREAK_ARRAY expects
// (width-getter units — it multiplies by fs+0x7C itself before gxu, verified
// decompile), or 0 when the rect is unresolved. rectWidth ÷ scale, calibrated
@@ -151,7 +139,7 @@ float WrapBudget(void *fs) {
void *ResolveSelf(void *L, const char *usage) {
void *fs = nullptr;
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
fs = Game::Lua::ResolveObject(L, 1);
fs = Game::Lua::ResolveFontString(L, 1, /*raiseError=*/false);
if (fs == nullptr)
Game::Lua::Error(L, "%s", usage);
return fs;
@@ -163,7 +151,7 @@ int __fastcall Script_GetStringHeight(void *L) {
return 0;
const float h = reinterpret_cast<StringHeightInternal_t>(
Offsets::FUN_FONTSTRING_STRING_HEIGHT)(fs);
Game::Lua::PushNumber(L, InternalToPixel(h));
Game::Lua::PushNumber(L, Game::InternalToPixel(h));
return 1;
}
@@ -178,7 +166,7 @@ int __fastcall Script_GetUnboundedStringWidth(void *L) {
}
const float w = reinterpret_cast<MeasureSubstring_t>(
Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING)(fs, nullptr, text, 0);
Game::Lua::PushNumber(L, InternalToPixel(w));
Game::Lua::PushNumber(L, Game::InternalToPixel(w));
return 1;
}
@@ -240,7 +228,7 @@ int __fastcall Script_GetWrappedWidth(void *L) {
// one-line width.
const float budget = WrapBudget(fs);
if (budget <= 0.0f) {
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
Game::Lua::PushNumber(L, Game::InternalToPixel(measure(fs, nullptr, text, 0)));
return 1;
}
// The tooltip auto-size's own recipe (FUN_00530640): break positions from
@@ -254,7 +242,7 @@ int __fastcall Script_GetWrappedWidth(void *L) {
if (segs > 64u)
segs = 64u;
if (segs <= 1u) {
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
Game::Lua::PushNumber(L, Game::InternalToPixel(measure(fs, nullptr, text, 0)));
return 1;
}
int len = 0;
@@ -279,7 +267,7 @@ int __fastcall Script_GetWrappedWidth(void *L) {
if (w > widest)
widest = w;
}
Game::Lua::PushNumber(L, InternalToPixel(widest));
Game::Lua::PushNumber(L, Game::InternalToPixel(widest));
return 1;
}
@@ -289,7 +277,7 @@ int __fastcall Script_GetLineHeight(void *L) {
return 0;
const float h = reinterpret_cast<FsFontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(
fs, nullptr, 1);
Game::Lua::PushNumber(L, InternalToPixel(h));
Game::Lua::PushNumber(L, Game::InternalToPixel(h));
return 1;
}
@@ -362,7 +350,7 @@ int __fastcall Script_GetMaxLines(void *L) {
int __fastcall Script_SetFormattedText(void *L) {
void *fs = nullptr;
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
fs = Game::Lua::ResolveObject(L, 1);
fs = Game::Lua::ResolveFontString(L, 1, /*raiseError=*/false);
if (fs == nullptr || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: fontstring:SetFormattedText(\"format\"[, ...])");
return 0;
+202
View File
@@ -0,0 +1,202 @@
// 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/>.
// Frame alpha as 3.x draws it: a frame's own alpha times its parent's.
//
// Vanilla keeps one alpha byte per frame (Offsets::OFF_FRAME_ALPHA) and copies
// it down: SetAlpha - and the XML `alpha` attribute, which runs the same setter
// - writes the value into every child frame that exists right then, replacing
// the child's own, and SetParent takes nothing from the new parent. So:
// - a frame built or parented inside one that is already transparent draws
// at full alpha. Zygor Guides Viewer's border glow is an alpha="0.0" frame
// holding its glow textures in a child; on vanilla the glow shows at full
// strength from login until its first flash sets the parent's alpha again;
// - a child's own alpha is lost the moment its parent's changes;
// - GetAlpha on a child reports whatever its parent last copied in, so an
// animation fading a child starts from its parent's value.
// 3.x keeps each frame's own alpha and draws it multiplied by its parent's.
//
// Co-hooks of the setter (FUN_FRAME_SET_FRAME_ALPHA), SetParent
// (FUN_FRAME_SET_PARENT), the frame destructor and frame GetAlpha:
// - SetAlpha and XML `alpha` record the frame's own alpha;
// - the byte holds own x the parent's drawn alpha. When it changes, the
// frame's regions recolor and each child is recomputed from its own alpha,
// through the child's own vtable setter, as the engine's copy-down calls
// it - so the Model family's override, which hands the byte to its model,
// still runs;
// - SetParent recomputes the frame and everything under it from the new
// parent. The frame constructor calls it too, after installing the base
// vtable and initializing the byte and both lists;
// - GetAlpha returns the own alpha.
// A frame whose alpha was never set has own alpha 1.
//
// Engine code that reads the byte directly gets the drawn value. That's what
// the region recolors and the Model want. The chat bubble and nameplate fades
// compare it with their own targets. They're WorldFrame children, where drawn
// and own are the same while WorldFrame is opaque.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <unordered_map>
namespace Frame::Alpha {
namespace {
using SetFrameAlpha_t = void(__thiscall *)(void *frame, uint32_t alpha);
using SetParent_t = void(__thiscall *)(void *frame, void *parent);
using Destructor_t = void(__thiscall *)(void *frame);
using Recolor_t = void(__thiscall *)(void *region, int arg);
using ScriptFn_t = int(__fastcall *)(void *L);
SetFrameAlpha_t g_origSetAlpha = nullptr;
SetParent_t g_origSetParent = nullptr;
Destructor_t g_origDestructor = nullptr;
ScriptFn_t g_origGetAlpha = nullptr;
// Own alpha of every live frame that set one other than 255. Every frame
// class's destructor runs FUN_FRAME_DESTRUCTOR, whose hook drops the frame's
// entry, so a frame that outlives a /reload keeps its own alpha.
std::unordered_map<const void *, uint8_t> g_own;
// Set while a frame is being given its drawn alpha through its vtable setter.
bool g_propagating = false;
int g_depth = 0;
// Deeper than any real frame tree; stops a corrupt child list from recursing.
constexpr int kMaxDepth = 64;
uint8_t *DrawnByte(void *frame) {
return static_cast<uint8_t *>(frame) + Offsets::OFF_FRAME_ALPHA;
}
uint8_t Own(const void *frame) {
const auto it = g_own.find(frame);
return it == g_own.end() ? 255 : it->second;
}
void SetOwn(const void *frame, uint8_t alpha) {
if (alpha == 255)
g_own.erase(frame);
else
g_own[frame] = alpha;
}
// Truncating, as 3.3.5's CSimpleFrame::GetEffectiveAlpha (0x00482E60) and
// UpdateAlpha (0x00491680) compute `m_alpha * m_parentAlpha / 255`.
uint8_t Mul(uint8_t a, uint8_t b) {
return static_cast<uint8_t>(static_cast<uint32_t>(a) * b / 255);
}
uint8_t DrawnFor(void *frame, uint8_t own) {
void *parent = Game::Read<void *>(frame, Offsets::OFF_REGION_PARENT);
return parent != nullptr ? Mul(own, *DrawnByte(parent)) : own;
}
// Walks one of the frame's intrusive lists (node+4 next, node+8 object; a NULL
// or low-bit-1 link ends it), as the engine's setter does.
template <typename Fn> void ForEachLink(void *frame, uintptr_t listOffset, Fn fn) {
uintptr_t link = Game::Read<uintptr_t>(frame, listOffset);
while (link != 0 && (link & 1) == 0) {
void *obj = *reinterpret_cast<void *const *>(link + 8);
const uintptr_t next = *reinterpret_cast<const uintptr_t *>(link + 4);
if (obj != nullptr)
fn(obj);
link = next;
}
}
// Gives `frame` its drawn alpha through its own vtable setter.
void ApplyDrawn(void *frame, uint8_t drawn) {
if (*DrawnByte(frame) == drawn || g_depth >= kMaxDepth)
return;
const bool saved = g_propagating;
g_propagating = true;
++g_depth;
void **vtable = *static_cast<void ***>(frame);
reinterpret_cast<SetFrameAlpha_t>(
vtable[Offsets::VTBL_FRAME_SET_FRAME_ALPHA / sizeof(void *)])(frame, drawn);
--g_depth;
g_propagating = saved;
}
// The engine setter's body with `drawn` in place of the copied value: an
// unchanged byte returns early, otherwise the regions recolor and each child
// is redrawn from its own alpha. SetFrameAlpha_h runs it in place of the
// original, which would copy the value down.
void Draw(void *frame, uint8_t drawn) {
uint8_t *byte = DrawnByte(frame);
if (*byte == drawn)
return;
*byte = drawn;
ForEachLink(frame, Offsets::OFF_FRAME_REGION_LIST, [](void *region) {
void **vtable = *static_cast<void ***>(region);
reinterpret_cast<Recolor_t>(
vtable[Offsets::VTBL_REGION_RECOLOR / sizeof(void *)])(region, 0);
});
ForEachLink(frame, Offsets::OFF_FRAME_CHILD_LIST,
[drawn](void *child) { ApplyDrawn(child, Mul(Own(child), drawn)); });
}
void __fastcall SetFrameAlpha_h(void *frame, void * /*edx*/, uint32_t alpha) {
const uint8_t value = static_cast<uint8_t>(alpha);
if (g_propagating) {
// Reached from ApplyDrawn: `value` is already the drawn alpha.
Draw(frame, value);
return;
}
SetOwn(frame, value);
Draw(frame, DrawnFor(frame, value));
}
void __fastcall SetParent_h(void *frame, void * /*edx*/, void *parent) {
g_origSetParent(frame, parent);
ApplyDrawn(frame, DrawnFor(frame, Own(frame)));
}
void __fastcall Destructor_h(void *frame, void * /*edx*/) {
g_own.erase(frame);
g_origDestructor(frame);
}
// frame:GetAlpha() - the own alpha, not the drawn one.
int __fastcall GetAlpha_h(void *L) {
const int n = g_origGetAlpha(L);
if (n != 1)
return n;
const void *frame = Game::Lua::ResolveObject(L, 1);
if (frame == nullptr)
return n;
Game::Lua::SetTop(L, -2);
Game::Lua::PushNumber(L, Own(frame) / 255.0);
return 1;
}
const Game::HookAutoRegister _setAlphaHook{Offsets::FUN_FRAME_SET_FRAME_ALPHA,
reinterpret_cast<void *>(&SetFrameAlpha_h),
reinterpret_cast<void **>(&g_origSetAlpha)};
const Game::HookAutoRegister _setParentHook{Offsets::FUN_FRAME_SET_PARENT,
reinterpret_cast<void *>(&SetParent_h),
reinterpret_cast<void **>(&g_origSetParent)};
const Game::HookAutoRegister _destructorHook{Offsets::FUN_FRAME_DESTRUCTOR,
reinterpret_cast<void *>(&Destructor_h),
reinterpret_cast<void **>(&g_origDestructor)};
const Game::HookAutoRegister _getAlphaHook{Offsets::FUN_SCRIPT_FRAME_GETALPHA,
reinterpret_cast<void *>(&GetAlpha_h),
reinterpret_cast<void **>(&g_origGetAlpha)};
} // namespace
} // namespace Frame::Alpha
+211 -91
View File
@@ -34,12 +34,15 @@
// Installing OnEnter/OnLeave would be clobbered by an addon's own
// `SetScript("OnEnter", …)` (pfUI sets `unit` before its scripts), and there's
// nothing to read from Lua anyway — the mouseover unit is an engine global. So
// we mirror retail / SuperWoW's `SetMouseoverUnit`: watch the engine's
// mouse-focus frame (`*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)`, the
// CFrameScriptObject* `GetMouseFocus` returns) once per frame on the shared
// WorldTick, and when it's a `unit`-attributed frame call the engine's FULL
// mouseover setter (FUN_00492890) — 1:1 with hovering the unit's 3D model:
// model highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the GUID slot.
// we mirror 4.3.4's CGWorldFrame::OnLayerUpdate: once per frame (the shared
// WorldTick) read the engine's mouse-focus frame
// (`*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)`, the
// CFrameScriptObject* `GetMouseFocus` returns), ask FrameXML's
// `SecureButton_GetModifiedUnit` for its unit (see ModifiedUnit), and hand
// that to the engine's FULL mouseover setter (FUN_00492890) — 1:1 with
// hovering the unit's 3D model: model highlight, UPDATE_MOUSEOVER_UNIT, the
// GUID slot, and the mouseover tooltip. The tooltip is a deliberate
// difference: 4.3.4 shows it only when the world frame has the focus.
// Stomp-proof: the engine's own 3D-hover setter is event-driven, so nothing
// overwrites the slot while the cursor is over UI.
//
@@ -98,12 +101,13 @@
// `RegisterForClicks("RightButtonUp")`, which real unit frames do.
//
// `OnAttributeChanged` is a real SetScript-able handler: SetAttribute (not
// SetAttributeNoHandler) fires it with `this` = frame, `arg1` = name, `arg2` =
// value. Implemented by co-hooking the base-frame script-name resolver
// SetAttributeNoHandler) fires it as `(self, name, value)` with the vanilla
// `this` / `arg1` / `arg2` globals set too (see FireAttributeChanged). Implemented by co-hooking the base-frame script-name resolver
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
#include "frame/Attributes.h"
#include "frame/ScriptArgs.h"
#include "Common.h"
#include "Game.h"
@@ -117,6 +121,8 @@
#include "tick/WorldTick.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include "unit/Tooltip.h"
#include "xml/FrameLoad.h"
#include <cctype>
#include <cstdint>
@@ -124,6 +130,7 @@
#include <cstring>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace Frame::Attributes {
@@ -313,10 +320,10 @@ bool CallTableFunc2Str(void *L, const char *table, const char *method,
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
// Keyed by the same pointer the engine stores at the mouse-focus slot. Single-
// threaded (Lua + WorldTick on the main thread), so no synchronization needed.
std::unordered_map<const void *, std::string> g_unitByFrame;
// Frames whose mouse we enabled when their `unit` attribute was first set
// (a bare frame never becomes the mouse focus otherwise). Keyed by the C
// object. Single-threaded (Lua + WorldTick on the main thread).
std::unordered_set<const void *> g_unitFrames;
// The last GUID *we* wrote into the mouseover slot. We only touch the slot on a
// change of our own value, so when the cursor isn't over a unit frame (guid 0)
@@ -386,24 +393,69 @@ void BuildRosterUnitTooltip(uint64_t guid) {
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_BUILD_UNIT))(tooltip, packed);
}
// The unit a hovered frame stands for — 4.3.4's CGWorldFrame::OnLayerUpdate:
// for the mouse-focus frame it pcalls the FrameXML function
// `SecureButton_GetModifiedUnit(frame, macroRunning)` under the engine's
// script error handler and uses the token it returns, so `unitsuffix` and
// modifier-qualified `unit` attributes count. 4.3.4 asks protected frames;
// 1.12 has no protection, so the analog is a frame that has attributes at
// all. macroRunning is nil while hovering (no macro is executing), so the
// call passes the frame alone. Returns "" for no unit.
std::string ModifiedUnit(void *L, void *frame) {
std::string token;
const int top = Game::Lua::GetTop(L);
Game::Lua::PushObject(L, frame); // [frame]
const int fi = Game::Lua::GetTop(L);
if (PushSub(L, fi, /*create*/ false)) { // [frame, attrs]
Game::Lua::SetTop(L, fi);
int errIdx = 0;
const int errRef = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
if (errRef > 0) {
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, errRef);
errIdx = Game::Lua::GetTop(L);
}
Game::Lua::PushString(L, "SecureButton_GetModifiedUnit");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, fi);
if (Game::Lua::PCall(L, 1, 1, errIdx) == 0 &&
Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
token = Game::Lua::ToString(L, -1);
}
}
Game::Lua::SetTop(L, top);
return token;
}
void MouseoverTick() {
const void *focus = CurrentMouseFocus();
void *focus = const_cast<void *>(CurrentMouseFocus());
uint64_t guid = 0;
std::string token;
if (focus != nullptr) {
auto it = g_unitByFrame.find(focus);
if (it != g_unitByFrame.end())
guid = Unit::Identity::GuidForToken(
it->second.c_str()); // live — follows target changes
if (void *L = Game::Lua::State()) {
token = ModifiedUnit(L, focus);
if (!token.empty())
guid = Unit::Identity::GuidForToken(token.c_str()); // live — follows target changes
}
}
if (guid != g_lastSet) {
// The tooltip built below is for the frame's unit, so `GetUnit()`
// answers with the frame's token — "mouseover" would not resolve for
// a member with no live object (see Unit::Tooltip).
if (guid != 0)
Unit::Tooltip::StageToken(
Game::Read<void *>(static_cast<uintptr_t>(Offsets::VAR_GAMETOOLTIP_OBJECT_PTR)),
token.c_str());
CallEngineMouseover(guid); // sets slot + highlight + tooltip + event
// Offline / far out-of-range party or raid member: the setter wrote the
// GUID slot but skipped the tooltip (no live object). Build it from the
// roster so hovering the frame still shows name / level / "Offline".
if (guid != 0 && !GuidHasLiveUnit(guid))
BuildRosterUnitTooltip(guid);
Unit::Tooltip::ClearStagedToken();
g_lastSet = guid;
}
}
@@ -883,60 +935,31 @@ const Game::HookAutoRegister _attrResolverHook{
reinterpret_cast<void *>(&FrameResolver_h),
reinterpret_cast<void **>(&g_frameResolverOriginal)};
// Fire OnAttributeChanged on `frame`, with arg1 = name and arg2 = the new value
// (still at stack[3] in DoSet). Recursion-guarded so a handler that itself calls
// SetAttribute doesn't loop. The invoker (FUN_FRAME_INVOKE_SCRIPT) binds `this`
// and runs the handler under its own protected pcall; it doesn't restore the
// stack top, so snapshot/restore around it (as Tooltip::SetEvents does).
int g_firingAttr = 0;
using InvokeAttrScript_t = void(__fastcall *)(uint32_t handler, void *frame);
// Fire OnAttributeChanged on `frame` — 4.3.4's
// CSimpleFrame::RunOnAttributeChangedScript: the handler gets the lowercased
// name and the new value (still at stack[3] in DoSet) as real arguments,
// `(self, name, value)`, plus the vanilla `this`/`arg1`/`arg2` globals.
// RunWithValues carries arbitrary Lua values, which the engine's
// printf-style runners cannot, and restores the stack and globals.
//
// No recursion guard, as in 4.3.4: a handler that sets attributes — its own
// or another frame's — fires their handlers too. Secure headers and the
// state driver depend on that. A runaway loop ends at Lua's C-call limit as
// an ordinary, reported script error.
void FireAttributeChanged(void *L, void *frame, const char *name) {
if (frame == nullptr || g_firingAttr > 0)
if (frame == nullptr)
return;
uint32_t *slot = AttrSlotFor(frame, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
const int savedTop = Game::Lua::GetTop(L);
// Snapshot the caller's arg1/arg2 first. SetAttribute can be called from
// inside a running script handler that still needs them, and the invoker
// below neither saves the globals nor restores the stack top — so we save
// (onto the stack, below anything the invoker pushes), overwrite, fire,
// then put them back. 1.12 passes handler args as globals, not params.
Game::Lua::PushString(L, "arg1");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1]
Game::Lua::PushString(L, "arg2");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1, oldArg2]
const int oldArg1 = savedTop + 1;
const int oldArg2 = savedTop + 2;
Game::Lua::PushString(L, "arg1");
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, name);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg1 = name
Game::Lua::PushString(L, "arg2");
if (savedTop >= 3)
Game::Lua::PushValue(L, 3); // the new value
if (top >= 3)
Game::Lua::PushValue(L, 3); // the new value
else
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg2 = value
++g_firingAttr;
reinterpret_cast<InvokeAttrScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], frame);
--g_firingAttr;
// Restore arg1/arg2 from the snapshot (the fired handler may also have
// written them; the caller's values win).
Game::Lua::PushString(L, "arg1");
Game::Lua::PushValue(L, oldArg1);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::PushString(L, "arg2");
Game::Lua::PushValue(L, oldArg2);
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::SetTop(L, savedTop);
Frame::ScriptArgs::RunWithValues(L, static_cast<int>(slot[0]), frame, top + 1, 2);
Game::Lua::SetTop(L, top);
}
// ---- the methods -----------------------------------------------------------
@@ -968,11 +991,18 @@ void EnableFrameMouse(void *L) {
Game::Lua::SetTop(L, top); // restore the caller's stack
}
int DoSet(void *L, bool fireHandler) { // (self, name, value)
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:SetAttribute(\"name\", value)");
// (self, name, value). `usage` is the method's error text; 4.3.4 requires
// the value argument to be present (`lua_type(L, 3) != LUA_TNONE`), so
// `SetAttribute("x")` is an error while `SetAttribute("x", nil)` clears.
int DoSet(void *L, bool fireHandler, const char *usage) {
void *frame = Game::Lua::ResolveFrame(L);
if (frame == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2) || Game::Lua::GetTop(L) < 3) {
Game::Lua::Error(L, usage, Game::Lua::ObjectDisplayName(frame));
return 0;
}
Game::Lua::SetTop(L, 3);
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
@@ -990,17 +1020,11 @@ int DoSet(void *L, bool fireHandler) { // (self, name, value)
// (the key the mouse-focus poll uses) and enable its mouse so it can be
// hovered.
if (std::strcmp(lname, "unit") == 0) {
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj != nullptr) {
if (isString) {
const char *tok = Game::Lua::ToString(L, 3);
const bool isNew = g_unitByFrame.find(obj) == g_unitByFrame.end();
g_unitByFrame[obj] = tok ? tok : "";
if (isNew)
EnableFrameMouse(L);
} else {
g_unitByFrame.erase(obj);
}
if (isString) {
if (g_unitFrames.insert(frame).second)
EnableFrameMouse(L);
} else {
g_unitFrames.erase(frame);
}
}
// A `type*` attribute makes the frame a clickable unit button: (re)install
@@ -1018,12 +1042,17 @@ int DoSet(void *L, bool fireHandler) { // (self, name, value)
}
if (fireHandler)
FireAttributeChanged(L, Game::Lua::ResolveObject(L, 1), lname);
FireAttributeChanged(L, frame, lname);
return 0;
}
int __fastcall Script_SetAttribute(void *L) { return DoSet(L, /*fireHandler*/ true); }
int __fastcall Script_SetAttributeNoHandler(void *L) { return DoSet(L, /*fireHandler*/ false); }
int __fastcall Script_SetAttribute(void *L) {
return DoSet(L, /*fireHandler*/ true, "Usage: %s:SetAttribute(\"name\", value)");
}
int __fastcall Script_SetAttributeNoHandler(void *L) {
return DoSet(L, /*fireHandler*/ false,
"Usage: %s:SetAttributeNoHandler(\"name\", value)");
}
// `cleared = frame:ClearAttribute("name")` — retail (11.2.0) attribute removal;
// `cleared` is true iff the attribute was set (and is now gone). Routes through
@@ -1031,8 +1060,12 @@ int __fastcall Script_SetAttributeNoHandler(void *L) { return DoSet(L, /*fireHan
// NOT fire OnAttributeChanged — verified against retail (a live 12.0 test showed
// ClearAttribute firing no handler, unlike SetAttribute).
int __fastcall Script_ClearAttribute(void *L) {
void *frame = Game::Lua::ResolveFrame(L);
if (frame == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:ClearAttribute(\"name\")");
Game::Lua::Error(L, "Usage: %s:ClearAttribute(\"name\")",
Game::Lua::ObjectDisplayName(frame));
return 0;
}
char lname[256];
@@ -1044,19 +1077,21 @@ int __fastcall Script_ClearAttribute(void *L) {
Game::Lua::SetTop(L, 2); // (self, name)
Game::Lua::PushNil(L); // (self, name, nil) → value = nil clears the key
DoSet(L, /*fireHandler*/ false); // retail ClearAttribute fires no OnAttributeChanged
DoSet(L, /*fireHandler*/ false, ""); // retail ClearAttribute fires no OnAttributeChanged
Game::Lua::PushBool(L, existed);
return 1;
}
// 4.3.4's Script_GetAttribute. The 3-arg form `(prefix, name, suffix)` is
// taken only with exactly those three arguments and a string name; a nil
// prefix or suffix counts as empty. Lookup order: prefix..name..suffix,
// "*"..name..suffix, prefix..name.."*", "*"..name.."*", then name.
int __fastcall Script_GetAttribute(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:GetAttribute(\"name\")");
void *frame = Game::Lua::ResolveFrame(L);
if (frame == nullptr)
return 0;
}
// 3-arg form: (prefix, name, suffix) with wildcard precedence.
if (Game::Lua::GetTop(L) >= 4 && Game::Lua::IsString(L, 3)) {
if (Game::Lua::GetTop(L) == 4 && Game::Lua::IsString(L, 3)) {
const char *pfx = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "";
const char *nm = Game::Lua::ToString(L, 3);
const char *sfx = Game::Lua::IsString(L, 4) ? Game::Lua::ToString(L, 4) : "";
@@ -1074,6 +1109,11 @@ int __fastcall Script_GetAttribute(void *L) {
Game::Lua::PushNil(L);
return 1;
}
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:GetAttribute(\"name\")",
Game::Lua::ObjectDisplayName(frame));
return 0;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
@@ -1083,6 +1123,87 @@ int __fastcall Script_GetAttribute(void *L) {
return 1;
}
// ---- XML <Attributes> --------------------------------------------------------
// 4.3.4's CSimpleFrame::LoadXML_Attributes, for each `<Attributes>` child of
// a frame element:
//
// <Attributes>
// <Attribute name="unit" value="player"/>
// <Attribute name="count" type="number" value="3"/>
// </Attributes>
//
// `type` is nil / boolean / number / string (the default), converted with the
// loader's own helpers (FUN_XML_ATTR_TO_BOOL, FUN_SSTR_TO_FLOAT). Like 4.3.4
// it stores without firing OnAttributeChanged — here by calling our own
// SetAttributeNoHandler, so a `unit` or `type*` set from XML has the same
// effect as one set from Lua. It runs after the engine has loaded the whole
// node (Xml::FrameLoad), so the element's <Scripts> are already in place, and
// inherited templates are loaded first, so the frame's own values win.
// Problems are the engine's load warnings, with 4.3.4's texts.
void LoadXmlAttributes(void *L, void *frame, const void *attributes, void *status) {
using ToBool_t = int(__fastcall *)(const char *);
using ToFloat_t = float(__stdcall *)(const char *);
const int top = Game::Lua::GetTop(L);
int frameIdx = 0;
for (const void *e = Xml::FirstChild(attributes); e != nullptr; e = Xml::NextSibling(e)) {
const char *frameName = Game::Lua::ObjectDisplayName(frame);
if (!Xml::TagIs(e, "Attribute")) {
Xml::Warn(status, "Frame %s: Unknown attributes element %s", frameName, Xml::Tag(e));
continue;
}
const char *name = Xml::Attr(e, "name");
if (name == nullptr) {
Xml::Warn(status, "Frame %s: unnamed attribute element", frameName);
continue;
}
const char *type = Xml::Attr(e, "type");
if (type == nullptr)
type = "string";
const char *value = Xml::Attr(e, "value");
const bool isNil = Ascii::EqualCI(type, "nil");
if (value == nullptr && !isNil) {
Xml::Warn(status, "Frame %s: attribute element named %s missing value",
frameName, name);
continue;
}
if (frameIdx == 0) {
if (!Xml::PushWrapper(L, frame))
break;
frameIdx = Game::Lua::GetTop(L);
}
Game::Lua::PushCClosure(L, &Script_SetAttributeNoHandler, 0);
Game::Lua::PushValue(L, frameIdx);
Game::Lua::PushString(L, name);
if (isNil)
Game::Lua::PushNil(L);
else if (Ascii::EqualCI(type, "boolean"))
Game::Lua::PushBool(L, reinterpret_cast<ToBool_t>(Offsets::FUN_XML_ATTR_TO_BOOL)(value) != 0);
else if (Ascii::EqualCI(type, "number"))
Game::Lua::PushNumber(L, reinterpret_cast<ToFloat_t>(Offsets::FUN_SSTR_TO_FLOAT)(value));
else
Game::Lua::PushString(L, value);
Game::Lua::PCall(L, 3, 0, 0);
Game::Lua::SetTop(L, frameIdx);
}
Game::Lua::SetTop(L, top);
}
void OnFrameNode(void *frame, const void *node, void *status) {
void *L = nullptr;
for (const void *c = Xml::FirstChild(node); c != nullptr; c = Xml::NextSibling(c)) {
if (!Xml::TagIs(c, "Attributes"))
continue;
if (L == nullptr && (L = Game::Lua::State()) == nullptr)
return;
LoadXmlAttributes(L, frame, c, status);
}
}
const Xml::FrameLoad::AutoSubscribe _frameNode{&OnFrameNode};
// ---- registration ----------------------------------------------------------
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
@@ -1115,12 +1236,11 @@ const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
// session, so there's always at least one entry to go stale). This was the
// "SetAttribute occasionally errors after /reload" report; same class of
// bug as Tooltip::SetEvents issue #33.
// * g_unitByFrame maps a frame to its `unit` token for the mouse-focus poll;
// a stale entry at a reused address would point the mouseover at the wrong
// unit and mislead the isNew mouse-enable gate.
// * g_unitFrames records which frames already had their mouse enabled; a
// stale entry at a reused address would skip enabling a new frame's.
void PrepareForReload() {
g_attrHandlers.clear();
g_unitByFrame.clear();
g_unitFrames.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
+199
View File
@@ -0,0 +1,199 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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/>.
// `frame:SetClampRectInsets(left, right, top, bottom)` and
// `frame:GetClampRectInsets()`.
//
// A frame clamped to the screen (SetClampedToScreen) can't be moved or
// dragged past the screen's edges. Later clients let it name a different
// rectangle to keep on screen: the frame's own rect with each edge moved by
// its inset, in the frame's units and screen directions (a negative left or
// bottom, or a positive right or top, grows the rect; the opposite lets that
// much of the frame go off screen). 3.3.5's WorldMapFrame keeps clear of the
// experience bars with (0, 0, 0, -60); its chat frames keep their tabs and
// edit box on screen with (-35, 35, 26, -50). Vanilla has the clamp but no
// insets. Zygor Guides Viewer 2.0 sets them in its main frame's OnLoad and
// grows its small anchor frame's rect over the whole viewer.
//
// The engine clamps inside the rect calculation (FUN_LAYOUT_CALC_RECT): it
// resolves the frame's edges and, for a clamped frame, slides the rect onto
// the screen. For a frame with insets the hook takes the unclamped rect, grows
// it by the insets, slides that onto the screen with the engine's own steps
// and order, and moves the frame's rect by the same amount. Every other
// frame goes through the engine's clamp untouched.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <unordered_map>
namespace Frame::ClampRectInsets {
namespace {
struct Insets {
double left = 0.0, right = 0.0, top = 0.0, bottom = 0.0;
};
// Keyed by the frame object. Frames live until the UI is torn down, and a
// /reload rebuilds every one of them, so the map is cleared on reload.
std::unordered_map<void *, Insets> g_insets;
// rect: bottom, left, top, right, in layout units.
using CalcRect_t = int(__thiscall *)(void *layout, float *rect);
CalcRect_t g_origCalcRect = nullptr;
using Invalidate_t = void(__thiscall *)(void *layout, int arg);
void *LayoutOf(void *frame) { return static_cast<uint8_t *>(frame) + Offsets::OFF_REGION_ANCHOR; }
void *FrameOf(void *layout) { return static_cast<uint8_t *>(layout) - Offsets::OFF_REGION_ANCHOR; }
// A length in the frame's own units (what GetLeft returns) in layout units:
// the inverse of Script GetLeft's conversion.
float ToLayoutUnits(double length, void *frame) {
const float effScale = Game::Read<float>(frame, Offsets::OFF_REGION_EFFECTIVE_SCALE);
return Game::PixelToInternal(static_cast<float>(length) * effScale);
}
int __fastcall CalcRect_h(void *layout, void * /*edx*/, float *rect) {
const uint32_t flags = Game::Read<uint32_t>(layout, Offsets::OFF_LAYOUT_FLAGS);
if ((flags & Offsets::LAYOUT_FLAG_CLAMPED) == 0 || g_insets.empty())
return g_origCalcRect(layout, rect);
void *frame = FrameOf(layout);
const auto it = g_insets.find(frame);
if (it == g_insets.end())
return g_origCalcRect(layout, rect);
// The frame's own rect, before any clamping. Only the clamp bit is
// toggled, so other flag bits the calculation changes survive.
Game::Ref<uint32_t>(layout, Offsets::OFF_LAYOUT_FLAGS) &= ~static_cast<uint32_t>(Offsets::LAYOUT_FLAG_CLAMPED);
const int resolved = g_origCalcRect(layout, rect);
Game::Ref<uint32_t>(layout, Offsets::OFF_LAYOUT_FLAGS) |= Offsets::LAYOUT_FLAG_CLAMPED;
if (!resolved)
return resolved;
const Insets &in = it->second;
float bottom = rect[0] + ToLayoutUnits(in.bottom, frame);
float left = rect[1] + ToLayoutUnits(in.left, frame);
float top = rect[2] + ToLayoutUnits(in.top, frame);
float right = rect[3] + ToLayoutUnits(in.right, frame);
// The engine's clamp, step for step, on the grown rect.
float dx = 0.0f, dy = 0.0f;
if (left < 0.0f) {
dx -= left;
right -= left;
left = 0.0f;
}
if (bottom < 0.0f) {
dy -= bottom;
top -= bottom;
bottom = 0.0f;
}
// The screen's extent in layout units, the bounds the engine's clamp uses.
const float width = Game::Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE);
if (right > width) {
dx -= right - width;
left -= right - width;
right = width;
}
const float height = Game::Read<float>(Offsets::VAR_COORD_DDC_HEIGHT_SCALE);
if (top > height) {
dy -= top - height;
bottom -= top - height;
top = height;
}
rect[0] += dy;
rect[1] += dx;
rect[2] += dy;
rect[3] += dx;
return resolved;
}
const Game::HookAutoRegister _hook{Offsets::FUN_LAYOUT_CALC_RECT, reinterpret_cast<void *>(&CalcRect_h),
reinterpret_cast<void **>(&g_origCalcRect)};
int __fastcall Script_SetClampRectInsets(void *L) {
void *frame = Game::Lua::ResolveFrame(L);
if (frame == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
!Game::Lua::IsNumber(L, 4) || !Game::Lua::IsNumber(L, 5)) {
Game::Lua::Error(L, "Usage: frame:SetClampRectInsets(left, right, top, bottom)");
return 0;
}
Insets in;
in.left = Game::Lua::ToNumber(L, 2);
in.right = Game::Lua::ToNumber(L, 3);
in.top = Game::Lua::ToNumber(L, 4);
in.bottom = Game::Lua::ToNumber(L, 5);
if (in.left == 0.0 && in.right == 0.0 && in.top == 0.0 && in.bottom == 0.0)
g_insets.erase(frame);
else
g_insets[frame] = in;
// Re-lay the frame out, as SetClampedToScreen does after changing the clamp.
reinterpret_cast<Invalidate_t>(Offsets::FUN_FONTSTRING_LAYOUT_INVALIDATE)(LayoutOf(frame), 0);
return 0;
}
int __fastcall Script_GetClampRectInsets(void *L) {
void *frame = Game::Lua::ResolveFrame(L);
if (frame == nullptr)
return 0;
const auto it = g_insets.find(frame);
const Insets in = it != g_insets.end() ? it->second : Insets{};
Game::Lua::PushNumber(L, in.left);
Game::Lua::PushNumber(L, in.right);
Game::Lua::PushNumber(L, in.top);
Game::Lua::PushNumber(L, in.bottom);
return 4;
}
void PrepareForReload() { g_insets.clear(); }
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetClampRectInsets", &Script_SetClampRectInsets},
{"GetClampRectInsets", &Script_GetClampRectInsets},
};
const Game::Doc::Field kInsets[] = {
Game::Doc::Req("left", "number", "Moves the left edge; negative grows the rect."),
Game::Doc::Req("right", "number", "Moves the right edge; positive grows the rect."),
Game::Doc::Req("top", "number", "Moves the top edge; positive grows the rect."),
Game::Doc::Req("bottom", "number", "Moves the bottom edge; negative grows the rect."),
};
const Game::Doc::Function kSetClampRectInsets{
"Sets the rect, relative to the frame, that the screen clamp keeps on screen.", kInsets, {}};
const Game::Doc::Function kGetClampRectInsets{
"Returns the frame's clamp rect insets; all zero when none are set.", {}, kInsets};
const Game::Doc::Method g_methodDocs[] = {
{"SetClampRectInsets", &kSetClampRectInsets},
{"GetClampRectInsets", &kGetClampRectInsets},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])), g_methodDocs,
static_cast<int>(sizeof(g_methodDocs) / sizeof(g_methodDocs[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ReloadAutoRegister _reload{&PrepareForReload};
} // namespace
} // namespace Frame::ClampRectInsets
+226 -68
View File
@@ -11,41 +11,94 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `button:SetScript("PreClick"|"PostClick", fn)` — backport of the modern
// button scripts that fire immediately before and after OnClick, as real frame
// scripts settable via the standard SetScript / GetScript / HookScript. Vanilla
// 1.12 has only OnClick and OnDoubleClick on buttons.
// The click bracket: `button:SetScript("PreClick"|"PostClick", fn)` and
// `GetMouseButtonClicked()`, both modelled on what 3.3.5's engine does around a
// click, and both placed where 1.12's engine makes the same decisions.
//
// 1.12 already has the script-dispatch machinery, just not these two names.
// Two co-hooks (same shape as Tooltip::SetEvents):
// What 3.3.5 does (Ghidra on the 3.3.5 client; class trail via the two script
// resolvers — CSimpleFrame FUN_0048e680, CSimpleButton FUN_0096f200):
//
// - The Button script resolver FUN_BUTTON_SCRIPT_RESOLVER maps a script name
// to its handler slot (OnClick -> button+0x4CC, …). SetScript/GetScript/
// HookScript all go through it. We co-hook it: for PreClick/PostClick
// (which the engine doesn't know) we hand back a pointer to an external
// per-button cell — a button object has no free slot for two new scripts,
// and the resolver's only contract is "return the address the script lives
// at", so external storage works exactly like an in-object slot.
// - CSimpleButton::Click FUN_0096fd70(this, name, down) wraps FUN_0096f090,
// which fires PreClick (+0x2d8), OnClick (+0x2e0) and PostClick (+0x2e8) as
// THREE INDEPENDENT `if (slot)` blocks. PreClick/PostClick fire whether or
// not the button has an OnClick. DoubleClick (FUN_0096fdd0 -> FUN_0096f150)
// fires only OnDoubleClick — no Pre/Post.
// - `GetMouseButtonClicked` (FUN_0050f950) pushes a frame-manager field,
// [frameMgr+0x1234]. Exactly four dispatches write it, each with the same
// C-stack bracket around the handler run:
//
// - The engine fires a button's OnClick through the arg'd script runner
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT(frame, frame+0x4CC, "%s", &buttonName).
// We intercept that runner (through `Frame::RunnerHook`, the shared single
// hook on it) and, when the fired slot IS the OnClick slot, re-invoke the
// runner for our PreClick cell first, then let OnClick run, then re-invoke
// for PostClick — reusing the SAME (fmt, varargs) so PreClick/PostClick
// receive arg1 = the button name exactly like OnClick.
// saved = mgr->clickedButton;
// mgr->clickedButton = name; // this dispatch's own button string
// ... fire the handler(s) ...
// mgr->clickedButton = saved;
//
// Why the runner and not the button click vmethod (FUN_00779540): SuperWoW
// inline-hooks that vmethod for click-casting, and a second MinHook there
// corrupts the trampoline (ERROR #132 — see Offsets.h). The runner is
// uncontested, fires at the same top-level dispatch level (so its exec-context
// stamp is valid), and the OnClick slot address is an exact gate.
// the click above, the double-click above, and — on EVERY frame type, not
// just buttons — CSimpleFrame::OnMouseDown FUN_0048fc30 and OnMouseUp
// FUN_0048fce0. Not OnDragStart. Confirmed live on 3.3.5 (2026-09-15):
// `down LeftButton LeftButton`, `up LeftButton LeftButton`, `drag LeftButton
// nil`. The C-stack save is what makes a nested click shadow the outer one
// and restore on unwind, so the innermost dispatch wins; nothing captures
// the OS mouse message and nothing evicts on a timer, so outside a handler
// the answer is nil even while a button is held.
// - CheckButton (FUN_009623c0) toggles its checked state BEFORE the click
// dispatch, so PreClick already sees the new state.
//
// Limitation: because the runner only runs when a slot is non-empty, PreClick/
// PostClick fire only when the button also has an OnClick handler set. Modern
// fires them regardless; on this client that would require hooking the click
// vmethod, which SuperWoW owns. Bracketing an existing OnClick is the common
// case and the one this backport serves.
// How 1.12 maps onto that:
//
// 1. Script storage. The Button resolver FUN_BUTTON_SCRIPT_RESOLVER knows only
// OnClick (+0x4CC) and OnDoubleClick (+0x4D4). SetScript/GetScript/HookScript
// all go through it, and its only contract is "return the address the
// script lives at", so for the two names it doesn't know we hand back an
// external per-button cell (a button object has no spare 8-byte slot) —
// the Tooltip::SetEvents technique.
//
// 2. PreClick/PostClick firing — a co-hook on FUN_BUTTON_CLICK, the Button
// click vmethod. Every Lua-creatable button's click reaches it (Button
// directly; CheckButton's override toggles then chains to it), and it is
// where the engine decides a click happened. The detour brackets the whole
// click exactly like FUN_0096fd70: fire PreClick, call the original (which
// fires OnClick iff one is set), fire PostClick. Pre/Post are fired through
// the engine's own variadic forwarder FUN_FRAME_RUN_SCRIPT_VARIADIC with the
// engine's own "%s" literal and name literal — the identical path and
// identical pointers the engine uses for OnClick — so a handler cannot tell
// the three apart. The name comes from the vmethod's button mask via the
// six engine .data literals FUN_BUTTON_CLICK's own jump table selects
// (unrecognized mask -> "UNKNOWN", exactly as Button:Click("Fnord") gives
// OnClick). `down` is not supplied: 1.12's vmethod never learns whether the
// click came from a press or a release, so Pre/Post keep 1.12 OnClick's
// shape `(button)`.
//
// 3. GetMouseButtonClicked scope — the runner interceptor. Every one of the
// fires 3.3.5 brackets reaches FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT as
// (frame, slotPtr, "%s", name), so the existing shared `Frame::RunnerHook`
// subscription matches slotPtr against the four slots — OnClick,
// OnDoubleClick, OnMouseDown, OnMouseUp — saves/sets the name from the
// fire's own argument, runs the original, restores. Zero hooks of its own;
// the CSimpleFrame slot offsets are shared by every frame type, so the
// OnMouseDown/OnMouseUp gate covers plain Frames like 3.3.5 does. The
// click detour ALSO sets the scope around its whole bracket (so PreClick and
// PostClick read it, and it is live for the whole dispatch as in 3.3.5);
// for the OnClick fire inside, the runner gate re-sets the same value —
// a harmless nested save/restore.
//
// Deliberate divergence from 3.3.5: its click dispatch drops a nested click of
// the SAME button entirely (`+0x2a8 & 0x10`). 1.12's engine has no such guard —
// a self-clicking OnClick recurses — and this module doesn't add one to the
// engine's own click; it only skips OUR PreClick/PostClick on that re-entry
// (per-button flag) so a self-clicking PreClick can't run away through us.
// A nested click of a DIFFERENT button gets its own full bracket, as in 3.3.5.
//
// Two rejected designs, for the record. (a) Bracketing at the runner only and
// requiring an OnClick handler to exist — the engine enters the runner only
// when [+0x4CC] is set (FUN_BUTTON_CLICK: `if (slot) fire`), so a button with
// no OnClick never fired Pre/Post; that shipped as a "limitation" blamed on a
// SuperWoW hook of the click vmethod that a per-DLL literal scan later showed
// does not exist (details at FUN_BUTTON_CLICK in Offsets.h). (b) Planting a
// sentinel no-op OnClick ref to satisfy the engine's gate and hiding it from
// GetScript/HookScript/SetScript(nil) — a parallel mechanism that lies to the
// engine; Rule #1. (An even earlier GetMouseButtonClicked captured the button
// from the WH_GETMESSAGE hook and evicted it on a world-tick timer, which
// reported a button for as long as one was held.)
#include "frame/ClickEvents.h"
@@ -65,20 +118,23 @@ enum ScriptKind { SK_PRECLICK = 0, SK_POSTCLICK, SK_COUNT };
// Matched case-insensitively against the resolver's input name.
constexpr const char *kNamesLower[SK_COUNT] = {"preclick", "postclick"};
// Per-button storage: one 8-byte {handler, context} slot per script kind. Only
// buttons that actually had SetScript("PreClick"/"PostClick", …) called get an
// entry, so the map stays small. unordered_map nodes are pointer-stable, so a
// cell address handed to the engine stays valid until PrepareForReload clears
// the map (buttons and their handler refs die on /reload).
// Per-button storage: one 8-byte {handler, context} slot per script kind, plus
// the re-entry flag for the click bracket. Only buttons that actually had
// SetScript("PreClick"/"PostClick", …) called get an entry, so the map stays
// small and the click detour's lookup is a miss for almost every UI button.
// unordered_map nodes are pointer-stable, so a cell address handed to the
// engine stays valid until PrepareForReload clears the map (buttons and their
// handler refs die on /reload).
struct Cell {
uint32_t slot[SK_COUNT][2]; // [kind] = {handler ref, exec context}
bool firing; // this button's click bracket is on the stack
};
std::unordered_map<void *, Cell> g_cells;
uint32_t *SlotFor(void *button, int kind, bool create) {
Cell *CellFor(void *button, bool create) {
auto it = g_cells.find(button);
if (it != g_cells.end())
return it->second.slot[kind];
return &it->second;
if (!create)
return nullptr;
Cell &c = g_cells[button];
@@ -86,7 +142,8 @@ uint32_t *SlotFor(void *button, int kind, bool create) {
s[0] = 0;
s[1] = 0;
}
return c.slot[kind];
c.firing = false;
return &c;
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
@@ -104,11 +161,54 @@ bool EqualsIgnoreCase(const char *s, const char *literal) {
}
}
// Recursion guard: a PreClick/OnClick/PostClick handler can programmatically
// click a button again. While bracketing, further OnClick fires pass straight
// through (the nested click still runs — it just doesn't get its own nested
// PreClick/PostClick), which prevents runaway from a self-clicking handler.
int g_firing = 0;
// --- clicked button (GetMouseButtonClicked) --------------------------------
// The innermost bracketed dispatch's button string, null outside one — and
// Game::Lua::PushString tail-jumps to pushnil on null, so that surfaces as nil
// with no extra branch. It always points at one of the engine's own static name
// literals ("LeftButton" … "UNKNOWN"), which live for the process, so holding
// the pointer across the dispatch — and across a /reload a handler triggers —
// can never dangle.
const char *g_clickedButton = nullptr;
// The fire's first "%s" argument — the button name. Every bracketed fire is
// exactly ("%s", name); any other shape isn't a name, so it reads null.
const char *FirstStringArg(const char *fmt, const void *varargs) {
if (fmt == nullptr || varargs == nullptr)
return nullptr;
const char *p = fmt;
while (*p != '\0' && *p != '%')
++p;
if (p[0] != '%' || p[1] != 's')
return nullptr;
return *reinterpret_cast<const char *const *>(varargs);
}
// The engine's own name literal for a click-vmethod button mask — the exact
// pointer FUN_BUTTON_CLICK's jump table hands OnClick.
const char *NameForMask(uint32_t mask) {
uintptr_t va;
switch (mask) {
case 0x01: va = Offsets::VAR_BUTTON_NAME_LEFT; break;
case 0x02: va = Offsets::VAR_BUTTON_NAME_MIDDLE; break;
case 0x04: va = Offsets::VAR_BUTTON_NAME_RIGHT; break;
case 0x08: va = Offsets::VAR_BUTTON_NAME_4; break;
case 0x10: va = Offsets::VAR_BUTTON_NAME_5; break;
default: va = Offsets::VAR_BUTTON_NAME_UNKNOWN; break;
}
return reinterpret_cast<const char *>(va);
}
int __fastcall Script_GetMouseButtonClicked(void *L) {
Game::Lua::PushString(L, g_clickedButton);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
&Script_GetMouseButtonClicked);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// --- Resolver co-hook (Button script-name -> slot) ---------------------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
@@ -120,44 +220,95 @@ int __fastcall Resolver_h(void *self, void *edx, const char *name) {
return engineSlot;
for (int k = 0; k < SK_COUNT; ++k)
if (EqualsIgnoreCase(name, kNamesLower[k]))
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
return reinterpret_cast<int>(CellFor(self, /*create*/ true)->slot[k]);
return 0;
}
// --- Runner interceptor (fire PreClick before / PostClick after OnClick) ---
// --- Runner interceptor: GetMouseButtonClicked scope ------------------------
// Subscribed to `Frame::RunnerHook`, the single shared hook on
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT (MinHook allows one hook per address).
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT (MinHook allows one hook per address). This
// is the OnUpdate hot path too, so the gate is four pointer compares and nothing
// else.
// Fire the handler (if any) for `kind` on `frame`, reusing the OnClick fire's
// own (fmt, varargs) so arg1 = the button name. We call the engine runner
// directly (its exec-context stamp is valid at this dispatch level). The runner
// balances the Lua stack itself; we snapshot/restore the top as cheap insurance
// against any imbalance leaking to the still-running button click code.
void FireCell(void *frame, int kind, const char *fmt, void *varargs) {
uint32_t *slot = SlotFor(frame, kind, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
bool IsBracketedSlot(void *frame, const uint32_t *slotPtr) {
const char *base = reinterpret_cast<const char *>(frame);
const auto at = [base](uintptr_t off) {
return reinterpret_cast<const uint32_t *>(base + off);
};
return slotPtr == at(Offsets::OFF_BUTTON_ONCLICK_HANDLER) ||
slotPtr == at(Offsets::OFF_BUTTON_ONDOUBLECLICK_HANDLER) ||
slotPtr == at(Offsets::OFF_FRAME_ONMOUSEDOWN_SLOT) ||
slotPtr == at(Offsets::OFF_FRAME_ONMOUSEUP_SLOT);
}
// Falls through (false) for every fire 3.3.5 doesn't bracket; otherwise runs it
// inside the clicked-button scope and reports it handled (true).
bool OnRun(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
if (!IsBracketedSlot(frame, slotPtr))
return false;
const char *saved = g_clickedButton;
g_clickedButton = FirstStringArg(fmt, varargs);
Frame::RunnerHook::Original(frame, slotPtr, fmt, varargs);
g_clickedButton = saved;
return true;
}
// --- Click vmethod co-hook: the PreClick / PostClick bracket -----------------
// FUN_BUTTON_CLICK is __thiscall(button, mask, fromScript) RET 8, modelled as
// __fastcall with a dummy edx so both stack args land where the engine put
// them (same trick as the resolver above).
using Click_t = void(__fastcall *)(void *self, void *edx, uint32_t mask,
int fromScript);
Click_t g_clickOriginal = nullptr;
// The engine's variadic script forwarder — the entry FUN_BUTTON_CLICK itself
// uses for OnClick. __cdecl, so the trailing name is pushed as one 4-byte arg.
using FireVariadic_t = void(__cdecl *)(void *frame, uint32_t *slotPtr,
const char *fmt, ...);
// Fire the handler (if any) in `slot` with arg1 = `name`, through the engine's
// forwarder so the fire takes the identical path to the engine's OnClick fire
// (exec context stamped from slot[1], hooked runner, ScriptArgs positional
// args). The runner balances the Lua stack itself; snapshot/restore the top as
// cheap insurance against any imbalance leaking into the still-running click.
void FireSlot(void *button, uint32_t *slot, const char *name) {
if (slot[0] == 0)
return;
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
Frame::RunnerHook::Original(frame, slot, fmt, varargs);
reinterpret_cast<FireVariadic_t>(Offsets::FUN_FRAME_RUN_SCRIPT_VARIADIC)(
button, slot, reinterpret_cast<const char *>(Offsets::VAR_SCRIPT_FMT_S),
name);
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
// Falls through (false) for every fire that isn't a top-level OnClick;
// otherwise runs the whole bracket itself and reports it handled (true).
bool OnRun(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
const auto onClickSlot = reinterpret_cast<uint32_t *>(
reinterpret_cast<char *>(frame) + Offsets::OFF_BUTTON_ONCLICK_HANDLER);
if (slotPtr != onClickSlot || g_firing > 0)
return false;
void __fastcall Click_h(void *self, void *edx, uint32_t mask, int fromScript) {
Cell *cell = CellFor(self, /*create*/ false);
if (cell == nullptr || cell->firing) {
// No Pre/Post on this button, or a re-entrant click of the same button
// from inside its own bracket: the engine's click runs untouched (its
// OnClick fire, if any, is scoped by the runner gate above).
g_clickOriginal(self, edx, mask, fromScript);
return;
}
++g_firing;
FireCell(frame, SK_PRECLICK, fmt, varargs);
Frame::RunnerHook::Original(frame, slotPtr, fmt, varargs); // the button's OnClick
FireCell(frame, SK_POSTCLICK, fmt, varargs);
--g_firing;
return true;
const char *name = NameForMask(mask);
const char *saved = g_clickedButton;
g_clickedButton = name;
cell->firing = true;
FireSlot(self, cell->slot[SK_PRECLICK], name);
g_clickOriginal(self, edx, mask, fromScript); // the engine's click: OnClick iff set
// The handlers may have SetScript'd Pre/Post away or re-created the cell's
// neighbours; re-resolve rather than trust the pointer across Lua.
cell = CellFor(self, /*create*/ false);
if (cell != nullptr) {
FireSlot(self, cell->slot[SK_POSTCLICK], name);
cell->firing = false;
}
g_clickedButton = saved;
}
static const Game::HookAutoRegister _resolverHook{
@@ -165,6 +316,11 @@ static const Game::HookAutoRegister _resolverHook{
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _clickHook{
Offsets::FUN_BUTTON_CLICK,
reinterpret_cast<void *>(&Click_h),
reinterpret_cast<void **>(&g_clickOriginal)};
static const Frame::RunnerHook::AutoSubscribe _runnerSub{&OnRun};
} // namespace
@@ -175,4 +331,6 @@ void PrepareForReload() {
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
const char *CurrentButton() { return g_clickedButton; }
} // namespace Frame::ClickEvents
+5
View File
@@ -22,4 +22,9 @@ namespace Frame::ClickEvents {
// the other per-reload cleaners.
void PrepareForReload();
// The button name of the innermost click / mouse dispatch running now (one of
// the engine's static name literals), or null outside one — what
// GetMouseButtonClicked returns; 4.3.4's CSimpleTop::m_mouseButtonName.
const char *CurrentButton();
} // namespace Frame::ClickEvents
+24
View File
@@ -38,10 +38,13 @@
// `CFrameScriptObject` subtype, so calling it after `ResolveObject` is
// safe without the RTTI subtype gate `Script_GetName` does first.
#include "frame/ClickFrame.h"
#include "Game.h"
#include "Offsets.h"
#include <cstring>
#include <string>
namespace Frame::ClickFrame {
@@ -96,4 +99,25 @@ static void RegisterLuaFunctions() {
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
void *Find(void *L, const char *name, size_t length) {
using namespace Game::Lua;
if (L == nullptr || name == nullptr || length == 0)
return nullptr;
const std::string key(name, length);
const int top = GetTop(L);
PushString(L, key.c_str());
RawGet(L, GLOBALS_INDEX);
void *frame = nullptr;
if (Type(L, -1) == TYPE_TABLE) {
void *obj = ResolveObject(L, -1);
if (obj != nullptr && IsA(obj, Offsets::VAR_FRAME_LUA_TYPE_ID)) {
const char *objName = ObjectName(obj);
if (std::strcmp(objName != nullptr ? objName : "<unnamed>", key.c_str()) == 0)
frame = obj;
}
}
SetTop(L, top);
return frame;
}
} // namespace Frame::ClickFrame
+26
View File
@@ -0,0 +1,26 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace Frame::ClickFrame {
// 4.3.4's GetClickFrame (UIBindings.cpp), for CLICK binding commands: the
// frame named by the first `length` bytes of `name` — `_G[name]` read raw,
// a frame, and its own name equal to `name` — or null. Leaves the Lua stack
// as it found it.
void *Find(void *L, const char *name, size_t length);
} // namespace Frame::ClickFrame

Some files were not shown because too many files have changed in this diff Show More