Compare commits

...
33 Commits
Author SHA1 Message Date
Brues 695077b6da refactor(callbacks): lighter EventRegistry dispatch
Modernize the frame-event forwarder: OnEvent now takes its (self, event, ...)
positionally (modern script args, on by default) and forwards straight to
TriggerEvent — dropping the DispatchGameEvent hop and the unpack(arg) repack,
and forwarding the exact arg count instead of a padded arg1..arg9 (which
silently dropped args for events that emit more than nine). A fallback reads
the event/arg globals when SetModernScriptArgs is off, so the registry stays
robust to that switch.

Keep the ordinary {[event]=count} ref-count table rather than storing counts
as frame attributes: SetAttribute is heavier per call (it lowercases the name,
runs the secure-attribute parsing, and fires OnAttributeChanged) for the same
steady-state memory, so a plain table is lighter on the register/unregister
path.
2026-08-31 16:51:17 -05:00
Brues 4d744b6638 feat(interface): INTERFACE_VERSION global constant
Publishes INTERFACE_VERSION (11200 on this client) — the client interface/TOC
version, read from the engine's own accessor (FUN_ADDON_CLIENT_INTERFACE_
VERSION, the same value the addon loader compares each `## Interface:`
against) rather than a literal. A global number re-published every /reload
via ModuleAutoRegister, mirroring CLASSIC_API_VERSION, since it is a fixed
client constant.

Verified in-game: /dump INTERFACE_VERSION -> 11200.
2026-08-31 15:06:01 -05:00
Brues c2a4cbdf27 docs: drop WoW-version and vanilla-gap phrasing
The docs are a modern-API backport onto 1.12, so "added in <patch>" and
"vanilla 1.12 lacks/does X differently" are implied by the doc existing —
they were noise. Swept both across docs/API.md and README.md: dropped the
version-introduction and vanilla-limitation clauses, and where a version note
was the only thing explaining a hardcoded/degenerate return value, kept the
reason without the version. Behavior notes and "ClassicAPI extension"
provenance are unchanged. The large line count is mostly paragraph reflow
after clause removal; no API names, signatures, or code examples changed.
2026-08-31 13:23:09 -05:00
Brues d30ae53fbf feat(editbox): ClearHistory
Empties the up/down input history. The engine's SetHistoryLines internal
(FUN_0077cd90) frees the whole history ring when called with 0, so clear =
free (0) then reallocate at the saved max — dropping every entry and resetting
the write position while keeping the line limit, so the box keeps recording
afterward (matching modern semantics, not "history disabled"). No-op when the
box has no history.

New offsets: OFF_EDITBOX_HISTORY_MAX (+0x388, the ring capacity GetHistoryLines
reads), FUN_EDITBOX_SET_HISTORY_MAX.

Verified in-game: AddHistoryLine + ClearHistory leaves GetHistoryLines
unchanged and the up-arrow history empty; new lines still record.
2026-08-31 11:14:34 -05:00
Brues 8f32955823 feat(editbox): SetHighlightColor / GetHighlightColor
The engine paints the text selection with three Texture regions (start-line
+0x350, middle-block +0x354, end-line +0x358; positioned by FUN_0077d950),
and their vertex color IS the highlight color — there's no single color field.
So SetHighlightColor packs {b,g,r,a} and writes it to all three via the
engine's own generic region color setter (FUN_0077f750, the same one behind
Texture:SetVertexColor / FontString:SetTextColor), and GetHighlightColor reads
the first region back through the matching getter FUN_0077f8c0 (the read half
of Texture:GetVertexColor). The blinking caret is a separate region (+0x3d4),
left untouched.

Colors are 0..1, alpha defaults to 1. New offsets: FUN_REGION_GET_COLOR,
OFF_EDITBOX_HIGHLIGHT_REGION; FUN_FONTSTRING_SET_COLOR relabeled as the
generic region color setter it always was.

Verified in-game: SetHighlightColor(1,0,0) reads back 1 0 0 1 and a selection
paints red.
2026-08-31 10:53:04 -05:00
Brues 2778643f4f feat(editbox): GetUTF8CursorPosition, ClearHighlightText, HasFocus, HasText
Round out the EditBox method backports alongside Set/GetCursorPosition, all
reusing state the engine already maintains:

- GetUTF8CursorPosition — caret as a character index (vs GetCursorPosition's
  byte offset), via the engine's own byte->char counter FUN_0077bc80.
- ClearHighlightText — drop the selection but keep the caret (collapse
  selStart/selEnd onto the cursor + highlight-dirty bit). Vanilla can only
  clear a selection with HighlightText(0,0), which yanks it to offset 0.
- HasFocus — pointer identity against VAR_FOCUSED_EDITBOX (the global the
  engine's SetFocus writes / ClearFocus clears).
- HasText — text byte length (+0x338) is non-zero.

Registered on the EditBox method registry, so only EditBox frames dispatch
them. New offsets: OFF_EDITBOX_TEXT_LENGTH, FUN_EDITBOX_COUNT_CHARS.

The module now covers cursor / selection / focus / text-state, so it moves
from editbox/Cursor.cpp to editbox/Methods.cpp (namespace EditBox::Methods).
Documented in docs/API.md and the README under the Frame widget section.

Verified in-game: SetText+SetFocus reports HasFocus/HasText true and both
cursor getters 5; an empty box reports both predicates false.
2026-08-31 10:38:35 -05:00
Brues 3b60ca4094 docs(readme): drop addon-Lua functions from the DLL call table
The "In-game Lua calls" table lists what the DLL registers in C++, but it
included functions provided by the bundled !!!ClassicAPI addon's Lua:
AuraUtil.* and the secure-template Frame functions (SecureCmdOptionParse,
Register/UnregisterStateDriver, Register/UnregisterAttributeDriver,
Register/UnregisterUnitWatch, UnitWatchRegistered, SecureButton_GetAttribute,
SecureButton_GetUnit). Remove those; keep the C++-backed entries
(SetAttribute/GetAttribute/ClearAttribute, PreClick/PostClick, GetClickFrame).
2026-08-31 10:25:45 -05:00
Brues c20be0cef1 feat(editbox): SetCursorPosition / GetCursorPosition
Vanilla's EditBox registry has 48 methods (HighlightText, Insert, SetText,
...) but no cursor accessor, so modern addons that set text then place the
caret error with "attempt to call method 'SetCursorPosition' (a nil value)"
(reported: TwitchEmotes autocomplete).

The caret already exists in the engine — a byte offset at editbox+0x36c —
with reusable primitives: FUN_0077e360 clamps and sets it (the engine's own
"restore caret" step in the insert path), FUN_0077ccf0 collapses the
selection onto it. Modern SetCursorPosition is byte-based too, so it maps
1:1. New EditBox::Cursor module registers both methods on the EditBox method
registry (0x00CF5378); registering under that context means only EditBox
frames dispatch them, so `this` is always a real CSimpleEditBox. Set path:
clamp+set the caret, collapse the selection, flag the highlight dirty.

Verified in-game: SetText + SetCursorPosition + GetCursorPosition round-trips
and the caret lands at the requested offset.
2026-08-31 10:16:37 -05:00
Brues 1ad6b57202 fix(table): writer-side mark for deliberate nil appends
The v1.12.8 gap heuristic (keep the stored length when it overshoots the
border by exactly one) protected `table.insert(t, nil)` slots (#36) but
broke every 5.1-dialect table that is stale by exactly one — a
pairs-cleared one-element table, or a recycled table whose previous life
was one slot longer. Tablet-2.0's del()/copy() pool produces the latter on
every tooltip refresh, so FuBar/Ace2/Tablet menus and tooltips shifted by
one with a nil hole at slot 1. The two states are byte-identical, and both
consumers reach the heal through table.insert — neither the table nor the
caller can split them.

Only the writer knows which dialect the table speaks, and the write is
observable: co-hook the engine's table.insert (FUN_LUA_TABLE_INSERT =
0x7FB6B0, verified against the table-lib luaL_reg pair in .data and a
decompile matching 5.0.2 tinsert line for line) and record
{table -> stored length} in a weak-keyed registry table whenever the
two-arg form appends a literal nil. Rule 3 now heals every stale table to
the border (the 5.1 answer) and keeps the stored length only when the gap
is one AND the mark still matches. Stale marks die on their own: any later
insert/remove moves the stored length off the recorded value, and the weak
key collects with the table.

Verified in-game with a 21-test suite (directed regressions for
#29/#36/#39, byte-identical twin discrimination, metamethod isolation,
coroutine-side marking, and a seeded differential fuzzer against an
executable model of the contract): 15/21 on the broken build, 21/21 with
the fix, #36's nil-append cases among the passes both times.

New plumbing: LUA_SET_METATABLE (0x6F4020, from LuaCAPI.md) and
Game::Lua::SetMetatable for building the weak mark table from C.

Fixes #39
2026-08-31 01:56:03 -05:00
Brues 993c82cca7 refactor: route unit-token resolution through Game::ResolveUnitToken
Sweep the ~35 modules that each hand-rolled a `ResolveUnitToken_t` typedef
plus `reinterpret_cast<…>(Offsets::FUN_RESOLVE_UNIT_TOKEN)` onto the shared
`Game::ResolveUnitToken` helper. Behavior-identical — it is the same engine
function — and net -87 lines. The typedef now lives in exactly one place
(Game.cpp).

unit/Position.h is a header, so it gains an `#include "Game.h"`; every other
touched file already pulled Game.h in. No offsets, logic, or unrelated
typedefs were changed.
2026-08-30 16:57:04 -05:00
Brues 94e77ef5af feat(unit): UnitCreatedBySpell + consolidate the unit-token resolver
Add `UnitCreatedBySpell(unit)` — the spell that summoned a unit (a totem's
totem-drop spell, a pet/guardian's summon spell), read from the unit's
UNIT_CREATED_BY_SPELL descriptor field (byte +0x230). It is the same field
the engine's unit-title builder consults to pick the Pet/Minion/Guardian/
Creation title, and it is broadcast, so it resolves for any unit in range.
This is the summoning spell, distinct from the (server-only) spell a totem
casts.

Also add a single `Game::ResolveUnitToken(token)` helper that wraps the
engine token->CGUnit* resolver, and route unit/Pet.cpp through it plus the
Game::Read<T> field accessors — removing the hand-rolled ResolveUnitToken_t
typedef and raw reinterpret_casts from this module. The name was already
referenced in Offsets.h and CLAUDE.md but had never been implemented.
2026-08-30 16:43:54 -05:00
Brues 1458d60b51 feat(spell): GetSpellInfo accepts a spell name, link, or "Name(Rank N)"
The shared spell-arg resolver errored on any non-number arg1, so
GetSpellInfo("Fireball") raised instead of resolving. Match retail's
full argument set: arg1 may now be a spellID, a spell name, or a spell
hyperlink.

A name resolves against the player's then pet's spellbook (retail's
scope) and returns the highest rank you know; an unknown name returns
nil rather than raising. A trailing "(Rank N)" subtext pins that exact
rank -- the vanilla SpellName(Rank N) form CastSpellByName uses, with a
space before the paren tolerated. A link's embedded spellID is parsed
directly, so it works for any spell, learned or not.

Because GetSpellLink / IsPassiveSpell / IsHarmfulSpell / IsHelpfulSpell
share this resolver, they gain the same name and link forms.

Verified in-game: "Mind Blast" -> rank 9 (max known); "Mind Blast
(Rank 8)" and "Mind Blast(Rank 8)" -> rank 8; "Mind Blast (Rank 99)"
-> nil.
2026-08-30 13:36:07 -05:00
Brues 8b106ae0d6 fix(addons): define global HookScript for ModernSpellBook
ModernSpellBook's MSB_Spellbook.lua re-hooks its "show all ranks"
checkbox by calling HookScript(frame, "OnClick", handler) as a GLOBAL.
That global never existed on any client -- HookScript has always been
the widget method frame:HookScript, which our DLL provides -- so the
call errors with "attempt to call global 'HookScript' (a nil value)".

Define the global in AddOnCompat, forwarding to the method, gated on the
addon being present so the global namespace stays clean otherwise.
2026-08-30 08:52:01 -05:00
Brues 3c4d4eda5f docs(readme): link the Feature column instead of trailing "See […]"
Move each feature table's docs/API.md link onto the Feature name in
column one and drop the "See [x]." tail from the Effect column. Rows
with no docs section (Inline textures, Tooltip line cap) are unchanged.
2026-08-29 19:20:59 -05:00
brues-code 6aea4a1752 Retail-like /reload: new addons, new files, ## metadata edits, deletions (#37)
* feat(addons): retail-like /reload — new addons and new files load

Two engine limitations kept anything created after boot from loading
until a client restart:

1. File visibility: every relative-path read resolves through a
   loose-file hash index built once at boot (FUN_00646EA0, latched);
   the archive layer's live disk probe is dead code (DAT_00865B44
   ships 1, boot clears it) and writes never register. New files -
   including a new addon's TOC and first-ever SavedVariables - were
   invisible until restart.
2. Registry membership: the addon registry is built once at login;
   new folders are never walked.

Addons::Rescan runs per /reload from the ModuleAutoRegister callback
(before the AddOns.txt re-read, progress count, and load pass) and:

- re-indexes Interface\AddOns + WTF\Account with the engine's own
  dedup-safe boot indexer (FUN_VFS_INDEX_SUBTREE) - new files in
  existing addons then load natively, since the per-addon loader
  re-reads the TOC from disk on every load pass; a newly installed
  addon's first SV save now also survives /reload;
- replays login scan walk #2 verbatim (FUN_ADDON_SCAN_DISK_DIRS with
  the engine's own dir-to-parser callback) so new folders register as
  complete normal entries via the dedup-guarded TOC parser;
- mirrors the two structures the login scan would have filled:
  reverse-LoadWith links (tail-loop grow+append, restricted to pairs
  involving a new entry - the engine's lists have no dedup) and the
  flat GetNumAddOns display array (FUN_0051DA70 phase 2: filter byte
  +0x29, engine grow + qsort comparator).

Append-only by design: no teardown, no eviction, no mutation of
existing entries - the prior hot-reload attempt died re-running the
login-only FUN_ADDON_INIT teardown mid-reload. Still login-only
(no safe mechanism): ## metadata edits of already-registered addons
and folder deletions.

Also centralizes Embedded.cpp's addon-entry field constants into
Offsets.h now that a second module reads them.

* refactor(addons): centralize the registry list walk; record dedup-guard probe

The intrusive linked-list walk (head at [VAR_ADDON_LIST_HEAD], next at
entry + [VAR_ADDON_LIST_CTRL] + 4, low-bit-1/NULL sentinel) was
implemented twice -- a template in Rescan.cpp and an inline loop in
Embedded.cpp's FinalizeEmbeddedEntry. Move it to addons/Registry.h as
Addons::ForEachEntry, with a void- or bool-returning visitor (false
stops early). The early stop is load-bearing for Embedded: re-linking
the found entry to the head invalidates the current next-pointer, so
the walk must not continue past it -- the shared helper makes that
contract explicit.

Also record the TOC-parser dedup-guard verification in Rescan.cpp's
scan-call comment: the guard is the parser's FIRST block (hash lookup +
name compare, early return before the TOC path is even built), and both
the Storm hash (uppercases a-z, folds '/'->'\') and the SStrCmpI compare
are case-insensitive -- so the per-/reload scan replay can never re-read
or mutate a registered entry's ## metadata, and a case-renamed folder
matches its existing entry instead of registering a duplicate.

* feat(addons): /reload re-parses edited ## metadata and drops deleted addons

The TOC parser's dedup guard makes a registered entry's ## metadata
read-once: an edited ## SavedVariables: / ## Dependencies: / any ##
line never re-parsed until re-login, and a deleted folder's entry
lingered in the addon list. Both were documented login-only limits of
the retail-like /reload feature.

Rescan's new step 1.5 closes them with evict + re-register, all engine
machinery:

- Change detection: FNV-1a over the TOC's ## lines only (BOM skipped
  like the parser, line terminators excluded), read through the hooked
  FUN_FILE_READ so flavor selection is hashed exactly as the parser
  reads it. File-reference edits never trigger the evict path -- the
  load pass re-reads those natively. Baselines seed at login and for
  newly registered folders in the same Run(), so an edit is always
  diffed against what was actually parsed.
