Compare commits

...
53 Commits
Author SHA1 Message Date
Brues 1fd1295679 debugtools: use DEBUG_FRAMESTACK from locale if available 2026-08-06 19:27:14 -05:00
Brues e4a75e68ca feat(frame): add GetMouseFoci — mouse-focus stack under the cursor
Modern WoW exposes GetMouseFoci() (the stack of frames under the
cursor) alongside GetMouseFocus(), with GetMouseFocus() ==
GetMouseFoci()[1]. Vanilla stores only the single topmost focus
(Script_GetMouseFocus reads *(ctx + 0x7C)); there is no stored stack.

Reimplement natively (no Lua polyfill, no hooks): walk the engine frame
list that Script_EnumerateFrames iterates (head *(ctx + 0xCC4), next
+0x308, low-bit/null sentinel), keep frames that are visible +
mouse-enabled and whose rect contains the cursor (Region getters +
cursor/effective-scale, mirroring Frame::Modern's IsMouseOver), order
top-first by strata rank then frame level (level read directly from
+0xC4), and force index 1 to the engine's stored focus so the invariant
holds by Lua table identity. Frames are pushed via the shared
UI::FrameObject::Push so == compares the same wrapper GetMouseFocus
returns.

Filters to the mouse-enabled (interactive) stack, matching 1.15.x
(verified: GetMouseFocus skips a higher non-mouse-enabled cooldown to
land on the button, and 1.15.x GetMouseFoci returns the same set).
/framestack lists more because it is a pure-geometry debug view, not the
mouse-focus set.

Docs: API.md entry (Simplified Technical English).
2026-08-06 18:42:35 -05:00
Brues 28bf41c647 locale: Backport debuff symbols and debug label
Add DEBUFF_SYMBOL_{CURSE,DISEASE,MAGIC,POISON} and DEBUG_FRAMESTACK translations to locale files (deDE, enUS, esES, frFR, ruRU, zhCN, zhTW).
2026-08-06 17:59:16 -05:00
Brues ac97f9b960 ci: add workflow_dispatch manual trigger to Build
Push-triggered runs can be silently dropped when GitHub throttles
webhooks during an Actions incident, leaving no way to kick a build.
Add a workflow_dispatch trigger as a manual escape hatch, runnable
from the Actions UI.

A single Resolve step computes is_release/tag from the pushed ref, the
dispatched ref, or an optional `tag` input, and every downstream step
keys off it — so push behavior is unchanged. Dispatch modes: run from a
vX.Y.Z tag (release build of that commit), run from a branch with a
`tag` input (release build attached to that tag), or run from a branch
with a blank input (dev build artifact). The Release step now passes
tag_name explicitly so it also works when the ref is a branch.
2026-08-06 17:17:46 -05:00
Brues 7f6c6e2dd4 consolidate UPDATE_MOUSEOVER_UNIT compat logic 2026-08-06 16:45:19 -05:00
Brues 9d09266fc6 build(embed): flag-driven embedded toc version + marker-gated dev override
Two related fixes to the embedded-addon precedence so users stop
running a stale/mismatched on-disk !!!ClassicAPI without realizing it,
while keeping the developer's disk-wins workflow intact.

Version is now a pure build-flag artifact. The embedded copy's
`## Version:` is stamped from CLASSICAPI_TAG at embed time
(cmake/embed_addon.cmake rewrites the line in-memory before hex
encoding, portable via a temp-file round-trip). The committed source
toc stays `## Version: DEV` and is never edited, so a release build is
`-DCLASSICAPI_TAG=vX.Y.Z` and nothing else. Dropped the CI sed step
that previously mutated the source toc. Moved the version-derivation
block above the embed step so CLASSICAPI_TOC_VERSION is available to it.

Dev override moved from the toc version to an explicit marker. A
`.classicapi-dev` file in the on-disk addon folder now forces the disk
copy to win regardless of version (checked via the original
FUN_FILE_READ, so it reflects a real on-disk file). Consequently `DEV`
no longer sorts as "newer than any release" in CompareVersions — it
sorts oldest — so a stray `DEV` source/clone copy on a user's disk no
longer silently shadows a newer embedded release. The normal local
loop still works without the marker (local DEV DLL + DEV disk = tie =
disk wins); the marker is only needed to prefer disk against a released
DLL. The marker is gitignored and excluded from the embed (both globs),
and the release ships only the DLL, so it can exist only in a
developer working tree.
2026-08-06 16:37:36 -05:00
Brues d972433ab0 compat(puppeteer): guard EnemyTracker against mouseover-loss fire
ClassicAPI 1.8.4 added a UPDATE_MOUSEOVER_UNIT loss-fire polyfill
(matching retail): the event now also fires when the mouseover goes
unit -> nothing, with `mouseover` already cleared. Puppeteer's
EnemyTracker OnEvent reads `unit = UnitGUID("mouseover")` on that
event, which is now nil on the loss fire; it then calls
`UnitCanAttack("player", unit)`, and Puppeteer routes UnitCanAttack
through its DoubleUnitProxy (no nil guard) straight to native
UnitCanAttack -> engine raises `Usage: UnitCanAttack("unit",
"otherUnit")` on the nil arg.

Wrap PTEnemyUpdater's OnEvent from our AddOnCompat shim and skip the
handler when UPDATE_MOUSEOVER_UNIT fires with no live mouseover, so
the nil never reaches the proxy. Gains still pass through, so enemy
tracking is unaffected. Deferred via ContinueOnAddOnLoaded (Puppeteer
loads after us) and guarded on PTEnemyUpdater existing (it is only
created when SuperWoW is present).
2026-08-06 14:23:29 -05:00
Brues b5513c6b22 fix(cbor): emit canonical map key order (RFC 7049 §3.9)
The CBOR encoder walked string-keyed tables with lua_next (pairs()
order), which Lua does not guarantee to be stable. The pfUI export
path rebuilds its config tables on every press, so each rebuild
re-hashed the same keys into a different internal layout and the map
serialized its key/value pairs in a different byte order -> different
compressed bytes -> different Base64. Harmless (every variant decodes
to the same config) but no stable fingerprint.

EncodeTable's map branch now stashes each key, records its encoded
bytes, sorts by encoded-key bytes (shorter first, then bytewise per
RFC 7049 3.9), then re-fetches key/value in sorted order. Nested
maps sort recursively, so the whole byte stream is canonical. Decode
is order-agnostic, so the round trip stays lossless.

Verified in-game: encode/decode/re-encode of a pfUI profile now
produces a byte-identical Base64 string.
2026-08-06 14:02:19 -05:00
Brues 8a434fea0e fix(aura): keep SMSG_SPELL_GO caster through the descriptor-sync race
C_UnitAuras dropped sourceGUID/sourceUnit for buffs cast by other units
(e.g. a druid's Mark of the Wild / Gift of the Wild on the player).

SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
the target's descriptor. In that window an addon aura poll runs
ReconcileCache, which calls EvictAbsent. The just-captured cache entry
named an aura the descriptor did not list yet, so EvictAbsent dropped it
and lost the caster. OnAuraAdded then recreated the entry with no caster,
so sourceGUID stayed nil. Verified by trace: every "Store NEW caster=0x8"
was followed at once by "EvictAbsent (had caster=0x8)".

Give each cache entry a write timestamp (stampMs) and exempt entries
younger than kEvictGraceMs (2s) from EvictAbsent. A real removal is far
older and still gets dropped, and OnAuraRemoved evicts genuine removals
directly, so stale-aura cleanup is unaffected.

Verified in-game: a remote druid buff now reports sourceGUID; a self-cast
buff still reports sourceGUID + sourceUnit="player".
2026-08-06 01:26:39 -05:00
Brues dbbba88aad feat(tooltip): show spell rank in the corner of SetSpellByID
`GameTooltip:SetSpellByID` now shows the spell rank in the top-right
corner of the title line. The cross-class `SetTalentByID` fallback shows
it too. Both go through `Spell::Tooltip::ShowByID`.

The engine builder `BuildSpellTooltip` (`FUN_0052E610`) sets the title
line right-text to `Rank[locale]` when `param_5` or `param_2` is not
zero. `param_5` has no other effect in the builder (confirmed by
disassembly). `SetAction` and `SetPetAction` pass `param_5=1` for this.
Native `SetSpell` and the old `SetSpellByID` passed `0`, so they did not
show the rank. `ShowByID` now passes `param_5=1`.

The change is additive. A spell with no rank (passive or racial) shows
nothing there, the same as the engine.

With this change, the macro tooltips of SuperCleveRoidMacros match the
native action-button tooltips.
2026-08-06 00:32:16 -05:00
Brues b8bd0bce4e fix(unit): gate +0xE68 sub-struct reads on OBJECT_TYPE_PLAYER, not PLAYER_CONTROLLED
The CGPlayer sub-struct at [unit+0xE68] (guild key, PLAYER_FLAGS, quest
list, visible items) exists only on real player objects. Reads of it were
gated on UNIT_FLAG_PLAYER_CONTROLLED, but the server sets that flag on
pets, totems, guardians, companions, and MC'd creatures too (Turtle
UnitDefines.h; Pet.cpp/SpellEffects.cpp/SpellAuras.cpp setters) -- all
TYPEID_UNIT creatures with no such sub-struct. On one of them the +0xE68
pointer is garbage-but-non-null, so the null check passes and the deref
AVs (ERROR #132, pfUI issue #34: UnitIsInMyGuild on a nameplate pet/totem,
frequent in crowded dungeons/raids).

Add Unit::Flags::IsPlayerObject (m_objectType == OBJECT_TYPE_PLAYER via
[obj+0x14]) and use it as the gate at every arbitrary-unit +0xE68 reader:

  - UnitIsInMyGuild  (ReadGuildKey)      -- the reported crash
  - UnitIsAFK/UnitIsDND (TestPlayerFlag) -- same latent crash on a pet
  - IsUnitOnQuest    (Quest::Log::UnitHasQuest)
  - GetInventoryItemID (non-local unit visible-items path)

Local-player-only readers are unaffected. IsPlayerControlled stays for
minion/ownership classification (Unit::Pet), which wants the broad flag.
2026-08-05 20:53:30 -05:00
Brues c3a80053e9 equipment: Backported missing localization strings 2026-08-05 15:52:02 -05:00
Brues 5a3badb5bd feat(macro): add C_Macro.CreateMacro / EditMacro with string icons
The vanilla CreateMacro / EditMacro globals take an icon INDEX into the
GetMacroIconInfo list, and that list omits INV_* item icons entirely --
so item icons are unreachable through the stock API even though the
engine stores and renders them fine. C_Macro.CreateMacro / EditMacro
accept the icon as a texture string (a full Interface\Icons\<name> path
or a bare <name> basename) instead, writing the basename directly to the
macro struct; GetMacroInfo re-prefixes it as Interface\Icons\%s on read,
so any Interface\Icons\ texture round-trips.

Both go straight to the engine's own workers (FUN_004f1010 create /
FUN_004f1280 edit), which do the bounded copy, re-run the primary-spell
parser, set the persist-on-logout dirty flag, and refresh the action
bars. EditMacro resolves its index arg as a slot number or a macro name
(via the GetMacroIndexByName resolver), leaves any nil field unchanged,
and reads back the entry's local flag so the worker's unconditional
rewrite of it is a no-op.

The legacy globals stay untouched (index-only); string-icon callers opt
in through the C_Macro namespace. Verified in-game incl. an INV_* item
icon surviving a relog. Unblocks a pfUI macro-UI remodel.
2026-08-05 01:53:08 -05:00
Brues aae4eb8a2b fix(util): preserve nil holes in vararg forwarding; drop no-op unpack ranges
On this Lua 5.0 build, unpack() ignores its explicit i/j arguments entirely
and takes its element count from getn() -- which returns the table's `.n`
field, or border-searches to the first nil when `.n` is absent. Verified in
the binary (luaB_unpack 0x007030a0 -> luaL_getn 0x006f5050) and in-game
(unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).

- GenerateClosure built its forwarding table `rest` with no `.n`, so
  unpack(rest, 1, restN) border-searched and silently dropped every value
  past the first nil hole in the payload -- despite the comment claiming
  embedded nils survive. It now stamps `all.n` and unpack(all) forwards
  holes intact.

- Every other unpack(x, 1, x.n) / unpack(x, 1, count==x.n) call was a no-op:
  the table already carried `.n`, so unpack(x) is identical. Simplified
  across FunctionUtil, Pools, CallbackRegistry, and TableUtil's SafeUnpack,
  and corrected the header comments that recommended the redundant form.
2026-08-04 18:13:14 -05:00
Brues 0a31ee8a89 fix(events): stop EventRegistry dispatch on PLAYER_LOGOUT to prevent #132 crashes
A callback firing during the engine's logout teardown can touch state
that has already been freed, faulting with ERROR #132 (ACCESS_VIOLATION).
A dedicated frame owns PLAYER_LOGOUT and tears down frameEventFrame's
event delivery (UnregisterAllEvents + clear OnEvent) so nothing dispatches
during teardown. A separate frame keeps this decoupled from the
ref-counted consumer event set, which may no longer hold PLAYER_LOGOUT.
2026-08-04 13:25:02 -05:00
Brues 969273f5f7 tradeskilllink: link reagents in chat 2026-08-04 12:28:43 -05:00
Brues a4760386ed tradeskilllink: Properly format links for SetHyperlink 2026-08-04 11:55:47 -05:00
Brues f9ef12bc9b itemmixin: GetItemLink/GetInventoryTypeName switched to select
These were backported before select was
2026-08-03 22:39:16 -05:00
Brues 89d86bffb7 addon-compat: unregister ShaguTweaks libpredict's backported TBC events
ShaguTweaks' libpredict sender frame (ShaguTweaksPredictionSender) registers
TBC-era events alongside its vanilla ones — COMBAT_LOG_EVENT_UNFILTERED and
UNIT_SPELLCAST_START/STOP/FAILED/INTERRUPTED/SENT (libpredict.lua:342-347). In
stock 1.12.1 those never fire, so registering them was a harmless no-op and only
the vanilla SPELLCAST_* path ran. ClassicAPI now backports those events, so
libpredict's TBC branches fire too and double-process cast/heal predictions.

Unregister the six TBC events on libpredict.sender once ShaguTweaks loads,
restoring vanilla-only behavior. Guarded on the addon existing and the
libpredict table being present.
2026-08-03 17:06:07 -05:00
Brues a0d8d6d723 fix(events): forward leading-nil event payloads to callbacks
EventRegistry callbacks (RegisterOnceFrameEventAndCallback, etc.) dropped any
payload whose first arg was nil — e.g. this client's reload PLAYER_ENTERING_WORLD
(arg1=nil, arg2=1) delivered nil to every callback parameter. Root cause is a
WoW Lua 5.0 quirk: unpacking a table whose hole lands right after a present
element into a `func(first, ...)` collapses the receiving vararg count to 0, so
the payload after `owner` was silently lost.

Fix both build-then-unpack sites in CallbackRegistry to pass the leading arg
explicitly and the payload as the unpacked tail:
- TriggerEvent Function branch: func(owner, unpack(arg, 1, arg.n))
- GenerateClosure: func(bound[1], unpack(rest, 1, restN))

Verified in-game: RegisterOnceFrameEventAndCallback("PLAYER_ENTERING_WORLD", cb)
now delivers arg2=1 to the callback's 2nd parameter on reload.
2026-08-03 14:23:12 -05:00
Brues e0e4fbcf84 docs: list the new frame-attribute + modern-script-arg APIs
The attribute methods and the modern-script-args toggle shipped on this
branch but weren't in the README index, and the toggle wasn't documented
at all.

- README: add frame:SetAttribute / SetAttributeNoHandler / ClearAttribute /
  GetAttribute, the OnAttributeChanged script, and SetModernScriptArgs /
  GetModernScriptArgs to the Frame row.
- docs/API.md: document SetModernScriptArgs(enable) / GetModernScriptArgs()
  (opt-in modern positional handler args) with a Frame TOC entry. The
  attribute methods and OnAttributeChanged were already covered.
2026-08-03 01:57:45 -05:00
brues-code 1da06d4a73 frame: attribute backport, modern script args, and SMSG hook consolidation (#13)
* frame: backport Frame:SetAttribute/GetAttribute + unit-frame mouseover/click

Adds native SetAttribute / SetAttributeNoHandler / GetAttribute on the base
Frame registry: a per-frame, case-insensitive value store with the 3.3.5
prefix/name/suffix getter precedence, stored on the frame's own Lua table.

A `unit` attribute makes the frame a mouseover source. A WorldTick poll of the
engine's mouse-focus frame (GetMouseFocus's global) calls the real mouseover
setter (FUN_00492890) for its unit -- 1:1 with hovering the 3D model: model
highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the native GUID slot.
Stomp-proof (the engine's 3D-hover setter is event-driven) and independent of
the frame's scripts, so an addon setting its own OnEnter can't break it.

A `type1`/`type2`/`type` attribute installs a chained OnClick on that frame only
(PCall-guarded, Button-only) that dispatches target / assist / focus on the
unit at click time, then runs any prior handler.

No global hooks: mouseover reads the mouse-focus global; clicks use the frame's
own OnClick. Deliberately does NOT MinHook the button-click dispatcher
(FUN_00779540) -- SuperWoW's click-casting owns that prologue and a second
inline hook corrupts the trampoline (ERROR #132).

* frame: extend attribute backport (one-verb click dispatcher + OnAttributeChanged)

The `type*` click path now mirrors retail's SecureActionButton_OnClick:
resolve ONE verb per click from the modifier/button-qualified `type`
attribute and perform it, instead of a fixed `type1=target`.

- Modifier/button resolution: `[alt-/ctrl-/shift-]type[1..5]` with
  prefix..name..suffix -> name..suffix -> name precedence, read from the
  live key state and the click's button.
- Verbs: target (with the engine's default-interaction precedence -
  cast a pending spell / drop a cursor item on the unit instead of
  switching target), assist, focus, spell, stopcasting, macro, and menu.
- Own-the-click: when a verb resolves we perform it and do NOT chain the
  frame's previous OnClick, so a configured `type1` no longer double-
  dispatches alongside an addon's own conditional click handler.
- Self-healing wire: addons that re-SetScript OnClick (pfUI on every raid
  relayout) clobber our closure; WireOnClick re-wraps a clobbered Lua
  handler but skips its own C closure, so re-setting `type*` recovers
  without double-chaining.

Dispatch goes straight to C++ where we own the code - no Lua round-trip
through the global table: spell -> Spell::AtUnit::CastByName (a native
unit-targeted cast; the unit's GUID is fed to the engine's dispatcher so
there's no target juggling, and ground-target spells land at the unit's
feet), focus -> Unit::Focus::Set, and the target verb's predicates ->
Spell::AtCursor::IsPlacementActive / Cursor::Info::HasItem. Only genuine
engine entries (TargetUnit/AssistUnit/SpellTargetUnit/DropItemOnUnit/
SpellStopCasting) and addon-provided functions still go through Lua
globals. Spell::AtUnit gains a C++ header sharing its CastCore with the
C_Spell.CastAtUnit Lua entry.

macro verb: takes the content from the `macrotext`/`macro` attribute and
prefers an addon-provided RunMacro (SuperCleveRoidMacros etc. - handles
named macros and extended macro text), falling back to running the text
natively when no RunMacro global is present. The native path runs each
line through the stock FrameXML ChatEdit_ParseText via a throwaway
edit-box (GetText closure + no-op __index), so it needs no addon.

menu/togglemenu verb: pops the standard unit dropdown at the cursor via
the new ClassicAPI_ToggleUnitMenu (Util/SecureUnitMenu.lua), resolving
the unit -> menu type with Blizzard's own generic resolver
(self->SELF, pet->PET, grouped player->PARTY, other player->PLAYER,
NPC->RAID_TARGET_ICON). Keeps the FrameXML dropdown logic in Lua with
the C side as a thin call.

OnAttributeChanged is now a real SetScript/GetScript/HookScript-able
frame script on every frame: SetAttribute fires it, SetAttributeNoHandler
suppresses it. Made settable by co-hooking the base-frame script-name
resolver FUN_0076a0d0 (the one every frame type delegates through) and
handing back an external per-frame {handler, context} cell for that name -
frames are immortal in 1.12, so the cell never goes stale. One hook does
it (vs the tooltip OnTooltipSet* work's five) because we already own the
firing side. 1.12 convention: the handler reads this = frame, arg1 = name,
arg2 = value. Recursion-guarded so a handler may itself SetAttribute.

* frame: add Frame:ClearAttribute (retail 11.2.0)

`cleared = frame:ClearAttribute(name)` removes an attribute and returns
whether it was set. Registered on the base Frame method registry next to
SetAttribute/GetAttribute, so every frame has it.

Routes through DoSet's nil-value path (removes the stored key, drops a
`unit` mouseover binding) but does NOT fire OnAttributeChanged — verified
against a live retail 11.2 client: SetAttribute fires the handler,
ClearAttribute fires nothing, and the return is true for a set attribute /
false when there was nothing to clear.

* frame: store attributes in the Lua registry, hidden from Lua

Attributes were kept as a subtable on the frame's own Lua table under a
control-char key ("\1ClassicAPIAttributes"). That key, while collision-
safe, is still enumerable via pairs(frame) and readable/writable from Lua.

Move the store to registry[frame] in the Lua registry (LUA_REGISTRYINDEX),
which has no _G path — so the attribute map is invisible to pairs(frame)
and can't be read or tampered with from script, matching how retail keeps
attributes C-side. Only PushSub changed (create/fetch now key the registry
by the frame table); GetAttribute/DoSet/CopyAttr sit on top unchanged.
1.12 frames are immortal, so the strong registry key never leaks. The dead
kAttrKey constant is removed.

Verified in-game: SetAttribute/GetAttribute still round-trip, and a
pairs(frame) scan no longer finds any ClassicAPI key.

* frame: add the `item` click verb (SecureActionButton item action)

type*="item" uses the modifier-qualified `item` attribute:
- a "bag slot" string (e.g. "0 1") -> UseContainerItem(bag, slot);
- otherwise an item name / itemID / link -> C_Item.UseItemByName(item, unit),
  passing the unit as the use target (self-use items ignore it, targeted
  ones like scrolls/bandages apply to it);
- the deprecated `bag` + `slot` attributes are honored when `item` is unset.

Both bag paths go through lua_call (new CallTableFunc2Str / CallGlobalNum2)
rather than a direct C++ call: the item-use bag walk does SetTop(L, 0), so
it needs its own Lua stack frame — a direct call would wipe the dispatcher's
stack. (spell stays direct because Spell::AtUnit is stack-free; the helper
comment records the distinction.) Retail's "bag slot" string is the
preferred form; the deprecated per-attribute form is supported too.

* docs: format the attribute click verbs as a table

The verbs were one long paragraph of nested parentheticals - hard to scan.
Replace it with a Verb | Extra attributes | Action table so each verb's
inputs and behavior are legible at a glance. Content-equivalent; no API change.

* frame: clear target on the `target` verb when unit is "none"

Retail's SecureActionButton target action treats a "none" unit as "clear
target". Match it: the `target` verb calls ClearTarget() when the resolved
`unit` is "none" (case-insensitive), before the normal cursor/spell
precedence. Mouseover already handles "none" implicitly (it resolves to
GUID 0 = no unit). Docs' target-verb row updated.

* frame: pass modern positional args to script handlers

Vanilla always invokes frame-script handlers with zero Lua args; the
handler reads this/arg1/event as globals. Modern addon ports write
SetScript("OnMouseWheel", function(self, delta) ...) and get nothing.

Co-hook both script runners -- FUN_FRAME_INVOKE_SCRIPT (no-arg scripts)
and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) -- and reimplement
their pcall tail: keep the engine's exact this/argN global save-set-
restore and message-handler errfunc, but additionally push self +
arg1..argN as real positional arguments and pcall with nargs = 1+N.
Purely additive: a zero-param vanilla handler ignores the extras and
reads globals; a modern (self, delta) handler binds them.

OnEvent gets (self, event, arg1..argN), detected structurally by
matching the invoked handler ref against the frame OnEvent slot
(frame+0x0C) -- exact, since each SetScript makes a distinct ref.

Runtime switch SetModernScriptArgs()/GetModernScriptArgs(), default on;
when off both detours are a straight passthrough to the original runner,
which matters because FUN_FRAME_RUN_SCRIPT_ARGS is the hottest Lua path
in the engine (every OnUpdate frame). Verified in-game across
OnMouseWheel/OnClick/OnValueChanged/OnUpdate/OnEnter/OnLeave/OnEvent.

* net: consolidate spell SMSG hooks onto one dispatch funnel

The spell subsystem's per-opcode SMSG handlers (SpellGo, SpellStart,
SpellDelayed, ChannelStart/Update, SpellFailure, SpellFailedOther,
SpellCooldown) were each co-hooked directly -- and nampower (hadesmem)
plus SuperWoW detour the same prologues. Two inline hookers on one
prologue is the documented MinHook collision risk, and a whole-protocol
bisect fingered exactly this cluster as the crash-suspect surface.

Add Net::PacketDispatch: a receive-side mirror of Net::SendObserver that
co-hooks the single incoming-SMSG dispatch funnel FUN_NET_MESSAGE_DISPATCH
(0x00537AA0) -- a chokepoint nampower does NOT hook (it detours the leaf
handlers) -- and fans each packet out to opcode subscribers with the
cursor at the body and centralized save/restore.

Migrate all eight handlers to subscribers: aura/Source (SpellGo, which
re-decodes the packed guids + spellId the leaf handler used to hand it
pre-decoded), spell/Cast (Start/Delayed/Channel*/Failure*), and
lossofcontrol (Cooldown). The parse bodies are unchanged; only the hook
site moved. Subscribers run before the engine's leaf handler instead of
after a co-hook's original -- verified order-independent for all eight
(self-contained cache/state updates; SpellGo's aura-application handoff
rides a separate SMSG_UPDATE_OBJECT).

Cuts the ClassicAPI-nampower shared spell prologues from 10 to 3 (the
OnAura* method hooks, which have no packet chokepoint to relocate to).
One hook on an uncontended function replaces eight on contended ones.

Verified in-game: cast bars, channels (incl. Mind Control cast-then-
channel), pushback, remote-cast interrupt eviction, and C_LossOfControl
school lockouts all intact.

* ScriptArgs is back to opt-in

* frame: fix ScriptArgs comments to match opt-in default

* frame/net: dedupe packet observers, reuse Lua/token helpers, fix attr edge cases

Post-review cleanup of the attribute + SMSG-consolidation work.

- net: extract the shared packet-observer plumbing (PacketObserver.h) — the
  intrusive subscriber list, Subscribe(), and the read-opcode / reset-cursor /
  fan-out / restore loop — so Net::PacketDispatch (SMSG, u16) and
  Net::SendObserver (CMSG, u32) each reduce to a one-line hook thunk over
  FanOutByOpcode<OpcodeT>. Drops the dead per-namespace Callback aliases
  rather than keeping them as compat shims.

- Game::Lua: promote PushGlobalFunction / CallGlobal / CallGlobalString into
  the shared Lua helpers (a pattern several modules open-code); Frame
  attributes use them instead of private copies.

- frame/Attributes: resolve unit tokens through Unit::Identity::GuidForToken
  (engine resolver + player fast-path) instead of a duplicate raw-cast wrapper.

- frame/Attributes: wire the chained OnClick for every "type" action attribute
  the click resolver reads (IsTypeAttr: modifier prefixes + type1..type5), not
  just type/type1/type2 — a middle-click or modifier-only binding now installs.

- frame/Attributes: FireAttributeChanged snapshots and restores the caller's
  arg1/arg2 around the fire, so calling SetAttribute from inside a running
  script handler no longer clobbers its globals.
2026-08-03 00:37:36 -05:00
Brues 960b37d7e6 README: trim addon helper list and remove legacy notes
Remove Mixin/CreateFromMixins from the AddOns helper list and streamline the surrounding text. Drop outdated paragraphs describing engine-dependent addon features and the note about the triple-`!!!` folder prefix/load-order. This is a documentation cleanup only; no code or behavior changes.
2026-08-02 01:19:16 -05:00
Brues 0c27d61839 unit: add markN raid-target-marker unit tokens
"mark1".."mark8" (star..skull) now work as unit tokens against the whole
Script_Unit* surface, and fire per-token unit events (UNIT_HEALTH, ...)
with arg1 == "markN" -- the same TokenObserver path focus/nameplateN use.

Resolution: a markN branch in the FUN_TOKEN_TO_GUID hook
(unit/TokenExtensions.cpp) reads the engine's 8-slot marker table
(VAR_RAID_TARGET_GUIDS = 0x00B71368, mark{1..8} = table[N-1]), so every
stock Unit* gains the token for free and "mark1target" composes via the
existing suffix walker.

Events: unit/RaidTarget.cpp runs a WorldTick watcher that diffs the marker
table and attaches/detaches one descriptor observer per marked unit,
deferring registration until the unit is in the object table (a unit can
be marked while out of range and enter later) -- the same range handling
Focus does, x8. One distinct callback per slot fires its fixed "markN"
token. Idle (no markers set) costs 8 compares and no object lookups; a
marker on a non-unit is never observed.

Verified the array shape against the binary (reverse lookup FUN_004bb190
walks 8 uint64 slots) and against nampower's CGRaidInfo_m_raidtargets.
Docs added under Unit tokens (markN); README token table updated.
2026-08-01 23:55:44 -05:00
Brues f02f576e70 addon: hide + force-enable embedded !!!ClassicAPI so it can't be disabled
The bundled library provides FrameXML-compat fixes (e.g. the tooltip
mouseover guard) that the DLL and other addons rely on; letting a user
uncheck it at character-select would silently break them.

FinalizeEmbeddedEntry now sets entry+0x29 (filter-out) to hide it from
the AddOns list -- the flat-display builder FUN_0051da70 copies only
+0x29==0 entries into GetNumAddOns/GetAddOnInfo -- and entry+0x2b
(DefaultState) enabled. A co-hook on the per-character enable resolver
FUN_ADDON_ENABLE_RESOLVE (0x0051e470) force-returns "enabled" for
!!!ClassicAPI, defeating any stale WTF disable-override that hiding
alone would otherwise leave stuck off.

Hiding only affects the index-based display array; the addon hash table
keeps the entry, so ## Dependencies: !!!ClassicAPI, IsAddOnLoaded, and
GetAddOnInfo-by-name all still resolve. README updated to match.
2026-08-01 22:53:26 -05:00
Brues 0bd85dd4f3 tooltip: guard mouseover-clear recolor, fix white name flash (#12)
The DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover loss (retail
parity). The stock FrameXML GameTooltip OnEvent recolors the name
unconditionally via GameTooltip_UnitColor("mouseover"), which returns
white when no mouseover exists -- so the loss fire (and the transient
clear when clicking a unit) flashed NPC and PvP-flagged-player names
white during the tooltip fade.

Wrap the stock OnEvent in Util/GameTooltip.lua: swallow the event when
UnitExists("mouseover") is false, otherwise delegate to the original
untouched. The name keeps its color through the loss window and
recolors normally when a mouseover resolves again.
2026-08-01 22:53:15 -05:00
brues-code 29e3dab24a UNIT_SPELLCAST_* event family + GUID unit tokens (#11)
* spell: backport UNIT_SPELLCAST_* events for the local player

Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED,
INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP)
for the player, so cast-bar / rotation addons written against the modern
signature work on 1.12. Each carries (unit, castGUID, spellID, spellName,
rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape
castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's
events so START pairs with STOP. arg1 is always "player" (player-only phase).

The events are derived, not independently tracked: Spell::Cast already
hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so
Spell::CastEvents reuses that state.
  - START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick
    against the previous snapshot.
  - SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too).
    Deferred for channels so it lands after CHANNEL_START (modern order).
  - INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed
    co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED
    (repeated per retry, reusing the cast guid); a pre-cast rejection fires
    FAILED.
  - SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID
    forward so all of a cast's events share one guid.
  - CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also
    re-anchors g_channel.endMs to the server's remaining time so channel
    pushback (which shortens vanilla channels) shows in UnitChannelInfo.

Every fire is gated on Event::Custom::HasListeners (new), so the system
costs one pointer-compare per transition when no addon registers.

Supporting changes:
  - Net::SendObserver: shared co-hook on the NetClient send funnel with an
    AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point
    capture) both watch outgoing packets without owning the hook. Extracted
    from ComboDuration's private hook.
  - Cast.cpp: player channel now self-expires at its computed endMs in the
    poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP
    trailed the real end by ~1s); wrap-safe delta compare so a long-uptime
    ms tick past 2^31 can't misfire it.

Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are
not implemented.

* spell: extend UNIT_SPELLCAST_* to non-player units (phase 2)

Fires the six observable cast/channel events -- START, STOP,
CHANNEL_START, CHANNEL_STOP, SUCCEEDED, INTERRUPTED -- for other units,
not just the player. Each is fanned out once per unit token currently
mapping to the caster (target / focus / nameplateN / party / raid / pet /
mouseover), so a target frame, a party frame, and a nameplate cast bar
all get their own event with a shared castGUID -- matching retail.

Token fan-out reuses the engine's own GUID->token reverse map
(FUN_00515c50, nampower's unfilled GetNamesFromGUID) for native tokens,
plus focus/nameplate which the engine can't see, via
Unit::Identity::TokensForGUID. SuperWoW's raw-GUID token ("0x...") is
filtered out so only standard tokens fan out.

Remote events are derived from Spell::Cast's existing packet hooks:
  - START / CHANNEL_START from HandleSpellStart (SMSG_SPELL_START)
  - SUCCEEDED from the SPELL_GO hook (Aura::Source)
  - STOP / CHANNEL_STOP at the computed end, or on abort
  - INTERRUPTED (casts only) from HandleCastAborted (ClearCastingSpell /
    failure packets)
A per-caster state machine (PollRemote, from OnWorldTick) fires them in
modern order; a channel's SPELL_GO cast->channel ClearCastingSpell is
filtered so it doesn't self-interrupt at its own start.

Not fired for remotes (data is caster-only / client-local): SENT,
DELAYED, FAILED, CHANNEL_UPDATE.

Also in this change:
  - castGUID follows type rule from https://warcraft.wiki.gg/wiki/GUID#Cast : real casts are Type 3 with a
    time-based castUID (low 23 bits = UNIX second, high bits = per-second
    counter, matching retail's structure), threaded across a cast's events
    (player and remote converge on the same value); FAILED is Type 2 with
    a flat incrementing integer.
  - Channels never fire INTERRUPTED (player or remote) -- retail emits
    only CHANNEL_STOP whether a channel completes or is cut short.

arg1 is the caster's unit token ("player" or another token); the
spellName / rank tail extensions are unchanged.

* spell: return the castGUID as UnitCastingInfo's castID

UnitCastingInfo / CastingInfo now return the cast's castGUID as castID
(return 7) instead of nil -- the same string the UNIT_SPELLCAST_* events
carry, so an addon can correlate the polled info with the events. It's
pulled from Spell::CastEvents (the single source of the castUID) via a new
CurrentCastGuid accessor, and works for the player and remote units. The
timing stays sourced from Spell::Cast -- only the castGUID crosses over,
so there's no duplication of cast state.

UnitChannelInfo is left retail-exact (no castID; channels don't carry
one), even though our channel events do include the shared castGUID.

Also corrects the spell-cast-GUID spec attribution in comments/docs to
warcraft.wiki.gg/wiki/GUID#Cast (it was wrongly credited to Wowhead).

* spell: add UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR events

Backports the ground-target reticle events for the player:
RETICLE_TARGET when an AoE placement reticle comes up (Blizzard, Flare,
Rain of Fire, ...), RETICLE_CLEAR when it's cancelled. Args are
(unit, "", spellID, spellName, rank); arg1 is always "player".

Driven by polling the engine's targeting-flag global (0x00CECAC0 -- the
predicate behind SpellIsTargeting) from OnWorldTick: 0->nonzero fires
RETICLE_TARGET with the pending spellID (0x00CEAC58, written by
Spell_C_CastSpell); nonzero->0 fires RETICLE_CLEAR. CLEAR is suppressed
on placement (a CMSG_CAST_SPELL for the reticle spell went out, seen via
the shared send observer), so it fires only on a genuine cancel --
matching retail, which emits no CLEAR when the spell is placed/cast.

The castGUID slot (arg2) is "" rather than retail's nil: there's no cast
to identify yet, and the engine's event dispatcher can't emit a nil
mid-argument-list (a NULL %s corrupts the args after it -- the nil trick
only survives as the last arg). unit (arg1) and spellID (arg3) are exact.

* spell: fire UNIT_SPELLCAST_FAILED_QUIET for the quiet result whitelist

Vanilla's Spell_C_SpellFailed dropped SPELL_FAILED_DONT_REPORT silently.
Modern surfaces suppressed (no-error-text) failures as
UNIT_SPELLCAST_FAILED_QUIET rather than FAILED.

Verified against the 3.3.5 client's unit-spellcast dispatch (FUN_007fecc0),
which selects the QUIET event for a fixed 3-code whitelist, mapped to 1.12's
SpellCastResult enum (FUN_006e23e0):
  CHARMED (0x14) . DONT_REPORT (0x17) . SPELL_IN_PROGRESS (0x61)

SPELL_IN_PROGRESS (casting while already casting / a nampower spell-queue
rejection) is the common case that previously mis-fired as a loud FAILED.

* unit: accept raw GUID literals as unit tokens

Fold a "0x<hex>" branch into the existing FUN_TOKEN_TO_GUID hook so every
Unit* function (UnitName, UnitHealth, UnitClass, UnitExists, ...) accepts a
64-bit GUID literal in place of a token. The parsed GUID goes straight to
the engine's own object-manager lookup (the path "player" takes), so an
unloaded/out-of-range GUID resolves to nil like any absent unit, no error.
Reuses the existing suffix walker, so "0x...target" composes.

Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll"), cached):
SuperWoW already provides GUID input via its own resolver hook, so when it
is loaded we defer to avoid two layers parsing the same string. Behaves
identically either way. Input direction only.
2026-07-31 21:38:44 -05:00
Brues 555ea02447 loot: add C_Loot.LootAllCorpses to auto-loot nearby corpses
C_Loot.LootAllCorpses([max]) loots every nearby lootable corpse in
sequence -- coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM)
from each window before releasing and advancing. Optional max caps the
corpse count.

Shares Loot::Scan's existing corpse-walk state machine and its single
FUN_LOOT_CONTROLLER hook -- MinHook allows one hook per target, so this
can't be a separate module. ScanNearbyLoot scrapes each window and
releases; LootAllCorpses does the same walk but actually loots it first.
The walk stays silent (LOOT_OPENED/LOOT_CLOSED suppressed, so LootFrame
never flickers) while the item-received / money chat + inventory events
fire normally.

Packet-based and target-clean, matching the rest of the C_Loot suite:
no right-click, no target change, no auto-walk toward out-of-range
corpses. Money goes through FUN_005ea8c0 (CMSG_LOOT_MONEY) with the SAME
player pointer passed to FUN_CMSG_LOOT_UNIT -- whose send wrote this
window's target GUID to the player's +0x1D28 guard slot, so the money
send's internal guard passes. Item sends bypass the client BoP/unique
confirm dialog (the server still enforces real loot permissions).

Reimplemented in spirit from WeirdUtils's LootAllCorpses (public domain)
on our own packet loot primitives + object-scan helpers, rather than its
right-click-autoloot approach. Wires the existing AOELOOT keybinding to
it (was ScanNearbyLoot).
2026-07-31 14:51:24 -05:00
Brues e3f5318747 bag: gate BAG_UPDATE_DELAYED on VAR_IN_WORLD, drop the PEW latch
f91ffd3 gated the drain on a one-way PLAYER_ENTERING_WORLD latch
(Event::EnteringWorld::HasEnteredWorld), added on the belief that no
pollable in-world flag existed. cd674aa found the real one:
VAR_IN_WORLD (0x00B4B424), 1 while in world and 0 during any loading
screen.

The one-way latch stays true forever after the first login, so if the
object burst re-populates bag descriptors during a LATER zone/instance
loading screen it would fire BAG_UPDATE_DELAYED mid-load again -- the
very thing the gate was meant to prevent, recurring on every zone.
VAR_IN_WORLD clears on every transition, so polling it re-suppresses
during later loads and emits one DELAYED once actually in-world
(matching retail's one-per-load).

Switch the gate to VAR_IN_WORLD and remove the now-dead HasEnteredWorld
latch/accessor and its header; EnteringWorld keeps only its PEW-payload
role.
2026-07-31 13:04:15 -05:00
Brues cd674aa115 hearth: gate HEARTHSTONE_BOUND on in-world flag, not location diff
Turtle's server re-sends SMSG_BINDPOINTUPDATE on every map/zone
transition with the unchanged bind location, behind a loading screen.
The valid-flag gate only suppresses the initial post-login sync, so
every map change fired HEARTHSTONE_BOUND.

A player can't bind mid-load, so gate on the engine's "world is live"
flag instead: VAR_IN_WORLD (0x00B4B424), set to 1 by the enter-world
init FUN_004908C0 and back to 0 by the world-teardown FUN_00490A80 that
runs at the start of every transition (verified via the enter/leave
pair in Ghidra). The resync arrives with the flag 0 (suppressed); a
real innkeeper bind always arrives with it 1 (fires) -- including a
rebind at the same inn, unlike a location diff would.

Add VAR_IN_WORLD to Offsets.h (the real pollable in-world boolean;
0x00B4E37C, once wrongly labeled this, is only instance grace-period
state). Update the bind-handler offset comment and docs/API.md.
2026-07-31 12:58:32 -05:00
Brues f91ffd3189 bag: gate BAG_UPDATE_DELAYED on PLAYER_ENTERING_WORLD, not VAR_IN_WORLD
The in-world gate added in 5d785ea polled 0x00B4E37C, believed to be an
"in world" flag. It isn't: verified in the binary it's instance
grace-period state, written from a packet handler (FUN_0049e6c0) and
read only by the "recently left an instance" 90-second window check
(FUN_00495c90). It never reliably goes non-zero from being in the world,
so OnWorldTick's early-return held g_pending forever and
BAG_UPDATE_DELAYED stopped firing entirely. There is no clean pollable
in-world boolean at these offsets (0x00B4E378 is the map ID; map 0 is
valid).

Gate on the correct signal instead: PLAYER_ENTERING_WORLD, already owned
by Event::EnteringWorld. Expose a one-way HasEnteredWorld() latch flipped
the first time PEW fires (loading screen done, world live) and stays true
across /reload; UpdateDelayed holds g_pending until then, then emits one
DELAYED covering the settled login inventory. Correct the mislabeled
0x00B4E37C note in Offsets.h so the trap isn't repeated.
2026-07-30 20:13:05 -05:00
Brues 8361ae01eb equipment: unregister equip observers before re-registering on reload
PLAYER_EQUIPMENT_CHANGED fired an extra time per /reload. The event's
descriptor-field observers are registered in ObserverSetup_h, a co-hook
of the engine's per-enter-world inventory-observer setup. /reload
re-runs that setup but does NOT destroy the CGPlayer object, so the
prior session's observer nodes are still attached; the engine registrar
always appends (no dedup by callback), stacking a second observer per
equipment field each reload. Logout/login recreates the player object,
old nodes die with it, so the count resets to one -- matching the
observed "doubles only on /reload" symptom.

Unregister each equipment-field observer before re-registering it. On
/reload this drops the stale node (same object/guid/callback key) so we
keep exactly one per slot; on logout/login the object is already gone so
the unregister no-ops. Mirrors the objects-survive-/reload guard
Unit::TokenObserver uses for nameplates.
2026-07-30 20:02:59 -05:00
Brues 5d785ea0d3 bag: hold BAG_UPDATE_DELAYED until in-world
The per-frame world tick (FUN_0066FD50) runs behind the initial-login
loading screen, and the engine populates every bag slot during the
initial object burst -- setting g_pending while still loading. That made
OnWorldTick fire BAG_UPDATE_DELAYED repeatedly mid-load, so addons
rescanned bags for inventory that wasn't a real change yet.

Gate the drain on the engine's in-world flag (new VAR_IN_WORLD =
0x00B4E37C): hold g_pending -- without clearing -- until the flag is set.
The first in-world frame then emits a single DELAYED covering the settled
login inventory (matching retail's one BAG_UPDATE_DELAYED at login), and
a pending update suppressed during a later zone transition fires on
return. The three bag hooks are unchanged (still just set the flag).
2026-07-30 11:33:55 -05:00
Brues e84673dde5 item: clamp bank-bag slot count in NewItems baseline scan
AppendNonBagOwned read each bank-bag container's slot count straight
from the invMgr's first dword with no upper bound. A container resolved
while only partially initialized (plausible right at login) can yield a
garbage value there, driving AppendGuidArray into a multi-billion-
iteration loop -- a multi-second freeze. Clamp to kMaxContainerSlots
(64), far above any real vanilla/Turtle bag, so it's invisible to real
inventories and only trips on a bogus count.
2026-07-30 11:33:45 -05:00
Brues a2c5db7b76 time: add GetTimeCached() frame-stable timestamp
Backports retail 4.x's frame-cached GetTime() behavior under a distinct
name, leaving vanilla GetTime() live/per-call. 1.12's Script_GetTime
recomputes the OS tick on every call, so same-frame calls can differ;
GetTimeCached() samples once per frame (via the shared Tick::WorldTick
hook) and returns that until the next frame, on the same epoch as
GetTime() so the two are directly comparable. Falls back to a live
sample before the first world tick so it never returns 0.

Deliberately NOT a perf feature and documented as such: the 1.12 tick
source is cheap (GetTickCount shared-page read or rdtsc) and both
functions pay the same dominant Lua->C dispatch, so caching saves
nothing measurable. The only benefit is frame-stable semantics; the
real hot-loop fix is caching GetTime() in a Lua local, which no C
function can do for the caller. New global chosen over modifying
GetTime() to avoid changing a core vanilla function's behavior.
2026-07-30 03:12:07 -05:00
Brues 46597f6587 unit: fire UPDATE_MOUSEOVER_UNIT on mouseover loss
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.

Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.

Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.

Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
2026-07-30 00:45:00 -05:00
Brues 4e1c1863e9 debugtools: add Refresh button to TableInspector
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.

Adds TableInspector_Refresh() and a Refresh button in the header between
Back and the breadcrumb (breadcrumb shifted right and narrowed to fit).
2026-07-29 23:58:01 -05:00
Brues a4c57614bc debugtool: Use MouseIsOver global for fallback
WIthout ClassicAPI this will fallback to the 1.12.1 implementention of MouseIsOver
2026-07-29 13:14:13 -05:00
Brues e1a9d9b5c4 docs: document IsIndoors / IsOutdoors
Add the two globals to the State section of docs/API.md (with TOC
entries) and the README State row. Notes the 1/nil return contract
(retail + SuperWoW 2.2 parity) and that the state is a live WMO
geometry query, not zone/area based.
2026-07-29 11:04:45 -05:00
Brues cd280ac307 state: add IsIndoors / IsOutdoors globals
Backport the modern `IsIndoors()` / `IsOutdoors()` no-arg globals to 1.12.
Both return 1 when the condition holds, nil otherwise (the historical
retail contract, also what SuperWoW 2.2 uses), so `if IsOutdoors()` and
`IsOutdoors() == 1` both work. Recomputed live from the engine's WMO
geometry query, so they flip as the player crosses an interior threshold
rather than tracking zone/area; nil pre-world.

The signal is a live WMO query, not a cached flag. Vanilla ships no Lua
binding for it, and the static anchors all dead-ended (the WMO runtime
flag bits differ from 3.3.5; ZONE_CHANGED_INDOORS is never fired with a
literal event ID; WMOAreaTable is only the area-naming path). SuperWoW
2.2 exposes `IsIndoors`, so decoding its handler gave the exact recipe:
resolve the local player object, then call FUN_00612cc0(unit) -- a thunk
that reads the unit world/collision context at +0xE0 and tail-jumps to
the WMO query FUN_006706f0 (nonzero = outdoors, fully null-safe). Same
shape as the 3.3.5 client's IsIndoors (unit+0xB8), so cross-confirmed.

Added Offsets::FUN_UNIT_IS_OUTDOORS with the full trail; the two
functions live in Unit::State next to IsMounted / IsSwimming and share
Unit::Identity::PlayerObject().
2026-07-29 11:01:59 -05:00
Brues 564495248c item: apply random suffix in by-string GetItemNameByID / GetItemInfo
C_Item.GetItemNameByID and C_Item.GetItemInfo resolved an item link down
to just the itemID and dropped the random suffix (3rd `item:` field), so
"item:12022:0:769:0" returned "Iridium Chain" instead of "Iridium Chain of
the Owl" (and GetItemInfo's rebuilt link lost the 769). The global
GetItemInfo applies it; now these match.

Item::Arg::Resolve parses the suffix alongside the itemID. New
Item::Link::NameFromIDSuffix builds the suffix-decorated name from
(itemID, suffixID) without a live CGItem, via the engine's inner name
builder (FUN_005d8b00 = the formatter behind FUN_ITEM_BUILD_INSTANCE_NAME,
which reads the localized "of the X" suffix from ItemRandomProperties).
Item::Link::BasicFromIDSuffix preserves the suffix in the rebuilt link
(payload + bracketed name); BasicFromItemID is now a suffix=0 wrapper.

GetItemNameByID and GetItemInfo route through these; numeric/suffixless
input still yields the base name. Numeric item stats are unaffected (the
suffix only decorates the name/link). Enchant/unique fields are normalized
to 0 in the rebuilt link (suffix is what affects the name).

Verified in-game: item:12022:0:769:0 -> "Iridium Chain of the Owl" (name +
link); bare 12022 / item:12022:0:0:0 -> "Iridium Chain".
2026-07-28 16:33:50 -05:00
Brues 1f3f3cb9f6 aura: infer server-side DoT-duration changes from the triggering cast
C_UnitAuras expirationTime for a debuff on another unit is derived from
SMSG_SPELL_GO at cast time; the vanilla server never retransmits a LATER
server-side duration change on a non-self unit (verified in the server
source: SetAuraDuration/RefreshHolder, and UpdateAuraDuration is self-
scoped -- on a mob no packet is built at all). So a refresh/consume goes
stale in our cache. We can't hear the change, but we DO see the triggering
cast, so mirror the server's edit off that.

Aura::Source gains a rule engine applied in SpellGo_h (before the aura
gate -- triggers apply no aura themselves): a trigger matched by exact
spellID OR by SpellFamilyName + school index, an affected aura matched by
family + family-flag overlap (+ optional icon, rank-proof like the server
scripts), and an op (refresh/reduce/set/remove). Proc-applied affected
auras enter the cache unattributed (casterGuid 0); the rule accepts those
and attributes them to the trigger caster (also gives them a sourceUnit).

Lua: C_UnitAuras.RegisterAuraDurationModifier (exact-ID trigger) and
RegisterAuraDurationModifierByTrigger (family+school trigger), so the
server-specific ruleset lives in the addon, not the DLL.

Default Turtle ruleset (AuraDurationModifiers.lua, gated on
TURTLE_WOW_VERSION; IDs/flags DBC-verified):
  Conflagrate  -> reduce Immolate 3s
  Molten Blast -> refresh Flame Shock
  Shadow Weaving (5/5 talent 15334) -> priest shadow-school casts refresh
    Shadow Vulnerability 15258 (covers the max-stack refresh blind spot;
    Turtle reworked it, so stock DBC proc masks don't apply -- match by
    school). Best-effort: DoT-tick procs emit no cast packet.
Carnage's roll-gated Rip/Rake refresh is deliberately excluded (the client
can't know the server roll).

Verified in-game (debug trace): Conflagrate/Molten Blast, and Mind Blast /
SW:P / Mind Flay all refresh 15258 with advancing expiration + sourceUnit.
2026-07-28 16:14:45 -05:00
Brues 7ae9b83ad7 spell: CastAtUnit casts unit-target spells directly at the unit
Previously CastAtUnit only handled ground-target spells (placement at the
unit's feet); a non-ground spell fired with no implicit target (fell to
current selection). Now it resolves the unit once to its object, reads
position AND GUID, and dispatches the cast WITH that GUID -- the engine's
FUN_SPELL_CAST_DISPATCH substitutes it exactly where it would the current
selection (verified: passing 0 substitutes VAR_CURRENT_SELECTION_GUID), so
a unit-target/normal spell fires straight at that unit regardless of
current target.

Branch on placement state after dispatch: no placement -> cast went out
(unit-target); ground placement -> commit at the unit's feet (existing);
other placement -> unit not accepted as target, cancel + false.

AtCursor gains an optional targetGuid on DispatchSpellCast[ByName] (0 =
prior behavior, so CastAtCursor is unchanged) and IsPlacementActive().

Verified in-game: ground spells still land at the unit's feet; Frostbolt/
Renew/PW:Fortitude cast on the named unit while another unit stays targeted.
2026-07-27 23:36:03 -05:00
Brues 26e5fdc052 readme: place C_Glue helpers in the right sections
IsFirstLoadThisSession is glue-only -> GlueXML-calls section only.
IsOnGlueScreen is registered on both states -> main in-game table AND the
GlueXML-calls section.
2026-07-27 22:18:52 -05:00
Brues 4281cd8253 glue: add C_Glue.IsFirstLoadThisSession and C_Glue.IsOnGlueScreen
IsFirstLoadThisSession() - glue-only; true only during the first glue
screen since process launch, false after a world->glue logout (login <->
character-select stays within the first session). Backed by a
process-static glue-boot counter incremented in the GlueModuleAutoRegister
callback (fires once per glue boot).

IsOnGlueScreen() - true on a GlueXML screen, false in the world.
Registered on both Lua states; since glue and in-game states are mutually
exclusive the answer is a constant per state (true from glue, false
in-game), so it's callable from either environment with no runtime flag.

Verified in-game: first login true/true, post-world-logout false; in-game
C_Glue.IsOnGlueScreen() false.
2026-07-27 22:12:28 -05:00
Brues 9239756153 frame: add frame:GetEffectiveAlpha()
Own alpha times every ancestor's alpha -- the product WoW composites at
render time. Vanilla has only GetAlpha (a frame's own alpha); this walks
self -> parent -> ... via the engine's own GetAlpha/GetParent, so it
reflects the engine exactly, including 8-bit alpha quantization
(SetAlpha(0.5) -> 127/255). Frame-scoped, matching GetAlpha's registry.

Verified in-game: matches an equivalent Lua GetAlpha/GetParent walk, and
a two-level 0.5/0.5 chain returns (127/255)^2 = 0.248043 exactly.
2026-07-27 21:40:16 -05:00
Brues f13aa1943d unit: add UnitPosition(unit)
Backport the modern UnitPosition -> posY, posX, posZ, instanceID. Reads
any visible unit's world position through the shared Unit::Position helper
(CGObject::GetPosition vtable slot 5 -- same path UnitDistanceSquared
uses), so no group/taint restriction like retail: works for target,
mouseover, nameplates, in-range party/raid. nil when the unit has no
known position.

Retail return order (posY=world Y/west, posX=world X/north, posZ=height);
instanceID is the current Map.dbc id. Verified in-game: squared delta of
two UnitPosition reads matches UnitDistanceSquared exactly.

Note: SuperWoW also defines a global UnitPosition with a different shape;
last registrant wins if both load. Documented in docs/API.md.
2026-07-27 21:19:58 -05:00
Brues 438ad97f8f readme: list IsMouseOver/GetRect/IsDragging in the Frame row 2026-07-27 20:59:20 -05:00
Brues 7f4b20c6ec frame: add region:GetRect and region:IsDragging
Two more Region-base methods backported from 3.3.5, both on the Region
registry so they resolve on every region type.

GetRect() -> left, bottom, width, height, composed from the engine's own
GetLeft/GetBottom/GetWidth/GetHeight (all UI-scale conversion is engine
code). Returns nothing for an unpositioned region, matching retail.

IsDragging() -> true while the region is the frame currently being
moved/sized. Reads the engine's single global drag target
(*(VAR_UI_CONTEXT_PTR) + 0xCFC); StopMovingOrSizing (0x00776990) gates its
own clear on the identical `context+0xCFC == self` predicate. Non-frame
regions never match the stored frame, so they report false.

Verified in-game: GetRect on UIParent/Minimap; IsDragging toggles with a
live frame drag.
2026-07-27 20:56:04 -05:00
Brues 3f11f942bd addon: use native region:IsMouseOver
Add a MouseIsOver(region, top, bottom, left, right) global that forwards
to the native Region:IsMouseOver method (mirrors modern FrameXML's
forwarding; nil offsets default to 0 in the C method).

Drop DebugTools' hand-rolled DebugTools_IsMouseOver helper in favor of
the native method. This also fixes _CollectFramesUnderMouse's showHidden
path: the old helper baked a visibility check into the geometric test, so
a hidden-but-hovered frame could never surface; the native method is
purely geometric and visibility is already gated separately at the call
site.
2026-07-27 20:38:10 -05:00
Brues 9622ea7742 frame: add region:IsMouseOver([top, bottom, left, right])
Native Region method (all region types) for cursor-in-rect testing with
optional per-edge offsets. Mirrors the engine's own math: delegates to
the Region-registry rect getters (GetLeft/Right/Top/Bottom) and
GetCursorPosition, dividing the cursor by the region's effective scale
read from obj+0x7C -- the same field GetLeft/GetEffectiveScale read
internally, so it works on textures/fontstrings where GetEffectiveScale
(Frame-only) would typecheck-fail. Returns false for an unpositioned
region. Offset semantics match modern FrameXML's MouseIsOver forwarding.
2026-07-27 20:30:37 -05:00
Brues 659dd7e159 newitems: fix false "new" flags from transient bag-scan drops
A bag update in progress transiently drops items from the pure-C++ bag
read -- sometimes the entire backpack (all 16 slots) for a frame or two.
The old logic rebuilt the seen-set from each scan, so a momentarily-
missing item fell out of the set and was re-flagged "new" the instant it
reappeared (e.g. dragging/swapping an item into the backpack lit it up).

Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
2026-07-27 20:13:49 -05:00
Brues 1ae102b554 add string.split to workaround certain addons replacing strsplit 2026-07-27 15:41:23 -05:00
86 changed files with 6935 additions and 599 deletions
+42 -14
View File
@@ -4,6 +4,18 @@ on:
push:
branches: ['**']
tags: ['v*']
# Manual escape hatch (e.g. when push webhooks are throttled during a
# GitHub Actions incident). Run from the Actions UI:
# - select a `vX.Y.Z` tag ref -> release build of that tag, OR
# - run from a branch and pass `tag: vX.Y.Z` -> release build that
# creates/attaches the release to that tag.
# Leave `tag` blank on a branch run for a plain dev build (artifact).
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v2.0.0). Blank = dev build.'
required: false
default: ''
permissions:
contents: write
@@ -16,36 +28,52 @@ jobs:
with:
submodules: recursive
- name: Stamp embedded TOC version
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
# Resolve the effective version once so every step below agrees.
# is_release=true when a vX.Y.Z tag is in play (pushed tag,
# dispatched-from-tag, or a `tag` input); the embedded addon's
# `## Version:` and the compiled CLASSIC_API_VERSION are both
# stamped from it at embed/compile time (see cmake/embed_addon.cmake)
# — no source edit.
- name: Resolve version
id: ver
shell: bash
run: |
$version = '${{ github.ref_name }}' -replace '^v',''
$toc = 'AddOns/!!!ClassicAPI/!!!ClassicAPI.toc'
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
tag="${{ github.ref_name }}"
else
tag=""
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
if [ -n "$tag" ]; then
echo "is_release=true" >> "$GITHUB_OUTPUT"
else
echo "is_release=false" >> "$GITHUB_OUTPUT"
fi
- name: Configure (tag)
if: startsWith(github.ref, 'refs/tags/v')
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
- name: Configure (release)
if: steps.ver.outputs.is_release == 'true'
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ steps.ver.outputs.tag }}"
- name: Configure (branch)
if: "!startsWith(github.ref, 'refs/tags/v')"
- name: Configure (dev)
if: steps.ver.outputs.is_release != 'true'
run: cmake -B build -A Win32
- name: Build
run: cmake --build build --config Release
- name: Upload artifact
if: "!startsWith(github.ref, 'refs/tags/v')"
if: steps.ver.outputs.is_release != 'true'
uses: actions/upload-artifact@v4
with:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
- name: Release
if: startsWith(github.ref, 'refs/tags/v')
if: steps.ver.outputs.is_release == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: build/Release/ClassicAPI.dll
generate_release_notes: true
+3 -1
View File
@@ -45,4 +45,6 @@
# debug information files
*.dwo
CLAUDE.md
CLAUDE.md
/AddOns/!!!ClassicAPI/.classicapi-dev
+3
View File
@@ -42,3 +42,6 @@ Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
Util\SecureUnitMenu.lua
+38
View File
@@ -43,3 +43,41 @@ end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
local libp = ShaguTweaks.libpredict
if libp then
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
end
end)
end
-- Guard UPDATE_MOUSEOVER_UNIT handlers (issue #12).
function CAPI_MouseoverClearedCompat(frame)
if not frame then return end
local originalOnEvent = frame:GetScript("OnEvent")
frame:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") then
return
end
if originalOnEvent then
originalOnEvent()
end
end)
end
CAPI_MouseoverClearedCompat(GameTooltip)
if C_AddOns.DoesAddOnExist("Puppeteer") then
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
@@ -0,0 +1,70 @@
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
--
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
-- LATER server-side duration change on a non-self unit -- verified in the
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so C_UnitAuras.RegisterAuraDurationModifier mirrors the server's edit
-- off that trigger.
--
-- A trigger is matched by exact spellID (stable across ranks -- these are the
-- IDs the server binds its spell script to); the affected aura by
-- SpellFamilyName + a family-flag overlap (rank-proof, exactly how the server
-- scripts locate it). Flags below are from the client's Spell.dbc. When the
-- trigger is a whole class of spells rather than a named ability,
-- RegisterAuraDurationModifierByTrigger matches it by SpellFamilyName + school
-- index instead (see Shadow Weaving below).
--
-- NOT included on purpose: Carnage's Rip/Rake refresh fires behind a
-- server-side probability roll on Ferocious Bite. The client can't know the
-- roll's outcome, so inferring it would show wrong timers. If you accept that,
-- register it yourself, e.g. (DRUID = 7):
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("800000",16), 108, "refresh") -- Rip
-- C_UnitAuras.RegisterAuraDurationModifier(<ferociousBiteID>, 7, tonumber("1000",16), 494, "refresh") -- Rake
-- Conflagrate's *full*-consume variant (other servers) removes Immolate
-- outright; that clears the aura slot and ClassicAPI evicts it via the normal
-- removal path, so the reduce rule below is harmless there and correct on
-- Turtle (where Immolate keeps ticking with 3s shaved off).
if TURTLE_WOW_VERSION then
local WARLOCK, SHAMAN = 5, 11
-- Lua 5.0 has no 0x hex literals; tonumber(hex, 16) keeps the flags readable.
local IMMOLATE_FLAG = tonumber("4", 16) -- CF_WARLOCK_IMMOLATE (bit 2)
local FLAMESHOCK_FLAG = tonumber("10000000", 16) -- CF_SHAMAN_FLAME_SHOCK (bit 28)
-- Conflagrate (all ranks) -> shave 3s off the caster's Immolate.
local CONFLAGRATE = { 17962, 18930, 18931, 18932 }
for i = 1, table.getn(CONFLAGRATE) do
C_UnitAuras.RegisterAuraDurationModifier(CONFLAGRATE[i], WARLOCK, IMMOLATE_FLAG, 0, "reduce", 3)
end
-- Molten Blast (all ranks) -> refresh the caster's Flame Shock to full.
local MOLTEN_BLAST = { 36916, 36917, 36918, 36919, 36920, 36921 }
for i = 1, table.getn(MOLTEN_BLAST) do
C_UnitAuras.RegisterAuraDurationModifier(MOLTEN_BLAST[i], SHAMAN, FLAMESHOCK_FLAG, 0, "refresh")
end
end
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
-- (Smite, heals) which are priest-family but not shadow-school.
--
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
-- and re-checked there so a respec INTO 5/5 registers without a reload.
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
local PRIEST, SHADOW = 6, 5
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
if IsPlayerSpell(15334) then
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
end
end)
+19 -9
View File
@@ -26,10 +26,17 @@ local function GenerateClosure(func, ...)
for i = 1, boundN do bound[i] = arg[i] end
return function(...)
local callN = arg.n
local merged = {}
for i = 1, boundN do merged[i] = bound[i] end
for i = 1, callN do merged[boundN + i] = arg[i] end
return func(unpack(merged, 1, boundN + callN))
-- Forward bound + call args faithfully, embedded/leading nils and all.
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
-- a `.n` field: unpack takes its count from getn (which returns t.n)
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
local all = {}
local n = 0
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
for i = 1, callN do n = n + 1; all[n] = arg[i] end
all.n = n
return func(unpack(all))
end
end
@@ -145,11 +152,14 @@ function CallbackRegistryMixin:TriggerEvent(event, ...)
if funcs then
for owner, func in pairs(funcs) do
-- Stock 3.3.5 passes the owner as the first arg so callers can
-- distinguish multiple registrations of the same function.
local n = arg.n
local merged = { owner }
for i = 1, n do merged[i + 1] = arg[i] end
func(unpack(merged, 1, n + 1))
-- distinguish multiple registrations of the same function. `arg`
-- carries its own `.n`, so unpack(arg) forwards the payload with
-- leading/embedded nils intact. Do NOT instead merge into a
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
-- build's unpack counts via getn, so a `.n`-less table collapses at
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
-- arg1=nil arg2=1, dropped the payload).
func(owner, unpack(arg))
end
end
end
+16 -14
View File
@@ -2,7 +2,9 @@
--
-- Implementation differences from the modern source:
-- - Lua 5.0 has no select() and no `...` as expression; use the
-- implicit `arg` table (arg.n + arg[i]) and unpack(arg, 1, arg.n).
-- implicit `arg` table (arg.n + arg[i]). unpack(arg) already spans
-- arg[1..arg.n] via getn — this build's unpack ignores explicit i/j
-- args, so the count must live in the table's `.n`.
-- - Lua 5.0 has no `#` operator; use table.getn().
-- - 5.0's xpcall is strictly (f, handler) — no trailing args. We wrap
-- the script call in a local closure that captures frame + arg.
@@ -32,7 +34,7 @@ function ExecuteFrameScript(frame, scriptName, ...)
if script then
local args = arg;
local function invoke()
return script(frame, unpack(args, 1, args.n));
return script(frame, unpack(args));
end
xpcall(invoke, CallErrorHandler);
end
@@ -45,7 +47,7 @@ function CallMethodOnNearestAncestor(self, methodName, ...)
end
if ancestor then
return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n));
return true, ancestor[methodName](ancestor, unpack(arg));
end
return false;
@@ -58,7 +60,7 @@ function GetValueOrCallFunction(tbl, key, ...)
local value = tbl[key];
if type(value) == "function" then
return value(unpack(arg, 1, arg.n));
return value(unpack(arg));
else
return value;
end
@@ -117,12 +119,12 @@ function IterateTables(iteratorFunction, ...)
end
local s_passThroughClosureGenerators = {
function(f) return function(...) return f(unpack(arg, 1, arg.n)); end; end,
function(f, a) return function(...) return f(a, unpack(arg, 1, arg.n)); end; end,
function(f, a, b) return function(...) return f(a, b, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg, 1, arg.n)); end; end,
function(f) return function(...) return f(unpack(arg)); end; end,
function(f, a) return function(...) return f(a, unpack(arg)); end; end,
function(f, a, b) return function(...) return f(a, b, unpack(arg)); end; end,
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg)); end; end,
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg)); end; end,
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg)); end; end,
};
local s_flatClosureGenerators = {
@@ -138,7 +140,7 @@ local function GenerateClosureInternal(generatorArray, f, ...)
local count = arg.n;
local generator = generatorArray[count + 1];
if generator then
return generator(f, unpack(arg, 1, count));
return generator(f, unpack(arg));
end
assertsafe(false, "Closure generation does not support more than " .. (table.getn(generatorArray) - 1) .. " parameters");
@@ -147,14 +149,14 @@ end
-- Syntactic sugar for function(...) return f(a, b, c, ...); end
function GenerateClosure(f, ...)
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg, 1, arg.n));
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg));
end
-- Generates a closure with the specified arguments that will ignore extra arguments when called later. Useful for passing
-- through callbacks to APIs where we don't want extra arguments to be passed through, i.e. simple OnClick scripts.
-- This is equivalent to: function() return f(a, b, c); end INSTEAD OF function(...) return f(a, b, c, ...); end
function GenerateFlatClosure(f, ...)
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg, 1, arg.n));
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg));
end
function RunNextFrame(callback)
@@ -166,7 +168,7 @@ FunctionUtil = {};
function FunctionUtil.SafeInvokeMethod(table, methodName, ...)
local method = table[methodName];
if method then
return method(table, unpack(arg, 1, arg.n));
return method(table, unpack(arg));
end
return nil;
@@ -22,6 +22,18 @@ function EventRegistry:OnLoad()
-- Lua-5.0 varargs, so we forward through a small wrapper.
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
end)
-- Stop delivering events once logout begins: a callback firing during the
-- engine's teardown can touch state that's already gone. A dedicated frame
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
-- — folding the check into frameEventFrame's OnEvent would depend on the
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
self.logoutFrame = CreateFrame("Frame")
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
self.logoutFrame:SetScript("OnEvent", function()
self.frameEventFrame:UnregisterAllEvents()
self.frameEventFrame:SetScript("OnEvent", nil)
end)
end
-- Helper so we can forward up to nine event args (the most any vanilla
+2 -5
View File
@@ -216,8 +216,7 @@ function ItemMixin:GetItemLink() -- requires item data to be loaded
end
if self.itemID then
local _, link = GetItemInfo(self.itemID)
return link
return (select(2, C_Item.GetItemInfo(self.itemID)));
end
if not self:IsItemEmpty() then
@@ -299,9 +298,7 @@ end
function ItemMixin:GetInventoryTypeName()
if not self:IsItemEmpty() then
-- GetItemInfoInstant returns: itemID, itemType, itemSubType, equipLoc, ...
local _, _, _, equipLoc = C_Item.GetItemInfoInstant(self:GetItemID())
return equipLoc
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
end
end
+6 -5
View File
@@ -14,7 +14,8 @@
-- consumer addons doesn't fail; they'll error at Acquire() time
-- if anything actually invokes them.
-- - Lua 5.0 has no `...` as expression; the converter mixins use
-- `unpack(arg, 1, arg.n)` to forward varargs.
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
-- fallbacks (same pattern as FunctionUtil.lua).
@@ -381,12 +382,12 @@ function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
end
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FramePoolCollectionConverterMixin:CreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
@@ -432,12 +433,12 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
end
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FontStringPoolCollectionMixin:CreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
@@ -0,0 +1,59 @@
-- Unit right-click menu for attribute-driven unit frames.
--
-- Backs the `menu` / `togglemenu` click verb of the Frame:SetAttribute
-- backport (see src/frame/Attributes.cpp). When a frame with a `unit`
-- attribute resolves a click to the `menu` verb, the DLL calls
-- ClassicAPI_ToggleUnitMenu(unit), which pops the standard unit dropdown at
-- the cursor -- the same UnitPopup menu Blizzard's PlayerFrame / TargetFrame /
-- PartyMemberFrame show on right-click. Lets a unit frame express its
-- right-click menu purely as an attribute (e.g. type2 = "menu") instead of a
-- hand-rolled OnClick handler.
--
-- The unit -> menu-type resolution mirrors Blizzard's own generic resolver
-- (TargetFrameDropDown_Initialize in FrameXML): self -> SELF, pet -> PET, a
-- grouped player -> PARTY (whisper / inspect / trade / follow / promote / ...),
-- any other player -> PLAYER (adds INVITE), anything else (NPC) ->
-- RAID_TARGET_ICON. Vanilla's stock UI never wires a unit to the "RAID" menu
-- (there are no clickable raid unit frames in 1.12), and PARTY carries the
-- options players actually want on a grouped member, so grouped players (party
-- or raid) use PARTY -- matching how pfUI drives its raid menu.
local dropdown;
local function EnsureDropdown()
if not dropdown then
dropdown = CreateFrame("Frame", "ClassicAPIUnitMenuDropDown", UIParent, "UIDropDownMenuTemplate");
dropdown.displayMode = "MENU";
end
return dropdown;
end
local function ResolveMenu(unit)
if UnitIsUnit(unit, "player") then
return "SELF";
elseif UnitIsUnit(unit, "pet") then
return "PET";
elseif UnitIsPlayer(unit) then
if UnitInParty(unit) or UnitInRaid(unit) then
return "PARTY";
end
return "PLAYER";
end
return "RAID_TARGET_ICON";
end
function ClassicAPI_ToggleUnitMenu(unit)
if not unit or not UnitExists(unit) then
return;
end
local which = ResolveMenu(unit);
local name;
if which == "RAID_TARGET_ICON" then
name = RAID_TARGET_ICON;
end
local dd = EnsureDropdown();
dd.initialize = function()
UnitPopup_ShowMenu(dd, which, unit, name);
end
ToggleDropDownMenu(1, nil, dd, "cursor");
end
+1 -1
View File
@@ -431,7 +431,7 @@ function SafePack(...)
end
function SafeUnpack(tbl)
return unpack(tbl, 1, tbl.n)
return unpack(tbl)
end
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
+13 -5
View File
@@ -448,8 +448,8 @@ local function CreateFrame_TradeSkillLink()
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
if this.itemID then
GameTooltip:SetHyperlink("item:"..this.itemID);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
@@ -490,12 +490,17 @@ local function CreateFrame_TradeSkillLink()
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemLink then
if this.itemID then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink(this.itemLink);
GameTooltip:SetHyperlink("item:"..this.itemID);
GameTooltip:Show();
end
end);
btn:SetScript("OnClick", function()
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(this.itemLink);
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
@@ -564,6 +569,7 @@ local function CreateFrame_TradeSkillLink()
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.itemID = reagent.itemID
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
@@ -582,6 +588,7 @@ local function CreateFrame_TradeSkillLink()
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
@@ -630,6 +637,7 @@ local function CreateFrame_TradeSkillLink()
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
@@ -726,7 +734,7 @@ local function ShowTradeSkillLink(link, text)
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
+3
View File
@@ -0,0 +1,3 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
end
+30
View File
@@ -2,7 +2,31 @@ if GetLocale() ~= "deDE" then return end
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
CONFIRM_SAVE_EQUIPMENT_SET = "Möchtet Ihr das Ausrüstungsset \"%s\" speichern?";
DEBUFF_SYMBOL_CURSE = "Fl";
DEBUFF_SYMBOL_DISEASE = "Kr";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Gi";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Ausrüstungsmanager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Diesen Platz ignorieren";
EQUIPMENT_MANAGER_IS_DISABLED = "Der Ausrüstungsmanager ist abgeschaltet.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d fehlen)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "In Beuteln platzieren";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Diesen Platz einbeziehen";
EQUIPMENT_SETS = "Ausrüstungs-Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ein Ausrüstungsset mit diesem Namen gibt es bereits.";
EQUIPMENT_SETS_TOO_MANY = "Ihr könnt keine weiteren Ausrüstungssets erstellen.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Spezialisierung zuweisen:";
EQUIPMENT_SET_EDIT = "Name/Icon ändern";
EQUIPMENT_SET_SETTINGS = "Einstellungen";
EQUIPSET_EQUIP = "Anlegen";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Ausrüstungstausch fehlgeschlagen. Inventar ist voll.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Einige Gegenstände des Sets '%s' können nicht gewechselt werden, da Ihr Euch im Kampf befindet.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Mindestens ein Teil des Sets '%s' fehlt und kann nicht ausgerüstet werden.";
FOCUSTARGET = "Ziel als Fokus festlegen";
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
@@ -15,6 +39,10 @@ COPPER_AMOUNT_SYMBOL = "K";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "G";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Alle Symbole";
ICON_FILTER_ITEM = "Gegenstände";
ICON_FILTER_SPELL = "Zauber";
ICON_SELECTION_TITLE_CURRENT = "Aktuell ausgewählt";
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Ausrüstungsmanager";
PAPERDOLL_NEWEQUIPMENTSET = "Neues Set";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
+30
View File
@@ -1,11 +1,39 @@
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
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";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Equipment Manager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d missing)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Place In Bags";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Include This Slot";
EQUIPMENT_SETS = "Equipment Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "An equipment set with that name already exists.";
EQUIPMENT_SETS_TOO_MANY = "You cannot create any more new equipment sets.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assign to Specialization:";
EQUIPMENT_SET_EDIT = "Change Name/Icon";
EQUIPMENT_SET_SETTINGS = "Settings";
EQUIPSET_EQUIP = "Equip";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
ICON_FILTER_SPELL = "Spells";
ICON_SELECTION_TITLE_CURRENT = "Currently Selected";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
@@ -39,6 +67,8 @@ ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "s";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,7 +2,31 @@ if GetLocale() ~= "esES" then return end
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "¿Quieres guardar el conjunto de equipamiento \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Mal";
DEBUFF_SYMBOL_DISEASE = "En";
DEBUFF_SYMBOL_MAGIC = "Mag";
DEBUFF_SYMBOL_POISON = "Ve";
DEBUG_FRAMESTACK = "Trama de pila";
EQUIPMENT_MANAGER = "Gestor de equipamiento";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorar esta casilla";
EQUIPMENT_MANAGER_IS_DISABLED = "El Gestor de equipamiento está desactivado.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(falta %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Colocar en bolsas";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Incluir esta casilla";
EQUIPMENT_SETS = "Conjuntos de equipamiento: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ya existe un conjunto de equipamiento con ese nombre.";
EQUIPMENT_SETS_TOO_MANY = "Ya no puedes crear más equipamientos.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Asignar a especialización:";
EQUIPMENT_SET_EDIT = "Cambiar nombre/icono";
EQUIPMENT_SET_SETTINGS = "Config.";
EQUIPSET_EQUIP = "Equipar";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "No se ha podido cambiar el equipamiento. Inventario lleno.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Algunos objetos del equipo %s no se cambiarán porque estás en combate.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Falta uno o más objetos del equipo %s y no se ha podido equipar.";
FOCUSTARGET = "Establecer objetivo";
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
@@ -15,6 +39,10 @@ COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Todos los iconos";
ICON_FILTER_ITEM = "Objetos";
ICON_FILTER_SPELL = "Hechizos";
ICON_SELECTION_TITLE_CURRENT = "Selección actual";
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
ITEM_MOD_MANA_SHORT = "Maná";
ITEM_MOD_HEALTH_SHORT = "Salud";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestor de equipamiento";
PAPERDOLL_NEWEQUIPMENTSET = "Nuevo conjunto";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "frFR" then return end
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
CONFIRM_SAVE_EQUIPMENT_SET = "Voulez-vous sauvegarder l'ensemble d'équipement %s ?";
DEBUFF_SYMBOL_CURSE = "Mn";
DEBUFF_SYMBOL_DISEASE = "Md";
DEBUFF_SYMBOL_MAGIC = "Mg";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Empilement des cadres";
EQUIPMENT_MANAGER = "Gestionnaire d'équipement";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorer cet emplacement";
EQUIPMENT_MANAGER_IS_DISABLED = "Le Gestionnaire d'équipement est désactivé.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(objet manquant : %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Mettre en sac";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Inclure cet emplacement";
EQUIPMENT_SETS = "Ensembles d'équipement : |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Un ensemble d'équipement portant ce nom existe déjà.";
EQUIPMENT_SETS_TOO_MANY = "Vous ne pouvez plus créer de nouveaux ensembles d'équipement.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assigner à la spécialisation :";
EQUIPMENT_SET_EDIT = "Modifier le nom/l'icône";
EQUIPMENT_SET_SETTINGS = "Réglages";
EQUIPSET_EQUIP = "Equiper";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Échec de l'échange d'équipement. L'inventaire est plein.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Certains objets de l'ensemble %s ne peuvent pas être échangés car vous êtes en combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Une ou plusieurs pièces de l'ensemble %s manquaient et n'ont pu être équipées.";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GEARSETS_POPUP_TEXT = "Nom de l'ensemble (16 caractères max.) :";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Toutes les icônes";
ICON_FILTER_ITEM = "Objets";
ICON_FILTER_SPELL = "Sorts";
ICON_SELECTION_TITLE_CURRENT = "Sélection actuelle";
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Vie";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestionnaire d'équipement";
PAPERDOLL_NEWEQUIPMENTSET = "Nouvel ensemble";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "ruRU" then return end
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Пр";
DEBUFF_SYMBOL_DISEASE = "Бз";
DEBUFF_SYMBOL_MAGIC = "Мг";
DEBUFF_SYMBOL_POISON = "Яд";
DEBUG_FRAMESTACK = "Соединение рамок";
EQUIPMENT_MANAGER = "Комплекты экипировки";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(предмет для ячейки \"%d\" отсутствует)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Поместить в сумку";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Учитывать ячейку";
EQUIPMENT_SETS = "Комплекты экипировки: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Это название уже используется для другого комплекта.";
EQUIPMENT_SETS_TOO_MANY = "Вы не можете создавать новые комплекты экипировки.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Выбрать специализацию:";
EQUIPMENT_SET_EDIT = "Изменить название/значок";
EQUIPMENT_SET_SETTINGS = "Параметры";
EQUIPSET_EQUIP = "Надеть";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комплект обмундирования. Инвентарь переполнен.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предм
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GEARSETS_POPUP_TEXT = "Введите название комплекта (не более 16 символов):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Все значки";
ICON_FILTER_ITEM = "Предметы";
ICON_FILTER_SPELL = "Заклинания";
ICON_SELECTION_TITLE_CURRENT = "Текущий выбор";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "zhCN" then return end
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_TARGETFOCUS = "目标焦点";
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
DEBUFF_SYMBOL_CURSE = "诅";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架栈";
EQUIPMENT_MANAGER = "装备管理";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(缺少%d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放入背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包括此栏位";
EQUIPMENT_SETS = "装备配置方案:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "一个重名的装备方案已经存在。";
EQUIPMENT_SETS_TOO_MANY = "你不能再创建新的装备方案了。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定专精:";
EQUIPMENT_SET_EDIT = "修改名称/图标";
EQUIPMENT_SET_SETTINGS = "设置";
EQUIPSET_EQUIP = "装备";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GEARSETS_POPUP_TEXT = "输入方案名称(最多16个字符):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有图标";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法术";
ICON_SELECTION_TITLE_CURRENT = "当前已选择";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
+30
View File
@@ -2,6 +2,29 @@ if GetLocale() ~= "zhTW" then return end
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要儲存裝備設定「%s」嗎?";
DEBUFF_SYMBOL_CURSE = "咒";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架堆疊";
EQUIPMENT_MANAGER = "裝備管理員";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此欄位";
EQUIPMENT_MANAGER_IS_DISABLED = "裝備管理員已關閉。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d件%s|cffff0000(缺少%d件)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放進背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包含此欄位";
EQUIPMENT_SETS = "裝備設定:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "您所選擇的名稱已經有裝備設定使用了。";
EQUIPMENT_SETS_TOO_MANY = "你無法製造更多的新裝備設定。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定專精:";
EQUIPMENT_SET_EDIT = "改變名稱/圖示";
EQUIPMENT_SET_SETTINGS = "設定";
EQUIPSET_EQUIP = "裝備";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "裝備切換失敗。物品欄已滿。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "%s設定中的某些物品必須等到你脫離戰鬥後才能更換。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "%s設定中的一個或多個物品未尋獲,因此無法裝備。";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
@@ -12,9 +35,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
GEARSETS_POPUP_TEXT = "輸入設定名稱(最多16個字元):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有圖示";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法術";
ICON_SELECTION_TITLE_CURRENT = "目前選擇";
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
ITEM_MOD_MANA_SHORT = "法力";
ITEM_MOD_HEALTH_SHORT = "生命力";
@@ -42,6 +70,8 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "裝備管理員";
PAPERDOLL_NEWEQUIPMENTSET = "新設定";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
+4 -23
View File
@@ -155,25 +155,6 @@ _initFrame:SetScript("OnEvent", function()
end
end);
-- Vanilla has no Frame:IsMouseOver(); do it ourselves from cursor + bounds.
local function DebugTools_IsMouseOver(frame)
if (not frame) then return false; end
local visOK, visible = pcall(frame.IsVisible, frame);
if (not visOK or not visible) then return false; end
local sOK, scale = pcall(frame.GetEffectiveScale, frame);
if (not sOK or not scale or scale == 0) then return false; end
local cx, cy = GetCursorPosition();
cx = cx / scale;
cy = cy / scale;
local lOK, left = pcall(frame.GetLeft, frame);
local rOK, right = pcall(frame.GetRight, frame);
local tOK, top = pcall(frame.GetTop, frame);
local bOK, bottom = pcall(frame.GetBottom, frame);
if (not (lOK and rOK and tOK and bOK)) then return false; end
if (not (left and right and top and bottom)) then return false; end
return cx >= left and cx <= right and cy >= bottom and cy <= top;
end
local _EventTraceFrame;
_framesSinceLast = 0;
@@ -566,7 +547,7 @@ function EventTraceFrame_Update()
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
button:UnlockHighlight();
end
if (DebugTools_IsMouseOver(button)) then
if MouseIsOver(button) then
EventTraceFrameEvent_OnEnter(button);
end
end
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
local frame = EnumerateFrames(nil);
while (frame) do
local visOK, visible = pcall(frame.IsVisible, frame);
local mouseOver = DebugTools_IsMouseOver(frame);
if (mouseOver and (showHidden or (visOK and visible))) then
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 });
@@ -1037,7 +1018,7 @@ end
local function _UpdateFrameStackTooltip(tooltip, showHidden)
tooltip:ClearLines();
tooltip:AddLine("Frame Stack", 1, 1, 1);
tooltip:AddLine(DEBUG_FRAMESTACK or "Frame Stack", 1, 1, 1);
local matches = _CollectFramesUnderMouse(showHidden);
local count = table.getn(matches);
if (count == 0) then
+13 -5
View File
@@ -456,14 +456,11 @@
</Anchor>
</Anchors>
</FontString>
<!-- Breadcrumb showing the active navigation path,
e.g. "_G > MyFrame > backdrop". Updated each
`TableInspectorFrame_Update` tick. -->
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="380" y="16"/>
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="80" y="-34"/>
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
@@ -510,6 +507,17 @@
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
+8
View File
@@ -20,6 +20,7 @@
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
@@ -184,6 +185,13 @@ function TableInspector_Back()
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
+37 -27
View File
@@ -25,6 +25,39 @@ set(TINYCBOR_SOURCES
external/tinycbor/src/cborparser_float.c
external/tinycbor/src/cborerrorstrings.c)
# Version derivation. Dev builds use a sentinel "newer than every
# plausible release" so addon-side `CLASSIC_API_VERSION >= X` gates
# always pass locally. CI release builds pass `-DCLASSICAPI_TAG=vX.Y.Z`,
# which drives BOTH the compiled `CLASSIC_API_VERSION` int AND the
# embedded addon's `## Version:` toc line (substituted in
# embed_addon.cmake). The on-disk source toc stays `## Version: DEV`;
# only the embedded copy is stamped, so a release build needs nothing
# but the flag — no source edit.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TOC_VERSION "DEV")
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
set(CLASSICAPI_TOC_VERSION
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
)
endif()
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
# surface the addon to the engine even when the user doesn't have it
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
@@ -34,12 +67,16 @@ set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
"${EMBEDDED_CLASSICAPI_SRC}/*")
# The `.classicapi-dev` marker is a disk-only developer signal — keep it
# out of the embed inputs (embed_addon.cmake filters it too).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "/\\.classicapi-dev$")
add_custom_command(
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
-DADDON_NAME=ClassicAPI
-DADDON_VERSION=${CLASSICAPI_TOC_VERSION}
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
DEPENDS
${EMBEDDED_CLASSICAPI_FILES}
@@ -71,33 +108,6 @@ add_library(${PROJECT_NAME} SHARED
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
# 99,999,999. Picked so addon-side feature gates of the form
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
)
endif()
target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
+49 -31
View File
@@ -35,20 +35,21 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [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) |
| [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` |
| [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.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.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [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) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
@@ -60,17 +61,17 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `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.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [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` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [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` |
| [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` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifier`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -90,6 +91,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) |
| [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` |
@@ -153,6 +155,19 @@ when launching with `-console`), not as Lua functions. See the
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `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` |
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
@@ -180,6 +195,7 @@ when launching with `-console`), not as Lua functions. See the
|-------|-------------|
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
### Bindings
@@ -223,36 +239,38 @@ The Lua-side companion library lives in
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
functions but that consumer code still expects to find as globals —
`Mixin` / `CreateFromMixins`, `CallbackRegistryMixin`, `EventRegistry`,
`ColorMixin` + `CreateColor`, `Item` / `ItemLocation`, `MathUtil`
(`Lerp` / `Clamp` / `CreateCounter`), `TableUtil` (`tCompare`,
`MergeTable`, `SafePack`, etc.), and `EventUtil`
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
(`ContinueOnAddOnLoaded` etc.).
Some of the addon's surface depends on engine functions ClassicAPI
provides (`C_UIColor.GetColors` populates the modern color globals,
`C_Item.GetItemInfoInstant` powers `ItemMixin`, the
`ITEM_DATA_LOAD_RESULT` event drives `ContinueOnItemLoad`); the rest
is pure Lua and would work standalone. Without the DLL the addon
still loads, but those features no-op.
The triple-`!` prefix is **load-order-significant** — WoW loads
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
any consumer that depends on its globals. Don't rename the folder.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. The engine
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
`ADDON_LOADED`, supports `SavedVariables`, the works.
the library is always available when the DLL is loaded. It fires
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
`## Dependencies: !!!ClassicAPI` all work as usual.
If you do drop the folder into your `Interface/AddOns/` directory,
**that copy wins** — the DLL's embedded version is only registered
when the engine's normal scan doesn't already have an entry under
that name. Useful for editing the Lua locally without rebuilding the
DLL. The dispatch is transparent: addons consuming `Mixin`,
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
**It is intentionally always-on and cannot be disabled.** The library
provides FrameXML-compat fixes that other addons and the DLL's own
features rely on, so leaving it toggleable would let a user (or a
one-off character-select uncheck) silently break them. The DLL
therefore hides it from the character-select AddOns list and
force-enables it every login — so it never appears as a checkbox and
can't be switched off. Being hidden means it's omitted from the
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
by-name lookup and dependency reference still resolves normally.
If you drop the folder into your `Interface/AddOns/` directory, **that
copy's files win** — the DLL's embedded version only serves files when
the engine's normal scan doesn't already have a newer entry under that
name. Useful for editing the Lua locally without rebuilding the DLL.
The hidden + force-enabled treatment applies either way (it's keyed to
the addon name, not to which copy serves the files), so the local copy
is also absent from the AddOns list. The dispatch is transparent:
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
identically in both cases.
## Bundled addon: DebugTools
+24 -1
View File
@@ -34,6 +34,10 @@ if(NOT ADDON_NAME)
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
# Never embed the developer marker — it must reflect on-disk presence
# only (see src/addons/Embedded.cpp DiskHasDevMarker), and it is
# gitignored so it only exists in a developer working tree anyway.
list(FILTER INPUT_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
@@ -41,7 +45,26 @@ set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
# Stamp the addon's `## Version:` line from the build flag rather
# than the committed source (which stays `DEV`). This is what makes
# a release build a pure function of `-DCLASSICAPI_TAG` — the source
# toc is never edited. Only the `.toc` file is rewritten, in-memory,
# before hex-encoding; every other file is embedded verbatim.
if(ADDON_VERSION AND REL_PATH MATCHES "\\.toc$")
file(READ "${ABS_PATH}" TOC_TEXT)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${ADDON_VERSION}" TOC_TEXT "${TOC_TEXT}")
# Round-trip through a temp file to hex-encode the modified text
# (portable to CMake 3.10; avoids requiring string(HEX), 3.18+).
set(TOC_TMP "${OUT_HEADER}.toc.tmp")
file(WRITE "${TOC_TMP}" "${TOC_TEXT}")
file(READ "${TOC_TMP}" CONTENT_HEX HEX)
file(REMOVE "${TOC_TMP}")
else()
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
endif()
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
+877 -30
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
## Notes on specific entries
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
the cursor off a unit clears the mouseover and signals nothing.
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
(`0x00492890`, the sole writer of the mouseover GUID) and fires
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
retail. Gated on the *old* mouseover being a unit and the *new* one
not, so moving off a gameobject / item (which never fired a gain)
doesn't spuriously fire. No payload either way — handlers read
`UnitExists("mouseover")`, false at fire time on loss. See
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
+27
View File
@@ -185,6 +185,33 @@ void SetGlobalNumber(void *L, const char *name, double value) {
RawSet(L, GLOBALS_INDEX);
}
bool PushGlobalFunction(void *L, const char *name) {
PushString(L, name);
GetTable(L, GLOBALS_INDEX);
if (Type(L, -1) == TYPE_FUNCTION)
return true;
SetTop(L, -2); // pop the non-function
return false;
}
void CallGlobal(void *L, const char *name) {
const int top = GetTop(L);
if (PushGlobalFunction(L, name))
Call(L, 0, 0);
SetTop(L, top);
}
bool CallGlobalString(void *L, const char *name, const char *arg) {
const int top = GetTop(L);
const bool called = PushGlobalFunction(L, name);
if (called) {
PushString(L, arg);
Call(L, 1, 0);
}
SetTop(L, top);
return called;
}
void PushLocalizedString(void *L, const char *globalName, const char *fallback) {
PushString(L, globalName);
GetTable(L, GLOBALS_INDEX);
+16
View File
@@ -244,6 +244,22 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
// using `PushLocalizedString` + manual `string.format` invocation.
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n);
// Pushes `_G[name]` and returns true only if it resolved to a function
// (leaving it on the stack, ready to be called); otherwise pops it and
// returns false. The building block for the CallGlobal* helpers below and for
// any C module that dispatches to a Lua/FrameXML global by name (a pattern
// several modules otherwise open-code).
bool PushGlobalFunction(void *L, const char *name);
// `_G[name]()` — no args, no results; a no-op if the global isn't a function.
// Stack-neutral.
void CallGlobal(void *L, const char *name);
// `_G[name](arg)` — one string arg (NULL → nil), no results. Returns true iff
// the global was a function and got called, so callers can fall back to a
// native path when the FrameXML/addon global is absent. Stack-neutral.
bool CallGlobalString(void *L, const char *name, const char *arg);
} // namespace Lua
// Developer-console command system — the `~` console available when the
+371 -8
View File
@@ -140,6 +140,51 @@ enum Offsets {
// and call the inner invoker, which is self-contained.
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
// The arg-passing sibling of FUN_FRAME_INVOKE_SCRIPT — every frame
// script that carries values (OnClick(button), OnMouseWheel(delta),
// OnUpdate(elapsed), OnValueChanged(value), OnKeyDown(key), OnEvent's
// args, …) funnels through here. __cdecl(int handlerRef, void *frame,
// const char *fmt, void *vaPtr): fmt is a printf-style spec string
// (`%d`/`%u`/`%f`/`%s`), vaPtr points at the packed vararg buffer
// (4-byte stride for d/u/s, 8 for f). It sets the `arg1..argN` globals
// from the format (saving/restoring the previous values), sets the
// `this` global to `frame`, then runs the handler under a protected
// lua_pcall with **zero Lua args** and the engine error handler
// (VAR_FRAMESCRIPT_ERROR_HANDLER_REF) as errfunc. FUN_FRAME_INVOKE_SCRIPT
// is the same shape for the no-arg scripts (OnShow/OnHide/OnEnter/…).
// `Frame::ScriptArgs` co-hooks both to additionally pass the handler
// modern positional args (self, arg1..argN). The frame's own Lua object
// ref lives at frame+OFF_COBJECT_LUA_REF (refcount at
// OFF_COBJECT_LUA_REFCOUNT); FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER
// lazily creates it when the refcount is 0.
FUN_FRAME_RUN_SCRIPT_ARGS = 0x00704F10,
// The base ScriptObject's OnEvent handler slot — an 8-byte
// {handler, context} pair at frame+0x0C (from FUN_00702590, the base
// resolver every frame-type resolver chains through, and confirmed by
// the event dispatchers FUN_00703E50 / FUN_00703F50 passing `frame+0xC`
// as the slot). `*(int*)(frame + OFF_FRAME_ONEVENT_SLOT)` is the OnEvent
// handler ref; `Frame::ScriptArgs` compares it against the handler ref
// the runner is invoking to detect an OnEvent dispatch (exact — each
// SetScript makes a distinct ref, so a function bound to two scripts
// still differs per slot) and prepend the `event` positional.
OFF_FRAME_ONEVENT_SLOT = 0x0C,
// Registry ref (an int, not a pointer) to the frame-script message
// handler the engine passes as the `errfunc` to every script pcall —
// read as `*(int*)VAR_FRAMESCRIPT_ERROR_HANDLER_REF`, pushed via
// lua_rawgeti(REGISTRY, ref). Set once at engine init.
VAR_FRAMESCRIPT_ERROR_HANDLER_REF = 0x008722C8,
// The base Frame script-name resolver — __thiscall(frame, const char *name)
// -> int* slot, 0 for an unknown name. Maps the standard base-frame scripts
// to their 8-byte {handler, context} slots on the frame (OnLoad@+0x118,
// OnUpdate@+0x128, OnEnter@+0x140, … OnKeyUp@+0x190) after delegating to the
// ScriptObject base FUN_00702590 (OnEvent@+0xC). Every frame TYPE's resolver
// chains through this (the tooltip resolver above calls it first), so
// co-hooking it here lets `Frame::Attributes` make `OnAttributeChanged`
// SetScript/GetScript/HookScript-able on ALL frames — for that one name the
// co-hook hands back an external per-frame cell (frames are immortal in 1.12,
// so a pointer-keyed cell never goes stale). Same pattern + ABI modeling as
// FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
FUN_FRAME_SCRIPT_RESOLVER = 0x0076A0D0,
// The other per-object tooltip builders, co-hooked the same way as
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
@@ -316,6 +361,16 @@ enum Offsets {
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Inner name formatter behind FUN_ITEM_BUILD_INSTANCE_NAME, callable
// WITHOUT a CGItem: `__fastcall(char *out /*ecx*/, uint outSize /*edx*/,
// uint32 itemID, int suffixID)`. Fetches the ItemStats record for itemID
// and, when suffixID is a valid ItemRandomProperties row with a localized
// suffix name (record +0x1c + locale*4), formats base + suffix via
// ITEM_SUFFIX_TEMPLATE; otherwise the base name. Lets the by-STRING item
// paths (GetItemNameByID, C_Item.GetItemInfo) apply an item link's random
// suffix without a live item instance.
FUN_ITEM_BUILD_NAME_FROM_ID = 0x005D8B00,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -401,6 +456,12 @@ enum Offsets {
// probe — equivalent to checking `this+0x08 > 0` but more direct.
OFF_COBJECT_LUA_REFCOUNT = 0x04,
// `this+0x08` — the CObject's Lua-registry ref (an int key into the
// registry table). `lua_rawgeti(REGISTRY, this[+0x08])` pushes the
// frame's Lua-side object. Lazily populated by ScriptRegister when the
// refcount above is 0.
OFF_COBJECT_LUA_REF = 0x08,
// Direct cvar lookup — `__fastcall(const char *name) → CVar* | NULL`.
// Hash-table by-name lookup over the CVar registry; same call
// `Script_GetCVar` makes internally before the engine wraps the
@@ -544,6 +605,49 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,
// `__fastcall(const uint64_t *guid /*ecx*/, int *outCount /*edx*/) ->
// char** tokenArray` — the engine's GUID → unit-token REVERSE map. Fills
// a reused static buffer array (the returned pointer) with the name
// string of every ENGINE-NATIVE token currently pointing at `guid` and
// writes the count to `*outCount`. Checks, in order: player, pet, target,
// party1..4, partypet1..4, raid1..40, raidpet1..40, npc, mouseover — the
// same slots the per-token unit-event broadcast `FUN_00515e50` fans out
// to (that function is just this map + a fire-per-token loop). Does NOT
// know ClassicAPI's synthetic `focus` / `nameplateN` tokens (the engine
// has no concept of them) — callers add those separately. Non-throwing:
// returns count 0 pre-world (resolves the active player internally and
// bails if absent). The returned array is a shared static reused on the
// next call, so snapshot the strings before doing anything that could
// re-enter. nampower names this `GetNamesFromGUID` (left as an unfilled
// TODO in its offsets.hpp). Used by `Unit::Identity::TokensForGUID` for
// the phase-2 `UNIT_SPELLCAST_*` remote-unit fan-out.
FUN_UNIT_TOKENS_FROM_GUID = 0x00515C50,
// Mouseover-unit GUID globals — the `"mouseover"` token resolves to
// these (see the token-dispatch list above). Written ONLY by
// `FUN_SET_MOUSEOVER_UNIT`; zero when nothing is moused over. Used by
// `Unit::Mouseover` to detect the loss transition.
VAR_MOUSEOVER_GUID_LO = 0x00B4E2C8,
VAR_MOUSEOVER_GUID_HI = 0x00B4E2CC,
// The engine's single mouseover set/clear chokepoint —
// `__stdcall(guidLo, guidHi, arg3, arg4)` (early returns are all
// `ret 0x10`, and the caller pushes 4 without cleaning). It is the
// ONLY writer of `VAR_MOUSEOVER_GUID_*`, so every mouseover
// gain/loss/change flows through it. On a unit gain it fires
// `UPDATE_MOUSEOVER_UNIT` itself (via `FUN_FIRE_EVENT_NO_ARGS(0x150)`
// in the friendly/hostile-unit tooltip case); on loss it clears the
// GUID but fires nothing — the gap `Unit::Mouseover` closes.
// Change-gated by its callers (if it ran per-frame the gain fire
// would spam every frame while hovering, which vanilla doesn't), so
// it's a cool hook target.
FUN_SET_MOUSEOVER_UNIT = 0x00492890,
// Event ID of `UPDATE_MOUSEOVER_UNIT` (name-table slot
// `0x00BE16D8` → `(0x16D8 - 0x1198) / 4`). Fired with no args via
// `FUN_FIRE_EVENT_NO_ARGS`; Lua handlers read `UnitExists("mouseover")`.
EVENT_UPDATE_MOUSEOVER_UNIT = 0x150,
// `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
// comparator. **`int __stdcall(const char *a, const char *b, int n)`**
// — the function ends with `ret 0xc`, so the callee pops the
@@ -1181,6 +1285,20 @@ enum Offsets {
// collision, per-channel-start frequency).
FUN_SPELL_CHANNEL_START = 0x006E7550,
// `MSG_CHANNEL_UPDATE` handler (`FUN_006e75f0`) — same `int __stdcall(
// uint32_t *opCode, CDataStore *packet)` shape. Body: a single u32 =
// the channel's new REMAINING time (ms). Sent to the caster on damage
// pushback (which shortens a vanilla channel) and once more at the
// channel's end (remaining == 0). Verified at 0x006e75f0: reads the u32,
// then fires vanilla's `SPELLCAST_CHANNEL_UPDATE(remaining)` (event
// 0x157) on pushback or `SPELLCAST_CHANNEL_STOP` (0x158) at end — but
// stores the new end NOWHERE, so `Spell::Cast`'s g_channel.endMs
// (computed once at start) never reflects pushback. Co-hooked to
// re-anchor endMs (and fire UNIT_SPELLCAST_CHANNEL_UPDATE). nampower
// hooks it too (SpellChannelUpdateHandlerHook — accepted co-hook, per-
// pushback frequency, same as the START sibling).
FUN_SPELL_CHANNEL_UPDATE = 0x006E75F0,
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
@@ -1210,6 +1328,21 @@ enum Offsets {
// hook it.
FUN_SPELL_FAILURE = 0x006E8D80,
// `Spell_C_SpellFailed` — the CLIENT-side cast-failure entry, distinct
// from the two SMSG_SPELL_FAILURE/_OTHER packet handlers above. Fires
// for the local player's own cast rejections (out of range, no mana,
// "spell not ready", LoS, interrupted, …). `__fastcall(uint32_t
// spellId, uint8_t spellResult /*edx*/, int unk1, int unk2, bool
// failedByServer)` — signature per nampower's Spell_C_SpellFailedT.
// Backs `UNIT_SPELLCAST_FAILED` (`Spell::CastEvents`). Not hooked
// elsewhere in this project (we hook the packet handlers, not this).
FUN_SPELL_C_SPELL_FAILED = 0x006E1A00,
// `Spell_C_SpellFailed` result code for a fake/suppressed failure
// (Unleashed Potential and similar) — the engine's SPELL_FAILED_DONT_
// REPORT. nampower filters it out of its failure event; so do we.
SPELL_FAILED_DONT_REPORT = 23,
// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
// int spellID, char notify, char cleanup)` (nampower names the offset
// but never hooks it). THE engine choke point for "this unit stopped
@@ -1243,9 +1376,29 @@ enum Offsets {
// funnels through here (the raw-ingress sibling at 0x00537B10 only
// special-cases 0x1DD compressed-moves before queueing into this).
// Useful as a temporary whole-protocol sniff point: co-hook, peek the
// u16 at the cursor, restore, log.
// u16 at the cursor, restore, log. `Net::PacketDispatch` co-hooks it as
// the single fan-out point for incoming-packet subscribers — the
// collision-avoiding alternative to each feature MinHook-ing its
// per-opcode SMSG handler (which nampower/SuperWoW also hook). nampower
// does NOT hook this funnel (it detours the per-opcode handlers), so
// moving our parsing here removes those shared prologues.
FUN_NET_MESSAGE_DISPATCH = 0x00537AA0,
// Incoming spell-subsystem SMSG opcodes — the u16 message ids the engine
// dispatch table (see FUN_006e7150) maps to per-opcode handlers. Used by
// the `Net::PacketDispatch` subscribers that replaced the per-handler
// co-hooks. Verified from the registrar: 0x131/0x132 both route to
// FUN_006E7640 (it branches on the opcode; the 0x132 path decodes and
// calls the SpellGo builder FUN_006E7A70).
SMSG_SPELL_START = 0x131,
SMSG_SPELL_GO = 0x132,
SMSG_SPELL_FAILURE = 0x133,
SMSG_SPELL_COOLDOWN = 0x134,
SMSG_SPELL_CHANNEL_START = 0x139,
SMSG_SPELL_CHANNEL_UPDATE = 0x13A,
SMSG_SPELL_DELAYED = 0x1E2,
SMSG_SPELL_FAILED_OTHER = 0x2A6,
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
// client sends funnels through it (the argless wrapper `FUN_005AB630`,
@@ -1314,12 +1467,75 @@ enum Offsets {
FUN_SCRIPT_REGION_SETHEIGHT = 0x007A2150,
FUN_SCRIPT_REGION_GETWIDTH = 0x007A1E00,
FUN_SCRIPT_REGION_GETHEIGHT = 0x007A2030,
// Region rect getters (return a number, or nil when the region has no
// resolved rect). All on the REGION base registry → callable on any
// region type. Used by Frame::Modern's IsMouseOver.
FUN_SCRIPT_REGION_GETLEFT = 0x007A1980,
FUN_SCRIPT_REGION_GETRIGHT = 0x007A1AA0,
FUN_SCRIPT_REGION_GETTOP = 0x007A1BC0,
FUN_SCRIPT_REGION_GETBOTTOM = 0x007A1CE0,
// In-game GetCursorPosition (0x0046DAD0 is the glue-state copy). Pushes
// (x, y) in the same k = FUN_0041ad70()*DAT_007ffd68 space the region
// rect getters use — so dividing by the region's effective scale puts
// the cursor in the region's logical coordinate space.
FUN_SCRIPT_GETCURSORPOSITION = 0x0048B820,
// Region effective-scale field: `piVar[0x1f]` (float) read by both
// GetEffectiveScale (0x00774FC0) and internally by GetLeft/Top/etc. —
// present on every region object, so it's the all-region way to get the
// effective scale without the Frame-only GetEffectiveScale method.
OFF_REGION_EFFECTIVE_SCALE = 0x7C,
// Global UI/screen context pointer (every frame's +0xA0 field mirrors
// it; GetCursorPosition reads the cursor from *ptr + 0x1118). Its
// +0xCFC slot holds the frame currently being moved/sized via
// StartMoving/StartSizing (0 when idle) — the drag target. Backs
// Frame::Modern's IsDragging: StopMovingOrSizing (0x00776990) gates its
// clear on `*(context+0xCFC) == self`, i.e. the IsDragging predicate.
VAR_UI_CONTEXT_PTR = 0x00CF0BD8,
OFF_UI_CONTEXT_DRAG_TARGET = 0xCFC,
// Frame currently under the mouse (the CFrameScriptObject* that
// `GetMouseFocus` returns): `*(*VAR_UI_CONTEXT_PTR + 0x7C)`. 0 when the
// cursor is over no mouse-enabled frame. `Frame::Attributes` polls this to
// drive the `mouseover` override from the hovered frame's `unit` attribute.
OFF_UI_CONTEXT_MOUSE_FOCUS = 0x7C,
// Head of the engine's frame list (`*(*VAR_UI_CONTEXT_PTR + 0xCC4)`),
// and each frame CObject's next-in-enumeration pointer at +0x308 — the
// list `Script_EnumerateFrames` (0x00705F60) walks. A node with its low
// bit set (or null) is the end sentinel. `Frame::MouseFoci` walks it to
// build the GetMouseFoci stack.
OFF_UI_CONTEXT_FRAME_LIST_HEAD = 0xCC4,
OFF_FRAME_ENUM_NEXT = 0x308,
// Frame level, a plain int at CObject +0xC4 (the value `Script_GetFrameLevel`
// at 0x007744A0 pushes as `frame[0x31]`). Read directly for stack ordering.
OFF_FRAME_LEVEL = 0xC4,
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
// `frame:EnableMouse(enable)` (Frame registry) — `Frame::Attributes` calls
// it so a unit-attributed frame registers as the mouse-focus (bare frames
// otherwise never hover). Standard `int __fastcall(void *L)` Script_* shape.
FUN_SCRIPT_FRAME_ENABLEMOUSE = 0x00777070,
// Frame predicates used by `Frame::MouseFoci` to filter the enumeration to
// the interactive stack (visible + mouse-enabled). Standard Script_* shape
// `int __fastcall(void *L)`, self at Lua idx 1, result pushed at idx 2.
FUN_SCRIPT_FRAME_IS_VISIBLE = 0x007758D0,
FUN_SCRIPT_FRAME_IS_MOUSE_ENABLED = 0x00777130,
// `frame:GetFrameStrata()` — pushes the strata name string; ranked for
// stack ordering (`Frame::MouseFoci`).
FUN_SCRIPT_FRAME_GET_STRATA = 0x007742A0,
// Button OnClick dispatcher — `__thiscall(button, buttonCode)` at
// 0x00779540, invoking the button's OnClick slot `[button+0x4CC]`. NOTE: do
// NOT MinHook it — SuperWoW's click-casting inline-hooks the same prologue
// and a second hook corrupts the trampoline (ERROR #132). `Frame::Attributes`
// instead installs a normal chained OnClick on the opted-in frame. Kept as
// the verified dispatch reference only.
// Frame GetAlpha (own alpha, 0..1) + Region GetParent — walked by
// Frame::Modern's GetEffectiveAlpha up the parent chain.
FUN_SCRIPT_FRAME_GETALPHA = 0x00774DC0,
FUN_SCRIPT_REGION_GETPARENT = 0x007A1460,
FUN_SCRIPT_TEXTURE_SHOW = 0x0079B770,
FUN_SCRIPT_TEXTURE_HIDE = 0x0079B830,
FUN_SCRIPT_FONTSTRING_SHOW = 0x0079CDB0,
@@ -3209,6 +3425,54 @@ enum Offsets {
// without re-parsing the body. See `Macro::Spell::Script_GetMacroSpell`.
FUN_MACRO_SLOT_TO_ENTRY = 0x004F0E40,
// Macro create/edit workers — back `C_Macro.CreateMacro` /
// `C_Macro.EditMacro` (see [[src/macro/Edit.cpp]]). Both store the
// icon as a BARE basename at `entry + OFF_MACRO_ICON` (256 bytes,
// no validation); `Script_GetMacroInfo` re-prefixes it as
// `Interface\Icons\%s` on read, which is why any texture that lives
// under `Interface\Icons\` — including index-less `INV_*` item icons
// — round-trips even though vanilla's index-based UI can't pick it.
//
// Create — `__fastcall(char *name /*ECX*/, char *iconBasename /*EDX,
// may be null*/, char *body /*may be null*/, uint local, int
// perCharacter) -> uint macroID`. SStrCopys name→`+0x24` (0x40),
// icon→`+0x64` (0x100) when non-null, body via `FUN_004f1550`
// (`+0x164`), runs the primary-spell parser `FUN_004EFE00`, sets the
// dirty flag `DAT_00bdcbf8=1`, and refreshes the UI. Returns 0 on
// failure (empty name, or the 18-per-scope list is full). Pass
// `local=0` to match the legacy `CreateMacro` default.
FUN_MACRO_CREATE = 0x004F1010,
// Edit — `__fastcall(uint macroID, char *name, char *iconBasename,
// char *body, uint flag)`. Hash-finds the entry by macroID (no-op if
// absent); each of name/icon/body is written only when its pointer is
// non-null (so nil = leave that field unchanged), then reparse + dirty
// flag + UI refresh, exactly like the create worker. It ALWAYS writes
// `entry + OFF_MACRO_LOCAL_FLAG = flag`, so read the current flag and
// pass it back to leave it unchanged. Chosen over replicating the
// field writes because it also fires the action-button refresh
// (`FUN_004e5cc0`/`FUN_004e5c00`) that direct writes would miss.
FUN_MACRO_EDIT = 0x004F1280,
// macroID → 0-based slot (linear scan of VAR_MACRO_SLOT_MAP);
// `0xFFFFFFFF` on miss. `slot = FUN_MACRO_ID_TO_SLOT(macroID) + 1`
// is how the legacy `CreateMacro` wrapper turns the worker's return
// into the 1-based index it hands back to Lua.
FUN_MACRO_ID_TO_SLOT = 0x004F0EC0,
// Macro name → 0-based slot; `0xFFFFFFFF` on miss (the resolver
// behind `GetMacroIndexByName`). Feeding its miss value straight to
// `FUN_MACRO_SLOT_TO_ENTRY` yields NULL (bounds-rejected), so the
// by-name and by-index EditMacro paths share one validation.
FUN_MACRO_NAME_TO_SLOT = 0x004F0FA0,
// MacroEntry field offsets. Name/body are handled by the workers; the
// icon offset is documentation, and the local flag must be read to be
// preserved across an EditMacro that doesn't mean to change it.
OFF_MACRO_NAME = 0x24, // char[0x40] inline
OFF_MACRO_ICON = 0x64, // char[0x100] inline bare basename
OFF_MACRO_LOCAL_FLAG = 0x20, // uint32 `local` flag (echoed by GetMacroInfo)
// Macro-icon database. Populated lazily by `FUN_LOAD_MACRO_ICONS`
// on the first `GetNumMacroIcons` call — enumerates `Interface\Icons\`
// for `*.blp` files plus a wildcard match, sorts, and de-dupes. Each
@@ -3453,6 +3717,23 @@ enum Offsets {
// `FUN_006E3D10`. Non-zero means "the cast bar is showing this
// spell"; cleared to 0 when the cast finishes or is cancelled.
VAR_CURRENT_CAST_SPELL = 0x00CECA88,
// Ground/reticle-targeting flags — non-zero while a spell is awaiting a
// target click (the AoE reticle / target cursor). `SpellIsTargeting()`
// (`0x006E6CD0`) returns true iff this != 0 (via its predicate
// `FUN_006E48A0`, `return DAT_00CECAC0 != 0`); `Spell_C_TargetSpell`
// (`0x006E5250`) sets the flag bits from the spell's target type, and the
// clear helper `FUN_006E4900` (from `SpellStopTargeting`) / a ground
// placement zeroes it. Polled by `Spell::CastEvents::PollReticle` to fire
// UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR.
VAR_SPELL_TARGETING_FLAGS = 0x00CECAC0,
// The pending cast spellID — written by `Spell_C_CastSpell` (`0x006E4B60`)
// to the spell being cast, and used elsewhere as a `Spell.dbc` index (e.g.
// `CancelSpell`'s `records[this]`). While `VAR_SPELL_TARGETING_FLAGS` is
// set it's the spell whose reticle is up, so the RETICLE events read the
// spellID from here.
VAR_PENDING_CAST_SPELL = 0x00CEAC58,
// The cast-start state writer — `__fastcall(int spellID, int
// targetState)`. Sets `VAR_CURRENT_CAST_SPELL` (pushing any current
// spell into `VAR_QUEUED_CAST_SPELL`; `spellID == 0` restores the queued
@@ -3760,6 +4041,19 @@ enum Offsets {
// `Script_IsInInstance`).
VAR_CURRENT_MAP_ID = 0x00B4E378,
// "World is live" flag — 1 while the player is fully in the world, 0 during
// any loading screen (initial login, zone/instance transition, logout to
// glue). Set to 1 by the enter-world init `FUN_004908C0` (guarded
// `if (==0){=1;...}`) and back to 0 by the world-teardown `FUN_00490A80`
// (`if(!=0){=0;...}`) that runs at the start of every transition. Byte flag.
// The clean pollable gate for "can this happen in the world right now" —
// e.g. suppressing server resync packets that arrive behind a loading
// screen. Verified via the enter/leave pair in Ghidra.
//
// (NOTE: 0x00B4E37C is NOT this flag — an earlier note wrongly claimed it
// was. That address is instance grace-period state, read only by the
// "recently left an instance" 90-second check in `FUN_00495c90`.)
VAR_IN_WORLD = 0x00B4B424,
// Local player's current AreaTable.dbc area ID (u32 storage; only
// the low 16 bits are used — the value is broadcast over the wire
@@ -3864,6 +4158,17 @@ enum Offsets {
VAR_PARTY_MEMBER_GUIDS = 0x00BC6F48,
VAR_RAID_MEMBER_PTRS = 0x00B712A8,
// Raid-target-marker GUID table — 8 contiguous `uint64` slots, one
// per raid icon (slot 0 = mark1/star … slot 7 = mark8/skull), `0`
// when that marker is unassigned. Written by the
// `SMSG_RAID_TARGET_UPDATE` handler cluster (`FUN_004ba0b0` /
// `FUN_004ba220` / `FUN_004ba550`); the reverse lookup
// `FUN_004bb190(guidLo, guidHi)` walks it as `(&table)[i*2]` for
// `i` in 0..7 and returns the mark index or 8. `Unit::RaidTarget`
// reads it to resolve the `markN` token and to drive the per-mark
// unit-event observers. Matches nampower's `CGRaidInfo_m_raidtargets`.
VAR_RAID_TARGET_GUIDS = 0x00B71368,
VAR_LOCALE_INDEX = 0x00C0E080, // 0..8, picks one of the 9 localized strings
LUA_IS_NUMBER = 0x6F34D0,
@@ -3912,6 +4217,15 @@ enum Offsets {
LUA_NEW_TABLE = 0x6F3C90,
LUA_GET_TABLE = 0x6F3A40, // (was 0x6F3EA0, which is lua_rawset)
LUA_RAW_GET = 0x6F3B00,
// `lua_rawgeti(L, idx, n)` — pushes `table_at_idx[n]` without invoking
// metamethods. __fastcall(L /*ecx*/, idx /*edx*/, n /*stack*/). The
// frame-script runner uses it with `idx = REGISTRY` to push a value
// stored under an integer ref (handler, frame object, message handler,
// and luaL_ref'd saved globals). Note it also carries the WoW
// frame-script exec-context stamp (the `DAT_00ceeac0` dance) for pushed
// CObjects — which is exactly why the engine pushes handler/frame
// through it rather than a plain rawget.
LUA_RAWGETI = 0x6F3BC0,
LUA_SET_TABLE = 0x6F3E20,
LUA_RAW_SET = 0x6F3EA0,
LUA_INSERT = 0x6F31A0,
@@ -4570,6 +4884,22 @@ enum Offsets {
FUN_ADDON_CAN_LOAD = 0x0051E780,
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
// Per-character enable-state resolver — the inner check
// `FUN_ADDON_CAN_LOAD` (`0x0051E780`) consults to decide whether an
// addon is enabled: `__fastcall(const char *name /*ecx*/,
// const char *account /*edx*/, char resolveAll /*stack*/) -> uint`.
// Returns 0 = disabled (load pass then skips the addon, reason
// DISABLED), non-zero = enabled (2 = "enabled for all"). Walks the
// per-character override list at `0x00BE1BD8`; with no override it
// falls back to the entry's DefaultState byte (`+0x2b`):
// `-(uint)(entry[+0x2b] != 0) & 2`. `Addons::Embedded` co-hooks this
// to force-enable the embedded `!!!ClassicAPI` unconditionally — so a
// stale WTF disable-override (from back when the addon was still
// shown in the character-select list) can't leave it hidden AND
// unloaded. Cold: runs only during the login/reload load pass, once
// per addon.
FUN_ADDON_ENABLE_RESOLVE = 0x0051E470,
// Returns a pointer to the inline-stored login account name buffer
// at `0x00C27D88`, or NULL if no character is logged in yet.
// `Script_GetAddOnInfo` feeds this into the loadable + enabled
@@ -4674,6 +5004,23 @@ enum Offsets {
// `castGameObject != 0`).
OFF_CGPLAYER_CAST_GAMEOBJECT_PTR = 0xB4,
// Indoor/outdoor query for a unit — backs `IsIndoors()` / `IsOutdoors()`
// (`unit/State.cpp`). `__fastcall(unit) -> uint` where **nonzero =
// outdoors**, 0 = indoors. A 2-instruction thunk: reads the unit's
// world/collision-context pointer at `unit + 0xE0` and tail-jumps to the
// WMO query `FUN_006706f0`. That query is fully null-safe — null context
// or a unit not inside a WMO group returns 1 (outdoors); otherwise it
// resolves the current WMO group via `FUN_0069d4f0` and returns bit 15
// (`0x8000`) of the group's runtime flags word (the OUTDOOR bit).
//
// Derived from SuperWoW 2.2's `IsIndoors` handler (which does exactly
// `unit = ObjMgr(0x10, PlayerGUID()); push (query(unit)==0)`), then
// confirmed against the vanilla binary. Vanilla ships no Lua binding for
// this; the 3.3.5 client's `IsIndoors` (`0x00612300`) uses the identical
// shape (`unit + 0xB8` → WMO group flag test), so the semantics are
// cross-checked across both clients.
FUN_UNIT_IS_OUTDOORS = 0x00612cc0,
// No `MOVEFLAG_FLYING` constant intentionally. Vanilla 1.12
// doesn't flip a flying bit during taxi flights (verified
// empirically: `IsFlying()` checking 0x01000000 stayed false
@@ -4793,18 +5140,19 @@ enum Offsets {
//
// Single caller (the packet dispatcher's jump table for opcode
// 0x155) — quiet hook target, no contention with other DLLs.
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`
// every time the user re-binds at an innkeeper. Gating off the
// bind-valid flag's transition distinguishes "initial sync" (the
// one we suppress) from "user rebound at an inn" (the one we
// fire on — even when the area string stays the same, since the
// bind ACTION is what fires the event).
// Hooked by `Player::HearthstoneBound` to fire `HEARTHSTONE_BOUND`.
// Two packets must be suppressed so the event reflects only a real
// innkeeper bind: (1) the initial post-login/char-switch sync
// (gated on VAR_BIND_POINT_VALID being 0 before the handler runs),
// and (2) Turtle's per-map-transition resync, which re-sends the
// packet behind a loading screen — gated on VAR_IN_WORLD (the
// player can't bind mid-load). See `Player::HearthstoneBound`.
FUN_BINDPOINT_UPDATE_HANDLER = 0x005ED3C0,
// "Bind point is valid" flag. Set to 1 by the BINDPOINTUPDATE
// handler after the packet has been fully parsed. Zeroed by
// `FUN_005E2510` (the per-character-entry init routine).
// Reading this BEFORE the handler runs distinguishes initial
// sync (`== 0`) from a real re-bind (`== 1`).
// sync (`== 0`) from a later packet (`== 1`).
VAR_BIND_POINT_VALID = 0x00C4D4E0,
// SMSG_QUESTGIVER_QUEST_COMPLETE handler (opcode 0x191). Server
@@ -5018,6 +5366,20 @@ enum Offsets {
// delayed crashes on unrelated vtable reads.
FUN_CMSG_LOOT_ITEM = 0x005E1AD0,
// `__fastcall(player)` — sends `CMSG_LOOT_MONEY` (the coin/money
// request) for the player's currently-open loot window. Vanilla has
// no `LootMoney` Lua global; coin is virtual loot slot 0 and the Lua
// `LootSlot` handler `FUN_004C2790` routes the coin slot here (its
// `param_1 == 0 && VAR_LOOT_LOOTABLE != 0` branch calls this with the
// resolved local player). Guards internally on the player's active
// loot GUID at `[player + 0x1D28]` — which `FUN_CMSG_LOOT_UNIT` writes
// when it sends the loot request — so it silently no-ops if no window
// is open. Pass the SAME player pointer used for `FUN_CMSG_LOOT_UNIT`
// so the `+0x1D28` guard it just wrote is the one read here.
// `Loot::Scan`'s `LootAllCorpses` path uses it to grab coin alongside
// the per-slot `CMSG_LOOT_ITEM` sends.
FUN_CMSG_LOOT_MONEY = 0x005EA8C0,
// UNIT_DYNAMIC_FLAGS within `m_objectFields`. Standard
// server-driven dynamic-state bitmask. Empirically verified by
// cross-referencing three `Script_*` accessors that all read
@@ -5387,6 +5749,7 @@ enum Offsets {
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ICON_ID = 0x1D4, // u32 SpellIconID (→ SpellIcon.dbc)
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
+106 -12
View File
@@ -60,6 +60,13 @@ namespace {
constexpr const char *kAddonName = "!!!ClassicAPI";
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
// Developer marker. When this file exists in the on-disk addon folder,
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
// `DecideSource`). It is gitignored, excluded from the embed, and the
// release ships only the DLL — so it can only exist where a developer
// deliberately created it, never in a normal user's install.
constexpr const char *kDevMarkerFile = ".classicapi-dev";
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
// constructs for any of our addon's files. Comparing case-
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
@@ -156,17 +163,20 @@ bool ExtractTocVersion(const char *content, size_t size,
return false;
}
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
// sentinel meaning "newer than any real release" — local dev builds
// always win against installed-on-disk versions. Otherwise the two
// strings are walked as dot-separated numeric semver components
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
// local-build sentinel: two DEV builds are equal, but a DEV build sorts
// BELOW any real release. Dev-over-release precedence is signalled
// explicitly by the `.classicapi-dev` marker now — NOT by the toc
// version — so a stray `## Version: DEV` disk copy (repo clone / old
// bundle) never silently shadows a newer embedded release. Otherwise
// the two strings are walked as dot-separated numeric semver components
// (`1.2` < `1.10`).
int CompareVersions(const char *a, const char *b) {
const bool aDev = std::strcmp(a, "DEV") == 0;
const bool bDev = std::strcmp(b, "DEV") == 0;
if (aDev && bDev) return 0;
if (aDev) return 1;
if (bDev) return -1;
if (aDev) return -1;
if (bDev) return 1;
while (*a || *b) {
int va = 0, vb = 0;
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
@@ -203,16 +213,48 @@ void EnsureEmbeddedVersionExtracted() {
}
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
// hook), so it reflects a real file on disk / in an MPQ — never an
// embedded copy. That makes it a clean "is this a developer working
// tree" signal: the marker is gitignored + excluded from the embed and
// the release ships only the DLL, so a normal user's install can't
// carry it.
bool DiskHasDevMarker() {
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kDevMarkerFile);
void *buf = nullptr;
size_t size = 0;
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
}
// Decide whether disk or embedded should serve all reads for this
// addon. Called on the first FileRead_h call with a matching prefix.
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
// our hook), parses its version, compares to the embedded version,
// and caches the winner. If disk has no TOC at all, embedded wins by
// default.
// The `.classicapi-dev` marker forces disk unconditionally (explicit
// developer override). Otherwise reads the on-disk TOC via the original
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
// the embedded version, and caches the newer. If disk has no TOC at
// all, embedded wins by default.
void DecideSource() {
if (g_source != Source::Undecided) return;
EnsureEmbeddedVersionExtracted();
// Explicit developer override: disk wins regardless of version. This
// is how a dev keeps disk precedence when running local Lua against a
// *released* DLL (where the embedded version would otherwise be newer
// and win). Dev intent lives in this marker, decoupled from the toc
// version — so users never get silently shadowed by a stray copy.
if (DiskHasDevMarker()) {
g_source = Source::Disk;
return;
}
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kAddonTocFile);
@@ -336,7 +378,32 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
void MoveEmbeddedToListHead() {
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
// `FUN_0051da70` walks the linked list and copies an entry into
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
// setting it hides `!!!ClassicAPI` from the character-select AddOns
// list — there's no checkbox, so it can't be toggled off. The builder
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
// 0`); our entry is non-secure, so a manual `1` here is stable across
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
// never reads `+0x29`) and does NOT break dependency resolution or
// by-name queries (those use the addon hash table, which keeps the
// entry regardless).
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
// when the player has no per-character override. Belt-and-suspenders
// alongside the resolver co-hook below (which forces enabled anyway).
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// Post-registration finalize for the embedded entry: move it to the
// head of the load-order list (so consumers that read our globals from
// their main chunk see them — see the block comment above the offsets),
// hide it from the addon list, and mark it default-enabled. Combined
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
@@ -344,6 +411,11 @@ void MoveEmbeddedToListHead() {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
// Hide from the character-select AddOns list and default it
// to enabled.
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
@@ -361,7 +433,24 @@ void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
MoveEmbeddedToListHead();
FinalizeEmbeddedEntry();
}
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
// or any stale WTF disable-override, so hiding it from the addon list
// can never leave it stuck off. Returns 2 ("enabled for all"); the
// load-pass gate only tests `!= 0`. Every other addon falls through to
// the original untouched. See the Offsets.h note for the ABI.
using AddonEnableResolve_t =
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
char resolveAll) {
if (name != nullptr && PathEqualsCI(name, kAddonName))
return 2;
return AddonEnableResolve_o(name, account, resolveAll);
}
const Game::HookAutoRegister _hookFileRead{
@@ -374,6 +463,11 @@ const Game::HookAutoRegister _hookAddonInit{
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
const Game::HookAutoRegister _hookEnableResolve{
Offsets::FUN_ADDON_ENABLE_RESOLVE,
reinterpret_cast<void *>(&AddonEnableResolve_h),
reinterpret_cast<void **>(&AddonEnableResolve_o)};
} // namespace
} // namespace Addons::Embedded
+10 -20
View File
@@ -21,6 +21,7 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "spell/Mod.h"
#include "unit/Identity.h"
@@ -86,28 +87,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
// Snapshot the combo points at the moment the cast request leaves: the
// server consumes them before SMSG_SPELL_GO arrives, so reading at
// SpellGo time is too late.
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = Net::Read<uint32_t>(packet);
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
packet->m_read = saved;
}
g_origNetSend(conn, edx, packet);
// SpellGo time is too late. We watch sends through the shared
// `Net::SendObserver` rather than owning the funnel hook ourselves.
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
return;
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
const Game::HookAutoRegister _sendHook{
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
+332 -34
View File
@@ -19,14 +19,17 @@
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "player/StatSignal.h"
#include "spell/CastEvents.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "totem/Tracker.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
namespace Aura::Source {
@@ -127,6 +130,7 @@ struct Entry {
uint32_t spellId;
uint32_t expirationMs; // 0 = infinite / unknown duration
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
uint32_t stampMs; // last write time; EvictAbsent grace (see below)
int8_t kind; // Kind; descriptor-slot-derived, KIND_UNKNOWN if only seen via SpellGo
bool used;
};
@@ -135,6 +139,16 @@ constexpr int kCacheSize = 256;
Entry g_cache[kCacheSize];
int g_writeCursor = 0;
// SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
// the target's descriptor, so for a brief window a just-captured entry names
// an aura the descriptor doesn't list yet. `EvictAbsent` (run from a query's
// ReconcileCache) must not drop such a fresh entry — doing so discards the
// caster we just captured, which OnAuraAdded then recreates caster-less
// (verified: druid buff → sourceGUID lost). Entries younger than this window
// are exempt from EvictAbsent; a genuinely-removed aura is far older and still
// gets dropped (and OnAuraRemoved evicts real removals directly regardless).
constexpr uint32_t kEvictGraceMs = 2000;
// `fromCast` true: the SpellGo hook — authoritative caster + caster-modified
// (talented) timing. False: the OnAuraAdded application hook — timing only,
// no caster, and it must not clobber an entry SpellGo already owns (that
@@ -146,9 +160,12 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
if (targetGuid == 0 || spellId == 0)
return;
const uint32_t now = NowMs();
// Update an existing entry for this exact aura instance.
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
e.stampMs = now; // refresh EvictAbsent grace on any touch
// Learn the classification whenever a slot-derived kind arrives —
// independent of caster/timing ownership, and never downgrade a
// known kind back to unknown.
@@ -164,18 +181,17 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
}
}
// Take a free slot, else an expired one, else evict round-robin.
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || (e.expirationMs != 0 && now >= e.expirationMs)) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
kind, true};
now, kind, true};
return;
}
}
Entry &slot = g_cache[g_writeCursor];
g_writeCursor = (g_writeCursor + 1) % kCacheSize;
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, kind,
true};
slot = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
kind, true};
}
// ---- Out-of-range group-member aura snapshots ---------------------------
@@ -234,6 +250,263 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// ---- Server-side duration modifiers (trigger-driven inference) -----------
//
// Some server mechanics change a DoT's remaining time on another unit with
// NO client-visible signal — verified in tortoise-wow: the change goes
// through SetAuraDuration / RefreshHolder, and UpdateAuraDuration only
// notifies the aura's target-if-a-player (self-scoped), never the observing
// caster; on a mob the packet isn't even built. We can't hear the *change*,
// but we DO see the TRIGGERING cast's SMSG_SPELL_GO, so we mirror the
// server's edit on the cached entry. Rules come from Lua
// (`C_UnitAuras.RegisterAuraDurationModifier`), so the server-specific set
// lives in the addon, not the DLL. Turtle examples:
// Conflagrate -> Immolate: reduce 3s (Immolate keeps ticking, -3s)
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched by exact spellID (from the server's script binding,
// stable across ranks); the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional icon) — rank-proof, exactly how the server's scripts
// find it. Conflagrate's *full*-consume path removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
// deliberately NOT shipped as defaults — the client can't know the server's
// roll outcome, so inferring them would show wrong timers.
enum ModOp { MOD_REFRESH = 0, MOD_REDUCE = 1, MOD_SET = 2, MOD_REMOVE = 3 };
struct DurationMod {
uint32_t triggerSpellId; // exact trigger; 0 = match by family+school below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
int32_t triggerSchool; // (triggerSpellId==0) school index; -1 = any school
uint32_t affectedFamily; // SpellFamilyName of the affected aura
uint64_t affectedMask; // must overlap the affected aura's SpellFamilyFlags
uint32_t affectedIcon; // 0 = any; else affected aura's SpellIconID must equal
int32_t op;
int32_t valueMs; // REDUCE/SET amount; ignored by REFRESH/REMOVE
};
constexpr int kMaxMods = 128;
DurationMod g_mods[kMaxMods];
int g_modCount = 0;
bool AffectedMatches(const uint8_t *rec, const DurationMod &m) {
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.affectedFamily)
return false;
const uint64_t flags = *reinterpret_cast<const uint64_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if ((flags & m.affectedMask) == 0)
return false;
if (m.affectedIcon != 0 &&
*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ICON_ID) != m.affectedIcon)
return false;
return true;
}
// A rule's trigger matches either by exact spellID, or (triggerSpellId == 0)
// by the cast spell's SpellFamilyName + school index — one rule then covers
// every rank / server-added spell of a class's school (e.g. any priest
// shadow-school cast, for Shadow Weaving). triggerSchool < 0 = any school.
bool TriggerMatches(const DurationMod &m, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
if (m.triggerSpellId != 0)
return m.triggerSpellId == triggerSpellId;
if (triggerRec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != m.triggerFamily)
return false;
if (m.triggerSchool >= 0 &&
static_cast<int32_t>(*reinterpret_cast<const uint32_t *>(
triggerRec + Offsets::OFF_SPELL_SCHOOL)) != m.triggerSchool)
return false;
return true;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
case MOD_REFRESH:
if (e.durationMs > 0)
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs))
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
else
e.used = false; // shaved to/past now → server removes it
}
break;
case MOD_REMOVE:
e.used = false;
break;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
// own, so they'd otherwise be dropped.
void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
const uint64_t *targets, int numTargets) {
if (triggerSpellId == 0 || caster == 0 || numTargets <= 0)
return;
const uint8_t *triggerRec =
Spell::Lookup::RecordForID(static_cast<int>(triggerSpellId));
const uint32_t now = NowMs();
for (int r = 0; r < g_modCount; ++r) {
const DurationMod &m = g_mods[r];
if (!TriggerMatches(m, triggerSpellId, triggerRec))
continue;
for (int t = 0; t < numTargets; ++t) {
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != targets[t])
continue;
// The mechanic acts on the trigger-caster's own aura. Cast-applied
// auras (Immolate, Flame Shock) carry caster == player from SpellGo;
// but PROC-applied ones (Shadow Weaving) enter the cache via the
// application hooks with casterGuid 0 (no SMSG_SPELL_GO ever named a
// caster), so accept those too and attribute them to this trigger
// caster (also gives them a sourceUnit).
if (e.casterGuid != 0 && e.casterGuid != caster)
continue;
if (!AffectedMatches(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)), m))
continue;
if (e.casterGuid == 0)
e.casterGuid = caster;
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
}
}
}
bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
DurationMod &m = g_mods[i];
if (m.triggerSpellId == triggerSpellId && m.triggerFamily == triggerFamily &&
m.triggerSchool == triggerSchool && m.affectedFamily == affectedFamily &&
m.affectedMask == affectedMask && m.affectedIcon == affectedIcon) {
m.op = op;
m.valueMs = valueMs;
return true;
}
}
if (g_modCount >= kMaxMods)
return false;
g_mods[g_modCount++] = {triggerSpellId, triggerFamily, triggerSchool,
affectedFamily, affectedMask, affectedIcon, op, valueMs};
return true;
}
int OpFromString(const char *s) {
if (s == nullptr)
return -1;
if (_stricmp(s, "refresh") == 0)
return MOD_REFRESH;
if (_stricmp(s, "reduce") == 0)
return MOD_REDUCE;
if (_stricmp(s, "set") == 0)
return MOD_SET;
if (_stricmp(s, "remove") == 0)
return MOD_REMOVE;
return -1;
}
// `C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, affectedFamily,
// affectedFamilyFlags, affectedIcon, op [, valueSeconds])` -> bool.
// op: "refresh" | "reduce" | "set" | "remove". valueSeconds used by
// reduce/set. affectedIcon 0 = match any icon. Re-registering an identical
// (trigger, family, flags, icon) tuple replaces its op/value.
int __fastcall Script_RegisterAuraDurationModifier(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsString(L, 5)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifier(triggerSpellID, "
"affectedFamily, affectedFamilyFlags, affectedIcon, op[, "
"valueSeconds])");
return 0;
}
const auto trigger = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 2));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 3));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 4));
const int op = OpFromString(Game::Lua::ToString(L, 5));
const int32_t valueMs =
Game::Lua::IsNumber(L, 6)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 6) * 1000.0)
: 0;
if (op < 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(trigger, /*triggerFamily*/ 0,
/*triggerSchool*/ -1, family, mask,
icon, op, valueMs));
return 1;
}
// `C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily,
// triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op
// [, valueSeconds])` -> bool. Like the above, but the trigger is matched
// by SpellFamilyName + school index instead of an exact spellID — one rule
// covers every rank / server-added spell of a class's school. triggerSchool
// < 0 = any school. E.g. Shadow Weaving: priest (6) shadow-school (5) casts
// refresh the target's Shadow Vulnerability.
int __fastcall Script_RegisterAuraDurationModifierByTrigger(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsString(L, 6)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterAuraDurationModifierByTrigger("
"triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, "
"affectedIcon, op[, valueSeconds])");
return 0;
}
const auto tfamily = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto tschool = static_cast<int32_t>(Game::Lua::ToNumber(L, 2));
const auto family = static_cast<uint32_t>(Game::Lua::ToNumber(L, 3));
const auto mask = static_cast<uint64_t>(Game::Lua::ToNumber(L, 4));
const auto icon = static_cast<uint32_t>(Game::Lua::ToNumber(L, 5));
const int op = OpFromString(Game::Lua::ToString(L, 6));
const int32_t valueMs =
Game::Lua::IsNumber(L, 7)
? static_cast<int32_t>(Game::Lua::ToNumber(L, 7) * 1000.0)
: 0;
if (op < 0 || tfamily == 0) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, RegisterDurationMod(/*triggerSpellId*/ 0, tfamily, tschool,
family, mask, icon, op, valueMs));
return 1;
}
void RegisterDurationModLua() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterAuraDurationModifier",
&Script_RegisterAuraDurationModifier);
Game::Lua::RegisterTableFunction("C_UnitAuras",
"RegisterAuraDurationModifierByTrigger",
&Script_RegisterAuraDurationModifierByTrigger);
}
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
@@ -296,37 +569,33 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// keeps the parse short and robust.
constexpr int kMaxTargets = 16;
using SpellGo_t = void(__fastcall *)(uint64_t *itemGUID, uint64_t *casterGUID,
uint32_t spellId, CDataStore *packet);
SpellGo_t g_origSpellGo = nullptr;
void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
uint32_t spellId, CDataStore *packet) {
uint64_t targets[kMaxTargets];
int numTargets = 0;
if (packet != nullptr) {
const uint32_t savedRead = packet->m_read;
Net::Read<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = Net::Read<uint8_t>(packet);
for (int i = 0; i < numHit; ++i) {
const uint64_t guid = Net::Read<uint64_t>(packet);
if (numTargets < kMaxTargets)
targets[numTargets++] = guid;
}
packet->m_read = savedRead; // restore so the engine re-parses cleanly
}
g_origSpellGo(itemGUID, casterGUID, spellId, packet);
const uint64_t caster = (casterGUID != nullptr) ? *casterGUID : 0;
// 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 —
// which is why it can run from the dispatch funnel (before the leaf handler)
// rather than after a co-hook's original call. The aura *application* hooks
// fire off a separate SMSG_UPDATE_OBJECT, so RememberPlayerCast's handoff to
// them is unaffected by the move.
void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
int numTargets) {
if (caster == 0 || spellId == 0)
return;
// Feed the totem tracker BEFORE the aura gate below — a totem summon
// applies no aura, so it would otherwise be dropped. Player casts only.
if (caster == Unit::Identity::PlayerGuid())
// Feed the totem tracker + fire UNIT_SPELLCAST_SUCCEEDED BEFORE the aura
// gate below — a totem summon (and any non-aura spell) applies no aura,
// so it would otherwise be dropped. SPELL_GO is "the spell went off", so
// this is the succeeded signal for instants too. Player casts only.
if (caster == Unit::Identity::PlayerGuid()) {
Totem::Tracker::OnPlayerSpellGo(spellId);
Spell::CastEvents::OnPlayerSucceeded(static_cast<int>(spellId));
} else {
Spell::CastEvents::OnRemoteSucceeded(caster, static_cast<int>(spellId));
}
// Mirror server-side duration edits the client is never told about
// (Conflagrate -3s Immolate, Molten Blast refresh Flame Shock, …). Runs
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
@@ -361,9 +630,33 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
KIND_UNKNOWN);
}
const Game::HookAutoRegister _hook{Offsets::FUN_SPELL_GO,
reinterpret_cast<void *>(&SpellGo_h),
reinterpret_cast<void **>(&g_origSpellGo)};
// SMSG_SPELL_GO parse (funnel subscriber). At the leaf handler the engine has
// pre-decoded itemGuid/casterGuid/spellId; here we're at the raw body, so we
// decode them ourselves before the hit list:
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(i16),
// numHit(u8), hitGuids(u64 × numHit).
void ParseSpellGo(CDataStore *packet) {
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const uint32_t spellId = Net::Read<uint32_t>(packet);
Net::Read<int16_t>(packet); // castFlags (unused)
const uint8_t numHit = Net::Read<uint8_t>(packet);
uint64_t targets[kMaxTargets];
int numTargets = 0;
for (int i = 0; i < numHit; ++i) {
const uint64_t guid = Net::Read<uint64_t>(packet);
if (numTargets < kMaxTargets)
targets[numTargets++] = guid;
}
HandleSpellGo(caster, spellId, targets, numTargets);
}
void SpellGoSub(uint32_t opcode, CDataStore *packet) {
if (opcode == Offsets::SMSG_SPELL_GO && packet != nullptr)
ParseSpellGo(packet);
}
const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
@@ -495,9 +788,14 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
return;
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
// Fresh SpellGo capture the descriptor hasn't synced yet — see
// kEvictGraceMs. Don't evict it; that's the sourceGUID-loss race.
if (now - e.stampMs < kEvictGraceMs)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
+15
View File
@@ -60,6 +60,8 @@
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Bag::UpdateDelayed {
namespace {
@@ -127,6 +129,19 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
void OnWorldTick() {
if (!g_pending)
return;
// Bag changes aren't meaningful until the player is actually in the world.
// During any loading screen (initial login, zone/instance transition) the
// engine re-populates bag slots as objects stream in (setting g_pending)
// while still behind the screen; the per-frame world tick runs there too,
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
// mid-load for no real change. Hold the pending flag — do NOT clear it —
// until the engine's in-world flag is set; the first in-world frame then
// emits a single DELAYED covering the settled inventory (matching retail's
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
// so this re-suppresses on later zone changes too, not just initial login.
if (*reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
+1
View File
@@ -265,6 +265,7 @@ void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
+1 -3
View File
@@ -13,8 +13,6 @@
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
C_Loot.LootAllCorpses()
</Binding>
</Bindings>
+74 -4
View File
@@ -17,6 +17,7 @@ extern "C" {
#include <cbor.h>
}
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
@@ -76,6 +77,27 @@ int CountTablePairs(void *L, int idx) {
return n;
}
// Encode a single Lua value at `idx` into a standalone byte vector,
// reusing the same grow-and-retry contract as Script_SerializeCBOR.
// Used to obtain a map key's encoded bytes for canonical sorting; the
// Lua stack is left untouched (EncodeValue reads but never pops).
bool EncodeValueToVector(void *L, int idx, int depth, std::vector<uint8_t> &out) {
out.assign(64, 0);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, out.data(), out.size(), 0);
if (!EncodeValue(L, idx, &enc, depth))
return false;
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
out.resize(out.size() + extra);
continue;
}
out.resize(cbor_encoder_get_buffer_size(&enc, out.data()));
return true;
}
}
bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
if (depth >= kMaxDepth) return false;
if (idx < 0) idx = Game::Lua::GetTop(L) + idx + 1;
@@ -102,15 +124,63 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
CborEncoder map;
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
// Canonical CBOR (RFC 7049 §3.9): emit map entries sorted by their
// encoded-key bytes (shorter key first, then bytewise). Lua gives no
// stable iteration order for string-keyed tables, so without this the
// same config serializes to different byte orders each call. We stash
// the keys in a holding array table (`hold`), remember each key's
// encoded bytes + slot, sort, then re-fetch key/value in sorted order.
struct Entry {
std::vector<uint8_t> key;
int slot;
};
std::vector<Entry> entries;
entries.reserve(static_cast<size_t>(pairs));
Game::Lua::NewTable(L);
const int hold = Game::Lua::GetTop(L);
int slot = 0;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
if (!EncodeValue(L, -2, &map, depth + 1) ||
!EncodeValue(L, -1, &map, depth + 1)) {
Game::Lua::SetTop(L, -3);
// key @ -2, value @ -1
std::vector<uint8_t> keyBytes;
if (!EncodeValueToVector(L, -2, depth + 1, keyBytes)) {
Game::Lua::SetTop(L, hold - 1); // drop hold + key + value
return false;
}
Game::Lua::SetTop(L, -2);
++slot;
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushValue(L, -3); // copy key to top
Game::Lua::RawSet(L, hold); // hold[slot] = key
entries.push_back(Entry{std::move(keyBytes), slot});
Game::Lua::SetTop(L, -2); // pop value, keep key for Next
}
std::sort(entries.begin(), entries.end(),
[](const Entry &a, const Entry &b) {
if (a.key.size() != b.key.size())
return a.key.size() < b.key.size();
return std::lexicographical_compare(
a.key.begin(), a.key.end(), b.key.begin(), b.key.end());
});
for (const Entry &e : entries) {
Game::Lua::PushNumber(L, static_cast<double>(e.slot));
Game::Lua::RawGet(L, hold); // push key = hold[slot]
if (!EncodeValue(L, -1, &map, depth + 1)) {
Game::Lua::SetTop(L, hold - 1);
return false;
}
Game::Lua::RawGet(L, idx); // key -> value = table[key]
if (!EncodeValue(L, -1, &map, depth + 1)) {
Game::Lua::SetTop(L, hold - 1);
return false;
}
Game::Lua::SetTop(L, -2); // pop value
}
Game::Lua::SetTop(L, hold - 1); // drop holding table
return EncOk(cbor_encoder_close_container(enc, &map));
}
+16
View File
@@ -282,6 +282,22 @@ int LookupByName(const char *name) {
return -1;
}
bool HasListeners(int slot) {
if (slot < 0)
return false;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || slot >= count)
return false;
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
// value is the empty self-sentinel — same test the grow-safety scan
// uses. So this is true iff at least one frame registered for the event.
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
return head != 0 && (head & 1) == 0;
}
void RetryClaims() {
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
+9
View File
@@ -64,6 +64,15 @@ int Lookup(const char *name);
// fire ourselves (e.g., polyfilling missing event dispatches).
int LookupByName(const char *name);
// True iff at least one frame is currently registered for the event in
// `slot` (its subscriber chain is non-empty). Reads the entry's chain
// head at `+0x0C` — a couple of pointer derefs, no allocation. Use this
// to gate expensive per-fire work (arg synthesis, DBC lookups) so an
// event nobody listens to costs almost nothing: `if (HasListeners(slot))
// { …build args…; Fire(slot, …); }`. `false` for `slot < 0` or a slot
// past the live table.
bool HasListeners(int slot);
// Dispatches a custom event via the engine's printf-style event
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
+953
View File
@@ -0,0 +1,953 @@
// 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 attributes + unit-frame mouseover/click backport.
//
// Backports `frame:SetAttribute` / `frame:SetAttributeNoHandler` /
// `frame:ClearAttribute` / `frame:GetAttribute` to 1.12 as native methods on the base Frame registry
// (every frame gets them, like the modern clients). Vanilla has no attribute
// system at all — it arrived in 2.0 with secure frames. Attributes are just a
// per-frame, case-insensitive key→value store (verified from the 3.3.5
// implementation: a name→Lua-value map plus a getter with prefix/name/suffix
// wildcard precedence). We store the map in the Lua registry keyed by the frame
// (registry[frame]) — invisible to Lua script and tamper-proof, like retail's
// C-side attribute store (see PushSub).
//
// The headline use — SecureUnitButton behavior, minus the secure wrapper (1.12
// has no combat lockdown or taint, so protected actions just work):
//
// unitFrame:SetAttribute("unit", "party1") -- hover → mouseover = party1
// unitFrame:SetAttribute("type1", "target") -- left-click → target party1
//
// ── Mouseover (needs C — the engine's mouseover is a GUID slot) ──
//
// 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.
// Stomp-proof: the engine's own 3D-hover setter is event-driven, so nothing
// overwrites the slot while the cursor is over UI.
//
// ── Clicks (the frame's own OnClick — scoped, native) ──
//
// Clicks are events, not state, so they belong on the frame's OnClick — but
// ONLY on frames that opt in with a `type` attribute. When a `type*` attribute
// is set we install a chained OnClick on that frame (protected, because OnClick
// is Button-only). It mirrors retail's SecureActionButton_OnClick: resolve ONE
// verb per click from the modifier/button-qualified `type` attribute, perform
// it, done. No global hook, no handler on frames that never asked for one.
//
// Resolution (retail-style): the attribute is `[prefix]type[suffix]`, where the
// prefix is the held modifiers ("alt-"/"ctrl-"/"shift-") and the suffix is the
// button number (1=Left, 2=Right, …). So `type1`, `shift-type1`, `type` all
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
// default-interaction precedence — pending spell / cursor item cast/drop on the
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
// `spell` attribute and casts it on the unit via our native C_Spell.CastAtUnit —
// the unit's GUID goes straight to the cast dispatcher, no target juggling, and
// ground-target spells land at the unit's feet), `item` (uses the `item`
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
// deprecated `bag`/`slot` attributes also work), `macro` (from the
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
// the standard unit dropdown at the cursor via the addon's
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
//
// One verb per click is the point: because we own the click when a verb
// resolves (and only then chain the previous handler), a configured `type1` no
// longer runs *alongside* the addon's own conditional OnClick — that double
// dispatch (our fixed "target" + pfUI's ClickAction) was the conflict. An addon
// expresses all its click behavior as attributes; unconfigured clicks fall
// through to its OnClick (e.g. a right-click menu).
//
// Ordering / clobbering: it chains the handler present when `type*` is set, so
// set `type*` AFTER the frame's own `OnClick`. Addons that re-`SetScript`
// `OnClick` later (pfUI does on every raid relayout) replace our closure — so
// re-set `type*` after re-SetScript to recover. Re-wiring is safe: WireOnClick
// re-wraps a clobbered (Lua) handler but skips its own C closure, so it never
// double-chains. The frame must be a Button registered for the relevant clicks
// (`RegisterForClicks`) — left is the Button default; right needs
// `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
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
#include "Game.h"
#include "Offsets.h"
#include "cursor/Info.h"
#include "spell/AtCursor.h"
#include "spell/AtUnit.h"
#include "tick/WorldTick.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unordered_map>
namespace Frame::Attributes {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// ---- small string helpers --------------------------------------------------
void LowerCopy(char *dst, const char *src, size_t n) {
size_t i = 0;
for (; src && src[i] && i + 1 < n; ++i)
dst[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(src[i])));
dst[i] = '\0';
}
// Bounded, always-terminated copy (avoids the C4996 strncpy/strcpy warnings).
void BoundedCopy(char *dst, const char *src, size_t n) {
size_t i = 0;
for (; src && src[i] && i + 1 < n; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
// dst = tolower(a .. b .. c). Truncates at n-1.
void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char *c) {
size_t i = 0;
const char *parts[3] = {a, b, c};
for (const char *s : parts)
for (; s && *s && i + 1 < n; ++s)
dst[i++] = static_cast<char>(std::tolower(static_cast<unsigned char>(*s)));
dst[i] = '\0';
}
// ASCII case-insensitive full-string equality.
bool EqI(const char *a, const char *b) {
for (; *a && *b; ++a, ++b)
if (std::tolower(static_cast<unsigned char>(*a)) !=
std::tolower(static_cast<unsigned char>(*b)))
return false;
return *a == *b;
}
// True if `lname` (already lowercase) is a click "type" action attribute: an
// optional modifier prefix (alt-/ctrl-/shift-, any order/combo) followed by
// "type" and an optional button digit — i.e. anything the click resolver
// (ButtonSuffix + ReadModAttr) reads as a verb selector: type, type1..type5,
// shift-type1, alt-ctrl-type2, … . This is the exact set WireOnClick must
// (re)install for; keying the wiring off it (rather than a fixed
// type/type1/type2 list) means a middle-click (type3) or modifier-only config
// still installs the chained OnClick.
bool IsTypeAttr(const char *lname) {
const char *p = lname;
for (bool advanced = true; advanced;) {
if (std::strncmp(p, "alt-", 4) == 0) p += 4;
else if (std::strncmp(p, "ctrl-", 5) == 0) p += 5;
else if (std::strncmp(p, "shift-", 6) == 0) p += 6;
else advanced = false;
}
if (std::strncmp(p, "type", 4) != 0)
return false;
p += 4;
if (p[0] == '\0')
return true; // "type"
return p[0] >= '1' && p[0] <= '5' && p[1] == '\0'; // "typeN"
}
// ---- attribute storage (in the Lua registry, keyed by the frame) -----------
// Pushes the frame's attribute subtable onto the stack. Stored as
// registry[frame] in the Lua registry (LUA_REGISTRYINDEX) rather than as a
// field on the frame's own table — the registry is C-side and has no _G path,
// so the store is invisible to `pairs(frame)` and can't be read or tampered
// with from Lua, matching how retail keeps attributes C-side. With create=true,
// lazily creates and stores it. Returns true iff a subtable is left on top;
// false (nothing left on the stack) when absent and !create. 1.12 frames are
// immortal, so the strong registry key never leaks.
bool PushSub(void *L, int frameIdx, bool create) {
Game::Lua::PushValue(L, frameIdx); // [.., frame] (key)
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX); // [.., sub|nil]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return true; // [.., sub]
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the nil -> [..]
if (!create)
return false;
Game::Lua::NewTable(L); // [.., sub]
Game::Lua::PushValue(L, frameIdx); // [.., sub, frame] (key)
Game::Lua::PushValue(L, -2); // [.., sub, frame, sub] (value)
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[frame] = sub -> [.., sub]
return true;
}
// Copies string attribute `lname` (already lowercase) of the frame at
// frameIdx into buf. Returns true iff a string value was found. Restores the
// stack.
bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
const int top = Game::Lua::GetTop(L);
bool ok = false;
if (PushSub(L, frameIdx, false)) { // [.., sub]
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, lname);
Game::Lua::RawGet(L, si); // [.., sub, val]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, -1);
if (s) {
BoundedCopy(buf, s, n);
ok = true;
}
}
}
Game::Lua::SetTop(L, top);
return ok;
}
// If the frame's attribute subtable has a non-nil value for keyLower, leave that
// value on the stack top and return true; otherwise restore the stack and
// return false.
bool TryPushValue(void *L, int frameIdx, const char *keyLower) {
const int top = Game::Lua::GetTop(L);
if (PushSub(L, frameIdx, false)) { // [.., sub]
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, keyLower);
Game::Lua::RawGet(L, si); // [.., sub, val]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
Game::Lua::Remove(L, si); // [.., val]
return true;
}
}
Game::Lua::SetTop(L, top);
return false;
}
// Unit token → GUID goes through `Unit::Identity::GuidForToken` (engine
// resolver + `"player"` fast-path, handling our focus / nameplateN / raw-GUID
// extensions). For recognized tokens it never raises; unit attributes are
// tokens by contract.
// ---- Lua-global call helpers (used by the click dispatcher) -----------------
//
// The click dispatch mirrors retail's SecureActionButton_OnClick: it's C. It
// performs engine actions by calling ordinary Lua globals (TargetUnit,
// IsAltKeyDown, …) and our own actions by calling the C++ module directly
// (Spell::AtUnit, Unit::Focus). Both run under the engine's protected OnClick
// invocation, so a Lua error inside one is caught there (no crash).
// The plain global-call primitives (push-if-function, call with no/one arg)
// live in Game::Lua now (PushGlobalFunction / CallGlobal / CallGlobalString) —
// shared with the other modules that dispatch to FrameXML globals by name. The
// two shapes below are specific enough to stay here.
// _G[name]() -> boolean (false if the global isn't a function). Distinct from
// Game::Lua::CallGlobal in that it returns the call's boolean RESULT (used for
// the IsAltKeyDown/IsShiftKeyDown modifier probes).
bool CallBoolGlobal(void *L, const char *name) {
const int top = Game::Lua::GetTop(L);
bool r = false;
if (Game::Lua::PushGlobalFunction(L, name)) {
Game::Lua::Call(L, 0, 1);
r = Game::Lua::ToBoolean(L, -1) != 0;
}
Game::Lua::SetTop(L, top);
return r;
}
// _G[table][method](a, b) — two string args (null → nil, since PushString maps
// NULL to nil), no results. Returns true iff the method resolved to a function
// and got called. Used when the target is our own C_* function but the call must
// cross the Lua boundary — e.g. C_Item.UseItemByName, whose bag walk does
// SetTop(L, 0); going through lua_call gives it its own stack frame so that reset
// clears only its frame, not the dispatcher's (a direct C++ call would wipe our
// stack — the reason `spell`, which is stack-free, calls Spell::AtUnit directly
// while `item` routes through here).
bool CallTableFunc2Str(void *L, const char *table, const char *method,
const char *a, const char *b) {
const int top = Game::Lua::GetTop(L);
bool called = false;
Game::Lua::PushString(L, table);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // _G[table]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
const int t = Game::Lua::GetTop(L);
Game::Lua::PushString(L, method);
Game::Lua::GetTable(L, t); // _G[table][method]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushString(L, a);
Game::Lua::PushString(L, b);
Game::Lua::Call(L, 2, 0);
called = true;
}
}
Game::Lua::SetTop(L, top);
return called;
}
// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
//
// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
// To run macro text without depending on an addon, we replicate pfUI's shim in
// C: build a throwaway "edit box" whose `GetText` returns the line and whose
// every other method is a harmless no-op (via an `__index` metamethod), then
// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
// path the real chat box uses to dispatch a slash command / send a line.
// GetText: returns upvalue(1), the captured line.
int __fastcall MacroGetText_c(void *L) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
return 1;
}
// A no-op standing in for any edit-box method the parser happens to call.
int __fastcall MacroNoop_c(void *) { return 0; }
// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
int __fastcall MacroIndex_c(void *L) {
Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
return 1;
}
// Runs a single macro line through the stock chat parser.
void RunMacroLineC(void *L, const char *line, size_t len) {
if (len == 0) return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // fake editBox
const int obj = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
Game::Lua::PushLString(L, line, static_cast<unsigned int>(len));
Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
Game::Lua::SetTable(L, obj);
Game::Lua::NewTable(L); // metatable { __index = noop }
const int mt = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "__index");
Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
Game::Lua::SetTable(L, mt);
// No lua_setmetatable binding — use the Lua global.
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
Game::Lua::PushValue(L, obj);
Game::Lua::PushValue(L, mt);
Game::Lua::Call(L, 2, 0);
}
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
Game::Lua::PushValue(L, obj);
Game::Lua::PushNumber(L, 1); // send = 1
Game::Lua::Call(L, 2, 0);
}
Game::Lua::SetTop(L, top);
}
// Runs macro text one line at a time — a real macro is line-delimited, and a
// single ChatEdit_ParseText call only dispatches one command.
void RunMacroTextC(void *L, const char *text) {
if (!text) return;
for (const char *p = text; *p;) {
const char *nl = p;
while (*nl && *nl != '\n') ++nl;
RunMacroLineC(L, p, static_cast<size_t>(nl - p));
p = (*nl == '\n') ? nl + 1 : nl;
}
}
// ---- 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;
// 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)
// we never clobber the engine's own 3D mouseover.
uint64_t g_lastSet = 0;
const void *CurrentMouseFocus() {
const void *ctx = *reinterpret_cast<const void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
if (ctx == nullptr)
return nullptr;
return *reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
}
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
// prevHi)`. Highlights the unit's model, builds the mouseover tooltip, fires
// UPDATE_MOUSEOVER_UNIT, and writes the GUID slot; pass 0,0 for the optional
// previous, and (0,0,0,0) to clear. Same address `Unit::Mouseover` co-hooks, so
// calling it here goes through that hook (loss events keep firing correctly).
void CallEngineMouseover(uint64_t guid) {
reinterpret_cast<void(__stdcall *)(uint32_t, uint32_t, uint32_t, uint32_t)>(
static_cast<uintptr_t>(Offsets::FUN_SET_MOUSEOVER_UNIT))(
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0, 0);
}
void MouseoverTick() {
const void *focus = CurrentMouseFocus();
uint64_t guid = 0;
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 (guid != g_lastSet) {
CallEngineMouseover(guid); // sets slot + highlight + tooltip + event
g_lastSet = guid;
}
}
// ---- click dispatch (the frame's own chained OnClick) ----------------------
// Runs the chained-onto previous handler (upvalue 1), vanilla-style with no
// args (it reads the `this`/`arg1` globals the engine set for this invocation).
void ChainOld(void *L) {
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(1)) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
Game::Lua::Call(L, 0, 0);
}
}
// Builds the modifier prefix ("alt-"/"ctrl-"/"shift-", in that order — retail's
// order) from the live key state, e.g. "alt-shift-". Empty when none held.
void BuildModifierPrefix(void *L, char *buf, size_t n) {
buf[0] = '\0';
size_t i = 0;
auto add = [&](const char *s) {
for (const char *p = s; *p && i + 1 < n; ++p)
buf[i++] = *p;
buf[i] = '\0';
};
if (CallBoolGlobal(L, "IsAltKeyDown")) add("alt-");
if (CallBoolGlobal(L, "IsControlKeyDown")) add("ctrl-");
if (CallBoolGlobal(L, "IsShiftKeyDown")) add("shift-");
}
// Button name -> attribute suffix (retail's convention: the button number).
const char *ButtonSuffix(const char *btn) {
if (EqI(btn, "RightButton")) return "2";
if (EqI(btn, "MiddleButton")) return "3";
if (EqI(btn, "Button4")) return "4";
if (EqI(btn, "Button5")) return "5";
return "1"; // LeftButton / unknown
}
// Resolves a modified attribute into `buf`. Precedence (practical subset of the
// GetAttribute wildcard rules): prefix..name..suffix (e.g. "shift-type1"),
// then name..suffix ("type1"), then name ("type"). So a plain `type1` still
// applies under any modifier unless a modifier-specific attribute overrides it.
bool ReadModAttr(void *L, int fi, const char *prefix, const char *name,
const char *suffix, char *buf, size_t n) {
char key[128];
Compose3Lower(key, sizeof key, prefix, name, suffix);
if (CopyAttr(L, fi, key, buf, n)) return true;
Compose3Lower(key, sizeof key, "", name, suffix);
if (CopyAttr(L, fi, key, buf, n)) return true;
Compose3Lower(key, sizeof key, "", name, "");
return CopyAttr(L, fi, key, buf, n);
}
// Reads attribute `keyLower` as an int (stored as a number, or a numeric
// string) into *out. Leaves the stack as it found it. Returns true on success.
bool ReadAttrInt(void *L, int fi, const char *keyLower, int *out) {
bool ok = false;
if (TryPushValue(L, fi, keyLower)) { // value left on top
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NUMBER) {
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
ok = true;
} else if (t == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, -1);
char *end = nullptr;
const long v = s ? std::strtol(s, &end, 10) : 0;
if (s && end != s) {
*out = static_cast<int>(v);
ok = true;
}
}
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // pop the peeked value
}
return ok;
}
// Numeric analog of ReadModAttr (prefix..name..suffix precedence).
bool ReadModAttrInt(void *L, int fi, const char *prefix, const char *name,
const char *suffix, int *out) {
char key[128];
Compose3Lower(key, sizeof key, prefix, name, suffix);
if (ReadAttrInt(L, fi, key, out)) return true;
Compose3Lower(key, sizeof key, "", name, suffix);
if (ReadAttrInt(L, fi, key, out)) return true;
Compose3Lower(key, sizeof key, "", name, "");
return ReadAttrInt(L, fi, key, out);
}
// Parses a "bag slot" string (two integers, e.g. "0 1") into bag/slot. Returns
// false unless the whole string is exactly two integers, so a normal item name
// or a bare itemID ("12345") falls through to the name path.
bool ParseBagSlot(const char *s, int *bag, int *slot) {
char *end = nullptr;
const long b = std::strtol(s, &end, 10);
if (end == s) return false; // not a leading number
const char *p = end;
const long sl = std::strtol(p, &end, 10);
if (end == p) return false; // only one number
while (*end == ' ' || *end == '\t') ++end;
if (*end != '\0') return false; // trailing junk
*bag = static_cast<int>(b);
*slot = static_cast<int>(sl);
return true;
}
// _G[name](a, b) — two number args, no results. Returns true iff called.
bool CallGlobalNum2(void *L, const char *name, double a, double b) {
const int top = Game::Lua::GetTop(L);
const bool called = Game::Lua::PushGlobalFunction(L, name);
if (called) {
Game::Lua::PushNumber(L, a);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 2, 0);
}
Game::Lua::SetTop(L, top);
return called;
}
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
// Returns true if it owned the click (so the chained handler is skipped).
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
const char *verb, const char *unit) {
if (EqI(verb, "target")) {
if (!unit) return false;
// `unit="none"` clears the target (retail's SecureActionButton behavior).
if (EqI(unit, "none")) {
Game::Lua::CallGlobal(L, "ClearTarget");
return true;
}
// Cursor / pending-spell take precedence, matching the engine's default
// unit interaction — cast the pending spell / drop the item on the unit
// instead of switching target. The two predicates are ours (direct C++);
// the actions are engine-only, so they go through the Lua globals (which
// are the engine's own entries, with token→GUID resolution).
if (Spell::AtCursor::IsPlacementActive())
Game::Lua::CallGlobalString(L, "SpellTargetUnit", unit);
else if (Cursor::Info::HasItem())
Game::Lua::CallGlobalString(L, "DropItemOnUnit", unit);
else
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
return true;
}
if (EqI(verb, "assist")) {
if (!unit) return false;
Game::Lua::CallGlobalString(L, "AssistUnit", unit);
return true;
}
if (EqI(verb, "focus")) {
if (!unit) return false;
Unit::Focus::Set(Unit::Identity::GuidForToken(unit));
return true;
}
if (EqI(verb, "spell")) {
if (!unit) return false;
char spell[128];
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
return false;
Spell::AtUnit::CastByName(spell, unit);
return true;
}
if (EqI(verb, "item")) {
char item[128];
int bag, slot;
if (ReadModAttr(L, fi, prefix, "item", suffix, item, sizeof item)) {
if (ParseBagSlot(item, &bag, &slot))
CallGlobalNum2(L, "UseContainerItem", bag, slot);
else
CallTableFunc2Str(L, "C_Item", "UseItemByName", item, unit);
return true;
}
// Deprecated form: no `item`, take the `bag` + `slot` attributes.
if (ReadModAttrInt(L, fi, prefix, "bag", suffix, &bag) &&
ReadModAttrInt(L, fi, prefix, "slot", suffix, &slot)) {
CallGlobalNum2(L, "UseContainerItem", bag, slot);
return true;
}
return false;
}
if (EqI(verb, "macro")) {
char macro[512];
if (!ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro) &&
!ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro))
return false;
// Prefer an addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) — it
// handles named macros and extended macro text; fall back to the stock
// ChatEdit_ParseText path when no RunMacro global is present.
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
RunMacroTextC(L, macro);
return true;
}
if (EqI(verb, "stop") || EqI(verb, "stopcasting")) {
Game::Lua::CallGlobal(L, "SpellStopCasting");
return true;
}
if (EqI(verb, "menu") || EqI(verb, "togglemenu")) {
if (!unit) return false;
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
return true;
}
// Unknown verb → not handled here; the chained handler runs.
return false;
}
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
// frame (captured at install time, so we don't depend on the `this` global).
// Resolves one verb per click from the (modifier/button) `type` attribute and
// performs it — retail's one-action-per-click model. Only chains the previous
// handler when we DIDN'T own the click, so a configured `type1` no longer runs
// alongside the addon's own OnClick (that double-dispatch was the conflict).
int __fastcall OnClick_c(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2)); // frame
const int fi = Game::Lua::GetTop(L);
bool handled = false;
if (Game::Lua::Type(L, fi) == Game::Lua::TYPE_TABLE) {
char btn[32] = {0};
Game::Lua::PushString(L, "arg1");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
const char *b = Game::Lua::ToString(L, -1);
if (b)
BoundedCopy(btn, b, sizeof btn);
}
Game::Lua::SetTop(L, fi); // drop arg1, keep frame
const char *suffix = ButtonSuffix(btn);
char prefix[24];
BuildModifierPrefix(L, prefix, sizeof prefix);
char verb[32];
if (ReadModAttr(L, fi, prefix, "type", suffix, verb, sizeof verb)) {
char unit[128];
const bool haveUnit =
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
handled = DispatchVerb(L, fi, prefix, suffix, verb,
haveUnit ? unit : nullptr);
}
}
Game::Lua::SetTop(L, top);
if (!handled)
ChainOld(L); // let the frame's own OnClick handle unconfigured clicks
return 0;
}
// Installs OnClick as a closure over (previous handler, frame). Operates on
// self at stack index 1. Raises if `self` isn't a Button (OnClick is a Button
// script) — callers run it under PCall.
//
// Self-healing against clobbering: addons re-`SetScript("OnClick", …)` their
// own handler (pfUI does it on every raid relayout via UpdateScripts), which
// replaces our closure. So we re-wire whenever a `type*` attribute is (re)set,
// but only when the current OnClick is a *Lua* handler (a real clobber) or nil
// — if it's already a C function it's our own closure, and re-wrapping would
// double-chain. `IsCFunction` cleanly distinguishes the two here (addon click
// handlers are Lua; the only C-function OnClick we ever see is ours).
int __fastcall WireOnClick(void *L) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushString(L, "OnClick"); // (self, name)
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (self, name, old)
if (Game::Lua::GetTop(L) < 3) // defensive
Game::Lua::PushNil(L);
Game::Lua::SetTop(L, 3); // (self, name, old|nil)
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_FUNCTION &&
Game::Lua::IsCFunction(L, 3) != 0)
return 0; // already our closure
Game::Lua::PushValue(L, 1); // (self, name, old, self)
Game::Lua::PushCClosure(L, &OnClick_c, 2); // upvalues (old, self)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
return 0;
}
// ---- OnAttributeChanged script (settable via SetScript, like retail) --------
//
// Backport of the modern `OnAttributeChanged` widget script: SetAttribute (but
// NOT SetAttributeNoHandler) fires the frame's OnAttributeChanged handler. 1.12
// has no such script, so we make it SetScript/GetScript/HookScript-able the same
// way Tooltip::SetEvents adds OnTooltipSet* — co-hook the frame script-name
// resolver (FUN_FRAME_SCRIPT_RESOLVER, the base every frame type delegates to)
// and, for the one name the engine doesn't know, hand back an external 8-byte
// {handler, context} cell. Frames are immortal in 1.12 (no destroy), so a cell
// keyed by the frame's C object pointer never goes stale.
//
// 1.12 passes script args as globals, not params (see OnClick_c reading arg1),
// so we fire with `this` = frame (bound by the invoker), `arg1` = name and
// `arg2` = the new value: `function() local name, value = arg1, arg2 … end`.
struct AttrScriptSlot { uint32_t v[2]; }; // {handler, exec context}
std::unordered_map<const void *, AttrScriptSlot> g_attrHandlers;
uint32_t *AttrSlotFor(const void *frame, bool create) {
auto it = g_attrHandlers.find(frame);
if (it != g_attrHandlers.end())
return it->second.v;
if (!create)
return nullptr;
AttrScriptSlot &s = g_attrHandlers[frame]; // node-based → stable address
s.v[0] = 0;
s.v[1] = 0;
return s.v;
}
// Resolver co-hook. The engine's __thiscall(frame, name) is modelled as
// __fastcall with a dummy edx — `name` lands on the first stack slot either way
// (same trick as Tooltip::SetEvents). The original returns 0 for a name it
// doesn't know; we then claim "OnAttributeChanged".
using FrameResolver_t = int(__fastcall *)(void *frame, void *edx, const char *name);
FrameResolver_t g_frameResolverOriginal = nullptr;
int __fastcall FrameResolver_h(void *frame, void *edx, const char *name) {
const int slot = g_frameResolverOriginal(frame, edx, name);
if (slot != 0) // a real base-frame / subtype script — leave it
return slot;
if (EqI(name, "onattributechanged"))
return reinterpret_cast<int>(AttrSlotFor(frame, /*create*/ true));
return 0;
}
const Game::HookAutoRegister _attrResolverHook{
Offsets::FUN_FRAME_SCRIPT_RESOLVER,
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);
void FireAttributeChanged(void *L, void *frame, const char *name) {
if (frame == nullptr || g_firingAttr > 0)
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");
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
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);
}
// ---- the methods -----------------------------------------------------------
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
// otherwise never registers as hovered). Operates on self at index 1.
void EnableFrameMouse(void *L) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushBoolean(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
}
int DoSet(void *L, bool fireHandler) { // (self, name, value)
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:SetAttribute(\"name\", value)");
return 0;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
if (PushSub(L, 1, true)) { // [.., sub]
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, lname); // [.., sub, key]
Game::Lua::PushValue(L, 3); // [.., sub, key, value] (nil if absent)
Game::Lua::RawSet(L, si); // sub[lname] = value
Game::Lua::SetTop(L, si - 1); // drop sub
}
const bool isString = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING;
// `unit` drives the mouseover binding: index it by the frame's C object
// (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);
}
}
}
// A `type*` attribute makes the frame a clickable unit button: (re)install
// the chained OnClick (PCall-guarded — OnClick is Button-only). WireOnClick
// is idempotent and self-healing, so calling it on every `type*` set both
// avoids double-chaining and reinstalls after an addon clobbered our
// OnClick — set `type*` again after re-SetScript to recover. The handler
// reads all the attributes fresh at click time. IsTypeAttr covers every
// form the resolver reads (type, typeN, modifier-prefixed), so a middle-
// click or modifier-only binding still wires.
else if (IsTypeAttr(lname) && isString) {
Game::Lua::PushCClosure(L, &WireOnClick, 0);
Game::Lua::PushValue(L, 1); // arg = self
Game::Lua::PCall(L, 1, 0, 0); // ignore errors (non-Button frame)
}
if (fireHandler)
FireAttributeChanged(L, Game::Lua::ResolveObject(L, 1), 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); }
// `cleared = frame:ClearAttribute("name")` — retail (11.2.0) attribute removal;
// `cleared` is true iff the attribute was set (and is now gone). Routes through
// DoSet's nil-value path so it also drops a `unit` mouseover binding, but does
// NOT fire OnAttributeChanged — verified against retail (a live 12.0 test showed
// ClearAttribute firing no handler, unlike SetAttribute).
int __fastcall Script_ClearAttribute(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:ClearAttribute(\"name\")");
return 0;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
const bool existed = TryPushValue(L, 1, lname); // leaves the value on top if set
if (existed)
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the peeked value
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
Game::Lua::PushBool(L, existed);
return 1;
}
int __fastcall Script_GetAttribute(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:GetAttribute(\"name\")");
return 0;
}
// 3-arg form: (prefix, name, suffix) with wildcard precedence.
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) : "";
char key[256];
Compose3Lower(key, sizeof key, pfx, nm, sfx);
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, "*", nm, sfx);
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, pfx, nm, "*");
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, "*", nm, "*");
if (TryPushValue(L, 1, key)) return 1;
Compose3Lower(key, sizeof key, "", nm, "");
if (TryPushValue(L, 1, key)) return 1;
Game::Lua::PushNil(L);
return 1;
}
char lname[256];
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
if (TryPushValue(L, 1, lname))
return 1;
Game::Lua::PushNil(L);
return 1;
}
// ---- registration ----------------------------------------------------------
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetAttribute", &Script_SetAttribute},
{"SetAttributeNoHandler", &Script_SetAttributeNoHandler},
{"ClearAttribute", &Script_ClearAttribute},
{"GetAttribute", &Script_GetAttribute},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
g_frameMethods,
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
} // namespace
} // namespace Frame::Attributes
+153
View File
@@ -26,6 +26,12 @@
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `region:IsMouseOver([top, bottom, left, right])` — cursor-in-rect
// test with optional per-edge offsets, also on the REGION registry.
// - `region:GetRect()` — left, bottom, width, height, composed from the
// engine's Region getters. Also on the REGION registry.
// - `region:IsDragging()` — true while the region is the frame currently
// being moved/sized. Reads the engine's global drag target.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
@@ -34,6 +40,9 @@
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
// - `frame:GetEffectiveAlpha()` — own alpha × the parent chain's, the
// product WoW composites at render time. Frame-scoped (matches
// GetAlpha); walks the parents via the engine's own getters.
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
@@ -83,6 +92,119 @@ int __fastcall Script_GetSize(void *L) {
return 2;
}
// ---- IsMouseOver (Region base — all region types) --------------------------
// `region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])` —
// true when the cursor is within the region's rect, each edge optionally
// shifted by the matching offset (added to that edge, modern semantics:
// +top/-bottom/-left/+right enlarges). Vanilla addons have always done this
// in Lua as MouseIsOver(); we register it as the native Region method later
// clients expose.
//
// The math mirrors the engine exactly: GetLeft/Right/Top/Bottom (Region
// registry, all types) and GetCursorPosition both return values scaled by the
// same k = FUN_0041ad70()*DAT_007ffd68 factor, but the rect getters also
// divide by the region's effective scale — so dividing the cursor by that same
// effective scale (read from obj+0x7C, the field GetLeft/GetEffectiveScale
// themselves read) puts both in the region's logical space. A getter returns
// nil for an unpositioned region → we report false.
// Calls a Region getter that returns one number; false if it pushed nil/none.
bool CallRegionNumber(void *L, uintptr_t fn, double *out) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(fn, L); // (self, value | nil)
if (!Game::Lua::IsNumber(L, 2))
return false;
*out = Game::Lua::ToNumber(L, 2);
return true;
}
int __fastcall Script_IsMouseOver(void *L) {
// Offsets first — the stack gets reshaped by the delegated getters.
const double offTop = Game::Lua::IsNumber(L, 2) ? Game::Lua::ToNumber(L, 2) : 0.0;
const double offBottom = Game::Lua::IsNumber(L, 3) ? Game::Lua::ToNumber(L, 3) : 0.0;
const double offLeft = Game::Lua::IsNumber(L, 4) ? Game::Lua::ToNumber(L, 4) : 0.0;
const double offRight = Game::Lua::IsNumber(L, 5) ? Game::Lua::ToNumber(L, 5) : 0.0;
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
double left, right, top, bottom;
if (effScale == 0.0f ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom)) {
Game::Lua::PushBool(L, false); // no resolved rect
return 1;
}
Game::Lua::SetTop(L, 0);
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
const double x = Game::Lua::ToNumber(L, 1) / effScale;
const double y = Game::Lua::ToNumber(L, 2) / effScale;
left += offLeft;
right += offRight;
top += offTop;
bottom += offBottom;
const bool inside = x >= left && x <= right && y >= bottom && y <= top;
Game::Lua::PushBool(L, inside);
return 1;
}
// ---- GetRect (Region base — all region types) ------------------------------
// `region:GetRect()` → left, bottom, width, height. Composed from the
// engine's own Region getters (so all UI-scale conversion is its code).
// Matches retail: returns nothing for a region with no resolved rect —
// gated on GetLeft, which pushes nil when the position isn't resolved (a
// size-only region with no anchor has width/height but no rect).
int __fastcall Script_GetRect(void *L) {
double left, bottom, width, height;
if (!CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left))
return 0;
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETWIDTH, &width);
CallRegionNumber(L, Offsets::FUN_SCRIPT_REGION_GETHEIGHT, &height);
Game::Lua::SetTop(L, 1);
Game::Lua::PushNumber(L, left);
Game::Lua::PushNumber(L, bottom);
Game::Lua::PushNumber(L, width);
Game::Lua::PushNumber(L, height);
return 4;
}
// ---- IsDragging (Region base — all region types) ---------------------------
// `region:IsDragging()` — true while this region is the frame currently
// being moved/sized (StartMoving/StartSizing active, before
// StopMovingOrSizing). The engine tracks a single drag target in the global
// UI context at +0xCFC; StopMovingOrSizing itself gates on
// `context+0xCFC == self`, which is exactly this predicate. Reading the
// global context (rather than self+0xA0) keeps it correct for non-frame
// regions — a texture/fontstring self can never match the frame stored
// there, so it reports false, as it should.
int __fastcall Script_IsDragging(void *L) {
void *self = Game::Lua::ResolveObject(L, 1);
void *ctx = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
void *dragTarget =
ctx != nullptr
? *reinterpret_cast<void *const *>(
reinterpret_cast<const uint8_t *>(ctx) +
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
: nullptr;
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
return 1;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
@@ -95,6 +217,33 @@ int __fastcall Script_SetShown(void *L) {
return 0;
}
// ---- GetEffectiveAlpha (Frame) ---------------------------------------------
// `frame:GetEffectiveAlpha()` — the frame's own alpha times every
// ancestor's alpha, i.e. the product WoW composites at render time (fading
// UIParent fades all children). Vanilla exposes only GetAlpha (own alpha);
// we walk self → parent → … via the engine's own GetAlpha (Frame) and
// GetParent (Region), so no field offsets or scale math are re-derived. The
// iteration cap is a defensive guard against a pathological chain (WoW frame
// trees are shallow; the loop normally ends when GetParent returns nil at
// the top of the tree).
int __fastcall Script_GetEffectiveAlpha(void *L) {
Game::Lua::SetTop(L, 1); // (obj) = self
double eff = 1.0;
for (int guard = 0; guard < 128; ++guard) {
CallScript(Offsets::FUN_SCRIPT_FRAME_GETALPHA, L); // (obj, alpha)
eff *= Game::Lua::ToNumber(L, 2);
Game::Lua::SetTop(L, 1); // (obj)
CallScript(Offsets::FUN_SCRIPT_REGION_GETPARENT, L); // (obj, parent|nil)
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL)
break;
Game::Lua::Remove(L, 1); // drop obj → (parent) becomes index 1
}
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, eff);
return 1;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
@@ -274,6 +423,9 @@ const Game::HookAutoRegister _setPointHook{
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
{"IsMouseOver", &Script_IsMouseOver},
{"GetRect", &Script_GetRect},
{"IsDragging", &Script_IsDragging},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
@@ -282,6 +434,7 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
{"GetEffectiveAlpha", &Script_GetEffectiveAlpha},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
+211
View File
@@ -0,0 +1,211 @@
// 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/>.
// `GetMouseFoci()` -> table of every frame under the cursor, top-first.
//
// Modern WoW exposes GetMouseFoci() alongside GetMouseFocus(), with the
// invariant `GetMouseFocus() == GetMouseFoci()[1]`. Vanilla stores only the
// single topmost focus — Script_GetMouseFocus (0x0048DF40) reads
// `*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)` and there is no stored
// stack — so this is a native reimplementation of the frame-stack walk
// Blizzard_DebugTools does in Lua (`EnumerateFrames` + `IsMouseOver`):
//
// 1. Walk the engine frame list (the same one Script_EnumerateFrames
// 0x00705F60 iterates): head at `*(ctx + OFF_UI_CONTEXT_FRAME_LIST_HEAD)`,
// each frame's next at `+OFF_FRAME_ENUM_NEXT`, a low-bit-set / null node
// ends it.
// 2. Keep frames that are visible + mouse-enabled and whose rect contains the
// cursor (hit-test mirrors Frame::Modern's IsMouseOver: Region getters +
// cursor / effective-scale).
// 3. Order top-first by strata rank then frame level.
// 4. Force index 1 to be the engine's stored focus so the documented
// invariant holds exactly, regardless of how our sort tie-breaks.
//
// Pure query — no hooks, registered like GetMouseFocus.
#include "Game.h"
#include "Offsets.h"
#include "ui/FrameObject.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <vector>
namespace Frame::MouseFoci {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// The UI context is a pointer stored at VAR_UI_CONTEXT_PTR (null pre-world).
void *UIContext() {
return *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
}
// Strata name -> draw rank, low (behind) to high (on top). Unknown -> 0.
int StrataRank(const char *s) {
if (s == nullptr)
return 0;
static const struct {
const char *name;
int rank;
} kStrata[] = {
{"WORLD", 0}, {"BACKGROUND", 1}, {"LOW", 2},
{"MEDIUM", 3}, {"HIGH", 4}, {"DIALOG", 5},
{"FULLSCREEN", 6}, {"FULLSCREEN_DIALOG", 7}, {"TOOLTIP", 8},
};
for (const auto &e : kStrata)
if (std::strcmp(s, e.name) == 0)
return e.rank;
return 0;
}
// Region getter that pushes one number, called with `self` at Lua idx 1.
// Returns false when it pushed nil/none (unresolved rect).
bool SelfNumber(void *L, uintptr_t fn, double *out) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(fn, L); // (self, value | nil)
if (!Game::Lua::IsNumber(L, 2))
return false;
*out = Game::Lua::ToNumber(L, 2);
return true;
}
// Boolean predicate (IsVisible / IsMouseEnabled), `self` at Lua idx 1.
bool SelfBool(void *L, uintptr_t fn) {
Game::Lua::SetTop(L, 1);
CallScript(fn, L);
return Game::Lua::ToBoolean(L, 2) != 0;
}
struct Cursor {
double x, y;
};
// GetCursorPosition and the Region rect getters share the same scale factor;
// dividing the cursor by the frame's effective scale (the field GetLeft/etc.
// themselves divide by) puts both in the frame's logical space. Mirrors
// Frame::Modern's IsMouseOver exactly.
Cursor ReadCursor(void *L) {
Game::Lua::SetTop(L, 0);
CallScript(Offsets::FUN_SCRIPT_GETCURSORPOSITION, L); // (x, y)
return Cursor{Game::Lua::ToNumber(L, 1), Game::Lua::ToNumber(L, 2)};
}
// With `self` at Lua idx 1, is the cursor within the frame's rect?
bool CursorOverSelf(void *L, void *obj, const Cursor &cur) {
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
if (effScale == 0.0f)
return false; // unpositioned region — no rect
double left, right, top, bottom;
if (!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETLEFT, &left) ||
!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETRIGHT, &right) ||
!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETTOP, &top) ||
!SelfNumber(L, Offsets::FUN_SCRIPT_REGION_GETBOTTOM, &bottom))
return false;
const double x = cur.x / effScale;
const double y = cur.y / effScale;
return x >= left && x <= right && y >= bottom && y <= top;
}
struct Hit {
void *obj;
int strata;
int level;
};
int __fastcall Script_GetMouseFoci(void *L) {
void *ctx = UIContext();
Game::Lua::SetTop(L, 0);
if (ctx == nullptr) {
Game::Lua::NewTable(L); // pre-world → empty table
return 1;
}
// The engine's authoritative topmost focus — the frame GetMouseFocus
// returns. Forced to index 1 below.
void *focus = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
const Cursor cur = ReadCursor(L);
std::vector<Hit> hits;
void *node = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_FRAME_LIST_HEAD);
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
// Read the next link before any Lua work (the walk is engine-list
// based, independent of the Lua stack, but this keeps it obvious).
void *next = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_ENUM_NEXT);
Game::Lua::SetTop(L, 0);
UI::FrameObject::Push(L, node); // (self)
if (SelfBool(L, Offsets::FUN_SCRIPT_FRAME_IS_VISIBLE) &&
SelfBool(L, Offsets::FUN_SCRIPT_FRAME_IS_MOUSE_ENABLED) &&
CursorOverSelf(L, node, cur)) {
Game::Lua::SetTop(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_GET_STRATA, L); // (self, strata)
const int rank = StrataRank(Game::Lua::ToString(L, 2));
const int level = *reinterpret_cast<const int *>(
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_LEVEL);
hits.push_back(Hit{node, rank, level});
}
node = next;
}
// Top-first: higher strata, then higher level within a strata.
std::sort(hits.begin(), hits.end(), [](const Hit &a, const Hit &b) {
if (a.strata != b.strata)
return a.strata > b.strata;
return a.level > b.level;
});
// Order the frames, forcing GetMouseFocus() to be [1] so the invariant
// holds by construction (focus may be a frame our rect test skips, e.g.
// the WorldFrame, or a same-strata/level tie our sort orders differently).
std::vector<void *> ordered;
ordered.reserve(hits.size() + 1);
if (focus != nullptr)
ordered.push_back(focus);
for (const Hit &h : hits)
if (h.obj != focus)
ordered.push_back(h.obj);
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L); // result at idx 1
for (size_t i = 0; i < ordered.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
UI::FrameObject::Push(L, ordered[i]);
Game::Lua::RawSet(L, 1); // result[i+1] = frame
}
return 1;
}
void Register() {
Game::Lua::RegisterGlobalFunction("GetMouseFoci", &Script_GetMouseFoci);
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Frame::MouseFoci
+295
View File
@@ -0,0 +1,295 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Modern positional script arguments — make every frame-script handler
// receive `(self, arg1..argN)` the way later clients do, on top of the
// vanilla `this`/`arg1`/`event` globals.
//
// frame:SetScript("OnMouseWheel", function() print(this, arg1) end) -- vanilla
// frame:SetScript("OnMouseWheel", function(self, delta) print(self, delta) end) -- modern
//
// Vanilla ALWAYS invokes a script handler with ZERO Lua arguments: the two
// runners the engine funnels every dispatch through — FUN_FRAME_INVOKE_SCRIPT
// (no-arg scripts) and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) — set
// the `this`/`arg1..argN` globals and then `lua_pcall(handler, /*nargs*/0)`.
// So `function(self, delta)` gets nothing today.
//
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
// purely additive; Lua silently drops the extra positional args a zero-param
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
// exact global save/set/restore and its message-handler errfunc; only the
// final push+pcall differs.
//
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
// retail (and Frame::Modern's HookScript). An OnEvent dispatch is detected
// structurally at the runner: the handler ref being invoked equals the frame's
// OnEvent slot handler (frame+OFF_FRAME_ONEVENT_SLOT). This is exact — each
// SetScript creates a distinct ref, so a function bound to two scripts still
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
//
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
// straight passthrough to the original runner (zero reimplementation risk).
// Opt-in because this lands on the hottest Lua path in the engine
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Frame::ScriptArgs {
namespace {
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
bool g_enabled = false;
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
constexpr int kMaxArgs = 19;
// --- raw engine primitives not exposed via Game::Lua -----------------------
// lua_rawgeti(L, idx, n) — push table_at_idx[n]. Used with idx = REGISTRY to
// push a value stored under an integer ref (handler / frame object / message
// handler / luaL_ref'd saved globals).
using RawGetI_t = void(__fastcall *)(void *L, int idx, int ref);
// luaL_ref(L, t) → int — pop the top, store it under a fresh int key in table
// `t`, return the key. luaL_unref frees it.
using LuaLRef_t = int(__fastcall *)(void *L, int t);
using LuaLUnref_t = void(__fastcall *)(void *L, int t, int ref);
// FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER — __thiscall(frame, nameOrNull);
// lazily materializes the frame's Lua-registry ref at frame+OFF_COBJECT_LUA_REF.
using ScriptRegister_t = void(__thiscall *)(void *frame, void *nameOrNull);
const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
const auto LuaLRef = reinterpret_cast<LuaLRef_t>(Offsets::LUA_REF_REF);
const auto LuaLUnref = reinterpret_cast<LuaLUnref_t>(Offsets::LUA_REF_UNREF);
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
int EnsureLuaRef(void *frame) {
auto *f = reinterpret_cast<uint8_t *>(frame);
if (*reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
reinterpret_cast<ScriptRegister_t>(
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
return *reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REF);
}
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
void ArgName(char *buf, int k) {
buf[0] = 'a';
buf[1] = 'r';
buf[2] = 'g';
if (k < 10) {
buf[3] = static_cast<char>('0' + k);
buf[4] = '\0';
} else {
buf[3] = static_cast<char>('0' + k / 10);
buf[4] = static_cast<char>('0' + k % 10);
buf[5] = '\0';
}
}
// Sets _G[name] = <value currently on the stack top>, capturing the previous
// _G[name] into *outOldRef (a luaL_ref for later restore). Stack-neutral:
// consumes the value, leaves the stack as it was minus that value.
void SetGlobalSaving(void *L, const char *name, int *outOldRef) {
using namespace Game::Lua;
PushString(L, name); // [.., newVal, name]
GetTable(L, GLOBALS_INDEX); // [.., newVal, oldVal]
*outOldRef = LuaLRef(L, REGISTRY_INDEX); // [.., newVal]
PushString(L, name); // [.., newVal, name]
Insert(L, -2); // [.., name, newVal]
SetTable(L, GLOBALS_INDEX); // [..]
}
// Restores _G[name] to the value at oldRef, then frees the ref.
void RestoreGlobal(void *L, const char *name, int oldRef) {
using namespace Game::Lua;
RawGetI(L, REGISTRY_INDEX, oldRef); // [.., oldVal]
PushString(L, name); // [.., oldVal, name]
Insert(L, -2); // [.., name, oldVal]
SetTable(L, GLOBALS_INDEX); // [..]
LuaLUnref(L, REGISTRY_INDEX, oldRef);
}
// Reimplements the runner tail: set globals (with save/restore), then invoke
// the handler with modern positional args `(self, arg1..argN)` under the
// engine's own message-handler errfunc. `fmt`/`vaPtr` are null for the no-arg
// runner (N == 0).
void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
using namespace Game::Lua;
void *L = State();
if (L == nullptr)
return;
// --- _G.this = frame -----------------------------------------------------
const bool haveThis = frame != nullptr;
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
// (frame+0x0C); matching the handler ref against that slot identifies it
// exactly. When so, `event` (a global the dispatcher has set) is prepended
// as the first positional to mirror retail's (self, event, arg1..argN).
const bool isOnEvent =
haveThis &&
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
handlerRef;
int selfRef = 0;
int oldThisRef = 0;
if (haveThis) {
selfRef = EnsureLuaRef(frame);
RawGetI(L, REGISTRY_INDEX, selfRef); // push frame object
SetGlobalSaving(L, "this", &oldThisRef);
}
// --- _G.arg1..argN from the format --------------------------------------
int oldArgRefs[kMaxArgs];
char argNames[kMaxArgs][6];
int n = 0;
if (fmt != nullptr) {
const char *cur = static_cast<const char *>(vaPtr);
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
bool consumed = true;
switch (*p) {
case 'd':
PushNumber(L, static_cast<double>(*reinterpret_cast<const int *>(cur)));
cur += 4;
break;
case 'u':
PushNumber(L, static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur)));
cur += 4;
break;
case 'f':
PushNumber(L, *reinterpret_cast<const double *>(cur));
cur += 8;
break;
case 's':
PushString(L, *reinterpret_cast<const char *const *>(cur));
cur += 4;
break;
default:
consumed = false; // unknown spec — skip, consume nothing
break;
}
if (!consumed)
continue;
ArgName(argNames[n], n + 1);
SetGlobalSaving(L, argNames[n], &oldArgRefs[n]);
++n;
}
}
// --- call handler(self, arg1..argN) -------------------------------------
const int base = GetTop(L);
const int errRef = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
int errIdx = 0;
if (errRef > 0) {
RawGetI(L, REGISTRY_INDEX, errRef); // message handler
errIdx = base + 1; // absolute index of the errfunc
}
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
if (haveThis)
RawGetI(L, REGISTRY_INDEX, selfRef); // self
else
PushNil(L);
if (isOnEvent) { // event goes before the args, retail-style
PushString(L, "event");
GetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k) { // positional args, re-read from the globals
PushString(L, argNames[k]);
GetTable(L, GLOBALS_INDEX);
}
PCall(L, 1 + (isOnEvent ? 1 : 0) + n, 0, errIdx);
SetTop(L, base); // drop the message handler + any leftover error object
// --- restore globals (reverse order) ------------------------------------
for (int k = n - 1; k >= 0; --k)
RestoreGlobal(L, argNames[k], oldArgRefs[k]);
if (haveThis)
RestoreGlobal(L, "this", oldThisRef);
}
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
const void *vaPtr);
RunArgs_t g_origRunArgs = nullptr;
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
if (!g_enabled || handlerRef == 0 || fmt == nullptr) {
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
return;
}
RunModern(handlerRef, frame, fmt, vaPtr);
}
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
// Same address Tooltip::SetEvents calls directly to fire OnTooltipSet*; with
// the switch enabled those fire with (self) too, which is the modern shape.
using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
Invoke_t g_origInvoke = nullptr;
void __fastcall Invoke_h(int handlerRef, void *frame) {
if (!g_enabled || handlerRef == 0 || frame == nullptr) {
g_origInvoke(handlerRef, frame);
return;
}
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr);
}
const Game::HookAutoRegister _runArgsHook{
Offsets::FUN_FRAME_RUN_SCRIPT_ARGS,
reinterpret_cast<void *>(&RunArgs_h),
reinterpret_cast<void **>(&g_origRunArgs)};
const Game::HookAutoRegister _invokeHook{
Offsets::FUN_FRAME_INVOKE_SCRIPT,
reinterpret_cast<void *>(&Invoke_h),
reinterpret_cast<void **>(&g_origInvoke)};
// --- Lua toggle -------------------------------------------------------------
int __fastcall Script_SetModernScriptArgs(void *L) {
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_enabled);
return 1;
}
int __fastcall Script_GetModernScriptArgs(void *L) {
Game::Lua::PushBool(L, g_enabled);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SetModernScriptArgs",
&Script_SetModernScriptArgs);
Game::Lua::RegisterGlobalFunction("GetModernScriptArgs",
&Script_GetModernScriptArgs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Frame::ScriptArgs
+82
View File
@@ -0,0 +1,82 @@
// 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_Glue` session helpers.
//
// `C_Glue.IsFirstLoadThisSession()` — glue-only. Returns true only while the
// FIRST glue screen since the process launched is showing, and false on
// every return to glue after a world login (a genuine world→glue logout;
// login↔character-select stays within the first glue session). Retail uses
// it so the login UI runs one-time startup (intro cinematic, news) only on
// the initial launch, not on each relog.
//
// Backed by a process-static boot counter, no engine state needed: the glue
// registration callback fires exactly once per glue boot
// (FUN_LOAD_GLUE_SCRIPT_FUNCTIONS — initial launch + each world→glue return),
// so the counter equals "how many glue screens this session" and the first
// one is boot #1. The glue Lua state is recreated per boot, so C_Glue is
// re-registered each time; the counter is process-static and survives that.
//
// `C_Glue.IsOnGlueScreen()` — true when a GlueXML screen is showing (no
// character logged in), false in the world. Registered on BOTH Lua states,
// and the answer is a compile-time constant per state: the glue and in-game
// Lua states are mutually exclusive (glue exists only when no character is in
// the world), so the glue registration returns true and the in-game one
// returns false — no runtime flag to consult. Available in both states so
// shared code that runs in either environment can branch on it.
#include "Game.h"
namespace Glue::Session {
namespace {
// Glue-screen boots since process start. 1 during the very first glue screen.
int g_glueBootCount = 0;
int __fastcall Script_IsFirstLoadThisSession(void *L) {
Game::Lua::PushBool(L, g_glueBootCount <= 1);
return 1;
}
int __fastcall Script_IsOnGlueScreen_True(void *L) {
Game::Lua::PushBool(L, true);
return 1;
}
int __fastcall Script_IsOnGlueScreen_False(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
void RegisterGlue() {
++g_glueBootCount; // one glue boot per callback invocation
Game::Lua::RegisterTableFunction("C_Glue", "IsFirstLoadThisSession",
&Script_IsFirstLoadThisSession);
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_True);
}
void RegisterInGame() {
// In-world: only IsOnGlueScreen makes sense (and it's always false).
// IsFirstLoadThisSession is a glue concept, so it's not registered here.
Game::Lua::RegisterTableFunction("C_Glue", "IsOnGlueScreen",
&Script_IsOnGlueScreen_False);
}
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
} // namespace
} // namespace Glue::Session
+13 -2
View File
@@ -21,7 +21,7 @@
namespace Item::Arg {
Resolved Resolve(void *L, int idx) {
Resolved out{0, nullptr};
Resolved out{0, 0, nullptr};
if (Game::Lua::IsNumber(L, idx)) {
out.itemID = static_cast<int>(Game::Lua::ToNumber(L, idx));
return out;
@@ -34,7 +34,18 @@ Resolved Resolve(void *L, int idx) {
return out;
}
if (const char *m = std::strstr(s, "item:")) {
out.itemID = std::atoi(m + 5);
m += 5;
out.itemID = std::atoi(m);
// Random suffix is the 3rd colon-separated field: itemID:enchant:suffix.
// Walk to the 2nd ':' (stopping at the link boundary), read the number
// after it. Bare "item:N" (no colons) leaves suffix 0.
int colons = 0;
for (const char *p = m; *p != '\0' && *p != '|'; ++p) {
if (*p == ':' && ++colons == 2) {
out.suffix = std::atoi(p + 1);
break;
}
}
return out;
}
// Plain string — try numeric, otherwise expose as a name for
+1
View File
@@ -22,6 +22,7 @@ namespace Item::Arg {
// can ignore `name` and treat `itemID == 0` as "no usable input".
struct Resolved {
int itemID;
int suffix; // random-property / suffix ID (3rd `item:` field), 0 if none
const char *name;
};
+15 -4
View File
@@ -259,7 +259,8 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
// matching the modern async contract.
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Item::Arg::Resolved arg = Item::Arg::Resolve(L, 1);
const int itemID = arg.itemID;
if (itemID <= 0)
return 0;
@@ -288,14 +289,24 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
// Apply the link's random suffix ("... of the Owl") to both the name and
// the reconstructed link when the input carried one (arg.suffix); with no
// suffix these reduce to the base name / plain `item:N:0:0:0` link.
char suffixedName[128];
const char *displayName =
Item::Link::NameFromIDSuffix(static_cast<uint32_t>(itemID), arg.suffix,
suffixedName, sizeof(suffixedName))
? suffixedName
: name;
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
char link[256];
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
link, sizeof(link));
const bool haveLink = Item::Link::BasicFromIDSuffix(static_cast<uint32_t>(itemID),
arg.suffix, link, sizeof(link));
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
Game::Lua::PushString(L, PushedOrEmpty(displayName)); // 1 itemName
if (haveLink)
Game::Lua::PushString(L, link); // 2 itemLink
else
+8 -5
View File
@@ -106,14 +106,17 @@ static int __fastcall Script_GetInventoryItemID(void *L) {
int itemID = 0;
if (unitPtr == playerPtr) {
itemID = ItemIDForLocalPlayer(playerPtr, slot);
} else if (Unit::Flags::IsPlayerControlled(
} else if (Unit::Flags::IsPlayerObject(
static_cast<const uint8_t *>(unitPtr))) {
itemID = ItemIDForOtherUnit(unitPtr, slot);
} else {
// NPC / creature — no visible-items array. Returning nil here
// also matches `GetInventoryItemLink`'s practical behavior on
// NPCs (it crashes the client without this gate; addons that
// don't pre-filter the unit don't typically reach this path).
// Non-player object — no CGPlayer visible-items array at +0xE68.
// Gate on the object being a player, not merely player-controlled:
// a pet/totem/MC'd creature sets UNIT_FLAG_PLAYER_CONTROLLED but
// has no sub-struct there, so IsPlayerControlled would let it
// through and `ItemIDForOtherUnit` would deref garbage and crash
// (same root cause as pfUI issue #34). Returning nil also matches
// `GetInventoryItemLink`'s practical behavior on non-players.
return 0;
}
if (itemID == 0)
+31 -7
View File
@@ -30,6 +30,10 @@ using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Inner name builder, no CGItem needed: (out, outSize, itemID, suffixID).
using BuildNameFromID_t = void(__fastcall *)(char *out, unsigned outSize,
uint32_t itemID, int suffixID);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -63,25 +67,45 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildNameFromID_t>(
Offsets::FUN_ITEM_BUILD_NAME_FROM_ID);
fn(out, static_cast<unsigned>(outSize), itemID, suffixID);
return out[0] != '\0';
}
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr)
return false;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0')
return false;
char name[128];
if (!NameFromIDSuffix(itemID, suffixID, name, sizeof(name))) {
// Suffix builder yielded nothing (e.g. suffix row missing) — fall back
// to the base record name so we at least produce a valid link.
const char *base = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (base == nullptr || *base == '\0')
return false;
std::snprintf(name, sizeof(name), "%s", base);
}
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const int n = std::snprintf(out, outSize,
"%s|Hitem:%u:0:0:0|h[%s]|h|r",
"%s|Hitem:%u:0:%d:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
itemID, suffixID, name);
return n > 0 && static_cast<size_t>(n) < outSize;
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
return BasicFromIDSuffix(itemID, 0, out, outSize);
}
namespace {
// `C_Item.GetItemLink(itemLocation)` — returns the fully-decorated
+14
View File
@@ -51,4 +51,18 @@ bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// possible link (`item:` payload + colored prefix + bracketed name).
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize);
// Writes the suffix-decorated display name for `(itemID, suffixID)` into
// `out` WITHOUT a live item instance — the by-string analog of
// NameFromCGItem. `suffixID` is the 3rd `item:` field (random property /
// suffix); 0 = base name. Applies the "... of the Owl" suffix via the
// engine's inner builder (FUN_ITEM_BUILD_NAME_FROM_ID) when the item is
// cached and the suffix is a valid ItemRandomProperties row. Returns false
// (out empty) if the item isn't cached or has no name — warm and retry.
bool NameFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
// Like BasicFromItemID but preserves the random `suffixID` in both the
// `item:` payload (`item:N:0:suffix:0`) and the bracketed, suffix-decorated
// name. `suffixID` 0 reduces exactly to BasicFromItemID.
bool BasicFromIDSuffix(uint32_t itemID, int suffixID, char *out, size_t outSize);
} // namespace Item::Link
+20 -1
View File
@@ -94,8 +94,27 @@ static int __fastcall Script_C_Item_GetItemName(void *L) {
// for `itemID`, firing a background cache fill on miss. Accepts the
// same input shapes as `GetItemInfoInstant` (number, `"item:NNN..."`
// string, or any string containing a parseable `item:NN` chunk).
//
// When the input is an item link carrying a random suffix
// (`item:12022:0:769:0`), the returned name is suffix-decorated
// ("Iridium Chain of the Owl") — the 3rd `item:` field is parsed and
// applied via the engine's own name builder, so it matches the global
// `GetItemInfo`'s name. A bare itemID (number or `item:N`) has no suffix
// and yields the base name.
static int __fastcall Script_C_Item_GetItemNameByID(void *L) {
return PushNameForItemID(L, Item::Arg::ResolveItemID(L, 1));
const Item::Arg::Resolved r = Item::Arg::Resolve(L, 1);
if (r.itemID <= 0)
return 0;
char buf[128];
if (Item::Link::NameFromIDSuffix(static_cast<uint32_t>(r.itemID), r.suffix,
buf, sizeof(buf))) {
Game::Lua::PushString(L, buf);
return 1;
}
// Not cached yet — fire a background fill and return nil (same miss
// behavior as the base-name path).
Item::Data::WarmCache(static_cast<uint32_t>(r.itemID));
return 0;
}
static void RegisterLuaFunctions() {
+106 -25
View File
@@ -30,11 +30,23 @@
//
// The feed is event-driven, not polled: it hangs off `Bag::UpdateDelayed`,
// which co-hooks the engine's three `BAG_UPDATE` fire sites and signals once
// per frame in which a bag actually changed. On that signal we scan bags
// 0..4, diff the resident item GUIDs against the previous scan, flag anything
// newly acquired, and prune flags for items that left. `BAG_NEW_ITEMS_UPDATED`
// (no payload — matching retail) fires whenever the new-item set changes. So
// nothing runs on quiet frames.
// per frame in which a bag actually changed. On that signal we scan bags 0..4,
// flag any resident GUID that isn't in the owned "seen-set", reconcile the
// seen-set against everything owned now, and prune flags for items that have
// genuinely left. `BAG_NEW_ITEMS_UPDATED` (no payload — matching retail) fires
// whenever the new-item set changes. Nothing runs on quiet frames.
//
// The seen-set is deliberately NOT rebuilt from each scan: a bag update in
// progress transiently drops items from the read — sometimes the entire
// backpack at once, for a frame or two — and a naive "seen = this scan"
// approach would then re-flag the momentarily-missing item as new when it
// reappears (the exact bug this system had). Instead each entry carries a
// consecutive-absent-scan counter (`SeenReconcile`): present items reset it to
// 0, absent ones age by one, and only items absent `kMaxMissStreak` scans
// running are evicted. Counting scans (not wall-clock) means a pause with no
// bag activity never ages an owned item; any reappearance resets the streak.
// This keeps the set BOUNDED to ~currently-owned items — genuinely-removed
// items age out — while absorbing the transient reads.
//
// The one exception is establishing the login baseline: a lightweight
// WorldTick handler waits ~1.5s after the player is first resolvable (long
@@ -47,7 +59,7 @@
// — even though only *bag* items are ever flagged new. That's what stops a
// move INTO a bag from any of those (unequip, or a pull from the bank) from
// looking like a fresh loot: the item's GUID was already in the owned set, so
// the bag diff treats it as a move. The bank is reachable without the bank
// the seen-set treats it as already-owned, not new. The bank is reachable without the bank
// window open because its GUIDs are read straight from the invMgr array (the
// same gate-bypass `C_Item.GetItemCount` uses; the array is server-populated
// at login).
@@ -88,7 +100,25 @@ constexpr int kMaxItems = 512;
// engine ms counter) so it's independent of frame rate.
constexpr uint32_t kSettleMs = 1500;
uint64_t g_seen[kMaxItems]; // all owned GUIDs (bags + equipment + bank) at the last scan
// Consecutive bag-change scans an item may be absent from the read before it's
// dropped from the seen-set. A bag update in progress transiently drops items
// from the scan — sometimes the whole backpack at once — for a frame or two;
// counting SCANS (not wall-clock) means a pause with no bag activity never ages
// an owned item, and any reappearance resets the streak to 0, so a transient
// miss never reaches the threshold. Genuinely-removed items hit it within a few
// subsequent bag changes and age out — that's what keeps the set BOUNDED to
// ~currently-owned items rather than everything ever owned.
constexpr int kMaxMissStreak = 4;
constexpr int kMaxContainerSlots = 64;
// One seen-set entry: an owned item's GUID and how many consecutive scans it
// has been absent from the read (0 = present this scan).
struct SeenEntry {
uint64_t guid;
int miss;
};
SeenEntry g_seen[kMaxItems];
int g_seenCount = 0;
uint64_t g_new[kMaxItems]; // subset currently flagged "new"
int g_newCount = 0;
@@ -115,6 +145,44 @@ bool AddUnique(uint64_t *arr, int *count, uint64_t guid) {
return true;
}
// --- seen-set (bounded, absent-scan-count evicted) -----------------------
bool SeenContains(uint64_t guid) {
for (int i = 0; i < g_seenCount; ++i)
if (g_seen[i].guid == guid)
return true;
return false;
}
// One scan's reconcile: age every entry by one missed scan, then reset the
// streak (and insert) for everything owned right now, then evict entries that
// have been absent `kMaxMissStreak` scans running. `owned` is the union of the
// current bag scan and non-bag owned items (equipment + bank).
void SeenReconcile(const uint64_t *owned, int ownedCount) {
for (int i = 0; i < g_seenCount; ++i)
++g_seen[i].miss;
for (int j = 0; j < ownedCount; ++j) {
const uint64_t guid = owned[j];
if (guid == 0)
continue;
bool found = false;
for (int i = 0; i < g_seenCount; ++i)
if (g_seen[i].guid == guid) {
g_seen[i].miss = 0;
found = true;
break;
}
if (!found && g_seenCount < kMaxItems)
g_seen[g_seenCount++] = {guid, 0};
}
for (int i = 0; i < g_seenCount;) {
if (g_seen[i].miss >= kMaxMissStreak)
g_seen[i] = g_seen[--g_seenCount];
else
++i;
}
}
bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
for (int i = 0; i < *count; ++i) {
if (arr[i] == guid) {
@@ -247,7 +315,9 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
if (bagSlots > kMaxContainerSlots)
bagSlots = kMaxContainerSlots; // guard against a garbage count at login
if (bagSlots > 0)
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
}
@@ -257,12 +327,6 @@ void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
}
void CopyToSeen(const uint64_t *current, int count) {
for (int i = 0; i < count; ++i)
g_seen[i] = current[i];
g_seenCount = count;
}
// Login-baseline handler. Runs every frame but does no work once the
// baseline is taken — it only watches for (re)login and, after a short
// settle, takes the single authoritative baseline scan. Steady state is just
@@ -290,11 +354,14 @@ void OnTick() {
if (player == nullptr)
return; // not resolvable yet — retry next tick
// Everything currently owned — bags AND worn equipment — becomes the
// Everything currently owned — bags AND worn equipment + bank — becomes the
// baseline; nothing owned at login is "new". Including equipment means a
// later unequip (paperdoll → bag) is recognized as a move, not a loot.
g_seenCount = ScanBags(player, g_seen);
AppendNonBagOwned(g_seen, &g_seenCount);
uint64_t owned[kMaxItems];
int ownedCount = ScanBags(player, owned);
AppendNonBagOwned(owned, &ownedCount);
g_seenCount = 0;
SeenReconcile(owned, ownedCount);
g_newCount = 0;
g_seeded = true;
}
@@ -316,21 +383,35 @@ void OnBagChanged() {
const int bagCount = ScanBags(player, currentBags);
bool changed = false;
// Drop flags for items no longer in the bags (used, sold, equipped, banked).
// Flag bag items we've never owned. An item merely dropped from a recent
// scan (bag update mid-swap) is still in the seen-set (its miss streak is
// below the threshold), so it is NOT re-flagged when it reappears — that
// was the false-"new" bug. Checked before the reconcile so a genuinely new
// item isn't inserted first.
for (int i = 0; i < bagCount; ++i) {
if (!SeenContains(currentBags[i]))
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
}
// Reconcile the seen-set against everything owned now (bags + equipment +
// bank): resets present items, ages absent ones, evicts long-gone ones.
uint64_t owned[kMaxItems];
int ownedCount = 0;
for (int i = 0; i < bagCount; ++i)
AddUnique(owned, &ownedCount, currentBags[i]);
AppendNonBagOwned(owned, &ownedCount);
SeenReconcile(owned, ownedCount);
// Drop flags only for items that have genuinely left (fallen out of the
// seen-set entirely), not ones merely missing from this one scan.
for (int i = 0; i < g_newCount;) {
if (!Contains(currentBags, bagCount, g_new[i])) {
if (!SeenContains(g_new[i])) {
g_new[i] = g_new[--g_newCount];
changed = true;
} else {
++i;
}
}
for (int i = 0; i < bagCount; ++i) {
if (!Contains(g_seen, g_seenCount, currentBags[i]))
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
}
CopyToSeen(currentBags, bagCount);
AppendNonBagOwned(g_seen, &g_seenCount);
if (changed)
FireChanged();
+123 -37
View File
@@ -8,16 +8,27 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// Hook-driven loot scanner. Walks the nearby-lootable-units set, opens
// each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then *intercepts
// the engine's loot-window response* before any visual updates happen.
// The intercept (`FUN_LOOT_CONTROLLER`) lets the original function run
// so `VAR_LOOT_SLOTS` populates and the link builder works — but a
// Hook-driven loot corpse-walker. Walks the nearby-lootable-units set,
// opens each corpse's loot session via `FUN_CMSG_LOOT_UNIT`, then
// *intercepts the engine's loot-window response* before any visual updates
// happen. The intercept (`FUN_LOOT_CONTROLLER`) lets the original function
// run so `VAR_LOOT_SLOTS` populates and the link builder works — but a
// paired hook on `FUN_FIRE_EVENT_NO_ARGS` swallows the `LOOT_OPENED` /
// `LOOT_CLOSED` event fires while the scan is in progress. Net result:
// the engine's state machine cycles silently for each corpse and
// `LootFrame` never reacts. Other Lua addons listening to those events
// also see nothing during the scan; that's deliberate.
// `LOOT_CLOSED` event fires while the walk is in progress. Net result: the
// engine's state machine cycles silently for each corpse and `LootFrame`
// never reacts. Other Lua addons listening to those events also see nothing
// during the walk; that's deliberate.
//
// Two Lua entry points share this one walker (and, importantly, this one
// `FUN_LOOT_CONTROLLER` hook — MinHook allows a single hook per target, so
// they can't be separate modules):
// - `C_Loot.ScanNearbyLoot()` — scrape-only: read each corpse's contents
// into `GetLastScanResults()` and release without looting.
// - `C_Loot.LootAllCorpses([max])` — the same walk but actually LOOTS
// each window (coin via `CMSG_LOOT_MONEY`, every item slot via
// `CMSG_LOOT_ITEM`) before releasing. Silent (no LootFrame flicker);
// the usual item-received / money chat + inventory events still fire,
// since only the LootFrame open/close events are swallowed.
//
// Compared to the older polling-driven version this also collapses the
// state machine — we no longer need `WaitingOpen` / `WaitingClose` /
@@ -79,6 +90,7 @@ struct ScanCtx {
uint64_t currentGuid = 0;
int ticksSinceTransition = 0;
bool suppressEvents = false; // read by the FireEvent hook
bool lootMode = false; // LootAllCorpses (true) vs ScanNearbyLoot (false)
std::vector<uint64_t> queue;
std::vector<LootEntry> results;
};
@@ -106,6 +118,12 @@ using CloseLootInner_t = void(__fastcall *)(int sendRelease,
using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot,
char *outBuf,
int bufSize);
// `FUN_CMSG_LOOT_ITEM` — `__stdcall(uint8_t wireSlot)`, sends CMSG_LOOT_ITEM
// for one slot of the open window (see the offset comment for the
// __stdcall-vs-__fastcall stack-corruption hazard).
using SendLootItem_t = void(__stdcall *)(uint8_t wireSlot);
// `FUN_CMSG_LOOT_MONEY` — `__fastcall(player)`, sends the coin request.
using LootMoney_t = void(__fastcall *)(void *player);
// `FUN_LOOT_CONTROLLER` is `__fastcall(int, undefined4, int)` — three
// args: lootStructPtr in ECX, coin in EDX, an extra unk on stack[0].
// Mis-declaring as 4-arg __fastcall would push one phantom stack slot
@@ -207,6 +225,42 @@ void ScrapeCurrentLoot(LootEntry *out) {
}
}
// Loots everything in the currently-open window: coin (if any) plus every
// populated item slot. Called from the controller hook (loot mode) after the
// original populated `VAR_LOOT_SLOTS`, so the slot table is fully filled in.
//
// Items go through `FUN_CMSG_LOOT_ITEM` directly — the same silent path
// `C_Loot.LootUnitItem` uses, which bypasses the BoP/unique confirm dialog
// the Lua `LootSlot` wrapper would raise (appropriate for a programmatic
// loot-all; the server still enforces real permissions). Coin goes through
// `FUN_CMSG_LOOT_MONEY` with `s_scan.player` — the SAME pointer we passed to
// `FUN_CMSG_LOOT_UNIT`, whose send wrote this window's target GUID to the
// player's `+0x1D28` guard slot, so the money send's guard passes.
//
// Packet order to the server is items, then coin, then the release that
// follows (`SendCloseLoot`); the server processes loot-item / loot-money
// before the release, so nothing is dropped.
void LootCurrentWindow() {
// Coin present when `VAR_LOOT_LOOTABLE` holds a real amount (0 = none;
// 0xFFFFFFFF = already-looted sentinel, never seen on a fresh open).
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_LOOT_LOOTABLE);
if (coinRaw != 0 && coinRaw != 0xFFFFFFFFu) {
auto LootMoney = reinterpret_cast<LootMoney_t>(Offsets::FUN_CMSG_LOOT_MONEY);
LootMoney(s_scan.player);
}
auto SendItem = reinterpret_cast<SendLootItem_t>(Offsets::FUN_CMSG_LOOT_ITEM);
for (int slot = 0; slot < Offsets::LOOT_MAX_SLOTS; ++slot) {
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_LOOT_SLOTS + slot * Offsets::LOOT_SLOT_STRIDE);
const uint32_t itemID = *reinterpret_cast<const uint32_t *>(entry);
if (itemID == 0)
continue;
SendItem(*(entry + Offsets::OFF_LOOT_SLOT_WIRE_INDEX));
}
}
// === State-machine transitions ===
void StartLoot(uint64_t guid);
@@ -221,6 +275,7 @@ void Complete() {
s_scan.state = State::Idle;
s_scan.currentGuid = 0;
s_scan.suppressEvents = false;
s_scan.lootMode = false;
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
}
@@ -315,6 +370,12 @@ void __fastcall LootController_h(int lootStructPtr, int coin, int unk) {
ScrapeCurrentLoot(&entry);
s_scan.results.push_back(std::move(entry));
// Loot mode: actually take everything (coin + all slots) before the
// release below. Scrape ran first, so `GetLastScanResults()` still
// reports what each corpse held.
if (s_scan.lootMode)
LootCurrentWindow();
// Sends `CMSG_LOOT_RELEASE` and recursively calls this hook
// with `arg1 == 0`. That recursive call falls into the
// passthrough branch but still sees `suppressEvents == true`
@@ -377,46 +438,69 @@ int __fastcall EnumCallback(EnumCtx *ctx, void * /*unusedEdx*/, uint64_t guid) {
return 1;
}
void BuildQueue(void *player) {
void BuildQueue(void *player, size_t maxCount) {
s_scan.queue.clear();
auto Enum = reinterpret_cast<ClntObjMgrEnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
EnumCtx ctx{player, &s_scan.queue};
Enum(reinterpret_cast<ClntObjMgrEnumVisibleObjectsCallback_t>(&EnumCallback),
&ctx);
// Cap to `maxCount` corpses (0 = unlimited). Order is engine
// enumeration order, not distance-sorted, so the truncation is
// arbitrary among in-range corpses.
if (maxCount != 0 && s_scan.queue.size() > maxCount)
s_scan.queue.resize(maxCount);
}
// Shared starter for both entry points: resolve the player, build the
// corpse queue, kick off the walk. Returns whether it started — `false` if a
// walk is already in progress or there's no local player.
bool BeginWalk(bool lootMode, size_t maxCount) {
if (s_scan.state != State::Idle)
return false;
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return false;
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr)
return false;
s_scan.results.clear();
s_scan.player = player;
s_scan.lootMode = lootMode;
BuildQueue(player, maxCount);
if (s_scan.queue.empty()) {
Complete(); // nothing nearby — fire LOOT_SCAN_COMPLETED immediately
return true;
}
TryStartNext();
return true;
}
// === Lua bindings ===
// `C_Loot.ScanNearbyLoot()` — scrape-only walk. Returns `true` if it
// started (a subsequent `GetLastScanResults()` holds the contents once
// `LOOT_SCAN_COMPLETED` fires), `false` if a walk is already running or
// there's no local player.
int __fastcall Script_ScanNearbyLoot(void *L) {
if (s_scan.state != State::Idle) {
Game::Lua::PushBool(L, false);
return 1;
}
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/false, /*maxCount=*/0));
return 1;
}
s_scan.results.clear();
s_scan.player = player;
BuildQueue(player);
if (s_scan.queue.empty()) {
Complete();
Game::Lua::PushBool(L, true);
return 1;
}
TryStartNext();
Game::Lua::PushBool(L, true);
// `C_Loot.LootAllCorpses([max])` — loots every nearby lootable corpse
// (coin + all items) in sequence. Optional `max` caps how many corpses are
// visited. Returns `true` if it started, `false` if a walk (scan or loot)
// is already in progress or there's no local player. `LOOT_SCAN_COMPLETED`
// fires when the walk finishes; `GetLastScanResults()` then reports what
// each corpse held (i.e. what was looted).
int __fastcall Script_LootAllCorpses(void *L) {
const size_t maxCount = Game::Lua::IsNumber(L, 1)
? static_cast<size_t>(Game::Lua::ToNumber(L, 1))
: 0;
Game::Lua::PushBool(L, BeginWalk(/*lootMode=*/true, maxCount));
return 1;
}
@@ -462,6 +546,8 @@ int __fastcall Script_GetLastScanResults(void *L) {
void Register() {
Game::Lua::RegisterTableFunction("C_Loot", "ScanNearbyLoot",
&Script_ScanNearbyLoot);
Game::Lua::RegisterTableFunction("C_Loot", "LootAllCorpses",
&Script_LootAllCorpses);
Game::Lua::RegisterTableFunction("C_Loot", "IsScanInProgress",
&Script_IsScanInProgress);
Game::Lua::RegisterTableFunction("C_Loot", "GetLastScanResults",
+42 -44
View File
@@ -43,6 +43,7 @@
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
@@ -111,7 +112,7 @@ const char *ClassifyControlLoss(const uint8_t *rec) {
return nullptr;
}
// ---- School-interrupt lockout state (fed by the SMSG_SPELL_COOLDOWN hook) --
// ---- School-interrupt lockout state (fed by SMSG_SPELL_COOLDOWN) -----------
// One active lockout per school index (0..6); active while now < endMs.
// Self-expiring, per-process — a stale entry from before a relog is already
@@ -122,54 +123,51 @@ struct SchoolLock {
};
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
using CooldownHandler_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
CooldownHandler_t g_origCooldown = nullptr;
int __stdcall Cooldown_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
if (guid != 0 && guid == Unit::Identity::PlayerGuid()) {
int count = 0;
int firstDur = 0;
bool uniform = true;
int schoolIdx = -1;
while (packet->m_read + 8 <= packet->m_size && count < 256) {
const uint32_t spellID = Net::Read<uint32_t>(packet);
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
if (count == 0)
firstDur = dur;
else if (dur != firstDur)
uniform = false;
if (schoolIdx < 0) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec != nullptr) {
const int s = SpellSchoolIndex(rec);
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
schoolIdx = s;
}
}
++count;
}
// A uniform-duration batch of >=2 same-school spells is a school
// lockout (ProhibitSpellSchool); single/varied packets are normal
// cooldowns and ignored.
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
const int now = NowMs();
g_schoolLock[schoolIdx].startMs = now;
g_schoolLock[schoolIdx].endMs = now + firstDur;
// Subscribe to the SMSG dispatch funnel rather than co-hooking the cooldown
// handler (0x006E9460) directly — nampower detours that leaf handler, so
// sharing its prologue is the documented MinHook/hadesmem collision risk;
// the funnel is a chokepoint nobody else hooks. The dispatcher positions the
// cursor at the body (after the u16 opcode) and restores it afterward, so we
// just read.
void CooldownSub(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_SPELL_COOLDOWN || packet == nullptr)
return;
const uint64_t guid = Net::Read<uint64_t>(packet);
if (guid == 0 || guid != Unit::Identity::PlayerGuid())
return;
int count = 0;
int firstDur = 0;
bool uniform = true;
int schoolIdx = -1;
while (packet->m_read + 8 <= packet->m_size && count < 256) {
const uint32_t spellID = Net::Read<uint32_t>(packet);
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
if (count == 0)
firstDur = dur;
else if (dur != firstDur)
uniform = false;
if (schoolIdx < 0) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec != nullptr) {
const int s = SpellSchoolIndex(rec);
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
schoolIdx = s;
}
}
packet->m_read = saved; // restore before the engine re-parses
++count;
}
// A uniform-duration batch of >=2 same-school spells is a school lockout
// (ProhibitSpellSchool); single/varied packets are normal cooldowns and
// ignored.
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
const int now = NowMs();
g_schoolLock[schoolIdx].startMs = now;
g_schoolLock[schoolIdx].endMs = now + firstDur;
}
return g_origCooldown(opCode, packet);
}
const Game::HookAutoRegister _cooldownHook{
Offsets::FUN_SPELL_COOLDOWN_HANDLER, reinterpret_cast<void *>(&Cooldown_h),
reinterpret_cast<void **>(&g_origCooldown)};
const Net::PacketDispatch::AutoSubscribe _cooldownSub{&CooldownSub};
// ---- Aggregated active-effect list ----------------------------------------
+190
View File
@@ -0,0 +1,190 @@
// 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_Macro.CreateMacro` / `C_Macro.EditMacro` — create/edit macros whose
// icon is given as a texture STRING (a full `Interface\Icons\<name>` path
// or a bare `<name>` basename), not a `GetMacroIconInfo` index.
//
// C_Macro.CreateMacro(name, iconTexture, body, isCharacterMacro) -> index
// C_Macro.EditMacro(index, name, iconTexture, body) -> index
//
// Why this exists: vanilla `CreateMacro`/`EditMacro` take an icon INDEX
// into the `GetMacroIconInfo` list, and that list omits `INV_*` item
// icons entirely — so item icons are unreachable through the stock API
// even though the engine stores and renders them fine. The macro struct
// keeps the icon as a bare basename at `entry + OFF_MACRO_ICON` and
// `GetMacroInfo` reads it back as `Interface\Icons\%s`, so ANY texture
// living under `Interface\Icons\` round-trips once you can write the
// basename directly. These two functions do exactly that, unblocking a
// pfUI macro-UI remodel that wants arbitrary icons.
//
// The legacy globals `CreateMacro`/`EditMacro` are left untouched
// (index-only) — no overloading; string-icon callers opt in via the
// `C_Macro` namespace.
//
// Implementation: both go straight to the engine's own workers
// (`FUN_MACRO_CREATE` / `FUN_MACRO_EDIT`, see [[src/Offsets.h]]), which do
// the bounded SStrCopy into the struct, re-run the primary-spell parser,
// set the persist-on-logout dirty flag, and refresh the action bars.
// We only translate the icon string to a basename and, for edits,
// preserve the entry's `local` flag (the edit worker rewrites it
// unconditionally).
//
// Icon input is STRING-only by design (that's the whole point — item
// icons have no index). A numeric icon arg is treated as "no icon" on
// create and "leave unchanged" on edit; callers who want the numeric
// index path already have the legacy globals.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Macro::Edit {
namespace {
using MacroCreate_t = uint32_t(__fastcall *)(const char *name,
const char *iconBasename,
const char *body, uint32_t local,
int perCharacter);
using MacroEdit_t = void(__fastcall *)(uint32_t macroID, const char *name,
const char *iconBasename,
const char *body, uint32_t flag);
using MacroIdToSlot_t = uint32_t(__fastcall *)(uint32_t macroID);
using MacroNameToSlot_t = uint32_t(__fastcall *)(const char *name);
using MacroSlotToEntry_t = uint8_t *(__fastcall *)(uint32_t slot0Based);
constexpr uint32_t kSlotMiss = 0xFFFFFFFFu;
// Last path component of `s` (after the final `\` or `/`). Turns
// "Interface\\Icons\\INV_Sword_25" and a bare "INV_Sword_25" alike into
// the basename the macro struct stores; the engine re-prefixes it with
// `Interface\Icons\` on read. Aliases into `s` — no copy.
const char *IconBasename(const char *s) {
const char *bn = s;
for (const char *p = s; *p != '\0'; ++p) {
if (*p == '\\' || *p == '/')
bn = p + 1;
}
return bn;
}
// The icon to store, or nullptr (create: no icon; edit: leave unchanged).
// String-only — see the file header.
const char *IconArg(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return nullptr;
return IconBasename(Game::Lua::ToString(L, idx));
}
// A body/name string arg, or nullptr when absent (edit: leave unchanged).
const char *OptString(void *L, int idx) {
return Game::Lua::IsString(L, idx) ? Game::Lua::ToString(L, idx) : nullptr;
}
uint8_t *EntryForSlot(uint32_t slot0) {
auto fn = reinterpret_cast<MacroSlotToEntry_t>(
Offsets::FUN_MACRO_SLOT_TO_ENTRY);
return fn(slot0);
}
// C_Macro.CreateMacro(name, iconTexture, body, isCharacterMacro) -> index
// Returns the new macro's 1-based slot, or nil when creation fails (empty
// name, or the per-scope 18-macro list is full).
int __fastcall Script_CreateMacro(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(
L, "Usage: C_Macro.CreateMacro(name, iconTexture, body, "
"isCharacterMacro)");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
const char *icon = IconArg(L, 2);
const char *body = OptString(L, 3);
const int perCharacter = Game::Lua::ToBoolean(L, 4);
auto create = reinterpret_cast<MacroCreate_t>(Offsets::FUN_MACRO_CREATE);
const uint32_t macroID = create(name, icon, body, /*local*/ 0, perCharacter);
if (macroID == 0) {
Game::Lua::PushNil(L);
return 1;
}
auto idToSlot =
reinterpret_cast<MacroIdToSlot_t>(Offsets::FUN_MACRO_ID_TO_SLOT);
const uint32_t slot0 = idToSlot(macroID);
if (slot0 == kSlotMiss) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
return 1;
}
// C_Macro.EditMacro(index, name, iconTexture, body) -> index
// `index` is a 1-based slot number or a macro name. Any of name /
// iconTexture / body left nil is unchanged. Returns the macro's 1-based
// slot, or nil when `index` doesn't resolve to an existing macro.
int __fastcall Script_EditMacro(void *L) {
uint32_t slot0;
if (Game::Lua::IsNumber(L, 1)) {
slot0 = static_cast<uint32_t>(static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1);
} else if (Game::Lua::IsString(L, 1)) {
auto nameToSlot = reinterpret_cast<MacroNameToSlot_t>(
Offsets::FUN_MACRO_NAME_TO_SLOT);
slot0 = nameToSlot(Game::Lua::ToString(L, 1));
} else {
Game::Lua::Error(
L, "Usage: C_Macro.EditMacro(index, name, iconTexture, body)");
return 0;
}
// One validation covers both a bad slot number and a name miss:
// FUN_MACRO_NAME_TO_SLOT returns 0xFFFFFFFF on miss, which the slot
// resolver bounds-rejects to NULL just like an out-of-range index.
uint8_t *entry = EntryForSlot(slot0);
if (entry == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint32_t macroID = *reinterpret_cast<const uint32_t *>(entry);
// The edit worker rewrites entry+OFF_MACRO_LOCAL_FLAG unconditionally;
// read the current value and hand it back so an edit that only touches
// name/icon/body leaves the flag genuinely unchanged.
const uint32_t flag = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_MACRO_LOCAL_FLAG);
const char *name = OptString(L, 2);
const char *icon = IconArg(L, 3);
const char *body = OptString(L, 4);
auto edit = reinterpret_cast<MacroEdit_t>(Offsets::FUN_MACRO_EDIT);
edit(macroID, name, icon, body, flag);
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Macro", "CreateMacro",
&Script_CreateMacro);
Game::Lua::RegisterTableFunction("C_Macro", "EditMacro", &Script_EditMacro);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Macro::Edit
+55
View File
@@ -0,0 +1,55 @@
// 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 "PacketDispatch.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Net::PacketDispatch {
namespace {
PacketSubscriber *g_head = nullptr;
// `FUN_NET_MESSAGE_DISPATCH` — `__thiscall(netClient, param_1, CDataStore*)`,
// rendered here as `__fastcall(self /*ecx*/, edx, param_1, packet)`. The
// engine reads the leading u16 opcode from the packet, then dispatches to
// `netClient->handlers[opcode]`. We peek the same u16, fan out to subscribers
// with the cursor at the body, restore the cursor, and let the original run —
// so the engine (and any leaf-handler hooks) see an untouched packet.
using Dispatch_t = void(__fastcall *)(void *self, void *edx, uint32_t param_1,
CDataStore *packet);
Dispatch_t g_orig = nullptr;
void __fastcall Dispatch_h(void *self, void *edx, uint32_t param_1,
CDataStore *packet) {
if (packet != nullptr && g_head != nullptr)
Net::FanOutByOpcode<uint16_t>(g_head, packet); // opcode is a u16 SMSG id
g_orig(self, edx, param_1, packet);
}
} // namespace
AutoSubscribe::AutoSubscribe(Net::PacketCallback cb) {
Net::Subscribe(g_head, this, cb);
}
static const Game::HookAutoRegister _hook{
Offsets::FUN_NET_MESSAGE_DISPATCH,
reinterpret_cast<void *>(&Dispatch_h),
reinterpret_cast<void **>(&g_orig)};
} // namespace Net::PacketDispatch
+50
View File
@@ -0,0 +1,50 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
#pragma once
#include "net/PacketObserver.h"
#include "net/PacketReader.h"
#include <cstdint>
// Shared observer for INCOMING packets — the receive-side mirror of
// `Net::SendObserver`. The engine's SMSG dispatch funnel
// (`FUN_NET_MESSAGE_DISPATCH`) is the single chokepoint every dispatched SMSG
// passes through *before* the engine's per-opcode handler runs.
//
// Why this exists: co-existing DLLs (notably nampower via hadesmem, plus
// SuperWoW) MinHook/detour the individual per-opcode SMSG handlers
// (SpellGo/SpellStart/SpellDelayed/…). Two inline hookers on the same prologue
// is the documented collision risk. nampower does NOT hook this funnel — it
// hooks the leaf handlers — so any feature that only needs to *read* an
// incoming packet subscribes here instead, and we stop sharing those
// prologues. One hook on a function nobody else patches replaces N hooks on
// functions everybody patches.
//
// Declare a file-scope
// static const Net::PacketDispatch::AutoSubscribe _sub{&callback};
// Each callback receives the u16 `opcode` (already read) and the `CDataStore`
// positioned **right after the opcode** (body start), exactly as the engine's
// per-opcode handler would see it. The dispatcher resets that cursor before
// every subscriber (so they read independently) and restores the original
// `m_read` before handing the packet to the engine. Callbacks must only
// READ — never mutate the packet — and must filter on `opcode` themselves.
namespace Net::PacketDispatch {
// Chains onto the incoming-packet subscriber list at static-init time. The
// list plumbing + fan-out live in net/PacketObserver.h (shared with
// Net::SendObserver).
struct AutoSubscribe : Net::PacketSubscriber {
explicit AutoSubscribe(Net::PacketCallback cb);
};
} // namespace Net::PacketDispatch
+66
View File
@@ -0,0 +1,66 @@
// 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.
#pragma once
#include "net/PacketReader.h"
#include <cstdint>
// Shared plumbing for the two packet-observer funnels — the outgoing
// `Net::SendObserver` (CMSG, u32 opcode) and the incoming `Net::PacketDispatch`
// (SMSG, u16 opcode). Both co-hook a single engine chokepoint and fan each
// packet out to opcode subscribers; the only real differences are the opcode
// width and the hooked function's ABI. Everything else — the intrusive
// subscriber list, the subscribe helper, and the read-opcode / reset-cursor /
// fan-out / restore-cursor loop — lives here so a fix to the fan-out logic
// isn't made twice.
//
// Each observer keeps its own file-scope `PacketSubscriber *g_head` and defines
// a thin `AutoSubscribe` in its own namespace (so call sites stay
// `Net::PacketDispatch::AutoSubscribe{&cb}`); the shared pieces below do the
// work.
namespace Net {
using PacketCallback = void (*)(uint32_t opcode, CDataStore *packet);
// Intrusive node for an observer's subscriber list. Each observer's
// `AutoSubscribe` derives from this and chains itself on at static-init time.
struct PacketSubscriber {
PacketCallback cb;
PacketSubscriber *next;
};
// Chains `node` (already carrying its `cb`) onto `head`.
inline void Subscribe(PacketSubscriber *&head, PacketSubscriber *node,
PacketCallback cb) {
node->cb = cb;
node->next = head;
head = node;
}
// Reads the leading opcode (width `OpcodeT`) at the packet's current cursor,
// then invokes every subscriber with the cursor reset to the body before each
// (so they read independently) and the original `m_read` restored afterward —
// so the engine (and any leaf-handler hooks) see an untouched packet.
template <typename OpcodeT>
inline void FanOutByOpcode(PacketSubscriber *head, CDataStore *packet) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = static_cast<uint32_t>(Read<OpcodeT>(packet));
const uint32_t afterOpcode = packet->m_read;
for (auto *node = head; node != nullptr; node = node->next) {
packet->m_read = afterOpcode; // each subscriber reads independently
node->cb(opcode, packet);
}
packet->m_read = saved; // hand the engine an untouched cursor
}
} // namespace Net
+2 -1
View File
@@ -13,7 +13,8 @@
#include <cstdint>
// Minimal reader for the engine's `CDataStore` packet buffer, shared by the
// packet co-hooks (`Aura::Source`'s SpellGo, `Spell::Cast`'s SpellStart).
// packet-observer subscribers (`Aura::Source`'s SpellGo, `Spell::Cast`'s
// SpellStart, …) that read incoming SMSGs via `Net::PacketDispatch`.
//
// `CDataStore` is POLYMORPHIC in the engine (virtual Internal*/Reset/etc.),
// so a vtable pointer sits at offset 0 and the data members start at +0x04.
+48
View File
@@ -0,0 +1,48 @@
// 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 "SendObserver.h"
#include "Game.h"
#include "Offsets.h"
namespace Net::SendObserver {
namespace {
PacketSubscriber *g_head = nullptr;
// `FUN_NET_SEND` — `__thiscall(conn, CDataStore*)`, rendered here as
// `__fastcall(conn /*ecx*/, edx, packet)`. The leading u32 of the buffer is
// the opcode.
using NetSend_t = void(__fastcall *)(void *conn, void *edx, CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr && g_head != nullptr)
Net::FanOutByOpcode<uint32_t>(g_head, packet); // opcode is a u32 CMSG id
g_origNetSend(conn, edx, packet);
}
} // namespace
AutoSubscribe::AutoSubscribe(Net::PacketCallback cb) {
Net::Subscribe(g_head, this, cb);
}
static const Game::HookAutoRegister _hook{
Offsets::FUN_NET_SEND,
reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
} // namespace Net::SendObserver
+44
View File
@@ -0,0 +1,44 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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.
#pragma once
#include "net/PacketObserver.h"
#include "net/PacketReader.h"
#include <cstdint>
// Shared observer for outgoing packets. The engine's NetClient send funnel
// (`FUN_NET_SEND`) is a general chokepoint every dispatched CMSG passes
// through — not something a single feature should own. MinHook permits one
// hook per target, so any module that wants to watch sent packets subscribes
// here instead of hooking the funnel itself.
//
// Mirrors `Tick::WorldTick`: declare a file-scope
// `static const Net::SendObserver::AutoSubscribe _sub{&callback};`. The
// constructor chains onto the internal list at static-init time.
//
// Each subscriber's callback receives the leading `opcode` (u32) already
// read, and the `CDataStore` positioned **right after the opcode** so it can
// read the message body directly. The observer resets that cursor before
// every subscriber (so they read independently) and restores the original
// `m_read` before handing the packet to the engine. Callbacks must only
// READ — never mutate the packet.
namespace Net::SendObserver {
// Chains onto the outgoing-packet subscriber list at static-init time. The
// list plumbing + fan-out live in net/PacketObserver.h (shared with
// Net::PacketDispatch).
struct AutoSubscribe : Net::PacketSubscriber {
explicit AutoSubscribe(Net::PacketCallback cb);
};
} // namespace Net::SendObserver
+25 -5
View File
@@ -25,8 +25,10 @@
// mirroring the engine's registration shape exactly (bank 4, size 8). The
// dispatcher invokes us only when a slot's item GUID actually changed
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
// on our side, and the nodes share the engine observers' lifetime (they
// die with the player object; the setup re-runs each enter-world).
// on our side. The setup re-runs each enter-world; on logout/login the
// player object is recreated so old nodes die with it, but /reload REUSES
// the object, so we unregister-then-register per slot to avoid stacking a
// duplicate observer each reload (see ObserverSetup_h).
//
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
@@ -126,6 +128,10 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
using RegisterObserver_t = void(__fastcall *)(
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1, void *userArg2);
using UnregisterObserver_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
uint32_t guidLo, uint32_t guidHi,
const void *callback,
void *userArg1);
using ObserverSetup_t = void(__fastcall *)();
ObserverSetup_t g_origSetup = nullptr;
@@ -147,12 +153,26 @@ void __fastcall ObserverSetup_h() {
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return;
const uint32_t lo = static_cast<uint32_t>(guid);
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
auto reg = reinterpret_cast<RegisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_REGISTER);
auto unreg = reinterpret_cast<UnregisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_UNREGISTER);
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
const uint32_t field = Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8;
// Drop any observer we already registered for this field before adding
// a fresh one. The setup re-runs on every enter-world, including
// /reload — but /reload does NOT destroy the CGPlayer object, so our
// previous nodes are still attached and the registrar always APPENDS
// (no dedup by callback). Without this, each /reload stacks another
// observer per slot → PLAYER_EQUIPMENT_CHANGED fires N times after N
// reloads. On logout/login the object is recreated and the old nodes
// died with it, so the unregister no-ops there. Same objects-survive-
// /reload hazard `Unit::TokenObserver` guards against for nameplates.
unreg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
reinterpret_cast<const void *>(&EquipSlotChanged_cb), nullptr);
reg(Offsets::DESC_OBSERVER_BANK_PLAYER, field, lo, hi,
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
nullptr, nullptr);
}
+27 -12
View File
@@ -10,19 +10,27 @@
// `HEARTHSTONE_BOUND` event — polyfills modern WoW's event of the
// same name. Fires every time the player binds their hearthstone at
// an innkeeper, even when they re-bind at the SAME inn — matches
// an innkeeper, even when they re-bind at the SAME inn — matching
// modern semantics where the bind ACTION fires the event regardless
// of whether the location string changes.
// of whether the location changes.
//
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0` and
// gates on `VAR_BIND_POINT_VALID` to suppress only the initial
// post-login sync. The engine zeroes that flag during per-character
// entry init (`FUN_005E2510`); the handler sets it to 1 after
// parsing. If the flag is 0 before the original runs, this packet
// is the initial sync — don't fire. Any subsequent invocation is
// a real innkeeper rebind. Character switch is handled automatically
// because the entry init re-zeroes the flag, so each character's
// first update is also treated as sync.
// Hooks the BINDPOINTUPDATE packet handler at `FUN_005ED3C0`. Two
// non-bind packets must be suppressed:
//
// 1. Initial post-login / character-switch sync — gated on
// `VAR_BIND_POINT_VALID`. The engine zeroes that flag during
// per-character entry init (`FUN_005E2510`); the handler sets it
// to 1 after parsing. Flag 0 before the original runs ⇒ initial
// sync, don't fire.
// 2. Map/zone-transition resync — Turtle's server re-sends this
// packet on every map change with the (unchanged) bind location.
// The valid flag is already 1 by then, so (1) doesn't catch it.
// But those packets arrive behind a LOADING SCREEN, and the
// player can't bind mid-load — so gate on `VAR_IN_WORLD` (the
// engine's "world is live" flag, 0 during any loading screen).
// A genuine rebind always arrives with the world live (flag 1),
// so this suppresses the resync while still firing for a rebind
// at the same innkeeper.
//
// Event has no payload — addons call `GetBindLocation()` to read
// the new location, matching modern semantics.
@@ -51,7 +59,14 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
BindPointUpdateHandler_o(packetBuffer);
if (wasValid == 0)
return; // initial post-login sync — not a user rebind
return; // initial post-login / char-switch sync — not a user rebind
// Resync packets ride the enter-world burst behind a loading screen;
// a real innkeeper bind only happens with the world live. The player
// can't bind mid-load, so a not-in-world packet is always a resync.
if (*reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "");
+7 -1
View File
@@ -27,7 +27,13 @@ using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// 0xC) and returns true on first match. Mirrors the engine's loop in
// `Script_IsUnitOnQuest` (`0x004DFE10`).
bool UnitHasQuest(const uint8_t *unit, int target) {
if (target <= 0 || !Unit::Flags::IsPlayerControlled(unit))
// The quest list lives in the CGPlayer sub-struct at +0xE68, which
// only exists on real player objects. Gate on the object being a
// player, not merely player-controlled — a pet/totem/MC'd creature
// sets UNIT_FLAG_PLAYER_CONTROLLED but has no sub-struct there, so
// IsPlayerControlled would deref garbage and crash (same root cause
// as pfUI issue #34).
if (target <= 0 || !Unit::Flags::IsPlayerObject(unit))
return false;
auto *info = *reinterpret_cast<const uint8_t *const *>(
+19 -8
View File
@@ -140,20 +140,31 @@ bool Resolve() {
namespace {
// 4th arg is the GUID high dword. Ghidra types it `float` from usage, but
// it's a plain dword on the stack (the engine assigns it the current
// selection's high dword when 0); `unsigned` puts the correct 4 bytes there.
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
unsigned targetGuidLo, unsigned targetGuidHi);
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
void DispatchSlot(int slot0Based, int bookType) {
void DispatchSlot(int slot0Based, int bookType, uint64_t targetGuid) {
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
// (0, 0) GUID = no implicit target; the engine handles placement.
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
// GUID 0 = no implicit target (engine uses current selection / placement).
// A real GUID casts a unit-target spell straight at that unit.
dispatch(static_cast<unsigned>(slot0Based), bookType,
static_cast<unsigned>(targetGuid),
static_cast<unsigned>(targetGuid >> 32));
}
} // namespace
bool DispatchSpellCast(int spellID) {
bool IsPlacementActive() {
return *reinterpret_cast<const uint32_t *>(
Offsets::VAR_SPELL_PLACEMENT_STATE) != 0;
}
bool DispatchSpellCast(int spellID, uint64_t targetGuid) {
if (spellID <= 0)
return false;
@@ -167,11 +178,11 @@ bool DispatchSpellCast(int spellID) {
if (slot1 <= 0)
return false; // not in the player's spellbook
DispatchSlot(slot1 - 1, bookType);
DispatchSlot(slot1 - 1, bookType, targetGuid);
return true;
}
bool DispatchSpellCastByName(const char *name) {
bool DispatchSpellCastByName(const char *name, uint64_t targetGuid) {
if (name == nullptr || *name == '\0')
return false;
@@ -185,7 +196,7 @@ bool DispatchSpellCastByName(const char *name) {
if (slot < 0)
return false; // not in the player's spellbook
DispatchSlot(slot, bookType);
DispatchSlot(slot, bookType, targetGuid);
return true;
}
+17 -2
View File
@@ -13,8 +13,16 @@
#pragma once
#include <cstdint>
namespace Spell::AtCursor {
// True while the engine has an in-flight targeting placement of any kind
// (ground reticle, unit/item/corpse select). `VAR_SPELL_PLACEMENT_STATE != 0`.
// Used by `CastAtUnit` to tell a unit-target spell that fired immediately
// (no placement) from a ground spell that's waiting for a location.
bool IsPlacementActive();
// Resolves the engine's in-flight ground-target placement at the
// player's current cursor world position. Intended to be called
// immediately after initiating a ground-target cast (spell or item)
@@ -51,7 +59,14 @@ bool CommitAtCoords(const float coords[3]);
// name form defers to the engine's own resolver, which parses a
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
// `"Blizzard(Rank 6)"` casts that rank.
bool DispatchSpellCast(int spellID);
bool DispatchSpellCastByName(const char *name);
//
// `targetGuid` is the implicit cast target: `0` = the engine's default
// (current selection, or ground placement for AoE); a unit's GUID makes
// a unit-target/normal spell fire directly on that unit (the engine
// substitutes it exactly where it would the current target). Ground
// spells ignore it and still enter placement. `CastAtUnit` passes the
// resolved unit's GUID; `CastAtCursor` leaves it 0.
bool DispatchSpellCast(int spellID, uint64_t targetGuid = 0);
bool DispatchSpellCastByName(const char *name, uint64_t targetGuid = 0);
} // namespace Spell::AtCursor
+78 -41
View File
@@ -11,37 +11,78 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Spell.CastAtUnit(spellID, unit)` — cast a ground-target spell at
// the given unit's feet, bypassing the manual click on the AoE
// reticle. `CastAtUnit(spellID, "player")` drops it at the player's
// position, `"target"` at the target's, etc.
// `C_Spell.CastAtUnit(spellIDOrName, unit)` — cast a spell at the given
// unit, whatever its target type. One primitive covering both:
//
// Same two-step shape as `C_Spell.CastAtCursor`, but the placement is
// committed at the unit's world position instead of the cursor
// raycast:
// - **Ground-target spells** (Blizzard, Flamestrike, Rain of Fire…) are
// placed at the unit's feet — the reticle is committed at the unit's
// world position, no manual click.
// - **Unit-target / normal spells** (Frostbolt, Healing Touch, a buff…)
// fire directly on the unit — its GUID is fed to the engine's cast
// dispatcher as the implicit target, so no current-target juggling and
// no "select target" reticle.
//
// 1. `Spell::AtCursor::DispatchSpellCast(spellID)` initiates the
// cast; the engine enters placement mode for a ground-target
// spell.
// 2. Read the unit's world position (vtable `GetPosition`) and
// `Spell::AtCursor::CommitAtCoords()` commits the placement
// there.
// Mechanism: resolve the token once to the unit object, read its position
// and GUID, then dispatch the cast WITH that GUID
// (`Spell::AtCursor::DispatchSpellCast(spell, guid)`). The engine's
// dispatcher substitutes the GUID exactly where it would the current
// selection, so a unit-target spell fires immediately at that unit. A
// ground spell ignores the GUID and enters placement mode instead — so we
// branch on `IsPlacementActive()`:
//
// Returns `true` when the placement was committed at the unit; `false`
// when the spell isn't ground-target (placement cancelled), the spell
// isn't in the spellbook, or the unit has no resolvable position. The
// cast itself still proceeds for non-ground spells, just with no
// implicit target — matching `CastAtCursor`'s semantics.
// - No placement after dispatch → the cast already went out on the unit
// (unit-target / normal). Success.
// - Ground placement active → commit at the unit's feet. Success.
// - Other placement still active → the engine didn't accept the unit as a
// target (e.g. wrong faction / out of range); `CommitAtCoords` cancels
// it. Failure.
//
// Returns `true` when the spell was cast at the unit; `false` when the
// spell isn't in the spellbook, the unit has no resolvable position, or the
// unit wasn't a valid target. A genuinely unrecognized token string raises
// the engine's standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.
#include "Game.h"
#include "spell/AtCursor.h"
#include "spell/AtUnit.h"
#include "spell/MacroPrimarySpell.h"
#include "unit/Identity.h"
#include "unit/Position.h"
#include <cstdint>
namespace Spell::AtUnit {
namespace {
// The shared cast-at-unit core, used by both the Lua entry and the C++ API.
// Resolves the token once → object, reads position + GUID, dispatches the cast
// WITH that GUID, then handles the ground-spell placement branch. `byNumber`
// selects spellID vs spellName. Fail-fast so a bad/absent unit doesn't leave
// the engine mid-cast.
bool CastCore(const char *token, bool byNumber, int spellID, const char *spellName) {
void *unitObj = Unit::Position::ResolveToken(token);
float pos[3];
if (unitObj == nullptr || !Unit::Position::Read(unitObj, pos))
return false;
const uint64_t targetGuid = Unit::Identity::GuidForObject(unitObj);
const bool dispatched =
byNumber ? Spell::AtCursor::DispatchSpellCast(spellID, targetGuid)
: Spell::AtCursor::DispatchSpellCastByName(spellName, targetGuid);
if (!dispatched)
return false;
// Unit-target / normal spells fired straight at the GUID — no placement.
// Ground spells are now sitting in placement mode; commit at the unit's
// feet (CommitAtCoords cancels + fails for a non-ground placement, i.e.
// an unaccepted unit target).
if (!Spell::AtCursor::IsPlacementActive())
return true;
return Spell::AtCursor::CommitAtCoords(pos);
}
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
// arg1: numeric spellID (exact rank) or spell name ("(Rank N)"-aware).
// arg2: unit token.
@@ -52,30 +93,12 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
return 1;
}
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
// bad/absent unit doesn't leave the engine sitting in placement
// mode. (A genuinely unrecognized token string raises the engine's
// standard "Unknown unit" error inside the resolver, same as
// `UnitHealth("garbage")`.)
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
Game::Lua::PushBool(L, false);
return 1;
}
const bool dispatched =
const bool ok =
byNumber
? Spell::AtCursor::DispatchSpellCast(
static_cast<int>(Game::Lua::ToNumber(L, 1)))
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
const bool placed = Spell::AtCursor::CommitAtCoords(pos);
Game::Lua::PushBool(L, placed);
? CastCore(token, true, static_cast<int>(Game::Lua::ToNumber(L, 1)),
nullptr)
: CastCore(token, false, 0, Game::Lua::ToString(L, 1));
Game::Lua::PushBool(L, ok);
return 1;
}
@@ -110,4 +133,18 @@ const Spell::MacroPrimarySpell::PatternAutoRegister _patreg{
} // namespace
// ---- C++ API (see AtUnit.h) — same core as the Lua entry, no round-trip -----
bool CastByName(const char *spellName, const char *unitToken) {
if (spellName == nullptr || unitToken == nullptr)
return false;
return CastCore(unitToken, false, 0, spellName);
}
bool CastByID(int spellID, const char *unitToken) {
if (unitToken == nullptr)
return false;
return CastCore(unitToken, true, spellID, nullptr);
}
} // namespace Spell::AtUnit
+35
View File
@@ -0,0 +1,35 @@
// 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
// C++ entry to the cast-at-unit primitive behind `C_Spell.CastAtUnit`, so
// other C++ modules (the Frame attribute click dispatcher's `spell` verb) can
// cast a spell at a unit without a Lua round-trip through the global table.
//
// Both feed the unit's GUID straight to the engine's cast dispatcher — a
// unit-target/normal spell fires directly on the unit (no current-target
// juggling), a ground-target spell lands at the unit's feet. Returns true when
// the spell was cast. Safe to call from inside a Lua C function: a genuinely
// unknown token raises the engine's standard "Unknown unit" error in the
// resolver, caught by the enclosing protected call.
namespace Spell::AtUnit {
// Cast by name ("(Rank N)"-aware — `"Blizzard"` is the highest known rank).
bool CastByName(const char *spellName, const char *unitToken);
// Cast the exact numeric spellID (a specific rank).
bool CastByID(int spellID, const char *unitToken);
} // namespace Spell::AtUnit
+156 -138
View File
@@ -71,8 +71,10 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "spell/CastEvents.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
@@ -232,8 +234,10 @@ const char *SpellIconPath(const uint8_t *rec) {
}
// Pushes UnitCastingInfo's 11-tuple from a tracked cast, or nothing (nil)
// if there's no active cast.
int PushCastInfo(void *L, const TrackedSpell &c) {
// if there's no active cast. `casterGuid` identifies whose cast this is (the
// player's or a remote unit's) so `castID` can be pulled from Spell::CastEvents
// — the same castGUID the cast's UNIT_SPELLCAST_* events carry.
int PushCastInfo(void *L, const TrackedSpell &c, uint64_t casterGuid) {
if (c.spellID == 0)
return 0;
// Self-expire: once the cast window has elapsed, report nothing even if
@@ -252,7 +256,12 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
PushMs(L, c.startMs); // 4 startTimeMs
PushMs(L, c.endMs); // 5 endTimeMs
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
Game::Lua::PushNil(L); // 7 castID
char castGuid[48]; // 7 castID
if (Spell::CastEvents::CurrentCastGuid(casterGuid, c.spellID, castGuid,
sizeof castGuid))
Game::Lua::PushString(L, castGuid);
else
Game::Lua::PushNil(L);
Game::Lua::PushBool(L, false); // 8 notInterruptible
Game::Lua::PushNumber(L, static_cast<double>(c.spellID)); // 9 castingSpellID
Game::Lua::PushNil(L); // 10 castBarID
@@ -469,97 +478,81 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
// Phase 2 events: START (cast) or CHANNEL_START (channel). Skip pure
// instants (non-channel, castTime 0 → no bar); their SUCCEEDED still fires
// from the SPELL_GO hook.
if (castTime > 0 || instantChannel)
Spell::CastEvents::OnRemoteCastStart(caster, spellID, endMs,
instantChannel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
// other packet this handler processes is a cheap early-out. Packet layout
// mirrors nampower's SpellStartHandlerHook.
using SpellStartHandler_t = int(__fastcall *)(uint32_t unk, uint32_t opCode,
uint32_t unk2,
Net::CDataStore *packet);
SpellStartHandler_t g_origSpellStart = nullptr;
// SMSG_SPELL_START parse. Body (mirrored from nampower's SpellStartHandler):
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(u16),
// castTime(u32). Runs from the Net::PacketDispatch funnel with the cursor
// already positioned at the body.
void ParseSpellStart(Net::CDataStore *packet) {
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
Net::Read<uint16_t>(packet); // castFlags (unused)
const uint32_t castTime = Net::Read<uint32_t>(packet);
HandleSpellStart(caster, spellID, castTime);
}
int __fastcall SpellStartHandler_h(uint32_t unk, uint32_t opCode,
uint32_t unk2, Net::CDataStore *packet) {
if (opCode == 0x131 && packet != nullptr) {
const uint32_t saved = packet->m_read;
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
Net::Read<uint16_t>(packet); // castFlags (unused)
const uint32_t castTime = Net::Read<uint32_t>(packet);
packet->m_read = saved; // restore before the engine re-parses
HandleSpellStart(caster, spellID, castTime);
// SMSG_SPELL_DELAYED — cast pushback. The server only sends it to the
// affected caster, so it's effectively always the local player; extend the
// tracked cast's end (and report the accumulated delay as delayTimeMs) so an
// addon's bar stretches on damage. Body: guid(u64), delay(u32).
void ParseSpellDelayed(Net::CDataStore *packet) {
const uint64_t guid = Net::Read<uint64_t>(packet);
const uint32_t delay = Net::Read<uint32_t>(packet);
if (delay != 0 && g_cast.spellID != 0 &&
guid == Unit::Identity::PlayerGuid()) {
g_cast.endMs += static_cast<int>(delay);
g_cast.delayMs += static_cast<int>(delay);
}
return g_origSpellStart(unk, opCode, unk2, packet);
}
const Game::HookAutoRegister _spellStartHook{
Offsets::FUN_SPELL_START_HANDLER,
reinterpret_cast<void *>(&SpellStartHandler_h),
reinterpret_cast<void **>(&g_origSpellStart)};
// Co-hook on the SMSG_SPELL_DELAYED handler — cast pushback. The server
// only sends it to the affected caster, so it's effectively always the
// local player; extend the tracked cast's end time (and report the
// accumulated delay as delayTimeMs) so an addon's bar stretches on damage.
using SpellDelayed_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellDelayed_t g_origSpellDelayed = nullptr;
int __stdcall SpellDelayed_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const uint32_t delay = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (delay != 0 && g_cast.spellID != 0 &&
guid == Unit::Identity::PlayerGuid()) {
g_cast.endMs += static_cast<int>(delay);
g_cast.delayMs += static_cast<int>(delay);
}
}
return g_origSpellDelayed(opCode, packet);
}
const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
// channel has begun, with the server-authoritative (modifier-applied)
// duration. Sent only to the caster, so it's always the local player. This
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
// this packet marks the channel actually starting. For instant channels it
// re-stamps what the SpellStart path just wrote (same moment, server
// duration) — harmless. We stamp before the original runs so the data is
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
// mirrored from nampower's SpellChannelStartHandlerHook:
// MSG_CHANNEL_START — the server tells the caster a channel has begun, with
// the server-authoritative (modifier-applied) duration. Sent only to the
// caster, so always the local player. This is what makes CAST-THEN-CHANNEL
// spells (Mind Control: 3 s cast, then a 60 s channel) show both phases:
// their SMSG_SPELL_START carries the cast (castTime > 0, handled as a regular
// cast in HandleSpellStart) and only this packet marks the channel starting.
// For instant channels it re-stamps what the SpellStart path just wrote (same
// moment, server duration) — harmless. Running from the dispatch funnel keeps
// the stamp ahead of the engine's leaf handler (which fires
// SPELLCAST_CHANNEL_START), so the data is fresh when addons poll. Body:
// spellId(u32), durationMs(u32).
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
ChannelStart_t g_origChannelStart = nullptr;
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
StampChannel(spellID, now, now + static_cast<int>(duration));
}
void ParseChannelStart(Net::CDataStore *packet) {
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
if (spellID != 0 && duration > 0) {
const int now = NowMs();
StampChannel(spellID, now, now + static_cast<int>(duration));
}
return g_origChannelStart(opCode, packet);
}
const Game::HookAutoRegister _channelStartHook{
Offsets::FUN_SPELL_CHANNEL_START,
reinterpret_cast<void *>(&ChannelStart_h),
reinterpret_cast<void **>(&g_origChannelStart)};
// MSG_CHANNEL_UPDATE — sent to the channeling PLAYER on damage pushback
// (vanilla channels SHORTEN when hit) and once more at the channel's end
// (remaining == 0). Body is a single u32 = the new remaining time (ms); the
// spell is the in-progress channel (the packet carries no spellID). The engine
// stores the new end nowhere, so re-anchor g_channel.endMs to the server's
// remaining time so UnitChannelInfo (and the OnWorldTick endMs self-expiry)
// track pushback; on remaining == 0 clear the channel so CHANNEL_STOP fires
// promptly (ahead of the ~1 s-lagged +0x228 field).
void ParseChannelUpdate(Net::CDataStore *packet) {
if (g_channel.spellID == 0)
return;
const uint32_t remaining = Net::Read<uint32_t>(packet);
const int spellID = g_channel.spellID;
if (remaining == 0) {
g_channel.spellID = 0; // authoritative channel end
} else {
g_channel.endMs = NowMs() + static_cast<int>(remaining);
Spell::CastEvents::OnPlayerChannelUpdate(spellID);
}
}
// Shared abort handling for the two sibling failure packets. Servers
// derived from (v)mangos broadcast BOTH `SMSG_SPELL_FAILURE` and
@@ -582,61 +575,31 @@ void HandleCastAborted(uint64_t guid, int spellID) {
// clear it on interrupt — this packet is the only signal. Whether the
// caster receives their own broadcast is server-dependent; if it never
// fires, the endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid() && g_cast.spellID == spellID)
g_cast.spellID = 0;
}
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
// to observers whenever a started cast aborts (interrupt, death, movement,
// fizzle). It's what lets us drop a remote cast the moment it dies instead
// of letting the ghost bar run to its computed end (the documented
// remote-unit limitation above). Packet body is plain (not packed):
// casterGuid(u64), spellId(u32).
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailedOther_t g_origSpellFailedOther = nullptr;
int __stdcall SpellFailedOther_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
if (guid == Unit::Identity::PlayerGuid()) {
// Cast interrupts are surfaced by PollPlayer (g_castSucceeded). Player
// CHANNELS never fire INTERRUPTED (retail only fires CHANNEL_STOP for
// them, interrupted or not), so there's nothing to do for a channel.
if (g_cast.spellID == spellID)
g_cast.spellID = 0;
return;
}
return g_origSpellFailedOther(opCode, packet);
// Phase 2: remote unit — the poll fires INTERRUPTED + STOP for it.
Spell::CastEvents::OnRemoteAborted(guid, spellID);
}
const Game::HookAutoRegister _spellFailedOtherHook{
Offsets::FUN_SPELL_FAILED_OTHER,
reinterpret_cast<void *>(&SpellFailedOther_h),
reinterpret_cast<void **>(&g_origSpellFailedOther)};
// Co-hook on the SMSG_SPELL_FAILURE handler — FAILED_OTHER's sibling, with
// a trailing result(u8) after the same casterGuid(u64), spellId(u32) head
// (we don't read the result). This is the packet that actually stops the
// caster's cast VISUAL in the engine's own handler, so it demonstrably
// arrives for interrupts; hooking both siblings makes the eviction
// independent of which one the server chose to send.
using SpellFailure_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailure_t g_origSpellFailure = nullptr;
int __stdcall SpellFailure_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailure(opCode, packet);
// SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER — sibling abort packets. The
// server broadcasts one (which varies by core / abort path) to observers when
// a started cast aborts (interrupt, death, movement, fizzle); handling both
// makes eviction independent of which the server chose. Both share the head
// casterGuid(u64), spellId(u32) — plain, not packed (SpellFailure has a
// trailing result(u8) we don't read). Drops a remote cast the moment it dies
// instead of letting the ghost bar run to its computed end.
void ParseCastAborted(Net::CDataStore *packet) {
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
HandleCastAborted(guid, spellID);
}
const Game::HookAutoRegister _spellFailureHook{
Offsets::FUN_SPELL_FAILURE, reinterpret_cast<void *>(&SpellFailure_h),
reinterpret_cast<void **>(&g_origSpellFailure)};
// Co-hook on CGUnit_C::ClearCastingSpell — the engine choke point every
// "unit stopped casting" path funnels through (failure packets, SPELL_GO,
// movement, death, animation events, local cancel — see Offsets.h). The
@@ -671,6 +634,29 @@ const Game::HookAutoRegister _clearCastingHook{
reinterpret_cast<void *>(&ClearCastingSpell_h),
reinterpret_cast<void **>(&g_origClearCastingSpell)};
// One subscriber on the SMSG dispatch funnel, replacing the six per-opcode
// co-hooks these spell packets used to install (SpellStart / Delayed /
// ChannelStart / ChannelUpdate / Failure / FailedOther) — every one of which
// nampower also detours. The dispatcher hands us the cursor at the body (the
// opcode already consumed). CastStartSet and ClearCastingSpell stay direct
// hooks: they're engine methods, not SMSG handlers, so the funnel can't reach
// them (and ClearCastingSpell is the SuperWoW-shared interrupt choke point).
void SpellPacketSub(uint32_t opcode, Net::CDataStore *packet) {
if (packet == nullptr)
return;
switch (opcode) {
case Offsets::SMSG_SPELL_START: ParseSpellStart(packet); break;
case Offsets::SMSG_SPELL_DELAYED: ParseSpellDelayed(packet); break;
case Offsets::SMSG_SPELL_CHANNEL_START: ParseChannelStart(packet); break;
case Offsets::SMSG_SPELL_CHANNEL_UPDATE: ParseChannelUpdate(packet); break;
case Offsets::SMSG_SPELL_FAILURE:
case Offsets::SMSG_SPELL_FAILED_OTHER: ParseCastAborted(packet); break;
default: break;
}
}
const Net::PacketDispatch::AutoSubscribe _spellPacketSub{&SpellPacketSub};
} // namespace
// Per-frame upkeep for the player. The regular cast is stamped by the
@@ -714,13 +700,43 @@ void OnWorldTick() {
else if (chan == 0 && g_channelConfirmed)
g_channel.spellID = 0;
}
// Self-expiry backstop for the poll: the +0x228 broadcast clears ~1
// tick (~1s) late, so a channel that runs its full duration would
// otherwise linger until then — the poll's CHANNEL_STOP (and any
// g_channel reader) would trail the real end by ~1s. Clear at the
// server-computed endMs too, so the effective stop is the EARLIER of
// (computed end, early +0x228 clear — the summon-ritual / interrupt
// case). Mirrors PushChannelInfo's endMs guard, which already hides
// the channel from UnitChannelInfo at this same instant.
//
// Wrap-safe compare: the ms tick (rdtsc-backed, survives reboots) can
// sit past 2^31, where a stored endMs reads NEGATIVE as a signed int.
// A direct `now >= endMs` would then misfire for a channel straddling
// the boundary. The signed DELTA cancels the wrap (correct for any
// interval < ~24.8 days — a channel is always far shorter), so test
// `now - endMs >= 0` instead.
if (g_channel.spellID != 0 && g_channel.endMs != 0 &&
NowMs() - g_channel.endMs >= 0)
g_channel.spellID = 0;
}
// Derive the player's UNIT_SPELLCAST_* events from the cast/channel
// state above, now that this tick's clears have been applied. Poll-
// based so it needs no changes to the many stamp/clear sites; ~1 frame
// latency is imperceptible for a cast bar, and every fire is
// listener-gated (near-free when no addon uses these).
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.delayMs,
g_channel.spellID, g_channel.startMs);
Spell::CastEvents::PollRemote();
Spell::CastEvents::PollReticle();
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// `CastingInfo()` — local player's cast, no token lookup.
static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cast); }
static int __fastcall Script_CastingInfo(void *L) {
return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid());
}
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
@@ -736,11 +752,13 @@ static int __fastcall Script_UnitCastingInfo(void *L) {
if (u == nullptr)
return 0;
if (u == Resolve("player"))
return PushCastInfo(L, g_cast);
return PushCastInfo(L, g_cast, Unit::Identity::PlayerGuid());
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
const uint64_t guid = Unit::Identity::GuidForObject(u);
const RemoteCast *rc = FindRemoteCast(guid);
if (rc != nullptr && !rc->isChannel && NowMs() < rc->endMs)
return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs});
return PushCastInfo(L, TrackedSpell{rc->spellID, rc->startMs, rc->endMs},
guid);
return 0;
}
+695
View File
@@ -0,0 +1,695 @@
// 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 "CastEvents.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdio>
#include <cstdint>
#include <ctime>
namespace Spell::CastEvents {
namespace {
// Spell.dbc localized string arrays (9 locale slots, 4 bytes each).
constexpr int OFF_NAME = 0x1E0; // Name[9]
constexpr int OFF_RANK = 0x204; // Rank[9]
constexpr const char *kStart = "UNIT_SPELLCAST_START";
constexpr const char *kStop = "UNIT_SPELLCAST_STOP";
constexpr const char *kDelayed = "UNIT_SPELLCAST_DELAYED";
constexpr const char *kChannelStart = "UNIT_SPELLCAST_CHANNEL_START";
constexpr const char *kChannelStop = "UNIT_SPELLCAST_CHANNEL_STOP";
constexpr const char *kChannelUpdate = "UNIT_SPELLCAST_CHANNEL_UPDATE";
constexpr const char *kSucceeded = "UNIT_SPELLCAST_SUCCEEDED";
constexpr const char *kInterrupted = "UNIT_SPELLCAST_INTERRUPTED";
constexpr const char *kFailed = "UNIT_SPELLCAST_FAILED";
constexpr const char *kFailedQuiet = "UNIT_SPELLCAST_FAILED_QUIET";
constexpr const char *kSent = "UNIT_SPELLCAST_SENT";
constexpr const char *kReticleTarget = "UNIT_SPELLCAST_RETICLE_TARGET";
constexpr const char *kReticleClear = "UNIT_SPELLCAST_RETICLE_CLEAR";
const Event::Custom::AutoReserve _rStart{kStart};
const Event::Custom::AutoReserve _rStop{kStop};
const Event::Custom::AutoReserve _rDelayed{kDelayed};
const Event::Custom::AutoReserve _rChannelStart{kChannelStart};
const Event::Custom::AutoReserve _rChannelStop{kChannelStop};
const Event::Custom::AutoReserve _rChannelUpdate{kChannelUpdate};
const Event::Custom::AutoReserve _rSucceeded{kSucceeded};
const Event::Custom::AutoReserve _rInterrupted{kInterrupted};
const Event::Custom::AutoReserve _rFailed{kFailed};
const Event::Custom::AutoReserve _rFailedQuiet{kFailedQuiet};
const Event::Custom::AutoReserve _rSent{kSent};
const Event::Custom::AutoReserve _rReticleTarget{kReticleTarget};
const Event::Custom::AutoReserve _rReticleClear{kReticleClear};
// Autoshot spams client-side failures while ramping — nampower filters it
// out of its failure event, so do we.
constexpr uint32_t kAutoShotSpellId = 75;
// SpellCastTargets mask bit for a unit target (a packed GUID follows).
constexpr uint16_t kTargetFlagUnit = 0x0002;
const char *LocalizedField(const uint8_t *rec, int fieldOffset) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return *reinterpret_cast<const char *const *>(rec + fieldOffset + locale * 4);
}
// Cast UID for a REAL (Type-3) cast — player and remote alike. Per the retail
// spell-cast-GUID spec (warcraft.wiki.gg/wiki/GUID#Cast) for non-local-only
// casts: the low 23 bits are the cast's UNIX-epoch second modulo 2^23, and the
// higher bits are a counter incrementing for casts observed within the same
// second (Creature-GUID-like). Type-2 casts — local-only FAILED casts, "failed
// due to requirements not being met" — use a plain incrementing integer
// instead (`g_guidCounter`), matching the spec's local-cast rule.
int MakeCastUID() {
const uint32_t nowSec = static_cast<uint32_t>(std::time(nullptr));
static uint32_t s_lastSec = 0;
static uint32_t s_perSec = 0;
if (nowSec == s_lastSec)
++s_perSec;
else {
s_lastSec = nowSec;
s_perSec = 0;
}
return static_cast<int>((s_perSec << 23) | (nowSec & 0x7FFFFFu));
}
// Modern-shaped castGUID:
// `Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>`.
// Vanilla can't know server/instance/zone, so those three fields are 0. `type`
// is 3 for real casts (active abilities, channels — the common case) and 2 for
// local-only FAILED casts, per the spec's type table. The load-bearing parts
// are the spellID (field 6, which addons `strsplit("-")` out) and the unique
// per-cast castUID (field 7). Same (type, spellID, castUID) for every event of
// one cast → identical string, so START pairs with STOP.
void BuildCastGuid(char *out, size_t n, int type, int spellID, int castUID) {
std::snprintf(out, n, "Cast-%d-0-0-0-%d-%010X", type, spellID,
static_cast<unsigned>(castUID));
}
// Fire a standard-shape event `(unitTarget, castGUID, spellID, spellName,
// rank)`. Gated on a listener so an unwatched event does no DBC lookups or
// string formatting. A null name/rank pushes `nil` through the dispatcher's
// `lua_pushstring(NULL) → pushnil` tail-jump, which is fine.
void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
const int slot = Event::Custom::Lookup(eventName);
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
char castGuid[48];
BuildCastGuid(castGuid, sizeof(castGuid), type, spellID, guidNum);
Event::Custom::Fire(slot, "%s%s%d%s%s", "player", castGuid, spellID,
LocalizedField(rec, OFF_NAME),
LocalizedField(rec, OFF_RANK));
}
// SENT is the one event whose shape differs from the rest, matching modern:
// `(unitTarget, target, castGUID, spellID, spellName, rank)` — the target is
// inserted at arg2. `target` is a unit token (e.g. "target") resolved from
// the cast packet, or "" for self / no-target casts.
void FireSent(int spellID, int guidNum, const char *target) {
const int slot = Event::Custom::Lookup(kSent);
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
char castGuid[48];
BuildCastGuid(castGuid, sizeof(castGuid), /*type=*/3, spellID, guidNum);
Event::Custom::Fire(slot, "%s%s%s%d%s%s", "player", target ? target : "",
castGuid, spellID, LocalizedField(rec, OFF_NAME),
LocalizedField(rec, OFF_RANK));
}
// Fire a RETICLE event `(unitTarget, castGUID, spellID, spellName, rank)`.
// There's no cast yet, so the castGUID slot is empty — retail pushes `nil`
// there, but the engine's event dispatcher can't emit a nil mid-format (a
// NULL `%s` corrupts every arg after it — the nil trick only survives as the
// LAST arg), so we push `""` instead. `unit` (arg1) and `spellID` (arg3) —
// the load-bearing fields — are exact; only arg2 differs (`""` vs `nil`),
// which is inconsequential for a reticle (no cast to identify). spellName /
// rank are our usual extension tail.
void FireReticle(const char *eventName, int spellID) {
const int slot = Event::Custom::Lookup(eventName);
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
Event::Custom::Fire(slot, "%s%s%d%s%s", "player", "", spellID,
LocalizedField(rec, OFF_NAME),
LocalizedField(rec, OFF_RANK));
}
// spellID whose reticle we last reported active (0 = no reticle up).
int s_reticleSpell = 0;
// Set when the reticle spell is actually cast (a CMSG_CAST_SPELL for it went
// out) — so the reticle is clearing by PLACEMENT, and RETICLE_CLEAR is
// suppressed (retail fires CLEAR only on a real cancel).
bool s_reticlePlaced = false;
// --- Previous-frame snapshot of the player's cast/channel --------------
// Incrementing integer for Type-2 (local-only FAILED) cast UIDs only. Real
// casts get a time-based UID from `MakeCastUID`.
int g_guidCounter = 0;
int g_castSpell = 0; // 0 = not casting
int g_castStart = 0; // startMs of the tracked cast (detects same-spell recast)
int g_castGuid = 0; // castGUID counter value for the tracked cast
int g_castDelay = 0; // last-seen accumulated pushback (ms)
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
int g_chanSpell = 0;
int g_chanGuid = 0;
// castGUID minted at SENT, pending until the matching downstream event
// (START / SUCCEEDED / FAILED for the same spell) consumes it — so all of
// a cast's events share one guid. 0 = none pending.
int g_pendingGuid = 0;
int g_pendingSpell = 0;
// The castGUID UID for a real (Type-3) cast of `spellID`: reuse the SENT-
// minted pending one when it matches (and consume it), else mint a fresh
// time-based UID. Casts with no observed SENT (some engine-internal paths)
// just get a fresh one.
int NextCastGuid(int spellID) {
if (g_pendingGuid != 0 && g_pendingSpell == spellID) {
const int g = g_pendingGuid;
g_pendingGuid = 0;
g_pendingSpell = 0;
return g;
}
return MakeCastUID();
}
// Last cast that ended (fired STOP) + when, so SpellFailed_h can tell a
// mid-cast interruption (a started cast that's now aborting — the poll owns
// its INTERRUPTED) from a genuine pre-cast failure. The window covers the
// race where the poll's cast-end runs a frame before the engine's
// Spell_C_SpellFailed for the same cancel.
int g_endedSpell = 0;
int g_endedTMs = 0;
int g_endedGuid = 0;
constexpr int kEndWindowMs = 1000;
// SpellCastResult codes (nampower's game::SpellCastResult) that mean the
// cast is being INTERRUPTED rather than failing to start. Movement is the
// common one: while you hold the cast key and keep moving, the client
// retries and hits Spell_C_SpellFailed(MOVING) each time — modern surfaces
// each as a repeated UNIT_SPELLCAST_INTERRUPTED reusing the cast's guid.
constexpr int kSpellFailedInterrupted = 35; // 0x23
constexpr int kSpellFailedInterruptedCombat = 36; // 0x24
constexpr int kSpellFailedMoving = 46; // 0x2E
bool IsInterruptResult(int code) {
return code == kSpellFailedInterrupted ||
code == kSpellFailedInterruptedCombat || code == kSpellFailedMoving;
}
// SpellCastResult codes that modern surfaces as UNIT_SPELLCAST_FAILED_QUIET (a
// suppressed, no-error-text failure) rather than FAILED. This is a fixed
// whitelist, not a "was an error shown" heuristic: verified against the 3.3.5
// client's unit-spellcast event dispatch (FUN_007fecc0 selects the QUIET event
// for exactly CHARMED / DONT_REPORT / SPELL_IN_PROGRESS), then mapped to 1.12's
// SpellCastResult enum (FUN_006e23e0):
// CHARMED (0x14) · DONT_REPORT (0x17) · SPELL_IN_PROGRESS (0x61)
// SPELL_IN_PROGRESS is the common one — casting while already casting, or a
// nampower spell-queue rejection.
constexpr int kSpellFailedCharmed = 20; // 0x14
constexpr int kSpellFailedSpellInProgress = 97; // 0x61
bool IsQuietResult(int code) {
return code == kSpellFailedCharmed ||
code == Offsets::SPELL_FAILED_DONT_REPORT ||
code == kSpellFailedSpellInProgress;
}
// A channel's SUCCEEDED is deferred so it fires AFTER CHANNEL_START, matching
// modern's order (SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP). SPELL_GO
// (OnPlayerSucceeded) lands a poll before the channel is detected, so instead
// of firing SUCCEEDED there it stashes the channel spell here; PollPlayer
// fires it with the channel's guid immediately after CHANNEL_START. The window
// is a loss guard — if no CHANNEL_START ever claims it, the poll fires it
// standalone rather than dropping it.
int g_pendingChanSuccSpell = 0;
int g_pendingChanSuccTMs = 0;
constexpr int kChanSuccDeferMs = 500;
// Spell.dbc AttributesEx channel bits — same test Spell::Cast uses.
constexpr int OFF_ATTRIBUTES_EX = 0x1C;
constexpr uint32_t SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40; // IS_CHANNELED | SELF
bool IsChanneledSpell(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
return rec != nullptr &&
(*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX) &
SPELL_ATTR_EX_CHANNELED) != 0;
}
int NowMs() {
using TickMs_t = uint32_t(__fastcall *)();
return static_cast<int>(reinterpret_cast<TickMs_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))());
}
// ---- UNIT_SPELLCAST_FAILED (client-side cast rejection) ----------------
// Co-hook `Spell_C_SpellFailed` — the local player's own cast failures
// (out of range, no mana, not-ready, LoS, …). Recipe (hook point +
// filters) from nampower's Spell_C_SpellFailedHook. We don't hook the
// SMSG failure PACKET handlers here (Spell::Cast owns those for its cache
// eviction); this is the distinct client-side entry.
using SpellFailed_t = void(__fastcall *)(uint32_t spellId, int result,
int unk1, int unk2, int failedByServer);
SpellFailed_t g_origSpellFailed = nullptr;
void __fastcall SpellFailed_h(uint32_t spellId, int result, int unk1, int unk2,
int failedByServer) {
g_origSpellFailed(spellId, result, unk1, unk2, failedByServer);
if (spellId == kAutoShotSpellId)
return; // autoshot ramp spams client failures — filter (like nampower)
const int sid = static_cast<int>(spellId);
// A "quiet" failure the engine shows NO error text for (already casting,
// charmed, spammy retries, reticle cancels, Unleashed-Potential-style fake
// fails, …). Modern surfaces those as UNIT_SPELLCAST_FAILED_QUIET rather
// than FAILED — see IsQuietResult for the verified 3-code whitelist. Same
// type-2 (local-only) castGUID as FAILED.
if (IsQuietResult(result & 0xFF)) {
Fire(kFailedQuiet, sid, ++g_guidCounter, /*type=*/2);
return;
}
const int now = NowMs();
const bool recentCast =
g_endedSpell == sid && now - g_endedTMs < kEndWindowMs;
// Interrupt-class result (moving / kicked): this is the cast being
// INTERRUPTED, not failing to start. Modern re-fires INTERRUPTED for
// each such retry while you hold the cast key + keep moving, all reusing
// the interrupted cast's castGUID. Match that: fire INTERRUPTED with the
// in-progress guid, or the just-ended cast's guid, or a fresh one.
if (IsInterruptResult(result & 0xFF)) {
int guid = g_castSpell == sid ? g_castGuid
: recentCast ? g_endedGuid
: NextCastGuid(sid);
Fire(kInterrupted, sid, guid);
return;
}
// Non-interrupt failure. If it's for a cast that was started (in
// progress, or just ended), that cast's end is already reported by the
// poll's INTERRUPTED — don't also fire FAILED. Only a failure with NO
// started cast is a genuine pre-cast rejection (out of range, no mana,
// on cooldown) → UNIT_SPELLCAST_FAILED. This is the spec's Type 2: a
// local-only cast that never reached the server, so it gets a plain
// incrementing-integer UID (not the time-based real-cast UID).
if (g_castSpell == sid || recentCast)
return;
Fire(kFailed, sid, ++g_guidCounter, /*type=*/2);
}
const Game::HookAutoRegister _failedHook{
Offsets::FUN_SPELL_C_SPELL_FAILED,
reinterpret_cast<void *>(&SpellFailed_h),
reinterpret_cast<void **>(&g_origSpellFailed)};
// ---- UNIT_SPELLCAST_SENT (CMSG_CAST_SPELL leaving the client) ----------
// Watch outgoing sends via the shared observer (no funnel hook of our own).
// This is the earliest point in a cast's life: mint the castGUID here and
// stash it pending so the matching START / SUCCEEDED / FAILED reuse it.
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
return;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
if (spellID == 0)
return;
// If this is the spell whose reticle is up, the player just PLACED it —
// the reticle will clear by placement, not cancel, so suppress its
// RETICLE_CLEAR (retail fires CLEAR only on a real cancel).
if (s_reticleSpell != 0 && spellID == s_reticleSpell)
s_reticlePlaced = true;
// CMSG_CAST_SPELL body after spellId is SpellCastTargets: targetMask
// (u16), then a packed unit GUID when the UNIT flag is set (self /
// no-target casts carry none). Resolve that GUID to a unit token for
// SENT's `target` arg. Layout confirmed against the server's
// SpellCastTargets::read (tortoise-wow).
char target[64];
target[0] = '\0';
const uint16_t mask = Net::Read<uint16_t>(packet);
if (mask & kTargetFlagUnit) {
const uint64_t targetGuid = Net::ReadPackedGuid(packet);
if (targetGuid != 0)
Unit::Identity::TokenFromGUID(targetGuid, target, sizeof(target));
}
const int guid = MakeCastUID(); // real cast (Type 3) → time-based UID
g_pendingGuid = guid;
g_pendingSpell = spellID;
FireSent(spellID, guid, target);
}
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// ---- Remote (non-player) unit cast events ------------------------------
// Fire `eventName` for EVERY unit token currently mapping to `casterGuid`.
// The listener gate + DBC name/rank lookup + castGUID build happen once, then
// the payload is fanned out per token (`target` / `focus` / `nameplateN` /
// `party` / `raid` / `mouseover`). Same `(unit, castGUID, spellID, spellName,
// rank)` shape as the player events, just with a non-"player" unit.
void FireRemote(const char *eventName, uint64_t casterGuid, int spellID,
int guidNum) {
const int slot = Event::Custom::Lookup(eventName);
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return;
char tokens[16][32];
const int n = Unit::Identity::TokensForGUID(casterGuid, tokens, 16);
if (n == 0)
return;
char castGuid[48];
BuildCastGuid(castGuid, sizeof(castGuid), /*type=*/3, spellID, guidNum);
const char *name = LocalizedField(rec, OFF_NAME);
const char *rank = LocalizedField(rec, OFF_RANK);
for (int i = 0; i < n; ++i)
Event::Custom::Fire(slot, "%s%s%d%s%s", tokens[i], castGuid, spellID,
name, rank);
}
// One tracked cast per remote caster (a unit can't cast two at once), fired
// from the poll so ordering (START -> SUCCEEDED -> STOP) is centralized.
struct RemoteEvt {
uint64_t guid;
int spellID;
int guidNum;
int endMs;
bool isChannel;
bool pendingStart; // START/CHANNEL_START not yet fired
bool startFired;
bool succeeded; // SPELL_GO seen -> completion, not interrupt
bool succeededFired;
bool aborted; // failure packet / ClearCastingSpell seen
bool active;
};
constexpr int kRemoteEvtSlots = 64;
RemoteEvt g_remoteEvt[kRemoteEvtSlots];
RemoteEvt *FindRemoteEvt(uint64_t guid) {
for (auto &e : g_remoteEvt)
if (e.active && e.guid == guid)
return &e;
return nullptr;
}
RemoteEvt *AllocRemoteEvt(uint64_t guid) {
if (RemoteEvt *existing = FindRemoteEvt(guid))
return existing; // replace the caster's prior cast in place
for (auto &e : g_remoteEvt)
if (!e.active)
return &e;
return &g_remoteEvt[0]; // pool exhausted (64 casters) — reuse slot 0
}
} // namespace
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
int channelSpellID, int channelStartMs) {
// ---- Regular cast -------------------------------------------------
// A "new cast" is a different spell OR the same spell re-stamped at a
// new start time (a chained same-spell recast — the engine bails
// Spell_C_CastSpell on `spellID == current`, so only the fresh startMs
// distinguishes it). Fire STOP for the outgoing cast first, then START.
const bool newCast = castSpellID != 0 &&
(castSpellID != g_castSpell || castStartMs != g_castStart);
if (g_castSpell != 0 && (castSpellID == 0 || newCast)) {
// A cast that ended without a SPELL_GO for it was interrupted /
// cancelled — fire INTERRUPTED before STOP. By this tick, a natural
// completion's SPELL_GO (and thus OnPlayerSucceeded) has already run
// a frame earlier, so the flag reliably tells the two apart even
// though the engine routes both through the same abort choke point.
if (!g_castSucceeded)
Fire(kInterrupted, g_castSpell, g_castGuid);
Fire(kStop, g_castSpell, g_castGuid);
g_endedSpell = g_castSpell; // for SpellFailed_h's interrupt-vs-fail gate
g_endedGuid = g_castGuid; // repeated movement-interrupts reuse it
g_endedTMs = NowMs();
g_castSpell = 0;
}
if (newCast) {
g_castGuid = NextCastGuid(castSpellID);
g_castSpell = castSpellID;
g_castStart = castStartMs;
g_castDelay = castDelayMs;
g_castSucceeded = false;
Fire(kStart, castSpellID, g_castGuid);
} else if (g_castSpell != 0 && castDelayMs > g_castDelay) {
// Pushback grew (SMSG_SPELL_DELAYED extended the cast).
g_castDelay = castDelayMs;
Fire(kDelayed, g_castSpell, g_castGuid);
}
// ---- Channel ------------------------------------------------------
// Detect a new channel by spellID ONLY (not startMs): a channel is
// stamped twice at start — SMSG_SPELL_START, then MSG_CHANNEL_START
// re-stamps with the server duration — which changes startMs but is the
// SAME channel. A startMs check would spuriously fire STOP+START on the
// re-stamp. A genuine re-channel of the same spell passes through
// g_chanSpell==0 (CHANNEL_STOP) first, so spellID alone still catches it.
(void)channelStartMs;
const bool newChan = channelSpellID != 0 && channelSpellID != g_chanSpell;
if (g_chanSpell != 0 && (channelSpellID == 0 || newChan)) {
// Channels only ever fire CHANNEL_STOP — never INTERRUPTED — whether
// they end naturally or are cut short (retail behavior, verified).
Fire(kChannelStop, g_chanSpell, g_chanGuid);
g_chanSpell = 0;
}
if (newChan) {
// The initiating cast's SENT guid is still pending — a channel's
// SUCCEEDED is deferred (OnPlayerSucceeded), so it hasn't consumed the
// guid yet. NextCastGuid reuses it, keeping SENT / CHANNEL_START /
// SUCCEEDED / CHANNEL_STOP on one castGUID.
g_chanGuid = NextCastGuid(channelSpellID);
g_chanSpell = channelSpellID;
Fire(kChannelStart, channelSpellID, g_chanGuid);
// Modern fires SUCCEEDED right after CHANNEL_START. SPELL_GO deferred
// it to here so it lands after START, sharing the channel's guid.
if (g_pendingChanSuccSpell == channelSpellID) {
Fire(kSucceeded, channelSpellID, g_chanGuid);
g_pendingChanSuccSpell = 0;
}
}
// Loss guard: a deferred channel SUCCEEDED that no CHANNEL_START claimed
// within the window (the channel never stamped) fires standalone rather
// than being dropped.
if (g_pendingChanSuccSpell != 0 &&
NowMs() - g_pendingChanSuccTMs >= kChanSuccDeferMs) {
Fire(kSucceeded, g_pendingChanSuccSpell,
NextCastGuid(g_pendingChanSuccSpell));
g_pendingChanSuccSpell = 0;
}
}
void OnPlayerSucceeded(int spellID) {
if (spellID == 0)
return;
// Channels fire SUCCEEDED *after* CHANNEL_START (modern order). This
// SPELL_GO lands a poll before the channel is detected, so defer: stash
// the spell and let PollPlayer fire SUCCEEDED right after it fires
// CHANNEL_START, sharing the channel's guid.
if (IsChanneledSpell(spellID)) {
g_pendingChanSuccSpell = spellID;
g_pendingChanSuccTMs = NowMs();
return;
}
// Non-channel: fire now. Pair with the tracked cast guid when it's the
// same spell (cast-time — START already minted the guid); otherwise an
// instant with no tracked cast, so mint a fresh one (pairs with its SENT).
int guid;
if (g_castSpell == spellID) {
guid = g_castGuid;
g_castSucceeded = true; // marks this cast a completion, not an interrupt
} else {
guid = NextCastGuid(spellID); // instant — pairs with its SENT
}
Fire(kSucceeded, spellID, guid);
}
void PollReticle() {
const bool active =
*reinterpret_cast<const int *>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0;
if (active) {
if (s_reticleSpell == 0) {
// Reticle just came up — report the pending spell. Read the spellID
// now (it's set by Spell_C_CastSpell before targeting begins); if
// it's transiently 0 this frame, retry next tick.
const int spellID =
*reinterpret_cast<const int *>(Offsets::VAR_PENDING_CAST_SPELL);
if (spellID != 0) {
s_reticleSpell = spellID;
s_reticlePlaced = false;
FireReticle(kReticleTarget, spellID);
}
}
} else if (s_reticleSpell != 0) {
// Reticle cleared. Fire CLEAR only if the spell wasn't cast — i.e. the
// player CANCELLED (Esc / right-click). A placement (CMSG_CAST_SPELL
// went out, s_reticlePlaced set by OnSend) clears the reticle too but
// is not a CLEAR event in retail.
if (!s_reticlePlaced)
FireReticle(kReticleClear, s_reticleSpell);
s_reticleSpell = 0;
s_reticlePlaced = false;
}
}
void OnPlayerChannelUpdate(int spellID) {
// Only the tracked, already-started channel gets an UPDATE, reusing its
// guid so CHANNEL_START / UPDATE / STOP share one castGUID. A pushback
// arrives seconds into a channel — long after the poll fired
// CHANNEL_START — so g_chanSpell / g_chanGuid are set by now; if somehow
// not, skip (an UPDATE never precedes its START).
if (spellID == 0 || g_chanSpell != spellID)
return;
Fire(kChannelUpdate, spellID, g_chanGuid);
}
// ---- Remote unit entry points ------------------------------------------
void OnRemoteCastStart(uint64_t casterGuid, int spellID, int endMs,
bool isChannel) {
if (casterGuid == 0 || spellID == 0)
return;
RemoteEvt *e = AllocRemoteEvt(casterGuid);
*e = RemoteEvt{};
e->guid = casterGuid;
e->spellID = spellID;
e->guidNum = MakeCastUID();
e->endMs = endMs;
e->isChannel = isChannel;
e->pendingStart = true;
e->active = true;
}
void OnRemoteSucceeded(uint64_t casterGuid, int spellID) {
if (casterGuid == 0 || spellID == 0)
return;
RemoteEvt *e = FindRemoteEvt(casterGuid);
if (e != nullptr && e->spellID == spellID) {
e->succeeded = true; // poll fires SUCCEEDED (then STOP) in order
return;
}
// No tracked cast — an instant (no SMSG_SPELL_START). Nothing precedes it,
// so fire SUCCEEDED now with a fresh (server-time-based) remote UID.
FireRemote(kSucceeded, casterGuid, spellID, MakeCastUID());
}
bool CurrentCastGuid(uint64_t casterGuid, int spellID, char *out,
size_t outSize) {
if (casterGuid == 0 || spellID == 0 || out == nullptr)
return false;
int uid;
if (casterGuid == Unit::Identity::PlayerGuid()) {
// Player's own cast — the guid PollPlayer minted at START (threaded
// from SENT). Channels are excluded (g_chanGuid) — UnitCastingInfo is
// cast-only.
if (g_castSpell != spellID)
return false;
uid = g_castGuid;
} else {
// Remote caster — its live event entry (a channel entry is skipped).
const RemoteEvt *e = FindRemoteEvt(casterGuid);
if (e == nullptr || e->spellID != spellID || e->isChannel)
return false;
uid = e->guidNum;
}
BuildCastGuid(out, outSize, /*type=*/3, spellID, uid);
return true;
}
void OnRemoteAborted(uint64_t casterGuid, int spellID) {
if (casterGuid == 0 || spellID == 0)
return;
RemoteEvt *e = FindRemoteEvt(casterGuid);
if (e != nullptr && e->spellID == spellID)
e->aborted = true; // poll fires INTERRUPTED + STOP next frame
}
void PollRemote() {
const int now = NowMs();
for (auto &e : g_remoteEvt) {
if (!e.active)
continue;
// 1. START / CHANNEL_START.
if (e.pendingStart) {
FireRemote(e.isChannel ? kChannelStart : kStart, e.guid, e.spellID,
e.guidNum);
e.pendingStart = false;
e.startFired = true;
}
// 2. SUCCEEDED (SPELL_GO seen). A regular cast completes here (STOP
// follows immediately); a channel keeps running to its endMs.
if (e.succeeded && !e.succeededFired) {
FireRemote(kSucceeded, e.guid, e.spellID, e.guidNum);
e.succeededFired = true;
if (!e.isChannel) {
FireRemote(kStop, e.guid, e.spellID, e.guidNum);
e.active = false;
continue;
}
// A channel's SPELL_GO (which just set `succeeded`) also drives the
// engine's cast→channel `ClearCastingSpell` in the same packet
// batch — our abort hook sees that. It's the channel STARTING, not
// an interrupt, so drop the same-batch abort; otherwise the channel
// is killed at its own start. A genuine interrupt lands on a later
// poll, when `succeededFired` is already set, so it's still honored.
e.aborted = false;
}
// 3. Abort (kick / LoS / death / movement). A cut-short CAST fires
// INTERRUPTED then STOP; a CHANNEL fires only CHANNEL_STOP (retail
// never emits INTERRUPTED for channels, ended early or not — so the
// caster and observer match).
if (e.aborted) {
if (e.startFired && !e.isChannel)
FireRemote(kInterrupted, e.guid, e.spellID, e.guidNum);
FireRemote(e.isChannel ? kChannelStop : kStop, e.guid, e.spellID,
e.guidNum);
e.active = false;
continue;
}
// 4. Natural end at the computed time — the STOP for a channel, and
// the backstop for a cast whose SPELL_GO we never saw.
if (e.endMs != 0 && now - e.endMs >= 0) {
FireRemote(e.isChannel ? kChannelStop : kStop, e.guid, e.spellID,
e.guidNum);
e.active = false;
}
}
}
} // namespace Spell::CastEvents
+136
View File
@@ -0,0 +1,136 @@
// 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.
#pragma once
// Backport of the TBC+ `UNIT_SPELLCAST_*` events to 1.12, for the local
// player. The underlying cast/channel detection already exists in
// `Spell::Cast` (it hooks every relevant path for `UnitCastingInfo` /
// `UnitChannelInfo`); this module turns those state transitions into the
// retail-named Lua events so ported cast-bar / rotation addons work.
//
// MinHook allows one hook per target and `Spell::Cast` already owns the
// cast-related hooks, so this module doesn't hook anything — the hook
// owners call into it. Cast start/stop/channel/pushback are derived by
// polling `Spell::Cast`'s player state once per frame (`PollPlayer`,
// called from that module's `OnWorldTick`); succeeded/interrupted come
// from the SPELL_GO / abort paths in later slices.
//
// Event contract (arg1 is always the unit token):
// (unitTarget, castGUID, spellID, spellName, rank)
// The first three match the modern Classic-Era signature, so addons
// written against it read `unit, castGUID, spellID` and work; `spellName`
// / `rank` are ClassicAPI extension tail args. `castGUID` is a synthesized
// unique-per-cast string ("Cast-<n>") — not retail's GUID format, just
// stable across a cast's events so START can be paired with STOP.
//
// Every fire is gated on `Event::Custom::HasListeners`, so when no addon
// registers for these the module costs one pointer-compare per transition
// and does no arg synthesis.
#include <cstddef>
#include <cstdint>
namespace Spell::CastEvents {
// Derive player cast/channel events from a once-per-frame snapshot of
// `Spell::Cast`'s player state. Called from `Spell::Cast::OnWorldTick`
// after its own upkeep, passing the live `g_cast` / `g_channel` fields.
// Detects, against the previous snapshot:
// - regular cast start / stop / same-spell recast → UNIT_SPELLCAST_START / _STOP
// - accumulated pushback growth → UNIT_SPELLCAST_DELAYED
// - channel start / stop → UNIT_SPELLCAST_CHANNEL_START / _STOP
// Channels never fire INTERRUPTED — retail emits only CHANNEL_STOP for a
// channel whether it ends naturally or is cut short.
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
int channelSpellID, int channelStartMs);
// Derive the ground-target reticle events from the engine's targeting flags.
// Called from `Spell::Cast::OnWorldTick`. On the reticle appearing for a
// ground-targeted spell (Blizzard, Flare, …) fires UNIT_SPELLCAST_RETICLE_TARGET;
// on it clearing (spell placed or cancelled) fires UNIT_SPELLCAST_RETICLE_CLEAR.
// Both are `(unitTarget, nil, spellID, spellName, rank)` — always the player.
void PollReticle();
// Fire `UNIT_SPELLCAST_SUCCEEDED` for the local player. Called from the
// SPELL_GO hook (`Aura::Source`) when the caster is the player — that
// packet is "the spell went off," so it fires for instants (which never
// touch the cast state above) as well as completed cast-time / channel
// spells. Reuses the in-flight cast's castGUID when the spellID matches,
// else mints a fresh one (instant cast). For channels it defers to
// `PollPlayer`, which fires SUCCEEDED right after CHANNEL_START so the
// order matches modern (CHANNEL_START → SUCCEEDED).
void OnPlayerSucceeded(int spellID);
// Fire `UNIT_SPELLCAST_CHANNEL_UPDATE` for the local player. Called from the
// MSG_CHANNEL_UPDATE hook (`Spell::Cast`) when a pushback re-anchors the
// player's active channel end. Reuses the tracked channel's castGUID (set by
// CHANNEL_START); no-ops if the channel isn't tracked yet (an UPDATE never
// precedes its START). The end time itself is read via
// `C_Spell.UnitChannelInfo`, matching modern — this event is only the "re-read
// now" trigger.
void OnPlayerChannelUpdate(int spellID);
// Write the castGUID string of `casterGuid`'s current CAST of `spellID` into
// `out` (>= 48 bytes) and return true, or return false if that unit isn't
// tracked as casting this spell. It's the SAME string the cast's events carry
// (`SENT`/`START`/…), so `C_Spell.UnitCastingInfo`'s `castID` return can pull
// it from here and stay consistent with the events. `casterGuid` is the local
// player's GUID for own casts, or the remote caster's GUID. Channels are
// excluded (retail's UnitChannelInfo has no castID).
bool CurrentCastGuid(uint64_t casterGuid, int spellID, char *out, size_t outSize);
// `UNIT_SPELLCAST_SENT` is handled internally — the module subscribes to
// the shared `Net::SendObserver` and fires it when CMSG_CAST_SPELL leaves
// the client (the earliest point in a cast's life). The castGUID minted
// there threads forward: the matching START / SUCCEEDED / FAILED reuse it,
// so all of a cast's events share one guid.
// ---- Phase 2: non-player (remote) units --------------------------------
//
// Remote casts have a narrower data surface than the player's: vanilla only
// broadcasts a caster's cast via SMSG_SPELL_START (+ SPELL_GO at completion)
// and its abort via the failure packets / ClearCastingSpell choke point. So
// remotes get START / STOP / CHANNEL_START / CHANNEL_STOP / SUCCEEDED /
// INTERRUPTED, but NOT SENT / DELAYED / FAILED / CHANNEL_UPDATE (that data
// is caster-only or client-local and never reaches an observer — see the
// remote-unit limitations in CLAUDE.md).
//
// `Spell::Cast` owns the remote-cast packet hooks; it calls these at the
// transition points, and each event is fanned out to EVERY unit token the
// caster GUID currently maps to (target / focus / nameplateN / party / raid /
// mouseover), via `Unit::Identity::TokensForGUID`. Each remote cast gets its
// own synthesized castGUID, shared across its events.
// A remote unit began a cast (SMSG_SPELL_START). `endMs` is the computed end
// (server castTime, or channel duration); `isChannel` selects
// CHANNEL_START/STOP vs START/STOP. Called only for real bars — pure instants
// (no cast time, not a channel) are skipped by the caller. Fired from the
// poll so START lands at frame time in the right order.
void OnRemoteCastStart(uint64_t casterGuid, int spellID, int endMs,
bool isChannel);
// A remote unit's spell went off (SMSG_SPELL_GO). Marks the tracked cast so
// the poll fires SUCCEEDED (then STOP) in order; if there's no tracked cast
// (an instant, which sends no SPELL_START) fires SUCCEEDED immediately with a
// fresh castGUID.
void OnRemoteSucceeded(uint64_t casterGuid, int spellID);
// A remote unit's cast was aborted (failure packet / ClearCastingSpell). The
// poll fires INTERRUPTED + STOP (or CHANNEL_STOP) for it next frame.
void OnRemoteAborted(uint64_t casterGuid, int spellID);
// Per-frame driver for remote cast events — fires START/CHANNEL_START, then
// SUCCEEDED, then STOP/CHANNEL_STOP/INTERRUPTED in modern order as each
// tracked remote cast transitions, and expires casts at their computed end.
// Called from `Spell::Cast::OnWorldTick` after `PollPlayer`.
void PollRemote();
} // namespace Spell::CastEvents
+2 -2
View File
@@ -28,7 +28,7 @@ static constexpr int OFF_SPELL_NAME = 0x1E0; // localized char *[9]
static constexpr int OFF_SPELL_RANK = 0x204; // localized char *[9]
using BuildSpellTooltip_t = void(__thiscall *)(void *thisObj, int spellID, int arg2, int arg3,
int isPet, int arg5, int arg6, int arg7);
int isPet, int showRank, int arg6, int arg7);
void ShowByID(void *L, int spellID) {
if (spellID <= 0)
@@ -38,7 +38,7 @@ void ShowByID(void *L, int spellID) {
return;
auto BuildSpellTooltip =
reinterpret_cast<BuildSpellTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP);
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, 0, 0, 0);
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, /*showRank=*/1, 0, 0);
}
static int __fastcall Script_GameTooltipSetSpellByID(void *L) {
+3 -1
View File
@@ -17,7 +17,9 @@ namespace Spell::Tooltip {
// Resolves the `GameTooltip` self at Lua stack[1] (via the
// engine's `FrameScript_PushObject`/`FrameScript_GetObject` chain)
// and dispatches the engine's spell-tooltip builder for `spellID`.
// and dispatches the engine's spell-tooltip builder for `spellID`,
// with the spell's rank shown in the top-right corner of the title
// line (matching `SetAction`/`SetPetAction`; native `SetSpell` omits it).
// Silent no-op if `spellID <= 0` or stack[1] isn't a frame object.
//
// Used by `GameTooltip:SetSpellByID` directly, and by
+81
View File
@@ -0,0 +1,81 @@
// 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/>.
// `GetTimeCached()` — a frame-stable companion to vanilla `GetTime()`.
//
// 1.12's `GetTime()` is *live*: `Script_GetTime` (`0x00515ea0`) calls the
// OS tick source (`FUN_OS_TICKCOUNT_MS` → `GetTickCount`, or QPC in
// timing-mode 1) on every invocation and scales by 0.001, so two calls in
// the same frame can differ. Retail 4.x changed `GetTime()` to read a
// value sampled once per frame in the main loop, so all same-frame calls
// return the identical timestamp. Rather than change vanilla `GetTime`'s
// long-standing live behavior, we expose the frame-stable value under a
// new name and leave `GetTime()` alone.
//
// `GetTimeCached()` returns seconds on the SAME epoch as `GetTime()` (the
// OS millisecond counter × 0.001), so the two are directly comparable —
// the only difference is that `GetTimeCached()` holds constant for the
// duration of a frame. We refresh the cache from the shared once-per-frame
// `Tick::WorldTick` hook (which fires at the tail of each world frame).
#include "Game.h"
#include "Offsets.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Time::Cached {
namespace {
// OS millisecond counter sampled at the last frame boundary. 0 until the
// first world tick fires (pre-world). Read as uint32 — `GetTickCount`
// wraps at ~24.86 days, matching how the rest of the codebase treats this
// counter (see aura/Data.cpp), and how `Script_GetTime` masks it to 32
// bits before scaling.
uint32_t g_frameMs = 0;
uint32_t SampleTickMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// Tail-of-frame refresh. Tail-of-frame-N ≈ start-of-frame-(N+1) minus the
// inter-frame gap, which is all a frame-stable timestamp needs: every read
// during a frame returns the value stamped at the previous boundary.
void OnWorldTick() {
g_frameMs = SampleTickMs();
}
// `GetTimeCached()` → number (seconds). Frame-stable analog of
// `GetTime()`, same epoch. Falls back to a live sample when no frame has
// ticked yet (pre-world / glue), so it never returns 0.
int __fastcall Script_GetTimeCached(void *L) {
uint32_t ms = g_frameMs;
if (ms == 0)
ms = SampleTickMs();
Game::Lua::PushNumber(L, static_cast<double>(ms) * 0.001);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTimeCached", &Script_GetTimeCached);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
} // namespace
} // namespace Time::Cached
+23 -6
View File
@@ -34,14 +34,23 @@ bool IsPlayerControlled(const uint8_t *unit) {
return (unitFlags & Offsets::UNIT_FLAG_PLAYER_CONTROLLED) != 0;
}
bool IsPlayerObject(const uint8_t *unit) {
if (unit == nullptr)
return false;
return *reinterpret_cast<const int *>(unit + Offsets::OFF_CGOBJECT_TYPE_ID) ==
Offsets::OBJECT_TYPE_PLAYER;
}
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// Reads a PLAYER_FLAGS bit for any player-controlled unit. Path:
// - Resolve unit; gate on `UNIT_FLAG_PLAYER_CONTROLLED` to avoid
// reading the +0xE68 pointer on a creature/NPC (it's uninitialized
// for non-player units).
// Reads a PLAYER_FLAGS bit for a player. Path:
// - Resolve unit; gate on the object actually being a player
// (`OBJECT_TYPE_PLAYER`) so we never read the +0xE68 CGPlayer
// sub-struct on a non-player object. UNIT_FLAG_PLAYER_CONTROLLED is
// insufficient — pets/totems/MC'd creatures set it but have no
// sub-struct there.
// - Read `[unit + 0xE68] + 0x08` — the unit's CGPlayer-side info
// struct, where byte +0x08 is PLAYER_FLAGS. Same struct
// `Script_IsResting` reads at +0x05 bit (RESTING = 0x20) for the
@@ -64,7 +73,10 @@ bool TestPlayerFlag(void *L, uint32_t flagMask) {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (!IsPlayerControlled(unit))
// PLAYER_FLAGS lives in the CGPlayer sub-struct — gate on the object
// actually being a player, not merely player-controlled (pets/totems
// set that flag but have no sub-struct → garbage +0xE68 deref).
if (!IsPlayerObject(unit))
return false;
auto *playerInfo = *reinterpret_cast<const uint8_t *const *>(
@@ -134,7 +146,12 @@ int __fastcall Script_UnitIsFeignDeath(void *L) {
// Returns 0 if the unit isn't player-controlled, isn't a player, or
// hasn't had its guild data synced.
uint32_t ReadGuildKey(const uint8_t *unit) {
if (!IsPlayerControlled(unit))
// The guild key lives in the CGPlayer sub-struct at +0xE68, which
// only exists on real player objects. A player-controlled *creature*
// (pet, totem, MC'd mob) has UNIT_FLAG_PLAYER_CONTROLLED set but no
// sub-struct, so gating on IsPlayerControlled here would deref garbage
// and crash (issue: UnitIsInMyGuild on a nameplate pet/totem).
if (!IsPlayerObject(unit))
return 0;
auto *info = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGPLAYER_INFO);
+22 -5
View File
@@ -16,14 +16,31 @@ namespace Unit::Flags {
// Tests `UNIT_FIELD_FLAGS & UNIT_FLAG_PLAYER_CONTROLLED` on the unit's
// `m_objectFields` descriptor — same predicate
// `Script_UnitPlayerControlled` (`0x00516410`) implements. Required
// gate before dereferencing the CGPlayer-side sub-struct at
// `unit + OFF_CGPLAYER_INFO` (= +0xE68) because that slot is
// uninitialized for non-player CGUnit subclasses (CGCreature_C
// instances) and reading it AVs on the subsequent deref.
// `Script_UnitPlayerControlled` (`0x00516410`) implements. True for
// players AND their pets/totems/guardians/companions/charms (the
// server sets this flag on all of them — see Turtle
// `UnitDefines.h` UNIT_FLAG_PLAYER_CONTROLLED).
//
// NOT a safe gate for reading the CGPlayer sub-struct at
// `unit + OFF_CGPLAYER_INFO` (= +0xE68): a player-controlled *creature*
// (pet/totem/MC'd mob) is a CGCreature_C with no such sub-struct, so
// its +0xE68 slot is garbage-but-non-null and the subsequent deref AVs
// (pfUI issue #34 — `UnitIsInMyGuild` on a nameplate pet). Use
// `IsPlayerObject` for that. This predicate is for minion/ownership
// classification, where the broad flag is what's wanted.
//
// Returns `false` for `nullptr` units and units with no
// descriptor pointer yet (transient pre-spawn / out-of-sync state).
bool IsPlayerControlled(const uint8_t *unit);
// True only for real player objects (`OBJECT_TYPE_PLAYER`), read from
// the base CGObject `m_objectType` field at `[obj + 0x14]`
// (`OFF_CGOBJECT_TYPE_ID`). This is the correct gate before reading the
// CGPlayer sub-struct at `unit + OFF_CGPLAYER_INFO` (guild key,
// PLAYER_FLAGS, visible items, quest list) — that sub-struct exists only
// on player objects, never on player-controlled creatures.
//
// Returns `false` for `nullptr` units.
bool IsPlayerObject(const uint8_t *unit);
} // namespace Unit::Flags
+45
View File
@@ -328,6 +328,51 @@ const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
return nullptr;
}
int TokensForGUID(uint64_t guid, char (*out)[32], int maxTokens) {
if (guid == 0 || out == nullptr || maxTokens <= 0)
return 0;
int n = 0;
// Engine-native tokens via the reverse map. It fills a shared static
// array and returns its base; snapshot each string into `out` before we
// do anything re-entrant. Non-throwing (count 0 pre-world).
using TokensFromGuid_t =
const char *const *(__fastcall *)(const uint64_t *guid, int *outCount);
int nativeCount = 0;
const char *const *natives = reinterpret_cast<TokensFromGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_TOKENS_FROM_GUID))(&guid,
&nativeCount);
if (natives != nullptr) {
for (int i = 0; i < nativeCount && n < maxTokens; ++i) {
const char *tok = natives[i];
if (tok == nullptr)
continue;
// SuperWoW patches this reverse map to ALSO emit the caster's raw
// GUID string ("0x…") as a token (it makes GUID strings valid unit
// tokens). Skip it — the standard tokens are what retail-compat
// addons expect; echoing the GUID we were handed is noise.
if (tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'X'))
continue;
std::snprintf(out[n], 32, "%s", tok);
++n;
}
}
// Synthetic tokens the engine's map can't produce. `focus` resolves via
// our token-resolver hook (returns 0 when unset — never raises).
if (n < maxTokens && GuidForToken("focus") == guid)
std::snprintf(out[n++], 32, "focus");
// Visible nameplates — sparse slot array, so scan and skip gaps.
const int plateCount = NamePlate::Events::GetSlotCount();
for (int i = 1; i <= plateCount && n < maxTokens; ++i) {
if (NamePlate::Events::GetGUIDByIndex(i) == guid)
std::snprintf(out[n++], 32, "nameplate%d", i);
}
return n;
}
static int __fastcall Script_UnitTokenFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitTokenFromGUID(\"unitGUID\")");
+12
View File
@@ -86,6 +86,18 @@ uint64_t GuidForToken(const char *token);
// `buf` should be at least 32 bytes.
const char *TokenFromGUID(uint64_t guid, char *buf, size_t bufSize);
// Enumerate EVERY unit token currently mapping to `guid` (not just the
// first, unlike `TokenFromGUID`). Writes each token string into `out[i]`
// (each row must hold >= 32 bytes) and returns the count, capped at
// `maxTokens`. Engine-native tokens (player/pet/target/party/partypet/raid/
// raidpet/npc/mouseover) come from the engine's reverse map
// (`FUN_UNIT_TOKENS_FROM_GUID`); ClassicAPI's synthetic `focus` /
// `nameplateN` are appended on top. The result is snapshotted into `out`
// (safe to fire re-entrant Lua events while iterating). Returns 0 pre-world
// or when no token maps to `guid`. Used for the per-token `UNIT_SPELLCAST_*`
// remote-unit fan-out.
int TokensForGUID(uint64_t guid, char (*out)[32], int maxTokens);
// The engine's cached player-info record for `guid` — the NameCache data block
// (name @ `OFF_PLAYER_INFO_NAME`, realm, race @ `_RACE`, sex, class @ `_CLASS`),
// or null if the GUID isn't cached. Object-independent: it resolves players who
+115
View File
@@ -0,0 +1,115 @@
// 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/>.
// UPDATE_MOUSEOVER_UNIT loss-fire polyfill.
//
// Vanilla fires `UPDATE_MOUSEOVER_UNIT` only when a mouseover is *gained*
// (the engine's `FUN_SET_MOUSEOVER_UNIT` fires it inline in the
// friendly/hostile-unit tooltip case). When the cursor leaves the unit,
// the engine clears the mouseover GUID but fires nothing — so an addon
// that reacted to `UnitExists("mouseover")` becoming true never gets told
// when it becomes false. Retail fires the event for both gain and loss.
//
// We close the gap by co-hooking the single set/clear chokepoint (it is
// the ONLY writer of the mouseover GUID globals, so every gain / loss /
// change flows through it): resolve whether the mouseover was a *unit*
// before the original, then after, and fire `UPDATE_MOUSEOVER_UNIT` when
// it went from a unit to a non-unit.
//
// The unit-vs-non-unit test matters: the mouseover GUID also holds
// gameobjects (and items), but the engine only fires the *gain* event for
// units — the gameobject/item cases never fire it. So a naive
// "GUID present -> absent" test spuriously fires when you move off a
// gameobject that never fired a gain. Gating on "old was a unit, new
// isn't" fixes that while still matching retail's other cases:
//
// * unit -> nothing : fire (retail fires)
// * unit -> gameobject : fire (retail fires — mouseover unit lost)
// * gameobject -> nothing: no (bug fixed; no gain ever fired)
// * X -> unit : engine already fired inline; we don't (new is
// a unit, so our `!newIsUnit` guard is false —
// no double-fire on gain or unit->unit change)
//
// The event carries no arguments in vanilla (same as retail); handlers
// read `UnitExists("mouseover")` — which is false at fire time on the loss
// path, exactly matching retail semantics.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::Mouseover {
namespace {
// `FUN_SET_MOUSEOVER_UNIT` is `__stdcall(guidLo, guidHi, arg3, arg4)` —
// see Offsets.h. We don't use the args; they pass straight through.
using SetMouseover_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4);
using FireEventNoArgs_t = void(__fastcall *)(int eventID);
// `FUN_OBJECT_RESOLVE_BY_GUID(typeMask, debugName, guidLo, guidHi, prio)`
// — keeps the object only if its type flags intersect `typeMask`, so the
// unit|player mask returns non-null only for a live unit / player / pet /
// MC'd creature (never a gameobject or item). Same primitive
// `Unit::Flags::ResolveUnitOrPlayerByGuid` uses.
using ResolveObjectByGuid_t = void *(__fastcall *)(int typeMask,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
// True iff the current mouseover GUID resolves to a unit (not a
// gameobject / item / nothing).
bool MouseoverIsUnit() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_LO));
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MOUSEOVER_GUID_HI));
if ((lo | hi) == 0)
return false;
constexpr int kUnitOrPlayerMask =
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
auto fn = reinterpret_cast<ResolveObjectByGuid_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return fn(kUnitOrPlayerMask, "UPDATE_MOUSEOVER_UNIT", lo, hi, 0) != nullptr;
}
SetMouseover_t SetMouseover_o = nullptr;
void __stdcall SetMouseover_h(uint32_t guidLo, uint32_t guidHi,
uint32_t arg3, uint32_t arg4) {
const bool hadUnit = MouseoverIsUnit();
SetMouseover_o(guidLo, guidHi, arg3, arg4);
const bool hasUnit = MouseoverIsUnit();
// Only the unit-loss transition is missing from the engine. Any
// ->unit transition (gain / unit->unit change) already fired inline
// in the original, so `!hasUnit` prevents a double-fire there.
if (hadUnit && !hasUnit) {
auto fire = reinterpret_cast<FireEventNoArgs_t>(
static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS));
fire(Offsets::EVENT_UPDATE_MOUSEOVER_UNIT);
}
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_SET_MOUSEOVER_UNIT,
reinterpret_cast<void *>(&SetMouseover_h),
reinterpret_cast<void **>(&SetMouseover_o)};
} // namespace
} // namespace Unit::Mouseover
+87
View File
@@ -0,0 +1,87 @@
// 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/>.
// UnitPosition backport.
//
// `UnitPosition("unit") → posY, posX, posZ, instanceID` — the modern
// (WoD+) world-position accessor, ported to 1.12. The world position of
// any visible unit already lives on its CGObject; we read it through the
// shared `Unit::Position` helper (the object's GetPosition virtual, vtable
// slot 5 — the same path UnitDistanceSquared / UnitInRange use).
//
// Two semantic notes:
//
// - **Order matches retail.** The engine's C3Vector is {x, y, z} with the
// standard WoW convention (x = north, y = west, z = up). Retail's
// UnitPosition returns (posY, posX, posZ) — west, north, up — so we push
// the engine's y, then x, then z. An addon ported from retail that reads
// `posY, posX = UnitPosition(u)` gets coordinates labelled exactly as it
// expects.
// - **No group restriction.** Retail returns nil for units outside your
// party/raid (a taint/privacy guard that doesn't exist in 1.12). We can
// read any *visible* unit, so this is strictly more permissive — closer
// to SuperWoW's unit-position access than to retail's restricted form.
// Units the client can't see (out of range, different zone) have no
// known position → nil.
//
// `instanceID` is the currently-loaded Map.dbc id (VAR_CURRENT_MAP_ID) —
// every visible unit shares the player's instance, so the player's map id
// is the unit's instance id.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Unit::PositionApi {
namespace {
// `UnitPosition("unit") → posY, posX, posZ, instanceID`. nil when the unit
// has no known position (recognized-but-absent token, or a visible-object
// miss). Genuinely unrecognized token strings raise a Lua error, same as
// UnitHealth("garbage") — the engine's resolver owns that contract.
int __fastcall Script_UnitPosition(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPosition(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
float pos[3];
if (!Unit::Position::ReadToken(token, pos)) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(pos[1])); // posY (world Y, west)
Game::Lua::PushNumber(L, static_cast<double>(pos[0])); // posX (world X, north)
Game::Lua::PushNumber(L, static_cast<double>(pos[2])); // posZ (up)
const int mapId = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
Game::Lua::PushNumber(L, static_cast<double>(mapId));
return 4;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitPosition", &Script_UnitPosition);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "UnitPosition",
&Script_UnitPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::PositionApi
+180
View File
@@ -0,0 +1,180 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `markN` raid-target-marker unit tokens (`mark1`=star … `mark8`=skull).
//
// Two halves, mirroring `Unit::Focus`:
//
// 1. Resolution — `GetGUIDByMark` reads the engine's marker table
// (`VAR_RAID_TARGET_GUIDS`); the `markN` branch of the token
// resolver in `unit/TokenExtensions.cpp` calls it so every stock
// `Script_Unit*` (UnitHealth, UnitExists, UnitName, …) accepts
// `"markN"` for free.
//
// 2. Events — `Unit::TokenObserver` watches each marked unit's
// descriptor fields so `UNIT_HEALTH` / `UNIT_MANA` / `UNIT_AURA` /
// … fire with `arg1 == "markN"`, exactly like a native token. The
// engine only auto-watches its own tokens (target/party/raid/pet/
// mouseover), so a marked mob that is none of those fires no unit
// events until we observe it.
//
// Why the WorldTick poll (vs. a packet hook): the marker table is written
// by the server (`SMSG_RAID_TARGET_UPDATE`), and — more importantly — a
// `TokenObserver` can only attach to a LIVE `CGUnit`. A unit marked while
// out of range has no object to observe; when it enters range there is no
// per-mark event announcing it, and when it leaves range its observer
// nodes die with the object silently. So each tick we diff the 8 slots
// and (re)attach/detach observers against the current live objects — the
// same range-transition handling `Unit::Focus::OnWorldTick` does, ×8. The
// idle case (no markers set) costs 8 compares and zero object lookups.
//
// A marker on a non-unit GUID (totem corpse / gameobject / loot) simply
// never resolves to a `CGUnit`, so it is never observed and fires no
// events — correct, and matches `UnitExists("markN")` being false there.
#include "unit/RaidTarget.h"
#include "Game.h"
#include "Offsets.h"
#include "tick/WorldTick.h"
#include "unit/TokenObserver.h"
#include <cstdint>
namespace Unit::RaidTarget {
namespace {
// `"mark1".."mark8"` — the token strings fired as `arg1`. Static so the
// pointers stay valid for the `FUN_FIRE_EVENT` "%s" argument.
const char *const kMarkTokens[kMarkCount] = {
"mark1", "mark2", "mark3", "mark4",
"mark5", "mark6", "mark7", "mark8",
};
// GUID we are currently tracking for each marker slot (mirrors the live
// marker table), or 0. Doubles as the callback's stale-fire guard.
uint64_t g_markGUID[kMarkCount] = {};
// Whether a live descriptor observer is currently registered on
// `g_markGUID[i]`. Cleared when that unit leaves the object table so the
// tick re-registers when it returns (nodes die with the object).
bool g_markObserved[kMarkCount] = {};
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
// Resolve a GUID to its live `CGUnit`, or null when it isn't currently in
// the client's object table (out of range / despawned / a non-unit
// marker). Non-throwing — safe from the tick. Mirrors `Focus::ResolveObject`.
const uint8_t *ResolveObject(uint64_t guid) {
if (guid == 0)
return nullptr;
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
return static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "RaidTarget", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0x172));
}
// Descriptor-field observer callback for marker slot `Idx` (0-based).
// Fires the changed field's unit event (`fieldOffset >> 2`) with the
// `"markN"` token, so `arg1 == "markN"` works for `UNIT_HEALTH`,
// `UNIT_MANA`, `UNIT_AURA`, … — exactly like `FocusFieldCb`. Guarded on
// the live tracked GUID so an in-flight field change racing an unregister
// can't fire a stale token.
template <int Idx>
int __fastcall MarkFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
const uint64_t g = g_markGUID[Idx];
if (guidLo != static_cast<uint32_t>(g) || guidHi != static_cast<uint32_t>(g >> 32))
return 1;
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", kMarkTokens[Idx]);
return 1;
}
// One distinct callback per slot — `TokenObserver` keys observer nodes on
// the callback pointer, and each fires its own fixed token, so the slots
// must not share a function.
const TokenObserver::FieldCallback g_markCb[kMarkCount] = {
&MarkFieldCb<0>, &MarkFieldCb<1>, &MarkFieldCb<2>, &MarkFieldCb<3>,
&MarkFieldCb<4>, &MarkFieldCb<5>, &MarkFieldCb<6>, &MarkFieldCb<7>,
};
const uint64_t *MarkerTable() {
return reinterpret_cast<const uint64_t *>(
static_cast<uintptr_t>(Offsets::VAR_RAID_TARGET_GUIDS));
}
// Keep each slot's observer pointed at the unit currently wearing that
// marker. Per slot: adopt a changed/cleared assignment (dropping the old
// observer), then (re)register on the live object or note a despawn. The
// heavy path (ResolveObject) runs only for slots that actually hold a
// marker, so an unmarked table is 8 cheap compares.
void OnWorldTick() {
const uint64_t *table = MarkerTable();
for (int i = 0; i < kMarkCount; ++i) {
const uint64_t want = table[i];
if (want != g_markGUID[i]) {
// Marker moved to a different GUID (or was cleared): stop
// watching the old unit, adopt the new assignment.
if (g_markObserved[i]) {
TokenObserver::Unregister(g_markGUID[i], g_markCb[i]);
g_markObserved[i] = false;
}
g_markGUID[i] = want;
}
if (g_markGUID[i] != 0) {
const bool live = ResolveObject(g_markGUID[i]) != nullptr;
if (live && !g_markObserved[i]) {
TokenObserver::Register(g_markGUID[i], g_markCb[i]);
g_markObserved[i] = true;
} else if (!live && g_markObserved[i]) {
// Marked unit left the object table (out of range /
// despawn); its observer nodes died with it. Re-register
// when it returns.
g_markObserved[i] = false;
}
}
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Reset tracking on logout→character-select. The DLL isn't reloaded
// across a relog, so stale per-slot GUIDs from character A must not leak
// into B. Observer nodes already died with the world teardown, and the
// tick re-reads the (fresh) marker table anyway, so we only clear our
// mirror. Same rationale as `Focus::ClearOnLogout`; glue hook so it fires
// on logout but NOT on /reload.
void ClearOnLogout() {
for (int i = 0; i < kMarkCount; ++i) {
g_markGUID[i] = 0;
g_markObserved[i] = false;
}
}
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
} // namespace
uint64_t GetGUIDByMark(int mark) {
if (mark < 1 || mark > kMarkCount)
return 0;
return MarkerTable()[mark - 1];
}
} // namespace Unit::RaidTarget
+31
View File
@@ -0,0 +1,31 @@
// 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>
namespace Unit::RaidTarget {
// Number of raid-target markers (star … skull), i.e. the valid `markN`
// range is `1..kMarkCount`.
constexpr int kMarkCount = 8;
// GUID currently wearing raid marker `mark` (`1..kMarkCount`), or `0`
// when that marker is unassigned / `mark` is out of range. Reads the
// engine's marker table directly (`VAR_RAID_TARGET_GUIDS`). Consumed by
// the `markN` branch of the token resolver in `unit/TokenExtensions.cpp`,
// mirroring `NamePlate::Events::GetGUIDByIndex` / `Unit::Focus::Get`.
uint64_t GetGUIDByMark(int mark);
} // namespace Unit::RaidTarget
+43
View File
@@ -31,6 +31,22 @@ namespace {
using CancelAuraSend_t = void(__fastcall *)(int spellID);
// `FUN_UNIT_IS_OUTDOORS(unit) -> uint`, nonzero = outdoors (see Offsets.h).
// The thunk tail-jumps into the WMO query, so eax carries the result even
// though a decompiler renders the thunk itself as `void`.
using IsOutdoorsQuery_t = uint32_t(__fastcall *)(const void *unit);
// Tri-state indoor/outdoor read for the local player: 1 = outdoors,
// 0 = indoors, -1 = unknown (no resolvable player object, e.g. pre-world).
int PlayerOutdoorState() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return -1;
auto fn = reinterpret_cast<IsOutdoorsQuery_t>(
static_cast<uintptr_t>(Offsets::FUN_UNIT_IS_OUTDOORS));
return fn(player) != 0 ? 1 : 0;
}
uint32_t PlayerMovementFlags() {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
@@ -200,10 +216,37 @@ int __fastcall Script_IsAssistingRitual(void *L) {
return 1;
}
// `IsIndoors()` — 1 iff the local player is under a WMO roof (inside a
// building / cave / instance interior), else nil. Recomputed live from the
// engine's WMO geometry query, so it flips as you cross an interior
// threshold — not tied to the zone/area. nil before the player object
// exists (pre-world). Vanilla 1.12 ships no binding for this.
//
int __fastcall Script_IsIndoors(void *L) {
if (PlayerOutdoorState() == 0)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
// `IsOutdoors()` — 1 iff the local player is outdoors (open sky, or not
// inside a WMO interior), else nil. Exact complement of `IsIndoors` for a
// resolvable player; both return nil pre-world. See `Script_IsIndoors`.
int __fastcall Script_IsOutdoors(void *L) {
if (PlayerOutdoorState() == 1)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMounted", &Script_IsMounted);
Game::Lua::RegisterGlobalFunction("IsIndoors", &Script_IsIndoors);
Game::Lua::RegisterGlobalFunction("IsOutdoors", &Script_IsOutdoors);
Game::Lua::RegisterGlobalFunction("Dismount", &Script_Dismount);
Game::Lua::RegisterGlobalFunction("IsStealthed", &Script_IsStealthed);
Game::Lua::RegisterGlobalFunction("IsFalling", &Script_IsFalling);
+76
View File
@@ -27,6 +27,18 @@
// See `unit/Focus.cpp` for the storage + `PLAYER_FOCUS_CHANGED`
// event firing.
//
// - `mark1`..`mark8` — raid-target markers (star … skull), resolved
// from the engine's marker table via `Unit::RaidTarget`. That module
// also fires per-`markN` unit events (`UNIT_HEALTH`, …) through the
// same `TokenObserver` path as `focus`/`nameplateN`.
//
// - `0x<hex>` — a raw 64-bit GUID literal (SuperWoW's format:
// `"0x"` + up to 16 hex digits). Lets every `Unit*` accept a GUID
// in place of a token, which is SuperWoW's headline "hard
// dependency" feature. Only enabled when SuperWoW is NOT loaded —
// when it is, its own resolver hook owns GUID input and we defer to
// avoid double-handling. See `GuidTokenEnabled` below.
//
// Both families compose with the engine's own `target`-suffix walker
// (`focustarget`, `nameplate1targettarget`) via the shared
// `WalkSuffix` helper, which mirrors the engine's
@@ -42,13 +54,40 @@
#include "Offsets.h"
#include "nameplate/Walk.h"
#include "unit/Focus.h"
#include "unit/RaidTarget.h"
#include <cstdint>
#include <windows.h>
namespace Unit::TokenExtensions {
namespace {
// SuperWoW (loaded by VanillaFixes from `dlls.txt` as `SuperWoWhook.dll`)
// already makes the token resolver accept `"0x..."` GUID literals, so we
// only add it ourselves when SuperWoW is absent — otherwise both would
// parse the same input. VanillaFixes `LoadLibrary`s every listed DLL during
// its injection pass, long before this hook installs at game boot, so the
// module handle is present iff SuperWoW is active. Cached — module presence
// is fixed for the process lifetime.
bool GuidTokenEnabled() {
static const bool enabled =
GetModuleHandleA("SuperWoWhook.dll") == nullptr;
return enabled;
}
// Hex nibble, or -1 for a non-hex character. Case-insensitive.
int HexNibble(char c) {
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
// `__stdcall`, not `__cdecl` — `FUN_00064A4C0` ends with `ret 0xc`,
// callee cleans 3-arg stack frame. Calling it through a cdecl typedef
@@ -64,6 +103,8 @@ constexpr const char kNamePlatePrefix[] = "nameplate";
constexpr int kNamePlatePrefixLen = static_cast<int>(sizeof(kNamePlatePrefix) - 1);
constexpr const char kFocusPrefix[] = "focus";
constexpr int kFocusPrefixLen = static_cast<int>(sizeof(kFocusPrefix) - 1);
constexpr const char kMarkPrefix[] = "mark";
constexpr int kMarkPrefixLen = static_cast<int>(sizeof(kMarkPrefix) - 1);
constexpr const char kSuffixTarget[] = "target";
constexpr int kSuffixTargetLen = static_cast<int>(sizeof(kSuffixTarget) - 1);
@@ -110,6 +151,25 @@ uint64_t __fastcall Hook_h(const char *token) {
if (token == nullptr || *token == '\0')
return Original_o(token);
// Raw GUID literal `0x<hex>` (SuperWoW input format). The parsed GUID
// is returned straight to the engine's own back-half of the resolver
// (`FUN_00468460(unit-mask, guid)`), the identical path `"player"`
// takes — so an out-of-range / unloaded GUID resolves to nil exactly
// like any absent unit, no special handling. Feeds the same suffix
// walker as the other families, so `"0x…target"` composes too.
if (GuidTokenEnabled() && token[0] == '0' &&
(token[1] == 'x' || token[1] == 'X')) {
const char *p = token + 2;
uint64_t guid = 0;
int digits = 0;
for (int nibble; (nibble = HexNibble(*p)) >= 0; ++p) {
guid = (guid << 4) | static_cast<uint32_t>(nibble);
++digits;
}
if (digits > 0)
return WalkSuffix(guid, p);
}
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I);
// `focus` / `focustarget*`. The 5-char prefix is unique among
@@ -140,6 +200,22 @@ uint64_t __fastcall Hook_h(const char *token) {
}
}
// `markN` / `mark1target*`. Raid-target markers, one digit `1`..`8`
// after the prefix (`mark1` = star … `mark8` = skull). Anything else
// (`mark`, `mark0`, `mark9`) falls through to the engine. Single
// digit only, so `mark1target` leaves `p` at "target" for the walker.
if (sstrcmpi(token, kMarkPrefix, kMarkPrefixLen) == 0) {
const char *p = token + kMarkPrefixLen;
if (*p >= '1' && *p <= '8') {
const int mark = *p - '0';
++p;
const uint64_t guid = Unit::RaidTarget::GetGUIDByMark(mark);
if (guid == 0)
return 0;
return WalkSuffix(guid, p);
}
}
return Original_o(token);
}