- Evict: scrub the victim from every other entry's reverse-LoadWith
  list (the one reference the destructor cannot clean -- the loader's
  post-ADDON_LOADED loop dereferences those entries), then call the
  engine's own complete per-entry destructor FUN_ADDON_ENTRY_DESTROY
  (0x005200A0, __stdcall, RET 4; vtable slot 0 of the registry node
  factory at [0x00BE1B60], dispatched per entry by the login teardown).
  Verified by decompile: frees every owned allocation (name, dep/SV
  arrays + buffers, reverse-LoadWith buffer, the ## metadata table) and
  self-unlinks from both the intrusive list (FUN_00520A60, no-ops when
  already unlinked) and the hash bucket chain.
- Re-register: feed the name back to FUN_TOC_PARSER -- the dedup guard
  now misses and the entry rebuilds from the edited TOC exactly like a
  fresh registration. Refreshed entries join the existing
  FixupReverseLoadWith/RebuildDisplayArray fix-ups; the refresh runs
  before the before-snapshot so an address-reusing allocator cannot
  make the added-diff double-count or miss one. Any eviction forces the
  display-array rebuild (it holds the dead entry's freed name pointer
  until then). Victims are collected during the walk and processed
  after it -- the dtor zeroes the link node under the iterator.
- Deletion: an unreadable TOC evicts without re-registering; a folder
  the scan still finds simply re-registers the same reload.

Exempt: !!!ClassicAPI (Addons::Embedded owns its entry) and ## Secure:
/ SMSG-managed entries (the parser cannot restore packet-delivered
state; new OFF_ADDON_ENTRY_SECURE = 0x28, parser write site verified).

Verified in-game: ## X-Test metadata edit picked up across /reload
(GetAddOnMetadata returns the new value), SavedVariables list edit
takes effect, folder deletion drops the entry from GetNumAddOns, folder
restore re-registers it, and a no-change /reload is a pure hash-compare
no-op.

* fix(addons): address review — hash commit timing, evict flag, README row

- README: the /reload row said `##` metadata edits still need a
  re-login — stale from before the metadata-refresh step landed. Now
  matches docs/API.md: edits take effect, deleted folders drop from
  the addon list.
- Rescan: commit g_metaHash only after the re-registration actually
  happened, so a failed resolve/parse retries next /reload instead of
  silently absorbing the edit forever; a victim the parse cannot
  restore is erased (treated as deleted) so a reappearing folder
  re-seeds.
- Rescan: RefreshChangedMetadata returns whether anything was actually
  destroyed, not whether victims were collected — no display-array
  rebuild when every victim failed to resolve.
- Rescan: comment the load-order consequence of evict + re-register
  (the fresh entry appends at the list tail; declared deps still
  force-load first).
2026-08-29 19:16:34 -05:00
Brues 6304a253dd feat(aura): Dark Harvest tick compression; channel-aura pushback restamp
Turtle's Dark Harvest (52550-52552) makes the caster's Affliction dots on
the channel target tick 30% faster - and live preserves each dot's tick
COUNT, so the dot's real remaining time drains at 130% rate during the
overlap and it falls off early with no client-visible aura change (report:
gone with ~3 s still showing; Cursive's community calibration models the
same `remaining -= 0.30 x overlap`). tortoise-wow's recreation only
re-times tick timers and leaves holder duration alone, which does NOT
reproduce the early fall-off - so the compression math mirrors live, while
the affected-set predicate mirrors the server script
(IsDarkHarvestAfflictionPeriodicAura): same-caster warlock-family auras
matching the Corruption/Drain Life/CoA/Drain Soul/Siphon Life/CoD mask
with a periodic damage/leech effect (the aura-type gate is load-bearing:
Turtle's Atrocity shares Corruption's 0x2 bit but is a proc aura - DBC
sweep verified; the trigger mask 0x4000000000 selects exactly the three
DH ranks).

Aura::Source::AddTickCompression: while a caster's trigger-mask aura is
live on a target (its cache entry's lifetime: SpellGo -> OnAuraRemoved
eviction), that caster's matching entries drain an extra pct% of real
time. Anchored math (expiration = anchor - elapsed x pct/100) so frame
pacing can't drift it; an external re-stamp (dot recast, duration mod)
re-anchors from the fresh value. Caster-paired like the server, so an
observed party warlock's DH compresses their dots' timing too. ~1/s
UNIT_AURA nudges for event-driven consumers, final nudge at release.
Registered Turtle-gated in turtle/DarkHarvest.cpp with the percent read
from the rank record's EffectBasePoints[1]+1 (-31 -> 30, fallback 30).

Also: MSG_CHANNEL_UPDATE now re-anchors the cached target-side expiration
of the player's channel-applied auras (Aura::Source::RestampPlayerChannel).
The server shortens every hit target's holder on channel pushback
(Spell::DelayedChannel -> DelaySpellAuraHolder) and only tells the caster
via the new remaining time, so the SpellGo-stamped expiration read late by
the accumulated pushback; self-target holders were already corrected
natively via SMSG_UPDATE_AURA_DURATION. This also bounds the compression
window under pushback for free.
2026-08-28 21:56:02 -05:00
Brues fbe40743c0 fix(table): keep length on a deliberate single nil append
The stale-length heal (Length.cpp) could not tell a cleared-and-reused
table from a deliberate table.insert(t, nil): both leave the stored
length pointing at a nil slot with no explicit n field, but they want
opposite answers. Rule 3 healed both, so the next table.insert on a
single-nil-appended array wrote over the nil and shifted every later
element down by one -- silent corruption in Ace2-era addons (Waterfall's
{key1,val1,...} arg builder inserts a nil whenever an option has no
passValue, the common case), with no error anywhere.

The issue's preferred fix -- make table.insert always use the raw stored
length -- reintroduces #29: Dewdrop clears `values` with a pairs loop +
no-op table.setn, then rebuilds it via table.insert, and that append is
exactly what the heal fixed. Both consumers reach the heal through
table.insert, so the caller cannot distinguish them; only the staleness
gap can. A cleared table is stale by many slots; a deliberate nil append
is off by exactly one. So heal only when the stored length overshoots
the border by more than one slot, and keep it otherwise.

Verified in-game via CAPI_DemoTableLengthHeal (AddOnCompat.lua): nil
append preserved (getn=3, t[2]=nil, t[3]=b), Waterfall pairs intact, and
the cleared-table rebuild still heals to slot 1.

Fixes #36
2026-08-27 15:37:54 -05:00
Brues 772cd5ec15 fix(luasyntax): recover call args for loadstring'd vararg chunks
WoW's Lua 5.0 main chunks are not vararg (unlike stock 5.0), so a chunk
called with args had no `arg` to back the transpiled `...` and the old
`local arg={n=0}` fallback silently dropped them --
loadstring("return ...")(1,2,3) yielded nothing.

Compile non-addon vararg chunks (loadstring, /run, XML handler bodies)
as a real 5.0 vararg closure instead: wrap the body in
`return function(...) ... end` and call the main once after a successful
compile, so the caller receives the inner function whose genuine 5.0
`arg` carries the call args. Helper captures stay in the outer main
(runs once at load in the default env, preserving the setfenv-sandbox
rationale); the prefix is newline-free and the closing `end` rides its
own appended line, so error line numbers are unchanged. Addon file
chunks keep the (addonName, addonTable) preamble path.

Verified in-game: loadstring("print(select('#',...),...)")(1,2,3)
prints "3 1 2 3".
2026-08-26 20:24:15 -05:00
Brues a962c76f09 feat(aura): real isFromPlayerOrPlayerPet; fix castByPlayer to Era semantics
Populate AuraData's isFromPlayerOrPlayerPet from the cached caster GUID's
HIGHGUID prefix (player 0x0000 / pet 0xF140, confirmed in tortoise-wow
ObjectGuid.h) instead of hardcoding false. The UnitAura tuple's
castByPlayer (position 13) is the same value under retail's older name --
Era defines it as "applied by a player, their pet or vehicle", not the
local player -- so both now share one HIGHGUID test in ResolveDisplay.
The local-player question stays with the PLAYER filter, unchanged.

False on a cache miss (cast not observed this session); totem buffs read
false (vanilla totems carry the creature prefix, not pet).
2026-08-26 14:34:35 -05:00
Brues 292e206dee fix(aura): seat descriptor slots on cast captures FIFO, not newest-first
When two casters' same-spellID DoTs land in one descriptor-update batch,
the server assigns their slots ascending in cast order
(_AddSpellAuraHolder's first-free-slot scan, verified in tortoise-wow)
and the client diff dispatcher FUN_00604d00 fires OnAuraAdded in
ascending slot order (both loops are plain slot = 0..0x2F walks). The
first application therefore belongs to the earliest cast, and seating
the NEWEST unbound capture paired the two casters' timers and
attribution backwards.

Seat the oldest unbound capture instead, bounded by a 3s window that
covers instant applications and max-range projectile flight (Fireball's
DoT applies at impact) while skipping hit-but-never-applied captures.
The old "older = resisted" rationale for newest-first was moot: resisted
and immune targets sit in SMSG_SPELL_GO's miss list, which ParseSpellGo
never reads, so they never create captures at all.
2026-08-26 14:19:51 -05:00
Brues eef7140722 feat(combat): add StartAttack / StopAttack globals
Backport the non-toggling melee auto-attack verbs. 1.12 ships only the
toggling AttackTarget (FUN_006131A0), so /startattack can't guarantee it
won't cancel an in-progress swing. These call the engine's own start
(FUN_005ECB70, sends CMSG_ATTACKSWING) and stop (FUN_005ECAC0) primitives
directly, split into the two idempotent verbs.

StartAttack takes an optional unit-token target (resolved under pcall so a
name/bad token can't raise, falling back to the current target); with no
arg it validates and attacks the current target via FUN_00612DF0.
2026-08-26 13:49:44 -05:00
Brues 5279c463ed feat(slash): add /click handler and SLASH_CLICK locale strings
Register SlashCmdList["CLICK"], routing through SecureCmdOptionParse and
GetClickFrame to click a named button. Parse name + button only: 1.12's
Button:Click has no down/up path (verified in the base vmethod), so the
retail `down` arg has nothing to act on and a nil button defaults to
LeftButton natively.

Add SLASH_CLICK1/2 to all locale files from Classic Era GlobalStrings.
2026-08-26 12:28:30 -05:00
Brues 855b338736 feat(frame): add GetClickFrame global
Backport the engine-registered C global behind the /click command: resolve
a frame by its global name, returning it only when _G[name] holds a real
frame whose GetName() matches. Mirrors 3.3.5's Script_GetClickFrame; reads
the name from CFrameScriptObject vtable slot +4, verified against 1.12's
own Script_GetName.
2026-08-26 12:01:33 -05:00
Brues 6ca6af1497 fix(slash): route /focus, /clearfocus, /equipset through SecureCmdOptionParse 2026-08-26 11:30:32 -05:00
Brues 1170a4e63a fix(texture): mask via texgen for SetMask so SetTexCoord sprites clip right
SetMask rode the minimap BLIP recipe (explicit second UV stream, uv1 = uv0),
so the mask sampled at the base's texcoords - a SetTexCoord'd sprite made
unit 1 sample that same sub-rect of the MASK image, showing a corner chunk
of the shape instead of the shape.

Retire that path: the path mask is now one extra unit in the texgen draw
(MaskedRegionDrawImpl), its matrix built from the BASE region's own rect
(SetTexCoordModifiesRect crop gate included) plus the base's SetRotation so
a mask keeps following a rotated texture. The angle-0 matrix is exactly the
old recipe's formula re-expressed through positions (see BuildMaskMatrix),
so default 0..1-texcoord textures render identically; sprites become the
fixed case, matching retail (SetMask ignores SetTexCoord).

Side effect: SetMask and AddMaskTexture now compose (alpha intersection)
instead of object masks winning; the path mask occupies one of the 7 units.
2026-08-25 22:28:21 -05:00
Brues 0c8468cf2f refactor(reload): auto-register per-/reload cleanup; fix Time::Timer stale refs
Modules that keep reload-fragile file-static state (Lua registry refs,
frame/object-pointer-keyed maps, cached event-slot indices) each expose a
PrepareForReload() that DllMain called from the FrameScript_Initialize hook.
That list was hand-maintained, so a new module could silently forget to wire
itself in -- the failure mode behind the Frame::Attributes stale-ref bug and
Tooltip::SetEvents issue #33.

Add Game::ReloadAutoRegister + Game::RunReloadCleanups(), mirroring the
existing ModuleAutoRegister / HookAutoRegister / WorldTick::AutoSubscribe
patterns. Each module self-registers with one file-scope line after its
PrepareForReload:

    static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};

DllMain's 11-call manual list collapses to a single RunReloadCleanups(); a
new module can no longer be forgotten. Player::NameCache::Flush stays an
explicit call -- it's a save-before-teardown, not a stale-state clear, and
also runs on the glue-return path.

A sweep of all file-static state found one uncovered module: Time::Timer
pins C_Timer.After/NewTimer/NewTicker callbacks in the Lua registry but never
released them on /reload -- a surviving/stale ref could fire a dead closure.
Add Time::Timer::PrepareForReload (release each pending timer's registry
callback, clear the queue) and self-register it.

- Game.{h,cpp}: ReloadAutoRegister + RunReloadCleanups.
- DllMain.cpp: collapse the manual list to RunReloadCleanups(); drop 6
  now-dead includes.
- 11 existing reload modules + Time::Timer: add the self-register line.

Behavior-preserving (same cleanups, before the Lua teardown); auto-register
order is unspecified across TUs but every callback only clears its own state.
2026-08-25 18:37:44 -05:00
Brues 5196aab4b8 fix(frame): clear attribute maps on /reload to stop stale-ref SetAttribute error
Frame::Attributes kept two file-static maps keyed by the frame's C object
pointer -- g_attrHandlers (OnAttributeChanged handler refs) and
g_unitByFrame (unit tokens for the mouse-focus poll) -- and never cleared
them across a /reload. The Lua reset kills the handler refs, but the map
entries persist, and the allocator recycles frame addresses. So a new
frame reusing an old address, then getting a SetAttribute call, made
FireAttributeChanged fire a DEAD handler ref through the (protected)
invoker; the engine error handler still printed it as an error "in
function SetAttribute" with no Lua frame and an empty message.

SecureStateDriverManager sets OnAttributeChanged every session, so there
was always at least one entry to go stale -- this was the "SetAttribute
occasionally errors after /reload" report (pfUI UpdateVisibility ->
SetAttribute). Same class of bug as Tooltip::SetEvents issue #33.

Add Frame::Attributes::PrepareForReload() clearing both maps, wired from
FrameScript_Initialize_h alongside the other per-reload cleaners.

Also harden EnableFrameMouse (a best-effort side effect of setting the
"unit" attribute): it did SetTop(L, 1) to set up the engine EnableMouse
call, which destroyed the (name, value) at stack[2]/[3] that
FireAttributeChanged reads right after -- handing any OnAttributeChanged
handler a nil arg2. It now invokes the setter through lua_pcall at the top
of the stack and restores the caller's stack, so DoSet's (self, name,
value) survives, and a best-effort mouse-enable can never surface an error
in the addon's SetAttribute.
2026-08-25 18:22:49 -05:00
Brues 536c5e3c51 feat(frame): add PreClick/PostClick button scripts
Backport the modern PreClick/PostClick button scripts as real,
SetScript/GetScript/HookScript-able handlers. Vanilla 1.12 has only
OnClick and OnDoubleClick on buttons.

Two co-hooks (same technique as Tooltip::SetEvents):
- FUN_BUTTON_SCRIPT_RESOLVER: recognize the two names, returning an
  external per-button {handler, context} cell (a button has no free
  slot, and the resolver contract is just "return where the handler
  lives"). This makes them SetScript/GetScript/HookScript-able.
- FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT (the arg'd script runner), gated on
  slotPtr == frame + OFF_BUTTON_ONCLICK_HANDLER (0x4CC): fire PreClick
  before / PostClick after the OnClick, re-invoking with the same
  (fmt, varargs) so both receive arg1 = the button name like OnClick.

Deliberately NOT the button click vmethod FUN_00779540 -- SuperWoW
inline-hooks it for click-casting and a second MinHook there faults
(ERROR #132). The runner is uncontested and fires at the same top-level
dispatch level, so its exec-context stamp is valid.

Cells are keyed by button pointer and cleared on /reload
(PrepareForReload, wired in FrameScript_Initialize_h), matching the
tooltip-cell lifetime.

Limitation (documented): PreClick/PostClick fire only when the button
also has an OnClick handler, since the runner only runs for a non-empty
slot. Bracketing an existing OnClick is the common case; the no-OnClick
path would need the SuperWoW-owned vmethod.

Verified in-game: programmatic and real mouse clicks fire pre,click,post
with matching button-name args, and GetScript round-trips.

- Add offsets FUN_BUTTON_SCRIPT_RESOLVER, OFF_BUTTON_ONCLICK_HANDLER,
  FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT.
- Document under docs/API.md (Frame section) and the README index.
2026-08-25 17:43:41 -05:00
Brues c06bed9158 feat(unit): add GetSheathState
Vanilla 1.12 ships ToggleSheath but no getter for the player's
drawn-weapon type. Add GetSheathState(), which returns the modern
1-based value: 1 = None (sheathed), 2 = Melee, 3 = Ranged.

The engine stores the sheath state as a plain CGUnit int member at
+0xD40 (0-based: 0 sheathed, 1 melee, 2 ranged) -- the same field its
own toggler FUN_005EB480 reads to pick the next state and its setter
FUN_00611CF0 writes. GetSheathState reads the local player's value and
adds 1 for the Lua contract.

- Add OFF_UNIT_SHEATH_STATE (0xD40) with RE-provenance comment.
- Document under docs/API.md (State section) and the README index.
- Also list the Tracking API in the README function index (missed in
  the prior tracking commit).
2026-08-25 15:53:10 -05:00
Brues e793f80f6b feat(spell): add minimap tracking enumeration
Vanilla 1.12 only stores the single active tracking spell
(VAR_ACTIVE_TRACKING_SPELL, 0xBC6378) and never enumerates the tracking
abilities the player knows. Add GetNumTrackingTypes/GetTrackingInfo/
SetTracking, which rebuild that list by scanning the player spellbook for
spells whose EffectApplyAuraName is Track Creatures/Resources/Stealthed
(44/45/151) -- the same discriminator the engine's own active-tracker
updater (FUN_004E4170) uses. This reproduces pfUI's hardcoded tracking
table with no table to maintain and auto-covers server-custom trackers
(e.g. Turtle's Find Trees, 52917).

- GetTrackingInfo returns name, texture, active, category ("spell"),
  spellID; active is read from VAR_ACTIVE_TRACKING_SPELL.
- SetTracking selects a tracker by tail-calling the engine's
  Script_CastSpell(slot, "spell").
- Add offsets VAR_ACTIVE_TRACKING_SPELL and FUN_SCRIPT_CAST_SPELL
  (0x4B42F0), both with RE-provenance comments.
- Document under docs/API.md (Tracking section).

Verified in-game on a Hunter (8 trackers) and a Dwarf racial (1):
active flips on SetTracking, texture matches native GetTrackingTexture,
out-of-range returns nil.
2026-08-24 23:16:34 -05:00
Brues ded767bf19 feat(aura,turtle): support Turtle's Stinging Nettle serpent sting timers
The hunter talent (403; rank passives 51579/51580) makes Mongoose Bite
and triggered fire traps apply the highest known Serpent Sting rank at
20/40% of its duration. Server-side it's a direct AddAura (tortoise-wow
ApplyStingingNettle) — no SMSG_SPELL_GO for the sting — so the cache
seated it caster-less at the full 15s base.

Aura::Source gains a generalized triggered-application rule table (the
judgement-attribution shape, rule-driven): a registered trigger's
SPELL_GO — local player casts only — arms its hit targets for 2s, and a
caster-less application matching the rule's family/mask consumes the arm
in StampApplication (player attribution + the server's own
max(1, base × pct / 100) with player duration mods). Rules can be gated
on a talent passive, read live from the spell-knowledge bitmap at
trigger time so respecs are honored. Because the server's re-apply over
an existing shorter sting reuses the descriptor slot (no OnAuraAdded —
verified in-game with chained Mongoose Bites), the trigger also
refreshes the player's existing cached entry in place, honoring the
server's keep-the-longer-remaining guard. The arm consume runs BEFORE
the WasRecentPlayerCast attribution: the arm names the exact target,
while the recent-cast window is spell-scoped only — a real sting cast at
unit A must not stamp its full duration onto a nettle application on
unit B; a real cast's own application stays protected by the cast-owned
entry guard.

Turtle::StingingNettle registers the rules behind the Turtle::Detected
latch: fire traps by family mask (hunter 0x4 selects exactly the eight
trap-effect spells the server's script binding lists — rank-proof),
Mongoose Bite by exact IDs (its bit 0x2 is shared with Raptor Strike,
which does not trigger nettle). The percents come from the rank spells'
own EffectBasePoints[0] + 1 — the same DBC value the server's
CalculateSimpleValue reads — with the server's 20/40 fallback.
2026-08-24 22:47:52 -05:00
Brues 70a69b64fd fix(spell): fire UNIT_SPELLCAST_DELAYED when the server reconciles cast time
The server scales ranged abilities' cast times by ranged attack speed
(SpellEntry::GetCastTime's IsRangedSpell branch — quiver/Quick Shots),
which the client's prediction helper FUN_006e3340 can't see (its only
caster factor is the spell-haste mult at descriptor +0x22c). So Steady
and Aimed Shot bars started at the fixed 1.5s/2.5s prediction; the
confirming SMSG_SPELL_START already snapped g_cast.endMs to the server's
authoritative castTime, but nothing told addons — the bar kept the
duration it read at UNIT_SPELLCAST_START.

PollPlayer now snapshots the cast's endMs instead of its delayMs and
fires UNIT_SPELLCAST_DELAYED on any end movement — one signal covering
pushback (previously detected via delayMs growth) and the server
reconcile in either direction. Cast bars re-read UnitCastingInfo on
DELAYED and pick up the corrected end. If the packet ever beats the
first poll, START itself carries the corrected end and no DELAYED fires.

Fixes brues-code/pfUI#43
2026-08-24 21:57:11 -05:00
Brues 0f5b50ea14 feat(aura): refresh paladin judgement timers off swings and re-judges
The judgement debuff's duration is maintained server-side with no
client-visible aura change: the paladin's white swings refresh it
(tortoise-wow Unit::DealMeleeDamage - any nonzero melee damage
RefreshHolder's every SPELLFAMILY_PALADIN + SPELL_ATTR_EX3_ALWAYS_HIT
aura the attacker cast on the victim), and a re-judge is an in-place
holder refresh that dirties no descriptor field. Aura::Source's
countdown therefore ran to zero while the debuff stayed up
(brues-code/pfUI#45).

Two client-side triggers mirror the server exactly:

- Swings: Aura::JudgementRefresh subscribes to SMSG_ATTACKERSTATEUPDATE
  (wire format verified against the server's own SendAttackStateUpdate
  writer) and on totalDamage > 0 calls the caster-STRICT
  Aura::Source::RefreshJudgements(victim, attacker) - strict because a
  swing gives no proof of ownership, and adopting there would let any
  tank's white swings pin another paladin's judgement timer.

- The Judgement cast (20271, the one spell every seal judges through):
  the debuff itself is cast server-side with no SPELL_GO the client
  hears (verified live - entries were seated caster-less by
  OnAuraAdded), so the Judgement cast's own SPELL_GO is what names the
  owner. HandleSpellGo refresh-with-adoption's the victim's existing
  judgement entries (the re-judge case) and arms a 2s attribution
  window that StampApplication consumes to give the freshly applied
  debuff its caster - which is what makes the strict swing matching
  work at all.

Refreshes re-fire UNIT_AURA via the existing deferred signal;
StoreFromCast now signals on its in-place recast path too, since an
identity-preserving refresh changes no descriptor field and
event-driven aura bars would never re-read the new timing.

Ability damage (Crusader/Holy Strike) is deliberately NOT a trigger:
melee-class spell damage never routes through the server's refresh
block (DealMeleeDamage's only caller is the white-swing path).

Verified in-game: every landing swing snaps the cached expiration to
now + 10s and pfUI's timer follows; misses/dodges (totalDamage 0) do
not refresh; re-judging resets the timer.

Fixes brues-code/pfUI#45
2026-08-24 18:58:46 -05:00
86 changed files with 5303 additions and 1956 deletions
+16
View File
@@ -97,3 +97,19 @@ end
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
end
-- ModernSpellBook (Spellbook\MSB_Spellbook.lua) re-hooks its "show all ranks"
-- checkbox by calling HookScript as a GLOBAL -- HookScript(frame, "OnClick",
-- handler). That global never existed on any client: HookScript has always
-- been the widget method frame:HookScript, which our DLL provides. MSB even
-- guards the call with `if checkbox.HookScript` (the method IS present), then
-- calls the global (nil) -> "attempt to call global 'HookScript'". Define the
-- global so the call resolves, forwarding to the method. Gated on the addon
-- being present so the global namespace stays clean otherwise.
if C_AddOns.DoesAddOnExist("ModernSpellBook") and not HookScript then
function HookScript(frame, script, handler)
if frame and frame.HookScript then
frame:HookScript(script, handler)
end
end
end
@@ -1,101 +1,100 @@
-- Backport of 3.3.5 FrameXML/GlobalCallbackRegistry.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - 3.3.5 ref-counts WoW frame events via a SetAttribute/OnAttributeChanged
-- dance because attribute mutations are taint-safe in the secure-template
-- model. 1.12 has neither secure templates nor SetAttribute on frames,
-- so we keep an ordinary {[event]=count} table and Register/Unregister
-- on 0<->1 transitions.
-- Implementation notes:
-- - 3.3.5 ref-counts frame events through a SetAttribute/OnAttributeChanged
-- dance (attribute mutations are taint-safe under secure templates).
-- ClassicAPI backports SetAttribute, but routing counters through it is
-- heavier than a plain table: every set lowercases the name, runs the
-- secure-attribute parsing, and fires OnAttributeChanged. So we keep an
-- ordinary {[event]=count} table and Register/Unregister on 0<->1.
-- - OnEvent takes its (self, event, ...) positionally (modern script args,
-- on by default) and forwards straight to TriggerEvent, falling back to
-- the event/arg globals if that switch is turned off.
EventRegistry = CreateFromMixins(CallbackRegistryMixin)
EventRegistry = CreateFromMixins(CallbackRegistryMixin);
function EventRegistry:OnLoad()
CallbackRegistryMixin.OnLoad(self)
self:SetUndefinedEventsAllowed(true)
CallbackRegistryMixin.OnLoad(self);
self:SetUndefinedEventsAllowed(true);
self.eventCounts = {}
self.frameEventFrame = CreateFrame("Frame")
self.frameEventFrame.registry = self
self.frameEventFrame:SetScript("OnEvent", function()
-- 1.12 globals: `event` is the event name; `arg1..argN` are payload.
-- We don't repack them here; CallbackRegistryMixin:TriggerEvent uses
-- Lua-5.0 varargs, so we forward through a small wrapper.
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
end)
self.eventCounts = {};
self.frameEventFrame = CreateFrame("Frame");
self.frameEventFrame:SetScript("OnEvent", function(frameEventFrame, evt, ...)
if evt ~= nil then
-- Modern positional script args (default): (self, event, payload...),
-- forwarded with the exact arg count.
self:TriggerEvent(evt, ...);
else
-- SetModernScriptArgs is off: read the event/arg globals 1.12 always
-- sets. This path keeps the historical 9-arg cap.
self:TriggerEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9);
end
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
-- event currently emits) into TriggerEvent's vararg machinery.
function EventRegistry:DispatchGameEvent(event, ...)
self:TriggerEvent(event, unpack(arg))
self.logoutFrame = CreateFrame("Frame");
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT");
self.logoutFrame:SetScript("OnEvent", function()
self.frameEventFrame:UnregisterAllEvents();
self.frameEventFrame:SetScript("OnEvent", nil);
end);
end
function EventRegistry:RegisterFrameEvent(frameEvent)
local n = (self.eventCounts[frameEvent] or 0) + 1
self.eventCounts[frameEvent] = n
if n == 1 then
self.frameEventFrame:RegisterEvent(frameEvent)
end
local n = (self.eventCounts[frameEvent] or 0) + 1;
self.eventCounts[frameEvent] = n;
if n == 1 then
self.frameEventFrame:RegisterEvent(frameEvent);
end
end
function EventRegistry:UnregisterFrameEvent(frameEvent)
local n = self.eventCounts[frameEvent] or 0
if n > 0 then
n = n - 1
self.eventCounts[frameEvent] = n
if n == 0 then
self.frameEventFrame:UnregisterEvent(frameEvent)
end
end
local n = self.eventCounts[frameEvent] or 0;
if n > 0 then
n = n - 1;
self.eventCounts[frameEvent] = n;
if n == 0 then
self.frameEventFrame:UnregisterEvent(frameEvent);
end
end
end
function EventRegistry:RegisterFrameEventAndCallback(frameEvent, ...)
self:RegisterFrameEvent(frameEvent)
return self:RegisterCallback(frameEvent, unpack(arg))
self:RegisterFrameEvent(frameEvent);
return self:RegisterCallback(frameEvent, ...);
end
local function CreateCallbackHandle(cbr, cbrHandle, frameEvent)
local handle = {
Unregister = function()
cbr:UnregisterFrameEvent(frameEvent)
cbrHandle:Unregister()
end,
}
return handle
-- Wrapped in a table for future flexibility.
local handle = {
Unregister = function()
cbr:UnregisterFrameEvent(frameEvent);
cbrHandle:Unregister();
end,
};
return handle;
end
function EventRegistry:RegisterFrameEventAndCallbackWithHandle(frameEvent, ...)
self:RegisterFrameEvent(frameEvent)
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, unpack(arg))
return CreateCallbackHandle(self, cbrHandle, frameEvent)
self:RegisterFrameEvent(frameEvent);
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, ...);
return CreateCallbackHandle(self, cbrHandle, frameEvent);
end
function EventRegistry:UnregisterFrameEventAndCallback(frameEvent, ...)
self:UnregisterFrameEvent(frameEvent)
self:UnregisterCallback(frameEvent, unpack(arg))
self:UnregisterFrameEvent(frameEvent);
self:UnregisterCallback(frameEvent, ...);
end
function EventRegistry:GetEventCounts(...)
local counts = {}
for i = 1, arg.n do
local frameEvent = arg[i]
local count = self.eventCounts[frameEvent] or "?"
table.insert(counts, frameEvent .. " : " .. tostring(count))
end
local counts = {};
for i = 1, select("#", ...) do
local frameEvent = select(i, ...);
local count = self.eventCounts[frameEvent] or "?";
table.insert(counts, ("%s : %s"):format(frameEvent, tostring(count)));
end
return table.concat(counts, "\n")
return table.concat(counts, "\n");
end
EventRegistry:OnLoad()
EventRegistry:OnLoad();
@@ -7,19 +7,42 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
end
SlashCmdList["FOCUS"] = function(msg)
if msg == "" then
FocusUnit();
else
FocusUnit(msg);
end
if ( msg == "" ) then
FocusUnit();
else
local action, target = SecureCmdOptionParse(msg);
if ( action ) then
if ( not target or target == "focus" ) then
target = action;
end
FocusUnit(target);
end
end
end
SlashCmdList["CLEARFOCUS"] = function(msg)
ClearFocus()
if ( SecureCmdOptionParse(msg) ) then
ClearFocus();
end
end
SlashCmdList["EQUIP_SET"] = function(msg)
if msg ~= "" then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
end
local set = SecureCmdOptionParse(msg);
if ( set and set ~= "" ) then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(set))
end
end
SlashCmdList["CLICK"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( action and action ~= "" ) then
local name, mouseButton = string.match(action, "([^%s]+)%s+([^%s]+)");
if ( not name ) then
name = action;
end
local button = GetClickFrame(name);
if ( button and button:IsObjectType("Button") ) then
button:Click(mouseButton);
end
end
end
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Notiz eintragen";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/klick";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/fokus";
+2
View File
@@ -81,6 +81,8 @@ SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Establecer nota";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/clic";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/conjequip";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/enfoque";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Écrire une note";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/cliquer";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/équipens";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Добавить заметку";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/щелчок";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "设置备注";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "設定註記";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/點擊";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/裝備套裝";
SLASH_FOCUS1 = "/focus";
+19 -21
View File
@@ -21,19 +21,20 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| Syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
| String methods | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
| Script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| Event-driven nameplates | The modern `C_NamePlate` API, driven by real events instead of vanilla frame-scraping. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. See [NamePlate](docs/API.md#nameplate). |
| Focus target | A sticky focus unit, like later clients. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. See [Focus](docs/API.md#focus). |
| Multi-flavor & conditional TOC | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
## Full API reference
@@ -49,13 +50,12 @@ reference in **[docs/API.md](docs/API.md)**.
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [AuraUtil](docs/API.md#aurautil) | `AuraUtil.FindAura`, `AuraUtil.FindAuraByName`, `AuraUtil.ForEachAura`, `AuraUtil.UnpackAuraData` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
@@ -67,7 +67,7 @@ reference in **[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`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `SecureCmdOptionParse`, `RegisterStateDriver`, `UnregisterStateDriver`, `RegisterAttributeDriver`, `UnregisterAttributeDriver`, `RegisterUnitWatch`, `UnregisterUnitWatch`, `UnitWatchRegistered`, `SecureButton_GetAttribute`, `SecureButton_GetUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
@@ -93,13 +93,14 @@ reference in **[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.GetSpellDispelType`, `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.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `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`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `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`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
@@ -115,9 +116,7 @@ reference in **[docs/API.md](docs/API.md)**.
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
in the first row are *glue-only* — they exist to support GlueXML
patches that need a small persistence surface across sessions, since
vanilla 1.12 glue ships no general-purpose persistence API beyond
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
patches that need a small persistence surface across sessions.
The rest of the table is in-game calls that we also mirror onto
the glue state because GlueXML had no way to reach them otherwise.
@@ -136,7 +135,7 @@ the glue state because GlueXML had no way to reach them otherwise.
<summary><b>Macros</b> — engine-level parsing extensions</summary>
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
functions. See the
[Macros section in the Lua reference](docs/API.md#macros) for details.
| Form | What it does |
@@ -226,7 +225,7 @@ when launching with `-console`), not as Lua functions. See the
| Group | Constants |
|-------|-----------|
| Version | `CLASSIC_API_VERSION` |
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
@@ -254,7 +253,7 @@ when launching with `-console`), not as Lua functions. See the
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
ClassicAPI backports the later-client direct-action and temporary override
ClassicAPI backports the direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
session-only, frame-owned, and support the usual priority flag.
@@ -335,7 +334,7 @@ identically in both cases.
## Bundled addon: DebugTools
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
(originally shipped with the 3.0 client) lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
It's an independent addon — it doesn't use anything ClassicAPI adds, and
ClassicAPI works fine without it. It's bundled here because it's the
natural companion for testing and debugging anything written against the
@@ -350,12 +349,11 @@ Slash commands provided:
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
Lua globals provided (backports of 3.3.5 helpers that don't exist in
1.12):
Lua globals provided (backports of Blizzard helpers):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
+1551 -1438
View File
File diff suppressed because it is too large Load Diff
+10 -50
View File
@@ -16,15 +16,11 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "luasyntax/StringMethods.h"
#include "model/DisplayInfo.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
#include "text/InlineTexture.h"
#include "text/InlineTexturePool.h"
#include "texture/Mask.h"
#include "texture/Transform.h"
#include "tooltip/SetEvents.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
@@ -40,53 +36,17 @@ static FrameRegisterEvent_t FrameRegisterEvent_o = nullptr;
static void __fastcall InvalidFunctionPtrCheck_h() {}
static bool __fastcall FrameScript_Initialize_h() {
// BEFORE the engine tears down the old event table (at the start of
// FrameScript_Initialize), invalidate our cached slot indices. The
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
// Fire every module's PrepareForReload BEFORE the engine tears down the old
// event table / Lua state. Each module that keeps reload-fragile state (Lua
// refs, frame-pointer-keyed maps, cached event-slot indices) self-registers
// via `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`,
// so this one call clears them all — a new module can't forget to wire in
// (see the ReloadAutoRegister doc in Game.h). This hook fires on both
// `/reload` and `/logout` (the engine re-inits the Lua state in both).
Game::RunReloadCleanups();
// Clear the nameplate diff state so currently-visible plates
// refire CREATED and UNIT_ADDED on the next tick — re-presents
// them to the freshly reloaded UI, matching modern WoW. The
// wrappers themselves now live in the engine's own registry
// (via `FrameScript_Object::ScriptRegister`), which the engine
// tears down and rebuilds across the Lua reset — no per-module
// cache to clear here.
NamePlate::Events::PrepareForReload();
// The reload teardown destroys every icon pool texture, fontstring,
// and text node — forget all inline-icon pointers now so nothing
// touches them (see InlineTexture.h / InlineTexturePool.h).
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
// reload destroys every addon texture and the region pool reuses their pointers.
Texture::Transform::PrepareForReload();
// Drop per-region texture masks for the same reason (SetMask).
Texture::Mask::PrepareForReload();
// Drop pending character-dress jobs and their engine compositors — the
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Drop the per-tooltip OnTooltipSet* handler cells — the reload destroys
// and recreates every tooltip object, and the handler refs die with the
// Lua reset (issue #33: the cell table filled after ~18 reloads, then
// recycled tooltip addresses fired dead refs).
Tooltip::SetEvents::PrepareForReload();
// Forget the captured `string` table before the world Lua state resets
// (this hook also fires on /logout, where the state's tables die) —
// string-method resolution fails closed (stock index error) until
// LoadScriptFunctions re-captures.
LuaSyntax::StringMethods::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
// deterministic save point for the common lifecycle events.
// Persist the name cache before the engine tears down — a SAVE, not a clear,
// so it stays an explicit call rather than a ReloadAutoRegister.
Player::NameCache::Flush();
FrameScript_Initialize_o();
+17
View File
@@ -17,6 +17,12 @@
namespace Game {
void *ResolveUnitToken(const char *token) {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
namespace Lua {
// Each entry binds a typed function pointer in `Game::Lua::` to the
// corresponding raw VA in `Offsets`. The X-macro keeps the column-aligned
@@ -40,6 +46,7 @@ namespace Lua {
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
F(GetTop, lua_gettop, LUA_GET_TOP) \
@@ -288,6 +295,7 @@ namespace {
ModuleAutoRegister *g_moduleHead = nullptr;
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
HookAutoRegister *g_hookHead = nullptr;
ReloadAutoRegister *g_reloadHead = nullptr;
} // namespace
ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
@@ -309,6 +317,15 @@ void RunGlueModuleRegistrations() {
node->fn();
}
ReloadAutoRegister::ReloadAutoRegister(Fn f) : fn(f), next(g_reloadHead) {
g_reloadHead = this;
}
void RunReloadCleanups() {
for (auto *node = g_reloadHead; node != nullptr; node = node->next)
node->fn();
}
HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original)
: target(target), hook(hook), original(original), next(g_hookHead) {
g_hookHead = this;
+48
View File
@@ -57,6 +57,19 @@ inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
// --- Engine primitives ------------------------------------------------------
// Resolve a unit token (`"player"`, `"target"`, `"party3"`, `"nameplate1"`,
// …) to its live `CGUnit_C *`, through the engine's own resolver
// (`FUN_RESOLVE_UNIT_TOKEN`). Returns null for a valid token that currently
// has no unit (e.g. `"target"` with nothing targeted). NOTE: the engine
// RAISES a Lua error for a string that isn't a valid token form (an arbitrary
// name like `"Bob"`), so only pass real unit tokens — the same contract every
// stock `Unit*` global carries. Centralizes the
// `reinterpret_cast<…>(FUN_RESOLVE_UNIT_TOKEN)` idiom that was copy-pasted
// across a dozen modules.
void *ResolveUnitToken(const char *token);
using FrameScript_Initialize_t = bool(__fastcall *)();
using LoadScriptFunctions_t = void(__fastcall *)();
@@ -110,6 +123,9 @@ using lua_gettable_t = void(__fastcall *)(void *L, int idx);
using lua_rawget_t = void(__fastcall *)(void *L, int idx);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pops the table at the top and installs it as the metatable of the value
// at idx; returns 1.
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_insert_t = void(__fastcall *)(void *L, int idx);
using lua_remove_t = void(__fastcall *)(void *L, int idx);
using lua_gettop_t = int(__fastcall *)(void *L);
@@ -165,6 +181,7 @@ extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
extern const lua_gettop_t GetTop;
@@ -412,4 +429,35 @@ struct HookAutoRegister {
// and stops on first failure.
bool RunHookRegistrations();
// Self-registration for per-/reload state cleanup. A module that keeps
// file-static state which goes STALE across a game /reload declares a
// file-scope `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`
// right after its `void PrepareForReload()`. `RunReloadCleanups()` — called
// from the FrameScript_Initialize hook, BEFORE the engine tears down the Lua
// state — fires them all, so DllMain no longer hand-maintains the list and a
// new module can't forget to wire itself in.
//
// State is "reload-fragile" when it survives in C++ but its meaning does not:
// - Lua registry refs / handler cells (the Lua reset invalidates them);
// - maps/sets keyed by a frame/object POINTER (the allocator recycles
// addresses, so an old entry aliases an unrelated new object);
// - cached event-slot indices (the engine rebuilds the event table).
// Firing a stale entry is the recurring bug this prevents (Tooltip::SetEvents
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
// NOT fragile and must not register.
//
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
// undefined cross-TU order — fine, because each callback only clears its OWN
// state. This is the "clear stale state" category only; a save-before-teardown
// like Player::NameCache::Flush stays an explicit DllMain call (it persists
// rather than clears, and also runs on the glue-return path).
struct ReloadAutoRegister {
using Fn = void (*)();
explicit ReloadAutoRegister(Fn fn);
Fn fn;
ReloadAutoRegister *next;
};
void RunReloadCleanups();
} // namespace Game
+358 -7
View File
@@ -226,6 +226,35 @@ enum Offsets {
// so a pointer-keyed cell never goes stale). Same pattern + ABI modeling as
// FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
FUN_FRAME_SCRIPT_RESOLVER = 0x0076A0D0,
// Button widget script-name resolver — __thiscall(button, const char *name)
// -> int* slot, 0 for an unknown name. Sits at vtable+0xC on all four
// button-family vtables (Button + subclasses). Delegates to the base frame
// resolver FUN_FRAME_SCRIPT_RESOLVER first, then maps the two button
// scripts: OnClick -> button+0x4CC, OnDoubleClick -> button+0x4D4 (each an
// 8-byte {handler, context} slot). Vanilla has no PreClick/PostClick, so
// `Frame::ClickEvents` co-hooks this to hand out an external per-button cell
// for those two names — same technique as FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
FUN_BUTTON_SCRIPT_RESOLVER = 0x00778C50,
// Button OnClick script slot offset (the resolver's OnClick return). Used
// by `Frame::ClickEvents` to recognize an OnClick fire at the runner hook
// below: the fire passes slotPtr == button + this offset.
OFF_BUTTON_ONCLICK_HANDLER = 0x4CC,
// Frame-script runner WITH exec-context stamping — __cdecl(void *frame,
// int *slotPtr, const char *fmt, void *vaPtr). Saves DAT_00ceeac0, stamps
// it from slotPtr[1] (the cell's context), then calls FUN_FRAME_RUN_SCRIPT_ARGS
// (the arg'd runner Frame::ScriptArgs hooks) and restores. Every arg'd
// input-script fire funnels through here via FUN_007026F0 (its variadic
// forwarder); the event dispatcher FUN_00703F50 is the only other caller.
// `Frame::ClickEvents` co-hooks it, gates on `slotPtr == frame +
// OFF_BUTTON_ONCLICK_HANDLER` (an exact OnClick match — no other fire passes
// that slot address), and fires PreClick before / PostClick after by
// re-invoking with the same (fmt, vaPtr) so the button-name arg is reused.
// Deliberately NOT the button click vmethod FUN_00779540, which SuperWoW
// inline-hooks for click-casting (a second hook there faults, ERROR #132 —
// see the note near FUN_SCRIPT_FRAME_GET_STRATA); this runner is uncontested.
FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT = 0x00702710,
// 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
@@ -270,7 +299,12 @@ enum Offsets {
// the pointer at +0xB8 (both verified from the setters below). We read a
// line's text/color and re-apply them one slot down, then set line 0.
FUN_FONTSTRING_SET_TEXT = 0x00771D80, // __thiscall(fs, text, flag=0)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(fs, colorBGRA*)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(region, colorBGRA*) — generic region color setter (fontstrings + textures)
// Generic region color GETTER — `void __thiscall(region, uint32* outBGRA)`,
// writes the packed {b,g,r,a} (0xFFFFFFFF when the region has no explicit
// color). The read half of Texture:GetVertexColor (FUN_0079aa50); paired
// with FUN_FONTSTRING_SET_COLOR above. Backs EditBox:GetHighlightColor.
FUN_REGION_GET_COLOR = 0x0077F8C0,
OFF_FONTSTRING_TEXT = 0xF0, // char* — current text buffer
OFF_FONTSTRING_COLOR_PTR = 0xB8, // ptr → 4-byte {b,g,r,a} color storage
// A line's FontString is only positioned once shown: set the desired
@@ -1060,6 +1094,19 @@ enum Offsets {
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
OFF_UNIT_FIELD_CREATEDBY = 0x20,
// UNIT_CREATED_BY_SPELL — the spell that summoned this unit (descriptor
// byte +0x230, field index 0x8C). Set for every spell-summoned unit:
// totems (the shaman's totem-drop spell), pets, guardians, wild summons.
// Verified two ways: the unit-title builder `FUN_0052FD30` reads
// `[desc+0x230]` as the summoning spell (then switches on its Spell.dbc
// Effect[0] to pick "Guardian"/"Creation"), and the server stamps it
// with `m_spellInfo->Id` (tortoise-wow `EffectSummonTotem`/
// `EffectSummonGuardian`/`EffectSummon`). A broadcast field, so it's
// readable for any unit in range. `Unit::Pet` exposes it as
// `UnitCreatedBySpell`. NB: this is the SUMMONING spell — distinct from
// the spell a totem casts (that's server-side only, never broadcast).
OFF_UNIT_FIELD_CREATED_BY_SPELL = 0x230,
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
// `0x00605570` is really the engine's **creature-type resolver** — it's
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
@@ -1755,6 +1802,15 @@ enum Offsets {
// from SMSG_SPELL_START, not server-broadcast state).
OFF_UNIT_CAST_SPELL = 0xC8C,
// CGUnit field: the unit's sheath state as a plain int member (NOT a
// descriptor UpdateField). 0 = sheathed / no weapon shown, 1 = melee
// weapon drawn, 2 = ranged weapon drawn. Read and written by the engine's
// own sheath toggler `FUN_005EB480` (behind `ToggleSheath`) and its setter
// `FUN_00611CF0` (`*(this+0xD40) = newState`, saving the prior state to
// `+0xD3C`). `Unit::Sheath::GetSheathState` reads the local player's value
// and returns it 1-based (Lua's GetSheathState is 1=None/2=Melee/3=Ranged).
OFF_UNIT_SHEATH_STATE = 0xD40,
// NetClient message dispatch — `__thiscall void(void *conn, uint32_t
// arg, CDataStore *packet)`. Reads the u16 opcode off the packet
// cursor, then invokes `[conn + opcode*4 + 0x74]` (the handler table
@@ -1785,6 +1841,13 @@ enum Offsets {
SMSG_SPELL_CHANNEL_UPDATE = 0x13A,
SMSG_SPELL_DELAYED = 0x1E2,
SMSG_SPELL_FAILED_OTHER = 0x2A6,
// Melee swing result broadcast. Body (verified against the server's own
// writer, tortoise-wow Unit::SendAttackStateUpdate): u32 hitInfo,
// packedGuid attacker, packedGuid victim, u32 totalDamage, u8 subCount,
// per-sub {u32 school, f32 coeff, u32 damage, u32 absorb, i32 resist},
// u32 targetState, u32, u32 spellId, u32 blocked. `Aura::JudgementRefresh`
// reads through totalDamage.
SMSG_ATTACKERSTATEUPDATE = 0x14A,
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
@@ -1843,6 +1906,11 @@ enum Offsets {
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
// EditBox — 48 methods (table 0x0087BB68), ctx per
// docs/BlizzardScriptAPI.md. Backs `EditBox:SetCursorPosition` /
// `GetCursorPosition` (`EditBox::Methods`), the modern cursor / focus /
// text-state methods vanilla's EditBox lacks.
VAR_EDITBOX_METHOD_REGISTRY = 0x00CF5378,
// Model frame — backs `Model:SetDisplayInfo(creatureDisplayID)`
// (`Model::DisplayInfo`). Registry ctx per docs/raw_methods.txt (table
@@ -4102,6 +4170,15 @@ enum Offsets {
VAR_PET_SPELLBOOK = 0x00B6F098,
SPELLBOOK_MAX_SLOTS = 0x400,
// Active minimap-tracking spell ID (0 = none). Vanilla tracks exactly
// one active tracker as a plain int here, derived from the player's
// active Track Creatures/Resources/Stealthed aura by `FUN_004E4170`
// (an aura-descriptor observer registered by `FUN_004E4100`). Read by
// `Script_GetTrackingTexture` (`0x004E4A20`), `Script_CancelTrackingBuff`
// (`0x004E4A80`), and `GameTooltip:SetTrackingSpell` (`0x00532C50`);
// `Spell::Tracking::GetTrackingInfo` reads it to report `active`.
VAR_ACTIVE_TRACKING_SPELL = 0x00BC6378,
// Player-spell-knowledge bitmap — `[VAR_PLAYER_SPELL_BITMAP]` is a
// pointer to a dword bitmap covering all spellIDs the player has
// learned, including talent passives, racials, profession recipes,
@@ -4372,6 +4449,15 @@ enum Offsets {
// results). Callable directly from C++ — push args on stack, call.
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
// `Script_CastSpell` — engine's Lua wrapper for the slot-based
// `CastSpell(spellbookSlot, bookType)` global. `int __fastcall(void *L)`.
// Parses (slot, bookType) from stack[1]/[2] via `FUN_004B3EC0`, then
// dispatches through `FUN_SPELL_CAST_DISPATCH` with the current target
// (or self). Raises "Invalid spell slot in CastSpell" for a bad slot.
// Callable from C++ by preparing the stack and tail-calling it (the
// `Spell::Tracking::SetTracking` "select = cast" path does this).
FUN_SCRIPT_CAST_SPELL = 0x004B42F0,
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
// the spell name to a spellbook slot. For ground-target spells, the
@@ -4790,6 +4876,11 @@ enum Offsets {
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
// functions that call it. Used by the `__environment` env-protection hook.
LUA_GET_METATABLE = 0x6F3CF0,
// `lua_setmetatable(L, objindex)` — pops the table at the top of the
// stack and installs it as the metatable of the value at objindex;
// returns 1. Catalogued (★-verified) in docs/LuaCAPI.md. Used by
// Table::Length to build its weak-keyed mark table from C.
LUA_SET_METATABLE = 0x6F4020,
// `lua_getfenv(L, idx)` — pushes the environment table of the function
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
// it to read a function's environment before the protection check.
@@ -4869,6 +4960,16 @@ enum Offsets {
// (named there since the table.insert trace). Used by the 5.1
// `unpack(list, i, j)` upgrade for the default range end.
LUAL_GETN = 0x6F5050,
// `table.insert` (5.0 `luaB_tinsert`), `int __fastcall(L)`. Found two
// independent ways: the table-lib luaL_reg pair in .data (entry
// {name 0x008821B4 "insert", func} at 0x00822D70) and the LUAL_SETN
// caller trace above. Decompile matches 5.0.2 tinsert exactly:
// lua_gettop → luaL_checktype(1, TABLE) → n = luaL_getn(1)+1 →
// optional-pos shift-up loop (rawgeti/rawseti) → luaL_setn(1, n) →
// lua_pushvalue(value) → lua_rawseti(1, pos). Both `table.insert` and
// the FrameXML `tinsert` alias resolve to this one C function. Hooked
// by Table::Length to mark deliberate trailing-nil appends (#36/#39).
FUN_LUA_TABLE_INSERT = 0x7FB6B0,
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
@@ -5373,8 +5474,12 @@ enum Offsets {
//
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
// arg to `FUN_INTRUSIVE_LIST_INSERT`. `VAR_ADDON_LIST_HEAD` is the
// control's +0x08 head slot; walk entries via
// `next = *(entry + [VAR_ADDON_LIST_CTRL] + 4)` (= `entry+0x10`),
// stopping on a low-bit-1 sentinel or NULL.
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
VAR_ADDON_LIST_HEAD = 0x00BE1B6C,
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
@@ -5646,6 +5751,178 @@ enum Offsets {
VAR_ADDON_ARRAY = 0x00BE1B94,
VAR_ADDON_COUNT = 0x00BE1B90,
// AddOnEntry fields shared by `Addons::Embedded` and `Addons::Rescan`
// (the full per-field derivation lives in CLAUDE.md's AddOn registry
// section):
// +0x14 — canonical name pointer (what the load pass passes to the
// by-name loader `FUN_0051F240`).
// +0x29 — filter-out byte; the flat display-array builder skips
// entries where it's non-zero (how `!!!ClassicAPI` hides).
// LoadWith desc@+0x58 (count@+0x5C, data@+0x60) — `## LoadWith:`
// names, parsed by `FUN_TOC_PARSER` (append site writes
// rec[0x16..0x19]).
// reverse-LoadWith desc@+0x88 (count@+0x8C, data@+0x90,
// quantum@+0x94) — `AddOnEntry *`s of every addon that
// declared `## LoadWith: <this>`; built by the scan
// `FUN_0051C760`'s tail loop (rec[0x22..0x25]), consumed by
// the loader's post-ADDON_LOADED loop.
OFF_ADDON_ENTRY_NAME_PTR = 0x14,
OFF_ADDON_ENTRY_FILTER_OUT = 0x29,
OFF_ADDON_LOADWITH_COUNT = 0x5C,
OFF_ADDON_LOADWITH_ARRAY = 0x60,
OFF_ADDON_REVLOADWITH_DESC = 0x88,
// `## Secure:` flag byte. Parser write site verified in
// `FUN_TOC_PARSER`'s decompile (`*(bool *)(entry_dwords + 10)` =
// `+0x28`); also set by the SMSG_ADDON_INFO secure path at login.
// Entries with this set are SMSG-managed (Blizzard addons on stock
// clients) — never evict/re-register them: the parser cannot restore
// packet-delivered state (and their MPQ TOCs never change anyway).
OFF_ADDON_ENTRY_SECURE = 0x28,
// Complete single-`AddOnEntry` destructor — vtable slot 0 of the
// registry's node factory (`[0x00BE1B60]` = `&PTR_FUN_00808c7c`,
// installed by the boot init `FUN_0051C360`; the login teardown
// `FUN_0051FA40` dispatches per entry through it).
// `__stdcall(AddOnEntry *)`, RET 4 (arg read from `[EBP+8]`,
// verified by disassembly). Body (`FUN_005200A0` → field cleanup
// `FUN_005200D0`, both decompiled):
// - frees EVERY owned allocation: name (+0x14), the +0x30 string,
// all five dep/SV string arrays AND their buffers
// (+0x38/+0x48/+0x58/+0x68/+0x78), the reverse-LoadWith buffer
// (+0x88), and the per-entry `##` metadata hash table (+0x98 —
// the Title/Notes/Author/X-* nodes `GetAddOnMetadata` reads);
// - SELF-UNLINKS from both registry structures: the intrusive
// list (link node +0xC via `FUN_00520A60`, which neighbor-
// patches from the node alone and no-ops when already unlinked)
// and the hash bucket chain (+0x4);
// - frees the entry struct itself (`__AUUIADDON__` tag).
// What it does NOT clean: THIS entry's pointer inside OTHER
// entries' reverse-LoadWith lists — the caller must scrub those
// first (`Addons::Rescan`), or the loader's post-ADDON_LOADED loop
// (`FUN_0051F240` tail: reads +0x8C/+0x90, derefs each entry's
// +0x14) reads freed memory.
FUN_ADDON_ENTRY_DESTROY = 0x005200A0,
// `u8` set to 1 by `FUN_ADDON_INIT` after the login scan completes,
// cleared by the registry teardown `FUN_0051FA40`. Gates anything
// that touches the registry outside the login path.
VAR_ADDON_INITIALIZED = 0x00BE1C08,
// ── The frozen loose-file index (why new files need a restart) ────
//
// Every relative-path file open resolves through `FUN_00647E60`,
// which consults a HASH TABLE OF LOOSE FILES built ONCE per process:
// `FUN_00646EA0` (lazy, latched by `VAR_VFS_INDEX_READY`) walks the
// whole game directory tree via `FUN_VFS_INDEX_SUBTREE`, keying each
// file by its base-relative path and storing the joined on-disk path.
// Index hits open the mapped path LIVE from disk (edits to existing
// files take effect on /reload); index misses fall through to the
// MPQ search ONLY — the archive layer's live disk probe
// (`FUN_00654DD0` → GetFileAttributesA) is dead code at runtime
// because its enable global `DAT_00865B44` (ships as 1 in the image)
// is unconditionally cleared by early boot (`FUN_00402210` →
// `FUN_00648C20`). File WRITES (`FUN_0042A460` = raw CreateFileW)
// never register in the index either. Net effect on a stock client:
// any file created after boot — a new addon's TOC, a new .lua in an
// existing addon, even a freshly written SavedVariables file — is
// INVISIBLE to every relative-path read until the client restarts.
//
// `FUN_VFS_INDEX_SUBTREE` — the recursive indexer itself:
// void __fastcall IndexSubtree(
// const char *basePath, // ecx — index keys are relative to
// // this (the game dir)
// const char *relSubdir, // edx — subtree below basePath; ""
// // for the boot-time full walk
// void *findHandle);// stack — from FUN_VFS_FIND_OPEN on
// // join(basePath, relSubdir)
// Dedup-safe: every insert is preceded by a hash lookup, so
// re-running it on a subtree registers only genuinely new files —
// which is exactly what `Addons::Rescan` does per /reload for
// `Interface\AddOns` and `WTF\Account`.
//
// `FUN_VFS_FIND_OPEN` — `__stdcall(const char *dirPath) -> void*`
// (RET 4). Allocates a find block, strips trailing backslashes,
// requires the path to be an EXISTING DIRECTORY
// (GetFileAttributes & 0x10) — returns NULL otherwise — then arms
// it with "\*". Release with `FUN_VFS_FIND_CLOSE`
// (`__stdcall(void*)`, RET 4; NULL-safe).
//
// `VAR_VFS_BASE_PATH` — the game base dir recorded at boot
// (`FUN_006488B0`); `FUN_00646EA0` uses it as the walk base when
// `FUN_VFS_FIND_OPEN` accepts it, else falls back to ".".
// `VAR_VFS_INDEX_READY` — `u8` latch; nonzero once the boot walk ran.
FUN_VFS_INDEX_SUBTREE = 0x00646910,
FUN_VFS_FIND_OPEN = 0x006674A0,
FUN_VFS_FIND_CLOSE = 0x006676A0,
VAR_VFS_BASE_PATH = 0x00C52418,
VAR_VFS_INDEX_READY = 0x00C5251C,
// ── Login scan walk #2 (loose-addon discovery) — replayed per /reload
//
// `FUN_ADDON_SCAN_DISK_DIRS` — generic disk directory walker
// (resolves to kernel32 FindFirstFileW; the DISK sibling of
// `FUN_MPQ_ENUM_FILES`):
// int __fastcall ScanDiskDirs(
// const char *basePath, // ecx — e.g. "Interface\AddOns\"
// const char *pattern, // edx — appended to basePath; the
// // scan passes the "*" at
// // VAR_ADDON_SCAN_PATTERN
// Callback cb, // stack — see below
// void *userParam, // stack — passed to cb in edx
// int includeHidden); // stack — 0 skips hidden files
// RET 0xC. Callback: `int __fastcall(FindInfo * /*ecx*/,
// void *userParam /*edx*/)` with FindInfo `{+0x4 attrs (0x10 =
// directory), +0x8 char name[]}`; returning nonzero STOPS the walk
// (note: OPPOSITE of `FUN_MPQ_ENUM_FILES`' convention).
//
// `FUN_ADDON_DISK_DIR_CB` — the engine's own per-entry callback for
// this walk (defined + verified in Ghidra): for every directory not
// starting with '.', calls `FUN_TOC_PARSER` on the name; returns 0.
// `Addons::Rescan` replays the scan's exact call —
// `ScanDiskDirs(VAR_ADDON_PATH_PREFIX, VAR_ADDON_SCAN_PATTERN,
// FUN_ADDON_DISK_DIR_CB, 0, 0)` — so new-folder discovery is 100%
// engine code; the parser's dedup guard makes it a no-op per
// already-registered addon.
FUN_ADDON_SCAN_DISK_DIRS = 0x0042AD10,
FUN_ADDON_DISK_DIR_CB = 0x0051D7B0,
VAR_ADDON_SCAN_PATTERN = 0x00833180, // "*" (walker config block)
VAR_ADDON_PATH_PREFIX = 0x00853724, // "Interface\AddOns\"
// ── Flat display array + descriptor grow helpers ──────────────────
//
// The `GetNumAddOns`/`GetAddOnInfo(i)` array (`VAR_ADDON_ARRAY` /
// `VAR_ADDON_COUNT` above) is a full `{cap, count, data, quantum}`
// descriptor at `VAR_ADDON_ARRAY_CAP` (cap@0x00BE1B8C,
// count@0x00BE1B90, data@0x00BE1B94, quantum@0x00BE1B98) holding
// NAME POINTERS (entry+0x14), rebuilt from scratch and qsorted by
// `FUN_0051DA70` phase 2 whenever SMSG_ADDON_INFO lands.
// `Addons::Rescan` mirrors that phase (never calls `FUN_0051DA70`
// itself — its phase 1 consumes the packet) so newly appended
// registry entries show up in the by-index Lua surface.
//
// Grow helpers (one template instantiation per desc — use each at
// its verified site): `__thiscall(desc /*ecx*/, uint newCap)`,
// RET 4 — Storm-reallocs `desc->data` to newCap*4 and writes
// `desc->cap = newCap`.
// FUN_ADDON_ARRAY_GROW — flat display array (used by
// `FUN_0051DA70` phase 2)
// FUN_ADDON_REVLOADWITH_GROW — reverse-LoadWith descs (used by the
// scan tail loop)
// `FUN_DESC_QUANTUM_CALC` — `__thiscall(desc /*ecx*/, uint needed)`,
// RET 4: returns the append-rounding quantum (highest power of two
// ≤ needed, min 1; ≥ 0x40 caps at 0x40 and stores desc->quantum).
// `FUN_CRT_QSORT` — the CRT `qsort` (`__cdecl(base, num, width,
// cmp)`); `FUN_ADDON_NAME_COMPARE` is the engine's `__cdecl`
// name-pointer comparator `FUN_0051DA70` sorts the array with.
VAR_ADDON_ARRAY_CAP = 0x00BE1B8C,
VAR_ADDON_ARRAY_QUANTUM = 0x00BE1B98,
FUN_ADDON_ARRAY_GROW = 0x004755F0,
FUN_ADDON_REVLOADWITH_GROW = 0x005213C0,
FUN_DESC_QUANTUM_CALC = 0x00521C50,
FUN_CRT_QSORT = 0x0073F727,
FUN_ADDON_NAME_COMPARE = 0x0051DEB0,
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
@@ -6347,6 +6624,23 @@ enum Offsets {
FUN_GET_OBJECT_BY_GUID = 0x00468460,
OBJECT_TYPE_ITEM = 2,
// Melee auto-attack primitives (back StartAttack / StopAttack). 1.12
// exposes only the toggling AttackTarget (0x00489B50 -> FUN_006131A0);
// these are the non-toggling start/stop it calls internally.
// START: __thiscall(player, const uint64_t *targetGuid). Validates
// CanAttack on the target, sends CMSG_ATTACKSWING (opcode 0x141), and
// stamps the victim GUID at [player+0xC48]. Idempotent / switch-aware
// on a valid target (never stops an in-progress attack); an
// unattackable target while attacking routes to STOP.
// STOP: __fastcall(player). Clears the attack state; self-guards on the
// attack flag, so it is a no-op (safe) when not attacking.
// RESOLVE_TARGET: __thiscall(player, uint64_t *outGuid) -> int. Validates
// the player's CURRENT target (charm/fear/confuse redirect + CanAttack)
// and writes its GUID; returns 0 (and posts the UI error) if invalid.
FUN_ATTACK_START = 0x005ECB70,
FUN_ATTACK_STOP = 0x005ECAC0,
FUN_ATTACK_RESOLVE_TARGET = 0x00612DF0,
// Inbox — array of mail-entry pointers behind a slot indirection.
// The address itself holds a heap pointer (`MOV ECX, [imm32]` in
// the engine), so reading the array requires one extra deref
@@ -6499,6 +6793,12 @@ enum Offsets {
// SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS (verified via disassembly; the
// server enum agrees). NOT AttributesEx2's bit 29 (that's CANT_CRIT).
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS = 0x20000000,
// AttributesEx3 bit 18 — the paladin judgement-debuff marker. The server's
// melee-swing judgement refresh selects auras by SPELLFAMILY_PALADIN +
// this bit (tortoise-wow SpellDefines.h SPELL_ATTR_EX3_ALWAYS_HIT,
// Unit::DealMeleeDamage). Verified in the client Spell.dbc: all 14
// Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry it.
SPELL_ATTR_EX3_ALWAYS_HIT = 0x00040000,
// AttributesEx channel bits (CHANNELED_1 0x4 | CHANNELED_2 0x40) and the
// AttributesEx2 autorepeat flag (bit 5, Auto Shot / Shoot) — each shared
// by more than one module, so kept here rather than redefined locally.
@@ -6971,11 +7271,13 @@ enum Offsets {
// but FUN_004eae10 sets it EXPLICITLY — we mirror that.
// * The SECOND vertex UV stream (uv1) DOES feed unit 1 (FUN_004eae10
// passes a distinct uv1 = this+0x50), so uv1 = uv0 makes unit 1 sample
// the mask at the base's own texcoords — exactly SetMask semantics.
// uv1 is the LAST explicit UV slot that exists: the vertex-format
// component table at 0x008097A8 (13 formats × 13 components) has no
// format with a third UV set, so masks past the first CANNOT ride an
// explicit stream — they use texgen (below).
// the mask at the base's own texcoords. SetMask originally rode this
// recipe but was moved to texgen (below): texcoord-relative sampling
// breaks for SetTexCoord'd sprites (the mask gets sampled at the same
// sub-rect OF THE MASK IMAGE), and uv1 is the LAST explicit UV slot
// that exists — the vertex-format component table at 0x008097A8
// (13 formats × 13 components) has no format with a third UV set, so
// masks past the first cannot ride an explicit stream anyway.
GXRS_TEXTURE0 = 0x17, // texture bind, +unit 0..7 (via FUN_GX_RS_SET_PTR)
GXRS_COMBINER0 = 0x1F, // combiner preset, +unit 0..7 (via FUN_GX_RS_SET)
GXRS_COMBINE_MODULATE = 1, // preset value: COLOROP=MODULATE, ALPHAOP=MODULATE
@@ -7282,6 +7584,55 @@ enum Offsets {
// *(this+0xF0) via SStrDup.
OFF_EDITBOX_TEXT_FONTSTRING = 0x328, // CSimpleFontString* — the display text object
// CSimpleEditBox cursor / selection internals (backs EditBox::Methods'
// SetCursorPosition / GetCursorPosition). Verified from the engine's own
// HighlightText (FUN_00798870 → FUN_0077cca0) and Insert (FUN_00798400 →
// FUN_0077bee0) paths:
// +0x31c dirty-flag dword — bit 2 = selection/highlight changed, bit 4 =
// cursor changed (both trigger the next-paint relayout).
// +0x338 text length in BYTES (the clamp bound below).
// +0x35c / +0x360 selection start / end (byte offsets).
// +0x36c cursor insertion point, a BYTE offset — matches modern WoW's
// byte-based EditBox cursor (GetCursorPosition returns bytes; for
// ASCII text byte == character).
OFF_EDITBOX_FLAGS = 0x31c,
OFF_EDITBOX_TEXT_LENGTH = 0x338, // current text length in bytes
OFF_EDITBOX_CURSOR_BYTE = 0x36c,
// `void __thiscall(editbox, byteOffset)` — sets the cursor to a byte
// offset, clamping to [0, textByteLen] and OR-ing the +0x31c cursor-dirty
// bit. The engine's own "restore cursor" primitive (called from the insert
// path FUN_0077bee0 to put the caret back after splicing text).
FUN_EDITBOX_SET_CURSOR_BYTE = 0x0077e360,
// `void __thiscall(editbox)` — collapses the selection onto the cursor
// (selStart = selEnd = +0x36c). The engine's own "clear selection to the
// caret" helper (FUN_0077ccf0), used before extending a shift-selection.
FUN_EDITBOX_COLLAPSE_SELECTION = 0x0077ccf0,
// `int __thiscall(editbox, startByteIndex, byteCount)` — counts CHARACTERS
// (UTF-8 codepoints) in a byte range, walking the per-byte char-class table
// at +0x330 (a byte begins a codepoint when its class is 2/3/6). So
// `COUNT_CHARS(eb, 0, cursorByteOffset)` is the cursor's character index —
// backs GetUTF8CursorPosition. Verified from the arrow-key mover
// FUN_0077cb20's own `FUN_0077bc80(this, byteOff, cursorByte - byteOff)`.
FUN_EDITBOX_COUNT_CHARS = 0x0077bc80,
// Selection-highlight regions — three consecutive CSimpleTexture pointers
// (start-line +0x350, middle-block +0x354, end-line +0x358) the editbox
// shows/positions to paint the selection (FUN_0077d950). Their vertex
// color IS the highlight color — Set/GetHighlightColor read/write it via
// the generic region color accessors. The blinking caret is a SEPARATE
// region at +0x3d4, deliberately left alone.
OFF_EDITBOX_HIGHLIGHT_REGION = 0x350,
// Input-history ring buffer: max lines (ring capacity) at +0x388 (what
// GetHistoryLines reads), write position at +0x38c, container
// {size@+0x390, alloc@+0x394, data@+0x398} of {char* text, int exec}
// entries. AddHistoryLine writes at +0x38c and advances modulo +0x388.
OFF_EDITBOX_HISTORY_MAX = 0x388,
// `void __thiscall(editbox, int maxLines)` — the SetHistoryLines internal
// (FUN_0077cd90). Reallocates the history ring to maxLines; maxLines == 0
// frees the whole buffer and zeroes the container. ClearHistory calls it
// with 0 then the saved max — empties every entry and resets the write
// position while keeping the limit (so recording still works after).
FUN_EDITBOX_SET_HISTORY_MAX = 0x0077cd90,
// --- FontString measure internals (GetStringWidth icon fix + GetStringHeight) ---
// CSimpleFontString::GetStringWidthInternal — `float(__fastcall)(fs /*ecx*/)`,
// returns in x87 ST0. Lazily computes into the fs+0xFC cache (ANCHOR units,
+35 -41
View File
@@ -50,6 +50,7 @@
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
#include "addons/Registry.h"
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
@@ -377,26 +378,22 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
// `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;
// The list is walked via the shared `Addons::ForEachEntry`
// (`addons/Registry.h`); the callback stops the walk once it finds and
// re-links our entry, since the re-link invalidates the current
// next-pointer.
//
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is 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).
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
@@ -411,29 +408,26 @@ constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// 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));
while ((entry & 1) == 0 && entry != 0) {
Addons::ForEachEntry([](uintptr_t entry) -> bool {
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;
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
}
// Hide from the character-select AddOns list and default it to
// enabled, then move it to the head of the load order.
*reinterpret_cast<uint8_t *>(
entry + Offsets::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 *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return false; // stop — the re-link invalidated our next-pointer
});
}
void __fastcall AddonInit_h(const char *accountName) {
+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.
//
// 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 "Offsets.h"
#include <cstdint>
#include <type_traits>
namespace Addons {
// Walk the engine's intrusive AddOn-registry linked list, calling `fn(entry)`
// for each `AddOnEntry *`. Head at `[VAR_ADDON_LIST_HEAD]`; the next-pointer
// field is at `entry + [VAR_ADDON_LIST_CTRL] + 4` (= `entry + 0x10`); a
// low-bit-1 sentinel or NULL terminates. This is the same traversal the
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
//
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
// early). Early stop is load-bearing for a visitor that mutates the list —
// e.g. re-linking an entry to the head invalidates the current next-pointer,
// so the walk must not follow it. Shared by `Addons::Embedded` (find + re-link
// the embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
template <typename Fn> void ForEachEntry(Fn fn) {
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_HEAD));
while ((entry & 1) == 0 && entry != 0) {
if constexpr (std::is_void_v<decltype(fn(entry))>) {
fn(entry);
} else {
if (!fn(entry))
return;
}
entry = *reinterpret_cast<const uintptr_t *>(entry + linkOffset + 4);
}
}
} // namespace Addons
+523
View File
@@ -0,0 +1,523 @@
// 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/>.
// Retail-like `/reload`: pick up new addon folders and new files without
// restarting the client. Two independent engine limitations block that on
// a stock 1.12 client, each fixed here with the engine's own machinery:
//
// 1. FILE VISIBILITY. Every relative-path read resolves through a
// loose-file hash index built ONCE at boot (see Offsets.h,
// `FUN_VFS_INDEX_SUBTREE`); files created after boot — a new
// addon's TOC, a new .lua in an existing addon, a freshly written
// SavedVariables file — are invisible until restart. The boot
// indexer is dedup-safe and works on subtrees, so we re-run it per
// /reload on the only two subtrees whose contents can change what
// a /reload loads: `Interface\AddOns` and `WTF\Account`. New files
// in EXISTING addons need nothing more — the per-addon loader
// `FUN_0051F240` re-reads the TOC from disk on every load pass, so
// once the files are visible they load.
//
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
// new folders are never walked. We replay the login scan's disk
// walk verbatim (`FUN_ADDON_SCAN_DISK_DIRS` with the engine's own
// per-directory callback, which feeds the dedup-safe TOC parser),
// so new folders register as completely normal entries — then
// mirror the two registry structures the scan's OTHER passes would
// have filled: the reverse-LoadWith lists (scan tail loop) and the
// flat `GetNumAddOns` display array (`FUN_0051DA70` phase 2).
//
// 3. METADATA IMMUTABILITY. The parser's dedup guard makes a
// registered entry's `##` metadata read-once: an edited
// `## SavedVariables:` / `## Dependencies:` / any `##` line never
// re-parses. The engine's own complete per-entry destructor
// (`FUN_ADDON_ENTRY_DESTROY` — frees every owned allocation and
// self-unlinks from both the list and the hash; see Offsets.h)
// makes evict + re-register safe: destroy the entry so the dedup
// guard MISSES, re-feed the name to `FUN_TOC_PARSER`, and the
// entry rebuilds from the edited TOC exactly like a fresh
// registration. Gated on an actual `##` change (hash of the TOC's
// `##` lines only — file-reference edits load natively and never
// trigger the evict path). A TOC that no longer reads at all
// (folder deleted) evicts without re-registering — the deletion
// analog; a folder the scan still finds simply re-registers.
//
// Runs from the `ModuleAutoRegister` callback — LoadScriptFunctions
// post-hook, which fires inside FrameXML init (`FUN_0048FBF0`) BEFORE
// the AddOns.txt enable-state re-read, the load-progress count, and the
// addon load pass, on both login and every /reload. The engine then
// loads new entries natively: dep ordering, Bindings.xml, flavor TOCs,
// SavedVariables, ADDON_LOADED, the load-screen "Loading add-on %s".
// The window is also what makes eviction safe: the unload pass already
// ran (SavedVariables written from the OLD entry's SV lists, loaded
// bytes cleared), and the load pass hasn't — nothing holds entry state.
//
// Excluded from the evict path (everything else is fair game):
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
// (head re-link + filter byte);
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
//
// NOTE the surgical discipline. A previous attempt re-invoked the
// login-only teardown+rescan (`FUN_ADDON_INIT`) mid-/reload and
// corrupted the registry (duplicate loads + Lua memory explosion on the
// NEXT login). This module never runs bulk teardown; the only entry
// mutation beyond what a login scan produces is the per-entry evict
// above, which is (a) the engine's own complete destructor, (b) always
// preceded by the reverse-LoadWith scrub (the one reference the dtor
// can't clean), and (c) always followed by the same fix-ups a new
// registration gets. New entries come from the same parser call
// `Addons::Embedded` has exercised every login.
//
// Thread note: the loose-file index has no lock, but the engine itself
// builds it lazily on first access and we mutate it only during the
// reload loading screen on the main thread — the same context in which
// the engine does all its own file work.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Addons::Rescan {
namespace {
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
using FindOpen_t = void *(__stdcall *)(const char *dirPath);
using FindClose_t = void(__stdcall *)(void *findBlock);
using IndexSubtree_t = void(__fastcall *)(const char *basePath,
const char *relSubdir,
void *findHandle);
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
const char *pattern, void *callback,
void *userParam, int includeHidden);
using ResolveByName_t = void *(__fastcall *)(const char *name);
using DescGrow_t = void(__thiscall *)(void *desc, uint32_t newCap);
using QuantumCalc_t = uint32_t(__thiscall *)(void *desc, uint32_t needed);
using Qsort_t = void(__cdecl *)(void *base, uint32_t num, uint32_t width,
void *compare);
using TocParser_t = void(__fastcall *)(const char *name);
using EntryDestroy_t = void(__stdcall *)(void *entry);
// The engine's ubiquitous growable-array descriptor.
struct Desc {
uint32_t cap;
uint32_t count;
uint32_t *data;
uint32_t quantum;
};
// Append one dword to a descriptor, mirroring the engine's inline
// grow-and-append (the scan tail loop and `FUN_0051DA70` phase 2 share
// it): round the needed cap up to the quantum — computed by the
// engine's own `FUN_DESC_QUANTUM_CALC` when the desc has none — grow
// via the site's grow instantiation (which reallocs `data` and writes
// `cap`), then `data[count++] = value`.
void DescAppend(Desc *desc, uint32_t value, DescGrow_t grow) {
uint32_t needed = desc->count + 1;
if (desc->cap < needed) {
uint32_t quantum = desc->quantum;
if (quantum == 0) {
auto calc =
reinterpret_cast<QuantumCalc_t>(Offsets::FUN_DESC_QUANTUM_CALC);
quantum = calc(desc, needed);
}
if (needed % quantum != 0)
needed += quantum - needed % quantum;
grow(desc, needed);
}
desc->data[desc->count++] = value;
}
// The registry linked-list walk lives in `addons/Registry.h`
// (`Addons::ForEachEntry`), shared with `Addons::Embedded`.
// By-name registry lookup via the engine's own hash resolver (returns
// the entry's RequiredDeps desc, so subtract its offset — same recovery
// `Addons::Info` uses).
uintptr_t ResolveEntryByName(const char *name) {
auto resolve =
reinterpret_cast<ResolveByName_t>(Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
const uintptr_t desc = reinterpret_cast<uintptr_t>(resolve(name));
return desc != 0 ? desc - Offsets::OFF_ADDON_REQDEPS_DESC : 0;
}
// ── Step 1: loose-file index refresh ──────────────────────────────────
// Register new on-disk files under `relSubdir` with the frozen index,
// exactly as the boot walk would have (the indexer skips every file
// already present). No-op when the directory doesn't exist —
// `FUN_VFS_FIND_OPEN` requires an existing directory and returns NULL.
void ReindexSubtree(const char *base, const char *relSubdir) {
char dir[260];
std::snprintf(dir, sizeof(dir), "%s\\%s", base, relSubdir);
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
void *handle = findOpen(dir);
if (handle == nullptr)
return;
auto index =
reinterpret_cast<IndexSubtree_t>(Offsets::FUN_VFS_INDEX_SUBTREE);
index(base, relSubdir, handle);
findClose(handle);
}
void RefreshLooseFileIndex() {
// The boot walk's base choice (`FUN_00646EA0`): the recorded game
// dir if it opens as a directory, else ".". Index keys are relative
// to this, so it must match or the new keys would never be hit.
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
const char *base =
reinterpret_cast<const char *>(Offsets::VAR_VFS_BASE_PATH);
void *probe = findOpen(base);
if (probe != nullptr)
findClose(probe);
else
base = ".";
// The only subtrees whose post-boot changes affect what a /reload
// loads: addon content, and the SavedVariables the unload pass
// wrote moments ago (a first-ever SV file is otherwise invisible —
// the stock client loses a freshly installed addon's settings on
// /reload for exactly this reason).
ReindexSubtree(base, "Interface\\AddOns");
ReindexSubtree(base, "WTF\\Account");
}
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// The embedded addon's name — its entry is owned by `Addons::Embedded`
// (head re-link + filter byte) and never refreshed here.
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
// Per-process on purpose: the registry persists across /reload, and a
// stale hash after a full re-login scan only causes one harmless
// re-parse of already-current metadata.
std::unordered_map<std::string, uint32_t> g_metaHash;
// Read `<name>`'s base TOC exactly as the parser does — through the
// (hooked) `FUN_FILE_READ`, so flavor selection and TOC rewriting are
// reflected in what gets hashed. Caller frees `*buf` via Storm.
bool ReadToc(const char *name, void **buf, size_t *size) {
char path[260];
std::snprintf(path, sizeof(path), "Interface\\AddOns\\%s\\%s.toc", name,
name);
auto read = reinterpret_cast<AddOns::EngineIO::FileReadFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
*buf = nullptr;
*size = 0;
return read(0, path, buf, size, 1, 1, 0) != 0 && *buf != nullptr;
}
// FNV-1a over the TOC's `##` lines only. File-reference lines are
// excluded on purpose — the load pass re-reads those from disk every
// /reload anyway, so an edit there must not trigger the evict path.
// Line terminators are excluded (a CRLF↔LF conversion is not a
// metadata change); a per-line separator keeps adjacent lines from
// concatenating into the same hash. The BOM skip mirrors the parser.
uint32_t HashTocMetadata(const char *buf, size_t size) {
uint32_t h = 2166136261u;
size_t i = 0;
if (size >= 3 && static_cast<uint8_t>(buf[0]) == 0xEF &&
static_cast<uint8_t>(buf[1]) == 0xBB &&
static_cast<uint8_t>(buf[2]) == 0xBF)
i = 3;
while (i < size) {
size_t end = i;
while (end < size && buf[end] != '\n')
++end;
size_t stop = end;
while (stop > i && buf[stop - 1] == '\r')
--stop;
if (stop - i >= 2 && buf[i] == '#' && buf[i + 1] == '#') {
for (size_t k = i; k < stop; ++k) {
h ^= static_cast<uint8_t>(buf[k]);
h *= 16777619u;
}
h ^= '\n';
h *= 16777619u;
}
i = end + 1;
}
return h;
}
// True when the entry never takes the evict path: the embedded addon
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
// parser cannot restore packet-delivered state).
bool IsRefreshExempt(uintptr_t entry, const char *name) {
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
}
// Read + hash `<name>`'s `##` lines. False when the TOC doesn't read.
bool TryHashToc(const char *name, uint32_t *outHash) {
void *buf = nullptr;
size_t size = 0;
if (!ReadToc(name, &buf, &size))
return false;
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return true;
}
// Remove every pointer to `victim` from the OTHER entries'
// reverse-LoadWith lists — the one reference `FUN_ADDON_ENTRY_DESTROY`
// cannot clean (it frees the victim's own list, not the victim's
// presence in others'). Without this, the loader's post-ADDON_LOADED
// loop dereferences freed memory.
void ScrubReverseLoadWith(uintptr_t victim) {
ForEachEntry([victim](uintptr_t entry) {
if (entry == victim)
return;
auto *desc = reinterpret_cast<Desc *>(
entry + Offsets::OFF_ADDON_REVLOADWITH_DESC);
uint32_t w = 0;
for (uint32_t r = 0; r < desc->count; ++r)
if (desc->data[r] != static_cast<uint32_t>(victim))
desc->data[w++] = desc->data[r];
desc->count = w;
});
}
// The refresh pass. Walks the registry comparing each entry's current
// `##` hash against the last-parsed one; on change (or an unreadable
// TOC — deleted folder), evicts via the engine's own destructor and,
// for changes, re-feeds the name to the parser. Re-registered entries
// are appended to `refreshed` so they join the same reverse-LoadWith /
// display-array fix-ups a new folder gets. Returns true when anything
// was actually evicted (the display array holds a freed name pointer
// until it's rebuilt).
//
// Victims are collected during the walk and processed after it — the
// destructor unlinks the entry the iterator stands on (its next-pointer
// is zeroed), so evicting mid-walk would silently truncate the walk.
// Names are copied for the same reason: the dtor frees the entry's
// name string. `g_metaHash` is committed only once the re-registration
// actually happened — updating it during the walk would absorb the edit
// forever if any later step failed, with no retry on the next /reload.
bool RefreshChangedMetadata(std::vector<uintptr_t> &refreshed) {
struct Victim {
std::string name;
uint32_t newHash;
bool reRegister; // false: TOC unreadable — evict only
};
std::vector<Victim> victims;
ForEachEntry([&victims](uintptr_t entry) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (IsRefreshExempt(entry, name))
return;
uint32_t h = 0;
if (!TryHashToc(name, &h)) {
victims.push_back({name, 0, false});
return;
}
auto it = g_metaHash.find(name);
if (it == g_metaHash.end())
g_metaHash.emplace(name, h); // first sighting — seed only
else if (it->second != h)
victims.push_back({name, h, true});
});
bool anyEvicted = false;
auto destroy =
reinterpret_cast<EntryDestroy_t>(Offsets::FUN_ADDON_ENTRY_DESTROY);
auto parse = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
for (const Victim &v : victims) {
const uintptr_t entry = ResolveEntryByName(v.name.c_str());
if (entry == 0)
continue;
ScrubReverseLoadWith(entry);
destroy(reinterpret_cast<void *>(entry));
anyEvicted = true;
if (!v.reRegister) {
g_metaHash.erase(v.name);
continue;
}
// The parser appends the fresh entry at the list TAIL, so a
// refreshed addon's position in the load pass changes for this
// reload. Declared dependencies still force-load first; only
// undeclared load-order assumptions between unrelated addons
// could observe the difference.
parse(v.name.c_str());
const uintptr_t fresh = ResolveEntryByName(v.name.c_str());
if (fresh != 0) {
refreshed.push_back(fresh);
g_metaHash[v.name] = v.newHash;
} else {
// Evicted but the parse didn't restore it (TOC became
// unreadable between hash and parse) — treat as deleted so
// a reappearing folder re-seeds instead of diffing against
// a hash the registry never absorbed.
g_metaHash.erase(v.name);
}
}
return anyEvicted;
}
// ── Step 2: registry rescan + mirrored fix-ups ────────────────────────
// The scan tail loop's reverse-LoadWith build, restricted to pairs
// involving a newly registered entry. Login already linked old→old
// pairs; a pair is missing exactly when one side is new (the login
// walk skipped `## LoadWith:` names that didn't resolve, and new
// entries were never walked at all) — so appending just those pairs
// reproduces what a login scan would have built, with no duplicates in
// the engine's no-dedup lists.
void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
auto isNew = [&added](uintptr_t e) {
return std::find(added.begin(), added.end(), e) != added.end();
};
auto grow =
reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_REVLOADWITH_GROW);
ForEachEntry([&](uintptr_t entry) {
const bool entryIsNew = isNew(entry);
const uint32_t count = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_LOADWITH_COUNT);
auto names = *reinterpret_cast<const char *const *const *>(
entry + Offsets::OFF_ADDON_LOADWITH_ARRAY);
for (uint32_t i = 0; i < count; ++i) {
const uintptr_t target = ResolveEntryByName(names[i]);
if (target == 0 || (!entryIsNew && !isNew(target)))
continue;
DescAppend(reinterpret_cast<Desc *>(
target + Offsets::OFF_ADDON_REVLOADWITH_DESC),
static_cast<uint32_t>(entry), grow);
}
});
}
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
// sort with the engine's own comparator to restore alphabetical order.
void RebuildDisplayArray() {
auto desc = reinterpret_cast<Desc *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_ARRAY_CAP));
auto grow = reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_ARRAY_GROW);
desc->count = 0;
ForEachEntry([&](uintptr_t entry) {
if (*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) != 0)
return;
DescAppend(desc,
*reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR),
grow);
});
auto qsort = reinterpret_cast<Qsort_t>(Offsets::FUN_CRT_QSORT);
qsort(desc->data, desc->count, 4,
reinterpret_cast<void *>(Offsets::FUN_ADDON_NAME_COMPARE));
}
void Run() {
// Registry populated (login scan ran) and loose index built — both
// always true by the first in-world LoadScriptFunctions, but these
// are the states the steps below mutate, so gate explicitly.
if (*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0 ||
*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_VFS_INDEX_READY)) == 0)
return;
RefreshLooseFileIndex();
// Metadata refresh runs BEFORE the snapshot: a re-registered entry
// is alive by snapshot time, so it lands in `before` and can never
// double-count in `added` — even if the allocator reuses the old
// entry's address, `refreshed` tracks it independently of the diff.
std::vector<uintptr_t> refreshed;
const bool evicted = RefreshChangedMetadata(refreshed);
std::vector<uintptr_t> before;
ForEachEntry([&before](uintptr_t entry) { before.push_back(entry); });
// Replay login scan walk #2 verbatim: engine walker + engine
// callback + engine parser. The parser's dedup guard makes this a
// hash lookup per already-registered addon; new folders register
// as complete, normal entries (their TOC is readable now — and it
// goes through the FlavorToc/TocRewrite read hooks like any other).
//
// The dedup guard is why this replay can NEVER refresh or corrupt
// an existing entry's `##` metadata (verified in the parser's
// decompile): the guard is the function's FIRST block — hash
// lookup + name compare, early `return` on match BEFORE the TOC
// path is even built, so no file read and no entry write happen
// for a registered name. Both the Storm hash (`FUN_0064B3F0`,
// uppercases a–z and folds '/'→'\\' before hashing) and the
// compare (`SStrCmpI`) are case-insensitive, so a case-renamed
// folder still matches its existing entry rather than registering
// a duplicate.
auto scan =
reinterpret_cast<ScanDiskDirs_t>(Offsets::FUN_ADDON_SCAN_DISK_DIRS);
scan(reinterpret_cast<const char *>(Offsets::VAR_ADDON_PATH_PREFIX),
reinterpret_cast<const char *>(Offsets::VAR_ADDON_SCAN_PATTERN),
reinterpret_cast<void *>(Offsets::FUN_ADDON_DISK_DIR_CB),
/*userParam=*/nullptr, /*includeHidden=*/0);
std::vector<uintptr_t> added;
ForEachEntry([&](uintptr_t entry) {
if (std::find(before.begin(), before.end(), entry) == before.end())
added.push_back(entry);
});
// Seed the metadata map for entries this reload registered, so the
// next reload diffs a `##` edit against what was actually parsed
// (waiting for the next pass to seed would silently absorb an edit
// made between now and then).
for (uintptr_t entry : added) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
uint32_t h = 0;
if (!IsRefreshExempt(entry, name) && TryHashToc(name, &h))
g_metaHash[name] = h;
}
if (added.empty() && refreshed.empty() && !evicted)
return;
// Refreshed entries need the same link fix-ups a new folder gets:
// their old reverse-LoadWith pairs died with the old entry (scrub +
// dtor), and the parser doesn't build reverse links.
std::vector<uintptr_t> linked = added;
linked.insert(linked.end(), refreshed.begin(), refreshed.end());
if (!linked.empty())
FixupReverseLoadWith(linked);
// Rebuild whenever membership OR an entry identity changed — after
// any eviction (including a pure deletion) the display array still
// holds the dead entry's freed name pointer.
RebuildDisplayArray();
}
const Game::ModuleAutoRegister _autoreg{&Run};
} // namespace
} // namespace Addons::Rescan
+1 -5
View File
@@ -46,14 +46,10 @@ namespace Aura::Api {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
+13 -11
View File
@@ -77,13 +77,9 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
Offsets::OFF_SPELLICON_PATH);
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
// Returns the local player's CGUnit pointer, or nullptr pre-login.
const uint8_t *LocalPlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn("player"));
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
}
// Looks up the player-buff-table entry for a given spellID. The table
@@ -451,9 +447,15 @@ const char *DispelName(uint32_t dispelTypeID) {
// The display fields resolved from a spell record + caster, shared by the table
// emitter (`BuildTable`) and the positional emitter (`PushPositional`) so the
// name / icon / dispel-name lookups live in exactly one place. `castByPlayer` is
// the positional tuple's field #13; the table uses `isFromPlayerOrPlayerPet`
// instead, mirroring retail's own divergence between the two aura shapes.
// name / icon / dispel-name lookups live in exactly one place. `castByPlayer`
// is the positional tuple's field #13 and the table's
// `isFromPlayerOrPlayerPet` — same value, retail's two names for the two aura
// shapes. Both mean "applied by ANY player or a player's pet" (Era
// semantics), not the local player — that's the `PLAYER` filter's job. The
// answer is the caster GUID's HIGHGUID prefix: player 0x0000 / pet 0xF140
// (server-confirmed in tortoise-wow ObjectGuid.h). Vanilla totems carry the
// creature prefix, so totem buffs read false. False on a cache miss (cast not
// observed), since a GUID of 0 classifies as neither.
struct Display {
const char *name;
const char *icon;
@@ -472,8 +474,7 @@ static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid) {
d.name = LocalizedSpellName(spellRecord);
d.icon = SpellIconPath(spellRecord);
d.dispelName = DispelName(dispelTypeID);
d.castByPlayer =
casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
d.castByPlayer = Guid::IsPlayer(casterGuid) || Guid::IsPet(casterGuid);
return d;
}
@@ -523,10 +524,11 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", disp.castByPlayer);
// Boolean fields whose modern semantics don't apply in vanilla.
Game::Lua::SetFieldBool(L, "isStealable", false);
Game::Lua::SetFieldBool(L, "isBossAura", false);
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", false);
Game::Lua::SetFieldBool(L, "isNameplateOnly", false);
Game::Lua::SetFieldBool(L, "nameplateShowAll", false);
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
+78
View File
@@ -0,0 +1,78 @@
// 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/>.
// Paladin judgement melee refresh — client mirror of the server's white-swing
// rule (pfUI#45: the judgement debuff's timer ran to zero while the debuff
// stayed up through the paladin's auto-attacks).
//
// The server rule (tortoise-wow Unit::DealMeleeDamage, vmangos lineage): a
// melee swing that dealt ANY damage (`totalDamage > 0` — misses/dodges/parries
// deal none) refreshes every aura on the victim that the attacker cast whose
// spell is SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT — the judgement
// marker (all 14 Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry
// it, verified in the client Spell.dbc). RefreshHolder resets the holder to
// its own full duration.
//
// The edit itself is packet-silent (UpdateAuraDuration is self-scoped — the
// same blindness every Turtle::DurationMods rule works around), but its
// TRIGGER is not: DealMeleeDamage's only caller is Unit::AttackerStateUpdate
// (white swings), which then broadcasts SMSG_ATTACKERSTATEUPDATE to everyone
// in range — attacker, victim, and totalDamage are its first fields (wire
// format verified against the server's own SendAttackStateUpdate writer; see
// Offsets.h at SMSG_ATTACKERSTATEUPDATE). So the swing packet is an exact
// client-side stand-in for the refresh, for ANY paladin's judgements — ours
// or a raid member's.
//
// Ability damage (Crusader/Holy Strike) does NOT reach that server block —
// melee-class spell damage never routes through DealMeleeDamage on this core
// — so no rule is registered for it. Re-judging is covered separately: the
// judgement debuff is cast as a real triggered spell, its SMSG_SPELL_GO
// refreshes the cache entry via StoreFromCast.
//
// Not gated on Turtle::Detected(): the block is vmangos-lineage core (the
// classic "judgements are refreshed by the paladin's melee attacks"
// mechanic), and the refresh only ever touches existing judgement entries
// with a matching known caster — on a server without the mechanic the debuff
// would genuinely expire, removing the entry via OnAuraRemoved before drift
// could show.
#include "Offsets.h"
#include "aura/Source.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include <cstdint>
namespace Aura::JudgementRefresh {
namespace {
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_ATTACKERSTATEUPDATE || packet == nullptr)
return;
Net::Read<uint32_t>(packet); // hitInfo — the damage gate below subsumes it
const uint64_t attacker = Net::ReadPackedGuid(packet);
const uint64_t victim = Net::ReadPackedGuid(packet);
const uint32_t totalDamage = Net::Read<uint32_t>(packet);
// The server's exact gate: any nonzero melee damage refreshes; a fully
// avoided/absorbed swing (totalDamage 0) does not.
if (totalDamage == 0 || attacker == 0 || victim == 0)
return;
Aura::Source::RefreshJudgements(victim, attacker);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace Aura::JudgementRefresh
+627 -13
View File
@@ -238,14 +238,28 @@ Entry *FindBySlot(uint64_t targetGuid, uint32_t spellId, int slot) {
return nullptr;
}
// The newest cast capture for this aura still awaiting a descriptor slot —
// what an `OnAuraAdded` for `(target, spell)` is seating. Newest wins because
// SpellGo precedes the application by a packet, so the freshest unbound entry
// is the cast that just landed; an older one is a capture that never seated
// (fully resisted, immune) and is left to expire on its own. "Newest" is the
// smallest `Time::Clock::Elapsed(stamp, now)` — a wrap-safe signed difference;
// a raw `stampMs >` compare inverts across the 2^32 ms tick wrap.
Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
// A capture older than this is not the one an arriving application seats. An
// instant aura's application follows its SpellGo within a server tick plus
// latency (a few hundred ms); a projectile's (Fireball's DoT) follows at
// impact — up to ~1.5s of flight at max range. 3s absorbs both plus lag, yet
// stays far below any DoT's duration, so a capture whose application never
// came (hit but aura-rejected: death race, effect-level immunity on a mixed
// spell) can't be seated by a later same-spell cast's application. Fully
// resisted/immune casts never create captures at all — they sit in
// SMSG_SPELL_GO's miss list, and ParseSpellGo reads only the hit list.
constexpr uint32_t kSeatWindowMs = 3000;
// The cast capture an `OnAuraAdded` for `(target, spell)` is seating: the
// OLDEST unbound capture still inside the seat window — FIFO. When two
// casters' same-spellID DoTs land in one descriptor-update batch, the server
// assigned their slots in cast order (`_AddSpellAuraHolder`'s ascending
// first-free-slot scan) and the client's diff dispatcher (`FUN_00604d00`)
// fires OnAuraAdded in ascending slot order — verified: both its loops are
// plain `slot = 0..0x2F` walks. So the FIRST application belongs to the
// EARLIEST cast; seating newest-first paired the two casters' timers and
// attribution backwards. Elapsed times use `Time::Clock::Elapsed` — a raw
// `stampMs` compare inverts across the 2^32 ms tick wrap.
Entry *FindOldestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
Entry *best = nullptr;
uint32_t bestElapsed = 0;
for (int i = 0; i < g_usedHigh; ++i) {
@@ -254,7 +268,9 @@ Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now)
e.slot != SLOT_UNBOUND)
continue;
const uint32_t elapsed = Time::Clock::Elapsed(e.stampMs, now);
if (best == nullptr || elapsed < bestElapsed) {
if (elapsed > kSeatWindowMs)
continue;
if (best == nullptr || elapsed > bestElapsed) {
best = &e;
bestElapsed = elapsed;
}
@@ -340,6 +356,10 @@ Entry *Claim(uint32_t now) {
// ---- Writes --------------------------------------------------------------
// Defined in the duration-modifier section below; used by StoreFromCast's
// refresh path too.
void SignalAuraChanged(uint64_t guid);
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
// Identity is `(target, spell, caster)`, so a second caster of the same spell
// opens its own entry instead of overwriting the first's, and a recast by the
@@ -373,6 +393,12 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
e->casterGuid = casterGuid;
e->expirationMs = expirationMs;
e->durationMs = durationMs;
// An in-place recast refresh changes no descriptor field, so the engine
// fires no UNIT_AURA — event-driven consumers (aura-bar addons counting
// down a once-read duration) would never re-read the new timing. Signal it
// ourselves (deferred to the world tick, coalesced). A NEW application
// doesn't need this: its descriptor write fires the engine's own event.
SignalAuraChanged(targetGuid);
}
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
@@ -396,7 +422,7 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
Entry *e = FindBySlot(targetGuid, spellId, slot);
if (e == nullptr)
e = FindFreshestUnbound(targetGuid, spellId, now);
e = FindOldestUnbound(targetGuid, spellId, now);
if (e == nullptr && slot < 0)
e = FindSole(targetGuid, spellId);
if (e == nullptr) {
@@ -742,6 +768,168 @@ void RegisterDurationModLua() {
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// ---- Tick-speed compression (Turtle: Dark Harvest) -------------------------
//
// A channel that accelerates the caster's periodic auras on its target
// consumes them FASTER on the live server: each dot keeps its tick count, so
// ticking pct% faster drains its real remaining time at (100+pct)% rate while
// the trigger aura is up, and it falls off early by pct% of the overlap — with
// no client-visible aura change whatsoever. (tortoise-wow's recreation only
// re-times the tick timers and leaves holder duration alone, which does NOT
// reproduce the observed early fall-off; the compression model here matches
// Cursive's community calibration — `remaining -= 0.30 × overlap` — and the
// reported ~2.4–3 s loss over a full 8 s Dark Harvest.)
//
// The math is anchored, not incremental: each compressed entry remembers the
// expiration it had when compression began and is set to
// `anchor − elapsed × pct/100` every tick, so frame pacing can't drift it. An
// external write to the entry (dot recast → SpellGo re-stamp, a duration mod)
// is detected by comparing against the last value WE wrote and re-anchors from
// the fresh expiration — matching the server, where a recast dot is a new
// holder that compresses from its own full duration.
struct TickCompression {
uint32_t triggerFamily;
uint64_t triggerMask;
uint32_t affectedFamily;
uint64_t affectedMask;
int32_t pct;
};
constexpr int kCompressionMax = 4;
TickCompression g_compressions[kCompressionMax];
int g_compressionCount = 0;
// Live per-entry compression state, keyed the way the cache identifies an
// aura instance — (target, spell, caster) — so two warlocks' same-spell dots
// on one target compress independently. `lastWrittenMs` is the expiration this
// module wrote last tick — a mismatch means someone else re-stamped the entry.
struct CompressState {
uint64_t targetGuid;
uint64_t casterGuid;
uint32_t spellId;
uint32_t startMs; // compression (re-)anchor time
uint32_t anchorMs; // entry expiration at the anchor
uint32_t lastWrittenMs;
uint32_t lastSignalMs; // event-consumer nudge cadence (~1/s)
bool touched; // trigger still live this tick
bool used;
};
constexpr int kCompressStateMax = 16;
CompressState g_compressStates[kCompressStateMax];
CompressState *FindOrClaimCompressState(uint64_t targetGuid,
uint64_t casterGuid,
uint32_t spellId) {
CompressState *spare = nullptr;
for (auto &s : g_compressStates) {
if (s.used && s.targetGuid == targetGuid &&
s.casterGuid == casterGuid && s.spellId == spellId)
return &s;
if (!s.used && spare == nullptr)
spare = &s;
}
return spare; // unused slot for the caller to claim; nullptr = table full
}
// The server's affected-aura test is on the RUNTIME aura type (PERIODIC_DAMAGE
// / PERIODIC_LEECH), which the record mirrors as EffectApplyAuraName 3 / 53 on
// some effect. Load-bearing beyond the family mask: Turtle's Atrocity (45904)
// shares Corruption's 0x2 bit but is a proc-trigger aura (42) — the mask alone
// would compress it, this gate excludes it exactly as the server's does.
// (Drain Soul passes via its effect-2 periodic damage: aura=[86,3,0].)
bool HasPeriodicDamageOrLeech(const uint8_t *rec) {
if (rec == nullptr)
return false;
constexpr int32_t kPeriodicDamage = 3, kPeriodicLeech = 53;
for (int i = 0; i < 3; ++i) {
const int32_t aura = *reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4);
if (aura == kPeriodicDamage || aura == kPeriodicLeech)
return true;
}
return false;
}
// Compression pairs by CASTER, mirroring the server (each warlock's Dark
// Harvest accelerates that warlock's own dots): the trigger entry's caster
// must own the affected entry too. Another warlock's DH + dots are both
// broadcast SPELL_GO, so their timing compresses for us as well; caster-less
// entries (application seen, cast unobserved) can't be paired and stay
// untouched, same as every other caster-strict mechanic here.
void CompressTarget(const TickCompression &c, uint64_t targetGuid,
uint64_t casterGuid, uint32_t now) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != targetGuid ||
e.casterGuid != casterGuid || e.expirationMs == 0)
continue;
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
if (!AffectedMatchesRaw(rec, c.affectedFamily, c.affectedMask,
/*icon*/ 0) ||
!HasPeriodicDamageOrLeech(rec))
continue;
CompressState *s =
FindOrClaimCompressState(e.targetGuid, e.casterGuid, e.spellId);
if (s == nullptr)
continue;
if (!s->used) {
*s = {e.targetGuid, e.casterGuid, e.spellId, now,
e.expirationMs, e.expirationMs, 0, false, true};
} else if (e.expirationMs != s->lastWrittenMs) {
s->startMs = now; // externally re-stamped — restart from fresh
s->anchorMs = e.expirationMs;
}
s->touched = true;
const uint32_t cut = static_cast<uint32_t>(
static_cast<uint64_t>(now - s->startMs) *
static_cast<uint32_t>(c.pct) / 100);
e.expirationMs = s->anchorMs - cut;
s->lastWrittenMs = e.expirationMs;
// Event-driven consumers (aura bars that read once and count down
// locally) need periodic nudges to re-read the moving expiration;
// pollers pick it up on their own. First write nudges immediately.
if (s->lastSignalMs == 0 || now - s->lastSignalMs >= 1000) {
s->lastSignalMs = now;
SignalAuraChanged(e.targetGuid);
}
}
}
void ApplyTickCompressions(uint32_t now) {
if (g_compressionCount == 0)
return;
for (auto &s : g_compressStates)
s.touched = false;
for (int r = 0; r < g_compressionCount; ++r) {
const TickCompression &c = g_compressions[r];
for (int i = 0; i < g_usedHigh; ++i) {
const Entry &t = g_cache[i];
if (!t.used || t.casterGuid == 0)
continue;
// The trigger aura is live exactly while its entry is: unexpired
// (channel pushback already folded in via RestampPlayerChannel)
// and not yet evicted by OnAuraRemoved (interrupt / early end /
// target death).
if (t.expirationMs != 0 &&
Time::Clock::Reached(now, t.expirationMs))
continue;
if (!Spell::Lookup::IsFitToFamily(
Spell::Lookup::RecordForID(static_cast<int>(t.spellId)),
c.triggerFamily, c.triggerMask))
continue;
CompressTarget(c, t.targetGuid, t.casterGuid, now);
}
}
// Trigger gone → the entry keeps whatever expiration it drained to; one
// last nudge publishes the final position to event-driven consumers.
for (auto &s : g_compressStates) {
if (s.used && !s.touched) {
SignalAuraChanged(s.targetGuid);
s.used = false;
}
}
}
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (int i = 0; i < g_usedHigh; ++i)
@@ -751,6 +939,9 @@ void FlushAll() {
// should be treated as first-sight (unknown age), not diffed as new.
for (auto &s : g_groupSnaps)
s.used = false;
// Compressed entries are gone with the cache; their states go too.
for (auto &s : g_compressStates)
s.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
@@ -782,6 +973,9 @@ void OnWorldTick() {
}
const uint32_t now = NowMs();
// Tick-speed compression first, so a drained expiration is visible to the
// same tick's eviction sweep and signal flush.
ApplyTickCompressions(now);
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.expirationMs == 0 || !Time::Clock::Reached(now, e.expirationMs))
@@ -810,6 +1004,279 @@ void OnWorldTick() {
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// ---- Paladin judgement identity + refresh (pfUI#45) ----------------------
//
// The judgement debuff (Judgement of Light/Wisdom/Justice/Crusader) is cast
// SERVER-side as a triggered spell whose SMSG_SPELL_GO never reaches the
// client, so its cache entry is seated caster-less by OnAuraAdded — and the
// server's refresh mechanics are keyed by caster. The one attributable
// signal is the Judgement CAST itself (SPELL_PALADIN_JUDGEMENT, the single
// spell every seal judges through): its SPELL_GO names caster and victim.
// HandleSpellGo uses it to (a) refresh existing judgement entries WITH
// adoption — a re-judge is an in-place RefreshHolder server-side, no
// descriptor change, no OnAuraAdded — and (b) arm a short window so the
// fresh debuff's application gets attributed to the judging caster. From
// then on the caster-STRICT swing refresh (Aura::JudgementRefresh's
// SMSG_ATTACKERSTATEUPDATE subscriber) can do its job.
constexpr uint32_t kSpellJudgement = 20271; // server SPELL_PALADIN_JUDGEMENT
// The server's judgement-debuff selector, verbatim from its melee-refresh
// block (tortoise-wow Unit::DealMeleeDamage): SPELLFAMILY_PALADIN +
// SPELL_ATTR_EX3_ALWAYS_HIT. All 14 debuff ranks carry it (client DBC
// verified); no shared family-flag mask exists (each seal's debuff has its
// own bit), which is why this isn't an AddDurationMod rule.
bool IsJudgementSpell(const uint8_t *rec) {
constexpr uint32_t kPaladinFamily = 10; // SPELLFAMILY_PALADIN
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != kPaladinFamily)
return false;
return (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3) &
Offsets::SPELL_ATTR_EX3_ALWAYS_HIT) != 0;
}
// Armed attribution: a Judgement cast went off from `caster` at `victim`;
// the triggered debuff's OnAuraAdded lands ~a server tick later (SPELL_GO
// precedes the aura's SMSG_UPDATE_OBJECT — the same ordering kEvictGraceMs
// exists for). One-shot, short TTL so a judge whose debuff never lands
// can't mis-attribute a later application.
struct JudgeArm {
uint64_t victim;
uint64_t caster;
uint32_t untilMs;
};
constexpr int kJudgeArmMax = 4; // simultaneous judging paladins in view
constexpr uint32_t kJudgeArmTtlMs = 2000;
JudgeArm g_judgeArms[kJudgeArmMax];
void ArmJudgementAttribution(uint64_t victim, uint64_t caster) {
const uint32_t now = NowMs();
JudgeArm *slot = nullptr;
for (auto &a : g_judgeArms) {
if (a.victim == victim && a.caster == caster) {
slot = &a; // re-judge inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_judgeArms[0]; // all live (>4 paladins?) — steal the first
*slot = {victim, caster, now + kJudgeArmTtlMs};
}
// The caster armed for `victim`, or 0. One-shot: consumed on hit.
uint64_t ConsumeJudgementAttribution(uint64_t victim, uint32_t now) {
for (auto &a : g_judgeArms) {
if (a.victim != victim || a.victim == 0 ||
Time::Clock::Reached(now, a.untilMs))
continue;
const uint64_t caster = a.caster;
a = {};
return caster;
}
return 0;
}
// Shared body of the two refresh triggers. `adopt` lets the Judgement-cast
// path claim a caster-less entry (the cast is proof of ownership); the swing
// path stays caster-strict — a swing gives no proof, and adopting there
// would let any tank's white swings pin another paladin's judgement timer.
int RefreshJudgementsImpl(uint64_t unitGuid, uint64_t attackerGuid, bool adopt) {
if (unitGuid == 0 || attackerGuid == 0)
return 0;
const uint32_t now = NowMs();
int refreshed = 0;
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid)
continue;
if (e.casterGuid != attackerGuid && !(adopt && e.casterGuid == 0))
continue;
if (e.durationMs == 0)
continue; // never invent a duration
if (!IsJudgementSpell(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId))))
continue;
if (e.casterGuid == 0)
e.casterGuid = attackerGuid; // adoption claim
e.expirationMs = now + e.durationMs; // RefreshHolder → own duration
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
++refreshed;
}
return refreshed;
}
// ---- Triggered applications (server AddAura inference) --------------------
//
// Some server mechanics APPLY an aura as a side effect of another spell via a
// direct AddAura — no cast, no SMSG_SPELL_GO for the applied aura, and a
// duration the packet stream never carries. The application itself reaches
// the client only as a descriptor change (OnAuraAdded → caster-less, base
// duration). But the TRIGGER's SMSG_SPELL_GO is visible, so — same shape as
// the judgement attribution above — a registered trigger cast by the local
// player arms its hit targets for a short window, and the caster-less
// application that follows consumes the arm: player attribution + the rule's
// percentage of the aura's own (player-modified) base duration, mirroring
// the server's `max(1, CalculateDuration(caster) * pct / 100)`.
//
// Registered from src/turtle (Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40%). Local
// player only — a rule may be gated on a talent-granted passive
// (`gateSpellId`), and other players' talents aren't client-knowable.
struct TriggeredApplication {
uint32_t triggerSpellId; // exact trigger spell; 0 = match by family+mask below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
uint64_t triggerMask; // (triggerSpellId==0) family-flag overlap — rank-proof
uint32_t gateSpellId; // rule live only while the player knows this; 0 = always
uint32_t affectedFamily; // applied aura's SpellFamilyName
uint64_t affectedMask; // + family-flag overlap
int32_t durationPct; // applied duration = pct% of its own base
};
constexpr int kTrigAppMax = 32;
TriggeredApplication g_trigApps[kTrigAppMax];
int g_trigAppCount = 0;
// Talent gate: does the player currently know `spellID`? The engine's
// spell-knowledge bitmap (same store IsPlayerSpell reads — covers talent
// passives), checked live at trigger time so respecs are honored.
bool PlayerKnowsSpell(uint32_t spellID) {
if (spellID == 0)
return false;
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const uint32_t spellCount = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// Armed rule instance awaiting its application on `victim`. Caster is
// implicitly the local player (only player triggers arm).
struct TrigAppArm {
uint64_t victim; // 0 = empty
uint32_t affectedFamily;
uint64_t affectedMask;
int32_t durationPct;
uint32_t untilMs;
};
constexpr int kTrigAppArmMax = 8;
constexpr uint32_t kTrigAppArmTtlMs = 2000;
TrigAppArm g_trigAppArms[kTrigAppArmMax];
// Mirror the server's re-apply on an EXISTING matching aura at trigger time
// (tortoise-wow ApplyStingingNettle): an existing holder from this caster
// whose remaining time exceeds the scaled duration is kept; otherwise the
// server re-AddAura's at the scaled duration — a remove+add of the same
// spellID that reuses the descriptor slot, so NO OnAuraAdded fires and the
// armed window is never consumed (verified in-game: chained Mongoose Bites
// left the first application's timer running out). The trigger is the only
// audible signal, so the refresh happens here. A caster-less matching entry
// is adopted and stamped unconditionally: it's a nettle application whose
// arm was missed, seated by the application hook at the WRONG base duration,
// so its remaining time can't gate the mirror — and the player's trigger is
// proof of ownership, same as the judgement-cast adoption.
void RefreshTriggeredExisting(uint64_t victim, const TriggeredApplication &r,
uint32_t now) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != victim)
continue;
const bool mine = e.casterGuid == Unit::Identity::PlayerGuid();
if (!mine && e.casterGuid != 0)
continue; // another caster's aura — not ours to touch
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
if (!Spell::Lookup::IsFitToFamily(rec, r.affectedFamily,
r.affectedMask))
continue;
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ true);
if (base == 0)
continue;
uint32_t newMs = base * static_cast<uint32_t>(r.durationPct) / 100;
if (newMs == 0)
newMs = 1;
// The server's keep-the-longer guard, applied only to trusted
// (player-stamped) timing. An elapsed timer yields Remaining 0 and
// refreshes — the invisible server re-adds are exactly what let it
// elapse while the aura stayed up.
if (mine && e.expirationMs != 0 &&
Time::Clock::Remaining(now, e.expirationMs) > newMs)
continue;
e.casterGuid = Unit::Identity::PlayerGuid();
e.durationMs = newMs;
e.expirationMs = now + newMs;
e.stampMs = now;
SignalAuraChanged(victim);
break; // one aura instance per (target, spell, caster) — server touches one holder
}
}
// A player trigger cast landed on `victim`: arm the first live rule for this
// trigger (rules sharing a trigger are registration-ordered — the higher
// talent rank registers first, so its gate wins while known). Also refreshes
// an existing matching aura in place (see RefreshTriggeredExisting) — the arm
// only covers applications that reach the descriptor as a change.
void ArmTriggeredApplications(uint64_t victim, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
for (int i = 0; i < g_trigAppCount; ++i) {
const TriggeredApplication &r = g_trigApps[i];
if (r.triggerSpellId != 0
? r.triggerSpellId != triggerSpellId
: !Spell::Lookup::IsFitToFamily(triggerRec, r.triggerFamily,
r.triggerMask))
continue;
if (r.gateSpellId != 0 && !PlayerKnowsSpell(r.gateSpellId))
continue;
const uint32_t now = NowMs();
RefreshTriggeredExisting(victim, r, now);
TrigAppArm *slot = nullptr;
for (auto &a : g_trigAppArms) {
if (a.victim == victim && a.affectedFamily == r.affectedFamily &&
a.affectedMask == r.affectedMask) {
slot = &a; // re-trigger inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_trigAppArms[0]; // all live — steal the first
*slot = {victim, r.affectedFamily, r.affectedMask, r.durationPct,
now + kTrigAppArmTtlMs};
return;
}
}
// The armed duration percent for a caster-less application of `rec` on
// `victim`, or 0. One-shot: consumed on hit.
int32_t ConsumeTriggeredApplication(uint64_t victim, const uint8_t *rec,
uint32_t now) {
if (victim == 0 || rec == nullptr)
return 0;
for (auto &a : g_trigAppArms) {
if (a.victim != victim || Time::Clock::Reached(now, a.untilMs))
continue;
if (!Spell::Lookup::IsFitToFamily(rec, a.affectedFamily,
a.affectedMask))
continue;
const int32_t pct = a.durationPct;
a = {};
return pct;
}
return 0;
}
// ---- SMSG_SPELL_GO co-hook ----------------------------------------------
// The hit-target list is all we need: each entry is a unit the cast landed
@@ -847,7 +1314,27 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
// Judgement: refresh-with-adoption for a re-judge (in-place server
// refresh, no aura event) and arm attribution for the fresh debuff that
// follows. Before the aura gate — Judgement itself applies no aura.
if (spellId == kSpellJudgement) {
for (int i = 0; i < numTargets; ++i) {
RefreshJudgementsImpl(targets[i], caster, /*adopt*/ true);
ArmJudgementAttribution(targets[i], caster);
}
}
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
// Triggered-application rules (Stinging Nettle): a registered trigger
// cast by the local player arms its hit targets so the server-added aura
// that follows (no SPELL_GO of its own) gets player attribution + the
// rule's scaled duration. Before the aura gate — Mongoose Bite applies
// no aura of its own.
if (g_trigAppCount != 0 && caster == Unit::Identity::PlayerGuid())
for (int i = 0; i < numTargets; ++i)
ArmTriggeredApplications(targets[i], spellId, rec);
if (!SpellAppliesAura(rec))
return;
@@ -933,9 +1420,38 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
// (and attribute the caster) — SpellGo couldn't always land the talented
// value on this target's entry. Otherwise base (we lack other casters'
// mods). See WasRecentPlayerCast.
const bool byPlayer = WasRecentPlayerCast(spellId);
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
uint64_t caster = 0;
uint32_t durationMs = 0;
// A triggered-application arm (Stinging Nettle): the server AddAura'd
// this aura off the player's trigger cast at a scaled duration —
// attribute the player and mirror the server's max(1, base × pct / 100),
// player-modified base since the player is the caster. Checked BEFORE
// the recent-cast window below: the arm names this exact TARGET, while
// WasRecentPlayerCast is spell-scoped only — a real Serpent Sting cast
// at unit A must not stamp its full duration onto a nettle application
// landing on unit B moments later. The order is safe for a real cast's
// own application: its entry is already cast-owned (StoreFromCast), and
// StoreFromApplication never overwrites an owned entry's timing.
if (const int32_t pct = ConsumeTriggeredApplication(unitGuid, rec, NowMs())) {
const uint32_t playerBase = SpellDurationMs(rec, /*player*/ true);
if (playerBase > 0) {
caster = Unit::Identity::PlayerGuid();
durationMs = playerBase * static_cast<uint32_t>(pct) / 100;
if (durationMs == 0)
durationMs = 1;
}
}
if (caster == 0) {
const bool byPlayer = WasRecentPlayerCast(spellId);
durationMs = SpellDurationMs(rec, byPlayer);
caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
// A judgement debuff is cast server-side (no SPELL_GO, so neither
// path above can name its caster) — attribute it to the paladin
// whose Judgement cast just armed this victim. See the judgement
// section.
if (caster == 0 && IsJudgementSpell(rec))
caster = ConsumeJudgementAttribution(unitGuid, NowMs());
}
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
slot, KindForSlot(slot));
@@ -1043,6 +1559,85 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
affectedIcon, op, valueMs);
}
namespace {
bool RegisterTriggeredApplication(uint32_t triggerSpellId,
uint32_t triggerFamily, uint64_t triggerMask,
uint32_t gateSpellId, uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && (triggerFamily == 0 || triggerMask == 0)) ||
affectedMask == 0 || durationPct <= 0)
return false;
for (int i = 0; i < g_trigAppCount; ++i) { // replace an identical rule
TriggeredApplication &r = g_trigApps[i];
if (r.triggerSpellId == triggerSpellId &&
r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
r.gateSpellId == gateSpellId && r.affectedFamily == affectedFamily &&
r.affectedMask == affectedMask) {
r.durationPct = durationPct;
return true;
}
}
if (g_trigAppCount >= kTrigAppMax)
return false;
g_trigApps[g_trigAppCount++] = {triggerSpellId, triggerFamily, triggerMask,
gateSpellId, affectedFamily,
affectedMask, durationPct};
return true;
}
} // namespace
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct) {
return RegisterTriggeredApplication(triggerSpellId, /*triggerFamily*/ 0,
/*triggerMask*/ 0, gateSpellId,
affectedFamily, affectedMask,
durationPct);
}
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask,
int32_t durationPct) {
return RegisterTriggeredApplication(/*triggerSpellId*/ 0, triggerFamily,
triggerMask, gateSpellId,
affectedFamily, affectedMask,
durationPct);
}
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t pct) {
if (triggerFamily == 0 || triggerMask == 0 || affectedMask == 0 ||
pct <= 0 || pct > 100)
return false;
for (int i = 0; i < g_compressionCount; ++i) { // replace an identical rule
TickCompression &r = g_compressions[i];
if (r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
r.affectedFamily == affectedFamily &&
r.affectedMask == affectedMask) {
r.pct = pct;
return true;
}
}
if (g_compressionCount >= kCompressionMax)
return false;
g_compressions[g_compressionCount++] = {triggerFamily, triggerMask,
affectedFamily, affectedMask, pct};
return true;
}
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
// Caster-strict (no adoption) — the Judgement-cast path inside
// HandleSpellGo is the one with proof of ownership; see
// RefreshJudgementsImpl.
return RefreshJudgementsImpl(unitGuid, attackerGuid, /*adopt*/ false);
}
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid) {
@@ -1077,6 +1672,25 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
return 0;
}
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs) {
if (spellId == 0 || remainingMs == 0)
return;
const uint64_t player = Unit::Identity::PlayerGuid();
if (player == 0)
return;
const uint32_t now = NowMs();
// Every hit target's entry, matching DelaySpellAuraHolder's loop over the
// channel's m_UniqueTargetInfo (an AoE channel shortens them all).
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.casterGuid != player || e.spellId != spellId)
continue;
e.expirationMs = now + remainingMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
}
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds) {
if (unitGuid == 0 || slotSpellIds == nullptr)
return;
+82
View File
@@ -69,6 +69,36 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid);
// Mirrors the server's melee-swing judgement refresh (tortoise-wow
// Unit::DealMeleeDamage): a white swing from `attackerGuid` that dealt any
// damage refreshes every aura it cast on `unitGuid` whose spell is
// SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT (the judgement-debuff
// marker — all Judgement of Light/Wisdom/Justice/Crusader ranks) back to its
// own applied duration. Caster-strict: entries with an unknown caster are
// left alone (a swing gives no proof of ownership). The caster comes from
// the Judgement-cast attribution inside the SpellGo path — the debuff itself
// is cast server-side with no client-visible SPELL_GO, so the Judgement cast
// (20271) is what names the owner: it refresh-with-adoption's existing
// entries (re-judge = in-place server refresh, no aura event) and arms the
// fresh application's attribution. Returns the number refreshed. Fed by
// Aura::JudgementRefresh's SMSG_ATTACKERSTATEUPDATE subscriber.
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid);
// Re-anchors the expiration of every cached aura the local player cast with
// `spellId` to `now + remainingMs`. Mirrors the server's channel pushback
// (Spell::DelayedChannel → Unit::DelaySpellAuraHolder): a hit shortens the
// channel AND every hit target's holder of the channel spell by the same
// amount, then tells the caster the new remaining time via MSG_CHANNEL_UPDATE
// — the packet Spell::Cast feeds this from. Without it a pushed-back channel's
// target-side aura (Dark Harvest, the drains) keeps its full-length SpellGo
// expiration and reads late by the accumulated pushback. `durationMs` (the
// applied total, the bar's full width) is deliberately untouched. Self-target
// holders are also corrected natively by the server (it re-sends
// SMSG_UPDATE_AURA_DURATION), so restamping those entries just keeps the two
// sources agreeing. Caster-only packet ⇒ only the player's channels are
// correctable; remote pushback stays invisible (see spell/Cast.cpp).
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs);
// Op codes for AddDurationMod (mirror the Lua op strings).
enum DurationModOp {
DURATION_MOD_REFRESH = 0,
@@ -88,6 +118,58 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs);
// Register a server "triggered application" rule from C++: `triggerSpellId`'s
// SMSG_SPELL_GO — local player casts only — arms its hit targets for a short
// window, and a caster-less aura application on an armed target matching
// `affectedFamily` + a family-flag overlap with `affectedMask` is attributed
// to the player at `durationPct` percent of the aura's own player-modified
// base duration (the server's `max(1, CalculateDuration(caster) * pct / 100)`).
// For server mechanics that AddAura an aura off another spell's hit with no
// cast packet of its own (Turtle's Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40% duration).
// A re-trigger over an EXISTING matching aura is also handled: the server's
// re-add reuses the descriptor slot (no application event), so the trigger
// additionally refreshes the player's existing cached entry in place,
// honoring the server's keep-the-longer-remaining guard.
// `gateSpellId` (0 = none) keeps the rule live only while the player knows
// that spell (a talent-granted passive), read from the spell-knowledge bitmap
// at trigger time so respecs are honored; rules matching the same trigger are
// tried in registration order and the first live one arms (register the
// higher talent rank first). Returns false on bad input or a full table.
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct);
// Family-mask trigger form of the above: the trigger matches any cast whose
// spell is SpellFamilyName `triggerFamily` with a family-flag overlap of
// `triggerMask` — rank-proof when the bit selects exactly the trigger set
// (verify against the DBC that no unrelated spell shares it first: hunter
// 0x4 is exactly the fire-trap effects, but 0x2 is Mongoose Bite AND Raptor
// Strike, so that trigger needs the exact-ID form).
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct);
// Register a tick-speed compression rule from C++: while an aura matching
// (triggerFamily, triggerMask) is live on a target, every cached aura on that
// target CAST BY THE SAME CASTER matching (affectedFamily, affectedMask) — and
// whose record carries a periodic damage/leech effect, the server's aura-type
// gate — drains an extra `pct`% of real time: its expiration moves earlier by
// pct% of the elapsed overlap. Mirrors a server that preserves an accelerated
// dot's tick COUNT, so ticking pct% faster consumes it pct% faster with no
// client-visible aura change (Turtle's Dark Harvest; see
// src/turtle/DarkHarvest.cpp for the live-behavior derivation). Caster-paired
// like the server's own check, so another warlock's observed DH compresses
// THEIR dots' cached timing too; caster-less entries never compress. The
// trigger's cache-entry lifetime is the compression window — created by its
// SpellGo, shortened by channel pushback (RestampPlayerChannel), evicted by
// OnAuraRemoved on interrupt / early end — so the window tracks every edge the
// server's has. Returns false on bad input or a full table.
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t pct);
// One cached aura, as returned by `Enumerate`.
struct CachedAura {
uint32_t spellId;
+111
View File
@@ -0,0 +1,111 @@
// 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/>.
// `StartAttack([target])` / `StopAttack()` — the non-toggling melee
// auto-attack verbs. Vanilla 1.12 ships only `AttackTarget()`, which
// *toggles* (start <-> stop each call), so a `/startattack` can't guarantee
// it won't cancel an in-progress swing. These split the engine's own attack
// machinery into the two explicit, idempotent verbs modern clients expose.
//
// 1.12's `AttackTarget` (`0x00489B50`) is `FUN_006131A0`: resolve the target,
// then `isAttacking ? stop : start`. We call the same start/stop primitives
// directly (see the `FUN_ATTACK_*` notes in Offsets.h), skipping the toggle:
// StartAttack -> the "start" leg only (idempotent; switches or no-ops on a
// valid target, never stops it).
// StopAttack -> the "stop" leg only (self-guards, so a no-op when idle).
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Combat::Attack {
namespace {
using Start_t = void(__thiscall *)(void *player, const uint64_t *targetGuid);
using Stop_t = void(__fastcall *)(void *player);
using ResolveTarget_t = int(__thiscall *)(void *player, uint64_t *outGuid);
// Resolve an optional unit-token argument to a GUID without raising.
// `Unit::Identity::GuidForToken` raises for non-token strings (a name or
// garbage — 1.12 has no name->GUID resolver), so run it under `pcall`; a raise
// or an empty token yields 0, which the caller treats as "use current target".
int __fastcall ResolveTokenThunk(void *L) {
using namespace Game::Lua;
const char *token = ToString(L, 1);
const uint64_t g = (token && *token) ? Unit::Identity::GuidForToken(token) : 0;
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g)));
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g >> 32)));
return 2;
}
uint64_t SafeResolveToken(void *L, const char *token) {
using namespace Game::Lua;
if (token == nullptr || *token == '\0')
return 0;
const int base = GetTop(L);
PushCClosure(L, &ResolveTokenThunk, 0);
PushString(L, token);
uint64_t guid = 0;
if (PCall(L, 1, 2, 0) == 0) {
const uint32_t lo = static_cast<uint32_t>(ToNumber(L, -2));
const uint32_t hi = static_cast<uint32_t>(ToNumber(L, -1));
guid = (static_cast<uint64_t>(hi) << 32) | lo;
}
SetTop(L, base); // drop the results (or the caught error message)
return guid;
}
} // namespace
static int __fastcall Script_StartAttack(void *L) {
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return 0;
uint64_t target = SafeResolveToken(L, Game::Lua::ToString(L, 1));
if (target == 0) {
// No explicit target given (or unresolvable): validate and take the
// current target. Returns 0 — and posts the engine's own UI error —
// when there's nothing valid to attack.
auto resolve =
reinterpret_cast<ResolveTarget_t>(Offsets::FUN_ATTACK_RESOLVE_TARGET);
if (resolve(player, &target) == 0)
return 0;
}
reinterpret_cast<Start_t>(Offsets::FUN_ATTACK_START)(player, &target);
return 0;
}
static int __fastcall Script_StopAttack(void *L) {
(void)L;
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return 0;
reinterpret_cast<Stop_t>(Offsets::FUN_ATTACK_STOP)(player);
return 0;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("StartAttack", &Script_StartAttack);
Game::Lua::RegisterGlobalFunction("StopAttack", &Script_StopAttack);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Combat::Attack
+1 -4
View File
@@ -64,11 +64,8 @@ namespace Debug::Probe {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn("player"));
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
}
const uint8_t *ResolvePlayerDescriptor() {
+246
View File
@@ -0,0 +1,246 @@
// 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 EditBox method backports — cursor, selection, focus and text-state
// accessors that vanilla's 48-method EditBox registry lacks but modern addons
// call unconditionally (the caret bug that motivated this: TwitchEmotes'
// autocomplete calls SetCursorPosition after replacing the text).
//
// SetCursorPosition / GetCursorPosition — caret as a byte offset
// GetUTF8CursorPosition — caret as a character index
// ClearHighlightText — drop the selection, keep the caret
// HasFocus — does this box own keyboard focus
// HasText — is the box non-empty
// Set/GetHighlightColor — the text-selection highlight color
// ClearHistory — empty the up/down input history
//
// The engine already stores and manipulates all of this; we just expose it.
// Every method is registered on the EditBox method registry, so — like any
// per-type frame method — only EditBox frames dispatch to them (a Button or
// Frame still gets "method not found"), so the resolved `this` is always a
// real CSimpleEditBox.
//
// The caret is a BYTE offset into the UTF-8 text buffer (field +0x36c), which
// is exactly what modern WoW's byte-based EditBox cursor uses — for ASCII text
// a byte offset equals a character index, so callers passing `#text` land at
// the end either way.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace EditBox::Methods {
namespace {
using SetCursorByte_t = void(__thiscall *)(void *editbox, int byteOffset);
using CollapseSelection_t = void(__thiscall *)(void *editbox);
using CountChars_t = int(__thiscall *)(void *editbox, int startByte, int byteCount);
using RegionSetColor_t = void(__thiscall *)(void *region, const uint32_t *colorBGRA);
using RegionGetColor_t = void(__thiscall *)(void *region, uint32_t *outBGRA);
using SetHistoryMax_t = void(__thiscall *)(void *editbox, int maxLines);
// A Lua color arg (0..1) clamped and scaled to a 0..255 byte, matching the
// engine's own float->byte color conversion (truncating, like Texture's
// SetVertexColor). `dflt` is used when the arg is absent.
uint32_t ColorByte(void *L, int idx, double dflt) {
double v = Game::Lua::IsNumber(L, idx) ? Game::Lua::ToNumber(L, idx) : dflt;
if (v < 0.0)
v = 0.0;
else if (v > 1.0)
v = 1.0;
return static_cast<uint32_t>(v * 255.0);
}
int __fastcall Script_SetCursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: EditBox:SetCursorPosition(position)");
return 0;
}
const int pos = static_cast<int>(Game::Lua::ToNumber(L, 2));
reinterpret_cast<SetCursorByte_t>(
Offsets::FUN_EDITBOX_SET_CURSOR_BYTE)(eb, pos);
reinterpret_cast<CollapseSelection_t>(
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
// COLLAPSE_SELECTION moves selStart/selEnd but doesn't flag them; set the
// selection-dirty bit (2) so any highlight from before is cleared on the
// next paint. SET_CURSOR_BYTE already set the cursor-dirty bit (4).
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
return 0;
}
int __fastcall Script_GetCursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetCursorPosition()");
return 0;
}
Game::Lua::PushNumber(L, static_cast<double>(
Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE)));
return 1;
}
// The caret as a 0-based CHARACTER index (UTF-8 codepoints before the caret),
// vs GetCursorPosition's byte offset. Equal for ASCII; differs once the text
// holds multibyte characters.
int __fastcall Script_GetUTF8CursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetUTF8CursorPosition()");
return 0;
}
const int cursorByte = Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE);
const int chars = reinterpret_cast<CountChars_t>(
Offsets::FUN_EDITBOX_COUNT_CHARS)(eb, 0, cursorByte);
Game::Lua::PushNumber(L, static_cast<double>(chars));
return 1;
}
// Drop the current selection without moving the caret. Collapsing selection to
// the caret (selStart = selEnd = cursor) is exactly what the engine's own
// helper does; vanilla can only clear a selection via HighlightText(0, 0),
// which yanks it to offset 0 instead of leaving the caret in place.
int __fastcall Script_ClearHighlightText(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:ClearHighlightText()");
return 0;
}
reinterpret_cast<CollapseSelection_t>(
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
return 0;
}
// True when this edit box currently owns keyboard focus. The engine tracks the
// focused box in one global (written by SetFocus / cleared by ClearFocus), so
// focus is just pointer identity against it.
int __fastcall Script_HasFocus(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L,
eb == Game::Read<void *>(Offsets::VAR_FOCUSED_EDITBOX));
return 1;
}
// True when the box holds any text (its byte length is non-zero).
int __fastcall Script_HasText(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L,
Game::Read<int>(eb, Offsets::OFF_EDITBOX_TEXT_LENGTH) > 0);
return 1;
}
// SetHighlightColor(r, g, b [, a]) — the color of the text-selection highlight.
// The engine paints the selection with three Texture regions (start-line,
// middle-block, end-line); their vertex color is the highlight color, so set
// it on all three via the engine's own region color setter. Colors are 0..1;
// alpha defaults to 1. Takes effect on the next selection paint.
int __fastcall Script_SetHighlightColor(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
Game::Lua::Error(L, "Usage: EditBox:SetHighlightColor(r, g, b [, a])");
return 0;
}
const uint32_t r = ColorByte(L, 2, 1.0);
const uint32_t g = ColorByte(L, 3, 1.0);
const uint32_t b = ColorByte(L, 4, 1.0);
const uint32_t a = ColorByte(L, 5, 1.0);
const uint32_t packed = b | (g << 8) | (r << 16) | (a << 24); // {b,g,r,a}
auto setColor = reinterpret_cast<RegionSetColor_t>(
Offsets::FUN_FONTSTRING_SET_COLOR);
for (int off = Offsets::OFF_EDITBOX_HIGHLIGHT_REGION;
off <= Offsets::OFF_EDITBOX_HIGHLIGHT_REGION + 8; off += 4) {
void *region = Game::Read<void *>(eb, off);
if (region != nullptr)
setColor(region, &packed);
}
return 0;
}
// GetHighlightColor() -> r, g, b, a (each 0..1). Reads the color off the first
// highlight region; a region with no explicit color reads back white (opaque).
int __fastcall Script_GetHighlightColor(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetHighlightColor()");
return 0;
}
uint32_t packed = 0xFFFFFFFF;
void *region = Game::Read<void *>(eb, Offsets::OFF_EDITBOX_HIGHLIGHT_REGION);
if (region != nullptr)
reinterpret_cast<RegionGetColor_t>(Offsets::FUN_REGION_GET_COLOR)(
region, &packed);
const double inv = 1.0 / 255.0;
Game::Lua::PushNumber(L, ((packed >> 16) & 0xff) * inv); // r
Game::Lua::PushNumber(L, ((packed >> 8) & 0xff) * inv); // g
Game::Lua::PushNumber(L, (packed & 0xff) * inv); // b
Game::Lua::PushNumber(L, ((packed >> 24) & 0xff) * inv); // a
return 4;
}
// Empties the up/down input history, keeping the line limit so the box keeps
// recording afterward. The engine's SetHistoryLines internal frees the whole
// ring at max 0, so clear = free (0) then reallocate at the saved max — which
// drops every entry and resets the write position. No-op when history was
// never enabled (max 0).
int __fastcall Script_ClearHistory(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:ClearHistory()");
return 0;
}
const int max = Game::Read<int>(eb, Offsets::OFF_EDITBOX_HISTORY_MAX);
if (max > 0) {
auto setMax = reinterpret_cast<SetHistoryMax_t>(
Offsets::FUN_EDITBOX_SET_HISTORY_MAX);
setMax(eb, 0);
setMax(eb, max);
}
return 0;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetCursorPosition", &Script_SetCursorPosition},
{"GetCursorPosition", &Script_GetCursorPosition},
{"GetUTF8CursorPosition", &Script_GetUTF8CursorPosition},
{"ClearHighlightText", &Script_ClearHighlightText},
{"HasFocus", &Script_HasFocus},
{"HasText", &Script_HasText},
{"SetHighlightColor", &Script_SetHighlightColor},
{"GetHighlightColor", &Script_GetHighlightColor},
{"ClearHistory", &Script_ClearHistory},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace EditBox::Methods
+2
View File
@@ -320,6 +320,8 @@ void PrepareForReload() {
g_growLatched = false; // re-arm the one-shot grow for the new table
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
// Diagnostic Lua functions — registered via the auto-register pattern
// at the bottom of this file. Not on any hot path.
namespace {
+1 -4
View File
@@ -69,10 +69,7 @@ void PushFlag(void *L, bool value) {
// `[player + 0xE68]`. Returns nullptr if "player" isn't resolvable
// or the sub-struct is uninitialized (pre-login / glue).
const uint8_t *PlayerInfo() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
+46 -4
View File
@@ -103,6 +103,8 @@
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
#include "frame/Attributes.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
@@ -945,11 +947,30 @@ void FireAttributeChanged(void *L, void *frame, const char *name) {
// ---- 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.
// otherwise never registers as hovered).
//
// Two properties, both deliberate:
// * STACK-NEUTRAL. The old form did SetTop(L, 1) to set up (self, true) for
// the engine setter, which DESTROYED the (name, value) at stack[2]/[3] that
// FireAttributeChanged reads right after — handing any OnAttributeChanged
// handler a nil `arg2`. We instead push the call at the top and restore the
// caller's stack, so DoSet's (self, name, value) survives intact.
// * PROTECTED. SetAttribute is Frame-scoped (VAR_FRAME_METHOD_REGISTRY), so
// `self` is a real Frame and the setter's type check normally passes;
// invoking through lua_pcall is belt-and-suspenders so a best-effort side
// effect can never surface an error in the addon's SetAttribute. (Calling
// via pcall is also what lets us pass the args at the callee's own stack
// base without clobbering ours.)
void EnableFrameMouse(void *L) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushBoolean(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
const int top = Game::Lua::GetTop(L);
Game::Lua::PushCClosure(
L,
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE),
0);
Game::Lua::PushValue(L, 1); // self (the frame)
Game::Lua::PushBoolean(L, 1); // enable = true
Game::Lua::PCall(L, 2, 0, 0); // best-effort; swallow a transient type error
Game::Lua::SetTop(L, top); // restore the caller's stack
}
int DoSet(void *L, bool fireHandler) { // (self, name, value)
@@ -1088,4 +1109,25 @@ const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
} // namespace
// Drop every per-frame map before a /reload (or logout) resets the Lua state.
// Both are keyed by the frame's C object pointer, which the allocator recycles
// across a reload:
// * g_attrHandlers holds OnAttributeChanged handler refs. The refs die with
// the Lua reset, so a stale entry whose address a new frame reuses would
// fire a DEAD ref from FireAttributeChanged — the invoker's protected pcall
// catches it, but the engine error handler still prints it as an error "in
// SetAttribute" (SecureStateDriverManager sets OnAttributeChanged every
// session, so there's always at least one entry to go stale). This was the
// "SetAttribute occasionally errors after /reload" report; same class of
// bug as Tooltip::SetEvents issue #33.
// * g_unitByFrame maps a frame to its `unit` token for the mouse-focus poll;
// a stale entry at a reused address would point the mouseover at the wrong
// unit and mislead the isNew mouse-enable gate.
void PrepareForReload() {
g_attrHandlers.clear();
g_unitByFrame.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Frame::Attributes
+25
View File
@@ -0,0 +1,25 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Frame::Attributes {
// Drop the per-frame attribute-handler and unit-token maps before a /reload (or
// logout) resets the Lua state and recycles frame object pointers. Without it, a
// stale OnAttributeChanged ref fires from a new frame that reused an old
// address (surfaces as "SetAttribute occasionally errors after /reload"). Wired
// from `FrameScript_Initialize_h` in DllMain.cpp alongside the other cleaners.
void PrepareForReload();
} // namespace Frame::Attributes
+181
View File
@@ -0,0 +1,181 @@
// 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/>.
// `button:SetScript("PreClick"|"PostClick", fn)` — backport of the modern
// button scripts that fire immediately before and after OnClick, as real frame
// scripts settable via the standard SetScript / GetScript / HookScript. Vanilla
// 1.12 has only OnClick and OnDoubleClick on buttons.
//
// 1.12 already has the script-dispatch machinery, just not these two names.
// Two co-hooks (same shape as Tooltip::SetEvents):
//
// - The Button script resolver FUN_BUTTON_SCRIPT_RESOLVER maps a script name
// to its handler slot (OnClick -> button+0x4CC, …). SetScript/GetScript/
// HookScript all go through it. We co-hook it: for PreClick/PostClick
// (which the engine doesn't know) we hand back a pointer to an external
// per-button cell — a button object has no free slot for two new scripts,
// and the resolver's only contract is "return the address the script lives
// at", so external storage works exactly like an in-object slot.
//
// - The engine fires a button's OnClick through the arg'd script runner
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT(frame, frame+0x4CC, "%s", &buttonName).
// We co-hook that runner and, when the fired slot IS the OnClick slot,
// re-invoke the runner for our PreClick cell first, then let OnClick run,
// then re-invoke for PostClick — reusing the SAME (fmt, varargs) so
// PreClick/PostClick receive arg1 = the button name exactly like OnClick.
//
// Why the runner and not the button click vmethod (FUN_00779540): SuperWoW
// inline-hooks that vmethod for click-casting, and a second MinHook there
// corrupts the trampoline (ERROR #132 — see Offsets.h). The runner is
// uncontested, fires at the same top-level dispatch level (so its exec-context
// stamp is valid), and the OnClick slot address is an exact gate.
//
// Limitation: because the runner only runs when a slot is non-empty, PreClick/
// PostClick fire only when the button also has an OnClick handler set. Modern
// fires them regardless; on this client that would require hooking the click
// vmethod, which SuperWoW owns. Bracketing an existing OnClick is the common
// case and the one this backport serves.
#include "frame/ClickEvents.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <unordered_map>
namespace Frame::ClickEvents {
namespace {
enum ScriptKind { SK_PRECLICK = 0, SK_POSTCLICK, SK_COUNT };
// Matched case-insensitively against the resolver's input name.
constexpr const char *kNamesLower[SK_COUNT] = {"preclick", "postclick"};
// Per-button storage: one 8-byte {handler, context} slot per script kind. Only
// buttons that actually had SetScript("PreClick"/"PostClick", …) called get an
// entry, so the map stays small. unordered_map nodes are pointer-stable, so a
// cell address handed to the engine stays valid until PrepareForReload clears
// the map (buttons and their handler refs die on /reload).
struct Cell {
uint32_t slot[SK_COUNT][2]; // [kind] = {handler ref, exec context}
};
std::unordered_map<void *, Cell> g_cells;
uint32_t *SlotFor(void *button, int kind, bool create) {
auto it = g_cells.find(button);
if (it != g_cells.end())
return it->second.slot[kind];
if (!create)
return nullptr;
Cell &c = g_cells[button];
for (auto &s : c.slot) {
s[0] = 0;
s[1] = 0;
}
return c.slot[kind];
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
if (s == nullptr)
return false;
for (;; ++s, ++literal) {
unsigned char a = static_cast<unsigned char>(*s);
const unsigned char b = static_cast<unsigned char>(*literal);
if (a >= 'A' && a <= 'Z')
a = static_cast<unsigned char>(a + 32);
if (a != b)
return false;
if (b == 0)
return true;
}
}
// Recursion guard: a PreClick/OnClick/PostClick handler can programmatically
// click a button again. While bracketing, further OnClick fires pass straight
// through (the nested click still runs — it just doesn't get its own nested
// PreClick/PostClick), which prevents runaway from a self-clicking handler.
int g_firing = 0;
// --- Resolver co-hook (Button script-name -> slot) ---------------------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
Resolver_t g_resolverOriginal = nullptr;
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
const int engineSlot = g_resolverOriginal(self, edx, name);
if (engineSlot != 0) // a base-frame or existing button script — leave it
return engineSlot;
for (int k = 0; k < SK_COUNT; ++k)
if (EqualsIgnoreCase(name, kNamesLower[k]))
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
return 0;
}
// --- Runner co-hook (fire PreClick before / PostClick after OnClick) ---
using RunScript_t = void(__cdecl *)(void *frame, uint32_t *slotPtr,
const char *fmt, void *varargs);
RunScript_t g_runOriginal = nullptr;
// Fire the handler (if any) for `kind` on `frame`, reusing the OnClick fire's
// own (fmt, varargs) so arg1 = the button name. We call the original runner
// directly (its exec-context stamp is valid at this dispatch level). The runner
// balances the Lua stack itself; we snapshot/restore the top as cheap insurance
// against any imbalance leaking to the still-running button click code.
void FireCell(void *frame, int kind, const char *fmt, void *varargs) {
uint32_t *slot = SlotFor(frame, kind, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
g_runOriginal(frame, slot, fmt, varargs);
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
void __cdecl RunScript_h(void *frame, uint32_t *slotPtr, const char *fmt,
void *varargs) {
const auto onClickSlot = reinterpret_cast<uint32_t *>(
reinterpret_cast<char *>(frame) + Offsets::OFF_BUTTON_ONCLICK_HANDLER);
if (slotPtr != onClickSlot || g_firing > 0) {
g_runOriginal(frame, slotPtr, fmt, varargs);
return;
}
++g_firing;
FireCell(frame, SK_PRECLICK, fmt, varargs);
g_runOriginal(frame, slotPtr, fmt, varargs); // the button's OnClick
FireCell(frame, SK_POSTCLICK, fmt, varargs);
--g_firing;
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_BUTTON_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _runnerHook{
Offsets::FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT,
reinterpret_cast<void *>(&RunScript_h),
reinterpret_cast<void **>(&g_runOriginal)};
} // namespace
void PrepareForReload() {
g_cells.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Frame::ClickEvents
+25
View File
@@ -0,0 +1,25 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Frame::ClickEvents {
// Drop every per-button PreClick/PostClick handler cell before a `/reload` (or
// logout) tears down the button objects and resets the Lua state. The handler
// refs die with the Lua reset, and a recycled button address must not fire a
// stale ref. Wired from `FrameScript_Initialize_h` in DllMain.cpp, alongside
// the other per-reload cleaners.
void PrepareForReload();
} // namespace Frame::ClickEvents
+99
View File
@@ -0,0 +1,99 @@
// 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/>.
// `GetClickFrame(name)` — backport of the C global WoW registers for the
// `/click` handler to resolve a frame by its global name. Vanilla 1.12
// never shipped it; 3.3.5's `Script_GetClickFrame` (`0x00564130`) is the
// reference.
//
// It lives in the DLL, not addon Lua, because Blizzard shipped it as an
// engine-registered C global — the backport mirrors where the real
// function lives, not the fact that Lua could fake it.
//
// Behaviour, matching the 3.3.5 resolver `FUN_00562990` (minus its private
// name→frame memoization cache, which is only a perf optimization over the
// global lookup):
// 1. `_G[name]` — a plain global-table lookup.
// 2. The value must be a frame wrapper table backed by a
// `CFrameScriptObject`.
// 3. Its real `GetName()` must equal `name`. This is the load-bearing
// check: it stops a reassigned global (`_G["Foo"] = someOtherFrame`)
// from resolving to a differently-named frame.
// Return the frame on a match, otherwise nil.
//
// `GetName()` is the object's vtable slot +4 (`__thiscall`, returns
// `const char *`) — verified from 1.12's own `Script_GetName`
// (`0x0079FF60` / `0x007A1390`), which resolves self the same way and reads
// the name from that slot. It's a base virtual present on every
// `CFrameScriptObject` subtype, so calling it after `ResolveObject` is
// safe without the RTTI subtype gate `Script_GetName` does first.
#include "Game.h"
#include "Offsets.h"
#include <cstring>
namespace Frame::ClickFrame {
namespace {
// CFrameScriptObject::GetName — vtable slot +4, thiscall, no args.
using GetName_t = const char *(__thiscall *)(void *self);
const char *ObjectName(void *obj) {
auto vtable = *reinterpret_cast<void ***>(obj);
auto getName = reinterpret_cast<GetName_t>(vtable[1]);
return getName(obj);
}
} // namespace
static int __fastcall Script_GetClickFrame(void *L) {
using namespace Game::Lua;
if (GetTop(L) < 1) {
Error(L, "Usage: GetClickFrame(\"name\")");
return 0;
}
const char *name = ToString(L, 1);
if (name == nullptr) {
PushNil(L);
return 1;
}
// getglobal(name): push _G[name].
PushString(L, name);
GetTable(L, GLOBALS_INDEX);
if (Type(L, -1) != TYPE_TABLE) {
PushNil(L);
return 1;
}
void *obj = ResolveObject(L, -1);
if (obj != nullptr) {
const char *objName = ObjectName(obj);
if (objName != nullptr && std::strcmp(objName, name) == 0)
// The wrapper table is still on top — return it as-is.
return 1;
}
PushNil(L);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetClickFrame", &Script_GetClickFrame);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Frame::ClickFrame
+46
View File
@@ -0,0 +1,46 @@
// 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/>.
// `INTERFACE_VERSION` global — the client's interface (TOC) version number,
// 11200 on this client. Read from the engine's own accessor (the same value
// the addon loader compares each `## Interface:` against) rather than a
// literal, and re-published on every `/reload` via the standard
// `ModuleAutoRegister` flow. It is a true fixed client constant, so it is a
// global number like `CLASSIC_API_VERSION`, not a function.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Interface::Version {
namespace {
using ClientIfaceVer_t = uint32_t(__cdecl *)();
void RegisterLuaFunctions() {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const uint32_t version = reinterpret_cast<ClientIfaceVer_t>(
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
Game::Lua::SetGlobalNumber(L, "INTERFACE_VERSION",
static_cast<double>(version));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Interface::Version
+1 -4
View File
@@ -200,11 +200,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
return 0;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
if (auto *player = resolve("player")) {
if (auto *player = Game::ResolveUnitToken("player")) {
auto equip = reinterpret_cast<AutoEquipCursor_t>(
Offsets::FUN_AUTO_EQUIP_CURSOR_ITEM);
equip(player, 0);
+1 -3
View File
@@ -24,11 +24,9 @@ namespace {
using GetItemBySlot_t = void *(__thiscall *)(void *invMgr, int slot);
using GetVisibleItem_t = void *(__thiscall *)(void *unit, int slot);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
void *ResolveUnit(const char *token) {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
return Game::ResolveUnitToken(token);
}
// Walks the local player's private inventory manager — the same path
+1 -3
View File
@@ -50,12 +50,10 @@ namespace Item::Usable {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
+1 -5
View File
@@ -49,8 +49,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
// caller-provided 12-byte buffer with the unit's world position and
@@ -182,9 +180,7 @@ int __fastcall Script_GetNearbyLootableUnits(void *L) {
// Resolve the local player once — the canonical CGPlayer_C the
// engine's own interact-range checks use, distinct from the
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
const void *player = ResolveUnitToken("player");
const void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return 1;
+1 -4
View File
@@ -105,7 +105,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
using CloseLootInner_t = void(__fastcall *)(int sendRelease,
@@ -443,9 +442,7 @@ bool BeginWalk(bool lootMode, size_t maxCount) {
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");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return false;
+1 -5
View File
@@ -22,8 +22,6 @@ namespace Loot::Unit {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
// MSVC `__thiscall` puts `player` in ECX and the rest on the stack —
// matching the engine's own callsite at `FUN_0060FA20`. The trailing
@@ -74,9 +72,7 @@ int __fastcall Script_LootUnit(void *L) {
// `ResolveUnitToken("player")` gives the canonical CGPlayer_C the
// engine uses for `__thiscall` calls — distinct from the global
// at `VAR_LOCAL_PLAYER_PTR` (see the comment on that offset).
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = ResolveUnitToken("player");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return 0;
+1 -4
View File
@@ -39,7 +39,6 @@ enum class State {
WaitingOpen,
};
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
// `FUN_005E1AD0` is `__stdcall(uint8_t)`, not `__fastcall` — the
@@ -164,9 +163,7 @@ int __fastcall Script_LootUnitItem(void *L) {
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
+2
View File
@@ -202,4 +202,6 @@ void PrepareForReload() {
g_gameSlot.tv.value = nullptr;
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace LuaSyntax::StringMethods
+39 -20
View File
@@ -79,11 +79,12 @@
// `...` yields the name + a per-addon shared table. `__addonns` returns the
// same table for every file of an addon (registry-backed). The preamble is
// newline-free (line numbers preserved).
// * Non-addon vararg chunks (RunScript `/run`, XML <OnLoad>, loadstring) get a
// `local arg = {n=0}` fallback: their main chunk is not vararg, so a
// top-level `...` would otherwise be `unpack(nil)` and throw where 5.1
// yields nothing. A chunk called with args (`loadstring(...)(a,b)`) can't
// recover them — this build doesn't populate `arg` for called chunks.
// * Non-addon vararg chunks (RunScript `/run`, XML handler bodies,
// `loadstring`) compile as a REAL 5.0 vararg closure: the body is wrapped
// `return function(...) … end` and the main is called once at load, so
// the caller receives the inner function whose own `arg` carries real
// call args — `loadstring("return ...")(a, b)` yields a, b, matching 5.1
// called-chunk semantics (the old `local arg={n=0}` fallback lost them).
// The `__addonns` global is CONTEXT-GATED (see `Script_AddonNS`): it only
// answers for the addon mid-load, for its own name — so it cannot be used
// at runtime to read another addon's private table. Cross-addon access is
@@ -1300,6 +1301,7 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
pre += "local unpack=unpack;";
bool armed = false;
bool wrapped = false;
char addon[128];
if (didVararg) {
if (fromFileFunnel && AddonNameFromChunk(name, addon, sizeof addon)) {
@@ -1311,20 +1313,26 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
armed = true;
} else {
// Top-level `...` OUTSIDE the addon file funnel — RunScript (`/run`),
// XML <OnLoad>, or loadstring. Vanilla's main / RunScript chunk is
// NOT vararg, so `arg` is nil there and the `unpack(arg)` we emit
// would throw where 5.1's `...` yields nothing. Bind a chunk-local
// empty vararg table so those chunks match 5.1 (top-level `...`
// yields nothing). A vararg function's own `arg` local shadows this
// at its own scope, so nested `function(...)` bodies are unaffected.
//
// Limitation (verified in-game): a loadstring'd chunk CALLED with
// args — `loadstring("return ...")(a,b)` — can't recover them. This
// build does not populate `arg` for a called chunk, and `...` is not
// a real 5.0 expression, so there is no source for the call args.
// Not a regression: without the rewrite that chunk fails to compile
// outright. This case is vanishingly rare in addons.
pre += "local arg={n=0};";
// XML handler bodies, or loadstring. Vanilla's main chunk is NOT
// vararg (verified in-game): `arg` is nil there, and no rewrite can
// read call args the VM never captured. So compile the chunk as a
// REAL 5.0 vararg closure instead: wrap the body in
// `return function(...) … end` and, after a successful compile,
// call the main once so the caller receives the inner function.
// The inner's own 5.0 `arg` then carries whatever the caller
// passes — `loadstring("return ...")(a, b)` yields a, b — matching
// 5.1 called-chunk semantics. Chunks the engine calls with zero
// args (RunScript, OnLoad) see an empty `arg` exactly as the old
// `local arg={n=0}` fallback gave them; XML handlers invoked
// through Frame::ScriptArgs' positional push now receive those
// args through `...` as well — the modern handler shape. The
// helper captures stay in the OUTER main, which runs once at load
// in the default environment, so the sandbox rationale above is
// preserved (the inner binds them as upvalues). The prefix is
// newline-free and the synthetic `end` rides on its own appended
// final line, so existing line numbers are preserved.
pre += "return function(...) ";
wrapped = true;
}
}
@@ -1341,10 +1349,15 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
? 3
: 0;
std::string full;
full.reserve(bodyLen + pre.size() + 4);
full.reserve(bodyLen + pre.size() + 8);
full.append(body, bom); // keep a leading BOM ahead of the preamble
full += pre;
full.append(body + bom, bodyLen - bom);
// Close the vararg wrap on its OWN line: a body ending in a line comment
// (`-- foo` with no trailing newline) would otherwise swallow the `end`.
// Appending a final line shifts no existing line numbers.
if (wrapped)
full += "\nend";
// Grant this chunk (and only this chunk) the right to fetch its own
// namespace via `__addonns`; the preamble consumes the grant as the chunk's
@@ -1357,6 +1370,12 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
g_origLoadBuffer(L, full.data(), static_cast<unsigned>(full.size()), name);
if (armed && rc != 0)
g_loadingAddon[0] = '\0';
// Materialize the wrapped chunk's inner function. The main is helper-local
// binds plus `return function(...) … end` — closure creation only, it
// cannot raise — so a plain lua_call is safe, and it leaves the inner
// function exactly where the engine expects the compiled chunk.
if (wrapped && rc == 0)
Game::Lua::Call(L, 0, 1);
return rc;
}
+1 -4
View File
@@ -377,15 +377,12 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
}
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's explored-areas bitfield (`[player+0xE68] + 0xE6C`), or
// null before the player is resident. Bit `AreaBit` set == that AreaTable
// area is explored. Same storage the engine's discovered-overlay rebuild
// (FUN_004a67a0) reads.
const uint8_t *ExplorationBits() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
auto *sub = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
+2
View File
@@ -433,4 +433,6 @@ void PrepareForReload() {
CancelJob(job);
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Model::DisplayInfo
+2
View File
@@ -290,6 +290,8 @@ void PrepareForReload() {
g_slots.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
// the internal array. Returns 0 for an out-of-range OR currently-free slot.
+1 -4
View File
@@ -65,15 +65,12 @@ bool g_havePrevBodyYaw = false;
float g_prevCameraYaw = 0.0f;
bool g_havePrevCameraYaw = false;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Controller() {
return Game::Read<const uint8_t *>(Offsets::VAR_UI_INPUT_CONTROLLER);
}
const uint8_t *Player() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn("player"));
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
}
const uint8_t *Camera() {
+1 -5
View File
@@ -21,8 +21,6 @@ namespace Quest::Log {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// Walks the unit's `+0xE68` sub-struct quest list (20 slots, stride
// 0xC) and returns true on first match. Mirrors the engine's loop in
// `Script_IsUnitOnQuest` (`0x004DFE10`).
@@ -225,9 +223,7 @@ static int __fastcall Script_IsUnitOnQuest(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
Game::Lua::PushBool(L, UnitHasQuest(unit, target));
return 1;
}
+1 -4
View File
@@ -58,10 +58,7 @@ bool InWorld() {
// Player resolves only when in-world. Same gate other modules
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
// engine state during loading screens or glue.
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player") != nullptr;
return Game::ResolveUnitToken("player") != nullptr;
}
void CancelPlacement() {
+22 -12
View File
@@ -70,6 +70,7 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
@@ -86,7 +87,6 @@ namespace Spell::Cast {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
@@ -219,7 +219,7 @@ void PushMs(void *L, int ms) {
}
void *Resolve(const char *token) {
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
return Game::ResolveUnitToken(token);
}
// Effective cast time (ms) for the local player, 0 if instant. Uses the
@@ -514,14 +514,18 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime,
now - g_cast.startMs < kCastStartDedupMs) {
// Confirming packet. Keep startMs (no visual restart), but SNAP the
// end time to the server's authoritative castTime. The client stamp
// came from FUN_006e3340, which folds in the caster's cast-speed /
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
// Volley is the case that surfaced this: not channeled, not sped by
// Quick Shots server-side, so the hasted prediction ended the bar
// early and the player was "still casting after the bar finished."
// The server's castTime is reality; reconcile to it (preserving any
// pushback already accumulated). A no-op when client and server agree.
// came from FUN_006e3340, whose only caster factor is the cast-speed
// multiplier at descriptor +0x22c (UNIT_MOD_CAST_SPEED — spell
// haste); the server additionally scales ranged ABILITIES by ranged
// attack speed (SpellEntry::GetCastTime's IsRangedSpell branch,
// m_modAttackSpeedPct[RANGED_ATTACK] — quiver/Quick Shots), which
// the prediction can't see. Two real divergences so far: Volley
// (client +0x22c hastes it, server doesn't → bar ended early) and
// Steady/Aimed Shot (server ranged-hastes it, client doesn't → bar
// ran long, pfUI#43). The server's castTime is reality; reconcile
// to it (preserving accumulated pushback). A no-op when they agree;
// when they differ, PollPlayer sees the end move and fires
// UNIT_SPELLCAST_DELAYED so cast bars re-read the corrected times.
g_cast.endMs =
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
g_castTargetGuid = targetGuid; // confirming packet carries the target
@@ -612,7 +616,11 @@ void ParseChannelStart(Net::CDataStore *packet) {
// 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).
// promptly (ahead of the ~1 s-lagged +0x228 field). The pushback also
// shortened the channel's aura on each hit target server-side
// (Spell::DelayedChannel → DelaySpellAuraHolder), so re-anchor the cached
// target-side expirations too — else C_UnitAuras reads a pushed-back channel
// debuff (Dark Harvest, the drains) late by the accumulated pushback.
void ParseChannelUpdate(Net::CDataStore *packet) {
if (g_channel.spellID == 0)
return;
@@ -622,6 +630,8 @@ void ParseChannelUpdate(Net::CDataStore *packet) {
g_channel.spellID = 0; // authoritative channel end
} else {
g_channel.endMs = NowMs() + static_cast<int>(remaining);
Aura::Source::RestampPlayerChannel(static_cast<uint32_t>(spellID),
remaining);
Spell::CastEvents::OnPlayerChannelUpdate(spellID);
}
}
@@ -837,7 +847,7 @@ void OnWorldTick() {
// 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,
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.endMs,
g_channel.spellID, g_channel.startMs);
Spell::CastEvents::PollRemote();
Spell::CastEvents::PollReticle();
+12 -6
View File
@@ -164,7 +164,7 @@ 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)
int g_castEnd = 0; // last-seen endMs (any movement fires DELAYED)
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
// UNIT_SPELLCAST_INTERRUPTED de-dup. SpellFailed_h and PollPlayer can both
@@ -429,7 +429,7 @@ RemoteEvt *AllocRemoteEvt(uint64_t guid) {
bool PlayerCastSucceeded() { return g_castSucceeded; }
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
int channelSpellID, int channelStartMs) {
// ---- Regular cast -------------------------------------------------
// A "new cast" is a different spell OR the same spell re-stamped at a
@@ -462,12 +462,18 @@ void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
g_castGuid = NextCastGuid(castSpellID);
g_castSpell = castSpellID;
g_castStart = castStartMs;
g_castDelay = castDelayMs;
g_castEnd = castEndMs;
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;
} else if (g_castSpell != 0 && castEndMs != g_castEnd) {
// The cast's end moved: pushback (SMSG_SPELL_DELAYED extends it) or
// the confirming SMSG_SPELL_START snapping the client-predicted cast
// time to the server's authoritative one — the server scales ranged
// abilities by ranged attack speed (Steady/Aimed Shot), which the
// client's prediction helper doesn't model, so the confirming packet
// shortens the bar ~RTT after START (pfUI#43). Either direction, the
// addon must re-read the times; DELAYED is the re-read trigger.
g_castEnd = castEndMs;
Fire(kDelayed, g_castSpell, g_castGuid);
}
+6 -2
View File
@@ -45,11 +45,15 @@ namespace Spell::CastEvents {
// 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
// - end-time movement (either direction) → UNIT_SPELLCAST_DELAYED
// - channel start / stop → UNIT_SPELLCAST_CHANNEL_START / _STOP
// End-time movement covers both pushback (SMSG_SPELL_DELAYED) and the
// confirming SMSG_SPELL_START reconciling the client-predicted cast time to
// the server's (ranged-haste-scaled Steady/Aimed Shot, pfUI#43) — DELAYED is
// the "re-read the times" trigger, whichever way the end moved.
// 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,
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
int channelSpellID, int channelStartMs);
// Derive the ground-target reticle events from the engine's targeting flags.
+37 -7
View File
@@ -19,6 +19,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::Info {
@@ -179,15 +180,44 @@ static bool BookTypeIsPet(const char *s) {
return lc(s[0]) == 'p' && lc(s[1]) == 'e' && lc(s[2]) == 't' && s[3] == '\0';
}
// Resolves the Lua args to a spellID, supporting:
// GetSpellInfo(spellID) -- arg2 absent or non-string
// GetSpellInfo(slot, "spell"|"pet") -- arg2 string → spellbook lookup
// Returns 0 for invalid/empty inputs (Lua side surfaces 0 as nil since
// spellID 0 is never valid). Calls `lua_error` if arg1 isn't a number,
// matching the engine's own `lua_error`-on-misuse style.
// Parses a spellID out of a spell hyperlink ("…|Hspell:<id>[:…]…").
// Returns 0 when `s` isn't a spell link, so the caller can fall through
// to a plain-name lookup.
static int SpellIDFromLink(const char *s) {
if (s == nullptr)
return 0;
const char *p = std::strstr(s, "Hspell:");
if (p == nullptr)
return 0;
p += 7; // past "Hspell:"
if (*p < '0' || *p > '9')
return 0;
int id = 0;
while (*p >= '0' && *p <= '9')
id = id * 10 + (*p++ - '0');
return id;
}
// Resolves the Lua args to a spellID, supporting the full retail
// GetSpellInfo argument set:
// GetSpellInfo(spellID) -- arg1 number, arg2 non-string
// GetSpellInfo(slot, "spell"|"pet") -- arg1 number, arg2 string → book slot
// GetSpellInfo("name") -- arg1 string → spellbook name lookup
// GetSpellInfo("…|Hspell:ID|h…") -- arg1 string → link's embedded spellID
// A name resolves against the player's/pet's spellbook (retail's scope),
// returning the highest known rank; an unknown name yields 0. Returns 0
// for invalid/empty inputs (Lua side surfaces 0 as nil since spellID 0 is
// never valid). Only a non-string, non-number arg1 raises `lua_error` —
// a name retail doesn't recognize returns nil, it does not error.
static int ResolveLuaArgsToSpellID(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, 1);
const int fromLink = SpellIDFromLink(s);
return fromLink > 0 ? fromLink : Spell::Lookup::SpellNameToID(s);
}
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetSpellInfo(spellID) or GetSpellInfo(slot, bookType)");
Game::Lua::Error(L, "Usage: GetSpellInfo(spellID | \"name\" | link) "
"or GetSpellInfo(slot, bookType)");
return 0;
}
const int arg1 = static_cast<int>(Game::Lua::ToNumber(L, 1));
+1 -4
View File
@@ -34,15 +34,12 @@ namespace Spell::IsCurrent {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int ReadGlobalSpellID(uintptr_t addr) {
return *reinterpret_cast<const int *>(addr);
}
int ReadPlayerChannelSpell() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
+1 -4
View File
@@ -22,15 +22,12 @@ namespace Spell::Level {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// Reads the local player's descriptor — same pattern as
// Unit::Combat::Script_InCombatLockdown. Returns nullptr at login
// screen / character select / loading transition where the
// CGPlayer doesn't exist yet.
const uint8_t *PlayerDescriptor() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return Game::Read<const uint8_t *>(
+75
View File
@@ -16,6 +16,8 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstring>
namespace Spell::Lookup {
const uint8_t *RecordForID(int spellID) {
@@ -109,4 +111,77 @@ int FindSpellbookSlot(int spellID, int *outBookType) {
return 0;
}
int SpellNameToID(const char *name) {
if (name == nullptr || *name == '\0')
return 0;
const int locale = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
// A trailing "(subtext)" pins a specific rank — the vanilla
// "SpellName(Rank N)" addressing that CastSpellByName and friends use
// (a space before the paren is tolerated: "Mind Blast (Rank 8)"). The
// subtext is matched against the record's localized Rank field. With no
// subtext, we return the highest known rank of the base name.
char base[256];
char wantRank[64];
bool haveRank = false;
{
const char *open = std::strrchr(name, '(');
const char *close = (open != nullptr) ? std::strrchr(name, ')') : nullptr;
size_t baseLen;
if (open != nullptr && close != nullptr && close > open + 1) {
baseLen = static_cast<size_t>(open - name);
size_t rankLen = static_cast<size_t>(close - open - 1);
if (rankLen >= sizeof(wantRank))
rankLen = sizeof(wantRank) - 1;
std::memcpy(wantRank, open + 1, rankLen);
wantRank[rankLen] = '\0';
haveRank = true;
} else {
baseLen = std::strlen(name);
}
while (baseLen > 0 &&
(name[baseLen - 1] == ' ' || name[baseLen - 1] == '\t'))
--baseLen; // trim any space between the name and the paren
if (baseLen == 0 || baseLen >= sizeof(base))
return 0;
std::memcpy(base, name, baseLen);
base[baseLen] = '\0';
}
// Player book first, then pet — retail's precedence. For a plain name we
// keep the LAST matching slot: a spell's ranks are stored in ascending
// order, so the final match is the highest rank the player knows. For a
// rank subtext we return the exact name+rank match.
const uintptr_t books[2] = {Offsets::VAR_PLAYER_SPELLBOOK,
Offsets::VAR_PET_SPELLBOOK};
for (uintptr_t bookBase : books) {
auto *array = reinterpret_cast<const int *>(bookBase);
int best = 0;
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
const int spellID = array[i];
if (spellID <= 0)
continue; // BSS-zeroed empty slot
const uint8_t *record = RecordForID(spellID);
if (record == nullptr)
continue;
const char *sname = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_SPELL_NAMES + locale * 4);
if (sname == nullptr || std::strcmp(sname, base) != 0)
continue;
if (!haveRank) {
best = spellID; // highest rank (last match)
continue;
}
const char *srank = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
if (srank != nullptr && std::strcmp(srank, wantRank) == 0)
return spellID; // exact name + rank
}
if (!haveRank && best != 0)
return best;
}
return 0;
}
} // namespace Spell::Lookup
+9
View File
@@ -51,6 +51,15 @@ int SpellbookSlotToID(int slot1Based, int bookType);
// in either book.
int FindSpellbookSlot(int spellID, int *outBookType);
// Resolves a spell NAME to a spellID against the player's (then pet's)
// spellbook — the same scope retail's `GetSpellInfo(name)` uses. The
// match is exact and case-sensitive against the current locale's name
// field (matching retail and ShaguTweaks' `libspell`). When a spell has
// several ranks in the book, the highest rank is returned (the spellbook
// arrays list a spell's ranks in ascending order, so the last match
// wins). Returns 0 for a null/empty name or one the player doesn't know.
int SpellNameToID(const char *name);
// Resolves a 0-based UI slot into the recipe's spellID. Used by both
// tradeskill and craft scrapers — they share the same storage shape:
// `[entriesVar]` is a pointer to a heap-allocated array of recipe-
+1 -4
View File
@@ -21,15 +21,12 @@ namespace Spell::Mod {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's class SpellFamilyName, or 0 if it can't be resolved
// (e.g. at the login screen). Gates the SpellMod family match — only
// spells of the player's own class family receive the player's mods, so
// a same-flag-bit spell of another family isn't falsely modified.
uint32_t PlayerSpellFamily() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve("player"));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (unit == nullptr)
return 0;
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
+2 -5
View File
@@ -21,7 +21,6 @@ namespace Spell::SkillLine {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
// Reads UNIT_FIELD_BYTES_0 from the local player's descriptor. Same
@@ -31,8 +30,7 @@ using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
void ReadPlayerClassRace(uint8_t *outClassByte, uint8_t *outRaceByte) {
*outClassByte = 0;
*outRaceByte = 0;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return;
auto *desc = Game::Read<const uint8_t *>(
@@ -232,8 +230,7 @@ int __fastcall Script_GetSkillLineRank(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolve("player"));
auto *player = static_cast<uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr) {
Game::Lua::PushNil(L);
return 1;
+187
View File
@@ -0,0 +1,187 @@
// 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 "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Lookup.h"
#include <cstdint>
// `GetNumTrackingTypes()` / `GetTrackingInfo(index)` / `SetTracking(index)` —
// backport of the minimap-tracking enumeration the modern client exposes.
//
// Vanilla 1.12 never enumerates the tracking abilities the player KNOWS — it
// only stores the single currently-active tracker as a plain spellID in
// `VAR_ACTIVE_TRACKING_SPELL`, surfaced through `GetTrackingTexture()` /
// `CancelTrackingBuff()` / `GameTooltip:SetTrackingSpell()`. 3.3.5's
// `GetTrackingInfo` iterates a spellbook-derived list of tracking spells and
// reports name/texture/active/category per entry; we rebuild that list here.
//
// A tracking spell is identified exactly the way the engine's own active-
// tracker updater `FUN_004E4170` identifies one: a Spell.dbc record with an
// `EffectApplyAuraName` of Track Creatures (44), Track Resources (45), or
// Track Stealthed (151). That effect-based test reproduces pfUI's hardcoded
// spellID/icon table (Find Herbs/Minerals/Treasure/Trees, every Hunter
// Track*, Sense Undead/Demons, Track Humanoids) with no table to maintain,
// and auto-covers server-custom trackers such as Turtle's Find Trees (52917).
namespace Spell::Tracking {
// Tracking aura effect types — SPELL_AURA_TRACK_* ApplyAura names, the same
// values `FUN_004E4170` scans a spell's three effects for.
static constexpr int32_t AURA_TRACK_CREATURES = 44;
static constexpr int32_t AURA_TRACK_RESOURCES = 45;
static constexpr int32_t AURA_TRACK_STEALTHED = 151;
// True iff `spellID`'s record has any of the three Track* effects.
static bool IsTrackingSpell(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
auto *aura = Game::Ptr<const int32_t>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (aura[i] == AURA_TRACK_CREATURES || aura[i] == AURA_TRACK_RESOURCES ||
aura[i] == AURA_TRACK_STEALTHED)
return true;
}
return false;
}
// Walks the player spellbook in slot order counting tracking spells. If
// `wantNth >= 1` and an Nth tracking spell exists, writes its spellID and
// 1-based spellbook slot to `*outSpellID` / `*outSlot`. Returns the total
// number of tracking spells found (so callers detect out-of-range by
// comparing the return against `wantNth`).
//
// The spellbook array is zero-padded past the populated count (see
// `Spell::Lookup::FindSpellbookSlot`), so a 0 entry is just an empty slot —
// skip it and keep scanning the full range rather than break, matching the
// existing spellbook walkers.
static int EnumerateTracking(int wantNth, int *outSpellID, int *outSlot) {
auto *book = reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELLBOOK));
int found = 0;
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
const int spellID = book[i];
if (spellID <= 0 || !IsTrackingSpell(spellID))
continue;
++found;
if (found == wantNth) {
if (outSpellID != nullptr)
*outSpellID = spellID;
if (outSlot != nullptr)
*outSlot = i + 1;
}
}
return found;
}
// `GetNumTrackingTypes()` -> number of tracking spells in the player's book.
static int __fastcall Script_GetNumTrackingTypes(void *L) {
Game::Lua::PushNumber(L,
static_cast<double>(EnumerateTracking(0, nullptr, nullptr)));
return 1;
}
// `GetTrackingInfo(index)` -> name, texture, active, category, spellID.
// - index is 1-based into the tracking list (spellbook order).
// - texture is the SpellIcon.dbc path (nil if the icon is missing).
// - active is a boolean: the tracker currently in effect.
// - category is always "spell" — vanilla has no non-spell ("other")
// trackers, unlike 3.3.5's class-masked table.
// - spellID is a ClassicAPI extension (the modern 5th return is `nested`,
// which has no meaning here); handy since selecting/among trackers is
// spellID-driven.
// Returns nil for an out-of-range index.
static int __fastcall Script_GetTrackingInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTrackingInfo(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (index < 1)
return 0;
int spellID = 0;
int slot = 0;
if (EnumerateTracking(index, &spellID, &slot) < index)
return 0; // index past the number of tracking types -> nil
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
const int locale = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const char *name =
Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
const char *texture = nullptr;
const int iconID = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_ICON_ID);
if (auto *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID))) {
texture = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
}
const bool active =
Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_ACTIVE_TRACKING_SPELL)) ==
spellID;
Game::Lua::PushString(L, name); // 1. name (PushString handles NULL -> nil)
Game::Lua::PushString(L, texture); // 2. texture (icon path) or nil
Game::Lua::PushBool(L, active); // 3. active
Game::Lua::PushString(L, "spell"); // 4. category
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 5. spellID (extension)
return 5;
}
// `SetTracking(index)` — selects (activates) the tracking spell at `index`.
// Vanilla has no "set tracking" primitive: choosing a tracker IS casting its
// spell (the tracking dropdown does exactly this). So we resolve the entry's
// spellbook slot and tail-call the engine's own `Script_CastSpell(slot,
// "spell")` — reusing its slot bounds-check, target resolution, and error
// handling rather than reconstructing the cast path. Returns nil for an
// out-of-range index.
static int __fastcall Script_SetTracking(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: SetTracking(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (index < 1)
return 0;
int spellID = 0;
int slot = 0;
if (EnumerateTracking(index, &spellID, &slot) < index)
return 0;
// Rebuild the stack as CastSpell(slot, "spell") and hand off to the
// engine handler. `slot` is 1-based, matching what a Lua `CastSpell`
// caller passes.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushString(L, "spell");
using CastSpell_t = int(__fastcall *)(void *);
auto castSpell = reinterpret_cast<CastSpell_t>(Offsets::FUN_SCRIPT_CAST_SPELL);
return castSpell(L);
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetNumTrackingTypes",
&Script_GetNumTrackingTypes);
Game::Lua::RegisterGlobalFunction("GetTrackingInfo", &Script_GetTrackingInfo);
Game::Lua::RegisterGlobalFunction("SetTracking", &Script_SetTracking);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Tracking
+1 -3
View File
@@ -25,7 +25,6 @@ namespace Spell::Usable {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
// Spell.dbc record field offsets we read for usability. Fully documented
@@ -52,8 +51,7 @@ bool PlayerKnowsSpell(int spellID) {
}
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return Game::Read<const uint8_t *>(
+118 -8
View File
@@ -49,15 +49,49 @@
// `n` field is the 5.0 vararg-table contract (`arg` = {n=3, holes}),
// where trailing nils are intentional — healing it would corrupt
// vararg counts.
// 3. Otherwise the out-of-band length is stale (keys cleared without
// setn): scan down for the true border, 5.1-style, and return that.
// Read-only — no write-back, so a length read never mutates state;
// the engine's own `table.insert` calls `luaL_setn(n+1)` on the next
// append, which re-syncs the stored length by itself.
// 3. `t[n]` nil, no `t.n`: scan down for the true border `b` and heal to
// it (the 5.1 answer) — UNLESS the nil at slot `n` is a deliberate
// `table.insert(t, nil)` append slot, detected by the writer-side mark
// below, in which case keep the stored length. Read-only — no
// write-back, so a length read never mutates state; the engine's own
// `table.insert` calls `luaL_setn(n+1)` on the next append, which
// re-syncs the stored length by itself.
//
// Why a writer-side mark (the tinsert co-hook) and not a state heuristic: a
// deliberate 5.0 nil append (`table.insert(t, nil)` — writes `t[n]=nil` +
// `setn(n)`; issue #36: Waterfall's `{key,val,…}` builders append a nil for
// every option without a passValue, and the next insert must land AFTER the
// reserved slot) and a cleared-and-reused table that happens to be stale by
// exactly one (a one-element table pairs-cleared, or a recycled table whose
// previous life was one slot longer — Tablet-2.0's `del()`/`copy()` pool
// recycles per tooltip refresh, so this is everyday traffic) leave the table
// in BYTE-IDENTICAL states: stored `n`, dense below, `t[n]` nil, no `t.n`.
// No inspection of the table can split them, and caller-gating can't either
// (both reach here through `table.insert`). v1.12.8 tried a staleness-gap
// heuristic (keep when `n - b == 1`) and it corrupted the second group —
// issue #39, FuBar/Tablet/Dewdrop menus and tooltips shifting by one with a
// nil hole at slot 1. Only the WRITER knows which dialect the table speaks,
// and the write is observable: a co-hook on `table.insert`
// (FUN_LUA_TABLE_INSERT) records `t → storedN` in a weak-keyed registry
// table whenever the two-arg form appends a literal nil. Rule 3 keeps the
// stored length only for a marked table whose mark still equals `n`; every
// unmarked off-by-one table heals. A stale mark dies on its own: any later
// append/remove moves the stored length away from the recorded value, and
// the weak key lets a dead table's entry be collected.
//
// Note real Lua 5.1 gives the HEAL answer even for the deliberate append
// (`#`-based tinsert drops a trailing nil and the next insert overwrites
// it), so keeping the slot is deliberately 5.0-native behavior for
// 5.0-dialect writers — Waterfall's builder runs on the 5.0 `arg` vararg
// table and predates 5.1. 5.1-dialect code never relies on insert(t, nil)
// reserving a slot, because on real 5.1 it doesn't.
//
// Positional nil insertion (`table.insert(t, pos, nil)`) is not marked and
// heals; no consumer is known, and 5.0 itself makes it a shifted no-op.
//
// `table.setn` itself still works for code that calls it — the heal only
// changes the answer when the stored length points past the border, which
// is the same answer real 5.1 would give.
// changes the answer when the stored length points past the border and no
// mark vouches for it, which is the same answer real 5.1 would give.
#include "Game.h"
#include "Offsets.h"
@@ -70,6 +104,11 @@ using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
LuaLGetN_t g_getnOriginal = nullptr;
Game::Lua::CFunction g_tinsertOriginal = nullptr;
// Registry key of the weak-keyed mark table: `marks[t] = storedN` recorded
// at the moment `table.insert(t, nil)` reserved slot `storedN`.
constexpr char kMarkKey[] = "ClassicAPI_TrailingNilMark";
// True if t[k] (rawgeti) is nil. Balances the stack.
bool SlotIsNil(void *L, int absIdx, int k) {
@@ -79,6 +118,64 @@ bool SlotIsNil(void *L, int absIdx, int k) {
return isNil;
}
// Pushes the mark table, creating `registry[kMarkKey] = setmetatable({},
// {__mode = "k"})` on first use (per Lua state — the registry survives
// `/reload` because the state is reused, and the marked tables survive with
// it). Leaves exactly one value on the stack.
void PushMarkTable(void *L) {
Game::Lua::PushString(L, kMarkKey);
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2); // pop the nil. []
Game::Lua::NewTable(L); // [marks]
Game::Lua::NewTable(L); // [marks, meta]
Game::Lua::PushString(L, "__mode");
Game::Lua::PushString(L, "k");
Game::Lua::RawSet(L, -3); // meta.__mode = "k". [marks, meta]
Game::Lua::SetMetatable(L, -2);// pops meta. [marks]
Game::Lua::PushString(L, kMarkKey);
Game::Lua::PushValue(L, -2); // [marks, key, marks]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // [marks]
}
// True when `marks[t] == n` — the nil at slot `n` was written by a
// deliberate `table.insert(t, nil)` and nothing has moved the stored
// length since. Balances the stack.
bool HasTrailingNilMark(void *L, int absIdx, int n) {
PushMarkTable(L); // [marks]
Game::Lua::PushValue(L, absIdx); // [marks, t]
Game::Lua::RawGet(L, -2); // [marks, marks[t]]
const bool match =
Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER &&
static_cast<int>(Game::Lua::ToNumber(L, -1)) == n;
Game::Lua::SetTop(L, -3); // pop both
return match;
}
// Co-hook on the engine's `table.insert` (both `table.insert` and the
// `tinsert` alias are this one C function). The two-arg form appending a
// literal nil is the 5.0 idiom that must keep its reserved slot; record it
// so rule 3 can tell it apart from an identically-shaped stale table. The
// detection runs before the original (the arg stack is caller-owned, so
// index 1 still holds the table afterwards); the mark reads the raw stored
// length the original's `luaL_setn` just wrote.
int __fastcall TableInsert_h(void *L) {
const bool nilAppend = Game::Lua::GetTop(L) == 2 &&
Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE &&
Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL;
const int ret = g_tinsertOriginal(L);
if (nilAppend) {
const int storedN = g_getnOriginal(L, 1);
PushMarkTable(L); // [marks]
Game::Lua::PushValue(L, 1); // [marks, t]
Game::Lua::PushNumber(L, storedN);
Game::Lua::RawSet(L, -3); // marks[t] = storedN. [marks]
Game::Lua::SetTop(L, -2); // pop marks
}
return ret;
}
int __fastcall LuaLGetN_h(void *L, int idx) {
const int n = g_getnOriginal(L, idx);
if (n <= 0)
@@ -104,16 +201,29 @@ int __fastcall LuaLGetN_h(void *L, int idx) {
if (hasExplicitN)
return n;
// Stale out-of-band length — heal to the true border.
// Scan down for the true border.
int b = n - 1;
while (b > 0 && SlotIsNil(L, absIdx, b))
--b;
// Stored length exactly one past the border: either a deliberate
// `table.insert(t, nil)` reserved slot (keep — issue #36) or a cleared /
// recycled table stale by one (heal — issue #39). The states are
// identical; only the writer-side mark recorded by `TableInsert_h` can
// tell them apart.
if (n - b == 1 && HasTrailingNilMark(L, absIdx, n))
return n;
// Stale — heal to the border, the 5.1 answer.
return b;
}
const Game::HookAutoRegister _hook{Offsets::LUAL_GETN,
reinterpret_cast<void *>(&LuaLGetN_h),
reinterpret_cast<void **>(&g_getnOriginal)};
const Game::HookAutoRegister _hookInsert{
Offsets::FUN_LUA_TABLE_INSERT, reinterpret_cast<void *>(&TableInsert_h),
reinterpret_cast<void **>(&g_tinsertOriginal)};
} // namespace
+2
View File
@@ -2039,4 +2039,6 @@ void PrepareForReload() {
g_nodeBakedColor.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Text::InlineTexture
+2
View File
@@ -559,4 +559,6 @@ void PrepareForReload() {
g_maintainDisabled = false; // re-arm the SEH latch for the fresh UI
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Text::InlineTexturePool
+83 -94
View File
@@ -23,17 +23,23 @@
// bind the mask(s) on texture units 1..N (the MODULATE combiner multiplies each
// mask's alpha into the base's alpha) and re-run the engine's own submit.
//
// Two mask sources, two sampling paths:
// * SetMask("path") — the mask samples at the base's own texcoords, so it
// rides the explicit second UV stream (uv1 = uv0). One mask max: the
// engine's vertex-format table has no third UV set.
// * AddMaskTexture(maskRegion) — positioned masks. These use TEXGEN, the same
// mechanism as the engine's own minimap-disc mask (FUN_004ec440): each
// unit's UV is generated from the raw vertex POSITION and mapped through a
// per-unit texture matrix built from the mask's rect (plus its SetRotation,
// which is just a rotation term in the matrix). No vertex stream needed, so
// up to 7 simultaneous masks (device-stage-cap-clamped), each independently
// positioned, sized, rotated, and animated.
// Two mask sources, ONE sampling path — TEXGEN, the same mechanism as the
// engine's own minimap-disc mask (FUN_004ec440): each unit's UV is generated
// from the raw vertex POSITION and mapped through a per-unit texture matrix.
// No vertex stream is consumed per mask, so up to 7 simultaneous masks
// (device-stage-cap-clamped), and both sources compose (alpha intersection):
// * SetMask("path") — the matrix is built from the BASE region's own rect
// (+ its SetRotation, so the mask follows a rotated base). Position-based
// on purpose: the original recipe here (the minimap BLIP mask's
// FUN_004eae10, explicit second UV stream with uv1 = uv0) sampled the mask
// at the base's texcoords, which breaks for SetTexCoord'd sprites — the
// mask is then sampled at the same sub-rect OF THE MASK IMAGE, showing a
// corner chunk of the shape instead of the shape. Texgen spans the drawn
// quad regardless of texcoords (retail semantics); for default 0..1
// texcoords the two formulas are identical (see BuildMaskMatrix).
// * AddMaskTexture(maskRegion) — positioned masks: matrix from the mask
// region's rect (plus its SetRotation, a rotation term in the matrix);
// each independently positioned, sized, rotated, and animated.
//
// State discipline: the queued draw is only flushed at the indexed submit
// (FUN_GX_PRIM_INDEXED) the engine issues right after this stream call, so the
@@ -130,51 +136,7 @@ using PrimStreams_t = void(__fastcall *)(int count, const void *pos, int posStri
PrimStreams_t g_primStreamsOriginal = nullptr;
// Runs the engine's own submit with the mask bound on unit 1 and uv1 = uv0.
// Returns true if it drew; false if the mask isn't resident yet (caller draws
// the base unmasked this frame and retries next). POD-only body for the SEH
// wrapper.
bool MaskedDrawImpl(void *maskH, int count, const void *pos, int posStride, const void *s3,
int s3Stride, const void *colors, int colorStride, int drop8, int drop9,
const void *uv0, int uv0Stride) {
// Per-frame residency reference (force=1), exactly like the minimap's mask.
void *maskCGx =
reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(maskH, 1, nullptr);
if (maskCGx == nullptr)
return false;
auto rs = reinterpret_cast<GxRsSet_t>(Offsets::FUN_GX_RS_SET);
auto rsPtr = reinterpret_cast<GxRsSetPtr_t>(Offsets::FUN_GX_RS_SET_PTR);
// Historical note: this push/pop pair was believed to be a per-unit
// enable/disable; it's actually unit 1's texture-matrix push/pop (binding
// is what enables a stage). The pair is a net no-op here but its
// dirty-marking is part of the exact sequence this path shipped and was
// verified with — left untouched.
reinterpret_cast<GxTexMtxOp_t>(Offsets::FUN_GX_TEXMTX_PUSH)(1);
rsPtr(Offsets::GXRS_TEXTURE0 + 1, maskCGx); // bind mask on unit 1
// Force unit 1's combiner to MODULATE, exactly as the engine's own masked
// draw FUN_004eae10 does (GxRs(0x20, 1)) — robust vs. relying on the
// backend default.
rs(Offsets::GXRS_COMBINER0 + 1, Offsets::GXRS_COMBINE_MODULATE);
// uv1 = uv0 → unit 1 samples the mask at the base's texcoords.
g_primStreamsOriginal(count, pos, posStride, s3, s3Stride, colors, colorStride, drop8, drop9,
uv0, uv0Stride, uv0, uv0Stride);
reinterpret_cast<GxTexMtxOp_t>(Offsets::FUN_GX_TEXMTX_POP)(1);
return true;
}
bool SafeMaskedDraw(void *maskH, int count, const void *pos, int posStride, const void *s3,
int s3Stride, const void *colors, int colorStride, int drop8, int drop9,
const void *uv0, int uv0Stride) {
__try {
return MaskedDrawImpl(maskH, count, pos, posStride, s3, s3Stride, colors, colorStride,
drop8, drop9, uv0, uv0Stride);
} __except (EXCEPTION_EXECUTE_HANDLER) {
g_enabled = false; // disable the feature for the session
return true; // may have partly drawn — don't double-draw
}
}
// --- object mask: clip the base to a positioned mask region ------------------
// --- region rect resolution ---------------------------------------------------
using RegionGetRect_t = int(__thiscall *)(void *anchor, float *outTLBR);
using RegionResolve_t = void(__thiscall *)(void *anchor, int flag);
@@ -197,13 +159,13 @@ bool RegionRect(void *region, float outTLBR[4]) {
return reinterpret_cast<RegionGetRect_t>(Offsets::FUN_REGION_GET_RECT)(anchor, outTLBR) != 0;
}
// --- texgen path: positioned masks on units 1..N -----------------------------
// --- texgen: every mask samples on its own unit, 1..N -------------------------
//
// Each mask unit samples via texgen (object-space position) through a texture
// matrix mapping the raw vertex position to the mask's UV — the engine's own
// minimap-disc-mask mechanism. Outside the mask's rect the UV leaves [0,1] and
// the sampler clamps to the mask's transparent border, clipping the base
// (verified live on the uv1 path; same sampler state).
// (verified live on the original uv1 = uv0 SetMask recipe; same sampler state).
//
// The position→UV map is affine, derived from two verified facts:
// * corner positions and the layout rect share the same anchor-unit space
@@ -219,8 +181,10 @@ bool RegionRect(void *region, float outTLBR[4]) {
// v = ( s·x − c·y + Ty − maskTop ) / maskHd [maskHd = bottom − top]
// with c = cos a, s = sin a, pivot P in rendered space, and
// Tx = Px·(1−c) − s·(midY−Py), Ty = Py + c·(midY−Py) − s·Px
// (a = 0 ⇒ u = (x − left)/W, v = (midY − y − top)/Hd — exactly the shipped and
// verified uv1-path formula re-expressed through positions).
// (a = 0 ⇒ u = (x − left)/W, v = (midY − y − top)/Hd — exactly the retired
// uv1 = uv0 SetMask recipe's formula re-expressed through positions, so a
// path mask on a default-texcoord texture renders identically to that
// shipped-and-verified path).
bool BuildMaskMatrix(const float mr[4], float midY, bool rotated, float angle, float cxN,
float cyN, float out[16]) {
const float mW = mr[3] - mr[1]; // left→right
@@ -285,13 +249,15 @@ void __fastcall PrimIndexed_h(int primType, int indexCount, const void *indices)
// Runs the engine submit with each mask bound on its own unit via texgen. All
// fallible resolution happens before any Gx state is touched; the restores run
// in PrimIndexed_h. POD-only body for the SEH wrapper.
bool MaskedRegionDrawImpl(void *base, void *const *masks, int maskCount, int count,
const void *pos, int posStride, const void *s3, int s3Stride,
const void *colors, int colorStride, int drop8, int drop9,
const void *uv0, int uv0Stride) {
// Stage budget: units 1..(cap−1), 7 max. Extra masks are dropped, first-
// added wins — the base still draws with every mask that fits.
// in PrimIndexed_h. Returns false when a mask isn't resolvable yet (caller
// draws the base unmasked this frame and retries next). POD-only body for the
// SEH wrapper.
bool MaskedRegionDrawImpl(void *base, void *pathMaskH, void *const *masks, int maskCount,
int count, const void *pos, int posStride, const void *s3,
int s3Stride, const void *colors, int colorStride, int drop8,
int drop9, const void *uv0, int uv0Stride) {
// Stage budget: units 1..(cap−1), 7 max. Extra masks are dropped, path
// mask then Add order — the base still draws with every mask that fits.
void *dev = Game::Read<void *>(Offsets::VAR_GX_DEVICE);
if (dev == nullptr)
return false;
@@ -300,8 +266,6 @@ bool MaskedRegionDrawImpl(void *base, void *const *masks, int maskCount, int cou
usable = 7;
if (usable < 1)
return false;
if (maskCount > usable)
maskCount = usable;
float br[4]; // base rect {top, left, bottom, right}
if (!RegionRect(base, br))
@@ -315,26 +279,45 @@ bool MaskedRegionDrawImpl(void *base, void *const *masks, int maskCount, int cou
void *cgx[7];
float mtx[7][16];
for (int i = 0; i < maskCount; ++i) {
int units = 0;
// The path mask (SetMask) spans the base's own rect and follows the base's
// rotation — the exact matrix a mask region covering the base would get.
// Per-frame residency reference (force=1), exactly like the minimap's mask.
if (pathMaskH != nullptr) {
cgx[units] = reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(
pathMaskH, 1, nullptr);
if (cgx[units] == nullptr)
return false;
float angle = 0.0f, cxN = 0.5f, cyN = 0.5f;
const bool rotated = Texture::Transform::GetRotation(base, &angle, &cxN, &cyN);
if (!BuildMaskMatrix(br, midY, rotated, angle, cxN, cyN, mtx[units]))
return false;
++units;
}
for (int i = 0; i < maskCount && units < usable; ++i, ++units) {
// The mask's owned HTEXTURE (stored by SetTexture at +0xCC, present
// even while hidden — the draw-entry array only exists after a render,
// which a hidden mask never gets). Resolving to a renderable each frame
// IS the residency reference, exactly like the path-mask path.
// IS the residency reference, same as the path mask above.
void *hTex = Game::Read<void *>(masks[i], Offsets::OFF_SIMPLETEXTURE_HTEXTURE);
if (hTex == nullptr)
return false;
cgx[i] = reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(hTex, 1,
nullptr);
if (cgx[i] == nullptr)
cgx[units] = reinterpret_cast<GetRenderable_t>(Offsets::FUN_TEXTURE_GET_RENDERABLE)(
hTex, 1, nullptr);
if (cgx[units] == nullptr)
return false;
float mr[4];
if (!RegionRect(masks[i], mr))
return false;
float angle = 0.0f, cxN = 0.5f, cyN = 0.5f;
const bool rotated = Texture::Transform::GetRotation(masks[i], &angle, &cxN, &cyN);
if (!BuildMaskMatrix(mr, midY, rotated, angle, cxN, cyN, mtx[i]))
if (!BuildMaskMatrix(mr, midY, rotated, angle, cxN, cyN, mtx[units]))
return false;
}
if (units == 0)
return false;
// Infallible tail. Track each push as it lands so a fault mid-sequence
// never pops something that wasn't pushed.
@@ -344,7 +327,7 @@ bool MaskedRegionDrawImpl(void *base, void *const *masks, int maskCount, int cou
auto rsPtr = reinterpret_cast<GxRsSetPtr_t>(Offsets::FUN_GX_RS_SET_PTR);
reinterpret_cast<void(__fastcall *)()>(Offsets::FUN_GX_STATE_PUSH)();
g_pendingRestore = 1u;
for (int i = 0; i < maskCount; ++i) {
for (int i = 0; i < units; ++i) {
const int unit = 1 + i;
rsPtr(Offsets::GXRS_TEXTURE0 + unit, cgx[i]);
rs(Offsets::GXRS_COMBINER0 + unit, Offsets::GXRS_COMBINE_MODULATE);
@@ -358,13 +341,13 @@ bool MaskedRegionDrawImpl(void *base, void *const *masks, int maskCount, int cou
return true;
}
bool SafeMaskedRegionDraw(void *base, void *const *masks, int maskCount, int count,
const void *pos, int posStride, const void *s3, int s3Stride,
const void *colors, int colorStride, int drop8, int drop9,
const void *uv0, int uv0Stride) {
bool SafeMaskedRegionDraw(void *base, void *pathMaskH, void *const *masks, int maskCount,
int count, const void *pos, int posStride, const void *s3,
int s3Stride, const void *colors, int colorStride, int drop8,
int drop9, const void *uv0, int uv0Stride) {
__try {
return MaskedRegionDrawImpl(base, masks, maskCount, count, pos, posStride, s3, s3Stride,
colors, colorStride, drop8, drop9, uv0, uv0Stride);
return MaskedRegionDrawImpl(base, pathMaskH, masks, maskCount, count, pos, posStride, s3,
s3Stride, colors, colorStride, drop8, drop9, uv0, uv0Stride);
} __except (EXCEPTION_EXECUTE_HANDLER) {
RestoreAfterDraw(); // pop exactly what was pushed before the fault
g_enabled = false;
@@ -386,23 +369,27 @@ void __fastcall PrimStreams_h(int count, const void *pos, int posStride, const v
// effectively impossible.
void *region = reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(pos) -
Offsets::OFF_SIMPLETEXTURE_CORNERS);
// Positioned mask regions (AddMaskTexture) win over a path mask.
// A path mask (SetMask) and positioned mask regions (AddMaskTexture)
// compose — every mask MODULATEs into the same draw.
void *pathMaskH = nullptr;
if (!g_regionMask.empty()) {
auto it = g_regionMask.find(region);
if (it != g_regionMask.end())
pathMaskH = it->second.handle;
}
void *const *masks = nullptr;
int maskCount = 0;
if (!g_baseMasks.empty()) {
auto mit = g_baseMasks.find(region);
if (mit != g_baseMasks.end() && !mit->second.empty()) {
if (SafeMaskedRegionDraw(region, mit->second.data(),
static_cast<int>(mit->second.size()), count, pos,
posStride, s3, s3Stride, colors, colorStride, drop8,
drop9, uv0, uv0Stride))
return;
masks = mit->second.data();
maskCount = static_cast<int>(mit->second.size());
}
}
auto it = g_regionMask.find(region);
if (it != g_regionMask.end() && it->second.handle != nullptr) {
if (SafeMaskedDraw(it->second.handle, count, pos, posStride, s3, s3Stride, colors,
colorStride, drop8, drop9, uv0, uv0Stride))
return;
}
if ((pathMaskH != nullptr || maskCount > 0) &&
SafeMaskedRegionDraw(region, pathMaskH, masks, maskCount, count, pos, posStride, s3,
s3Stride, colors, colorStride, drop8, drop9, uv0, uv0Stride))
return;
}
g_primStreamsOriginal(count, pos, posStride, s3, s3Stride, colors, colorStride, drop8, drop9,
uv0, uv0Stride, uv1, uv1Stride);
@@ -604,4 +591,6 @@ void PrepareForReload() {
g_maskRegions.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Texture::Mask
+2
View File
@@ -674,6 +674,8 @@ void PrepareForReload() {
g_fsBaseline.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
// Fresh-bake entry (the inline-texture DrawBuilder co-hook): the emitter just
// baked axis-aligned verts, so capture the baseline and apply the rotation. See
// ApplyToNode / Transform.h for the timing contract.
+16
View File
@@ -293,7 +293,23 @@ void RegisterLuaFunctions() {
} // namespace
// Cancel every pending timer and release its Lua registry callback before a
// /reload (or logout) resets the Lua state. Otherwise g_timers would carry
// entries whose "ClassicAPI.Timer.N" registry callbacks died with the reset:
// the WorldTick fire pass drops a nil callback safely, but a surviving/stale
// registry ref could fire a dead closure. Clearing here is the consistent,
// correct behavior — a reload cancels pending timers, matching modern WoW,
// where C_Timer callbacks are Lua closures that don't survive the reset.
void PrepareForReload() {
if (void *L = Game::Lua::State())
for (const auto &t : g_timers)
ReleaseCallback(L, t.id);
g_timers.clear();
g_nextID = 1;
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
static const Tick::WorldTick::AutoSubscribe _tick{&Tick};
} // namespace Time::Timer
+2
View File
@@ -253,4 +253,6 @@ void PrepareForReload() {
g_cellCount = 0;
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Tooltip::SetEvents
+92
View File
@@ -0,0 +1,92 @@
// 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/>.
// Turtle WoW "Dark Harvest" (warlock channel; ranks 52550/52551/52552, family
// bit 0x4000000000): "While channeling, the time between periodic ticks of
// your Affliction spells on the target is reduced by 30%." The live server
// preserves each dot's TICK COUNT, so the faster ticks consume the dot's
// remaining time 30% faster while the channel aura is up — it falls off early
// by 0.3 × the overlap with NO client-visible aura change (live report: dots
// gone with ~3 s still showing after a full 8 s channel = 2.4 s + tick
// rounding; Cursive's curses.lua ships the same `remaining -= 0.30 × overlap`
// community calibration). tortoise-wow's recreation (spell_warlock.cpp
// `spell_warlock_dark_harvest`, added 2026-08-25) only re-times the tick
// timers via SetPeriodicTimer and leaves holder duration alone — that predicts
// NO early fall-off, contradicting live, so the compression math here is
// deliberately NOT mirrored from that source.
//
// What IS taken from tortoise-wow (`IsDarkHarvestAfflictionPeriodicAura`): the
// affected set — the caster's own SPELLFAMILY_WARLOCK periodic auras matching
// Corruption (0x2), Drain Life (0x8), Curse of Agony (0x400), Drain Soul
// (0x4000), Siphon Life (0x1'00000000), Curse of Doom (0x2'00000000). The
// drains can't overlap the channel in practice (one channel per caster), so
// the live set is the curses + Corruption + Siphon Life. Client DBC verified:
// all three DH ranks carry famName 5 / famFlags 0x4000000000, so the
// family-mask trigger is rank-proof.
//
// The percent comes from the rank record's EffectBasePoints[1] + 1 (the dummy
// effect the tooltip's $s2 prints: -31 → 30%), with the community-calibrated
// 30 as fallback — a Turtle rebalance that patches the client DBC tracks
// automatically. All ranks carry the same -31 today, so rank 1 is read.
//
// Gated on Turtle::Detected() via a WorldTick latch, like StingingNettle —
// the spells only exist in Turtle's DBC, so the rule is inert elsewhere
// anyway.
#include "Offsets.h"
#include "aura/Source.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::DarkHarvest {
namespace {
constexpr uint32_t kWarlock = 5; // SPELLFAMILY_WARLOCK
constexpr uint64_t kDarkHarvestFlag = 0x4000000000ULL;
constexpr uint64_t kAfflictionMask = 0x2ULL | 0x8ULL | 0x400ULL | 0x4000ULL |
0x100000000ULL | 0x200000000ULL;
constexpr uint32_t kDarkHarvestR1 = 52550;
constexpr int32_t kFallbackPct = 30;
int32_t HarvestPct() {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(kDarkHarvestR1));
if (rec == nullptr)
return kFallbackPct;
// EffectBasePoints[1] (the -30% dummy effect; index 0 is the tick damage).
const int32_t pct =
*reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS + 4) +
1;
return pct < 0 ? -pct : kFallbackPct;
}
bool g_registered = false;
void OnTick() {
if (g_registered || !Turtle::Detected())
return;
Aura::Source::AddTickCompression(kWarlock, kDarkHarvestFlag, kWarlock,
kAfflictionMask, HarvestPct());
g_registered = true;
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnTick};
} // namespace
} // namespace Turtle::DarkHarvest
+133
View File
@@ -0,0 +1,133 @@
// 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/>.
// Turtle WoW "Stinging Nettle" hunter talent (talent 403; rank passives
// 51579 / 51580): Mongoose Bite and triggered fire traps apply the hunter's
// highest known Serpent Sting rank at 20% (R1) / 40% (R2) of its duration.
//
// Server-side (tortoise-wow spell_hunter.cpp ApplyStingingNettle) the sting
// is a direct `target->AddAura(...)` — no cast, no SMSG_SPELL_GO for the
// sting itself — so the client sees only the descriptor application and
// OnAuraAdded would seat it caster-less at the full 15s base. The TRIGGERS
// are visible, though: Mongoose Bite is a real player cast, and the
// fire-trap effect spells are cast by the trap's OWNER (GameObject::Use
// TYPE_TRAP → pOwner->CastSpell) with non-zero SpellVisual, so they pass
// IsNeedSendToClient and their SMSG_SPELL_GO broadcasts with the hunter as
// caster and the victim in the hit list. Registered as Aura::Source
// triggered-application rules: the trigger arms its hit targets, and the
// caster-less Serpent Sting application that follows consumes the arm
// (player attribution + the server's max(1, base × pct / 100)).
//
// The rank gates are the talent-granted passives, read live from the
// spell-knowledge bitmap at trigger time (respec-safe); R2 registers first
// so its 40% wins while known. The percents come from the rank spells' own
// EffectBasePoints[0] + 1 — the same DBC value the server's
// CalculateSimpleValue reads (19 → 20%, 39 → 40%), with the server's own
// 20/40 fallback — so a Turtle rebalance that patches the client DBC tracks
// automatically.
//
// Server guards that mirror for free: no talent → no arm (gate fails, and
// the server applies nothing); an existing LONGER sting → the server skips
// AddAura entirely → no application event → the arm just expires. A nettle
// re-apply over a SHORTER existing sting reuses the descriptor slot (no
// OnAuraAdded, verified in-game with chained Mongoose Bites), so the arm
// mechanism additionally refreshes the existing cached entry at trigger
// time, mirroring the server's keep-the-longer rule — see
// RefreshTriggeredExisting in aura/Source.cpp. Other hunters' nettle stings
// stay at base duration (their talents aren't client-knowable).
//
// Gated on Turtle::Detected() via a WorldTick latch, like DurationMods —
// the trigger spells exist on stock 1.12, but the mechanic is Turtle's.
#include "Offsets.h"
#include "aura/Source.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "turtle/Detect.h"
#include <cstdint>
namespace Turtle::StingingNettle {
namespace {
constexpr uint32_t kHunter = 9; // SPELLFAMILY_HUNTER
// Serpent Sting's SpellFamilyFlags bit — all player ranks carry it (1978,
// 13549..13555, 25295, and Turtle's added Rank 9, 33459; client DBC
// verified).
constexpr uint64_t kSerpentStingFlag = 0x4000;
constexpr uint32_t kNettleR1 = 51579; // 20%
constexpr uint32_t kNettleR2 = 51580; // 40%
// The rank's duration percent: its record's EffectBasePoints[0] + 1 (the
// server's CalculateSimpleValue), else the server's hardcoded fallback.
int32_t NettlePct(uint32_t rankSpellId, int32_t fallbackPct) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(rankSpellId));
if (rec == nullptr)
return fallbackPct;
const int32_t pct =
*reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS) +
1;
return pct > 0 ? pct : fallbackPct;
}
// Fire-trap effects' SpellFamilyFlags bit. Verified exhaustively against the
// client Spell.dbc: bit 0x4 in the hunter family selects EXACTLY the eight
// spells Turtle's script binding lists (Immolation Trap Effect r1-5,
// Explosive Trap Effect r1-3) and nothing else — so the mask form is
// rank-proof, covering any future trap rank for free. (Freezing Trap is the
// distinct bit 0x8.)
constexpr uint64_t kFireTrapEffectFlag = 0x4;
// Mongoose Bite ranks must stay EXACT IDs: their family bit (0x2) is shared
// with all eight Raptor Strike ranks — verified against the DBC — and Raptor
// Strike does NOT trigger nettle server-side. Mask-matching it would keep a
// melee-weaving hunter's target permanently armed.
constexpr uint32_t kMongooseBite[] = {1495, 14269, 14270, 14271};
bool g_registered = false;
void RegisterAll() {
const int32_t pctR2 = NettlePct(kNettleR2, 40);
const int32_t pctR1 = NettlePct(kNettleR1, 20);
// Higher rank first — rules matching the same trigger arm in
// registration order, first live gate wins.
Aura::Source::AddTriggeredApplicationByFamily(
kHunter, kFireTrapEffectFlag, kNettleR2, kHunter, kSerpentStingFlag,
pctR2);
Aura::Source::AddTriggeredApplicationByFamily(
kHunter, kFireTrapEffectFlag, kNettleR1, kHunter, kSerpentStingFlag,
pctR1);
for (uint32_t id : kMongooseBite) {
Aura::Source::AddTriggeredApplication(id, kNettleR2, kHunter,
kSerpentStingFlag, pctR2);
Aura::Source::AddTriggeredApplication(id, kNettleR1, kHunter,
kSerpentStingFlag, pctR1);
}
}
void OnTick() {
if (g_registered || !Turtle::Detected())
return;
RegisterAll();
g_registered = true;
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnTick};
} // namespace
} // namespace Turtle::StingingNettle
+1 -4
View File
@@ -19,8 +19,6 @@ namespace Unit::ClassBase {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `UnitClassBase(unit) → (classFile, classID)` — returns the locale-
// independent class token ("WARRIOR", "PALADIN", "HUNTER", "ROGUE",
// "PRIEST", "SHAMAN", "MAGE", "WARLOCK", "DRUID") plus the numeric
@@ -81,8 +79,7 @@ int __fastcall Script_UnitClassBase(void *L) {
if (Unit::Identity::IsPlayerToken(token)) {
classByte = Game::Read<uint8_t>(Offsets::VAR_PLAYER_CLASS_BYTE);
} else {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit != nullptr) {
auto *desc =
Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
+1 -5
View File
@@ -44,8 +44,6 @@ namespace Unit::CreatureFamily {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitCreatureFamilyID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitCreatureFamilyID(\"unit\")");
@@ -55,9 +53,7 @@ int __fastcall Script_UnitCreatureFamilyID(void *L) {
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit == nullptr)
return 0;
+1 -4
View File
@@ -41,7 +41,6 @@ namespace Unit::CreatureType {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CreatureType_t = int(__fastcall *)(void *unit);
int __fastcall Script_UnitCreatureTypeID(void *L) {
@@ -53,9 +52,7 @@ int __fastcall Script_UnitCreatureTypeID(void *L) {
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *unit = resolve(token);
void *unit = Game::ResolveUnitToken(token);
if (unit == nullptr)
return 0;
+4 -10
View File
@@ -44,8 +44,6 @@ bool IsPlayerObject(const uint8_t *unit) {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// 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
@@ -72,8 +70,7 @@ bool TestPlayerFlag(void *L, uint32_t flagMask) {
if (token == nullptr)
return false;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
// 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).
@@ -123,8 +120,7 @@ int __fastcall Script_UnitIsFeignDeath(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
@@ -265,9 +261,8 @@ int __fastcall Script_UnitIsInMyGuild(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
const uint32_t playerKey = ReadGuildKey(
static_cast<const uint8_t *>(resolve("player")));
static_cast<const uint8_t *>(Game::ResolveUnitToken("player")));
if (playerKey == 0) {
// Player isn't in a guild — nobody is.
Game::Lua::PushNil(L);
@@ -357,8 +352,7 @@ int __fastcall Script_UnitIsPossessed(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
+1 -4
View File
@@ -32,8 +32,6 @@ namespace Unit::Health {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
@@ -44,8 +42,7 @@ const uint8_t *Descriptor(const uint8_t *unit) {
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// `UnitHealthMissing("unit")` — returns `max - current` health (never
+41 -6
View File
@@ -56,7 +56,6 @@ namespace Unit::Pet {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `int __fastcall(unit)` → 1 for a controllable pet, non-1 otherwise.
// The engine's pet-vs-minion title discriminator. See
// FUN_UNIT_PET_MINION_CLASS.
@@ -68,16 +67,14 @@ const uint8_t *ResolveUnit(void *L) {
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return nullptr;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// The unit's owner/controller GUID: CharmedBy, else CreatedBy (the two
// fields the engine itself uses for ownership). 0 for players, world
// creatures, and anything unowned.
uint64_t OwnerGuid(const uint8_t *unit) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
const int owners[] = {
@@ -85,13 +82,24 @@ uint64_t OwnerGuid(const uint8_t *unit) {
Offsets::OFF_UNIT_FIELD_CREATEDBY,
};
for (int off : owners) {
const uint64_t g = *reinterpret_cast<const uint64_t *>(desc + off);
const uint64_t g = Game::Read<uint64_t>(desc, off);
if (g != 0)
return g;
}
return 0;
}
// The spell that summoned this unit (its UNIT_CREATED_BY_SPELL descriptor
// field): the totem-drop spell for a totem, the summon spell for a
// pet/guardian, etc. 0 for a unit not summoned by a spell (players, world
// creatures) or an object with no descriptor.
uint32_t CreatedBySpell(const uint8_t *unit) {
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
return Game::Read<uint32_t>(desc, Offsets::OFF_UNIT_FIELD_CREATED_BY_SPELL);
}
// `UnitIsMinion(unit)` — true iff the unit is a player's minion (pet,
// guardian, totem, or charmed creature): player-controlled but not itself
// a player. NPCs, world creatures, and players return false. A bad or
@@ -184,6 +192,31 @@ int __fastcall Script_UnitOwnerGUID(void *L) {
return 1;
}
// `UnitCreatedBySpell(unit)` — the spell ID that summoned this unit, read
// from its UNIT_CREATED_BY_SPELL field: the shaman totem-drop spell for a
// totem, the summon spell for a pet/guardian, and so on. This is the same
// field the engine's unit-title builder consults to pick the
// "Pet"/"Minion"/"Guardian"/"Creation" title. It is a broadcast descriptor
// field, so it works for any unit in range, not just your own summons.
// Returns nil for an unresolved unit and for anything not summoned by a
// spell (players, world creatures). Note this is the *summoning* spell —
// the spell a totem casts is server-side only and never reaches the client.
// A ClassicAPI extension — not a stock WoW global.
int __fastcall Script_UnitCreatedBySpell(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint32_t spellId = CreatedBySpell(unit);
if (spellId == 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(spellId));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
@@ -192,6 +225,8 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitIsOtherPlayersPet",
&Script_UnitIsOtherPlayersPet);
Game::Lua::RegisterGlobalFunction("UnitOwnerGUID", &Script_UnitOwnerGUID);
Game::Lua::RegisterGlobalFunction("UnitCreatedBySpell",
&Script_UnitCreatedBySpell);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -3
View File
@@ -13,6 +13,7 @@
#pragma once
#include "Game.h"
#include "Offsets.h"
// Shared world-position read for any CGObject-derived unit. The
@@ -32,9 +33,7 @@ namespace Unit::Position {
inline void *ResolveToken(const char *token) {
if (token == nullptr)
return nullptr;
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
return Game::ResolveUnitToken(token);
}
// Reads `obj`'s world position into `out[3]` (x, y, z). Calls
+1 -4
View File
@@ -60,8 +60,6 @@ const char *PowerTypeToken(int type) {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
@@ -72,8 +70,7 @@ const uint8_t *Descriptor(const uint8_t *unit) {
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// Per-power-type display divisor. Vanilla stores rage as 0..1000
+1 -4
View File
@@ -19,8 +19,6 @@ namespace Unit::RaceBase {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `UnitRaceBase(unit) → (raceFile, raceID)` — returns the locale-
// independent race token (`"Human"`, `"Orc"`, `"Dwarf"`, `"NightElf"`,
// `"Scourge"`, `"Tauren"`, `"Gnome"`, `"Troll"`) plus the numeric
@@ -61,8 +59,7 @@ int __fastcall Script_UnitRaceBase(void *L) {
if (Unit::Identity::IsPlayerToken(token)) {
raceByte = Game::Read<uint8_t>(Offsets::VAR_PLAYER_RACE_BYTE);
} else {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit != nullptr) {
auto *desc =
Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
+2 -5
View File
@@ -23,7 +23,6 @@ namespace Unit::ShapeshiftForm {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CancelAuraSend_t = void(__fastcall *)(int spellID);
// Read the local player's current shapeshift form byte. Returns
@@ -32,8 +31,7 @@ using CancelAuraSend_t = void(__fastcall *)(int spellID);
// can tell "transient unresolved" from "left a form" and not fire
// a bogus change event during early-login windows.
int ReadCurrentForm() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return -1;
auto *fields = *reinterpret_cast<const uint8_t *const *>(
@@ -101,8 +99,7 @@ int __fastcall Script_CancelShapeshiftForm(void *L) {
if (form <= 0)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return 0;
+58
View File
@@ -0,0 +1,58 @@
// 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 "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::Sheath {
// `GetSheathState()` — returns which weapon type the local player currently
// has drawn. Takes no arguments. The return is **1-based**, matching modern
// WoW's contract:
// 1 = None (weapons sheathed / put away)
// 2 = Melee (melee weapon drawn)
// 3 = Ranged (ranged weapon drawn)
//
// Vanilla 1.12 ships `ToggleSheath` but no getter. The engine keeps the
// sheath state as a plain CGUnit int member at OFF_UNIT_SHEATH_STATE (+0xD40),
// 0-based (0 = sheathed, 1 = melee, 2 = ranged) — the same field the engine's
// own toggler `FUN_005EB480` reads to pick the next state and its setter
// `FUN_00611CF0` writes. We read the local player's value and add 1 for the
// Lua contract.
//
// Returns 1 (None) when the player CGUnit isn't resolvable yet (out of
// world) — the "weapons put away" default. `FUN_RESOLVE_UNIT_TOKEN` returns
// null for `"player"` out of world rather than raising, so this is safe from
// any context.
static int __fastcall Script_GetSheathState(void *L) {
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
int state = 0;
if (player != nullptr)
state = Game::Read<int>(player, Offsets::OFF_UNIT_SHEATH_STATE);
if (state < 0 || state > 2)
state = 0; // guard against any unexpected value (field is 0/1/2)
Game::Lua::PushNumber(L, static_cast<double>(state + 1));
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSheathState", &Script_GetSheathState);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Unit::Sheath
+1 -3
View File
@@ -20,12 +20,10 @@ namespace Unit::Speed {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetEffectiveSpeed_t = float(__thiscall *)(void *movementInfo, int forceWalk);
const uint8_t *MovementInfoForToken(const char *token) {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
+1 -4
View File
@@ -41,8 +41,6 @@ namespace Unit::SpellHaste {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
@@ -53,8 +51,7 @@ const uint8_t *Descriptor(const uint8_t *unit) {
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// `UnitSpellHaste("unit")` — spell haste percentage. `0` for an unhasted or
+1 -4
View File
@@ -20,8 +20,6 @@ namespace Unit::StandState {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `UnitStandState(unit)` — returns the unit's current standstate as
// the integer encoded in the low byte of `UNIT_BYTES_1`
// (descriptor + 0x210):
@@ -55,8 +53,7 @@ int __fastcall Script_UnitStandState(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit == nullptr) {
Game::Lua::PushNumber(L, 0);
return 1;
+1 -5
View File
@@ -43,8 +43,6 @@ namespace Unit::SubName {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitSubName(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
@@ -54,9 +52,7 @@ int __fastcall Script_UnitSubName(void *L) {
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit == nullptr)
return 0;