Compare commits

...
117 Commits
Author SHA1 Message Date
Brues 4bf2c17e0f docs: repair the broken links in the API reference
Fourteen anchors in docs/API.md pointed at headings that do not exist, so
those links went nowhere. Most are typos in the slug (`doesitemexit`,
`gettaxiroutep`, `afters`) or a slug that was never updated after its
heading gained an argument (`C_Spell.CastAtUnit` picked up
`placeGroundSpell`, `GetItemSubClassInfo` picked up its `C_Item` twin).
`C_QuestLog.GetQuestIDForLogIndex` was written in camel case, which a
heading anchor never is.

`GetMacroInfo` has no entry of its own in this document, so that reference
is now plain text instead of a link to nothing.

Also correct the table-of-contents text for `C_Spell.CastAtUnit`, which
still named the two-argument signature.

Found while checking the anchors this branch touched. A pass over both
documents now reports no broken link, in either direction.
2026-09-11 08:16:45 -05:00
Brues 3191c4dcfc fix(macro): leave a macro alone when its conditions are not ours
A macro written for another macro addon names conditions this parser does
not have. SuperCleveRoidMacros' `[alive]` is one. The `#showtooltip` reader
scans every macro body, so it met that condition and printed
"SecureCmdOptionParse: unknown condition 'alive'" at login, about a macro
that works.

Worse than the noise: a condition we cannot evaluate cannot pass, so its
group fails. A macro with no unconditional fallback clause then matched
nothing, and the reader wrote 0 over the spellID the engine's own parse had
put in the per-macro cache - costing that button its cooldown, range and
usable state.

`SecureCmdOptionParse` now records the keywords it does not own on its
cached parse and returns the first one as a third value. It still names
them, once each, for a caller the player drove. The reader passes the new
`quiet` flag and reads that value instead: on a foreign dialect it resolves
nothing, writes nothing, and stops re-evaluating until the body changes. The
engine's parse stays in the cache, and the addon that owns those conditions
can still claim the macro through `C_Macro.SetMacroDisplay`.

Such an entry also stays out of `dirty`, because a dirty entry nothing
evaluates would keep `HasPendingWork` true and re-run the catch-up on every
reader.

Moving the warn-once bookkeeping from parse time to call time also stops a
quiet scan from consuming the one warning a keyword gets, so a condition the
player later types is still named.

This is not a load-order fault. SuperCleveRoidMacros creates `_G.CleveRoids`
at the top of Init.lua, and that same file sets `ClassicAPIMacroDisplay`,
which lifts our wholesale yield on purpose so it can publish per macro. The
window is between our first tick and its first publish, which it cannot make
until the player object exists.
2026-09-11 08:14:35 -05:00
Brues 373dce8202 ShaguTweaks nil check
Fixes #49
2026-09-11 08:00:51 -05:00
Brues 4c7f1c36d6 docs: cover the macro conditions and #showtooltip in the README
The README said nothing about either, and its Macros section still claimed
the macro work added no Lua functions.

Two rows in Modern client behaviors: the `[conditions]` / `@unit` syntax
across 38 slash commands, and what `#showtooltip` drives on a macro button.
The Macros section now lists the body forms a macro accepts - the
directive, the conditions, the `!Name` prefix, and `#` comment lines, which
no longer reach chat. A new Slash commands section names all 38 commands by
group, and the bundled-addon section mentions that it holds them and
`SecureCmdOptionParse`.

`C_Macro.SetMacroDisplay` and `GetMacroItem` join the Macros row of the
call table, and `IsUnitToken` / `UnitTokenFromName` join the Unit row.

Also add the `## MerchantFrame` heading the README already linked to. The
six `C_MerchantFrame.*` entries sat inside `## Map` with no heading of
their own, so that link went nowhere.
2026-09-10 18:59:10 -05:00
brues-code 36c367a6d6 feat(macro): native #showtooltip, conditional slash commands, and a display API for addons (#48)
* feat(macro): native #showtooltip/#show, conditional /cast and /use

Backport the macro display system to the engine:

- Macro::ShowTooltip resolves `#showtooltip [cond] value` / `#show` (and
  the bare form over the macro's /cast and /use lines, first match wins)
  through the Lua SecureCmdOptionParse on the world tick and writes the
  result into the engine's per-macro spell cache (+0x564/+0x568), which
  every action reader already consumes. Repaints go through the engine's
  own slot-changed notifier. Item targets are cached alongside.
  Re-evaluates on modifier / mouseover / target / combat changes and every
  200 ms otherwise. Yields entirely when SuperCleveRoidMacros is loaded.
- The `?` icon rule is a co-hook on the macro icon getter, so action
  buttons and the drag cursor both show the resolved icon; GetMacroInfo,
  GameTooltip:SetAction, GetActionCooldown, GetActionCount and
  IsConsumableAction are re-registered in front of the engine for macro
  slots with a resolved directive and tail-call it otherwise.
- Macro::RunBody re-implements the engine's body runner so `#` lines
  never reach chat; Macro::Execute skips them too.
- /cast and /use in the embedded addon (3.3.5 handler shape): items
  first, [@unit] via C_Spell.CastAtUnit, 1..19 = inventory slot, other
  numbers stay spellIDs. SecureCmdOptionParse: a group whose only piece
  is @unit passes only while the unit exists (3.3.5 passes it
  unconditionally). C_Item.GetItemCount accepts names. GetMacroItem
  added; GetMacroSpell now reflects the directive.
- Offsets.h: 0x004F0F40 was the macroID -> MacroEntry resolver,
  mislabeled as a spell-state cache; renamed FUN_MACRO_ID_TO_ENTRY, with
  the action-bar / macro-runner constants this needs added and verified.

* feat(unit): add IsUnitToken for token-vs-name dispatch

Vanilla ships each "act on a unit" verb as two Lua entries, one taking a
unit token and one taking a character name, and each errors on the other
kind of input. Conditional slash commands need to accept either, because
SecureCmdOptionParse hands back a token for `/target [@focus]` and a name
for `/target Bob` through the same variable.

IsUnitToken(value) answers which one a string is by calling the engine's
own token resolver inside lua_pcall and reporting whether it returned or
threw. Consulting the resolver rather than matching a token list here
keeps the answer correct for every family it knows, including this
client's native GUID literals and our own focus / nameplateN / markN,
which unit/TokenExtensions.cpp hooks onto the same function. It reports
whether the text names a token, not whether a unit is there, so "target"
with nothing targeted is still true.

    if IsUnitToken(x) then TargetUnit(x) else TargetByName(x) end

Additive on purpose. Merging the pairs into a modern TargetUnit /
AssistUnit / FollowUnit was written and reverted: those names are common
enough for other DLLs and addons to replace that re-registration is
silently last-one-wins, with no error and no trampoline to chain. That
mechanism is only safe for names nobody else contends.

Offsets.h records two findings from decompiling the resolver. The throw
is raised by its callee, not by FUN_RESOLVE_UNIT_TOKEN, which is a thin
wrapper returning 0 on a miss. And this client reaches the error through
a patched fallthrough that first parses a 0x<hex> GUID literal, so GUID
unit tokens resolve natively with no SuperWoW loaded.

* feat(addon): backport the conditional slash commands

Adds 27 commands that take [conditions] through SecureCmdOptionParse,
following the 3.3.5 SecureCmdList handler shapes: targeting
(/targetexact, /cleartarget, /targetlasttarget, /targetlastenemy,
/targetenemy, /targetfriend, /targetenemyplayer, /targetfriendplayer,
/targetparty, /targetraid), casting and state (/stopcasting, /cancelaura,
/cancelform, /dismount), equipment (/equip, /equipslot), action bars
(/changeactionbar, /swapactionbar) and the pet family.

/target, /assist and /follow gain conditions by replacing the FrameXML
handlers, which is the only way to add them. Plain input keeps working:
each routes through IsUnitToken to the engine's token entry or its
by-name entry, where the old handler always went by name.

Three natives have a different shape here than the 3.3.5 handlers assume,
so those bodies are not straight ports. PetAttack takes no target and
always sends the pet at the current target. ChangeActionBarPage takes no
page and only fires ACTIONBAR_PAGE_CHANGED, so CURRENT_ACTIONBAR_PAGE is
set first. Pet autocast is a spellbook-slot toggle with no enable and
disable entries, so a name is resolved against the pet book and on/off
read the state and toggle only on a mismatch. /cancelaura needs no buff
walk, since CancelSpellByName already matches a player buff by name.

Not ported for want of a native: /targetlastfriend, and the /mt and /ma
family, which needs party assignments the client does not have.

Command names come from the locale files, with the localized name taken
from the matching Blizzard GlobalStrings and the English one alongside.
zhCN ships English for all 27. The short aliases retail also carries
(/eq, /tar) are left out, since those collide with addon shorthands.

* fix(macro): resolve item link values in #showtooltip

`#showtooltip` over a `/use item:6948` line showed no icon. The directive
evaluator built its item argument as `{0, 0, value}`, hardcoding the whole
value as a NAME, so the `item:N` and pasted-link forms could never match
and the value fell through to the spell lookup. `/use` itself was fine,
which is why the macro cast the item but the button stayed blank.

The link parsing already existed for the Lua entries. Split it out as
`Item::Arg::ResolveString` and call that instead of hand-building the
argument, so both paths read the forms the same way.

A bare number keeps skipping the item lookup: `ResolveString` reads "6948"
as an itemID, and a bare number that is not an equipment slot has to stay
a spellID per the documented `/cast 5019` rule.

An explicit itemID now also resolves when none is carried, falling back to
the ID after the bag and equipment walk misses. The icon and tooltip come
from the item record, and the tooltip path already handled an unheld item
through a hyperlink, so a macro naming an item in the bank or one written
before looting it now displays. Names are still limited to what you hold,
since 1.12 has no name-keyed item cache.

* fix(macro): make [@cursor] work in /cast, /use and #showtooltip

`/use [@cursor] item:4390` did nothing and its `#showtooltip` showed the
question mark. Two causes, both in the addon.

The parser rejected the clause. A group whose only piece is `@unit` passes
only while that unit exists, and the existence test ran UnitExists on
`cursor`, which is not a unit token. The engine raises there, the pcall
caught it, and the group failed. `none` was already exempt; `cursor` joins
it, since both name something other than a unit and neither can be tested
that way.

Nothing routed the target once it did pass. `SecureCmdUseItem` sent every
target through UseItemByName, which takes a unit GUID, and the spell branch
had only the on-unit and default cases. A `cursor` target now goes to
C_Item.UseAtCursor and C_Spell.CastAtCursor, which were written as the
`[@cursor]` analogs but had no caller. SecureCmdItemParse has already
turned a bag or inventory slot into a link, so one item call covers every
value form.

`#showtooltip` needed no change: the value resolves once the clause passes.

* feat(macro): accept a character name as a macro target

`/cast [target=Feral] Rejuvenation` did nothing. A `@unit` or `target=unit`
piece takes a character name as well as a unit token, but nothing here
turned a name into something the unit functions accept, and the existence
gate ran UnitExists on it, which raises for a string that names no token.

`UnitTokenFromName(name [, exactMatch])` runs the engine's own by-name
search — `FUN_UNIT_FIND_BY_NAME`, the one behind TargetByName, AssistByName
and FollowByName — and reports the winner as a token: a standard one when
it fits, else the GUID literal, which this client's resolver takes too. It
walks party, then raid, then every loaded unit, so it reaches units no
token names. Prefix match by default, nearest first, like TargetByName.

`GroupPasses` now resolves a named target once and uses it for both jobs:
the bare-group existence test (`[@Feral][] Spell` falls through when they
are not around) and the conditions, which previously raised on a name.
The pcall that already guarded the existence test is what tells a token
from a name, so a token target costs no more than before.

The slash handlers resolve the target the same way before handing it to
CastAtUnit, UseItemByName, FocusUnit or StartAttack, and drop the command
when a named unit is not around.

* feat(macro): drop a ground-target item at your feet with [@player]

`/use [@player] item:4390` sent the grenade through UseItemByName, which
passes a target GUID but never commits the placement, so the reticle came
up instead of the grenade landing. Route a `player` target to
`C_Item.UseAtUnit` instead, which commits at the unit's position.

Only your own position is offered. Aiming a ground-target item at another
unit is not something the game lets you do by hand, so the slash handler
does not either; every other unit target keeps going to UseItemByName.

`C_Item.UseAtUnit` now dispatches with the unit's GUID rather than a zero
target, matching `C_Spell.CastAtUnit`. Without that, routing `@player`
through it would have cost a non-ground item its target: a bandage or a
scroll would fire at the current selection instead of at you. It now
covers both kinds of on-use item, the way CastAtUnit covers both kinds of
spell.

* fix(macro): stop /cast [@unit] aiming ground spells at that unit

The same aim the item side was just denied: `/cast [@target] Flamestrike`
dropped the reticle on the target with no click, for any unit. Placing a
ground spell somewhere you could not put it by hand is aim the player has
not earned, and it should not differ between a spell and a grenade.

`/cast [@player]` and `/cast [@cursor]` still place, since both name a
position you can already aim at. Every other unit still has the spell cast
on it when it takes a unit target, which is what `@unit` is for; only the
ground-placement leg is dropped, leaving the reticle up exactly as casting
by hand does.

`C_Spell.CastAtUnit` keeps the whole primitive and gains a third argument
for the choice, defaulting to placing so the API and its callers are
unchanged. The restriction lives in the slash handler, the same split the
item side uses, where `C_Item.UseAtUnit` stays unrestricted and only
`/use` limits itself to `@player`.

`Spell::AtCursor::GroundPlacementPending` reports whether the in-flight
placement is a ground one, so the handler can leave that reticle up while
still cancelling a placement for a unit the engine did not accept. Leaving
that armed would strand the cursor.

* fix(item): stop a bad unit raising out of C_Item.UseItemByName

The optional `unit` went straight to the engine's token resolver, which
raises for a string that names no token. Both the code comment and the
API docs promised the opposite, that an unrecognized string is no target,
matching the no-op contract the item argument already has.

It is reachable without an addon calling the function directly: secure
button attributes and key bindings both feed it a configured string, so a
`unit="foo"` attribute raised inside a click handler.

Probe with Unit::TokenResolve::IsUnitToken first and treat anything else
as no target, which makes the documented contract true.

C_Item.UseAtUnit keeps raising, which is deliberate and now stated in its
docs: it is the companion of C_Spell.CastAtUnit, which documents the same
behavior as matching UnitHealth("garbage").

* feat(macro): let an addon publish its own macro display

`C_Macro.SetMacroDisplay(macroSlot, value)` hands a macro's resolution to
the display path, for an addon that parses macros itself. It is aimed at
SuperCleveRoidMacros, which today replaces ten action globals plus
GameTooltip.SetAction to put its answers on the bar, and borrows an
unrelated action slot to get a range check for a macro.

None of that is needed. Writing the spell into the engine's own per-macro
cache is what makes cooldown, range, usable, current and auto-repeat
correct for a macro button, with nothing overridden, and the icon then
reaches two places a Lua override cannot: the cursor while a macro is
dragged, and the macro window grid, both of which come from an engine
getter we hook.

The value takes the forms a `#showtooltip` value takes, so a publisher
hands over what it would have written in the macro. Three states: a value
to show, false for claimed-but-nothing-matched, nil to release.

Ownership is per macro. A published one is not parsed or re-evaluated by
us and is honored even while yielding; the rest stay ours. Nothing is
re-evaluated for the publisher, but a published resolution is re-applied
whenever the engine's re-parse overwrites the cache, so a macro edit
cannot silently replace it.

The wholesale stand-down when SuperCleveRoidMacros is loaded is lifted by
`CleveRoids.ClassicAPIMacroDisplay`, which a build sets to say it drives
us. Forks without it keep the all-or-nothing yield, so an older one cannot
end up fighting for the same buttons.

* fix(macro): accept the !Name prefix in /cast and #showtooltip

`#showtooltip` over `/cast !Shoot` showed nothing and the line cast
nothing: the `!` prefix, which asks for a toggle ability to be started
but never turned off, was never part of vanilla and no name resolver
knew it.

Split it in two.

The NAME is normalized in the one co-hook on the engine's single
name -> spellbook-slot resolver, which already owned the numeric
spellID form (so `spell/CastByID.cpp` becomes `spell/NameResolve.cpp`).
`FUN_RESOLVE_SPELL_NAME_TO_BOOK_ID` calls that resolver, so a single
strip covers the runtime cast, `Spell::Arg`, the `#showtooltip`
resolution and the engine's own macro parser -- which is what lets
`IsAutoRepeatAction` light a `/cast !Shoot` button even while macro
display is yielded to SuperCleveRoidMacros.

The BEHAVIOR stays in Lua: `SecureCmdCast` strips the prefix and casts
through the existing `CastSpellNoToggle`, which asks the engine whether
the ability is already up first. `CastSpellByName` cannot carry it --
re-registering that global is off limits and nampower already co-hooks
it.

`CastSpellNoToggle` gains the `placeGroundSpell` argument that
`C_Spell.CastAtUnit` has, so `/cast [@target] !X` keeps the rule that
only your own feet and the cursor place a ground-target spell. It also
tolerates a `!` on its own input now, since its auto-repeat name
compare is ours rather than the engine's.

* feat(item): take an item location wherever an item action takes an item

`C_Item.UseAtCursor`, `UseAtUnit`, `UseItemByName` and `EquipItemByName`
took an item reference only - an itemID, `item:N`, a link, or a name - and
resolved it by searching the bags for the first match. Holding two stacks of
the same item, or two copies enchanted differently, there was no way to say
which one you meant.

They now also take an item location: `{bagID=B, slotIndex=S}`,
`{equipmentSlotIndex=N}`, or an item GUID string - the shapes
`C_Item.GetItemID` already took. A location names one exact item.

`Item::Location::FindItemArgOrLocation` owns the overload; the old `Resolve`
(locations, pointer) and the new `ResolveItemArgOrLocation` (either, pointer)
are wrappers over it, so the classification and the walk live in one place.
None of the four call sites resolves anything itself any more.

A table is only ever a location, and so is a string that parses as a strict
`0x` + 16 hex digit GUID, which no item link or item name can look like. The
form is decided from the argument alone and committed to: a location resolve
STOMPS the Lua stack (`PackBagSlot` overwrites the first slots with its own
arguments), so falling back to the reference forms after one had run would
re-read the argument as whatever it left behind - a bag index read as an
itemID, which names an unrelated item.

`EquipItemByName` needs the source coordinates too, since the swap packet
carries them, which is what the detail form is for. An
`{equipmentSlotIndex=N}` location names a worn item, so with a `dstSlot` it
routes to `Item::Swap::FromPaperdoll` - that is how a ring 11<->12, trinket
13<->14 or weapon 16<->17 move is expressed. Without a `dstSlot` there is no
slot for the engine to pick, so that combination does nothing.

Its `lua_settop(L, 0)` ahead of `ClearCursor` is gone: `Script_ClearCursor`
(`0x004895B0`) is `FUN_00495190(1, 1)` and a return - no arguments, no stack
reads. Dropping it lets the item resolve run after the cursor clear, which a
location needs: while an item is held its slot reads empty, so the location
it came from would otherwise resolve to nothing.

* refactor: centralize the item constants and the icon-path build

Four copies of the ItemStats displayInfoID -> `Interface\Icons\<name>`
chain had accumulated - `item/GetData`, `item/Info`, `container/ItemInfo`
and the new `item/Icon` - and they had already drifted: two checked for an
empty icon name and two did not. `Item::Icon::PathForDisplayInfoID` is now
the one builder and the other three call it, so a change to that chain has
a single site. `PathForItemID` is it plus the cache lookup.

`action/ItemState` had local constants shadowing values already in
`Offsets.h`: `kItemSpellSlots` for `ITEMSTATS_SPELL_SLOT_COUNT`, and a bare
`trigger == 0` for `ITEM_SPELLTRIGGER_ON_USE`. `INVTYPE_AMMO` / `THROWN`
existed in two files, and the engine runner's `0x400` macro line buffer in
two more, so those move to `Offsets.h` as well - `INVTYPE_*` next to the
string table they index, `MACRO_LINE_BUFFER_SIZE` next to the delimiters.

CLAUDE.md's rule, and duplicated offsets drifting is exactly what it was
written for.

* fix(macro): indented directives, release, and icon-getter re-entry

Three defects found reviewing the `#showtooltip` work, plus dead code.

A directive typed with a leading space was lost AND leaked to chat.
`ParseDirective` and the two `#`-line filters all tested byte 0, but the
tokenizer splits on `\r\n` only, so ` #showtooltip` arrives with its space:
the parse missed it, and then `Macro::RunBody` / `Macro::Execute` failed to
recognize the comment and dispatched the line through `EXECUTE_CHAT_LINE` -
the /say this feature exists to stop. All three skip blanks now, as does
`CastCommandArgs` so an indented `/cast` line is still collected, and the
runner dispatches the trimmed line so an indented command works at all.

`C_Macro.SetMacroDisplay(slot, nil)` left the publisher's spell in place.
`Release` cleared our entry and asked for a rescan, but `Rescan` only marks
an entry dirty when it has a directive with options - so for a macro with
no directive of its own, nothing ever rewrote `+0x564`, and the button, its
tooltip, cooldown and auto-repeat state stayed on the published spell until
the engine happened to re-parse. It now hands the field back to the engine
by running the engine's own parser, then repaints.

The macro icon getter could run Lua from under a click. `FUN_MACRO_ICON_PATH`
has two callers and one is `FUN_MACRO_PICKUP`, inside the UseAction core, so
`Lookup`'s catch-up could evaluate conditions through `SecureCmdOptionParse`
and fire `ACTIONBAR_SLOT_CHANGED` into addon handlers while the drag was on
the stack - the re-entrancy hazard CLAUDE.md records for `Tooltip::SetEvents`.
`LookupPassive` reads only what the entries already hold; the world tick
does the work and repaints, so a resolution can be one tick behind an edit.

Also drop `Active()` (no callers) and `RunBody`'s unused trampoline, and
correct the header's reference to a module that does not exist: the icon
comes from `Macro::IconPath`, not `Action::Texture`.

* fix(slash): five conditional-command defects, and cheaper option passes

`/cast 5019` could consume an item. A bare number that is not an
equipment slot is a spellID, but it reached the item lookup, which reads a
number as an itemID - so carrying item 5019 used it instead of casting the
spell. `Macro::ShowTooltip::ResolveValue` already guarded this exact case,
so the button showed the spell while the click used an item.

Every bare `/targetenemy` stepped backwards. The cycling selectors pass
the parsed value as their reverse flag, and a clause that matches with no
value yields the empty string - which is a true boolean in Lua. Six
commands were affected in their most common form.

`/cast [@none] Spell` did nothing. `@none` names no unit, but only
`@cursor` was exempted from the token resolve, so `none` went to the
by-name search and the cast was dropped. It now means no unit, which is
the untargeted form of the verb; `/target [@none]` clears the target, as
documented. The other unit verbs no longer feed `none` to that search
either - it matches on the START of a name, so it would find a Nonek.

`/assist [@target] Bob` ignored Bob. `/target` and `/follow` both let a
trailing name win over an explicit `target=target`; assist did not.

`/use [@player] 0 1` used the wrong item. The position branches ran before
the bag branch, so a named slot was thrown away and the item re-found by
link - the first match anywhere, which with two stacks is the wrong one.
Every form now resolves to one item and goes through one call, which also
means a slot is aimed by `@unit` like a name is, and that `/use` always
uses: clicking a bag slot sells the item at a merchant and repairs it
under the repair cursor (`Script_UseContainerItem` branches on the
merchant GUID and on cursor mode 0x11), which a typed command must not do.

`GroupPasses` also stopped paying for an answer it discards. A group with
conditions only needs a token, so it asks `IsUnitToken` rather than
`pcall(UnitExists)` - the conditions test existence themselves. The
existence path keeps its single pcall, and a name, which costs an
object-manager search, is remembered for the rest of the pass.
2026-09-10 18:43:06 -05:00
Brues fe9e7dddcd fix(color): keep ColorMixin on the standard color globals
An addon that carries a copy of Blizzard's own definition -- a plain
NORMAL_FONT_COLOR = {r=1.0, g=0.82, b=0} and its friends -- assigns it
over ours at file scope, so every addon that loads after it finds a color
with no methods. pfUI's who list then fails on NORMAL_FONT_COLOR:GetRGB()
(brues-code/pfUI#56).

Re-apply the color globals after each addon loads, not only at our own
load, and mix ColorMixin INTO the table that is there instead of replacing
it: the values that addon chose stay, and each reference it already handed
out gets the methods too. ITEM_QUALITY_COLORS entries are repaired on the
same pass. This replaces the SpecialTalentUI special case.

C_UIColor.GetColors() makes a color object for each of its 187 rows, so
the values are kept from the first call and each later pass costs one
lookup per tag. Only a color that is absent or has lost its methods is
touched; a tag holding something else, and a <TAG>_CODE string that is
already set, are left alone.
2026-09-10 16:15:16 -05:00
Brues 9d8f1b1b5d build: pin the project to Win32 with a preset and a configure guard
WoW.exe is x86, so there is no valid 64-bit configure. Nothing in the
repo said so, which let VS Code decide instead: CMake Tools configures on
folder open by default, and with no presets file it takes the
architecture from a scanned kit. Here it chose the x86_amd64 cross kit
(-T host=x86 -A x64) and flipped the shared build directory to x64.

That failure is badly disguised. A 64-bit build first shows up as a
static_assert deep in a struct-layout check, which reads as an unrelated
code fault rather than a build-configuration one.

Add CMakePresets.json with a single win32 configure preset. A presets file
makes CMake Tools use presets and stop using kits, so the architecture
becomes a property of the project instead of whichever kit a machine
happens to select. The preset names no generator, matching the documented
command line: CMake then resolves the newest installed Visual Studio,
which a pinned version would get wrong. Schema version 3 is the earliest
that allows omitting the generator.

Add a CMAKE_SIZEOF_VOID_P guard after project() for whatever gets past
the preset -- a hand-typed -A x64, or a stale cache -- so the configure
fails with the recovery steps instead of building something unloadable.

The existing `cmake -B build -A Win32` flow keeps working; presets are
opt-in on the command line.
2026-09-10 15:23:00 -05:00
Brues 9d1e14410c refactor: share one bounded string copy as Common::BoundedCopy
addons/LoadState.cpp used strncpy, which MSVC deprecates (C4996) and
which does not terminate on truncation, so the call site had to add the
terminator itself. frame/Attributes.cpp had already met that warning and
answered it with a local BoundedCopy helper.

Promote that helper to Common rather than copy it a second time, and
point both files at it. The helper treats a null source as empty, so
LoadState loses its null-name branch as well. Behavior is unchanged:
strncpy(dst, src, kMaxName - 1) plus a manual terminator wrote at most
kMaxName - 1 characters, and so does BoundedCopy(dst, src, kMaxName).

strncpy_s was the other option and is worse here. It invokes the
invalid-parameter handler on truncation unless the caller passes
_TRUNCATE, and LoadState runs inside an engine hook.
2026-09-10 13:36:00 -05:00
Brues 8b06caf708 docs(api): rewrite the EquipmentSet intro for the caller
The paragraph explained the storage choice rather than the API: which
opcode carries set data, that "this server" does not speak it, and that
the file format matches a sibling project. None of that changes what a
caller writes, and "this server" names nothing.

Say what the namespace does, in the section-opener style the rest of the
file uses, and keep the one storage fact a caller acts on -- sets belong
to one character.
2026-09-10 13:30:33 -05:00
Brues 4684088675 docs(api): give GetWeaponEnchantInfo real numbers for both enchant kinds
The example claimed charges = 5 for Brilliant Mana Oil and hid the
enchant ID behind a placeholder. Live dumps of both kinds correct it: the
oil returns charges = 0 with enchant 2629, and Instant Poison returns
charges = 40 with enchant 323. Show each case, so the zero reads as a
property of time-based enchants rather than as the only answer the field
ever gives.
2026-09-10 13:21:43 -05:00
Brues 9a26207df2 feat(item): read a bagged weapon's temporary enchant
C_Item.GetWeaponEnchantInfo covers only the three equipped weapon slots,
so a poisoned or oiled weapon sitting in a bag reported nothing. Add
C_Item.GetItemTempEnchantInfo(itemLocation), returning the same
(hasEnchant, expirationMs, charges, enchantID) tuple for any location the
rest of the C_Item location family accepts.

The temporary-enchant reader already needed nothing but a CGItem, so it
now takes one directly, with a thin wrapper for the paperdoll path. Its
local copy of the slot offset (0x4C) gives way to the block layout
Offsets.h already carried for spell/BonusDamage.cpp, extended with the
within-slot duration and charges deltas and a named temporary-slot index.

Verified in game: a staff carrying Brilliant Mana Oil reports enchant
2629 both equipped and in a bag, and the remaining duration keeps
counting down while the weapon sits there.

Fixes #47
2026-09-10 13:17:56 -05:00
Brues 2adfaff020 docs(api): move the enchant functions into the Item section
C_Item.GetWeaponEnchantInfo and C_Item.GetEnchantInfo were documented
inside the Spell section, sandwiched between C_Spell entries, and
neither appeared in the Contents at all.

Both now sit in Item after C_Item.GetStackCount, kept adjacent since the
second resolves the IDs the first returns, and listed in that section's
Contents block so body and Contents order agree. The headings are
unchanged, so existing links to them still resolve.
2026-09-10 13:00:14 -05:00
Brues 8a7ab490f4 docs(api): move CURSOR_CHANGED in with the other events
It was documented under Cursor while every other standalone event lives
in the Events section, placed there to follow that section's alphabetical
run. The heading text is unchanged, so existing links to
#cursor_changed-event still resolve, and Cursor keeps a pointer to it so
a reader of GetCursorInfo can still find it.
2026-09-10 12:51:58 -05:00
Brues e26f8958e9 fix(compat): let a ClassicAPI-aware addon opt out of its whole shim
pfUI's `handlesHookScript` and ShaguTweaks' `API.classicapi_version` now
return at the top of the compat callback instead of gating one branch
inside it. An addon that advertises ClassicAPI awareness handles its own
color globals, HookScript path and event registrations, so no part of the
shim should run against it. Previously the pfUI flag suppressed only the
CreateFrame shim while the RAID_CLASS_COLORS override and the
UpdateColors no-op still applied, and a ClassicAPI-aware ShaguTweaks
still had its libpredict events unregistered underneath it.

The "brues" X-Website tag now gates the pfUI HookScript shim on its own,
so an unflagged fork carrying that tag behaves as before.
2026-09-10 12:51:58 -05:00
Brues f0a83f1f31 fix(compat): restore Compost:Erase when a 5.1-stubbed copy wins
Compost-2.0 rev 17406 replaces most of its own body with stubs once it
believes it is on Lua 5.1, and our 5.1 syntax backport makes its
loadstring probe pass. Losing GetTable and Reclaim is harmless -- both
are pure recycling hints -- but Erase is not: callers hand it a live
table and expect its keys gone, and the stub takes no parameter at all.
So every call site that clears in place, rather than assigning the
returned table, silently stops clearing.

One addon shipping that revision hands it to every other Ace2 addon,
because AceLibrary arbitrates on the highest $Revision and ignores load
order. Puppeteer is the reported casualty: PTUnit:ClearAuras erases its
debuff-type and aura-ID sets in place, so a dispel type never leaves the
set once applied and a cursed raid member keeps its debuff colour after
the curse is cleaned.

Restore an in-place Erase, picked by behaviour rather than by revision or
addon name so a Compost that already clears is left alone. Checked on
every ADDON_LOADED because AceLibrary can upgrade a library at any point
during load and an upgrade installs the new copy's functions over ours;
the verdict is cached against the function that earned it, so a repeat
check costs two identity comparisons and reuses one probe table.
2026-09-10 11:37:49 -05:00
Brues 5070c892e2 feat(texture): add Texture:SetDesaturation and GetDesaturation
Backports the parametric desaturation methods. The engine desaturates a
region by storing one pixel-shader object in the region's shader slot and
binding it per draw, so an amount is a matter of having more shader
objects: one CGxShader per level (256 levels, framebuffer precision),
created lazily through the engine's own loader under a path that exists
nowhere and filled with hand-encoded ps_2_0 / ps_1_1 bytecode, then
compiled the way the loader does. No draw hooks, no per-region state.

SetDesaturated is re-registered on the Texture registry (the registrar
front-inserts and the dispatcher walks from the front) so all three
methods share one state and `false` clears a partial amount. Every
desaturation path, including SetDesaturated(true), now multiplies the
vertex-colour tint in like retail; the stock shader dropped it.

The first ps_2_0 program was rejected by the D3D9 runtime validator: an
lrp may not write a register it reads as src0 or src2. The shipped
program is the instruction shape fxc emits, and both programs were
validated on a NULLREF device. A build attempted while the device is
lost is not latched, and a level that cannot be built falls back to the
engine's stock shader.
2026-09-09 16:39:14 -05:00
Brues 8295ec9442 docs(api): clarify the multi-flavor TOC section
The intro claimed this client is the Vanilla game type, which read as a
contradiction against the paragraph below it saying `_Vanilla` files are
not used. Both are true: `_Vanilla` as a file suffix names the Classic
Era client, while `vanilla` as a directive value names this client's game
type. Drop the intro sentence, since the [Family] / [Game] table already
states it where a reader can use it, and add a note about the shared word
where the collision is first hit.

Explain why `_Vanilla` and `_Classic` are skipped instead of saying only
that they target an engine this build does not match, and state the
consequence: an addon shipping just `_Vanilla` and `_Mainline` TOCs does
not load at all, so it needs a `_ClassicAPI.toc`.

List both flavor sets in probe order, and give the Bindings half the same
shape as the TOC half.

The `## Interface:` subsection showed a multi-version line and then said
nothing about it. Say that the addon loads when 11200 appears anywhere in
the list.
2026-09-09 13:08:37 -05:00
Brues c5b1431073 feat(cvar): default scriptMemory to 0
Sets the cvar most people already set by hand, so a fresh install does
not meet the interface memory popup. Only the default is overridden: the
write happens when the live value still equals the cvar's registered
value (read from +0x30, so no hardcoded 49152), which leaves a
deliberate Config.wtf choice alone. The archive flag makes the 0 persist,
so the comparison fails on every later boot and the write happens once
per install.

What the cvar gates, since none of it is obvious: FUN_00495590 polls
Lua's live allocation (KB) against it, forces a full GC when over,
and if still over fires MEMORY_EXHAUSTED and starts a 15-second timer,
after which FUN_00401560(0) exits the process. FrameXML shows the event
as the "disable some interface add-ons and restart" popup. The poll is
gated on `limit > 0`, so 0 disables it, and nothing else caps Lua memory
(lmemPool's allocator is six size classes falling through to the Storm
heap, with no ceiling of its own).

Timing is load-bearing: scriptMemory is writable on the glue state and
read-only in the world, where the innermost setter's `if (flags & 4)
return;` makes any write a silent no-op. The glue registration is what
makes this land; the in-game one stays as a second chance. Verified by
deleting the Config.wtf line, launching, and finding 0 in the AddOns
dialog and back in Config.wtf on exit.

Adds CVar::Factory::GetDefaultString for the comparison.
2026-09-09 12:17:33 -05:00
Brues b39c16578b feat(addon): add the /startattack and /stopattack slash commands
Blizzard ships both in FrameXML's ChatFrame.lua; the handlers here are
3.3.5a's verbatim, routed to ClassicAPI's StartAttack / StopAttack.
Every locale file gets the client's own command names, taken from the
per-locale GlobalStrings dumps: /angriffstart, /empezarataque,
/lanceattaque, /начатьатаку, /開始攻擊 and their stop counterparts, next
to the English names. Gives users a clean /startattack without the
macro-addon trick of placing the Attack spell on an action slot to read
its state.

Refs #46
2026-09-09 00:49:26 -05:00
Brues 7d7e392924 fix(spellbook): flag talent ranks by name when the next-rank link is missing
SkillLineAbility's next-rank column is empty for whole chains — every
Mind Flay rank (15407, 17311..17314, 18807) holds 0 — and the engine's
own rank-chain walk (FUN_SPELL_RANK_CHAIN_KNOWN) reads that same field,
so IsClassTalentSpellBookItem flagged only Mind Flay rank 1. Fall back
to the engine's other notion of a rank set: a spellbook spell that
shares a talent rank spell's localized name, the same rule a name-cast
uses to pick the highest rank.
2026-09-09 00:14:23 -05:00
Brues 14fa610bc8 feat(spellbook): add the skill-line, cast-count, and loss-of-control surface
Nine C_SpellBook functions, two C_Spell functions, and one event, all
backed by engine data the stock Lua surface never exposed:

- LEARNED_SPELL_IN_SKILL_LINE(spellID, skillLineIndex, isGuildPerkSpell),
  fired from the learn-spell hook under the engine's own
  LEARNED_SPELL_IN_TAB gate (notified AND appended to the book). The
  learn/unlearn co-hooks move from spell/Info.cpp to spell/Learn.cpp.
- GetNumSpellBookSkillLines / GetSpellBookSkillLineInfo /
  GetSpellBookItemSkillLineIndex / GetSkillLineIndexByID, reading the
  engine's tab array ({skillLineID, numSpells} entries at 0x00B71160).
  The info table calls the engine's own Script_GetSpellTabInfo for the
  name and icon.
- C_Spell.GetSpellCastCount / GetSpellBookItemCastCount: the minimum
  over reagent slots of floor(carried / required), where carried is
  equipped + bags 0..4. GetActionCount / IsConsumableAction are
  item-only in this client, so the count is computed, not mirrored.
- C_Spell.GetSpellLossOfControlCooldown /
  GetSpellBookItemLossOfControlCooldownInfo / ...Duration: the
  C_LossOfControl effect that blocks a given spell, shaped as a
  cooldown. Silence and pacify gate on Spell.dbc PreventionType
  (+0x294, verified against six known spells).
- IsClassTalentSpellBookItem: Talent.dbc rank spells plus trained
  higher ranks via SkillLineAbility's next-rank link (+0x20, verified
  on Mortal Strike). Talent::SpellSet now owns the talent spell set
  that GetCurrentLevelSpells already used.
- ContainsAnyDisenchantSpell: any known spell with effect 99.

Shared helpers added along the way: Spell::Lookup::SpellBankArgToBookType
and SpellbookItemArgsToID, Spell::Cooldown::Query, and
LossOfControl::LockoutForSpell.
2026-09-09 00:10:52 -05:00
Brues 8ca415e9a0 feat(spellbook): add C_SpellBook.GetPlayerSpellsByAura
Every spell the player currently knows with an effect applying the given
aura, as a 1-based ascending array of spell IDs. This is IsPlayerSpell
turned around: it walks the same known-spell bitmap and reports the set
bits whose Spell.dbc record carries the aura, so only a set bit ever
touches a record -- a few hundred lookups, not the ~28,000-row table.

It replaces a C_Spell.GetSpellsByAura that scanned the whole table and
was never committed. That answered "which spells anywhere apply this
aura", when the caller summing aura amounts is asking "which of MY
spells do" -- and the bitmap is that set. It also holds only a talent's
current rank, so a sum over the result never double-counts ranks.

Known is not active. A passive is in effect while known and never shows
in the buff list; a castable buff in the result is merely learned; a
buff another player put on us is not in the bitmap at all. Callers split
on C_Spell.IsSpellPassive and check the buff list for the rest. auraName
0 (no aura) and negatives return the empty array, as does pre-login.

Lives beside PlayerKnowsSpell in spell/Info.cpp rather than in a new
file, so the bitmap read is not copied an eighth time.

Verified in-game. Rogue: aura 217 -> {52508}, rank 2 only. Priest: aura
134 -> {14777, 45553} -- Meditation rank 3 only, plus Improved
Shadowform, a source no one had listed and the data surfaced on its
own. Each class returns empty for the other's aura.

Motivated by pfUI's energytick module: its energy-tick interval needed
the SPELL_AURA_MOD_ENERGY_REGEN_TIME amount, which it had as constants
derived from two hardcoded spell IDs. With this and GetSpellEffectInfo it
names no spell at all.
2026-09-08 23:04:26 -05:00
Brues becf2f4866 feat(spell): add C_Spell.GetSpellEffectInfo
Returns one table per spell effect holding that effect's raw Spell.dbc
fields: effect, auraName, basePoints, baseDice, dieSides, pointsPerLevel,
dicePerLevel, miscValue. Three entries always, an unused slot all zeros.

Deliberately raw rather than one computed magnitude per effect. The
engine's figure also folds in a die roll the client cannot reproduce,
combo points against the combo target, the caster's spell mods, and a
level rescale for flagged spells -- a number carrying none of those while
looking authoritative is worse than the inputs. The docs carry the
formula and say that dieSides > 1 means the magnitude is rolled.

Three new offsets. DieSides was already established by the
EFFECT_BASE_DICE note, which records the verified column run as
Effect@61, DieSides@64, BaseDice@67, BasePoints@76 -- it just had no
named constant. The two per-level fields extend the same run at columns
70 and 73, and both are FLOAT, not int: field 73 on spells
10/17/43/91/122 decodes as 0.1/0.8/1.0/-1.0/0.5 as float and as garbage
as int32.

Verified in-game against answers known independently of the code:
Power Word: Fortitude rank 1 reports auraName 29, basePoints 2,
baseDice 1 and miscValue 2 (STAT_STAMINA), i.e. the +3 Stamina on its
tooltip; Blizzard rank 1 reports effect 27 / auraName 3 with
pointsPerLevel 0.1 (float32 widened), confirming the float read from
Lua; Blade Rush rank 2 reports auraName 217 with basePoints 11 and
baseDice 1, the 12 the server sums for SPELL_AURA_MOD_ENERGY_REGEN_TIME.

Motivated by that last one: computing a rogue's energy tick interval
needs the aura's amount, and the only alternative from Lua was
hardcoding it or parsing a localized spell description.
2026-09-08 22:02:22 -05:00
Brues 28658c2995 docs(api): move Enum.UICursorType in with the other enums
API.md already groups every Enum.* table in one run under Globals, with
its own block of TOC entries. UICursorType was written into the Cursor
section instead, which split that convention and gave one enum a
top-level spot none of the other seven has.

Moved next to Enum.SpellBookItemType, reformatted to the value-first
table shape the neighbouring sections use, and left the CURSOR_CHANGED
payload pointing at it by link. Also corrects "macro index" to "macro
slot" in that payload list, matching what the argument now carries.
2026-09-08 15:48:44 -05:00
Brues f5cce25d23 feat(cursor): add the CURSOR_CHANGED event
Fires CURSOR_CHANGED(isDefault, newCursorType, oldCursorType,
oldCursorVirtualID) and registers the Enum.UICursorType table its two
type arguments index. Classic Era's Vanilla PaperDollFrame uses this
event for the equip-slot highlight, so ported UI code looks for it.

The trigger is a once-per-frame diff of the cursor globals on
Tick::WorldTick, NOT the engine's CURSOR_UPDATE. That event only has
three fire sites and none announces a non-item pickup: the cursor clear
FUN_00495190 fires for every type but zeroes VAR_CURSOR_TYPE first, so
the read there always says "empty", while FUN_00494b60 and FUN_004950f0
fire after a set only when a GUID or itemID is present. An interceptor
on CURSOR_UPDATE therefore reported item cursors correctly and nothing
else -- money, spells, macros and the two pet cursors were silent. There
is no common choke point on the set side (each type writes
VAR_CURSOR_TYPE in its own path), so a state diff is what covers them
all. CURSOR_UPDATE is left completely untouched.

Info::ReadRaw is the cheap half of the diff -- seven plain global reads,
no object-manager lookup -- so the bag-item itemID resolve only happens
on a frame where something actually changed. The cost of the diff is
that the event lands one frame after the change.

Also fixes a bug this uncovered in the shipped GetCursorInfo:
Script_PickupMacro converts the Lua 1-based index into a macroID via
VAR_MACRO_SLOT_MAP before storing it, so the cursor global holds e.g.
0x01000003, and PushMacroCursor pushed that straight through as the
"macro index". docs/API.md already documented the return as macroIndex,
so the contract was right and the code was wrong. Both paths now convert
through the engine's own reverse scan FUN_MACRO_ID_TO_SLOT, the same
conversion the legacy CreateMacro wrapper does. VAR_CURSOR_MACRO_INDEX
is renamed VAR_CURSOR_MACRO_ID, since the old name invited the mistake.

Verified in-game: item, spell (vanilla and Turtle-range IDs) and macro
cursors all fire with correct old/new types, the repeated CURSOR_UPDATEs
from moving a stack produce no CURSOR_CHANGED, and GetCursorInfo returns
slot 4 for a General macro and 21 for a Character one.

oldCursorVirtualID has no faithful equivalent here -- the modern
client's "virtual ID" is not a concept this client has -- so it carries
the previous content's identifying number and says so in the docs. No
consumer is known to read it; Blizzard's own users treat the event as a
bare notification.
2026-09-08 15:45:37 -05:00
Brues 1306eaf1b8 feat(container): add four C_Container getters
GetContainerFreeSlots(bagID) returns which slots are empty as an array
of slot numbers, beside the existing count. A full bag gives an empty
table and a bag that is not there gives nothing, matching the
MayReturnNothing that Blizzard's own EquipmentManager branches on.

GetContainerItemQuestInfo(bag, slot) returns the {isQuestItem, questID,
isActive} table that draws the two quest markers on a bag button.
questID is the item's m_startQuest, isActive asks Quest::Log, and
isQuestItem is item class 12; questID stays absent rather than 0 because
every caller tests it for truth. An uncached item reports the empty
answer and warms the cache, so the next bag update draws what the first
hover could not. Verified in-game: item 18423 reports questID 7495
"Victory for the Alliance", and a quest-class item that starts nothing
reports isQuestItem with no questID.

GetContainerItemEquipmentSetInfo(bag, slot) returns whether the item
belongs to an equipment set, plus the set names joined with ", ".
Blizzard's own version is documented as non-functional, so there is no
working output to match; the lookup goes through the new
EquipmentSet::Data::SetsContainingItem, which matches on the saved
per-instance GUID. That identifies the one physical item saved into the
set rather than any copy of its itemID, and survives the item being
moved between bags. The reserved GUID_EMPTY / GUID_IGNORED slot markers
are rejected there so they cannot match a caller.

Get/SetBackpackAutosortDisabled and Get/SetBankAutosortDisabled keep the
backpack out of SortBags and the main bank container out of
SortBankBags. Both persist as cvars, like sortBagsRightToLeft. This
mirrors the per-bag BagSlotFlags.DisableAutoSort in Blizzard's generated
docs; the backpack and main bank have no slot to carry that flag, which
is why each gets its own pair.

Dropped as unimplementable rather than shipped inert:
Get/SetInsertItemsLeftToRight (the server picks the destination slot,
stack merges included, so there is no client-side insertion point to
steer) and Get/SetBackpackSellJunkDisabled (nothing sells junk here).
SortBank would be a pure alias, since kBankBags already covers the main
bank container.
2026-09-08 14:08:06 -05:00
Brues 807afc083f refactor(quest): share the quest-log lookup by questID
Four functions in this file walked VAR_QUEST_LOG_ENTRIES looking for a
questID, each re-deriving the stride and the header-row gate. Name that
walk once as Quest::Log::IndexForQuestID and let IsOnQuest,
GetLogIndexForQuestID and GetHeaderIndexForQuest read it, so a fifth
consumer does not become a fifth copy.

GetQuestIDForLogIndex indexes rather than searches, so it keeps its own
read, but its raw 16 / +8 / +0 literals become the named
OFF_QUEST_LOG_ENTRY_* offsets.

No behaviour change.
2026-09-08 14:07:41 -05:00
Brues c26afcc050 feat(baselib): forward trailing arguments through xpcall
Ports Lua 5.2's `xpcall(f, msgh, ...)` shape. This client's xpcall takes only
(f, msgh), and its implementation is explicit about the rest: `lua_settop(L, 2)`
discards any further arguments, then `lua_pcall(L, 0, ...)` calls f with none.
So the argument-forwarding form did not fail loudly -- it called f with nothing,
and a function that read its parameters saw nil. (Not a 5.0-vs-5.1 difference:
both take (f, msgh). Forwarding arrived in 5.2.)

Replacing the base-library function rather than wrapping it in Lua, for the
same two reasons `select` and `unpack` are C in this directory. A Lua wrapper
would allocate a closure AND an `arg` table per call, putting an allocation on
every error-guarded path including per-frame ones; and HookSecureFunc's
kUnhookableNames lists xpcall precisely because shadowing a core primitive with
a Lua closure diverges from what C-side callers see.

lua_pcall wants the callee and its arguments contiguous on top with the handler
at a fixed index, but the incoming order puts the handler between them, so:

  PushValue(2) -> f, msgh, a1..aN, msgh
  Insert(1)    -> msgh, f, msgh, a1..aN
  Remove(3)    -> msgh, f, a1..aN

then PCall(top - 2, MULTRET, errfunc = 1), push the status boolean, Insert(2),
and return GetTop - 1 so the handler drops out of the result set. 5.2 does the
same reshuffle with one lua_rotate, which this Lua has no equivalent of.

Purely additive: a two-argument call forwards zero arguments and is the old
behavior exactly, so the capturing-closure idiom in ExecuteFrameScript and the
plain two-argument call in ItemUtil both keep working untouched.

Verified with 25 assertions across both halves. Backward: exact return counts,
embedded and trailing nil returns keeping their slots, reference identity, the
handler running once and receiving the message, a non-function handler tolerated
on the success path, nil f caught, an erroring handler still yielding false, and
the load-bearing one -- a two-argument call still passing f zero arguments.
Forward: false and embedded/trailing nil arguments surviving, twelve arguments
intact, forwarding composing with multiple returns, arguments still arriving on
the error path, and the two cases that target the shuffle -- the handler not
leaking in as argument 1, and the handler also being passable as an argument.

Two incidental confirmations: C-side lua_error does longjmp (the missing-handler
case is caught by an enclosing pcall), which the notes had flagged as uncertain;
and Lua-level error() still does not unwind, so f runs past it and xpcall
reports success with the handler never consulted.

Closes #45
2026-09-08 13:23:04 -05:00
Brues a124629669 fix(item): warm the cache when an icon lookup misses
The three icon accessors (GetItemIcon, C_Item.GetItemIcon,
C_Item.GetItemIconByID) all funnel through PushIconForItemID, which peeked the
cache and returned nil on a miss without queueing the item. Every sibling
getter -- name, quality, level, sell price, max stack size, inventory type,
family, spell, openable, uniqueness, set, average level -- calls WarmCache
there, so the icon was the only field that could not self-heal: a caller
polling after a miss re-resolved every other field while the icon stayed nil
for the rest of the session, because nothing else ever requested that itemID.

Most visible right after login, where the carried-item prefetch can walk a
bag-slot GUID array that SMSG_UPDATE_OBJECT is still filling and legitimately
not see an item yet -- reported downstream as icons that resolve when the
client loads fast and never when it doesn't. But that window only decided
WHICH items were affected; the permanence was this missing warm, which equally
broke every uncached icon the prefetch never touches -- quest rewards, vendor
and auction listings, links out of chat.

Verified in-game: IsItemDataCachedByID(19019) reports false, and a re-poll two
seconds later returns Interface\Icons\INV_Sword_39. Both calls returned nil
indefinitely before.
2026-09-08 09:28:20 -05:00
Brues be03d45f0c feat(frame): add frame:RegisterUnitEvent, unit-aware IsEventRegistered
Modern ports call frame:RegisterUnitEvent("UNIT_HEALTH", "player") to receive
an event for one unit instead of for every unit. Vanilla has no such method, so
the call errored, and FrameUtil.RegisterFrameForUnitEvents was left out of the
FrameXML backport for the same reason.

The engine already owns registration and dispatch, so this is only a filter.
Chain membership delegates to the engine's own Script_RegisterEvent; the filter
is stored per frame here; the check is one gate on the per-frame OnEvent fire,
where the dispatcher has already set _G.event and calls the arg'd script runner
with the OnEvent slot. Suppressing there also covers HookScript chains and
Frame::ScriptArgs' positional path, both downstream, as retail does.

Semantics follow Blizzard's 5.4.8 implementation, where the filter lives on the
subscriber node and the node's kind is fixed at creation: RegisterEvent over a
filtered registration keeps the filter, RegisterUnitEvent over a plain one stays
plain, RegisterUnitEvent over a filtered one replaces the units, and no usable
unit is a plain RegisterEvent. The filter dies with the node, through co-hooks
on Frame::UnregisterEvent and Frame::UnregisterAllEvents -- the latter also
being the CScriptObject destructor's path, so frame destruction is covered.

Two divergences from 5.4.8, both additive. Unit tokens are kept as strings and
compared case-insensitively, since focus/nameplateN/markN are not in the
engine's canonical token table. And the unit count is not capped: 5.4.8 reads
exactly two units and folds each token index into a byte of one dword, and
retail later filled that dword and documents four, but we have no dword to fill,
so every string argument past the event name is a unit.

The filter applies only when the event's first argument is a string. A
number-first event, or a no-arg event (dispatched through a runner this never
sees), fires as with RegisterEvent -- one rule that holds on every dispatch
path, and one that fails toward an extra event rather than a lost one.

FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT was already hooked by Frame::ClickEvents for
PreClick/PostClick, and MinHook allows only one hook per address -- a second
aborts the entire install. Frame::RunnerHook now owns that hook and fans out to
subscribers claiming disjoint slot addresses; ClickEvents moves onto it with no
behavior change.

Verified in-game: 28 assertions over the branch table above, the
PreClick/OnClick/PostClick bracket still firing in order through the shared
runner, and a capture window in which a filtered frame received only its own
token (20 of 20) while a plain control frame on the same events took 115 fires
across six token spellings.
2026-09-08 00:10:22 -05:00
Brues 66c878d03e fix(nameplate): resolve the plate during NAME_PLATE_UNIT_REMOVED
C_NamePlate.GetNamePlateForUnit and GetNamePlateForGUID both returned nil
inside a NAME_PLATE_UNIT_REMOVED handler. They share PushNamePlateForGUID,
which derived the frame purely from live engine state -- Object::ByGuid(guid)
then unit + 0xE60 -- and the event fires precisely because that binding is
gone, with the unit often already out of the object table.

Retail resolves it there (NamePlateDriverMixin:OnNamePlateRemoved calls
GetNamePlateForUnit), so publish the outgoing (guid, frame) pair for the span
of the dispatch and consult it as the getters' last fallback. Live state still
wins whenever it can answer, so nothing else changes.

Frames are pooled, so the engine can pull one off its freelist and rebind it to
another unit between the tick that last saw it and the tick that notices it is
gone. PlateReassigned checks the current tick's walk for the pointer and
suppresses the fallback in that case, rather than trusting the plate's cached
GUID at +0x4E8, which goes stale across recycling.

Fixes #44
2026-09-07 21:54:21 -05:00
Brues 3ad87eb2f3 feat(container): add C_Container.Get/SetSortBagsRightToLeft
Sets which end of the bags a sort fills from, matching the modern pair.
Backed by a `sortBagsRightToLeft` cvar through CVar::Factory, so it
persists to Config.wtf and GetCVar reads it under the same name retail
uses, rather than resetting every session.

The whole implementation is reversed cell lists. Both placement passes
still fill from the "front" and the "far end"; those ends have simply
swapped which physical slot they mean, so the sorted run and the junk
block flip together and junk stays opposite everything else.

Not adding libbagsort's other option, which flips the category ranking
so junk sorts first. It has no Blizzard counterpart, and it is mostly
redundant with this one: what it is reaching for is which end of the
bags the important items live at, and the fill direction already answers
that by moving the sorted run and the junk block at once.
2026-09-07 20:42:00 -05:00
Brues a9d2ad73f3 feat(container): add C_Container.SortBags and SortBankBags
Two phases, because every item move is a server round trip and the second
phase reads slot contents the first changed. Phase one packs partial
stacks; phase two runs on the next Bag::UpdateDelayed, plans the whole
layout, and fires the swaps as one batch, tracking each item's live
position rather than re-reading a layout it is mutating.

The ordering is ours, since retail computes it in code we cannot read and
its categories have no counterpart here: hearthstone, gear by descending
quality, consumables, reagents, trade goods, quest items, everything else
by quality, junk last and filled from the far end. Then class, subclass,
name, fuller stacks first - a real tuple comparator, so there is no
zero-padding to overflow and no placeholder name to wedge an unnamed item
among real ones. An item whose data has not arrived is pinned in place
with its slot withheld, rather than ranked by guesswork.

Neither phase may touch the Lua stack, since phase two runs from a bag
update callback. Hence Lua-free ContainersFrom / AutoStoreFrom /
MoveCountFrom, with the Lua-taking forms reduced to resolving the source
pointer and delegating, plus a direct invMgr walk instead of ResolveBag.

Five things had to be found the hard way, every one of them the client's
local state disagreeing with the server's:

- Never plan a swap between two stacks of the same item. Player::SwapItem
  tries a MERGE before it considers swapping and returns if that
  succeeds, collapsing two slots into one or redistributing counts and
  leaving both in place. Neither is a swap, so a single collision
  invalidated the bookkeeping for every move after it in the batch. One
  collision in a 55-item bag turned one pass into three.

- Entry marked "unplaced" with destBag = -1, safe while bag IDs were 0..4
  and wrong the moment the main bank joined as bagID -1: every item bound
  for the main bank read as unplaced and had its move dropped, so nothing
  could move into the main bank at all. The sentinel is destSlot now,
  which cannot collide because slots are 1-based.

- FUN_INVENTORY_SWAP's last argument gates a pre-send confirmation check.
  With 0, a Bind-on-Equip item the character could wear is stashed behind
  the bind dialog and NO PACKET IS SENT - silently, since the builder
  returns void. Now passes 1, which is what the engine itself does when
  re-issuing a swap after the dialog is accepted, and is correct here
  because both endpoints are always bag content slots where nothing binds.

- CMSG_AUTOSTORE_BAG_ITEM cannot merge across bags. Its destination byte
  can only be INVENTORY_SLOT_BAG_0 (255) for the player, and server-side
  that is the specific-container branch: keyring and backpack only. The
  search-every-bag path is NULL_BAG (0), which the builder cannot emit.
  Both sets now use one explicit two-pointer merge that addresses both
  endpoints and works everywhere. It costs a packet per transfer rather
  than per emptied source, which is the price of being correct.

- The merge loop must choose split-versus-swap from its own model, not
  the item's live stack count. The batch goes out in one frame with no
  replies, so descriptors still hold pre-batch counts; a move the model
  knew was the last of a stack went out as a split, and the server
  rejects a split that would empty its source.

Consolidation packs fully rather than stopping once a slot is freed: 9
and 2 with a cap of 10 become 10 and 1. It settles in one pass and sends
nothing on the next call, because a packed group has every stack full
except the last, so the first step walks straight to the end.

Bank specifics: ViewForBag gains the main bank (bagID -1, inside the
player's own invMgr) and bank bags (5..10, each with its own), which can
themselves be specialty bags. Bank bags resolve only once the bank window
has been opened, which is the right gate rather than an accident - the
server refuses bank moves without it, so a bank sort with the bank closed
finds no cells and does nothing instead of dispatching a batch that would
be rejected wholesale.

_classicapi_SortBagsStats() and _classicapi_SortBagsBags() report the last
pass and the per-bag view. Both earned their keep: the stats ruled out our
side of the swap divergence by showing a complete plan fully dispatched
with zero local failures, and the per-bag dump ended the sentinel hunt by
eliminating the entire area three successive guesses had been aimed at.
2026-09-07 17:47:05 -05:00
Brues db88fca050 feat(container): add C_Container.AutoStoreItem
Moves an item and lets the server pick the destination slot, which is
worth having because the server does not merely find an empty one: it
first merges the stack into existing stacks of the same item, splitting
it across several when that is what fits, and places a remainder only if
one is left. So one call consolidates a partial stack.

Two engine builders back it, because the wire has two autostore opcodes.
FUN_005E12E0 sends CMSG_AUTOSTORE_BAG_ITEM (0x10B) and takes a
destination container, so it can confine an item to one equipped bag or
let the server search the whole inventory. FUN_005E18F0 sends
CMSG_AUTOSTORE_BANK_ITEM (0x282) and takes no destination at all - the
server derives the direction from the source, so a bank source travels
to the inventory and an inventory source to the bank. AutoStore picks
between them and accepts only the pairs the wire can honestly serve.
Bank-to-bank is not among them, so consolidating stacks within the bank
still needs per-pair moves.

Both builders' stack-arg counts come from their RET, not from a
decompiler parameter list. 0x10B's last slot is never read by the body
but is still popped, and declaring seven args instead of eight made the
callee pop four bytes we never pushed - which shifted the caller's frame
and surfaced as a garbage Lua state pointer in the next function to run,
several frames from the call. Noted at both offsets, since this family
is not uniform in arity.

Three fixes to the shared bagID encoder found while doing it:

- EncodeBagSlot had no case for the keyring (bagID -2), so SwapItems,
  MoveItem and AutoStoreItem all rejected keyring slots. The engine's
  own map in FUN_PACK_BAG_SLOT has had it all along, and the linear base
  was already recorded in the invMgr slot map.
- Its bank constants restated named Offsets::INVMGR_* values, so they
  now derive from them, and the slot map is fully named rather than half
  constants and half comment. Each player-container range is bounded by
  both its own last linear slot and the live invMgr count - stricter
  than before, where the backpack had no upper bound at all.
- FUN_PACK_BAG_SLOT's fourth out-param was labelled outUnused. It is the
  bank flag, and it is what makes right-clicking a bank item send 0x282
  instead of using the item.
2026-09-07 01:38:34 -05:00
Brues 26af9bd30f docs(nameplate): correct two stale claims in the Info.cpp header
The header still described the removed fresh-wrapper-per-call path: that
we deliberately skip FUN_00701BD0 to avoid its refcount bump, and that a
default nameplate's wrapper is rebuilt per call so identity is unstable.
PushNamePlateFrame delegates to UI::FrameObject::Push, which registers
lazily when the refkey is unset, so addon-created and engine-created
plates alike resolve through rawgeti(REGISTRY, frame + 0x08) and identity
is stable across calls.

It also claimed there is no central list of live nameplates. There is:
FUN_00608870 walks the header at {0x00C4D928, 0x00C4D92C}, unlinking the
plate and reinserting it in screen-depth order, and FUN_006086E0 calls it
on every position update. FUN_00608A10 unlinks from that list and pushes
the node onto a free list at {0x00C4D91C, 0x00C4D920}, which FUN_006087F0
pops without reinitializing.

Since that reverses the comment's premise, record why the object hash
table is still the enumeration source: the list is unlinked and
reinserted throughout the render while no nameplate code touches the hash
table, a recycled node keeps a stale GUID at +0x4E8 until FUN_007CB6D0
rebinds it so unit + 0xE60 is the only authoritative binding, and the
hash walk yields the unit, nameplate and instance block together.
2026-09-06 23:52:27 -05:00
Brues e7156caa47 feat(console): add the rest of the console API
Eight functions alongside ConsoleGetAllCommands, completing the surface:
ConsoleExec, ConsoleEcho, ConsolePrintAllMatchingCommands, ConsoleIsActive,
ConsoleGetColorFromType, ConsoleGetFontHeight, SetConsoleKey and
CalculateStringEditDistance.

ConsoleExec is the one with real reach. Nothing in this client's Lua could
run a console command before, so every entry ConsoleGetAllCommands returns
was visible and unreachable at once. It is the engine's own executor --
FUN_0063CE00, which resolves the name and calls the node's +0x18 handler,
the same field the enumeration reports -- and it is what replays each
`SET` line of Config.wtf at boot.

That includes `set`, and `set` is not SetCVar: the console handler uses
the unfiltered lookup and performs no read-only test, while Script_SetCVar
checks flag bit 0x4 first. So ConsoleExec can write a cvar SetCVar refuses
and reach cvars no Lua getter can see. Inherent to exposing the console
rather than introduced here, and documented as such.

The remaining state came out of the console module: FUN_0063C860 toggles
VAR_CONSOLE_VISIBLE when the key matches VAR_CONSOLE_TOGGLE_KEY and the
`-console` switch flag VAR_CONSOLE_ENABLED is set, which gives an honest
ConsoleIsActive; FUN_00638A60 seeds nine ARGB colours whose index is the
second argument to the writer, stored per line at +0x1C; FUN_0063CB00 is a
one-line setter for the toggle key.

There is deliberately no ConsoleSetFontHeight, and the crash that settled
it is recorded at FUN_CONSOLE_FONT_INIT in Offsets.h. Writing the height
global alone does nothing, and the only thing that rebuilds the font has
no code callers at all -- one table entry at 0x0080E170 -- so it runs once
at startup before any console line exists. Its null-check-and-free of the
previous handle reads like re-entrancy and is not: every line holds a
cached font-dependent draw object at +0x20, so re-running it faults on the
NEXT frame inside the texture manager, not in the call. The console's own
shutdown frees the font and those objects together, which is what the
pairing is for. A safe setter would mean replaying that teardown across
every line to change a text size nothing asks to change.

SetConsoleKey takes the client's numeric key code rather than a key name,
because the console stores and compares exactly that and nothing in it
translates names.

Verified in-game with -console: edit distance 3 for kitten/sitting and 2
for help/hepl, red and yellow at types 3 and 4 with nil out of range,
ConsoleIsActive false while closed, and exec output, a yellow echo and a
prefix match for both Export commands all present in the console.
2026-09-06 17:19:49 -05:00
Brues 02ad0ea6ab feat(cvar): add C_CVar.GetCVarBitfield / SetCVarBitfield
Treat a cvar's numeric value as a bitfield and read or write one bit, so a
single cvar carries many independent booleans. Upstream this backs the
record of which tutorial popups a player has dismissed: one cvar, one bit
per popup, persisted by the config machinery for free.

Namespaced only. Blizzard's FrameXML calls the bare globals, but those are
its own wrappers rather than the API, and nothing here needs them.

Both wiki pages are stubs, so the details come from Blizzard's generated
documentation: `index` is typed `luaIndex`, which is what settles that it
is 1-based; the getter's return is nilable; and the setter names its
requirements as a valid and public cvar, a non-read-only one, a
non-secure one, and an index in range. The first two are questions this
client can genuinely answer -- the lookup is the same filtering one behind
C_CVar.DoesCVarExist, so a cvar preserved out of Config.wtf without being
implemented is refused here exactly as by every other Lua cvar function --
and nothing here is ever secure.

The value is parsed and rewritten as a uint64 in C++, never handed through
a Lua number, so all 64 bits are exact and the 1..64 index range is the
arithmetic type's own width rather than a limit chosen here. Writing goes
through CVar::Factory::SetString rather than the engine setter directly:
that wrapper already declares FUN_SET_CVAR_VALUE as the __thiscall it is,
and getting that wrong corrupts the stack silently. It also means a
bitfield write fires the change callback and marks the config dirty,
exactly as SetCVar does.

Verified in-game against a registered cvar: bits 1 and 5 give 17,
clearing bit 1 gives 16 with bit 5 untouched, and bit 64 gives
9223372036854775824 and reads back true -- a value past Lua 5.0's exact
integer range, which is what proves the arithmetic is not round-tripping
through a double. Out-of-range indices read nil, and setting refuses for
an out-of-range index, an unknown cvar, and a read-only one.
2026-09-06 16:55:58 -05:00
Brues 1dee8e5683 feat(cvar): add C_CVar.AreCVarsLoaded
Reports the cvar registry's own initialised state rather than a constant,
so the function states a fact instead of an assumption. VAR_CVAR_HASH_MASK
holds 0xFFFFFFFF until the first registration builds the table, and every
lookup already gates on that sentinel, which makes it the closest thing
this client has to "the cvar system is up".

It can only return true from Lua, and that is worth being plain about.
The config system runs off the boot path -- FUN_0063D380 is reached
straight from the entry point, registers the console commands and replays
Config.wtf -- long before a Lua state exists, so there is no window in
which anything could observe false. The question is real upstream, where
an account's cvars can be stored server-side and arrive after login;
nothing here is fetched over the network, so once boot has run there is
nothing left to wait for.

Kept anyway because ported code that gates on it would otherwise call a
nil, which is the whole reason the function is worth having.

Verified in-game: returns true, as a boolean.
2026-09-06 16:36:58 -05:00
Brues 583f4bb011 feat(cvar): add C_CVar.DoesCVarExist
A ClassicAPI addition -- no such function exists upstream -- named for the
DoesAddOnExist / DoesSpellExist pair already here.

It uses FUN_FIND_CVAR, the same lookup GetCVar and SetCVar go through,
because the question a caller actually has is whether those will work.
That makes the invariant exact:

    C_CVar.DoesCVarExist(name) == (C_CVar.GetCVarInfo(name) ~= nil)

The alternative lookup FUN_0063DE30 skips the bit-31 test and would also
find a cvar preserved out of Config.wtf without the client implementing a
setting by that name. Reporting one of those as existing would mislead
every caller who then tried to read it, since no Lua getter can, so the
filtering lookup is the honest one here.

Returns false rather than raising for a non-string, matching the two
Does*Exist functions it is named after: a predicate should answer.

Verified in-game: true for a real cvar, false for an unimplemented
Config.wtf name, for a console command, for an unknown name, and for nil
and a number. A sweep over every entry ConsoleGetAllCommands returns finds
zero disagreements with GetCVarInfo.
2026-09-06 16:32:38 -05:00
Brues 23ebfbe7ab docs(cvar): record why some cvars are invisible to Lua
Chasing four cvars that ConsoleGetAllCommands reported but GetCVar raised
on turned up the mechanism behind both.

FUN_FIND_CVAR is not a plain by-name lookup. After matching it tests
`if (-1 < (int)flags) return 0` -- the flags read as SIGNED, so it returns
only cvars with bit 31 set. GetCVar, SetCVar and C_CVar.* all go through
it, so a cvar without that bit cannot be reached from Lua at all.
FUN_0063DE30 is the same hash walk without the test.

Bit 31 is set when the registrar's registerConsole argument is 0, and the
config loader FUN_0063D820 runs each `SET` line in Config.wtf as a console
command, whose handler FUN_0063D500 registers an unknown name as a fresh
cvar with registerConsole = 1 and flags 0 -- into which the registrar ORs
the archive bit, so the config writer saves it straight back out.

So any line left in Config.wtf becomes a cvar on the next boot whether or
not the client implements a setting by that name: preserved across
sessions, writable from the console, invisible to Lua. realmlist.wtf runs
through the same path, so a line there materialises in Config.wtf too.

Nothing to fix. C_CVar.GetCVarInfo already uses the filtering lookup, so
it returns nil exactly where GetCVar and SetCVar do and the Lua cvar
surface agrees with itself, while ConsoleGetAllCommands lists these
because the console genuinely can reach them. Both functions were already
right; only the reason was unrecorded.
2026-09-06 16:29:03 -05:00
Brues 18927551d5 feat(cvar): add C_CVar.GetCVarInfo
Returns a cvar's value, its default, and what may be done to it. Two of
the seven returns are real here and five are structurally false, which is
worth stating precisely rather than leaving as unimplemented:

  isReadOnly              REAL, flag bit 0x4. Script_SetCVar tests it
                          before anything else and raises `"%s" is read
                          only`, so this answers in advance whether
                          SetCVar will work. Three cvars carry it on this
                          client: realmList, realmName, scriptMemory.
  isStoredServerAccount   Always false. These describe a cvar the server
  isStoredServerCharacter stores per account or per character, kept in
                          step by the synchronizeConfig cvar. No such
                          mechanism and no flag bit for one exists here,
                          so false is accurate rather than a placeholder.
  isLockedFromUser        Always false. No flag governs it.
  isSecure                Always false. Marks cvars that cannot be set in
                          combat, a protection with no analogue here.

Derived by reading 3.3.5's own GetCVarInfo, which returns the first four:
value and default from the struct, then flag bits 4 and 5 for the two
server-stored booleans. The flag OFFSET transfers -- +0x1C in both -- but
the string offsets do not (3.3.5 keeps them at +0x28 and +0x40), so the
ones used here come from this client's own cvarlist handler, which
compares +0x20 against +0x30 to print "(default: %s)".

Unknown names return nil rather than raising, so a caller can tell "no
such cvar" from "set to empty". FUN_FIND_CVAR searches the cvar registry
alone, so a console COMMAND's name finds nothing and returns nil -- the
contract's "console variables only" for free, with no test needed.

Namespaced only. A bare GetCVarInfo global is nil on a live client, and
C_CVar.GetCVarBool already established the namespace here.

Offsets.h gains OFF_CVAR_DEFAULT_STR and names three flag bits, each from
the code that acts on it: archive (the config writer skips cvars without
it), staged (the setter stores to +0x38 and marks the config dirty
instead of applying), and read only.

Verified in-game: realmList reports its value, its stock default and
isReadOnly true; gxResolution reports 2560x1080 against a 640x480 default
with every boolean false; an unknown name and the console command `help`
both return nil; and a sweep over every cvar finds exactly the three
read-only ones.
2026-09-06 16:15:29 -05:00
Brues 2c2f280c51 feat(console): add ConsoleGetAllCommands
Returns every console command and console variable as ConsoleCommandInfo
tables, backed by the registry FUN_CONSOLE_COMMAND_REGISTER fills. The
walk mirrors the `help` handler (FUN_0063FDC0), the only other thing that
enumerates it: step with next = *(uint32_t *)(node + LINK_OFFSET + 4),
stop on null or a low-bit-tagged sentinel.

Commands and CVars are ONE list, not two. The CVar registrar's last act
is FUN_CONSOLE_COMMAND_REGISTER(cvar->name, 0x0063DDE0, categoryId,
help) -- which is what makes `/console <cvar> <value>` work -- so the
command list already holds every CVar, and walking the CVar list that
`cvarlist` and the config saver use would report each one twice. That
shared handler address is also an exact commandType test: a node pointing
at it is a Cvar, anything else a Command. A pointer comparison against a
value the registrar itself writes, not a name or category heuristic.

Named as a global, not a C_Console member. The wiki files it under
C_Console.GetAllCommands, but on a live 12.1.0 client C_Console is nil
and ConsoleGetAllCommands is the real name -- the page is stale about the
namespace. The field names come from Blizzard's own generated API docs
and were then confirmed against a live entry: command, help, category,
commandType, scriptContents, scriptParameters, with the two script fields
empty strings rather than nil there too.

Enum.ConsoleCategory and Enum.ConsoleCommandType are stated in full
rather than derived from the engine's nine categories. Deriving works --
reading VAR_CONSOLE_CATEGORY_TABLE and capitalizing "debug" to "Debug"
was verified in-game -- but it makes the enum track the wrong thing. It
is a Blizzard contract, not a description of the local console, and it
carries values this client cannot produce: Reveal 9 and None 10 have no
category here, Macro 2 and Script 3 no registration path. A client that
renamed its `gm` category would silently register
Enum.ConsoleCategory.Master and turn Enum.ConsoleCategory.Gm into nil,
breaking a constant meant to be fixed everywhere. Offsets.h keeps the
verification: ids 0..8 are debug, graphics, console, combat, game,
default, net, sound, gm in that order, so a category read off a node
lands inside the enum without translation.

scriptContents and scriptParameters describe console macros and scripts,
which have no registration path here, so both are always "".

Verified in-game: 538 entries, 487 of them CVars. `help` -> Command /
Console with its description, `realmList` -> Cvar / Default, and our own
`ExportDBCFiles` -> Command / Debug, which also proves the walk sees
runtime registrations rather than only boot-time ones. C_Console is
absent, both enums resolve including the values this client cannot
return, and every field has its declared type.
2026-09-06 15:46:30 -05:00
Brues 991baa2610 feat(lua): cap upvalues at Lua 5.1's 60 rather than the byte
The limit shipped at 255, the structural ceiling -- f->nups and
LClosure.nupvalues are lu_byte, so that is the most a closure can hold.
But the point of the change is that a function valid on a 5.1 client
should load here, and 5.1's own MAXUPVALUES is 60. Anything past that is
not a compatibility gap, it is an invitation to write something no other
client would accept, which is not a thing to be first at.

One constant. Nothing in either hook depends on which number it is: the
spill path, the stale-clear and the emission loop are unchanged, and the
refusal still comes from the engine's own luaY_checklimit, so its message
carries whichever limit is in force.

Verified in-game: 60 upvalues load and sum correctly, 61 fails with
"too many upvalues (limit=60)".
2026-09-06 14:16:39 -05:00
Brues e68b690cf1 feat(lua): raise the parser's upvalue limit from 32 to 255
Lua 5.0 refuses the 33rd upvalue of a function with "too many upvalues
(limit=32)", as a syntax error on the whole file. Lua 5.1 allows 60, so a
large handler that reads many file-level locals can be valid on a modern
client and unloadable here -- and it is the one class of 5.1 difference
the source transpiler cannot reach, because the limit sits in the parser.

The limit is not the `PUSH 0x20` immediate in indexupvalue. It is the size
of a fixed array: FuncState carries `expdesc upvalues[32]` at +0x38, and
the next field, actvar[200], begins at +0x2B8 -- exactly 0x38 + 32 * 0x14.
Raising the immediate alone writes the 33rd upvalue over actvar[0], the
stack of locals in scope, and the function compiles wrong without raising.

In 5.0 that array has exactly two readers, both located in the binary:
indexupvalue (FUN_006FDF80), which scans it and appends, and pushclosure
(FUN_006FD980), which walks it after the body to emit one OP_MOVE or
OP_GETUPVAL per entry behind OP_CLOSURE. Everything past the parser is
dynamic -- f->nups and LClosure.nupvalues are bytes, OP_GETUPVAL's B field
is 9 bits, closures are sized per instance, the debug-name array grows on
demand -- so the byte is the ceiling and the array the only obstacle.

Co-hook both readers and keep entries 33+ in side storage keyed by the
FuncState's address. A function within 32 never leaves engine code: both
hooks call the original for it. Only a function the engine would have
rejected takes the reimplemented path, and that path calls the same
helpers the original does -- luaY_checklimit with 255 in place of 32, so
an overflow still raises the engine's own message; luaM_growaux for the
name array; luaK_codeABx / luaK_codeABC for emission. Bytecode and VM are
untouched: a 40-upvalue closure is one 5.0's VM already builds and runs.

Staleness is self-correcting without an open_func hook. A parse error
longjmps out, so side storage keyed by a stack address can outlive its
function and the next chunk may reuse the address. But side entries exist
only while nups > 32 and indexupvalue is the only writer, so a FuncState
arriving there with nups <= 32 cannot own any -- whatever is filed under
its address is cleared on the new function's first upvalue, before
anything could read it.

Compile-time only, so no per-frame cost, and nothing else hooks lparser
internals -- LuaSyntax's transpiler hooks luaL_loadbuffer, a different
function, and the other Octo DLLs do not touch the compiler.

The Proto offsets Frame::ScriptArgs kept locally move to Offsets.h now
that a second file reads the struct; its numparams @+0x45 / is_vararg
@+0x46 independently pin nups to the byte before them.

Verified in-game: 32 and 33 both load (engine path, first spill); 40
upvalues through the OP_MOVE path and again through a middle function's
OP_GETUPVAL path each return the right sum; a syntax error inside a
40-upvalue body followed by a clean parse still returns the right sum
(the stale-clear); 255 split across two scopes loads, and 256 fails with
the engine's own "too many upvalues (limit=255)".
2026-09-06 13:38:03 -05:00
Brues e5690b1c6a docs(readme): list texture:ResetTexCoord in the Frame row 2026-09-06 11:24:45 -05:00
Brues e91b1457fd ci: ship the pdb and map in the dev build artifact
The per-push artifact held only the DLL, so a dev build could not be
triaged from the outside. With the PDB sitting next to the DLL the
client's own crash handler resolves our frames to file and line -- a
tester's log reads `Texture::Mask::PrimIndexed_h+12 (Mask.cpp,246)`
instead of a bare address -- and the map resolves `0001:offset`
addresses by hand when there is no dump. That is the difference between
a report that names the function and one that needs a Ghidra session to
place a return address.

Both files are already produced for every build: the /Zi, /MAP and /DEBUG
flags are set unconditionally, not gated on release. They were simply not
being uploaded, while the release path has bundled them all along.

Their value is that they match one exact build, so they travel with it
rather than being rebuilt later from the same commit. All three share
build/Release as their parent and land at the zip root.

if-no-files-found: error, because an artifact missing its symbols looks
identical to a good one until someone needs a stack out of it.
2026-09-06 11:15:15 -05:00
Brues 09c5832ae8 fix(texture): make texture coordinates address the atlas art, not the file
With an atlas applied, SetTexCoord was addressing the whole sheet, so a
crop showed a slice of the surrounding art rather than a part of the
sprite, and GetTexCoord reported the atlas rect where a caller expected
what it had asked for. Both came from one cause: SetAtlas was writing the
atlas rect into the region's only set of coordinates, leaving the
caller's own nowhere to live.

A live client shows the two are separate state there: GetTexCoord reads
the full 0..1 rect immediately after SetAtlas, with GetAtlas still
reporting the atlas, so 0..1 means all of the sprite rather than all of
the sheet. Keep the caller's coordinates alongside the atlas name and
compose: SetTexCoord records them in sprite space and writes the mapped
result for the engine to draw, GetTexCoord hands them back. Both handlers
are co-hooked and pass straight through for a texture with no atlas,
which is every texture in the game until an addon calls SetAtlas -- the
empty-map test is the whole cost in that case. Both the 4-argument and
8-argument corner forms compose; anything else is left to the engine to
reject so its own error text is what the author sees.

ResetTexCoord becomes a plain restore of identity, which is what it
always was conceptually -- the atlas rect now falls out of composing
identity rather than being a special case.

Also clear the atlas on SetTexture rather than inferring it from the
texture handle. The handle comparison cannot detect a re-set to the very
file the atlas names, because the engine returns the same HTEXTURE, so
GetAtlas kept reporting an atlas that was no longer showing. That guard
predates this change. It stays for recycled regions, which is the case it
is actually good for and the reason this module needs no region
constructor hook.

Verified in-game against a registered quarter-rect atlas: identity is
reported after SetAtlas, a crop reads back in sprite space, the engine
receives the composed file-space rect from both argument forms, reset
returns to the full sprite, SetTexture clears the atlas even to the same
file, and a texture with no atlas behaves exactly as before.
2026-09-06 11:12:55 -05:00
Brues 4015fee5e2 feat(texture): add texture:ResetTexCoord
Drops a crop made with SetTexCoord or SetSpriteSheetCell and goes back to
showing the whole of whatever the texture points at.

What "the whole of it" means was the only open question, and the
generated API docs answer none of it -- ResetTexCoord is listed with no
arguments, no returns and no description, and Blizzard's own FrameXML
never calls it. Settled by testing a live client instead: with an atlas
applied, GetTexCoord reads the full 0..1 rect, and a reset after a
SetTexCoord returns to that with GetAtlas still reporting the atlas.

So the source and the user's texcoords are separate state there, and
0..1 means "all of the sprite" rather than "all of the sheet". Here an
atlas IS its texcoords -- SetAtlas is SetTexture plus SetTexCoord, with
no second layer to hold the sub-rect -- so identity for an atlas'd
texture is the atlas's own rect. Resetting to a literal 0,0,1,1 would
show the whole sheet where retail shows the sprite, so the atlas rect is
restored instead, which reproduces the visible result. Reuses GetAtlas's
staleness guard, so a texture pointed elsewhere since resets to the full
rect.

Verified in-game against a registered quarter-rect atlas: SetAtlas gives
the rect, SetTexCoord replaces it, ResetTexCoord restores it with the
atlas still set, and a plain textured region with no atlas resets to
0,0,1,1 with GetAtlas nil.
2026-09-06 10:59:47 -05:00
Brues 744bfb31d1 docs(tick): name the UI render root's arguments for what they are
The hook declared `(void *bounds, int flag)`. The second argument is the
frame's elapsed time as a float: the HLAYER drain calls every layer as
`cb(layer + 0x18, DAT_00885608)`, and DAT_00885608 is set at 0x00442330
and zeroed at 0x004426C3 once the drain finishes. FUN_00765650 passes it
straight to every frame's vtable +0x38 per-frame update, which is where
OnUpdate's `%f` comes from -- the same value visible threading through
both hooks in a recent backtrace.

Rename both parameters and correct the type rather than leave an `int`
sitting under a comment calling it a float. ABI-identical: both are
4-byte stack slots under __stdcall and both are forwarded untouched.

The Offsets.h note gains the rest of what was verified while checking
this: the caller discards the return value, `bounds` is the layer rect
and is never read by this callback, and ECX is not read on entry, so it
is not a __thiscall in disguise.
2026-09-06 10:43:00 -05:00
Brues 8706d1d99b docs: combine the launch switches into one API.md section
The two README rows covered one subject -- things you put on the command
line that starts the game -- and each carried its full detail inline,
which is the one place in that table where a row was doing a reference
page's job. Fold them into a single linked row and give the detail a
Launch section in API.md, next to the console commands, which are the
other entries that are not Lua functions.

The section also states what the two switches share, which neither row
had room for: the name matching is case-insensitive, `-name value`,
`-name=value` and `/name value` are equivalent, and a value with spaces
needs quoting. For -config it now says what does and does not follow a
profile, since the addon enable list living under WTF\Account\ is the
part that surprises people.
2026-09-06 09:59:17 -05:00
Brues 8637bf05e5 feat(config): add -gluescript / -gamescript launch switches
Four switches that run Lua as the client starts:

  -gluescript      <code>   run at the glue screens
  -gluescriptFile  <path>   the file's contents, run at the glue screens
  -gamescript      <code>   run once in-game
  -gamescriptFile  <path>   the file's contents, run once in-game

None of these exist in 1.12. The strings appear nowhere in the binary, so
there is nothing to parse them and nothing to un-gate -- the feature is
ours end to end.

Registration time is too early for either state. The in-game module
callback runs from FUN_LOAD_SCRIPT_FUNCTIONS, before FrameXML loads, and
the glue callback runs from FUN_LOAD_GLUE_SCRIPT_FUNCTIONS, before
GlueXML does. A script running there could not touch a frame, call a
FrameXML function or see an addon, which is most of what one would be
written for. So each is armed at registration and run from a tick, where
the UI is demonstrably up. Glue uses FrameTick, whose hook is the UI
render root and so fires at the login and character-select screens; it is
re-armed by the glue module callback on every glue boot, so a script that
logs in automatically still works after a logout. Game uses WorldTick,
which is world-only, so its first tick is past FrameXML, past the addon
load pass and past enter-world init; it latches for the life of the
process, since "run once in-game" says once.

FUN_LUA_RUN_STRING resolves the Lua state itself rather than taking one,
so a single call site reaches whichever state is current and the two
paths differ only in when they fire. It installs the FrameScript error
handler as the pcall's errfunc, so a faulty script reports itself the way
any script error does instead of failing silently, and because every
chunk goes through the shared compile chokepoint these scripts get the
same 5.1 syntax support the rest of the client's Lua has.

A *File is read with Win32 rather than the engine's file layer. The
engine resolves relative paths against a loose-file index built once at
boot, so it cannot see a file created afterwards and cannot open an
absolute path at all -- and an absolute path is what goes in a shortcut.
Reading it directly also lets both switches share one execution path, so
inline code and file contents cannot diverge.

The switch parser moves to Config::CommandLine, shared with -config
rather than copied: the quoting and token-boundary rules are exactly
where a second copy would drift unnoticed. Lifting it also fixed a latent
overread, where a switch name at the very end of the command line was
compared past its terminator.

Verified in-game on Turtle: inline and file forms in both states; the
in-game script silent across /reload and a second login while the glue
script re-ran on returning to character select; a syntax error and a
missing file each reported without a crash; and -config still applying
through the shared parser.
2026-09-06 09:53:58 -05:00
Brues 802039fd08 fix(texture): stop a zero-dimension texture hanging the mip accounting
The engine's mip-chain byte total FUN_GX_TEXTURE_MIP_BYTES (0x00594CE0)
walks a texture's levels by halving both dimensions until each reaches
exactly 1, and gates every halving on JBE. A dimension of 0 therefore
never shifts and never reaches 1, so the loop at 0x00594D47 spins
forever. It does no allocation and no I/O, which is why it presents as a
hard freeze at 100% of one core rather than a crash, and why the client
never recovers.

Caught live at 0x00594D78 by a SIGSEGV-forced backtrace taken during
login under Wine. The frame's four printed stack slots are this
function's own reused parameter slots, and read back as 4 bytes per block
(uncompressed 32-bit) with both dimensions 0. The call chain checks out
edge for edge from the region-layer draw down through the state flush and
the texture-stage apply, so it is a real stack rather than a heuristic
scan. Note that the state flush applies a dirty list, so the UI draw at
the bottom is where the flush happened, not necessarily what bound the
texture.

One branch above the loop the engine already handles the analogous case
correctly: a texture with no mip chain returns its base-level bytes and
stops. The guard gives a degenerate dimension the same answer. It
resolves the caller's arguments the way the original does and, when
either dimension would shift to zero, raises that one to the smallest
value the accounting can express before calling through. The loop then
terminates on its first pass. No mip math, format table or reduction
level is reimplemented. Clamping at the entry rather than at a call site
also covers the second caller, FUN_00594C80, which accounts for a lock by
passing a dirty rect's width and height and hangs identically on an empty
but still flagged rect.

A dimension can also reach zero without the texture being 0x0, via the
reduction level the accounting shifts by -- a caps field (caps + 0xE0,
the block being device + 0x23C per FUN_005923D0) filled from the graphics
backend. Measured 0 on a native D3D9 driver, unmeasured under wined3d.
So the guard tests the shifted value and clamps to 1 << reduce rather
than assuming either case.

Who feeds it a zero is a separate question, and the guard does not bury
it. ClassicAPI constructs no textures, and DimensionGate cannot admit a
zero it would otherwise reject, since the validator's power-of-two test
already yields "is a power of two" for 0. So the first occurrence is
logged with everything readable off the texture, plus
Texture::Mask::AnyMaskActive() to rule the mask path in or out -- passive
evidence that costs whoever hits the freeze nothing.
2026-09-06 09:07:11 -05:00
Brues d14f0b44c1 fix(compat): restore vanilla script args for LunaUnitFrames headers
Fixes #41.

LunaUnitFrames sets SetupGroupHeader(groupType) as its group headers'
OnEvent while also calling it directly with a group name. As a handler it
relies on groupType being nil so it falls back to this.unitGroup. Our
modern script arguments hand it `self` instead, so it looks the header up
by frame rather than by name, gets nothing, and indexes nil at
units.lua:358.

The addon is correct vanilla code. A function that is both called directly
and used as a script handler is an ordinary vanilla idiom, and passing
positional arguments to handlers breaks it wherever the first parameter
means something other than self.

Adds CAPI_VanillaScriptArgsCompat(frame, script), which rewraps one
handler in a no-parameter closure so the extra arguments stop there and
the original runs with the empty argument list it was written for. The
globals survive the nesting, so it still reads `this` and `event`. Same
shape as the existing CAPI_MouseoverClearedCompat.

Only the group headers need it. SetupRaidHeader takes the header frame
itself, so `self` is already the value it wants.

This covers one addon, not the class. Any addon using the same idiom hits
the same failure, and each will need its own entry here until handlers
opt in to modern arguments rather than receiving them unconditionally.
2026-09-05 16:02:37 -05:00
Brues 008282d65a fix(aura): revoke nibble polarity when the flags contradict it
Detection says a Turtle server writes polarity into the aura flag nibble.
If that stops being true — Turtle changes SetAuraFlag, or something else
identifies as Turtle without the patch — nothing noticed, and the failure
is the loud one: reading 0x08 as "harmful" under the stock encoding means
"effect index 0 has an aura", so nearly every buff reports as a debuff.

IsSlotHarmful now checks each nibble against the three values Turtle's
SetAuraFlag can produce (HELPFUL, HELPFUL|CANCELABLE, HARMFUL). Anything
else latches the slot range back on for the rest of the process.

Revocation only, never promotion. Agreement is not provable: a run of
0x08 readings is equally consistent with single-effect auras under the
stock encoding, so no observation can justify turning nibble reading ON.
Identity stays the only thing that does. Defaulting the other way would
misclassify every buff on a stock server for as long as it took a
contradiction to show up, which is the failure this guards against.

Does not help a non-Turtle server that copied the spill patch — that one
still falls back to the range and reports spilled debuffs as buffs, the
same answer native UnitBuff/UnitDebuff give there. Covering it needs
server-independent polarity (a port of the server's IsPositiveSpell
heuristic), which is a much larger change for a rarer case.
2026-09-05 15:40:50 -05:00
Brues 9a8c314d69 fix(aura): scope flag-nibble polarity to Turtle
The aura flag nibble does not mean the same thing on every server, and
reading it as polarity everywhere was badly wrong off Turtle.

Stock (vmangos SpellAuraDefines.h + SetAuraFlag): the bits are
CANCELABLE 0x01, EFF_INDEX_2 0x02, EFF_INDEX_1 0x04, EFF_INDEX_0 0x08 —
one per spell effect index that carries an aura, plus cancelable for a
positive aura. No polarity is encoded at all. So testing 0x08 for
"harmful" was really testing "effect index 0 has an aura", which is true
of nearly every aura, and reported almost every BUFF as a debuff.

Turtle (tortoise-wow SetAuraFlag) replaces those effect-index bits with
polarity: 0x04 positive, 0x08 negative. It needs to, because it also
drops the else in the slot search so a debuff spills into 0..31 once the
16 harmful slots are full — the range stops being reliable there and the
nibble is what preserves the answer. Stock keeps a strict either/or, so
nothing ever crosses over and the range is exact.

IsSlotHarmful now picks per server and is the one place that decides.
Every other site defers to it rather than testing either signal.

Identity detection rather than behavioral: on Turtle every nibble is one
of {0x04,0x05,0x08}, while stock nibbles routinely fall outside that set,
so an out-of-set nibble proves stock — but the converse never arrives,
since single-effect stock auras land on 0x08 by coincidence. Stock is
provable, Turtle is not, and the unprovable direction is the catastrophic
one. A Turtle fork still detects (clones keep the TURTLE_* global names).
A non-Turtle server that copied the spill patch falls back to the range
and reports spilled debuffs as buffs, matching what native
UnitBuff/UnitDebuff already do there.
2026-09-05 15:34:03 -05:00
Brues 4b1bf16ba5 refactor(settings): one place builds ClassicAPI's WTF\Account paths
Three modules resolved these independently. equipmentset/Storage.cpp and
friendlist/Notes.cpp had byte-identical copies of the same three engine
reads (account, realm, character), the same null and empty gates, and the
same separator joining — differing only in the leaf filename.
settings/Account.cpp had a third copy of the account half, with its own
inline pointer cast instead of Game::Read.

Settings::Paths now owns that, exposing AccountFile and CharacterFile.
Both return an empty string until the globals they need are populated, so
callers keep treating empty as "not available yet" and no longer null-check
the individual names. Behavior is unchanged: the paths built are the same
strings, and the readers moved across verbatim.

AddOns::SavedVarsFirst deliberately keeps its own copies. It mirrors the
ENGINE's saved-variable layout and has to match it byte for byte, which is
a different contract from naming ClassicAPI's own files — the two only
happen to share a directory, and tying them together would mean an edit
intended for one silently moving the other.

Storage.cpp and Notes.cpp no longer reference Game or Offsets at all, so
those includes go too.
2026-09-05 14:38:17 -05:00
Brues 5528fb5fa4 feat(config): add a -config launch switch for settings profiles
`-config Config2.wtf` makes the client read and write WTF\Config2.wtf in
place of WTF\Config.wtf, so one install can hold several profiles.

The engine already names its config file through a single pointer: boot
passes "Config.wtf" to the config initializer, which keeps it at
0x00C4EDD4 and uses it for both sides — the load at 0x0063D428
(MOV ECX,[0x00C4EDD4]; CALL 0x0063D820) and the save in FUN_0063D980,
which builds "WTF\" + the same pointer. Supplying a different string is
therefore the entire feature, with no second code path and no way for
reading and writing to disagree.

This is patched rather than hooked because of when it happens. The config
is read from FUN_00402350 during boot, several calls before VanillaFixes
invokes our Load export — VF hooks GetCPUFrequency (0x0042C060), which is
reached from the FUN_0063A230() that runs after the config is already in.
DllMain does run in time, since VF injects every dlls.txt DLL while the
process is still CREATE_SUSPENDED and resumes it only afterwards. So this
is the one module DllMain names directly. It is a 4-byte store into an
immediate operand, not a hook, so it carries none of the loader-lock cost
that keeps hook installation out of DllMain.

VanillaFixes forwards any non-.exe argument to the game verbatim
(CmdLineParse/AppendArg), so the switch works from the launcher shortcut.

The name must be a plain filename. The engine builds the save path as
"WTF\" + name, and a folder that does not exist makes the save fail
silently, which would discard a session's settings with no warning.
2026-09-05 13:15:04 -05:00
Brues 7ab32df2aa feat(texture): add texture:SetSpriteSheetCell
Crops a texture to one cell of an evenly divided grid, which is the
arithmetic every sprite-sheet call site would otherwise repeat. It is the
primitive behind raid target markers, whose eight icons share one 4x4
sheet, and this client already ships the FrameXML helper
(SetRaidTargetIconTexture) that wants it.

`cell` is 1-based and counts left to right then top to bottom — Blizzard
types the argument `luaIndex`, and its own callers pass GetRaidTargetIndex
straight through without adjusting it.

cellWidth and cellHeight are accepted and ignored. Both call sites in
Blizzard's UI pass the three-argument form, and the grid alone already
fixes each cell's coordinates, so honoring them would mean guessing at
their meaning.

The coordinates go through the engine's own SetTexCoord handler, so this
module contributes only the grid math.
2026-09-05 00:30:39 -05:00
Brues 8e1429bfac feat(texture): publish raid target markers as atlases
All eight markers live in one 4x4 sheet on this client, and the per-marker
files other clients ship are absent here — the art directory holds only
UI-RaidTargetingIcons.blp. So naming a single marker requires a sheet plus
a sub-rect, which is what an atlas is. A port referencing an individual
marker file otherwise gets nothing.

Blizzard publishes no atlas names for these. Checked: zero hits for
raidtarget / targeting / raidmarker in UiTextureAtlasMember, and current
Classic Era still draws them by computing texcoords in
SetRaidTargetIconTexture and by per-file |T markup in chat. The names here
therefore follow the standalone marker filenames other clients ship, so an
addon written against those finds the art under the name it already knows.

This lives in the addon rather than AtlasData.h on purpose: the DLL's
built-in table means "names Blizzard defined", and adding invented ones
there would quietly corrupt that contract. Cell math matches the chat
marker tokens, and markers 4 and 8 were confirmed on screen.

Also adds CreateAtlasMarkup alongside the existing texture markup helper.
2026-09-05 00:26:40 -05:00
Brues 9322382596 feat(texture): bind chatframe-button-highlightalert
First atlas binding derived by widget match rather than name match, and the
reason the table now documents both methods.

Classic Era's FloatingChatFrame.xml draws the chat menu button's Flash
overlay with atlas "chatframe-button-highlightalert". This client's
FloatingChatFrame.xml draws the same $parentFlash region with
UI-ChatIcon-BlinkHilight and no TexCoords, so the whole file is the sprite.
Both UIs are exported on disk, so the binding is read off the two trees
rather than guessed. Verified in-game as a blue glow ring.

Size comes from the 1.12 file (32x32), not the member table (34x32) —
packing added bleed that the vanilla art does not have.
2026-09-05 00:20:43 -05:00
Brues c3cf9e6f3a feat(texture): add atlas support — C_Texture, SetAtlas, and |A markup
An atlas names a texture plus a sub-rect inside it, so SetAtlas is the
engine's own SetTexture + SetTexCoord (+ the size setters for
useAtlasSize) driven off one lookup. Every step delegates to the engine's
Lua handlers, so the argument guards and the UI-scale conversion stay in
force and this module only adds the name resolution.

Adds C_Texture.GetAtlasInfo / GetAtlasExists / GetAtlasID /
GetAtlasElementID / GetAtlasElements, the RegisterAtlas extension for
addons that ship their own sprite sheet, texture:SetAtlas / GetAtlas, and
the |A:name:h:w|a inline markup alongside the existing |T form. All five
readers key off one bound set, so GetAtlasExists is true exactly when
GetAtlasInfo returns a table.

The built-in table is five rows on purpose. Measured against Classic Era's
UiTextureAtlas tables and the community listfile: 0 of 868 atlas sheets
exist in this client's art, and while Blizzard kept the original vanilla
filename as the atlas name for 16 members, only 5 have dimensions that
agree — vanilla's own scrollbar template draws a centered 16x16 out of a
32x32 file that the atlas calls 18x16. Texcoords are not derivable from
the two tables, so the rest grow from _classicapi_DumpAtlasMisses()
rather than from guesses. AtlasData.h carries the full derivation.

|A shares every scanner, measure, wrap and placement stage with |T —
ParseAtlasIcon fills the same IconDesc, since an atlas is exactly the
path-plus-sub-rect that struct already holds. That makes the chat
anti-spoof mandatory, not optional: IconFilter now defangs |A too, or a
player could render art by typing it into /say and the speech bubble.

GetAtlas stores the HTEXTURE it installed and clears itself when the
region points elsewhere, which gives the correct nil-after-SetTexture
behavior and avoids a second hook on FUN_SIMPLETEXTURE_CTOR — Texture::Mask
owns that target and MinHook refuses a duplicate, which would abort the
whole hook install.
2026-09-05 00:11:08 -05:00
Brues f5d6046523 feat(map): add C_Map.GetMapArtLayers
Describes the single fixed layer a map's background is drawn as: a
1002x668 canvas cut into 256-pixel tiles (the 4x3 grid of twelve the
world map frame draws), scale 1 to 1 with no extra zoom steps, since a
map does not zoom here. A map with no art returns an empty table.

Canvas and tile sizes come from the client's own WorldMapFrame.xml, and
move to Map::Area so the overlay hit-rect math shares them instead of
keeping a second copy.

They stay constants on purpose. A live read of WorldMapDetailFrame looks
more engine-driven but is wrong twice: the frame is addon-mutable while
the DBC coordinates it normalizes are authored against the shipped size,
and these are logical sizes -- an HD map patch ships larger tiles, which
Map::Overlays already detects and scales, and reporting a patched tile's
real size would break the canvas/tile division that yields the grid.
2026-09-04 22:57:22 -05:00
Brues e1ebf6ecce feat(map): add C_Map.MapHasArt and GetMapArtLayerTextures
A map's background is the tile set Interface\WorldMap\<dir>\<dir>1..12,
where <dir> is the WorldMapArea row's own name -- the path the world map
frame builds its tiles from. So "does this map have art" and "give me its
tiles" are both answerable by probing that set, even though the modern
atlas art model has no counterpart here.

MapHasArt is independent of whether a map has coordinates: the
whole-world map has art but a zero rect, and instance rows can have a
rect but ship no art.

GetMapArtLayerTextures returns texture PATHS rather than the numeric file
ids of the modern call. A texture is named by its path on this client and
SetTexture takes that path, so ids would be both unavailable and
unusable; GetMapOverlays already makes the same substitution for its
fileDataIDs. There is one art layer, so layerIndex 1 returns the
background and any other index an empty table.

Path convention and the 12-tile count taken from the client's own
FrameXML rather than assumed.
2026-09-04 22:38:49 -05:00
Brues 698827e9c5 fix(addons): report a real loaded state from IsAddOnLoaded
The engine sets an addon entry's loaded byte at the START of its load,
before dependencies and before the addon's own files run, clearing it
again only if a required dependency fails. That byte therefore means
"load has begun" -- it is loadedOrLoading, not loaded.

IsAddOnLoaded returned it for both values, so an addon checking itself
at file scope got (true, true) where it should get (true, false). The
old comment claimed the in-flight window was unobservable from Lua; it
is plainly observable, since the addon's own files and every dependency
pulled in during them execute inside it.

Add AddOns::LoadState, which co-hooks the per-addon loader and keeps a
stack of the names currently loading -- a stack because dependency loads
nest, and the name is copied because LoadAddOn passes a transient Lua
string. loadedOrLoading stays the engine byte; loaded now also requires
the addon not to be mid-load.

Known gap, documented: an addon asking about itself from its own
ADDON_LOADED handler still reads (true, false), as that event fires just
before the load call returns.

Also mirror the stock LoadAddOn global into C_AddOns. It already returns
the modern (loaded, value) shape -- 1/nil plus a locale-independent
reason token -- so it registers as the engine's own handler.
2026-09-04 21:48:13 -05:00
Brues 60530e32fc feat(faction): add C_Reputation inactive getters and by-id setters
Adds SetFactionInactiveByID / SetFactionActiveByID, which file a faction
into or out of the "Inactive" category by id rather than by
displayed-list position, plus IsFactionActive and IsFactionActiveByID to
read that state back.

IsFactionActive is a real backport and takes a sort index, returning a
boolean; the ByID forms are ClassicAPI extensions.

Both stock setters are just an index resolve around one shared engine
setter that already takes a faction id, so the by-id pair is that call
with 1 or 0. It sends the change to the server, rebuilds the displayed
list (so positions shift), and being the function UnitFactionPolyfill
hooks, fires UNIT_FACTION.

The inactive getter is the odd one out: unlike the at-war getter, its
worker takes the 0-based index and resolves internally. So both read
forms are built from Resolver() plus a factored rep-slot read, which
reproduces the engine's own index -> factionID -> repListID -> flag
chain and lets them share one implementation.

Also mirror the stock SetFactionInactive, SetFactionActive and
IsFactionInactive globals into the namespace, registering the engine's
own handlers rather than reimplementing them.
2026-09-04 21:32:14 -05:00
Brues 3cffb71b20 feat(faction): add C_Reputation.ToggleFactionAtWarByID
Flips a faction's at-war state by ID rather than by displayed-list
position. A ClassicAPI extension -- Blizzard has no such call.

The stock FactionToggleAtWar(index) resolves its index to a faction id
and then reads, negates and writes through two helpers that already take
an id, so the by-id form is that pair without the resolve. Every rule
stays in the engine: the refusal to make peace below -3000 standing, the
peace-forced flag, the loot-session bail, and CMSG_SET_FACTION_ATWAR.
Going through the engine's setter also picks up UnitFactionPolyfill's
hook on it, so UNIT_FACTION fires, and its change detection keeps that
silent when the engine refuses the toggle.

Declare the setter's newState as int, matching the polyfill's detour: a
char argument defines only the low byte of EDX and would leave the
detour's int parameter with undefined high bits.

Also mirror the stock global into the namespace as
C_Reputation.ToggleFactionAtWar, registering the engine's own handler
rather than reimplementing it. Adds the getter and script-wrapper
addresses, which existed only as comment references.
2026-09-04 21:18:00 -05:00
Brues 7887237a6e feat(faction): add C_Reputation.SetSelectedFactionByID
Selects a reputation-pane faction by ID rather than by displayed-list
position, matching what SetWatchedFactionByID gives over the stock
index call. A ClassicAPI extension -- Blizzard has no such call.

The engine already holds the selection AS a faction id: the stock
SetSelectedFaction(index) resolves its argument through the faction
index resolver and stores the resulting id, and GetSelectedFaction maps
that id back to an index. So this writes the id straight through, with
no list walk, and works even while the faction is not listed.

Also mirror the stock SetSelectedFaction global into C_Reputation. Its
handler already has the Lua C ABI, so it registers as-is rather than
being reimplemented.
2026-09-04 21:05:53 -05:00
Brues 98af79137b feat(faction): add C_Reputation.GetFactionDataByID
Keys the modern FactionData table by faction ID instead of by
displayed-list position. Reuses the shared ReadFactionData chain and
table builder, so it needs no new engine work.

Unlike GetFactionInfoByID this is not limited to the player's
reputation list: an unencountered faction fills cleanly with
currentStanding 0 and atWarWith false, so any real faction's name,
description and thresholds are readable.
2026-09-04 20:50:58 -05:00
Brues 622882725f feat(map): C_Map user waypoint (Tier 3)
Add the eight user-waypoint calls and USER_WAYPOINT_UPDATED: set, get,
has, clear, per-map position, the two hyperlink converters, and the
can-set check.

The pin is one piece of session state (a uiMapID, a 0..1 position and an
optional z) that survives a UI reload. GetUserWaypointPositionForMap
re-projects it onto any other map through world coordinates, so a zone
pin resolves on its continent and back. The hyperlink is the standard
worldmap:uiMapID:x:y payload with both coordinates scaled by 10000, and
the parser accepts a whole link or the bare payload.

UiMapPoint's factories go in the addon, matching where retail ships them.
Extract the shared vector2 marshalling to map/Vector2.h so the waypoint
and coordinate modules don't each carry a copy.
2026-09-04 20:46:22 -05:00
Brues 0517bb3753 feat(map): C_Map map hierarchy (Tier 2)
Add GetMapInfo, GetMapChildrenInfo, GetMapInfoAtPosition and
GetFallbackWorldMapID, giving the World -> Continent -> Zone tree.

Continents carry no AreaTable id (their WorldMapArea rows have areaID 0),
so they are named by a negative -(WorldMapArea row) -- mirroring the
engine's own zoneOrSort idiom, where positive is an AreaTable zone and
negative a QuestSort row. Names and mapType for those come from Map.dbc.

Move the row math into Map::Area (RowForUiMapID, ContinentRowForMapID,
RowRect, PercentInRow, WorldFromRow) and put the Tier 1 coordinate
functions on it, so they accept continent ids too and GetMapRectOnMap
honors topUiMapID.
2026-09-04 20:06:15 -05:00
Brues fb17488237 feat(map): C_Map coordinate transforms (Tier 1)
Add GetPlayerMapPosition, GetWorldPosFromMapPos, GetMapPosFromWorldPos,
and GetMapRectOnMap on the existing AreaTable-id=uiMapID scheme, reusing
Map::Area's world<->map projection. Positions returned as Vector2DMixin.
2026-09-04 19:34:45 -05:00
Brues b1ee8cc183 fix(texture): pool-free must skip oversized textures too
Regression from the size gate (be92d72 / 2ce5b00): the cave lets textures
larger than the recycle pool can index be created, but the pool FREE path
FUN_00448670 was left filing every freed texture into a bucket keyed by
each dimension's power-of-two factor, with no upper bound. A power-of-two
texture over 1024 (tz 6 per axis) files at bucket 36-42, past the 36-entry
table, and writes through a garbage list head — an ACCESS_VIOLATION at
0x004487D9 that fired whenever such a texture was freed, shutdown included.
Only power-of-two dimensions reach the bad index; NPOT has a small
power-of-two factor and stayed in range, which is why it went unseen until
a 2048x2048 was loaded and the client closed.

The allocator's cave already routes oversized textures around the pool;
this does the mirror on free — a cold hook on FUN_00448670 sends any
texture the bucket table can't index to the engine's own non-pooled free
FUN_0058AD60, exactly the guard's else-branch for too-small / too-many-mip
textures. Verified: a 2048x2048 texture renders and /reload (full texture
teardown) no longer crashes.
2026-09-04 18:46:22 -05:00
Brues c08ded6db4 feat(texture): BLP loads at any size; guard the async fallback
BLP decodes into the shared scratch BEFORE it calls the allocator, so the
size cave (which fires from the allocator) was always too late for it — a
2048x2048 BLP overran the scratch on this client. A pre-hook on the
decompressor FUN_005A8430, which carries the level-0 dimensions, grows the
scratch first and repoints the caller's already-copied scratch slot at the
new buffer, or fails cleanly for a texture the GPU cannot hold. It acts
only when the slot IS the shared scratch; the IMG path decodes into its own
right-sized buffer and is left alone. GrowScratch now retires the previous
buffer one generation late (frees at the next growth) so any unaccounted
holder reads stale bytes, not unmapped memory. Verified: a 2048x2048 DXT1
BLP renders and grows the scratch to 2048.

The async force-load path shoves a read that never fit the shared buffer
into a 512 KiB static fallback with no size check — the engine bug behind
"big BLPs crash". The hook refuses a request larger than the fallback; the
texture stays blank. Growing the fallback to fit was tried and reverted:
the read runs on the engine's async reader thread, which has no crash
handler, and a read into a right-sized heap buffer froze then killed the
process where the same read into the oversized shared buffer does not — not
yet isolated. Consequence: an uncompressed BLP file larger than the shared
buffer (2 MiB stock, 32 MiB with VanillaHelpers) does not load; DXT stays
far under it and TGA is synchronous, so only a pathological input is lost.
Verified: a 2900x2900 uncompressed BLP draws nothing and does not freeze.
2026-09-04 18:31:17 -05:00
Brues 640f78b2eb docs: texture size and shape
README gains an "Any texture size" row in Modern client behaviors; API.md
gains a "Texture size and shape" section under Frame, after the texture
methods: any width and height, no fixed upper limit beyond the graphics
card's maximum, memory allocated only when a large texture loads, and the
two remaining file-level limits (BLP files over ~2 MB / 32 MB with
VanillaHelpers stay blank; NPOT BLPs with DXT or mips are untested).
2026-09-04 17:32:51 -05:00
Brues a51ec40e52 refactor(texture): drop the NPOT toggle; non-power-of-two is simply on
_classicapi_NonPowerOfTwoTextures existed to A/B the same texture during
bring-up. As a knob it fails the same test the textureMaxDimension cvar
did: it reset every launch, so neither a user nor an addon author could
rely on either state, and disabling NPOT for real would be a code change
anyway. Removing it also removes the snapshot/restore machinery that
served only it. The enable path is byte-identical to before.

_classicapi_TextureLimits() stays as the read-only diagnostic, minus the
always-true NPOT field: scratchSide, poolMax, poolSide, deviceMax,
strideFixed.
2026-09-04 17:12:41 -05:00
Brues 2ce5b007af feat(texture): grow the decode scratch on demand; guard the async fallback
Layers 3 and 4 of Texture::DimensionGate. There is no number of ours
anywhere: the cave hands each rejected texture's width and height to
EnsureScratch, which refuses only what the device's own caps max refuses
(the field the create validator FUN_0058AB10 compares against, so we never
grow for a texture the engine would reject a moment later) and otherwise
replaces the scratch with one that fits, through the engine's decode-buffer
constructor FUN_005A4AF0 — the routine both decoders already use when the
scratch is NULL. The scratch is a high-water mark; nothing changes for
anyone who never loads a texture over the stock limit.

The "big BLPs crash" bug is fixed at its source rather than by enlarging
the shared async buffer: FUN_0044AD50 (the force-load every UI texture
takes on first draw) shoves a read that never fit the shared buffer into a
512 KiB static fallback at 0x00885804 with no size check — bounded exactly
by the pool globals that follow it. A hook refuses to force a request
larger than the fallback; it stays pending and the texture stays
invisible, the normal not-loaded state. BLP files are thereby bounded by
the shared buffer (2 MiB stock, 32 MiB with VanillaHelpers) gracefully.
TGA reads are synchronous and never touch that path.

Verified in-game: scratch reads 1024 at boot, a 2560x1080 TGA renders
with no configuration, and the scratch then reads 2560. Device max on
the test GPU is 16384.

_classicapi_TextureLimits() now returns scratchSide, poolMax, poolSide,
deviceMax, nonPowerOfTwo, strideFixed.
2026-09-04 17:06:35 -05:00
Brues be92d721f0 feat(texture): dimension gate — NPOT textures, bound follows the scratch
1.12 requires power-of-two textures at two software sites (allocator
FUN_00448450 and create validator FUN_0058AB10); 3.3.5 and 4.3.4 keep the
rule unchanged. Verified in-game that the D3D backend samples NPOT
correctly for the TGA/UI path, so Texture::DimensionGate relaxes both
sites, default on.

The real size ceiling is not the allocator's `< 0x400` but a single global
decode scratch buffer sized once at boot (512^2 stock, 1024^2 with
VanillaHelpers) and written with no bounds check. The validator's
device-caps max is raw hardware and protects nothing — a 2560x1080 TGA
faulted in the decoder's pixel writer at 0x004492E5. So the gate reads the
scratch dimension back from the boot immediates and enforces exactly that,
and every dimension failure — POT or size — routes through a 28-byte code
cave that applies the bound before the fresh-allocation tail. Routing POT
failures straight to that tail would jump over the size CMPs.

Also fixes VanillaHelpers' recycle-pool collision: it grows the table to
6x6 but leaves the index stride at 5, so 32x1024 shared a bucket with 64x32
(rendered nothing) and 64x1024 with 128x32 (worked by luck). Both index
sites are rewritten to stride 6 when its 36-bucket table is detected.

Lua: _classicapi_NonPowerOfTwoTextures([enable]), _classicapi_TextureLimits().
2026-09-04 16:16:11 -05:00
Brues b81f672ad7 fix(aura): classify auras by polarity flag, not slot range
The server seats buffs in slots 0..31 and debuffs in 32..47 only while it
can: once the 16 debuff slots are full, _AddSpellAuraHolder parks further
debuffs in free BUFF slots and sets UNIT_FLAG_AURAS_VISIBLE so the client
still renders them (tortoise-wow, verified). Classifying by slot range
therefore reported those as buffs -- absent from HARMFUL, and wrong in
isHelpful / isHarmful. Confirmed live on a 20-aura target: 18 harmful, two
of them sitting in buff slots, where the old code returned 16.

Every visible aura carries its real polarity in the per-slot flag nibble
(SpellAuraHolder::SetAuraFlag writes 0x04 positive / 0x08 negative), so
HELPFUL / HARMFUL now select on that instead. All the walkers -- by index,
by spell ID, by name, GetUnitAuras, GetAuraSlots -- share one order helper,
SlotInFilterOrder, which visits the polarity's home range first and the
other range after, so every index is unchanged whenever nothing has
spilled. Aura::Source's KindForSlot classifies the same way, keeping the
descriptor-drop fallback consistent with the query paths. The out-of-range
group array carries spell IDs with no flags, so that path stays
range-based -- the seating range is the only polarity it has.

GameTooltip:SetUnitAura now bridges the two index spaces. The engine's
SetUnitBuff / SetUnitDebuff index their own slot RANGE, which diverges from
the polarity index space as soon as a debuff spills, so the method resolves
(index, filter) to an absolute slot and hands the engine the index that
slot holds within its own range, picking the method by where the slot
lives. It counts with the engine's TOOLTIP visibility predicate
(FUN_GAMETOOLTIP_AURA_VISIBLE = 0x00534DF0), not the UnitBuff one
(0x00519860): the latter adds a per-player immunity-bitmap check off record
+0x2B0, so the two index spaces are not interchangeable. Both decompiled to
confirm the walks being mirrored.

The aura tooltip builder only ever receives spellId / level / stacks, so a
debuff parked in a buff slot renders identically through SetUnitBuff.

Note this is deliberately MORE than the engine does: stock UnitBuff /
UnitDebuff split by slot range and show a spilled debuff as a buff. Retail's
contract is the aura's actual polarity, and the descriptor already carries
it, so we report that.
2026-09-04 03:58:14 -05:00
Brues fdbe66b057 fix(aura): let a duration rule edit an aura its own trigger creates
Turtle's Bestial Wrath gives the hunter's pet Scent of Blood for 18s, but
C_UnitAuras reported 8s and then dropped the timer entirely while the buff
was still up.

Server-side (tortoise-wow spell_hunter_bestial_wrath) Bestial Wrath neither
applies the aura nor carries its duration on the wire: effect 0 is
TRIGGER_SPELL(Scent of Blood, 52995), and the script then stretches the
holder that trigger just created via SetAuraMaxDuration/SetAuraDuration.
Both are plain field writes, and 1.12's only duration packet is scoped to an
aura-bearer that is a PLAYER, which a pet is not -- so the client never hears
the 18s and keeps the 8s base it computed when the aura landed. Exactly the
class of edit Aura::Source's duration rules exist to mirror; two things had
to change before one could express it.

The affected-aura selector demanded a SpellFamilyFlags overlap, and every
Turtle custom spell carries flags of 0 (308 in the hunter family alone), so
no mask could ever name this aura. It now takes a family-flag overlap and/or
a SpellIconID, at least one of the two -- an icon is shared across a spell's
whole rank ladder, so it stays as rank-proof as a flag, and icon 2245 picks
out exactly the four Scent of Blood records and nothing else. Registrars
reject the mask-0 + icon-0 rule that would match a whole class family, and
RefreshDurationByFamily takes the same invariant so the two entry points
cannot disagree about what they match.

A trigger can also CREATE the aura it edits, which the immediate-apply model
could not see. The trigger's SMSG_SPELL_GO arrives before the triggered
cast's, so editing at trigger time either matches nothing or is overwritten
moments later when the triggered cast stores its own base duration -- the
edit is lost either way. Idempotent ops now ARM the target as well, and the
next matching store re-applies the edit, from the cast path or the
application path, whichever the aura arrives on. Only refresh and set arm:
re-running those lands on the same value, while reduce would shave twice and
remove would kill an aura that was legitimately re-applied, so Conflagrate
behaves exactly as before.

The rule reads Bestial Wrath's own duration from the client's spell data
instead of hardcoding the server's 18s, so a rebalance that patches the DBC
carries over on its own.

Fixes brues-code/pfUI#50
2026-09-02 19:39:59 -05:00
Brues bd1bd9f5d8 feat(aura): slot-batching API and lazy caster match
C_UnitAuras.GetAuraSlots / GetAuraDataBySlot / UnitAuraBySlot: enumerate a
unit's auras once into opaque slot ids and fetch each by id, so a full scan
walks the aura array once instead of once per index (the by-index getters
re-walk from slot 0 on every call). Slot ids cover live descriptor slots,
the stealth / range cache fallback, and the out-of-range group array, so
the slot path keeps every source the by-index path had.

GetAuraSlots also takes a table as a 5th argument (ClassicAPI extension)
and fills it in place, returning `continuationToken, n`. Lua 5.0 allocates
an `arg` table for EVERY vararg call (luaD_precall 0x006F6050 ->
adjust_varargs 0x006F6200 -> luaH_new, verified in Ghidra), so a Lua helper
receiving the retail multi-return costs one table per batch. The fill form
keeps per-frame scans allocation-free; AuraUtil.ForEachAura / FindAura use
it with a per-nesting-depth buffer pool instead of retail's vararg helpers.

The descriptor walkers (FindNthSlot, FindSlotBySpellID/Name, GetUnitAuras)
now evaluate IsPlayerCast only when the filter restricts on caster. It
scans the Aura::Source cache (~1000 entries in a raid) and was paid for
every populated slot on every call, PLAYER token or not.

Cache-fallback eligibility is centralized (EligibleFallbacks /
EligibleGroupFallbacks) so the by-index, bulk, and by-slot paths share one
ordering, which is the ordinal an opaque cache slot id encodes.
2026-09-02 16:19:31 -05:00
Brues b6698ecc7e docs(readme): list UnitCreatedBySpell in the Unit row
Already documented in docs/API.md; only the README summary table was
missing it. Inserted in alphabetical position.
2026-09-02 02:34:37 -05:00
Brues e123a6facc fix(cast): fire CHANNEL_STOP/START on same-spell re-channel
Recasting a channel while already channeling it (e.g. Fishing while
fishing) left the cast bar frozen: PollPlayer detected a new channel by
spellID only, but a re-channel keeps channelSpellID == g_chanSpell and
the continuous channel never lets +0x228 (and thus g_channel.spellID)
return to 0 in between, so neither CHANNEL_STOP nor CHANNEL_START fired.

Also flag a same-spell re-channel when the channel was re-stamped
(channelStartMs advanced) AND a fresh CMSG_CAST_SPELL is pending for the
spell (g_pendingGuid). Both are required so neither false-fire trap
triggers: the two-stamp-per-start sequence (SMSG_SPELL_START then
MSG_CHANNEL_START) is absorbed because the first CHANNEL_START already
consumed g_pendingGuid, and a rejected recast (SPELL_IN_PROGRESS) never
re-stamps so channelStartMs is unchanged.
2026-09-02 02:05:55 -05:00
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
Brues be6dc200fe fix(addon): restore MouseIsOver's stock 1/nil return contract
The embedded addon replaces FrameXML's MouseIsOver with a wrapper over
the native region:IsMouseOver(), which returns a boolean. Stock 1.12's
MouseIsOver (UIParent.lua:1718) returns 1 on hit and nil otherwise, and
vanilla-era addons compare against that literal - Quiver gates every
widget click on `MouseIsOver(f) == 1`, which `true` silently fails,
deadening its entire config dialog (buttons, checkboxes, icon clicks).

Return 1/nil from the wrapper. 1 is truthy, so modern-style
`if MouseIsOver(f) then` callers are unaffected.
2026-08-24 10:37:39 -05:00
Brues 7f52afeef4 perf(event): fire custom events through O(1) AutoReserve::Slot()
Every custom-event fire resolved its name to a slot via
Event::Custom::Lookup - a linear strcmp scan over the ~46-entry
reservation table, on every single fire. The slot itself was already
cached per table build; only the name->reservation hop was repeated.
On a client that is purely CPU-bound, that is wasted work on paths
that burst exactly when frames are scarce (nameplate churn, cast spam,
loot rolls, mouse/modifier input).

AutoReserve now remembers its reservation index (the ctor's dedup
resolves duplicate names to the same entry) and exposes Slot() - two
loads. Reload safety is unchanged and stays centralized: Slot() reads
through the shared table PrepareForReload/RetryClaims maintain, so the
value can never go stale. Lookup is deleted; LookupByName remains for
engine-defined names.

All fire sites converted to the instance handle:
- CastEvents: the k* name constants become the AutoReserve instances,
  so all 19 fire sites stay textually identical; the Fire/FireReticle/
  FireRemote helpers take the reservation by reference.
- equipmentset: reservations move into Set.h as C++17 inline consts so
  the Data and Api TUs share one handle.
- Cache::QueryLoad::Register takes the AutoReserve pointer instead of
  an event-name string (creature/gameobject callers updated).
- Tts keeps its lazy VanillaTTS-stand-down reservations via
  null-guarded pointers set at registration.
- GlobalMouse/InputEvents/lossofcontrol instances moved above their
  fire sites (static-init list order is irrelevant).
2026-08-24 10:11:10 -05:00
237 changed files with 23748 additions and 3577 deletions
+17 -1
View File
@@ -97,12 +97,28 @@ jobs:
- name: Build
run: cmake --build build --config Release
# Dev builds carry their own symbols. With the PDB sitting next to the DLL
# the client's crash handler resolves our frames to file and line — a
# tester's log reads `Texture::Mask::PrimIndexed_h+12 (Mask.cpp,246)`
# instead of a bare address — and the map resolves `0001:offset` addresses
# by hand when there is no dump. Both match THIS build and nothing else,
# which is why they travel with it rather than being rebuilt later from the
# same commit. Unlike the release bundle these need no off-putting name:
# the artifact is behind the Actions UI, so whoever downloads it is already
# the audience for symbols.
- name: Upload artifact
if: steps.ver.outputs.is_release != 'true'
uses: actions/upload-artifact@v4
with:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
# Shared parent is build/Release, so all three land at the zip root.
path: |
build/Release/ClassicAPI.dll
build/Release/ClassicAPI.pdb
build/Release/ClassicAPI.map
# A symbol-less artifact looks identical to a good one until someone
# needs a stack out of it, so fail the build instead of warning.
if-no-files-found: error
# Bundle both debug artifacts into one zip so a crash from a shipped DLL
# can be triaged exactly. The PDB gives WinDbg/x64dbg/VS a line-level
+1
View File
@@ -43,6 +43,7 @@ Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
Util\UiMapPoint.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
+167 -9
View File
@@ -1,5 +1,10 @@
if C_AddOns.DoesAddOnExist('pfUI') then
EventUtil.ContinueOnAddOnLoaded('pfUI', function()
-- A pfUI that sets `handlesHookScript` was written against our
-- additions and manages them itself, so none of the shims below apply
-- to it.
if pfUI.handlesHookScript then return end
-- pfUI\pfUI.lua redeclares the RAID_CLASS_COLORS object at least 14 times (insane!!)
-- This stops it from replacing all our ColorMixin values
pfUI.UpdateColors = function() end
@@ -17,13 +22,12 @@ if C_AddOns.DoesAddOnExist('pfUI') then
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The wrapper is removed as
-- soon as the actionbar module finishes loading so it affects nothing
-- else. A pfUI is trusted to handle HookScript itself if EITHER signal
-- is present: the maintained "brues" X-Website tag OR the
-- `pfUI.handlesHookScript` capability flag. Without the website the
-- flag is required; if neither is present we apply the shim.
-- else. The maintained "brues" fork, identified by its X-Website tag,
-- handles HookScript on its own buttons and is exempt from this part;
-- every other fork gets the shim.
local website = GetAddOnMetadata("pfUI", "X-Website")
local hasBruesWebsite = website and strfind(website, 'brues')
if not hasBruesWebsite and not pfUI.handlesHookScript then
if not hasBruesWebsite then
local _createFrame = CreateFrame
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
@@ -43,13 +47,10 @@ if C_AddOns.DoesAddOnExist('pfUI') then
end)
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
if not ShaguTweaks or (ShaguTweaks.API and ShaguTweaks.API.classicapi_version) then return end
local libp = ShaguTweaks.libpredict
if libp then
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
@@ -79,6 +80,53 @@ end
CAPI_MouseoverClearedCompat(GameTooltip)
-- Restores vanilla calling for ONE frame's script handler. Our DLL hands script
-- handlers modern positional arguments (`self`, then the event's own args) on
-- top of the `this` / `arg1` globals vanilla uses. That is additive for a
-- handler declared `function()`, but a handler declared with a parameter of its
-- own -- one written to be BOTH called directly and used as a script -- now
-- receives `self` in that parameter instead of the nil it was written for.
--
-- The wrapper below takes no parameters, so those extra arguments stop here,
-- and the original runs with the empty argument list vanilla gave it. The
-- globals are untouched by the nesting, so the handler still reads `this` and
-- `event` normally.
function CAPI_VanillaScriptArgsCompat(frame, script)
if not frame then return end
local original = frame:GetScript(script)
if not original then return end
frame:SetScript(script, function()
original()
end)
end
-- LunaUnitFrames sets `SetupGroupHeader(groupType)` as its group headers'
-- OnEvent while also calling it directly with a group name. As a handler it
-- expects `groupType` to be nil so it falls back to `this.unitGroup`. Given
-- `self` instead, it looks the header up by frame rather than by name, finds
-- nothing, and indexes nil (units.lua:358, issue #41). Its raid counterpart
-- takes the header frame itself, so `self` is already what it wants -- only the
-- group headers need this.
if C_AddOns.DoesAddOnExist("LunaUnitFrames") then
EventUtil.ContinueOnAddOnLoaded("LunaUnitFrames", function()
if not (LunaUF and LunaUF.Units) then return end
local function FixGroupHeader(unit)
CAPI_VanillaScriptArgsCompat(getglobal("LUFHeader" .. (unit or "")),
"OnEvent")
end
-- Headers are built on demand, so cover the ones already up and every
-- one created later.
FixGroupHeader("party")
FixGroupHeader("partytarget")
FixGroupHeader("partypet")
hooksecurefunc(LunaUF.Units, "LoadGroupHeader", function(self, unit)
FixGroupHeader(unit)
end)
end)
end
if C_AddOns.DoesAddOnExist("Puppeteer") then
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
@@ -97,3 +145,113 @@ 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
-- Compost-2.0 (rev 17406+, bundled by BigWigs among others) replaces most of
-- its own body with stubs once it believes it is on Lua 5.1, on the reasoning
-- that table recycling is pointless when the collector already does it:
--
-- local lua51 = loadstring("return function(...) return ... end") and true or false
-- ...
-- if lua51 then function lib:Erase() return {} end
-- else function lib:Erase(t) --[[ clears t in place ]] end
--
-- Our 5.1 syntax backport makes that probe pass, so the stubs win. For
-- GetTable and Reclaim that is harmless -- both are pure recycling hints. Erase
-- is not: callers hand it a live table and expect its keys gone. The stub takes
-- no parameter at all, so every `compost:Erase(t)` that clears in place (rather
-- than assigning the returned table) silently stops clearing.
--
-- Puppeteer is the reported casualty. PTUnit:ClearAuras erases its
-- TrackedDebuffTypes, AfflictedDebuffTypes and aura-ID sets in place, so a
-- dispel type never leaves the set once applied: a cursed raid member keeps its
-- debuff colour after the curse is cleaned, and HasDebuffID answers true for
-- auras that are long gone. It takes a second addon shipping the newer Compost
-- to show up, because AceLibrary arbitrates on the highest $Revision and
-- ignores load order -- so a lone Puppeteer keeps its own working rev 11579.
--
-- Restore an in-place Erase matching the pre-stub body. Probed by behavior
-- rather than by revision or addon name, so a Compost that already clears is
-- left alone.
--
-- Re-checked on every ADDON_LOADED rather than once, because AceLibrary can
-- upgrade a library at any point during load -- including from an addon loaded
-- on demand mid-session -- and an upgrade installs the new copy's functions
-- over ours. We cannot simply wait for a late event either, since this addon
-- loads first and AceLibrary does not exist yet at that point.
--
-- Repeating is cheap because the verdict is cached against the function that
-- earned it: a check settles to two identity comparisons once the live Erase
-- has been either vetted or replaced, and only an Erase we have never seen
-- costs a probe. The probe table is reused across checks (refilled each time,
-- so leftover state cannot skew the answer) rather than built per check.
local compostProbe = {}
local vettedErase
local function EraseInPlace(self, t)
if type(t) ~= "table" then return end
setmetatable(t, nil)
for k in pairs(t) do
t[k] = nil
end
table.setn(t, 0)
return t
end
local function EnsureCompostErasesInPlace()
if not (AceLibrary and AceLibrary.HasInstance
and AceLibrary:HasInstance("Compost-2.0")) then
return
end
local compost = AceLibrary("Compost-2.0")
if compost.Erase == EraseInPlace or compost.Erase == vettedErase then
return
end
compostProbe[1] = "array"
compostProbe.key = "hash"
compost:Erase(compostProbe)
if next(compostProbe) == nil then
vettedErase = compost.Erase -- clears in place; leave it alone
return
end
compost.Erase = EraseInPlace
end
EventRegistry:RegisterFrameEventAndCallback("ADDON_LOADED",
EnsureCompostErasesInPlace)
EventRegistry:RegisterFrameEventAndCallback("PLAYER_LOGIN",
EnsureCompostErasesInPlace)
EnsureCompostErasesInPlace()
-- Some addons carry a copy of Blizzard's own color definitions -- a plain
-- `NORMAL_FONT_COLOR = {r=1.0, g=0.82, b=0}` and its friends -- and assign
-- them at file scope. That takes ColorMixin off those globals for every addon
-- that loads later, which is what breaks pfUI's who list when it calls
-- `NORMAL_FONT_COLOR:GetRGB()` (brues-code/pfUI#56).
--
-- Re-apply after each addon loads, so a clobbered color is repaired before the
-- next addon reads it. This also covers an addon loaded on demand mid-session.
-- The pass mixes the methods back into the table that is there, and costs one
-- lookup per color when nothing is broken.
EventRegistry:RegisterFrameEventAndCallback("ADDON_LOADED",
CAPI_ApplyStandardColorGlobals)
EventRegistry:RegisterFrameEventAndCallback("PLAYER_LOGIN",
CAPI_ApplyStandardColorGlobals)
+82 -55
View File
@@ -14,24 +14,61 @@
-- * FindAuraByName(auraName, unit, filter)
-- * UnpackAuraData(auraData)
--
-- Iteration batches through C_UnitAuras.GetAuraSlots + a by-slot fetch
-- (continuation tokens), exactly as retail's does: one enumeration per batch,
-- then a direct fetch per aura -- linear in the aura count. The by-index getters
-- (GetAuraDataByIndex / UnitAura) re-walk the aura array from slot 0 on every
-- call, so iterating by index is quadratic; don't go back to it here.
--
-- Two deliberate differences from that source:
-- 1. Retail's ForEachAura/FindAura batch through GetAuraSlots / GetAuraDataBySlot
-- (continuation tokens) -- 1.12 has no slot-batching API, so the bodies here
-- iterate by index (1, 2, ...) until the accessor returns no aura. Same public
-- contract, different internals.
-- 2. The non-packed ForEachAura and FindAura source each aura from the positional
-- C_UnitAuras.UnitAura (no table), so both are zero-allocation here -- FindAura
-- in particular, where the retail version allocates a table per aura scanned.
-- 1. Retail's helpers receive GetAuraSlots' return list as varargs
-- (ForEachAuraHelper / FindAuraRecurse). Lua 5.0 builds an `arg` table for
-- EVERY vararg call, so that shape allocates one table per batch here --
-- exactly the per-frame garbage this library exists to avoid. We use
-- GetAuraSlots' fill-a-table form instead (a table as its 5th argument,
-- a ClassicAPI extension): the slot ids land in a reusable buffer and the
-- call returns `continuationToken, n`. No vararg frame anywhere.
-- 2. The non-packed ForEachAura and FindAura fetch each aura through the
-- positional C_UnitAuras.UnitAuraBySlot (no table), the way retail's
-- AuraUtil did before it retired the positional by-slot accessor. So both
-- are zero-allocation here -- FindAura in particular, where the current
-- retail version allocates a table per aura scanned.
--
-- The comparator / dispel-priority / border helpers (DefaultAuraCompare,
-- UnitFrameDebuffComparator, ShouldDisplayDebuff, SetAuraBorder*, IsPriorityDebuff)
-- are NOT ported: they depend on isPriorityAura / auraInstanceID / the aura
-- visualization pipeline / secure-call machinery, none of which exist on 1.12.
local UnitAura = C_UnitAuras.UnitAura
local GetAuraSlots = C_UnitAuras.GetAuraSlots
local GetAuraDataBySlot = C_UnitAuras.GetAuraDataBySlot
local UnitAuraBySlot = C_UnitAuras.UnitAuraBySlot
AuraUtil = AuraUtil or {};
-- Slot-id buffers for GetAuraSlots' fill form, one per nesting level: a `func`
-- or `predicate` that itself iterates another unit's auras gets its own buffer
-- instead of clobbering the outer scan's. Costs one table per nesting level for
-- the session (retail's varargs are re-entrant for free; ours must be explicit).
-- An error thrown out of `func` skips the release and leaves `depth` one higher
-- for the rest of the session -- harmless: later scans use the next buffer up,
-- allocated once.
local slotBufs = {};
local depth = 0;
local function AcquireSlotBuf()
depth = depth + 1;
local buf = slotBufs[depth];
if not buf then
buf = {};
slotBufs[depth] = buf;
end
return buf;
end
local function ReleaseSlotBuf()
depth = depth - 1;
end
-- Returns the AuraData table's fields in the classic `UnitAura` positional order,
-- for code written against that shape. Retail appends `unpack(auraData.points)`;
-- vanilla auras carry no points, so the tuple ends at timeMod (15 values). Field
@@ -58,66 +95,56 @@ function AuraUtil.UnpackAuraData(auraData)
auraData.timeMod;
end
-- Calls `func` for each aura on `unit` matching `filter`, in index order, until
-- `func` returns a truthy value or the auras run out. Non-packed (default) passes
-- the 15 positional `UnitAura` values (no table); `usePackedAura` passes the
-- AuraData table from GetAuraDataByIndex. `batchSize <= 0` does nothing; any other
-- value (or nil) visits every matching aura -- `batchSize` is retail's slot-batch
-- hint, not a cap on how many auras are visited.
-- Calls `func` for each aura on `unit` matching `filter`, in order, until `func`
-- returns a truthy value or the auras run out. Non-packed (default) passes the 15
-- positional `UnitAura` values (no table); `usePackedAura` passes the AuraData
-- table. `batchSize` is the number of slots fetched per GetAuraSlots call (nil =
-- all at once); it never caps how many auras are visited. `batchSize <= 0` does
-- nothing.
function AuraUtil.ForEachAura(unit, filter, batchSize, func, usePackedAura)
if batchSize and batchSize <= 0 then
return;
end
local index = 1;
while true do
local done;
if usePackedAura then
local auraData = C_UnitAuras.GetAuraDataByIndex(unit, index, filter);
if auraData == nil then
local buf = AcquireSlotBuf();
local continuationToken, n;
repeat
continuationToken, n = GetAuraSlots(unit, filter, batchSize, continuationToken, buf);
for i = 1, n do
local done;
if usePackedAura then
done = func(GetAuraDataBySlot(unit, buf[i]));
else
done = func(UnitAuraBySlot(unit, buf[i]));
end
if done then
ReleaseSlotBuf();
return;
end
done = func(auraData);
else
local name, icon, count, dispelType, duration, expirationTime, source,
isStealable, nameplateShowPersonal, spellId, canApplyAura,
isBossDebuff, castByPlayer, nameplateShowAll, timeMod =
UnitAura(unit, index, filter);
if name == nil then
return;
end
done = func(name, icon, count, dispelType, duration, expirationTime,
source, isStealable, nameplateShowPersonal, spellId, canApplyAura,
isBossDebuff, castByPlayer, nameplateShowAll, timeMod);
end
if done then
return;
end
index = index + 1;
end
end
-- Recursive worker: `...` is the current aura's positional values (nil when the
-- index is past the last aura). Tail-recurses to the next index. Lua 5.0 has
-- proper tail calls, so depth is constant regardless of aura count.
local function FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3, ...)
if ... == nil then
return nil; -- past the last aura -> not found
end
if predicate(arg1, arg2, arg3, ...) then
return ...;
end
index = index + 1;
return FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3,
UnitAura(unit, index, filter));
until continuationToken == nil;
ReleaseSlotBuf();
end
-- Returns the positional values of the first aura for which `predicate` returns
-- truthy, or nil. `predicate` receives up to three caller args followed by the
-- aura's positional values: predicate(arg1, arg2, arg3, name, icon, ...). Sources
-- auras from the positional accessor, so no table is allocated while scanning.
-- auras from the positional by-slot accessor, so no table is allocated while
-- scanning.
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
return FindAuraRecurse(predicate, unit, filter, 1, arg1, arg2, arg3,
UnitAura(unit, 1, filter));
local buf = AcquireSlotBuf();
local continuationToken, n;
repeat
continuationToken, n = GetAuraSlots(unit, filter, nil, continuationToken, buf);
for i = 1, n do
local slot = buf[i];
if predicate(arg1, arg2, arg3, UnitAuraBySlot(unit, slot)) then
ReleaseSlotBuf();
return UnitAuraBySlot(unit, slot);
end
end
until continuationToken == nil;
ReleaseSlotBuf();
return nil;
end
-- Returns the positional values of the first aura whose name matches `auraName`,
+80 -9
View File
@@ -81,18 +81,89 @@ function ColorMixin:WrapTextInColorTableCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
end
for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
-- Publishes the standard color globals (NORMAL_FONT_COLOR and friends) as
-- ColorMixin objects, the way Blizzard_SharedXMLBase/Color.lua does.
--
-- Runs at load and again after every addon loads (see Util/AddOnCompat.lua).
-- An addon that carries a copy of Blizzard's own definition -- a plain
-- `NORMAL_FONT_COLOR = {r=1.0, g=0.82, b=0}` -- assigns it over ours at file
-- scope, and every addon that loads after it then finds a color with no
-- methods. A later pass mixes ColorMixin INTO the table that is there instead
-- of replacing it, so the values that addon chose stay, and each reference it
-- gave out gets the methods too.
--
-- Only a color that is absent or has lost its methods is touched. A tag that
-- holds something other than a color table belongs to some other addon, so it
-- is left alone. A `<TAG>_CODE` string that is already set is never rewritten:
-- FrameXML writes those together with the values they go with.
local standardColors
-- True for a table that carries r/g/b numbers.
local function HasRGB(value)
return type(value) == "table"
and type(value.r) == "number"
and type(value.g) == "number"
and type(value.b) == "number"
end
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
-- True for a color table that still needs the mixin.
local function IsPlainColorTable(value)
return HasRGB(value) and not value.GetRGB
end
-- ITEM_QUALITY_COLORS is the same story with one more level: FrameXML builds
-- each entry as a plain {r, g, b, hex} table, and the `color` object on it is
-- ours. An addon that carries its own copy of the table takes those objects
-- with it, so repair the entries on the same pass. Keys run from -1, so this
-- walks the table with pairs.
local function ApplyItemQualityColors()
if type(ITEM_QUALITY_COLORS) ~= "table" then return end
for _, quality in pairs(ITEM_QUALITY_COLORS) do
if type(quality) == "table" then
if IsPlainColorTable(quality.color) then
quality.color = Mixin(quality.color, ColorMixin)
quality.color.a = quality.color.a or 1
elseif quality.color == nil and HasRGB(quality) then
quality.color = CreateColor(quality.r, quality.g, quality.b, 1)
end
end
end
end
function CAPI_ApplyStandardColorGlobals()
-- `C_UIColor.GetColors()` makes a color object for each of its 187 rows,
-- so keep the values from the first call. Each later pass is then one
-- lookup per tag.
if not standardColors then
standardColors = {}
for _, dbColor in ipairs(C_UIColor.GetColors()) do
table.insert(standardColors, {
tag = dbColor.baseTag,
r = dbColor.color.r,
g = dbColor.color.g,
b = dbColor.color.b,
a = dbColor.color.a,
})
end
end
for _, entry in ipairs(standardColors) do
local current = _G[entry.tag]
local color
if current == nil then
color = CreateColor(entry.r, entry.g, entry.b, entry.a)
_G[entry.tag] = color
elseif IsPlainColorTable(current) then
color = Mixin(current, ColorMixin)
color.a = color.a or 1
end
if color and _G[entry.tag.."_CODE"] == nil then
_G[entry.tag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
ApplyItemQualityColors()
end
CAPI_ApplyStandardColorGlobals()
+8 -2
View File
@@ -3,8 +3,8 @@
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
-- native in 1.12.
-- - RegisterFrameForUnitEvents (no RegisterUnitEvent in 1.12), and the
-- scale/layout/secure/EventRegistry helpers (rely on APIs absent here).
-- - the scale/layout/secure/EventRegistry helpers (rely on APIs absent
-- here).
FrameUtil = FrameUtil or {};
@@ -14,6 +14,12 @@ function FrameUtil.RegisterFrameForEvents(frame, events)
end
end
function FrameUtil.RegisterFrameForUnitEvents(frame, events, ...)
for i, event in ipairs(events) do
frame:RegisterUnitEvent(event, unpack(arg));
end
end
function FrameUtil.UnregisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
@@ -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();
+121 -9
View File
@@ -346,6 +346,14 @@ CONDITIONS.worn = CONDITIONS.equipped;
local warned = {};
-- Keywords met during the current `ParseOptions` run that this parser does
-- not own. A condition we cannot evaluate means the options were written for
-- another macro parser's dialect, so the fact is recorded on the parsed
-- clause set rather than reported here. The caller decides what to do with
-- it: a command the player ran names the keyword, and the macro-display scan
-- leaves the macro alone.
local parseUnknowns;
-- piece -> { pred = fn, neg = bool, args = { n, [i] } or nil }
local function ParseCondition(piece)
local neg = false;
@@ -368,10 +376,11 @@ local function ParseCondition(piece)
local pred = CONDITIONS[keyword];
if not pred then
if not warned[keyword] then
warned[keyword] = true;
print("SecureCmdOptionParse: unknown condition '" .. keyword .. "'");
if not parseUnknowns then
parseUnknowns = { n = 0 };
end
parseUnknowns.n = parseUnknowns.n + 1;
parseUnknowns[parseUnknowns.n] = keyword;
pred = AlwaysFalse;
neg = false; -- an unknown keyword is a hard false
end
@@ -413,8 +422,10 @@ local function ParseClause(text)
return { value = Trim(value), groups = groups };
end
-- options -> { n, [i] = clause }. Splits on ';' at bracket depth 0.
-- options -> { n, [i] = clause, unknown = { n, [i] = keyword } or nil }.
-- Splits on ';' at bracket depth 0.
local function ParseOptions(options)
parseUnknowns = nil;
local clauses = { n = 0 };
local len = strlen(options);
local pos = 1;
@@ -436,6 +447,8 @@ local function ParseOptions(options)
clauses[clauses.n] = ParseClause(strsub(options, pos, i - 1));
pos = i + 1;
end
clauses.unknown = parseUnknowns;
parseUnknowns = nil;
return clauses;
end
@@ -445,8 +458,80 @@ end
local cache = {};
-- `@unit` takes a character name as well as a token (`[target=Feral]`), and a
-- name has to be searched for in the object manager. That search is the one
-- expensive step here, so a pass remembers what it already found. The table
-- is only built once a pass meets a name, and the next pass drops it: a token
-- kept across passes could name a different unit by then.
local passNames;
local function TokenForName(name)
if passNames then
local cached = passNames[name];
if cached ~= nil then
return cached or nil; -- false records "nobody there"
end
else
passNames = {};
end
local token = UnitTokenFromName(name);
passNames[name] = token or false;
return token;
end
-- A `@unit` piece as something the engine's unit functions accept, with no
-- question asked about whether a unit is there. `IsUnitToken` tells a token
-- from a name by asking the engine's own resolver, and it does not go looking
-- for the unit -- which is what the conditions do next anyway.
local function TokenForTarget(target)
if IsUnitToken(target) then
return target;
end
return TokenForName(target);
end
-- Whether a `@unit` piece has a unit behind it right now. The pcall tells a
-- token from a name in one step: the engine raises for a string that names no
-- token, which is exactly the case to hand to the by-name search. That also
-- keeps a typo failing the group instead of the caller.
local function TargetExists(target)
local ok, exists = pcall(UnitExists, target);
if ok then
return exists and true or false;
end
local token = TokenForName(target);
return token ~= nil and UnitExists(token) and true or false;
end
-- Targets that do not name a unit. `none` clears the target and `cursor`
-- names a world position, so neither can be tested with UnitExists and both
-- always pass the existence gate below.
local NON_UNIT_TARGETS = { none = true, cursor = true };
local function GroupPasses(group)
local target = group.target or "target";
local target = group.target;
local named = target and not NON_UNIT_TARGETS[strlower(target)];
if group.n == 0 then
-- A group that only names a unit (`[@mouseover]`) passes only while
-- that unit exists, so `[@mouseover][] Spell` falls through to the
-- next group when nothing is moused over. A bare `[]` and the
-- non-unit targets always pass. Groups with conditions leave
-- existence to them (`[@focus,noexists]` still works).
if not named then
return true;
end
return TargetExists(target);
end
if named then
-- The conditions ask the engine about this unit, so a name has to
-- become a token first. Existence is theirs to test, so it is not
-- asked for here.
target = TokenForTarget(target);
if not target then
return false;
end
end
target = target or "target";
for i = 1, group.n do
local cond = group[i];
local ok = cond.pred(target, cond.args);
@@ -460,29 +545,56 @@ local function GroupPasses(group)
return true;
end
function SecureCmdOptionParse(options)
-- Returns the matched value, the passing group's target, and the name of the
-- first condition the options use that this parser does not own.
--
-- That third value marks the options as another macro parser's dialect. A
-- caller that scans macro bodies it did not write passes `quiet` and reads it,
-- so it can leave such a macro alone instead of naming a keyword the player
-- has no reason to fix.
function SecureCmdOptionParse(options, quiet)
if type(options) ~= "string" then
error("Usage: SecureCmdOptionParse(\"options\")");
return nil; -- WoW's error() may not unwind
end
-- Start of a pass: drop what the last one resolved by name. Nothing is
-- allocated unless a name was actually met, so the all-token case (the
-- common one, polled several times a second) costs nothing here.
if passNames then
passNames = nil;
end
local clauses = cache[options];
if not clauses then
clauses = ParseOptions(options);
cache[options] = clauses;
end
-- Name each keyword once, for a caller that wants to hear about it.
local unknown = clauses.unknown;
if unknown and not quiet then
for i = 1, unknown.n do
local keyword = unknown[i];
if not warned[keyword] then
warned[keyword] = true;
print("SecureCmdOptionParse: unknown condition '" .. keyword .. "'");
end
end
end
local foreign = unknown and unknown[1] or nil;
for i = 1, clauses.n do
local clause = clauses[i];
local groups = clause.groups;
if groups.n == 0 then
return clause.value;
return clause.value, nil, foreign;
end
for j = 1, groups.n do
if GroupPasses(groups[j]) then
return clause.value, groups[j].target;
return clause.value, groups[j].target, foreign;
end
end
end
return nil;
return nil, nil, foreign;
end
@@ -6,20 +6,559 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
SlashCmdList[name] = callback;
end
-- `/cast` and `/use` (the FrameXML ChatFrame.lua shape): conditionals through
-- SecureCmdOptionParse, items before spells, `[@unit]` cast targets.
-- A bare number is an inventory slot only when it is one (1..19); any other
-- number is a spellID (`/cast 5019`), which the spell resolver already
-- accepts anywhere a spell name goes.
local INVSLOT_FIRST, INVSLOT_LAST = 1, 19;
function SecureCmdItemParse(item)
if ( not item ) then
return nil, nil, nil;
end
local bag, slot = string.match(item, "^(%d+)%s+(%d+)$");
if ( not bag ) then
slot = string.match(item, "^(%d+)$");
if ( slot ) then
local n = tonumber(slot);
if ( n < INVSLOT_FIRST or n > INVSLOT_LAST ) then
slot = nil;
end
end
end
if ( bag ) then
item = GetContainerItemLink(bag, slot);
elseif ( slot ) then
item = GetInventoryItemLink("player", slot);
end
return item, bag, slot;
end
-- A number with no bag or inventory slot behind it. `/cast 5019` names a
-- spell, so the item lookup must never see it: an item reference reads a
-- number as an itemID, and a carried item with that ID would be used in
-- place of the spell.
local function IsBareNumber(text)
return string.match(text, "^%d+$") ~= nil;
end
-- Targets that name no unit. `@cursor` names the position under the mouse,
-- which a ground-target spell or item is placed at; `@none` asks for no unit
-- at all, so it becomes the untargeted form of whatever verb ran.
local NON_UNIT_TARGETS = { none = true, cursor = true };
-- `@unit` and `target=unit` accept a character name as well as a unit token
-- (`[target=Feral]`), and the engine's unit functions take only tokens.
-- Resolve once here so every command below hands them something they accept.
-- Returns nil for a name with nobody around to match it.
local function SecureCmdTargetUnit(target)
if ( not target or target == "" or IsUnitToken(target) ) then
return target;
end
-- Keep the non-unit targets out of the by-name search, which matches on
-- the START of a name: `@none` would happily find a nearby Nonek.
if ( NON_UNIT_TARGETS[strlower(target)] ) then
return nil;
end
return UnitTokenFromName(target);
end
-- One target normalization for every verb below: an empty target and `@none`
-- become nil (no unit), a non-unit target keeps its own name, and anything
-- else has to resolve to a token. Returns `false` for a name with nobody
-- around to match it, which the callers treat as "do nothing".
local function SecureCmdNormalizeTarget(target)
if ( not target or target == "" ) then
return nil;
end
local lower = strlower(target);
if ( lower == "none" ) then
return nil;
end
if ( NON_UNIT_TARGETS[lower] ) then
return lower;
end
return SecureCmdTargetUnit(target) or false;
end
-- The item a `/use` line names, in the form the three calls below take. An
-- explicit bag or inventory slot becomes a location, which names ONE item:
-- looking the item up by name again would take the first match anywhere, and
-- with two stacks of the same thing that is the wrong one.
local function SecureCmdItemLocation(name, bag, slot)
if ( bag ) then
return { bagID = tonumber(bag), slotIndex = tonumber(slot) };
end
if ( slot ) then
return { equipmentSlotIndex = tonumber(slot) };
end
return name;
end
function SecureCmdUseItem(name, bag, slot, target)
local item = SecureCmdItemLocation(name, bag, slot);
if ( not item ) then
return;
end
if ( target == "cursor" ) then
-- `@cursor` names a world position, so a ground-target item places
-- its effect there. An item with no ground effect is used normally.
C_Item.UseAtCursor(item);
elseif ( target == "player" ) then
-- `@player` drops a ground-target item at your own feet. Only your
-- own position is offered this way; aiming one at another unit is
-- not something you can do by hand either. An item with no ground
-- effect is used on you, as before.
C_Item.UseAtUnit(item, "player");
else
-- Every form goes through one call, so a slot is aimed at `@unit`
-- just like a name is. `/use` also always means use: clicking a bag
-- slot sells the item at a merchant and repairs it under the repair
-- cursor, which a typed command should never do.
C_Item.UseItemByName(item, target);
end
end
local function SecureCmdCast(msg)
local action, target = SecureCmdOptionParse(msg);
if ( not action or action == "" ) then
return;
end
-- `!Name` asks for the spell to be started but never turned off, for the
-- abilities that toggle: auto-repeat (Shoot, Auto Shot) and the self-auras
-- (stances, aspects, seals, forms, tracking). Strip the prefix here and
-- cast through `CastSpellNoToggle`, which asks the engine whether the
-- ability is already up before it casts.
local noToggle;
if ( string.sub(action, 1, 1) == "!" ) then
noToggle = true;
action = string.sub(action, 2);
if ( action == "" ) then
return;
end
end
target = SecureCmdNormalizeTarget(target);
if ( target == false ) then
return; -- a named unit with nobody around to match it
end
local name, bag, slot = SecureCmdItemParse(action);
if ( slot or (name and not IsBareNumber(action) and C_Item.GetItemCount(name) > 0) ) then
SecureCmdUseItem(name, bag, slot, target);
elseif ( target == "cursor" ) then
C_Spell.CastAtCursor(action);
elseif ( not target or target == "target" ) then
if ( noToggle ) then
CastSpellNoToggle(action);
else
CastSpellByName(action);
end
elseif ( noToggle ) then
-- Same unit rules as below: your own feet take a ground-target
-- spell, another unit does not.
CastSpellNoToggle(action, target, target == "player");
elseif ( target == "player" ) then
-- A ground-target spell lands at your own feet; a normal one is
-- cast on you.
C_Spell.CastAtUnit(action, target, true);
else
-- Any other unit has the spell cast on it, but a ground-target one
-- is not placed there: that is aim you do not have by hand. The
-- reticle comes up as usual.
C_Spell.CastAtUnit(action, target, false);
end
end
SlashCmdList["CAST"] = SecureCmdCast;
SlashCmdList["USE"] = SecureCmdCast;
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
target = SecureCmdTargetUnit(target);
if ( target ) then
FocusUnit(target);
end
end
end
end
SlashCmdList["CLEARFOCUS"] = function(msg)
ClearFocus()
if ( SecureCmdOptionParse(msg) ) then
ClearFocus();
end
end
SlashCmdList["STARTATTACK"] = function(msg)
local action, target = SecureCmdOptionParse(msg);
if ( action ) then
if ( not target or target == "target" ) then
target = action;
end
target = SecureCmdTargetUnit(target);
if ( target ) then
StartAttack(target);
end
end
end
SlashCmdList["STOPATTACK"] = function(msg)
if ( SecureCmdOptionParse(msg) ) then
StopAttack();
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
-- Runs the conditions and calls `fn()` when a clause matched. The shape of
-- every command whose only argument is its conditions.
local function SecureCmdGated(msg, fn)
if ( SecureCmdOptionParse(msg) ) then
fn();
end
end
-- Strips a trailing "(Rank N)" so a rank-qualified name still matches.
local function PlainSpellName(spell)
local plain = string.match(spell, "^([^(]+)%(");
if ( plain ) then
return strtrim(plain);
end
return spell;
end
-- ---------------------------------------------------------------------
-- Targeting
--
-- Each verb has one entry that takes a unit token and another that takes a
-- character name, and each errors on the other kind. `SecureCmdOptionParse`
-- hands back either, so pick with `IsUnitToken`.
-- ---------------------------------------------------------------------
local function SecureCmdTarget(msg, exactMatch)
local action, target = SecureCmdOptionParse(msg);
if ( not action ) then
return;
end
if ( target ) then
local lower = strlower(target);
if ( lower == "none" ) then
ClearTarget(); -- `@none` clears the target
return;
end
if ( NON_UNIT_TARGETS[lower] ) then
return; -- `@cursor` names no unit to target
end
end
if ( not target or target == "target" ) then
target = action;
end
if ( not target or target == "" ) then
return;
end
if ( IsUnitToken(target) ) then
TargetUnit(target);
else
TargetByName(target, exactMatch);
end
end
SlashCmdList["TARGET"] = function(msg)
SecureCmdTarget(msg);
end
SlashCmdList["TARGET_EXACT"] = function(msg)
SecureCmdTarget(msg, 1);
end
SlashCmdList["ASSIST"] = function(msg)
if ( msg == "" ) then
AssistUnit("target");
return;
end
local action, target = SecureCmdOptionParse(msg);
if ( not action ) then
return;
end
if ( target and NON_UNIT_TARGETS[strlower(target)] ) then
return; -- `@none` and `@cursor` name no unit to assist
end
-- An explicit `@target` leaves the trailing name in charge, the same way
-- `/target` and `/follow` read it.
if ( not target or target == "target" ) then
target = action;
end
if ( target == "" ) then
AssistUnit("target");
elseif ( IsUnitToken(target) ) then
AssistUnit(target);
else
AssistByName(target);
end
end
SlashCmdList["FOLLOW"] = function(msg)
if ( msg == "" ) then
FollowUnit("target");
return;
end
local action, target = SecureCmdOptionParse(msg);
if ( not action ) then
return;
end
if ( target and NON_UNIT_TARGETS[strlower(target)] ) then
return; -- `@none` and `@cursor` name no unit to follow
end
if ( not target or target == "target" ) then
target = action;
end
if ( target == "" ) then
FollowUnit("target");
elseif ( IsUnitToken(target) ) then
FollowUnit(target);
else
FollowByName(target);
end
end
SlashCmdList["CLEARTARGET"] = function(msg)
SecureCmdGated(msg, ClearTarget);
end
SlashCmdList["TARGET_LAST_TARGET"] = function(msg)
SecureCmdGated(msg, TargetLastTarget);
end
-- The cycling selectors take the parsed value as their "reverse" flag, so
-- `/targetenemy [mod:shift] 1` steps backwards while shift is held. A clause
-- that matched with no value has to arrive as nil: an empty string is a true
-- boolean in Lua, which would make every bare `/targetenemy` step backwards.
local function SecureCmdTargetCycle(msg, fn)
local action = SecureCmdOptionParse(msg);
if ( action ) then
fn(action ~= "" and action or nil);
end
end
SlashCmdList["TARGET_LAST_ENEMY"] = function(msg)
SecureCmdTargetCycle(msg, TargetLastEnemy);
end
SlashCmdList["TARGET_NEAREST_ENEMY"] = function(msg)
SecureCmdTargetCycle(msg, TargetNearestEnemy);
end
SlashCmdList["TARGET_NEAREST_ENEMY_PLAYER"] = function(msg)
SecureCmdTargetCycle(msg, TargetNearestEnemyPlayer);
end
SlashCmdList["TARGET_NEAREST_FRIEND"] = function(msg)
SecureCmdTargetCycle(msg, TargetNearestFriend);
end
SlashCmdList["TARGET_NEAREST_FRIEND_PLAYER"] = function(msg)
SecureCmdTargetCycle(msg, TargetNearestFriendPlayer);
end
SlashCmdList["TARGET_NEAREST_PARTY"] = function(msg)
SecureCmdTargetCycle(msg, TargetNearestPartyMember);
end
SlashCmdList["TARGET_NEAREST_RAID"] = function(msg)
SecureCmdTargetCycle(msg, TargetNearestRaidMember);
end
-- ---------------------------------------------------------------------
-- Casting and player state
-- ---------------------------------------------------------------------
SlashCmdList["STOPCASTING"] = function(msg)
SecureCmdGated(msg, SpellStopCasting);
end
SlashCmdList["CANCELFORM"] = function(msg)
SecureCmdGated(msg, CancelShapeshiftForm);
end
SlashCmdList["DISMOUNT"] = function(msg)
SecureCmdGated(msg, Dismount);
end
SlashCmdList["CANCELAURA"] = function(msg)
local spell = SecureCmdOptionParse(msg);
if ( spell and spell ~= "" ) then
CancelSpellByName(PlainSpellName(spell));
end
end
-- ---------------------------------------------------------------------
-- Equipment
-- ---------------------------------------------------------------------
SlashCmdList["EQUIP"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( action and action ~= "" ) then
local item = SecureCmdItemParse(action);
if ( item ) then
C_Item.EquipItemByName(item);
end
end
end
SlashCmdList["EQUIP_TO_SLOT"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( not action ) then
return;
end
local slot, name = string.match(action, "^(%d+)%s+(.+)");
slot = tonumber(slot);
if ( not slot or slot < INVSLOT_FIRST or slot > INVSLOT_LAST ) then
return;
end
local item = SecureCmdItemParse(name);
if ( item ) then
C_Item.EquipItemByName(item, slot);
end
end
-- ---------------------------------------------------------------------
-- Action bars
--
-- `ChangeActionBarPage` takes no argument here. It announces the page in
-- `CURRENT_ACTIONBAR_PAGE`, so set that first.
-- ---------------------------------------------------------------------
local function SetActionBarPage(page)
if ( page and page >= 1 and page <= NUM_ACTIONBAR_PAGES ) then
CURRENT_ACTIONBAR_PAGE = page;
ChangeActionBarPage();
end
end
SlashCmdList["CHANGEACTIONBAR"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( action and action ~= "" ) then
SetActionBarPage(tonumber(action));
end
end
SlashCmdList["SWAPACTIONBAR"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( not action ) then
return;
end
local a, b = string.match(action, "(%d+)%s+(%d+)");
a, b = tonumber(a), tonumber(b);
if ( a and b ) then
if ( CURRENT_ACTIONBAR_PAGE == a ) then
SetActionBarPage(b);
else
SetActionBarPage(a);
end
end
end
-- ---------------------------------------------------------------------
-- Pet
--
-- `PetAttack` takes no target here: it always sends the pet at the
-- player's current target. Conditions still decide whether it fires, but
-- `/petattack [@mouseover]` cannot aim at the mouseover.
-- ---------------------------------------------------------------------
SlashCmdList["PET_ATTACK"] = function(msg)
SecureCmdGated(msg, PetAttack);
end
SlashCmdList["PET_FOLLOW"] = function(msg)
SecureCmdGated(msg, PetFollow);
end
SlashCmdList["PET_STAY"] = function(msg)
SecureCmdGated(msg, PetWait);
end
SlashCmdList["PET_PASSIVE"] = function(msg)
SecureCmdGated(msg, PetPassiveMode);
end
SlashCmdList["PET_DEFENSIVE"] = function(msg)
SecureCmdGated(msg, PetDefensiveMode);
end
SlashCmdList["PET_AGGRESSIVE"] = function(msg)
SecureCmdGated(msg, PetAggressiveMode);
end
-- Autocast is a spellbook-slot toggle here, so a name has to be resolved
-- against the pet book, and on/off read the current state and toggle only
-- when it differs.
local function PetSpellSlot(name)
local numPetSpells = HasPetSpells();
if ( not numPetSpells ) then
return nil;
end
name = strlower(name);
for i = 1, numPetSpells do
local spell = GetSpellName(i, BOOKTYPE_PET);
if ( spell and strlower(spell) == name ) then
return i;
end
end
end
local function SecureCmdPetAutocast(msg, enable)
local spell = SecureCmdOptionParse(msg);
if ( not spell or spell == "" ) then
return;
end
local slot = PetSpellSlot(PlainSpellName(spell));
if ( not slot ) then
return;
end
if ( enable == nil ) then
ToggleSpellAutocast(slot, BOOKTYPE_PET);
return;
end
local allowed, enabled = GetSpellAutocast(slot, BOOKTYPE_PET);
if ( allowed and ((enabled and true or false) ~= enable) ) then
ToggleSpellAutocast(slot, BOOKTYPE_PET);
end
end
SlashCmdList["PET_AUTOCASTON"] = function(msg)
SecureCmdPetAutocast(msg, true);
end
SlashCmdList["PET_AUTOCASTOFF"] = function(msg)
SecureCmdPetAutocast(msg, false);
end
SlashCmdList["PET_AUTOCASTTOGGLE"] = function(msg)
SecureCmdPetAutocast(msg, nil);
end
+34
View File
@@ -92,3 +92,37 @@ function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
, yOffset or 0
);
end
function CreateAtlasMarkup(atlasName, width, height, offsetX, offsetY)
return string.format("|A:%s:%d:%d:%d:%d|a",
atlasName
, height or 0
, width or 0
, offsetX or 0
, offsetY or 0
);
end
-- Raid target markers, published as atlas names.
--
-- All eight markers live in one 4x4 sheet, so naming a single marker means
-- naming a cell inside that sheet — exactly what an atlas is for. Blizzard
-- publishes no atlas names for these, so the names here follow the standalone
-- marker files that other clients ship. An addon written against those files
-- finds the art under the name it already knows.
--
-- Marker N sits at column (N-1) mod 4 and row floor((N-1) / 4), counting from
-- the top left. That is the same cell math the chat marker tokens use.
local RAID_TARGET_SHEET = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
local RAID_TARGET_COLUMNS = 4;
local RAID_TARGET_CELL = 0.25; -- a 64 pixel cell in a 256 pixel sheet
for marker = 1, 8 do
local index = marker - 1;
local left = math.mod(index, RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
local top = math.floor(index / RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
C_Texture.RegisterAtlas("UI-RaidTargetingIcon_"..marker, RAID_TARGET_SHEET,
64, 64,
left, left + RAID_TARGET_CELL,
top, top + RAID_TARGET_CELL);
end
+3 -1
View File
@@ -1,3 +1,5 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
if region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) then
return 1;
end
end
+18
View File
@@ -0,0 +1,18 @@
-- Backport of retail's Blizzard_ObjectAPI/UiMapPoint.lua. A UiMapPoint is the
-- plain struct the C_Map waypoint calls take and return:
-- { uiMapID = number, position = Vector2DMixin, z = number? }
local CreateVector2D = CreateVector2D
UiMapPoint = {};
function UiMapPoint.CreateFromCoordinates(uiMapID, x, y, z)
return { uiMapID = uiMapID, position = CreateVector2D(x, y), z = z };
end
function UiMapPoint.CreateFromVector2D(uiMapID, position, z)
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = z };
end
function UiMapPoint.CreateFromVector3D(uiMapID, position)
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = position.z };
end
+62
View File
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Notizen für %s eintragen:";
SET_NOTE = "Notiz eintragen";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CANCELAURA1 = "/auraabbruch";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/gestaltabbrechen";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/aktionsleistewechsel";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLEARTARGET1 = "/zielaufheben";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/klick";
SLASH_CLICK2 = "/click";
SLASH_DISMOUNT1 = "/absitzen";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/anlegen";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/platzanlegen";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/fokus";
SLASH_FOCUS2 = "/focus";
SLASH_PET_AGGRESSIVE1 = "/baggressiv";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/bangriff";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/bautozauberaus";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/bautozauberan";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/bautozauberanaus";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/bdefensiv";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/bfolgen";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/bpassiv";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/bbleib";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/angriffstart";
SLASH_STARTATTACK2 = "/startattack";
SLASH_STOPATTACK1 = "/angriffstop";
SLASH_STOPATTACK2 = "/stopattack";
SLASH_STOPCASTING1 = "/stoppwirken";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/aktionsleisteumschalten";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/spezifischanvisieren";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/vorherigerfeind";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/vorherigesziel";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/feindanvisieren";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/feindlichenspielerwählen";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/freundanvisieren";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/freundlichenspielerwählen";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/gruppeanvisieren";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/schlachtzuganvisieren";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/benutzen";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
+62
View File
@@ -79,11 +79,73 @@ SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
SLASH_CANCELAURA1 = "/cancelaura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/cancelform";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/changeactionbar";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLEARTARGET1 = "/cleartarget";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_DISMOUNT1 = "/dismount";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/equip";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/equipslot";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
SLASH_PET_AGGRESSIVE1 = "/petaggressive";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/petattack";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/petautocastoff";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/petautocaston";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/petautocasttoggle";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/petdefensive";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/petfollow";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/petpassive";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/petstay";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/startattack";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/stopattack";
SLASH_STOPCASTING1 = "/stopcasting";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/swapactionbar";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/targetexact";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/targetlastenemy";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/targetlasttarget";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/targetparty";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
+62
View File
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Establecer nota para %s:";
SET_NOTE = "Establecer nota";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CANCELAURA1 = "/cancelaraura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/cancelforma";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/cambiarbarraccion";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLEARTARGET1 = "/quitarobjetivo";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/clic";
SLASH_CLICK2 = "/click";
SLASH_DISMOUNT1 = "/desmontar";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/equipar";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/conjequip";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/equipranu";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/enfoque";
SLASH_FOCUS2 = "/focus";
SLASH_PET_AGGRESSIVE1 = "/mascagresiva";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/mascataque";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/nomascotaautohechizo";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/mascotautohechizo";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/alternarmascotautohechizo";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/mascdefensiva";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/mascsigue";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/mascpasiva";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/mascqueda";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/empezarataque";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/pararataque";
SLASH_STOPCASTING1 = "/pararlanzamiento";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/intercambiarbarraccion";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/objetivoexacto";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/ultimoobjetivoenemigo";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/ultimobjetivo";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/objetivoenemigo";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/objetivojugadorenemigo";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/objetivoamistoso";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/objetivojugadoraliado";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/objetivogrupo";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/objetivobanda";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/usar";
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
+62
View File
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Écrire une note pour %s :";
SET_NOTE = "Écrire une note";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CANCELAURA1 = "/annuleaura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/annuleform";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/changebarreaction";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
SLASH_CLEARTARGET1 = "/effacercible";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/cliquer";
SLASH_DISMOUNT1 = "/descendre";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/équiper";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/équipens";
SLASH_EQUIP_TO_SLOT1 = "/equipempl";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focal";
SLASH_PET_AGGRESSIVE1 = "/famagresse";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/famattaque";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/lancautofamoff";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/lancautofamon";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/lancautofamchange";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/famdéfend";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/famsuit";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/fampassif";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/famreste";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/lanceattaque";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/stopattaque";
SLASH_STOPCASTING1 = "/stopsort";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/échangerbarreaction";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/cibleexacte";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/dernierennemi";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/dernièrecible";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/ciblerennemi";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/ciblerjoueurennemi";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/ciblerami";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/ciblerjoueurami";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/ciblergroupe";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/ciblerraid";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/utiliser";
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
+62
View File
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
SET_NOTE = "Добавить заметку";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CANCELAURA1 = "/снятьауру";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/нормоблик";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/сменитьпанель";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_CLEARTARGET1 = "/сбросцели";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/щелчок";
SLASH_DISMOUNT1 = "/спешиться";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/экип";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/надеть";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/фокус";
SLASH_PET_AGGRESSIVE1 = "/питомецагрессивный";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/питомецатака";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/питомецзаклинаниеоткл";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/питомецзаклинаниевкл";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/питомецавтозаклинание";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/питомецзащита";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/питомецзамной";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/питомецпассивный";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/питомецстоять";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/начатьатаку";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/стопатака";
SLASH_STOPCASTING1 = "/стопзаклинание";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/сменитьпанелькоманд";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/цельточно";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/цельпослвраг";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/цельпослцель";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/цельвраг";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/цельдруг";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/цельгруппа";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/цельрейд";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/использовать";
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
+62
View File
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
SET_NOTE = "设置备注";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CANCELAURA1 = "/cancelaura";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/cancelform";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/changeactionbar";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLEARTARGET1 = "/cleartarget";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_DISMOUNT1 = "/dismount";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/equip";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_EQUIP_TO_SLOT1 = "/equipslot";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
SLASH_PET_AGGRESSIVE1 = "/petaggressive";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/petattack";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/petautocastoff";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/petautocaston";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/petautocasttoggle";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/petdefensive";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/petfollow";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/petpassive";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/petstay";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/startattack";
SLASH_STARTATTACK2 = "/startattack";
SLASH_STOPATTACK1 = "/stopattack";
SLASH_STOPATTACK2 = "/stopattack";
SLASH_STOPCASTING1 = "/stopcasting";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/swapactionbar";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/targetexact";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/targetlastenemy";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/targetlasttarget";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/targetparty";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
+62
View File
@@ -78,10 +78,72 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "設定%s的註記:";
SET_NOTE = "設定註記";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CANCELAURA1 = "/取消光環";
SLASH_CANCELAURA2 = "/cancelaura";
SLASH_CANCELFORM1 = "/取消形態";
SLASH_CANCELFORM2 = "/cancelform";
SLASH_CHANGEACTIONBAR1 = "/改變快捷列";
SLASH_CHANGEACTIONBAR2 = "/changeactionbar";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
SLASH_CLEARTARGET1 = "/清除目標";
SLASH_CLEARTARGET2 = "/cleartarget";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/點擊";
SLASH_DISMOUNT1 = "/下馬";
SLASH_DISMOUNT2 = "/dismount";
SLASH_EQUIP1 = "/裝備";
SLASH_EQUIP2 = "/equip";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/裝備套裝";
SLASH_EQUIP_TO_SLOT1 = "/裝備欄位";
SLASH_EQUIP_TO_SLOT2 = "/equipslot";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/專注目標";
SLASH_PET_AGGRESSIVE1 = "/寵物攻擊型";
SLASH_PET_AGGRESSIVE2 = "/petaggressive";
SLASH_PET_ATTACK1 = "/寵物攻擊";
SLASH_PET_ATTACK2 = "/petattack";
SLASH_PET_AUTOCASTOFF1 = "/關閉寵物自動施法";
SLASH_PET_AUTOCASTOFF2 = "/petautocastoff";
SLASH_PET_AUTOCASTON1 = "/開啟寵物自動施法";
SLASH_PET_AUTOCASTON2 = "/petautocaston";
SLASH_PET_AUTOCASTTOGGLE1 = "/切換寵物自動施法";
SLASH_PET_AUTOCASTTOGGLE2 = "/petautocasttoggle";
SLASH_PET_DEFENSIVE1 = "/寵物防禦型";
SLASH_PET_DEFENSIVE2 = "/petdefensive";
SLASH_PET_FOLLOW1 = "/寵物跟隨";
SLASH_PET_FOLLOW2 = "/petfollow";
SLASH_PET_PASSIVE1 = "/寵物被動型";
SLASH_PET_PASSIVE2 = "/petpassive";
SLASH_PET_STAY1 = "/寵物停留";
SLASH_PET_STAY2 = "/petstay";
SLASH_STARTATTACK1 = "/開始攻擊";
SLASH_STARTATTACK2 = "/startattack";
SLASH_STOPATTACK1 = "/停止攻擊";
SLASH_STOPATTACK2 = "/stopattack";
SLASH_STOPCASTING1 = "/停止施法";
SLASH_STOPCASTING2 = "/stopcasting";
SLASH_SWAPACTIONBAR1 = "/切換快捷列";
SLASH_SWAPACTIONBAR2 = "/swapactionbar";
SLASH_TARGET_EXACT1 = "/選定指定目標";
SLASH_TARGET_EXACT2 = "/targetexact";
SLASH_TARGET_LAST_ENEMY1 = "/選定前一敵人";
SLASH_TARGET_LAST_ENEMY2 = "/targetlastenemy";
SLASH_TARGET_LAST_TARGET1 = "/選定前一目標";
SLASH_TARGET_LAST_TARGET2 = "/targetlasttarget";
SLASH_TARGET_NEAREST_ENEMY1 = "/目標敵人";
SLASH_TARGET_NEAREST_ENEMY2 = "/targetenemy";
SLASH_TARGET_NEAREST_ENEMY_PLAYER1 = "/選定敵方玩家";
SLASH_TARGET_NEAREST_ENEMY_PLAYER2 = "/targetenemyplayer";
SLASH_TARGET_NEAREST_FRIEND1 = "/目標友方";
SLASH_TARGET_NEAREST_FRIEND2 = "/targetfriend";
SLASH_TARGET_NEAREST_FRIEND_PLAYER1 = "/選定友方玩家";
SLASH_TARGET_NEAREST_FRIEND_PLAYER2 = "/targetfriendplayer";
SLASH_TARGET_NEAREST_PARTY1 = "/目標隊伍";
SLASH_TARGET_NEAREST_PARTY2 = "/targetparty";
SLASH_TARGET_NEAREST_RAID1 = "/目標團隊";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/使用";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
+15
View File
@@ -2,6 +2,21 @@ cmake_minimum_required(VERSION 3.10)
project(ClassicAPI LANGUAGES CXX RC)
# WoW.exe is x86, so the injected DLL must be x86 too — there is no valid
# 64-bit configure of this project. Fail here rather than let one through:
# a 64-bit build's first visible symptom is a static_assert deep in a
# struct-layout check, which reads as an unrelated code fault. An IDE that
# configures on its own (VS Code's CMake Tools picks an architecture from a
# scanned kit) can flip an existing build directory this way, which is what
# CMakePresets.json's `win32` preset exists to prevent.
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4)
message(FATAL_ERROR
"ClassicAPI builds 32-bit only. This configure uses "
"${CMAKE_SIZEOF_VOID_P}-byte pointers. Delete the build directory, "
"because CMake cannot switch platform in place. Then configure with "
"`cmake --preset win32` or `cmake -B build -A Win32`.")
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
+25
View File
@@ -0,0 +1,25 @@
{
"version": 3,
"cmakeMinimumRequired": {
"major": 3,
"minor": 21,
"patch": 0
},
"configurePresets": [
{
"name": "win32",
"displayName": "Win32 (x86)",
"description": "The only supported configuration: WoW.exe is 32-bit, so the DLL must be 32-bit too. No generator is named, so CMake picks the newest installed Visual Studio, matching the documented command line.",
"binaryDir": "${sourceDir}/build",
"architecture": "Win32"
}
],
"buildPresets": [
{
"name": "release",
"displayName": "Release",
"configurePreset": "win32",
"configuration": "Release"
}
]
}
+74 -40
View File
@@ -21,19 +21,25 @@ 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 = ...`). |
| [Upvalue limit](docs/API.md#upvalue-limit) | A function can use up to 60 upvalues, the outer locals it refers to, matching Lua 5.1. Lua 5.0 stops at 32 and rejects the whole file, so a large handler that reads many file-level locals can be valid 5.1 and still fail to load here. Nothing changes for a function within 32. |
| [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`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| 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). |
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows what the macro does. The icon, tooltip, cooldown, range, usable state and auto-repeat glow all follow the spell or item that the directive resolves to, conditions included. They update as the conditions change. The result goes into the engine field that action-bar addons already read, so they need no code of their own. An addon with its own macro parser can publish its result through `C_Macro.SetMacroDisplay`. It then does not need to replace the action globals. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [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. |
| [Launch switches](docs/API.md#launch) | Options you add to the shortcut that starts the game. `-config <name>` reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. `-gluescript` and `-gluescriptFile` run Lua at the login and character-select screens, every time those screens appear. `-gamescript` and `-gamescriptFile` run it one time, after you enter the world. Each script runs after the interface has loaded, so it can call interface functions and use frames. |
| [Any texture size](docs/API.md#texture-size-and-shape) | Textures load at any size and any shape. Width and height do not need to be powers of two, and there is no fixed upper limit. The only limit is the maximum texture size of your graphics card. This applies to every texture path: `SetTexture`, sprite sheets, inline textures, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one, so small textures cost nothing extra. |
## Full API reference
@@ -47,27 +53,27 @@ reference in **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [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` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [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` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.SetCVarBitfield` |
| [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` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [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` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture: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:SetAtlas`, `texture:GetAtlas`, `texture:ResetTexCoord`, `texture:SetSpriteSheetCell`, `texture:SetDesaturation`, `texture:GetDesaturation`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:RegisterUnitEvent`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [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` |
@@ -76,14 +82,14 @@ reference in **[docs/API.md](docs/API.md)**.
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
@@ -91,19 +97,21 @@ reference in **[docs/API.md](docs/API.md)**.
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [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` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [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` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [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` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -115,9 +123,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.
@@ -133,17 +139,40 @@ the glue state because GlueXML had no way to reach them otherwise.
</details>
<details>
<summary><b>Macros</b> — engine-level parsing extensions</summary>
<summary><b>Macros</b> — forms a macro body accepts</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
[Macros section in the Lua reference](docs/API.md#macros) for details.
The DLL parses these forms, so an action-bar addon needs no code for them.
See the [Macros section in the Lua reference](docs/API.md#macros) for
details.
| Form | What it does |
|------|--------------|
| `/cast <spellID>` | `/cast 5019` casts Shoot if known; macro slot tags correctly for action-bar UI |
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
| `#showtooltip [conditions] Value` | First line of a macro. Sets what the button shows. Bare `#showtooltip` follows the first `/cast` or `/use` line instead. `#show` is the same form. |
| `[conditions]` and `@unit` | Accepted by 38 commands. The slash-command list below names them. |
| `!Name` | Starts an ability but never turns it off. Use it for auto-repeat shots and for the self-buffs (stance, aspect, seal, form, tracking). |
| A line that starts with `#` | A comment. The line never reaches chat. |
| `/cast <spellID>` | For a spell you know, `/cast 5019` casts it by ID. The macro slot also tags correctly for an action-bar addon. |
| `CastSpellByName("<spellID>")` | A numeric string resolves through the engine name resolver, the same as `/cast`. |
| `CastSpellNoToggle("<name>")` in a macro | The engine macro parser reads this as a primary-spell line. The macro slot in an addon such as pfUI then highlights while the spell is on auto-repeat or its self-aura is active. |
</details>
<details>
<summary><b>Slash commands</b> — <code>[conditions]</code> on 38 commands</summary>
The bundled addon registers these commands. Each one takes the same
`[conditions]` and `@unit` syntax as `/cast`. Each client language has its
own command names next to the English ones. See
[More commands with `[conditions]`](docs/API.md#more-commands-with-conditions)
for what each command does.
| Group | Commands |
|-------|----------|
| Casting | `/cast`, `/use`, `/stopcasting`, `/cancelaura`, `/cancelform`, `/dismount` |
| Targeting | `/target`, `/targetexact`, `/cleartarget`, `/targetlasttarget`, `/targetlastenemy`, `/targetenemy`, `/targetfriend`, `/targetenemyplayer`, `/targetfriendplayer`, `/targetparty`, `/targetraid`, `/assist`, `/follow`, `/focus`, `/clearfocus`, `/startattack`, `/stopattack` |
| Equipment | `/equip`, `/equipslot`, `/equipset` |
| Action bars | `/changeactionbar`, `/swapactionbar`, `/click` |
| Pet | `/petattack`, `/petfollow`, `/petstay`, `/petpassive`, `/petdefensive`, `/petaggressive`, `/petautocaston`, `/petautocastoff`, `/petautocasttoggle` |
</details>
@@ -169,6 +198,7 @@ when launching with `-console`), not as Lua functions. See the
|-------|---------|
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `CURSOR_CHANGED` | `isDefault, newCursorType, oldCursorType, oldCursorVirtualID` |
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
| `EQUIPMENT_SWAP_PENDING` | `setID` |
| `EQUIPMENT_SWAP_FINISHED` | `success, setID` |
@@ -177,6 +207,7 @@ when launching with `-console`), not as Lua functions. See the
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LEARNED_SPELL_IN_SKILL_LINE` | `spellID, skillLineIndex, isGuildPerkSpell` |
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
@@ -214,6 +245,7 @@ when launching with `-console`), not as Lua functions. See the
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `USER_WAYPOINT_UPDATED` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
@@ -226,7 +258,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` |
@@ -235,6 +267,7 @@ when launching with `-console`), not as Lua functions. See the
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
| Cursor type | `Enum.UICursorType.{Default,Item,Money,Spell,PetAction,Merchant,Macro,Pet,…}` (0–20; this client yields 0–5, 7 and 9) |
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
@@ -254,7 +287,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.
@@ -302,7 +335,9 @@ functions but that consumer code still expects to find as globals —
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
(`ContinueOnAddOnLoaded` etc.).
(`ContinueOnAddOnLoaded` and more). It also holds the conditional slash
commands and `SecureCmdOptionParse`, the parser that reads `[conditions]`
and `@unit` for them.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
@@ -335,7 +370,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 +385,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). |
+4265 -1676
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -556,9 +556,26 @@ Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequenc
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
```
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
only.
Atlas markup is also supported:
```
|A:atlasName:height:width[:offsetX:offsetY]|a
```
It shares every scanner, measure, wrap and placement stage with `|T` — only the
payload parser differs. `ParseAtlasIcon` resolves the name through
`Texture::Atlas` and fills the SAME `IconDesc`, because an atlas is exactly the
"path + normalized sub-rect" that struct already carries for a `|T` span with
texcoords. A `height`/`width` of `0` resolves to the atlas's own pixel size at
PARSE time (not the line font height `|T` falls back to), which is what keeps the
rest of the pipeline marker-agnostic. Tiling atlases draw one quad, so
`tilesHorizontally` / `tilesVertically` are not honored here.
Adding any further marker means touching `IconStartLen`, `FindIconClose`,
`InlineSpanLen`, the ghost guard in `FlushLayout` — and, mandatory,
`chat/IconFilter.cpp`, or the marker becomes a chat-spoof vector.
FileDataID paths are Legion+ — 1.12 resolves texture PATH strings only.
## Confirmed: 1.12 has ZERO inline-texture support
+2
View File
@@ -213,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
|--------------|--------------------------|---------------------------------------------------|
| `0x006F3140` | `setobj` / TValue copy | 16-byte memcpy |
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams` `+0x45`, `maxstacksize` `+0x47`; C closure: reserves 0x14 slots and calls the C function |
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
| `0x006F6960` | `luaD_pcall` | protected call dispatcher |
| `0x006F7340` | `luaD_growstack` (or checkstack) | invoked when stack needs space |
+9
View File
@@ -31,4 +31,13 @@ bool PatchBytes(void *dst, const void *src, size_t len) {
return true;
}
void BoundedCopy(char *dst, const char *src, size_t n) {
if (dst == nullptr || n == 0)
return;
size_t i = 0;
for (; src != nullptr && src[i] != '\0' && i + 1 < n; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
} // namespace Common
+8
View File
@@ -11,8 +11,16 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include <cstddef>
namespace Common {
bool PatchBytes(void *dst, const void *src, size_t len);
// Bounded, always-terminated string copy: writes at most `n - 1`
// characters plus the terminator, and treats a null `src` as empty. `n`
// is the size of `dst`. Stands in for `strncpy`, which MSVC deprecates
// (C4996) and which does not terminate on truncation.
void BoundedCopy(char *dst, const char *src, size_t n);
} // namespace Common
+19 -50
View File
@@ -15,16 +15,13 @@
#include "Game.h"
#include "MinHook.h"
#include "Offsets.h"
#include "config/FileSwitch.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 +37,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();
@@ -247,6 +208,14 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hModule);
// The ONE thing that has to happen this early, and the only module this
// file names directly. The engine reads its config file during boot,
// several calls before VanillaFixes invokes `Load`, so a `-config`
// switch cannot be applied from the normal install path — by then the
// file has been read. This is a single 4-byte store, not a hook, so it
// carries none of the loader-lock cost described above.
Config::FileSwitch::Apply();
// Install nothing here (loader lock — see the block comment above).
//
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
+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
+1500 -59
View File
File diff suppressed because it is too large Load Diff
+2 -14
View File
@@ -47,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include <cstdint>
@@ -62,19 +63,6 @@ uint32_t ReadEntry(int slot0) {
return base[slot0];
}
// Returns 1-based macro slot index if `key` matches any entry in the
// macro-slot map, or 0 if no match. `key` is the action entry with the
// `0x40000000` type bit stripped (i.e. `entry & 0xBFFFFFFF`).
int FindMacroSlot(uint32_t key) {
auto *map = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MACRO_SLOT_MAP));
for (int i = 0; i < Offsets::MACRO_SLOT_MAP_COUNT; ++i) {
if (map[i] == key)
return i + 1;
}
return 0;
}
int __fastcall Script_GetActionInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetActionInfo(slot)");
@@ -117,7 +105,7 @@ int __fastcall Script_GetActionInfo(void *L) {
if (type == Offsets::ACTION_TYPE_BAG_OR_MACRO) {
const uint32_t key = entry & Offsets::ACTION_PAYLOAD_MASK_BAG_OR_MACRO;
const int macroSlot = FindMacroSlot(key);
const int macroSlot = Action::Slot::MacroSlotForID(key);
if (macroSlot > 0) {
Game::Lua::PushString(L, "macro");
Game::Lua::PushNumber(L, static_cast<double>(macroSlot));
+117
View File
@@ -0,0 +1,117 @@
// 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/>.
// `GetActionCooldown` / `GetActionCount` / `IsConsumableAction` for macro
// slots whose `#showtooltip` / `#show` resolved to an ITEM. A spell
// resolution needs none of this — the engine reads a macro slot's spell from
// its primary-spell cache, which `Macro::ShowTooltip` writes — but the engine
// has no per-macro item field, so the item forms of these three are answered
// here, each with the engine's own helper for the item-by-ID slot case:
//
// - cooldown → `FUN_ITEM_QUERY_COOLDOWN` via `Item::Cooldown::PushCooldown`
// (the same triple `GetContainerItemCooldown` returns);
// - count → `FUN_ACTION_ITEM_COUNT`, the value `UseAction` caches per
// item-by-ID slot;
// - consumable → the rule of the inner `IsConsumableAction` (`0x004E5250`):
// ammo / thrown, or any on-use spell slot with negative
// charges.
//
// Registered in front of the engine globals (last registration wins) and a
// strict superset of them: every other slot tail-calls the engine function
// with the untouched stack.
#include "Game.h"
#include "Offsets.h"
#include "item/Cooldown.h"
#include "item/Record.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
namespace Action::ItemState {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
using ItemCount_t = uint32_t(__fastcall *)(uint32_t itemID);
int Engine(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// True (and fills `info`) when stack[1] is a slot whose macro resolved to an item.
bool ItemTarget(void *L, Macro::ShowTooltip::Info *info) {
if (!Game::Lua::IsNumber(L, 1))
return false;
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
return Macro::ShowTooltip::ForSlot(slot0, info) &&
info->target == Macro::ShowTooltip::Target::Item;
}
int __fastcall Script_GetActionCooldown(void *L) {
Macro::ShowTooltip::Info info;
if (!ItemTarget(L, &info))
return Engine(Offsets::FUN_SCRIPT_GET_ACTION_COOLDOWN, L);
Item::Cooldown::PushCooldown(L, info.itemID);
return 3;
}
int __fastcall Script_GetActionCount(void *L) {
Macro::ShowTooltip::Info info;
if (!ItemTarget(L, &info))
return Engine(Offsets::FUN_SCRIPT_GET_ACTION_COUNT, L);
const uint32_t count = reinterpret_cast<ItemCount_t>(Offsets::FUN_ACTION_ITEM_COUNT)(
static_cast<uint32_t>(info.itemID));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
bool IsConsumable(int itemID) {
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false;
const uint32_t invType = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
if (invType == Offsets::INVTYPE_AMMO || invType == Offsets::INVTYPE_THROWN)
return true;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
const uint32_t spellID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_ID + i * 4);
const uint32_t trigger = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER + i * 4);
const int32_t charges = Game::Read<int32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_CHARGES + i * 4);
if (spellID != 0 && trigger == Offsets::ITEM_SPELLTRIGGER_ON_USE && charges < 0)
return true;
}
return false;
}
int __fastcall Script_IsConsumableAction(void *L) {
Macro::ShowTooltip::Info info;
if (!ItemTarget(L, &info))
return Engine(Offsets::FUN_SCRIPT_IS_CONSUMABLE_ACTION, L);
if (IsConsumable(info.itemID))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetActionCooldown", &Script_GetActionCooldown);
Game::Lua::RegisterGlobalFunction("GetActionCount", &Script_GetActionCount);
Game::Lua::RegisterGlobalFunction("IsConsumableAction", &Script_IsConsumableAction);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Action::ItemState
+44
View File
@@ -0,0 +1,44 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "action/Slot.h"
#include "Offsets.h"
namespace Action::Slot {
int MacroSlotForID(uint32_t macroID) {
if (macroID == 0)
return 0;
auto *map = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MACRO_SLOT_MAP));
for (int i = 0; i < Offsets::MACRO_SLOT_MAP_COUNT; ++i) {
if (map[i] == macroID)
return i + 1;
}
return 0;
}
uint32_t MacroIDForSlot(int slot0) {
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE));
const uint32_t entry = table[slot0];
if (entry == 0 || (entry & Offsets::ACTION_TYPE_MASK) != Offsets::ACTION_TYPE_BAG_OR_MACRO)
return 0;
const uint32_t key = entry & Offsets::ACTION_PAYLOAD_MASK_BAG_OR_MACRO;
return MacroSlotForID(key) != 0 ? key : 0;
}
} // namespace Action::Slot
+36
View File
@@ -0,0 +1,36 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Action-slot ↔ macro decoding shared by every module that has to answer
// "which macro sits in this action slot" (`GetActionInfo`, the
// `#showtooltip` display overrides). The action table stores macroIDs under
// the ambiguous `0x40000000` tag; the macro-slot map says which of those ids
// are macros (the rest are bag items). Pure C — no Lua stack.
namespace Action::Slot {
// 1-based macro slot holding `macroID` — the same linear scan of
// `VAR_MACRO_SLOT_MAP` the engine's `FUN_MACRO_ID_TO_SLOT` does — or 0 when
// no macro has that id (0 is never a macroID).
int MacroSlotForID(uint32_t macroID);
// The macroID in action slot `slot0` (0-based, `< ACTION_TABLE_MAX_SLOTS`),
// or 0 when the slot is empty, isn't `0x40000000`-tagged, or holds a bag
// item rather than a macro.
uint32_t MacroIDForSlot(int slot0);
} // namespace Action::Slot
+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) {
+24 -10
View File
@@ -26,6 +26,7 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/LoadState.h"
#include <cstdint>
@@ -251,19 +252,23 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
//
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
// matters for LoD addons whose load is split across multiple
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
// inside a single call, so the "loading" state is never observable
// from Lua. We return the same boolean twice — `loadedOrLoading`
// and `loaded` are identical here.
// Modern WoW distinguishes "load-in-progress" from "fully loaded", and
// the two really are distinguishable here: the engine's loaded byte
// (`entry+0x18`) is set at the START of a load, before the addon's files
// run, so the byte alone answers `loadedOrLoading`, not `loaded`. The
// window is reachable from Lua — an addon's own file-scope code, and any
// dependency pulled in during it, run while the byte already reads set.
//
// So `loadedOrLoading` is the engine's byte, and `loaded` additionally
// requires the addon not to be mid-load, which `AddOns::LoadState`
// tracks from the loader itself.
//
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
// string names. `IS_LOADED` accepts an entry pointer as its name arg
// because the entry's first 12 bytes are the inline NUL-terminated
// directory name (same as the other per-field accessors), so we can
// feed it either an entry or a Lua-side string transparently.
// feed it either an entry or a Lua-side string transparently — and that
// same pointer is the name `LoadState` matches on.
int __fastcall Script_IsAddOnLoaded(void *L) {
const uint8_t *entry = ResolveAddOnName(L);
if (entry == nullptr) {
@@ -273,8 +278,11 @@ int __fastcall Script_IsAddOnLoaded(void *L) {
}
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
const bool loaded = isLoaded(entry) != 0;
Game::Lua::PushBool(L, loaded);
const bool loadedOrLoading = isLoaded(entry) != 0;
const bool loaded =
loadedOrLoading &&
!AddOns::LoadState::IsLoading(reinterpret_cast<const char *>(entry));
Game::Lua::PushBool(L, loadedOrLoading);
Game::Lua::PushBool(L, loaded);
return 2;
}
@@ -385,6 +393,12 @@ static void RegisterLuaFunctions() {
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
// The stock global, mirrored into the namespace — the engine's own
// handler already has the Lua C ABI and the modern return shape.
Game::Lua::RegisterTableFunction(
"C_AddOns", "LoadAddOn",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_LOAD_ADDON)));
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
&Script_GetAddOnOptionalDependencies);
Game::Lua::RegisterIntegerEnum(
+90
View File
@@ -0,0 +1,90 @@
// 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/>.
// Tracks in-progress addon loads so `C_AddOns.IsAddOnLoaded` can report
// `loadedOrLoading` and `loaded` separately. See `LoadState.h` for why the
// engine's own byte can't answer both.
//
// Loads nest — an addon's dependencies load inside its own call — so this
// keeps a stack rather than a single name. The name is copied rather than
// stored by pointer: `LoadAddOn` passes a Lua string, which does not
// outlive the call.
#include "addons/LoadState.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
namespace AddOns::LoadState {
namespace {
// Dependency chains are shallow; anything deeper simply isn't tracked
// (the depth counter still balances, so the stack can't desync).
constexpr int kMaxDepth = 16;
constexpr int kMaxName = 64;
char g_stack[kMaxDepth][kMaxName];
int g_depth = 0;
bool EqualsIgnoreCase(const char *a, const char *b) {
for (; *a != '\0' && *b != '\0'; ++a, ++b) {
char ca = *a;
char cb = *b;
if (ca >= 'A' && ca <= 'Z')
ca = static_cast<char>(ca - 'A' + 'a');
if (cb >= 'A' && cb <= 'Z')
cb = static_cast<char>(cb - 'A' + 'a');
if (ca != cb)
return false;
}
return *a == *b;
}
using LoadOne_t = unsigned int(__fastcall *)(const char *name, char flag,
void *progressCtx);
LoadOne_t s_loadOne_o = nullptr;
unsigned int __fastcall LoadOne_h(const char *name, char flag, void *progressCtx) {
const int slot = g_depth;
if (slot >= 0 && slot < kMaxDepth)
Common::BoundedCopy(g_stack[slot], name, kMaxName);
++g_depth;
const unsigned int result = s_loadOne_o(name, flag, progressCtx);
--g_depth;
if (g_depth < 0)
g_depth = 0; // defensive: never let the stack run negative
return result;
}
const Game::HookAutoRegister _hookLoadOne{
Offsets::FUN_ADDON_LOAD_ONE,
reinterpret_cast<void *>(&LoadOne_h),
reinterpret_cast<void **>(&s_loadOne_o)};
} // namespace
bool IsLoading(const char *name) {
if (name == nullptr || *name == '\0')
return false;
const int depth = (g_depth < kMaxDepth) ? g_depth : kMaxDepth;
for (int i = 0; i < depth; ++i)
if (EqualsIgnoreCase(g_stack[i], name))
return true;
return false;
}
} // namespace AddOns::LoadState
+33
View File
@@ -0,0 +1,33 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// `AddOns::LoadState` — tracks which addons are mid-load.
//
// The engine's own "loaded" byte (`entry+0x18`) is set at the START of a
// load, before the addon's files run, so on its own it can't tell
// "loading" from "loaded". That distinction is observable: an addon's
// file-scope code, and every dependency pulled in during it, execute
// while the byte already reads set.
//
// A co-hook on the per-addon loader keeps a stack of the names currently
// being loaded, which is the missing half of
// `C_AddOns.IsAddOnLoaded`'s `(loadedOrLoading, loaded)` pair.
namespace AddOns::LoadState {
// True while `name` is inside the engine's per-addon load call. Matching
// is case-insensitive, as addon-name matching is everywhere else.
bool IsLoading(const char *name);
} // namespace AddOns::LoadState
+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
+6 -9
View File
@@ -68,12 +68,9 @@ namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
const Event::Custom::AutoReserve _evtStart{"AUCTION_MULTISELL_START"};
const Event::Custom::AutoReserve _evtUpdate{"AUCTION_MULTISELL_UPDATE"};
const Event::Custom::AutoReserve _evtFailure{"AUCTION_MULTISELL_FAILURE"};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
@@ -206,7 +203,7 @@ bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
Event::Custom::Fire(_evtFailure.Slot(), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
@@ -279,7 +276,7 @@ void Tick() {
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
Event::Custom::Fire(_evtUpdate.Slot(), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
@@ -400,7 +397,7 @@ int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Event::Custom::Fire(_evtStart.Slot(), "");
Game::Lua::PushBool(L, true);
return 1;
}
+136 -20
View File
@@ -21,7 +21,8 @@
// tokens separated by `|` and/or whitespace, each optionally negated with a
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
// server encodes it — see `Aura::Data::IsSlotHarmful`), `PLAYER` /
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
@@ -46,14 +47,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.
@@ -356,22 +353,135 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
return 1;
}
// Iterates one slot range and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a
// follow-up call can append to the same outer table.
// C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])
// -> continuationToken, slot1, slot2, ...
//
// Enumerates the opaque slot ids of every aura on `unit` matching `filter`, in
// the order the by-index getters visit them, `maxSlots` at a time (nil / 0 =
// all). The first return is the token to pass back for the next batch, or nil
// when this batch reached the end — retail's batching contract, and what makes
// AuraUtil.ForEachAura linear: one enumeration per batch plus a direct by-slot
// fetch per aura, instead of a fresh from-slot-0 walk per index. The token is
// the 1-based ordinal to resume at; opaque to callers. Encoding of the slot ids
// is documented at `Data::OPAQUE_STRIDE`.
int __fastcall Script_GetAuraSlots(void *L) {
const char *unitToken = ArgUnit(L, 1);
const char *filterStr = ArgOptString(L, 2);
const int maxSlots = ArgInt(L, 3);
const int token = ArgInt(L, 4);
// ClassicAPI extension: a table as the 5th argument selects FILL mode —
// the slot ids are written into it (t[1..n], stale tail cleared, t.n = n)
// and the call returns `continuationToken, n` instead of the vararg list.
// Lua 5.0 builds an `arg` table for every vararg CALL, so a Lua helper
// that receives the retail multi-return allocates one table per batch;
// filling the caller's table here is what keeps a per-frame scan
// allocation-free (AuraUtil and pfUI's ScanAuraSlots use this form).
const bool fill = Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE;
int slots[Data::OPAQUE_SLOTS_MAX];
int total = 0;
if (unitToken != nullptr) {
const uint8_t *unit = ResolveUnit(unitToken);
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
const ParsedFilter pf = ParseFilters(filterStr);
total = Data::CollectSlots(unit, guid, RangeFilter(pf), pf.ToMatch(), slots,
Data::OPAQUE_SLOTS_MAX);
}
int start = (token > 0) ? token - 1 : 0;
if (start > total)
start = total;
int n = total - start;
if (maxSlots > 0 && n > maxSlots)
n = maxSlots;
const bool more = start + n < total;
if (fill) {
for (int i = 0; i < n; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
Game::Lua::RawSet(L, 5);
}
// Clear what a previous, longer fill left past n. The array is dense
// by construction, so stop at the first hole.
for (int k = n + 1;; ++k) {
Game::Lua::PushNumber(L, static_cast<double>(k));
Game::Lua::RawGet(L, 5);
const bool hole = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
Game::Lua::SetTop(L, -2);
if (hole)
break;
Game::Lua::PushNumber(L, static_cast<double>(k));
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, 5);
}
Game::Lua::PushString(L, "n");
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::RawSet(L, 5);
Game::Lua::SetTop(L, 0);
if (more)
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 2;
}
// Vararg form. Everything below only pushes, so the args can go; a full
// batch is up to OPAQUE_SLOTS_MAX + 1 values, past Lua's guaranteed C-call
// headroom.
Game::Lua::SetTop(L, 0);
if (Game::Lua::CheckStack(L, n + 1) == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (more)
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
else
Game::Lua::PushNil(L);
for (int i = 0; i < n; ++i)
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
return 1 + n;
}
// Shared body of GetAuraDataBySlot (table) / UnitAuraBySlot (positional):
// pushes the aura an opaque slot id from GetAuraSlots names, or a single nil
// for an id that no longer names one.
int PushAuraBySlot(void *L, Data::Emit emit) {
const char *unitToken = ArgUnit(L, 1);
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const int slot = ArgInt(L, 2);
const uint8_t *unit = ResolveUnit(unitToken);
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
if (Data::PushBySlot(L, unit, guid, slot, emit))
return (emit == Data::Emit::Positional) ? 15 : 1;
Game::Lua::PushNil(L);
return 1;
}
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
int __fastcall Script_GetAuraDataBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Table);
}
// C_UnitAuras.UnitAuraBySlot(unit, slot) -> the 15 positional UnitAura values | nil
// Zero-allocation sibling of GetAuraDataBySlot, namespaced under C_UnitAuras
// like C_UnitAuras.UnitAura. What AuraUtil's non-packed iteration reads.
int __fastcall Script_UnitAuraBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Positional);
}
// Iterates the auras of one polarity (in `SlotInFilterOrder`, the same order
// the by-index getters use) and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a follow-up
// call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey, Data::Match match) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
for (int slot = start; slot < end; ++slot) {
if (!Data::IsSlotPopulated(unit, slot))
continue;
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
Data::ReadSpellID(unit, slot)))
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = Data::SlotInFilterOrder(filter, i);
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
@@ -501,6 +611,12 @@ static void RegisterLuaFunctions() {
&Script_UnitBuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
&Script_UnitDebuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraSlots",
&Script_GetAuraSlots);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
&Script_GetAuraDataBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
&Script_UnitAuraBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
&Script_GetUnitAuraBySpellID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
+381 -158
View File
@@ -22,6 +22,7 @@
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
#include "time/Clock.h"
#include "turtle/Detect.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -77,13 +78,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
@@ -276,6 +273,94 @@ bool IsSlotPopulated(const uint8_t *unit, int slot) {
return IsVisible(SpellRecord(spellID));
}
// Every nibble Turtle's SetAuraFlag can write for an occupied slot: HELPFUL,
// HELPFUL|CANCELABLE, or HARMFUL alone. Nothing else is reachable there — a
// negative aura gets exactly HARMFUL, and a positive one always carries HELPFUL.
bool IsTurtlePolarityNibble(uint8_t nibble) {
return nibble == Offsets::UNIT_AURA_FLAG_HELPFUL ||
nibble == (Offsets::UNIT_AURA_FLAG_HELPFUL | Offsets::UNIT_AURA_FLAG_CANCELABLE) ||
nibble == Offsets::UNIT_AURA_FLAG_HARMFUL;
}
// Latched for the process once a nibble proves the flags are not polarity here.
// A server does not change its encoding mid-session, so this never resets.
bool g_nibbleContradicted = false;
bool IsSlotHarmful(const uint8_t *unit, int slot) {
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return false;
// Normally the slot range IS the polarity: an aura is written to 0..31 when
// helpful and 32..47 when harmful, and nothing ever crosses over. The flag
// nibble cannot be used here, because in a stock descriptor those bits mean
// something else entirely — one bit per spell effect index that carries an
// aura, plus a cancelable bit. Reading bit 0x08 as "harmful" would then be
// reading "effect index 0 has an aura", which is true of nearly every aura,
// and would report almost every BUFF as a debuff.
if (!Turtle::Detected())
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
// Turtle lets a debuff spill into a buff slot once the harmful range is
// full, so the range stops being reliable — and it repurposes those same
// nibble bits to carry the real polarity (0x04 helpful, 0x08 harmful) so
// the information survives the spill. There, the nibble is the answer.
if (g_nibbleContradicted)
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
auto *desc = Descriptor(unit);
if (desc == nullptr)
return false;
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
// Fail safe. Detection says this server SHOULD be writing polarity here, so
// a nibble it could not have produced means that assumption is stale — the
// server changed how it writes the flags. Fall back to the slot range for
// good rather than keep reading bits whose meaning we no longer know: the
// range is merely incomplete for spilled debuffs, whereas a misread nibble
// reports nearly every buff as a debuff. Revocation only, never promotion —
// nothing here can turn nibble reading ON, because the agreeing case is not
// provable (an all-0x08 run is equally consistent with single-effect auras
// under the other encoding).
if (nibble != 0 && !IsTurtlePolarityNibble(nibble)) {
g_nibbleContradicted = true;
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
}
return (nibble & Offsets::UNIT_AURA_FLAG_HARMFUL) != 0;
}
bool IsSlotTooltipVisible(const uint8_t *unit, int slot) {
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return false;
auto *desc = Descriptor(unit);
if (desc == nullptr)
return false;
const int spellID = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_AURA + slot * 4);
if (spellID <= 0) // the engine's own `0 < id` test (signed)
return false;
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
if ((nibble & Offsets::UNIT_AURA_VISIBLE_MASK) == 0)
return false;
const uint8_t *rec = SpellRecord(static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
using Fn = char(__fastcall *)(const uint8_t *);
auto fn = reinterpret_cast<Fn>(
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_AURA_VISIBLE));
return fn(rec) != 0;
}
int SlotInFilterOrder(Filter filter, int i) {
// Harmful: 32..47 first, then 0..31 (where the server parks debuffs once
// the 16 are full). Helpful: 0..31 then 32..47 — positive auras never
// spill downward in practice, so that tail is a no-op safety net.
if (filter == Filter::Harmful)
return (i + Offsets::UNIT_AURA_BUFF_COUNT) % Offsets::UNIT_AURA_TOTAL;
return i;
}
bool IsPlayerCast(const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
if (spellID == 0)
@@ -331,6 +416,30 @@ bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
CcMatches(match.cc, spellID);
}
// `MatchesAura` for a descriptor slot the caller has ALREADY established is
// populated. The caster lookup is the expensive predicate (a scan of the
// Aura::Source cache per slot), and `CasterMatches` ignores its answer when the
// match has no caster restriction — so skip the lookup entirely in that case.
static bool PopulatedSlotMatches(const uint8_t *unit, int slot,
const Match &match) {
const bool castByPlayer =
match.caster != CasterMode::Any && IsPlayerCast(unit, slot);
return MatchesAura(match, castByPlayer, ReadSpellID(unit, slot));
}
bool SlotMatches(const uint8_t *unit, int slot, const Match &match) {
return IsSlotPopulated(unit, slot) && PopulatedSlotMatches(unit, slot, match);
}
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
const Match &match) {
if (!IsSlotPopulated(unit, slot))
return false;
if (IsSlotHarmful(unit, slot) != (filter == Filter::Harmful))
return false;
return PopulatedSlotMatches(unit, slot, match);
}
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
// A miss counts as "not the player" (same as IsPlayerCast).
@@ -344,17 +453,10 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
Match match) {
if (unit == nullptr || oneBasedIndex < 1)
return -1;
const int start = (filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter == Filter::Harmful)
? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = SlotInFilterOrder(filter, i);
if (!SlotMatchesFilter(unit, slot, filter, match))
continue;
if (++matches == oneBasedIndex)
return slot;
@@ -366,18 +468,16 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, Match match) {
if (unit == nullptr || spellID == 0)
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
if (!IsSlotPopulated(unit, slot))
continue;
if (ReadSpellID(unit, slot) != spellID)
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
if (filter != nullptr &&
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
continue;
if (!PopulatedSlotMatches(unit, slot, match))
continue;
return slot;
}
@@ -388,19 +488,17 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, Match match) {
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
if (!IsSlotPopulated(unit, slot))
continue;
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
if (filter != nullptr &&
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
continue;
if (!PopulatedSlotMatches(unit, slot, match))
continue;
return slot;
}
@@ -451,9 +549,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 +576,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 +626,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);
@@ -655,7 +759,7 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const bool isHelpful = !IsSlotHarmful(unit, slot); // nibble, not slot range
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
@@ -720,36 +824,16 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
return false;
}
// Counts populated descriptor slots matching `filter` (and `caster`).
int CountSlots(const uint8_t *unit, Filter filter, Match match) {
if (unit == nullptr)
return 0;
const int start = (filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
int n = 0;
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
++n;
}
return n;
}
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`caster`: the spell must be a user-visible aura, it must not
// already be in a populated descriptor slot (no double-listing the same live
// aura) and, when player-filtered, must have been cast by us. Superseded /
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
// eviction, not filtered here.
// Whether cache entry `c` may surface as a fallback for `unit` under `filter`:
// the spell must be a user-visible aura and must not already be in a populated
// descriptor slot (no double-listing the same live aura). The caller's
// caster/dispel/CC predicates are applied separately by `CachedMatches`.
// Superseded / dispelled auras are kept out of the cache by `Aura::Source`'s
// removal eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, Match match) {
Filter filter) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
@@ -757,37 +841,58 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
return false; // still in the descriptor — returned via the slot path
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
// Applies `match` to a cache entry. The entry carries its caster, so the caster
// test is a plain GUID compare — no scan, nothing to defer.
bool CachedMatches(const Aura::Source::CachedAura &c, const Match &match) {
return MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(),
c.spellId);
}
// The eligible cache-fallback entries for `unit` under `filter`, in `Enumerate`
// order, unfiltered by any `Match` (callers apply `CachedMatches`). Applies the
// fallback gates: reconcile the cache against the descriptor, then yield nothing
// while the descriptor shows any visible aura (it is authoritative then — see
// DescriptorHasVisibleAura). `buf` must hold kFallbackMax entries. Returns the
// count. An entry's ordinal here is what an opaque cache slot id encodes, which
// is why the sequence must not depend on the caller's match.
int EligibleFallbacks(const uint8_t *unit, Filter filter,
Aura::Source::CachedAura *buf) {
if (unit == nullptr)
return 0;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return 0; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return 0;
Aura::Source::CachedAura raw[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, raw, kFallbackMax);
int m = 0;
for (int i = 0; i < n; ++i) {
if (FallbackEligible(unit, raw[i], filter))
buf[m++] = raw[i];
}
return m;
}
} // namespace
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, Match match, Emit emit) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return false; // descriptor authoritative — see DescriptorHasVisibleAura
// The descriptor held `descCount` matches; the caller already found fewer
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
const int target = oneBasedIndex - CountSlots(unit, filter, match);
if (target < 1)
return false;
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return false;
// The fallback yields only while the descriptor shows no visible aura, so
// the descriptor held none of the caller's matches: the cache supplies index
// `oneBasedIndex` (1-based) of the entries passing `match` directly.
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
const int n = EligibleFallbacks(unit, filter, buf);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, match))
if (!CachedMatches(buf[i], match))
continue;
if (++seen == target) {
if (++seen == oneBasedIndex) {
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
return true;
}
@@ -797,19 +902,10 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
Match match, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return;
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
const int n = EligibleFallbacks(unit, filter, buf);
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, match))
if (!CachedMatches(buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
@@ -821,8 +917,10 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
namespace {
// Absolute slot range [start, end) for a filter, matching the descriptor
// convention (helpful 0..31, harmful 32..47).
// Absolute slot range [start, end) for a filter in the GROUP ARRAY (helpful
// 0..31, harmful 32..47). The array carries spell IDs only — no flag nibble —
// so the seating range is the only polarity the out-of-range path has. (The
// descriptor path reads the nibble instead; see SlotInFilterOrder.)
void GroupRange(Filter filter, int &start, int &end) {
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
@@ -836,21 +934,27 @@ uint32_t GroupSpellID(const uint16_t *arr, int slot) {
return arr[slot];
}
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `caster` additionally requires the `Aura::Source` cache to attribute the
// cast to the local player (a miss excludes it — we can't confirm the caster
// from the group array alone).
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
Match match) {
// Whether the group-array slot holds a surfacable aura. Mirrors the engine's
// per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell ID with a real
// Spell.dbc record that passes the visibility predicate (the server only
// transmits *applied* auras, so there is no flag nibble to check).
bool GroupSlotVisible(const uint16_t *arr, int slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return id != 0 && IsVisible(SpellRecord(id));
}
// `GroupSlotVisible` plus the caller's `match` predicates. The caster answer
// (`GroupIsPlayerCast` — a cache scan; a miss counts as not-player, since the
// array alone can't confirm the caster) is computed only when `match` restricts
// on caster.
bool GroupSlotMatches(uint64_t guid, const uint16_t *arr, int slot,
const Match &match) {
if (!GroupSlotVisible(arr, slot))
return false;
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
return false;
return true;
const uint32_t id = GroupSpellID(arr, slot);
const bool castByPlayer =
match.caster != CasterMode::Any && GroupIsPlayerCast(guid, id);
return MatchesAura(match, castByPlayer, id);
}
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
@@ -870,28 +974,52 @@ int GroupMemberLevel(uint64_t guid) {
// still sit in the cache from when we observed them in range.
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id != 0 && IsVisible(SpellRecord(id)))
if (GroupSlotVisible(arr, slot))
return true;
}
return false;
}
// Whether cache entry `c` may supplement the (empty) group array: a visible
// aura, not already present in the array, and — when player-filtered — cast by
// us. See GroupArrayHasVisibleAura for when this path runs.
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
Match match) {
// aura not already present in the array. The caller's `match` is applied
// separately by `CachedMatches`. See EligibleGroupFallbacks for when this path
// runs.
bool GroupFallbackEligible(const uint16_t *arr,
const Aura::Source::CachedAura &c) {
if (!IsVisible(SpellRecord(c.spellId)))
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
if (GroupSpellID(arr, slot) == c.spellId)
return false;
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
// The eligible group cache-fallback entries for out-of-range member `guid`, in
// `Enumerate` order, unfiltered by any `Match` (callers apply `CachedMatches`).
// Yields nothing while the group array exposes ANY visible aura: the server's
// out-of-range aura data is then present and authoritative, mirroring how the
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
// Only an entirely empty array falls back to the cache — the server resends
// out-of-range stats as deltas only, so a member seen in range and then moved
// away again leaves an empty party-stats aura array (the array is cleared when
// they become visible and never re-sent), yet their auras still sit in the
// cache from when we observed them in range. `buf` must hold kFallbackMax
// entries. An entry's ordinal here is what an opaque group-cache slot id
// encodes, which is why the sequence must not depend on the caller's match.
int EligibleGroupFallbacks(const uint16_t *arr, uint64_t guid, bool harmful,
Aura::Source::CachedAura *buf) {
if (arr == nullptr || GroupArrayHasVisibleAura(arr))
return 0;
Aura::Source::CachedAura raw[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, raw, kFallbackMax);
int m = 0;
for (int i = 0; i < n; ++i) {
if (GroupFallbackEligible(arr, raw[i]))
buf[m++] = raw[i];
}
return m;
}
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
// member whose group array is empty. Pushes the AuraData table on a hit.
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
@@ -899,10 +1027,10 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
Emit emit = Emit::Table) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], match))
if (!CachedMatches(buf[i], match))
continue;
if (++seen == oneBasedIndex) {
const Attribution a = CachedAttribution(buf[i]);
@@ -920,10 +1048,10 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
int &nextKey) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
const int level = GroupMemberLevel(guid);
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], match))
if (!CachedMatches(buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
const Attribution a = CachedAttribution(buf[i]);
@@ -939,7 +1067,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
bool harmful, uint32_t spellID,
const char *spellName, Match match) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
const int n = EligibleGroupFallbacks(arr, guid, harmful, buf);
for (int i = 0; i < n; ++i) {
if (spellID != 0 && buf[i].spellId != spellID)
continue;
@@ -948,7 +1076,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], match))
if (!CachedMatches(buf[i], match))
continue;
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
@@ -972,7 +1100,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
@@ -983,10 +1111,8 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
}
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
match, emit);
return false;
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter, match,
emit);
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
@@ -1006,23 +1132,20 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
match))
return true;
}
// Empty array (server delta-resend gap): supplement from the cache.
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr, match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr, match))
return true;
return false;
}
@@ -1047,23 +1170,20 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
match))
return true;
}
// Empty array (server delta-resend gap): supplement from the cache.
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName, match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName, match))
return true;
return false;
}
@@ -1079,7 +1199,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
if (!GroupSlotMatches(guid, arr, slot, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
@@ -1088,9 +1208,112 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
nextKey);
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx, nextKey);
}
// ── Opaque aura slots ──────────────────────────────────────────────────────
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
int *out, int cap) {
int n = 0;
if (unit != nullptr) {
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL && n < cap; ++i) {
const int slot = SlotInFilterOrder(filter, i);
if (SlotMatchesFilter(unit, slot, filter, match))
out[n++] = slot;
}
// The cache fallback yields only while the descriptor shows no visible
// aura, so these never interleave with live slots — they follow them,
// exactly as the by-index getters order them.
Aura::Source::CachedAura buf[kFallbackMax];
const int m = EligibleFallbacks(unit, filter, buf);
const int base = (filter == Filter::Harmful) ? OPAQUE_CACHE_HARMFUL
: OPAQUE_CACHE_HELPFUL;
for (int k = 0; k < m && n < cap; ++k) {
if (CachedMatches(buf[k], match))
out[n++] = base + k;
}
return n;
}
// No live CGUnit — the out-of-range group path.
if (guid == 0)
return 0;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return 0;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
for (int slot = start; slot < end && n < cap; ++slot) {
if (GroupSlotMatches(guid, arr, slot, match))
out[n++] = OPAQUE_GROUP + slot;
}
Aura::Source::CachedAura buf[kFallbackMax];
const int m = EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
const int base = (filter == Filter::Harmful) ? OPAQUE_GROUP_CACHE_HARMFUL
: OPAQUE_GROUP_CACHE_HELPFUL;
for (int k = 0; k < m && n < cap; ++k) {
if (CachedMatches(buf[k], match))
out[n++] = base + k;
}
return n;
}
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit) {
if (slot < 0)
return false;
const int kind = slot / OPAQUE_STRIDE;
const int k = slot % OPAQUE_STRIDE;
switch (kind) {
case 0: // live descriptor slot
if (unit == nullptr || !IsSlotPopulated(unit, k))
return false;
Push(L, unit, k, emit);
return true;
case 1: // helpful cache fallback
case 2: { // harmful cache fallback
if (unit == nullptr || k >= kFallbackMax)
return false;
const Filter filter = (kind == 2) ? Filter::Harmful : Filter::Helpful;
Aura::Source::CachedAura buf[kFallbackMax];
if (k >= EligibleFallbacks(unit, filter, buf))
return false;
PushFromCache(L, unit, buf[k], filter == Filter::Helpful, emit);
return true;
}
case 3: { // out-of-range group-array slot
if (guid == 0 || k >= Offsets::UNIT_AURA_TOTAL)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
if (!GroupSlotVisible(arr, k))
return false;
PushEnriched(L, guid, GroupSpellID(arr, k),
k < Offsets::UNIT_AURA_BUFF_COUNT, 1, GroupMemberLevel(guid),
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr, emit);
return true;
}
case 4: // helpful group cache fallback
case 5: { // harmful group cache fallback
if (guid == 0 || k >= kFallbackMax)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
const bool harmful = (kind == 5);
Aura::Source::CachedAura buf[kFallbackMax];
if (k >= EligibleGroupFallbacks(arr, guid, harmful, buf))
return false;
const Attribution a = CachedAttribution(buf[k]);
PushEnriched(L, guid, buf[k].spellId, !harmful, 1, GroupMemberLevel(guid),
false, buf[k].slot, &a, emit);
return true;
}
default:
return false;
}
}
} // namespace Aura::Data
+104 -13
View File
@@ -10,6 +10,8 @@
#pragma once
#include "Offsets.h"
#include <cstdint>
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
@@ -18,19 +20,38 @@
// `unit + OFF_CGUNIT_OBJECT_FIELDS`; the layout is documented in
// `Offsets.h` under `OFF_UNIT_FIELD_AURA*`.
//
// Slot indexing convention used here matches the engine's:
// Slots are absolute 0..47, and how polarity is read out of them DEPENDS ON THE
// SERVER. `IsSlotHarmful` owns that decision; nothing else should look at either
// signal directly.
//
// slot 0..31 → buffs (helpful)
// slot 32..47 → debuffs (harmful)
// Normally the slot range IS the polarity: positive auras are written to 0..31
// and negative ones to 32..47 by a strict either/or, so nothing crosses over.
// The flag nibble is no help — in a stock descriptor those bits record which
// spell effect indices carry an aura, not whether the aura is good or bad.
//
// Callers that take a 1-based Lua index into "buffs" or "debuffs"
// translate to the absolute 0..47 slot before calling in.
// Turtle changes both halves. Once the 16 harmful slots are full it spills
// further debuffs into 0..31 (setting UNIT_FLAG_AURAS_VISIBLE so the client
// still renders them), which makes the range unreliable — and it repurposes the
// nibble to carry the true polarity so the information survives the spill.
// There, the nibble is authoritative. See `Offsets::UNIT_AURA_FLAG_HARMFUL`.
//
// Either way `Filter` selects on the aura's REAL polarity, so a spilled debuff
// is reported as a debuff. The engine's own UnitBuff/UnitDebuff split purely by
// slot range and would call it a buff; that is a 1.12 UI limitation we
// deliberately do not mirror, since retail's contract is actual polarity. A
// polarity walk visits its home range first and then the other range, so
// indices are unchanged wherever no spill has happened — which is everywhere,
// on a server that does not spill.
//
// Callers that take a 1-based Lua index into "buffs" or "debuffs" translate
// to the absolute 0..47 slot before calling in.
namespace Aura::Data {
// Filter for slot iteration. The engine's `UnitBuff` / `UnitDebuff`
// pick one direction; modern `C_UnitAuras.GetAuraDataByIndex` defaults
// to helpful when no filter is specified.
// Filter for slot iteration: the aura's polarity per its flag nibble (see the
// file header). The engine's `UnitBuff` / `UnitDebuff` pick one direction;
// modern `C_UnitAuras.GetAuraDataByIndex` defaults to helpful when no filter
// is specified.
enum class Filter { Helpful, Harmful };
// How a resolved aura is emitted onto the Lua stack by the single-aura push
@@ -83,11 +104,33 @@ uint32_t ReadSpellID(const uint8_t *unit, int slot);
// to decide whether to surface an aura through Lua.
bool IsSlotPopulated(const uint8_t *unit, int slot);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
// matching `filter`. Returns -1 if no such aura. Walks
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
// the local player cast (per `IsPlayerCast`) are counted/returned.
// The aura's real polarity, read the way this server encodes it: the slot range
// normally, the flag nibble on Turtle (see the file header). False for an empty
// slot or a null unit. This is what `Filter` selects on, and the ONE place the
// two encodings are chosen between.
bool IsSlotHarmful(const uint8_t *unit, int slot);
// The engine's TOOLTIP visibility gate for a slot: occupied, visible nibble,
// and the spell passes `FUN_GAMETOOLTIP_AURA_VISIBLE` — exactly what
// `SetUnitBuff` / `SetUnitDebuff` count while turning an index into a slot.
// Differs from `IsSlotPopulated` (the UnitBuff gate), so use THIS to compute
// the index the engine's tooltip methods expect for a given slot.
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
// The i-th slot (0..UNIT_AURA_TOTAL-1) in the order a `filter` walk visits the
// descriptor: the polarity's home range first (helpful 0..31, harmful 32..47),
// then the other range, where spilled debuffs live. Every enumeration that
// hands out indices or slot lists uses this so they agree on order.
int SlotInFilterOrder(Filter filter, int i);
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
// passing `match`.
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
const Match &match);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
// all 48 slots in `SlotInFilterOrder`.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
Match match = {});
@@ -124,6 +167,12 @@ bool IsDispellable(uint32_t spellID);
// per-aura "was cast by the local player" answer and its spell ID.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
// `Aura::Source` cache, ~1000 entries in a raid) is computed ONLY when `match`
// restricts on caster, so the common filter-less walk never pays for it.
bool SlotMatches(const uint8_t *unit, int slot, const Match &match);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
int ReadStacks(const uint8_t *unit, int slot);
@@ -230,4 +279,46 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey);
// ── Opaque aura slots (GetAuraSlots / GetAuraDataBySlot) ────────────────────
//
// The by-index getters re-walk the descriptor from slot 0 on every call, so a
// full scan through them is quadratic in the aura count. `CollectSlots`
// enumerates once into opaque slot ids and `PushBySlot` fetches one aura by id
// with no walk — the modern `C_UnitAuras.GetAuraSlots` / `GetAuraDataBySlot`
// batching contract. An id is valid for the frame it was enumerated in.
//
// Encoding (kind = id / OPAQUE_STRIDE, k = id % OPAQUE_STRIDE):
// kind 0 live descriptor slot k (0..UNIT_AURA_TOTAL-1)
// kind 1 k-th eligible helpful cache-fallback entry (OPAQUE_CACHE_HELPFUL)
// kind 2 k-th eligible harmful cache-fallback entry (OPAQUE_CACHE_HARMFUL)
// kind 3 out-of-range group-array slot k (0..47) (OPAQUE_GROUP)
// kind 4 k-th helpful group cache-fallback entry (OPAQUE_GROUP_CACHE_HELPFUL)
// kind 5 k-th harmful group cache-fallback entry (OPAQUE_GROUP_CACHE_HARMFUL)
// Fallback ordinals count the UNFILTERED eligible sequence (visible, not
// already in the live array), so `PushBySlot` needs no filter; `CollectSlots`
// applies the caller's `match` only when choosing which ids to emit.
constexpr int OPAQUE_STRIDE = 0x100;
constexpr int OPAQUE_CACHE_HELPFUL = 1 * OPAQUE_STRIDE;
constexpr int OPAQUE_CACHE_HARMFUL = 2 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP = 3 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP_CACHE_HELPFUL = 4 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP_CACHE_HARMFUL = 5 * OPAQUE_STRIDE;
// Upper bound on ids one `CollectSlots` call can yield: the live array (or the
// group array) plus its cache fallback, each at most UNIT_AURA_TOTAL entries.
constexpr int OPAQUE_SLOTS_MAX = 2 * Offsets::UNIT_AURA_TOTAL;
// Writes the opaque ids of every aura on the unit matching `filter` + `match`
// into `out` (at most `cap`), in the order the by-index getters visit them:
// live slots (or, with no live CGUnit, the group array) first, then the cache
// fallback. `unit` null selects the out-of-range group path for `guid`. Returns
// the count written.
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
int *out, int cap);
// Pushes the aura an opaque id names — table or positional per `emit` — and
// returns true; pushes nothing and returns false for an id that names nothing
// (stale, malformed, or the wrong path for the unit's current state).
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit);
} // namespace Aura::Data
+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
+770 -30
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,16 @@ 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);
// Defined with the duration-modifier machinery below. A trigger can CREATE the
// aura it then edits, in which case the edit is armed at trigger time and
// applied here, when the aura it was waiting for actually lands. See PendingMod.
struct Entry;
void ConsumePendingMods(Entry &e, uint32_t now);
// 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
@@ -367,12 +393,20 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
e = Claim(now);
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
now, SLOT_UNBOUND, KIND_UNKNOWN, true};
ConsumePendingMods(*e, now);
return;
}
e->stampMs = now; // refresh EvictAbsent grace on any touch
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);
ConsumePendingMods(*e, now);
}
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
@@ -396,13 +430,14 @@ 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) {
e = Claim(now);
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
static_cast<int16_t>(slot), kind, true};
ConsumePendingMods(*e, now);
return;
}
@@ -414,6 +449,11 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
// back to unknown.
if (kind != KIND_UNKNOWN)
e->kind = kind;
// An armed server edit applies whoever owns the timing — the server made it
// AFTER the cast this entry came from, so it is the newer truth. It also
// adopts the entry, which makes the ownership guard below keep the edited
// value instead of the application's base-duration guess.
ConsumePendingMods(*e, now);
if (e->casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + talented timing
if (casterGuid != 0)
@@ -497,8 +537,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId, int slot) {
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
// A trigger is matched either by exact spellID (from the server's script
// binding, stable across ranks) or by SpellFamilyName + school; the affected
// aura by SpellFamilyName + a family-flag overlap (+ optional icon) — rank-proof,
// exactly how the server's scripts find it. Conflagrate's *full*-consume path
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID —
// rank-proof, exactly how the server's scripts find it, and the icon half is
// what reaches a custom spell with no family flags at all (see
// AffectedMatchesRaw). Conflagrate's *full*-consume path
// removes Immolate, which clears
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
@@ -523,9 +565,23 @@ int g_modCount = 0;
// The affected-aura selector alone, shared with the Lua-facing by-family
// refresh so the two cannot disagree about what they match.
//
// SpellFamilyName always has to match. Past that a rule discriminates by a
// SpellFamilyFlags overlap, by SpellIconID, or by both; a zero mask or a zero
// icon drops that half of the test. The icon-only form is what names an aura
// whose SpellFamilyFlags are ZERO — every one of Turtle's custom spells is
// (308 in the hunter family alone), so the mask cannot select them while the
// icon still can, and an icon is shared by a spell's whole rank ladder, which
// keeps it rank-proof exactly like a family flag. Registrars reject the
// degenerate mask-0 + icon-0 rule, which would match a whole class family.
bool AffectedMatchesRaw(const uint8_t *rec, uint32_t family, uint64_t mask,
uint32_t icon) {
if (!Spell::Lookup::IsFitToFamily(rec, family, mask))
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != family)
return false;
if (mask != 0 && !Spell::Lookup::IsFitToFamily(rec, family, mask))
return false;
if (icon != 0 &&
*reinterpret_cast<const uint32_t *>(
@@ -590,8 +646,8 @@ void FlushAuraSignals() {
g_pendingSignalCount = 0;
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
switch (m.op) {
void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
switch (op) {
case MOD_REFRESH:
if (e.durationMs > 0) {
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
@@ -599,14 +655,14 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
}
break;
case MOD_SET:
e.durationMs = static_cast<uint32_t>(m.valueMs);
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
e.durationMs = static_cast<uint32_t>(valueMs);
e.expirationMs = now + static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs)) {
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
if (e.expirationMs > now + static_cast<uint32_t>(valueMs)) {
e.expirationMs -= static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
} else {
e.used = false; // shaved to/past now → server removes it
@@ -619,6 +675,88 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
}
}
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
ApplyModOp(e, m.op, m.valueMs, now);
}
// ---- Deferred duration edits (the trigger creates the aura it edits) ------
//
// A trigger does not always act on an aura that is ALREADY there. Bestial
// Wrath's effect 0 is TRIGGER_SPELL(Scent of Blood), and the server's script
// then stretches the very holder that trigger just created (tortoise-wow
// spell_hunter_bestial_wrath). The trigger's own SMSG_SPELL_GO reaches us
// first, so at trigger time either nothing matches yet, or something matches
// and is overwritten moments later when the triggered cast stores its own base
// duration. Either way the edit is lost.
//
// So a trigger also ARMS its targets, and the next matching store inside the
// window re-applies the edit — from the cast path or the application path,
// whichever the triggered aura arrives on.
//
// Only the IDEMPOTENT ops arm. Re-running refresh or set lands on the same
// value, while reduce would shave a second time and remove would kill an aura
// that was legitimately re-applied. That keeps Conflagrate (reduce) behaving
// exactly as before.
bool OpIsIdempotent(int32_t op) {
return op == MOD_REFRESH || op == MOD_SET;
}
struct PendingMod {
uint64_t target;
uint64_t caster;
uint32_t affectedFamily;
uint64_t affectedMask;
uint32_t affectedIcon;
int32_t op;
int32_t valueMs;
uint32_t untilMs;
bool used;
};
// The triggered cast follows its trigger within one packet burst, so the window
// only has to cover network jitter. Sized like the other arm tables.
constexpr int kPendingModMax = 8;
constexpr uint32_t kPendingModTtlMs = 2000;
PendingMod g_pendingMods[kPendingModMax];
void ArmPendingMod(uint64_t target, uint64_t caster, const DurationMod &m,
uint32_t now) {
PendingMod *slot = nullptr;
for (auto &p : g_pendingMods) {
// Re-arming the same rule on the same target refreshes it in place.
if (p.used && p.target == target && p.affectedFamily == m.affectedFamily &&
p.affectedMask == m.affectedMask && p.affectedIcon == m.affectedIcon) {
slot = &p;
break;
}
if (slot == nullptr && (!p.used || Time::Clock::Reached(now, p.untilMs)))
slot = &p;
}
if (slot == nullptr)
slot = &g_pendingMods[0]; // all live — steal the first
*slot = {target, caster, m.affectedFamily, m.affectedMask,
m.affectedIcon, m.op, m.valueMs, now + kPendingModTtlMs, true};
}
void ConsumePendingMods(Entry &e, uint32_t now) {
for (auto &p : g_pendingMods) {
if (!p.used || p.target != e.targetGuid ||
Time::Clock::Reached(now, p.untilMs))
continue;
// Caster-scoped exactly like the immediate path, adoption included.
if (e.casterGuid != 0 && e.casterGuid != p.caster)
continue;
if (!AffectedMatchesRaw(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)),
p.affectedFamily, p.affectedMask, p.affectedIcon))
continue;
if (e.casterGuid == 0)
e.casterGuid = p.caster;
ApplyModOp(e, p.op, p.valueMs, now);
p.used = false; // one-shot, like the server's single-holder edit
return;
}
}
// On a trigger cast landing on its hit targets, mirror the server's duration
// edit on the caster's own matching cached aura. Called from SpellGo_h before
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
@@ -655,6 +793,11 @@ void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
ApplyMod(e, m, now);
break; // one matching aura per (rule, target), like the server
}
// The aura may not be here yet, or may be about to be overwritten by
// the cast this trigger sets off. Arm the idempotent ops so the edit
// lands when it arrives — see PendingMod.
if (OpIsIdempotent(m.op))
ArmPendingMod(targets[t], caster, m, now);
}
}
}
@@ -663,8 +806,11 @@ bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
int32_t triggerSchool, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
// Trigger must be identified one way or the other, and the affected aura
// needs at least one discriminator past its family — a flag overlap, an
// icon, or both. Neither would match every aura of a class.
if ((triggerSpellId == 0 && triggerFamily == 0) ||
(affectedMask == 0 && affectedIcon == 0) ||
op < MOD_REFRESH || op > MOD_REMOVE)
return false;
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
@@ -742,6 +888,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 +1059,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 +1093,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 +1124,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 +1434,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;
@@ -908,10 +1515,14 @@ const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
// Defer to `Aura::Data::IsSlotHarmful` rather than testing the range here — how
// polarity is encoded differs between servers, and that function is the one
// place that decides. The descriptor write pass has already landed both the
// slot and its flags by the time the application hooks fire.
int8_t KindForSlot(const void *unit, int slot) {
return Aura::Data::IsSlotHarmful(static_cast<const uint8_t *>(unit), slot)
? KIND_HARMFUL
: KIND_HELPFUL;
}
// Stamp expiration for an aura that just landed/refreshed in `slot` on `unit`.
@@ -933,12 +1544,41 @@ 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));
slot, KindForSlot(unit, slot));
}
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
@@ -1043,10 +1683,91 @@ 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) {
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
// Same selector invariant the rule registrar enforces: family plus at
// least one of flag-overlap / icon.
if (unitGuid == 0 || (mask == 0 && icon == 0) || casterGuid == 0)
return 0;
const uint32_t now = NowMs();
for (int i = 0; i < g_usedHigh; ++i) {
@@ -1077,6 +1798,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;
+91 -6
View File
@@ -61,14 +61,45 @@ bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
// if nothing matched. For a mechanic whose duration edit reaches the client
// too late to be attributed to a cast packet, so no rule can express it.
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
// SpellIconID, at least one of the two — to a full duration from now. Returns
// the spellID refreshed, or 0 if nothing matched. For a mechanic whose
// duration edit reaches the client too late to be attributed to a cast packet,
// so no rule can express it.
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,
@@ -78,8 +109,10 @@ enum DurationModOp {
};
// Register a server duration-modifier rule from C++. The trigger is matched by
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
// the two (0 drops that half of the test; a custom spell with no family flags
// is named by its icon alone). `valueMs` is the reduce/set
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
// modules for the server's built-in mods (the family/school-matched variant is
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
@@ -88,6 +121,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;
+1 -1
View File
@@ -143,7 +143,7 @@ void OnWorldTick() {
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
+94
View File
@@ -0,0 +1,94 @@
// 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/>.
// `success, ... = xpcall(f, msgh [, arg1, ...])` — call `f` under `msgh`,
// forwarding the trailing arguments to it.
//
// 1.12's Lua takes only `xpcall(f, msgh)`. Its implementation is explicit
// about it: `lua_settop(L, 2)` throws any further arguments away, then
// `lua_pcall(L, 0, ...)` calls `f` with none. So the argument-forwarding form
// doesn't merely fail — it silently calls `f` with nothing, and a function
// that reads its parameters sees nil. (Note this is NOT a 5.0-vs-5.1
// difference: both take `(f, msgh)`. Forwarding arrived later.)
//
// Callers work around it with a capturing closure —
// `xpcall(function() return f(a, b) end, msgh)` — which is what
// `ExecuteFrameScript` in the embedded addon does. That stays correct; this
// just removes the need for it.
//
// Why a C function and not a Lua wrapper over the original: a Lua wrapper
// would allocate a closure AND an `arg` table on every call (see the vararg
// note in FunctionUtil.lua), putting an allocation on any error-guarded path,
// including per-frame ones. `HookSecureFunc`'s `kUnhookableNames` also lists
// `xpcall` precisely because shadowing a core primitive with a Lua closure
// diverges from what C-side callers get. Replacing it with a real C function
// has neither problem, and matches how `select` and `unpack` are backported
// in this directory.
//
// A two-argument call is bit-for-bit the old behavior (zero forwarded args),
// so this is purely additive for existing code.
#include "Game.h"
namespace BaseLib::XPCall {
namespace {
int __fastcall Script_xpcall(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 2) {
Game::Lua::Error(L, "bad argument #2 to 'xpcall' (value expected)");
return 0; // unreachable
}
// `lua_pcall` wants the callee and its arguments contiguous on top, and
// the handler at a fixed index. Incoming order is
// `f(1), msgh(2), a1(3)..aN(top)`, so the handler sits between the two —
// shuffle it below `f`:
//
// PushValue(2) → f, msgh, a1..aN, msgh
// Insert(1) → msgh, f, msgh, a1..aN (top moved down to index 1)
// Remove(3) → msgh, f, a1..aN
//
// 5.2 does the same reshuffle with a single `lua_rotate`, which this Lua
// has no equivalent of.
Game::Lua::PushValue(L, 2);
Game::Lua::Insert(L, 1);
Game::Lua::Remove(L, 3);
const int status =
Game::Lua::PCall(L, /*nargs*/ top - 2, Game::Lua::MULTRET, /*errfunc*/ 1);
// The stack now holds the handler plus either f's results or the handler's
// return. Both cases want the status boolean in front of them, and the
// handler itself dropped from the return set — which returning
// `GetTop - 1` does, since results are taken from the top.
Game::Lua::PushBool(L, status == 0);
Game::Lua::Insert(L, 2);
return Game::Lua::GetTop(L) - 1;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("xpcall", &Script_xpcall);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("xpcall", &Script_xpcall);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::XPCall
+7 -7
View File
@@ -61,8 +61,8 @@ constexpr int kMaxWaitTicks = 1200; // give up and fire failure after this many
struct Registered {
void *cache;
const char *eventName;
uintptr_t getRecord; // this cache class's _GetRecord
const Event::Custom::AutoReserve *reserve; // completion event's reservation
uintptr_t getRecord; // this cache class's _GetRecord
Pending pending[kMaxPending];
int pendingCount;
};
@@ -79,8 +79,7 @@ Registered *Find(void *cache) {
}
void Fire(const Registered *r, uint32_t id, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(r->eventName),
static_cast<int>(id), success);
Event::Custom::FireIdSuccess(r->reserve->Slot(), static_cast<int>(id), success);
}
int FindPending(const Registered *r, uint32_t id) {
@@ -176,15 +175,16 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || eventName == nullptr || getRecordAddr == 0)
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || reserve == nullptr || getRecordAddr == 0)
return;
if (Find(cacheInstance) != nullptr) // idempotent (safe across /reload)
return;
if (g_cacheCount >= kMaxCaches)
return;
g_caches[g_cacheCount].cache = cacheInstance;
g_caches[g_cacheCount].eventName = eventName;
g_caches[g_cacheCount].reserve = reserve;
g_caches[g_cacheCount].getRecord = getRecordAddr;
g_caches[g_cacheCount].pendingCount = 0;
++g_cacheCount;
+14 -10
View File
@@ -15,6 +15,10 @@
#include <cstdint>
namespace Event::Custom {
struct AutoReserve;
}
// Shared async-load machinery for the client query caches that aren't
// item/quest — creature, gameobject, and (potentially) the other
// SMSG_*_QUERY caches. Modeled on `Item::Data`'s pending/timeout/event
@@ -30,10 +34,9 @@
// functions, so hooking creature's and gameobject's separately is fine);
// item/quest have their own parsers and aren't touched.
//
// static const char *kEvent = "CREATURE_DATA_LOAD_RESULT";
// static const Event::Custom::AutoReserve _r{kEvent}; // reserve the event
// static const Event::Custom::AutoReserve _r{"CREATURE_DATA_LOAD_RESULT"};
// ...
// Cache::QueryLoad::Register(cacheInstance, kEvent,
// Cache::QueryLoad::Register(cacheInstance, &_r,
// Offsets::FUN_CREATURE_GET_RECORD); // at init
// Cache::QueryLoad::RequestLoad(cacheInstance, id); // fire a load
//
@@ -44,13 +47,14 @@ namespace Cache::QueryLoad {
// Register a query cache for async-load tracking. `cacheInstance` is the
// cache global (e.g. `Offsets::VAR_CREATURE_CACHE` cast to void*);
// `eventName` is a Custom event the caller has reserved via
// `Event::Custom::AutoReserve`, fired `(id, success)` on completion;
// `getRecordAddr` is the cache class's `_GetRecord` (e.g.
// `Offsets::FUN_CREATURE_GET_RECORD`). `cacheInstance`/`eventName` must
// outlive the process (pass static values). Idempotent — calling again
// for the same cache is a no-op (safe across /reload).
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr);
// `reserve` is the caller's `Event::Custom::AutoReserve` for the
// completion event, fired `(id, success)`; `getRecordAddr` is the cache
// class's `_GetRecord` (e.g. `Offsets::FUN_CREATURE_GET_RECORD`).
// `cacheInstance`/`reserve` must outlive the process (pass statics).
// Idempotent — calling again for the same cache is a no-op (safe across
// /reload).
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
uintptr_t getRecordAddr);
// True if `id` is already in the cache (pure peek, no network query).
bool IsCached(void *cacheInstance, uint32_t id);
+30 -20
View File
@@ -11,28 +11,32 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Inline-texture (`|T`) chat anti-spoof.
// Inline-texture (`|T`, `|A`) chat anti-spoof.
//
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
// breaks that parity for `|T` alone: it detects the neutralized `||T` form and
// draws the icon anyway, so a player could `/say` `|Tpath|t` and have a real
// icon appear in everyone's chat AND in the speech bubble over their head.
// breaks that parity for the two texture markers: it detects the neutralized
// `||T` / `||A` form and draws the art anyway, so a player could `/say`
// `|Tpath|t` and have a real icon appear in everyone's chat AND in the speech
// bubble over their head.
//
// This restores vanilla's behavior for `|T` by defanging it in the chat path,
// while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw for typed
// input). It CANNOT be done in the text emitter: addon `|T` and player `|T`
// reach the emitter as byte-identical `||T` (vanilla doubles the unrecognized
// `|T` for both), so the only place they're distinguishable is the source —
// server-delivered chat flows through the chat dispatcher, addon `print` does
// not. Chat::Dispatch owns that hook and calls this on the message.
// This restores vanilla's behavior for both markers by defanging them in the
// chat path, while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw
// for typed input). It CANNOT be done in the text emitter: addon `|T` and player
// `|T` reach the emitter as byte-identical `||T` (vanilla doubles the
// unrecognized escape for both), so the only place they're distinguishable is
// the source — server-delivered chat flows through the chat dispatcher, addon
// `print` does not. Chat::Dispatch owns that hook and calls this on the message.
//
// Defang = replace the `|` of each `|T` with a space, breaking the pipe→T
// Defang = replace the `|` of each opener with a space, breaking the pipe→letter
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
// `|T` substring, so this catches the doubled form too). The path text stays
// readable, the closer `|t` is inert without an opener, and every other escape
// is untouched.
// `|T` substring, so this catches the doubled form too). The payload text stays
// readable, the closer is inert without an opener, and every other escape is
// untouched.
//
// ANY new marker the emitter learns MUST be added here in the same change, or it
// becomes a chat-spoof vector.
#include "IconFilter.h"
@@ -40,10 +44,16 @@ namespace Chat::IconFilter {
namespace {
// True if `s` contains an inline-texture opener (`|T`) anywhere.
// True if `s` contains an inline-texture opener anywhere. Both markers count:
// `|T` names a texture path and `|A` names an atlas, and either one would render
// art from player-typed text if it survived to the fontstring.
bool IsIconOpener(const char *s) {
return s[0] == '|' && (s[1] == 'T' || s[1] == 'A');
}
bool HasIconEscape(const char *s) {
for (; *s != '\0'; ++s)
if (s[0] == '|' && s[1] == 'T')
if (IsIconOpener(s))
return true;
return false;
}
@@ -68,12 +78,12 @@ const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
// opener survives for the inline-texture detector to fire on.
size_t j = 0;
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
if (msg[i] == '|' && msg[i + 1] == 'T') {
if (IsIconOpener(msg + i)) {
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
if (j + 1 < bufSize)
buf[j++] = ' '; // blank the `T`
i += 2; // consumed `|T`
buf[j++] = ' '; // blank the `T` / `A`
i += 2; // consumed the opener
} else {
buf[j++] = msg[i++];
}
+7 -2
View File
@@ -86,13 +86,18 @@ std::string StripMarkup(const char *s, unsigned len) {
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
case 'a': // stray atlas closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
case 'A': // atlas opener — skip through to the next |a
{
const char closer = (s[i + 1] == 'A') ? 'a' : 't';
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == closer))
++i;
break; // the |t is dropped by the 't' case on the next iteration
break; // the closer is dropped by the 't'/'a' case next iteration
}
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
+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
+123
View File
@@ -0,0 +1,123 @@
// 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 "CommandLine.h"
#include <windows.h>
namespace Config::CommandLine {
namespace {
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
char Lower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c; }
// Stops at `a`'s terminator rather than reading `len` bytes unconditionally, so
// a switch name at the very end of the command line cannot read past it.
bool EqualsIgnoreCase(const char *a, const char *b, size_t len) {
for (size_t i = 0; i < len; ++i) {
if (a[i] == '\0' || Lower(a[i]) != Lower(b[i]))
return false;
}
return true;
}
size_t Length(const char *s) {
size_t n = 0;
while (s[n] != '\0')
++n;
return n;
}
// Start of the value token for `-name`, or null when the switch is absent.
const char *FindValue(const char *name) {
const char *cmdLine = ::GetCommandLineA();
if (cmdLine == nullptr || name == nullptr)
return nullptr;
const size_t nameLen = Length(name);
if (nameLen == 0)
return nullptr;
for (const char *p = cmdLine; *p != '\0'; ++p) {
if (*p != '-' && *p != '/')
continue;
// Only at a token boundary, so a path containing the name cannot match.
if (p != cmdLine && !IsSpace(p[-1]) && p[-1] != '"')
continue;
if (!EqualsIgnoreCase(p + 1, name, nameLen))
continue;
const char *after = p + 1 + nameLen;
if (*after == '=') {
++after;
} else if (IsSpace(*after)) {
while (IsSpace(*after))
++after;
} else {
continue; // a longer switch that merely starts with `name`
}
return after;
}
return nullptr;
}
// Walks the token at `p`, honoring double quotes so a value containing spaces
// arrives whole. `emit` takes each character and returns whether it was kept;
// the walk stops as a failure the moment one is not.
template <typename Emit> bool ReadToken(const char *p, Emit emit) {
const bool quoted = (*p == '"');
if (quoted)
++p;
size_t n = 0;
while (*p != '\0' && (quoted ? (*p != '"') : !IsSpace(*p))) {
if (!emit(*p++))
return false;
++n;
}
return n > 0;
}
} // namespace
bool Value(const char *name, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
const char *p = FindValue(name);
if (p == nullptr)
return false;
size_t n = 0;
const bool ok = ReadToken(p, [&](char c) {
if (n + 1 >= outSize)
return false;
out[n++] = c;
return true;
});
out[n] = '\0';
return ok;
}
bool Value(const char *name, std::string &out) {
out.clear();
const char *p = FindValue(name);
if (p == nullptr)
return false;
return ReadToken(p, [&](char c) {
out.push_back(c);
return true;
});
}
} // namespace Config::CommandLine
+42
View File
@@ -0,0 +1,42 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include <string>
// Launch-switch parsing, shared by every module that reads one.
//
// A switch is written `-name value` or `-name=value`, and the `/name` form
// Windows tools also accept is honored. The value may be double-quoted so it can
// contain spaces, which a Lua snippet passed on the command line always will.
// The name is matched case-insensitively and only at a token boundary, so a path
// that happens to contain it cannot be mistaken for the switch itself.
//
// One parser for all of them on purpose: a second copy would drift, and the
// quoting and boundary rules are exactly where that drift would go unnoticed.
namespace Config::CommandLine {
// Copies the value of `-name` into `out`. False when the switch is absent, its
// value is empty, or the value does not fit -- a value too long to hold is a
// failure rather than a silent truncation, since a half-copied filename or
// snippet would be acted on as though it were complete.
bool Value(const char *name, char *out, size_t outSize);
// Same, bounded only by the command line itself. For values with no natural
// length, such as a script body.
bool Value(const char *name, std::string &out);
} // namespace Config::CommandLine
+68
View File
@@ -0,0 +1,68 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
// `-config <name>` — a launch switch that swaps which settings file the client
// uses, so one install can hold several profiles.
//
// The engine already names the config file through a single pointer: boot passes
// `"Config.wtf"` to the config initializer, which keeps it and uses it to both
// read the file at startup and write it at exit. Supplying a different string is
// therefore the whole feature — no second code path, and no way for reading and
// writing to disagree. See `Offsets::PATCH_CONFIG_FILENAME_PTR`.
#include "FileSwitch.h"
#include "CommandLine.h"
#include "Offsets.h"
#include <windows.h>
#include <cstddef>
namespace Config::FileSwitch {
namespace {
// The engine holds this pointer for the life of the process and reads through it
// again at exit to save, so the storage has to outlive everything — hence a
// file-static buffer rather than anything owned by a scope.
char g_configName[64];
// A filename is required to be a plain name. The engine builds the save path as
// `WTF\` + this, and a subdirectory that does not exist makes the save fail
// silently, which would throw away a session's settings with nothing to show for
// it. Refusing the switch is the loud failure that a lost config file is not.
bool IsPlainFilename(const char *name) {
if (*name == '\0')
return false;
for (const char *p = name; *p != '\0'; ++p) {
if (*p == '\\' || *p == '/' || *p == ':')
return false;
}
return true;
}
} // namespace
void Apply() {
if (!Config::CommandLine::Value("config", g_configName, sizeof g_configName))
return;
if (!IsPlainFilename(g_configName))
return;
void *target = reinterpret_cast<void *>(Offsets::PATCH_CONFIG_FILENAME_PTR);
DWORD previous = 0;
if (!::VirtualProtect(target, sizeof(const char *), PAGE_EXECUTE_READWRITE, &previous))
return;
*reinterpret_cast<const char **>(target) = g_configName;
::VirtualProtect(target, sizeof(const char *), previous, &previous);
}
} // namespace Config::FileSwitch
+37
View File
@@ -0,0 +1,37 @@
// 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
// `-config <name>` — makes the client read and write `WTF\<name>` in place of
// `WTF\Config.wtf`, so several settings profiles can live side by side.
namespace Config::FileSwitch {
// Reads the switch from the process command line and, when present, points the
// engine's config filename at it.
//
// MUST be called from DllMain, and is the one thing in this project that is.
// The engine loads the config during boot, several calls before the point where
// VanillaFixes invokes our `Load` export, so by the time the normal
// initialization path runs the file has already been read — see
// `Offsets::PATCH_CONFIG_FILENAME_PTR` for the full call ordering. DllMain is
// early enough because VanillaFixes injects every DLL while the game process is
// still suspended and resumes it only afterwards.
//
// This does not install a hook. It is a single 4-byte store into an immediate
// operand, so it costs none of what makes hooking from DllMain a problem: no
// trampoline, no thread suspension, no loader-lock work, and nothing for another
// DLL's hook to collide with.
void Apply();
} // namespace Config::FileSwitch
+170
View File
@@ -0,0 +1,170 @@
// 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/>.
// Lua from the launch command line:
//
// -gluescript <code> run at the glue screens
// -gluescriptFile <path> the file's contents, run at the glue screens
// -gamescript <code> run once in-game
// -gamescriptFile <path> the file's contents, run once in-game
//
// None of these exist in 1.12: the strings do not appear anywhere in the
// binary, so there is nothing to parse them and nothing to un-gate. The whole
// feature is therefore ours -- read the command line, and hand the source to
// the engine's own compile-and-run funnel at the right moment.
//
// WHEN. Registration time is too early for either state. The in-game module
// callback runs from FUN_LOAD_SCRIPT_FUNCTIONS, before FrameXML is loaded, and
// the glue callback runs from FUN_LOAD_GLUE_SCRIPT_FUNCTIONS, before GlueXML
// is. A script that ran there could not touch a frame, call a FrameXML
// function, or see an addon, which is most of what anyone would write one for.
// So each script is armed at registration and run from a tick, where the UI is
// demonstrably up:
//
// glue -> Tick::FrameTick, whose hook is the UI render root and so fires at
// the login and character-select screens. Armed by the glue module
// callback, which runs once per glue boot -- initial launch and every
// return from the world on logout -- so the glue script runs each
// time the glue screens come up, matching what "run at the glue
// screens" says. A script that logs in automatically therefore keeps
// working after a logout, which is the point of it.
// game -> Tick::WorldTick, which fires only in the world, so its first tick
// is past FrameXML, past the addon load pass, and past enter-world
// init. Latched for the life of the process, never re-armed on
// /reload or on a second login, because "run once in-game" says once.
//
// HOW. FUN_LUA_RUN_STRING resolves the Lua state itself, so one call reaches
// whichever state is current and the two paths differ only in when they fire.
// It also installs the FrameScript error handler as the pcall's errfunc, so a
// faulty script reports itself the way any script error does instead of failing
// silently. Because every chunk goes through the shared compile chokepoint,
// these scripts get the same 5.1 syntax support the rest of the client's Lua
// has.
//
// A `*File` is read with Win32 rather than through the engine's file layer, on
// purpose. The engine resolves relative paths against a loose-file index built
// once at boot, so it cannot see a file created afterwards and cannot open an
// absolute path at all -- and an absolute path is exactly what someone puts in
// a shortcut. Reading it ourselves also means both switches share one execution
// path, so inline code and file contents cannot diverge in behaviour.
#include "CommandLine.h"
#include "Offsets.h"
#include "debug/Log.h"
#include "Game.h"
#include "tick/FrameTick.h"
#include "tick/WorldTick.h"
#include <windows.h>
#include <cstdint>
#include <string>
namespace Config::LaunchScript {
namespace {
using RunString_t = int(__fastcall *)(const char *source, uint32_t size, const char *chunkName,
void *errCtx);
// A chunk name carries a leading `=` or `@` to tell Lua how to present it in an
// error. That marker is for Lua, not for a reader, so the log drops it.
const char *Displayed(const std::string &chunkName) {
const char *s = chunkName.c_str();
return (*s == '=' || *s == '@') ? s + 1 : s;
}
void Run(const std::string &source, const std::string &chunkName) {
if (source.empty())
return;
const int ok = reinterpret_cast<RunString_t>(Offsets::FUN_LUA_RUN_STRING)(
source.c_str(), static_cast<uint32_t>(source.size()), chunkName.c_str(), nullptr);
// The engine's error handler has already reported the detail to the user by
// now; this only records that the switch was the source of it.
if (ok == 0)
Debug::Log::Printf("[launchscript] %s did not run to completion", Displayed(chunkName));
}
// Whole-file read. Refuses a file larger than the compiler's own size parameter
// can describe, which is the engine's bound rather than one of ours.
bool ReadWholeFile(const char *path, std::string &out) {
out.clear();
const HANDLE file = ::CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE)
return false;
bool ok = false;
LARGE_INTEGER size{};
if (::GetFileSizeEx(file, &size) != 0 && size.QuadPart >= 0 &&
size.QuadPart <= static_cast<long long>(0xFFFFFFFFu)) {
if (size.QuadPart == 0) {
ok = true; // an empty file is not a failure, just nothing to run
} else {
out.assign(static_cast<size_t>(size.QuadPart), '\0');
DWORD read = 0;
ok = ::ReadFile(file, &out[0], static_cast<DWORD>(out.size()), &read, nullptr) != 0 &&
read == out.size();
}
}
::CloseHandle(file);
if (!ok)
out.clear();
return ok;
}
// Runs the inline switch and then the file switch, so a launch that passes both
// gets both, in the order they are written above.
void RunPair(const char *codeSwitch, const char *fileSwitch) {
std::string code;
if (Config::CommandLine::Value(codeSwitch, code))
Run(code, std::string("=-") + codeSwitch); // `=` shows the switch, not the source
std::string path;
if (!Config::CommandLine::Value(fileSwitch, path))
return;
std::string contents;
if (!ReadWholeFile(path.c_str(), contents)) {
Debug::Log::Printf("[launchscript] -%s could not read '%s'", fileSwitch, path.c_str());
return;
}
Run(contents, "@" + path); // `@` shows the path, so errors carry line numbers
}
bool g_glueArmed = false;
bool g_gameRan = false;
void OnFrameTick() {
if (!g_glueArmed)
return;
g_glueArmed = false;
RunPair("gluescript", "gluescriptFile");
}
void OnWorldTick() {
if (g_gameRan)
return;
g_gameRan = true;
RunPair("gamescript", "gamescriptFile");
}
void ArmGlue() { g_glueArmed = true; }
const Game::GlueModuleAutoRegister _glueAutoreg{&ArmGlue};
const Tick::FrameTick::AutoSubscribe _frameTick{&OnFrameTick};
const Tick::WorldTick::AutoSubscribe _worldTick{&OnWorldTick};
} // namespace
} // namespace Config::LaunchScript
+197
View File
@@ -0,0 +1,197 @@
// 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/>.
// `ConsoleGetAllCommands()` — every console command and console variable the
// client currently has, as `ConsoleCommandInfo` tables.
//
// A plain global, not a `C_Console` member: the wiki files this under
// `C_Console.GetAllCommands`, but on a live client that namespace does not
// exist and `ConsoleGetAllCommands` is the real name.
//
// The engine keeps them in one registry, filled by
// FUN_CONSOLE_COMMAND_REGISTER: a Storm intrusive list for iteration plus a
// by-name hash for dispatch. This walks the list the same way the `help`
// command does, which is the only other thing that enumerates it.
//
// ONE LIST, NOT TWO. Commands and CVars do not live separately. The CVar
// registrar's last act is to register the CVar as a console command as well —
// `FUN_CONSOLE_COMMAND_REGISTER(cvar->name, <shared handler>, categoryId,
// help)` — which is what makes `/console <cvarName> <value>` work. So the
// command list already holds every CVar, and walking the CVar list too (the one
// `cvarlist` and the config saver use) would report each one twice.
//
// That also gives an exact `commandType`: every CVar shares one handler
// address, so a node pointing at it is a CVar and anything else is a command.
// It is a pointer comparison against a value the registrar itself writes, not a
// name or category heuristic.
//
// FIELDS WE CANNOT FILL. `scriptContents` and `scriptParameters` describe
// console macros and scripts, which this client has no notion of — there is no
// registration path that could produce one. They are always the empty string,
// so a caller reading them gets a string rather than a nil surprise. `category`
// is the engine's own number, and the engine's table (debug, graphics, console,
// combat, game, default, net, sound, gm) is Enum.ConsoleCategory's 0..8 exactly,
// so it needs no translation.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Console::Commands {
namespace {
// Enum.ConsoleCommandType. Macro (2) and Script (3) exist in the contract but
// cannot occur here.
constexpr int kCommandTypeCvar = 0;
constexpr int kCommandTypeCommand = 1;
using ConsoleWrite_t = void(__fastcall *)(const char *line, int colorType);
// A list node, or 0 once the walk reaches the end. The low bit tags the
// sentinel, the same convention the addon registry and the parser lists use.
bool IsNode(uintptr_t node) { return node != 0 && (node & 1) == 0; }
char Lower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c; }
bool StartsWithIgnoreCase(const char *name, const char *prefix) {
for (size_t i = 0; prefix[i] != '\0'; ++i) {
if (name[i] == '\0' || Lower(name[i]) != Lower(prefix[i]))
return false;
}
return true;
}
void SetStringField(void *L, const char *key, const char *value) {
Game::Lua::PushString(L, key);
Game::Lua::PushString(L, value != nullptr ? value : "");
Game::Lua::SetTable(L, -3);
}
void SetIntField(void *L, const char *key, int value) {
Game::Lua::PushString(L, key);
Game::Lua::PushNumber(L, static_cast<double>(value));
Game::Lua::SetTable(L, -3);
}
// `ConsoleGetAllCommands() -> ConsoleCommandInfo[]`
int __fastcall Script_GetAllCommands(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const uintptr_t linkOffset =
Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LINK_OFFSET);
uintptr_t node = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LIST_HEAD);
int count = 0;
while (IsNode(node)) {
const auto *name =
Game::Read<const char *>(node + Offsets::OFF_CONSOLE_COMMAND_NAME);
const auto *help =
Game::Read<const char *>(node + Offsets::OFF_CONSOLE_COMMAND_HELP);
const uintptr_t handler =
Game::Read<uint32_t>(node + Offsets::OFF_CONSOLE_COMMAND_HANDLER);
const int category =
Game::Read<int32_t>(node + Offsets::OFF_CONSOLE_COMMAND_CATEGORY);
// Step before building the entry: the walk stays correct even if a
// field read below is skipped.
node = Game::Read<uint32_t>(node + linkOffset + 4);
if (name == nullptr)
continue; // a node the registrar never finished; nothing to report
Game::Lua::PushNumber(L, ++count);
Game::Lua::NewTable(L);
SetStringField(L, "command", name);
SetStringField(L, "help", help);
SetIntField(L, "category", category);
SetIntField(L, "commandType",
handler == Offsets::FUN_CONSOLE_CVAR_COMMAND_HANDLER
? kCommandTypeCvar
: kCommandTypeCommand);
SetStringField(L, "scriptContents", "");
SetStringField(L, "scriptParameters", "");
Game::Lua::SetTable(L, -3);
}
return 1;
}
// Both enums are Blizzard's, stated in full rather than derived from the
// engine's own category table. They are an API contract, not a description of
// what this client happens to have: an addon asking for a value we can never
// return still deserves the number instead of a nil. `Macro` and `Script` have
// no registration path here, and `Reveal` / `None` are not among the engine's
// nine categories -- everything else lines up, which is the point.
// `ConsolePrintAllMatchingCommands(prefix)` — write every command whose name
// starts with `prefix` to the console, the way its own tab completion lists
// candidates. Case-insensitive, matching how the engine resolves a command
// name.
//
// Prints nothing for an empty prefix rather than dumping all 500-odd entries;
// ConsoleGetAllCommands is the way to ask for everything.
int __fastcall Script_ConsolePrintAllMatchingCommands(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const char *prefix = Game::Lua::ToString(L, 1);
if (prefix == nullptr || prefix[0] == '\0')
return 0;
auto write = reinterpret_cast<ConsoleWrite_t>(Offsets::FUN_CONSOLE_WRITE);
const uintptr_t linkOffset =
Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LINK_OFFSET);
uintptr_t node = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_COMMAND_LIST_HEAD);
while (IsNode(node)) {
const auto *name =
Game::Read<const char *>(node + Offsets::OFF_CONSOLE_COMMAND_NAME);
node = Game::Read<uint32_t>(node + linkOffset + 4);
if (name != nullptr && StartsWithIgnoreCase(name, prefix))
write(name, 0);
}
return 0;
}
const Game::Lua::EnumIntegerEntry kCommandTypeEntries[] = {
{"Cvar", kCommandTypeCvar},
{"Command", kCommandTypeCommand},
{"Macro", 2},
{"Script", 3},
};
// The engine's table at VAR_CONSOLE_CATEGORY_TABLE holds ids 0..8 named debug,
// graphics, console, combat, game, default, net, sound, gm, in that order --
// so a `category` read off a command node is already one of these.
const Game::Lua::EnumIntegerEntry kCategoryEntries[] = {
{"Debug", 0}, {"Graphics", 1}, {"Console", 2}, {"Combat", 3},
{"Game", 4}, {"Default", 5}, {"Net", 6}, {"Sound", 7},
{"Gm", 8}, {"Reveal", 9}, {"None", 10},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ConsoleGetAllCommands", &Script_GetAllCommands);
Game::Lua::RegisterGlobalFunction("ConsolePrintAllMatchingCommands",
&Script_ConsolePrintAllMatchingCommands);
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCommandType", kCommandTypeEntries,
sizeof(kCommandTypeEntries) /
sizeof(kCommandTypeEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCategory", kCategoryEntries,
sizeof(kCategoryEntries) /
sizeof(kCategoryEntries[0]));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Console::Commands
+240
View File
@@ -0,0 +1,240 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The developer console, as Lua functions: running commands, writing lines,
// and reading the console's own state and appearance.
//
// `ConsoleExec` is the one with real reach. Nothing in this client's Lua could
// run a console command before, so every entry ConsoleGetAllCommands returns
// was visible and unreachable at once. It is the engine's own executor, the
// same one that replays each `SET` line of Config.wtf at boot.
//
// THAT INCLUDES `set`, AND `set` IS NOT SetCVar. The console's set handler
// looks a cvar up with the unfiltered lookup and performs no read-only check,
// while Script_SetCVar tests flag bit 0x4 first and refuses. So
// `ConsoleExec("set <name> <value>")` can write a cvar that
// `SetCVar` rejects, and can reach cvars no Lua getter can see (Offsets.h at
// CVAR_FLAG_READ_ONLY has the mechanism). That is inherent to exposing the
// console rather than something added here -- an addon already runs arbitrary
// Lua -- but it does mean the read-only guarantee C_CVar.GetCVarInfo reports
// stops being absolute once this exists. Deliberate: a console you cannot run
// commands through is not a console.
//
// Everything else is state the console module already keeps. The colours are
// its own nine-entry table, the font height its own global, and the toggle key
// its own byte, so each is read or written where the engine reads or writes it
// rather than being modelled again here.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Console::Shell {
namespace {
using ConsoleExec_t = void(__fastcall *)(const char *line, int addToHistory);
using ConsoleWrite_t = void(__fastcall *)(const char *line, int colorType);
using ConsoleSetKey_t = void(__fastcall *)(int keyCode);
// `ConsoleExec(command)` — run a console command line.
//
// Passes addToHistory = 1 so a command run from Lua appears in the console's
// own history, which is what someone stepping through the console afterwards
// expects to find.
int __fastcall Script_ConsoleExec(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
reinterpret_cast<ConsoleExec_t>(Offsets::FUN_CONSOLE_EXEC)(Game::Lua::ToString(L, 1), 1);
return 0;
}
// `ConsoleEcho(message [, colorType])` — write one line to the console.
//
// The engine no-ops cleanly when the graphics device is not up, so this is safe
// to call at any time, and the line is kept whether or not the console is
// currently open.
int __fastcall Script_ConsoleEcho(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
int colorType = 0;
if (Game::Lua::IsNumber(L, 2)) {
colorType = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (colorType < 0 || colorType >= Offsets::CONSOLE_COLOR_COUNT)
colorType = 0;
}
reinterpret_cast<ConsoleWrite_t>(Offsets::FUN_CONSOLE_WRITE)(Game::Lua::ToString(L, 1),
colorType);
return 0;
}
// `ConsoleIsActive()` — whether the console is open right now.
//
// False when the client was started without `-console`, since the toggle key
// is gated on that and the console can then never open.
int __fastcall Script_ConsoleIsActive(void *L) {
const uint32_t enabled = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_ENABLED);
const uint32_t visible = Game::Read<uint32_t>(Offsets::VAR_CONSOLE_VISIBLE);
Game::Lua::PushBool(L, enabled != 0 && visible != 0);
return 1;
}
// `ConsoleGetColorFromType(colorType)` -> r, g, b, a
//
// The colour the console draws that type of line in, as four 0..1 components to
// match every other colour in this API. Nil for a type outside the table.
int __fastcall Script_ConsoleGetColorFromType(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int type = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (type < 0 || type >= Offsets::CONSOLE_COLOR_COUNT) {
Game::Lua::PushNil(L);
return 1;
}
const uint32_t argb = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_CONSOLE_COLORS + static_cast<uintptr_t>(type) * sizeof(uint32_t));
Game::Lua::PushNumber(L, ((argb >> 16) & 0xFF) / 255.0);
Game::Lua::PushNumber(L, ((argb >> 8) & 0xFF) / 255.0);
Game::Lua::PushNumber(L, (argb & 0xFF) / 255.0);
Game::Lua::PushNumber(L, ((argb >> 24) & 0xFF) / 255.0);
return 4;
}
// `ConsoleGetFontHeight()` — the console's text height, as a fraction of the
// screen height.
int __fastcall Script_ConsoleGetFontHeight(void *L) {
Game::Lua::PushNumber(L, *reinterpret_cast<const float *>(Offsets::VAR_CONSOLE_FONT_HEIGHT));
return 1;
}
// There is deliberately no ConsoleSetFontHeight. Writing the height global
// alone changes nothing, since the font was already built at the old size, and
// the only thing that rebuilds it is the console's font init — which has no
// code callers at all, just one entry in a table at 0x0080E170. It runs once,
// during startup, before a single console line exists.
//
// Re-running it crashes, on a later frame rather than in the call: it frees the
// old font handle out from under the cached, font-dependent draw object every
// existing line holds at +0x20, and the next console render walks those. The
// console's own shutdown frees both together, which is what that pairing is
// for. Making a setter safe would mean replaying that teardown across every
// line, to change a text size nothing asks to change.
// `SetConsoleKey(keyCode)` — the key that opens and closes the console.
//
// Takes the engine's own key code, the value its key events carry, rather than
// a key name: the console module stores and compares exactly that, and nothing
// in it translates names.
int __fastcall Script_SetConsoleKey(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
reinterpret_cast<ConsoleSetKey_t>(Offsets::FUN_CONSOLE_SET_KEY)(
static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
// `CalculateStringEditDistance(a, b)` — Levenshtein distance, the number of
// single-character insertions, deletions or substitutions between two strings.
// The console uses it to suggest a command when one is mistyped.
//
// Two rolling rows rather than a full matrix, so the cost is bounded by the
// shorter string; the longer one only ever drives the loop.
int __fastcall Script_CalculateStringEditDistance(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const char *a = Game::Lua::ToString(L, 1);
const char *b = Game::Lua::ToString(L, 2);
size_t lenA = 0, lenB = 0;
while (a[lenA] != '\0')
++lenA;
while (b[lenB] != '\0')
++lenB;
if (lenA == 0 || lenB == 0) {
Game::Lua::PushNumber(L, static_cast<double>(lenA + lenB));
return 1;
}
// Index the rows by the shorter string so the buffers stay small whichever
// way round the arguments came in.
if (lenB > lenA) {
const char *t = a;
a = b;
b = t;
const size_t n = lenA;
lenA = lenB;
lenB = n;
}
constexpr size_t kMaxRow = 256; // beyond this the answer is of no practical use
if (lenB >= kMaxRow) {
Game::Lua::PushNil(L);
return 1;
}
size_t prev[kMaxRow], curr[kMaxRow];
for (size_t j = 0; j <= lenB; ++j)
prev[j] = j;
for (size_t i = 1; i <= lenA; ++i) {
curr[0] = i;
for (size_t j = 1; j <= lenB; ++j) {
const size_t cost = (a[i - 1] == b[j - 1]) ? 0u : 1u;
size_t best = prev[j] + 1; // deletion
if (curr[j - 1] + 1 < best)
best = curr[j - 1] + 1; // insertion
if (prev[j - 1] + cost < best)
best = prev[j - 1] + cost; // substitution
curr[j] = best;
}
for (size_t j = 0; j <= lenB; ++j)
prev[j] = curr[j];
}
Game::Lua::PushNumber(L, static_cast<double>(prev[lenB]));
return 1;
}
void Register() {
Game::Lua::RegisterGlobalFunction("ConsoleExec", &Script_ConsoleExec);
Game::Lua::RegisterGlobalFunction("ConsoleEcho", &Script_ConsoleEcho);
Game::Lua::RegisterGlobalFunction("ConsoleIsActive", &Script_ConsoleIsActive);
Game::Lua::RegisterGlobalFunction("ConsoleGetColorFromType",
&Script_ConsoleGetColorFromType);
Game::Lua::RegisterGlobalFunction("ConsoleGetFontHeight", &Script_ConsoleGetFontHeight);
Game::Lua::RegisterGlobalFunction("SetConsoleKey", &Script_SetConsoleKey);
Game::Lua::RegisterGlobalFunction("CalculateStringEditDistance",
&Script_CalculateStringEditDistance);
}
// The console exists before login, so the same surface is registered on the
// glue state.
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("ConsoleExec", &Script_ConsoleExec);
Game::Lua::RegisterGlueFunction("ConsoleEcho", &Script_ConsoleEcho);
Game::Lua::RegisterGlueFunction("ConsoleIsActive", &Script_ConsoleIsActive);
Game::Lua::RegisterGlueFunction("ConsoleGetColorFromType",
&Script_ConsoleGetColorFromType);
Game::Lua::RegisterGlueFunction("ConsoleGetFontHeight", &Script_ConsoleGetFontHeight);
Game::Lua::RegisterGlueFunction("SetConsoleKey", &Script_SetConsoleKey);
Game::Lua::RegisterGlueFunction("CalculateStringEditDistance",
&Script_CalculateStringEditDistance);
}
const Game::ModuleAutoRegister _autoreg{&Register};
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlue};
} // namespace
} // namespace Console::Shell
+74
View File
@@ -0,0 +1,74 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.AutoStoreItem(srcBag, srcSlot [, dstBag])` — move an item
// and let the SERVER choose the destination slot. A ClassicAPI-only
// extension, like its siblings `SwapItems` and `MoveItem`; the modern
// API has no equivalent, because it has no need for one.
//
// The reason to want it: the server does not just find an empty slot.
// It first merges the stack into existing non-full stacks of the same
// item, splitting it across several of them when that is what fits, and
// only places a remainder if any is left over. So this is a one-call
// stack consolidator that needs no stack-size lookup — the caller never
// has to know what fits. A merge computed on this side has to size
// every stack first, and `C_Item.GetItemMaxStackSizeByID` is nil until
// that item's data has arrived, so a cold item cache silently skips
// exactly the stacks it could not size.
//
// `dstBag` defaults to 0, which means "anywhere in the main inventory
// that it fits" rather than "the backpack" — on the wire the backpack
// IS the player container, so the two cannot be distinguished. Pass
// 1..4 to confine the item to one equipped bag. Bank destinations are
// rejected (see [[item/Swap.h]] `AutoStore`).
//
// Returns true once the packet is away — like the other two, the send
// is fire-and-forget and the server confirms through the normal
// BAG_UPDATE / SMSG_INVENTORY_CHANGE_FAILURE flow.
#include "Game.h"
#include "item/Swap.h"
namespace Container::AutoStoreItem {
namespace {
int __fastcall Script_C_Container_AutoStoreItem(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.AutoStoreItem(srcBag, srcSlot [, dstBag])");
return 0;
}
// Read every arg before calling AutoStore — its source lookup goes
// through ResolveBag, which stomps the Lua stack via PackBagSlot.
const int srcBag = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int srcSlot = static_cast<int>(Game::Lua::ToNumber(L, 2));
const int dstBag = Game::Lua::IsNumber(L, 3)
? static_cast<int>(Game::Lua::ToNumber(L, 3))
: 0;
const bool ok = Item::Swap::AutoStore(L, srcBag, srcSlot, dstBag);
Game::Lua::PushBool(L, ok);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "AutoStoreItem",
&Script_C_Container_AutoStoreItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::AutoStoreItem
+106
View File
@@ -0,0 +1,106 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.GetContainerItemEquipmentSetInfo(containerIndex, slotIndex)`
// — whether the item in a bag slot belongs to an equipment set, and the
// names of the sets it belongs to:
//
// inSet, setList = C_Container.GetContainerItemEquipmentSetInfo(bag, slot)
//
// The lookup is by the item's per-instance GUID against the GUIDs
// `EquipmentSet::Data` saved into each set, so it names the one physical
// item that was saved rather than every copy of that itemID — a spare
// of the same item sitting in the next slot reports `false`. The set's
// reserved empty / ignored slot markers can never match a real GUID,
// and `Data::SetsContainingItem` rejects them outright.
//
// `setList` is the set names joined with `", "`, in set order, which is
// the shape a caller formats straight into a tooltip line. It is `nil`
// rather than an empty string when the item is in no set, so the usual
// `if inSet then … setList … end` guard is all a caller needs.
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Data.h"
#include "equipmentset/Set.h"
#include "item/CGItem.h"
#include "item/Location.h"
#include <cstdint>
#include <string>
#include <vector>
namespace Container::EquipmentSetInfo {
namespace {
// The separator between names in `setList`. One place, so the string a
// caller splits on is stated once.
constexpr const char *kSeparator = ", ";
uint64_t ItemGuid(const uint8_t *cgItem) {
auto *instance = Item::InstanceBlock(cgItem);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
int __fastcall Script_C_Container_GetContainerItemEquipmentSetInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.GetContainerItemEquipmentSetInfo(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
// `ResolveBag` stomps stack slots 1 and 2 through the engine's
// `PackBagSlot`, so both arguments are already in locals above and
// nothing we need survives on the stack across it.
const uint64_t guid =
ItemGuid(Item::Location::ResolveBag(L, bagID, slotIndex));
const std::vector<const EquipmentSet::Set *> sets =
EquipmentSet::Data::SetsContainingItem(guid);
Game::Lua::SetTop(L, 0);
if (sets.empty()) {
Game::Lua::PushBool(L, false);
Game::Lua::PushNil(L);
return 2;
}
std::string setList;
for (size_t i = 0; i < sets.size(); ++i) {
if (i > 0)
setList += kSeparator;
setList += sets[i]->name;
}
Game::Lua::PushBool(L, true);
Game::Lua::PushString(L, setList.c_str());
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction(
"C_Container", "GetContainerItemEquipmentSetInfo",
&Script_C_Container_GetContainerItemEquipmentSetInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::EquipmentSetInfo
+43
View File
@@ -19,6 +19,7 @@
#include "item/Record.h"
#include <cstdint>
#include <vector>
namespace Container::FreeSlots {
@@ -115,6 +116,46 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
return 2;
}
// `C_Container.GetContainerFreeSlots(bagID)` — which slots are empty,
// as an array of slot numbers in ascending order, rather than
// `GetContainerNumFreeSlots`'s count of them. A full bag returns an
// empty table; a bag that isn't there returns nothing, which is how
// callers tell "no free slots" from "no such bag".
//
// `bagID` is 0..4 like the rest of this file (0 = backpack, 1..4 =
// equipped bag slots); bank and keyring indices are the "no such bag"
// case.
int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_Container.GetContainerFreeSlots(bagID)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount <= 0)
return 0; // nothing — no such bag
// Collect before building the table. Each `ResolveBag` stomps stack
// slots 1 and 2, so the table cannot be on the stack during the walk.
std::vector<int> freeSlots;
freeSlots.reserve(static_cast<size_t>(info.slotCount));
for (int slot = 1; slot <= info.slotCount; ++slot) {
if (Item::Location::ResolveBag(L, bagID, slot) == nullptr)
freeSlots.push_back(slot);
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
for (size_t i = 0; i < freeSlots.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(freeSlots[i]));
Game::Lua::RawSet(L, -3);
}
return 1;
}
// `C_Container.CalculateTotalNumberOfFreeBagSlots()` — the total number of
// free slots across the player's *general-purpose* bags (backpack + equipped
// bags 0..4 whose BagFamily is 0). Specialty bags (quivers, soul bags, herb
@@ -146,6 +187,8 @@ int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
&Script_C_Container_GetContainerNumFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "GetContainerFreeSlots",
&Script_C_Container_GetContainerFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
}
+2 -14
View File
@@ -49,32 +49,20 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Icon.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/StatAccum.h"
#include <cstdint>
#include <cstdio>
namespace Container::ItemInfo {
namespace {
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
out[0] = '\0';
const char *iconName = DBC::StringField(
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
if (iconName == nullptr || iconName[0] == '\0')
return false;
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
return true;
}
// Mirrors the engine's `readable` decision (0x004F97BD..0x004F97CF): a slot
// is readable when its static page text is set OR the per-instance
// readable-text id is non-zero (letters / server-authored notes).
@@ -137,7 +125,7 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
if (record != nullptr) {
const uint32_t displayInfoID = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
haveIcon = Item::Icon::PathForDisplayInfoID(displayInfoID, iconPath, sizeof(iconPath));
quality = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_QUALITY);
haveQuality = true;
+113
View File
@@ -0,0 +1,113 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.GetContainerItemQuestInfo(containerIndex, slotIndex)` —
// the quest state of a bag slot, as one table:
//
// { isQuestItem = bool, questID = number|nil, isActive = bool }
//
// This is what draws the two overlays on a bag button: a `questID` the
// player has not accepted yet gets the exclamation mark, and either an
// accepted `questID` or a plain quest item gets the question-mark
// border.
//
// The three fields come from three places:
//
// isQuestItem the item's class is Quest — an item that exists to be
// a quest objective.
// questID the quest the item BEGINS (`m_startQuest` in the
// cached item record). Absent, not zero, when the item
// starts nothing — callers test `if questInfo.questID`.
// isActive that started quest is already in the quest log.
//
// The table is always returned, even for an empty slot or an item whose
// data has not arrived yet, so callers can index it without a nil test.
// An uncached item warms the cache, so the next bag update draws the
// overlay the first hover could not.
#include "Game.h"
#include "Offsets.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "quest/Log.h"
#include <cstdint>
namespace Container::QuestInfo {
namespace {
// ItemClass.dbc row 12. Only this file cares which class means "quest",
// so the code stays local per the single-use rule in the offsets policy.
constexpr uint32_t kItemClassQuest = 12;
int __fastcall Script_C_Container_GetContainerItemQuestInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.GetContainerItemQuestInfo(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
// Resolve before building the table: `ResolveBag` stomps stack
// slots 1 and 2 through the engine's `PackBagSlot`, so nothing we
// want to keep may be on the stack across this call.
const int itemID =
Item::ID::FromCGItem(Item::Location::ResolveBag(L, bagID, slotIndex));
bool isQuestItem = false;
uint32_t startQuest = 0;
if (itemID != 0) {
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
// Cold cache. Report the empty answer now and fetch, so the
// overlay is right on the next bag update.
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
} else {
isQuestItem = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS) ==
kItemClassQuest;
startQuest = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_START_QUEST);
}
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
Game::Lua::SetFieldBool(L, "isQuestItem", isQuestItem);
if (startQuest > 0) {
Game::Lua::SetFieldNumber(L, "questID",
static_cast<double>(startQuest));
Game::Lua::SetFieldBool(
L, "isActive", Quest::Log::IsOnQuest(static_cast<int>(startQuest)));
} else {
// `questID` stays absent rather than 0 — the field is nilable and
// every caller tests it for truth.
Game::Lua::SetFieldBool(L, "isActive", false);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemQuestInfo",
&Script_C_Container_GetContainerItemQuestInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::QuestInfo
+936
View File
@@ -0,0 +1,936 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.SortBags()` — arrange the player's bags.
//
// Two phases, because every item move is a server round trip and the
// second phase has to read slot contents the first phase changed.
//
// PHASE ONE consolidates partial stacks, and hands that entirely to the
// server: one `CMSG_AUTOSTORE_BAG_ITEM` per redundant stack, which
// merges into existing stacks of the same item and splits across several
// of them when that is what fits. Nothing here computes what fits, so
// nothing here needs an item's max stack size — which matters, because
// that number is unavailable until the item's data has arrived from the
// server, and a merge that sizes stacks first silently skips the ones it
// could not size.
//
// PHASE TWO places items. It runs on the next `Bag::UpdateDelayed` — the
// engine's own "a bag changed this frame" signal — so the merges are
// settled before anything is read. It plans the whole layout up front
// and then tracks each item's live position as swaps fire, rather than
// re-reading a layout it is in the middle of mutating. Our swap is
// fire-and-forget and sets no client-side item lock, so the whole batch
// goes out in one frame.
//
// Neither phase may touch the Lua stack: phase two runs from a bag-update
// callback, outside any Lua call, where the engine may have its own
// values on the stack. Hence the `*From` senders and the direct invMgr
// walk rather than `ResolveBag`.
//
// THE ORDERING IS OURS. Retail computes it in code we cannot read, and
// its categories (per-bag assignment flags, expansion filters) have no
// counterpart here, so this is a defensible order rather than a
// reproduction of Blizzard's: hearthstone, gear by descending quality,
// consumables, reagents, trade goods, quest items, everything else by
// quality, junk last. Within a category, by item class, subclass, name,
// then fuller stacks first.
//
// An item whose data has NOT arrived is pinned: left where it is, with
// its slot withheld from the destination pool so nothing else is planned
// into it. It cannot be ranked without a class, quality and name, and
// guessing scatters it. A later call places it once the data lands. Phase
// one is unaffected, since it needs only the item ID.
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "cvar/Factory.h"
#include "item/BagFamily.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Swap.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
#include <vector>
namespace Container::SortBags {
namespace {
// The two sets this can arrange. Both go through the same snapshot,
// comparator and placement; they differ only in how phase one merges
// (see ConsolidateViaServer / ConsolidateLocally).
constexpr int kInventoryBags[] = {0, 1, 2, 3, 4};
constexpr int kBankBags[] = {-1, 5, 6, 7, 8, 9, 10};
// Fill direction, persisted like every other engine setting: the cvar
// carries it to Config.wtf and `GetCVar("sortBagsRightToLeft")` reads it,
// so it survives a session the way the retail setting does.
CVar::Factory::Handle g_rightToLeftCVar = nullptr;
bool RightToLeft() {
return CVar::Factory::GetInt(g_rightToLeftCVar, 0) != 0;
}
// The two containers a sort can be told to leave alone. An equipped bag
// is excluded per-bag through its own `DisableAutoSort` slot flag; the
// backpack and the main bank container have no such slot, so each gets
// its own setting. Persisted as cvars for the same reason the fill
// direction is.
CVar::Factory::Handle g_backpackAutosortCVar = nullptr;
CVar::Factory::Handle g_bankAutosortCVar = nullptr;
bool BackpackAutosortDisabled() {
return CVar::Factory::GetInt(g_backpackAutosortCVar, 0) != 0;
}
bool BankAutosortDisabled() {
return CVar::Factory::GetInt(g_bankAutosortCVar, 0) != 0;
}
// Merge packets sent by the most recent phase one, reset per sort.
int g_mergePackets = 0;
// The source slots phase one asked the server to empty. Phase two counts
// how many are still occupied when it runs: any at all means it is
// planning against a half-merged snapshot, because the server's replies
// did not all arrive in the bag update that woke it.
struct SourceSlot {
int bag, slot;
};
std::vector<SourceSlot> g_mergeSources;
// Item.dbc `m_class` values we rank on. Per-module codes, the way
// `Item::BagFamily` keeps its own.
constexpr uint32_t kClassConsumable = 0;
constexpr uint32_t kClassWeapon = 2;
constexpr uint32_t kClassArmor = 4;
constexpr uint32_t kClassReagent = 5;
constexpr uint32_t kClassTradeGoods = 7;
constexpr uint32_t kClassQuest = 12;
constexpr uint32_t kQualityPoor = 0;
constexpr uint32_t kQualityUncommon = 2;
constexpr uint32_t kQualityRare = 3;
constexpr uint32_t kQualityEpic = 4;
// Primary key. Lower sorts nearer the first slot.
enum Category : uint8_t {
kCatHearthstone = 0,
kCatGearEpic,
kCatGearRare,
kCatGearUncommon,
kCatGearPlain,
kCatConsumable,
kCatReagent,
kCatTradeGoods,
kCatQuest,
kCatOtherEpic,
kCatOtherRare,
kCatOtherUncommon,
kCatOtherPlain,
kCatJunk,
};
uint8_t CategoryFor(uint32_t itemID, uint32_t itemClass, uint32_t quality) {
if (itemID == Offsets::HEARTHSTONE_ITEM_ID)
return kCatHearthstone;
if (quality == kQualityPoor)
return kCatJunk;
if (itemClass == kClassWeapon || itemClass == kClassArmor) {
if (quality >= kQualityEpic)
return kCatGearEpic;
if (quality == kQualityRare)
return kCatGearRare;
if (quality == kQualityUncommon)
return kCatGearUncommon;
return kCatGearPlain;
}
if (itemClass == kClassConsumable)
return kCatConsumable;
if (itemClass == kClassReagent)
return kCatReagent;
if (itemClass == kClassTradeGoods)
return kCatTradeGoods;
if (itemClass == kClassQuest)
return kCatQuest;
if (quality >= kQualityEpic)
return kCatOtherEpic;
if (quality == kQualityRare)
return kCatOtherRare;
if (quality == kQualityUncommon)
return kCatOtherUncommon;
return kCatOtherPlain;
}
struct Entry {
const void *item; // CGItem — stable across the swap batch
int curBag, curSlot; // live position, updated as swaps fire
// Destination, or unassigned. The unassigned sentinel is destSLOT,
// never destBag: slots are 1-based so a negative one is impossible,
// whereas bagID -1 is the main bank. Testing destBag < 0 here treats
// every item bound for the main bank as unplaced and silently drops
// its move.
int destBag, destSlot;
uint32_t itemID;
uint32_t family; // the item's own bag family (0 = fits anywhere)
uint32_t itemClass, subClass, count;
const char *name;
uint8_t category;
};
struct Cell {
int bag, slot;
};
char Lower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
}
// A missing name sorts after every present one. Substituting a
// placeholder string instead (libbagsort uses "zzz") wedges the unnamed
// item in among items actually named with those letters; this keeps it
// deterministic and at the end. In practice a nameless item is pinned
// before it reaches here, so this is the belt to that braces.
int CompareNames(const char *a, const char *b) {
if (a == b)
return 0;
if (a == nullptr)
return 1;
if (b == nullptr)
return -1;
for (size_t i = 0;; ++i) {
const char ca = Lower(a[i]), cb = Lower(b[i]);
if (ca != cb)
return ca < cb ? -1 : 1;
if (ca == '\0')
return 0;
}
}
bool Precedes(const Entry &a, const Entry &b) {
if (a.category != b.category)
return a.category < b.category;
if (a.itemClass != b.itemClass)
return a.itemClass < b.itemClass;
if (a.subClass != b.subClass)
return a.subClass < b.subClass;
const int byName = CompareNames(a.name, b.name);
if (byName != 0)
return byName < 0;
if (a.count != b.count)
return a.count > b.count; // fuller stacks first
return a.itemID < b.itemID; // total order, so the result is stable
}
// A bag's storage, addressed without Lua: the invMgr holding its GUID
// array, the linear index its first slot sits at, and the family it
// accepts. The backpack lives inside the player's own invMgr; bags 1..4
// each have one of their own.
struct BagView {
const uint8_t *invMgr;
int firstLinear;
int slotCount;
uint32_t family;
};
// The backpack and the main bank both live inside the player's own
// invMgr, at different linear bases. Equipped bags and bank bags each
// have an invMgr of their own, reached through the bag item.
bool ViewForBag(int bagID, BagView *out) {
if (bagID == 0) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, Offsets::INVMGR_BACKPACK_FIRST_SLOT,
Offsets::BACKPACK_NUM_SLOTS, 0};
return true;
}
if (bagID == -1) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT -
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1,
0};
return true;
}
// Resolve the bag item that owns the container. Bank bags resolve
// only once the bank window has been opened this session, which is
// the right gate: the server refuses bank moves without it anyway,
// so a bank sort attempted with the bank closed finds no cells and
// does nothing rather than firing moves that would all be rejected.
int equipSlot = 0;
if (bagID >= 1 && bagID <= 4)
equipSlot = Offsets::INVSLOT_BAG1 + bagID - 1;
else if (bagID >= 5 && bagID <= 10)
equipSlot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT + 1 + (bagID - 5);
else
return false;
auto *bagItem = Item::Location::ResolveEquipmentSlot(equipSlot);
if (bagItem == nullptr)
return false;
auto *bagInv = static_cast<const uint8_t *>(
Item::Location::ContainerInventory(bagItem));
if (bagInv == nullptr)
return false;
const int slots = static_cast<int>(
Game::Read<uint32_t>(bagInv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (slots <= 0)
return false;
// A specialty bag only accepts its own family, and Turtle leaves the
// raw field empty on bags — `BitmaskForRecord` derives it from the
// container subclass, so a quiver reports a family here rather than
// reading as general-purpose. Shared with `C_Item.GetItemFamily`, so
// a bag and the items it holds agree.
uint32_t family = 0;
const int bagItemID = Item::ID::FromCGItem(bagItem);
if (bagItemID > 0) {
auto *rec = Item::PeekRecord(static_cast<uint32_t>(bagItemID));
if (rec != nullptr)
family = Item::BagFamily::BitmaskForRecord(rec);
}
*out = {bagInv, 0, slots, family};
return true;
}
const uint8_t *ItemAt(const BagView &v, int slotIndex0) {
auto *guids = *reinterpret_cast<const uint64_t *const *>(
v.invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return nullptr;
const uint64_t guid = guids[v.firstLinear + slotIndex0];
if (guid == 0)
return nullptr;
return static_cast<const uint8_t *>(
Object::ByGuid(Offsets::TYPEMASK_ITEM, guid, "SortBags", 0));
}
uint32_t StackCount(const uint8_t *item) {
auto *fields = Item::ObjectFields(item);
if (fields == nullptr)
return 1;
const uint32_t n =
Game::Read<uint32_t>(fields, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
return n == 0 ? 1 : n;
}
// --- phase one: ask the server to consolidate -------------------------------
// Returns true if any packet went out. Every stack after the first of
// its item ID is offered to the server, which merges what fits and
// rehomes the rest.
//
// When the item's record is cached we skip the ones that provably cannot
// stack, purely to save a packet. When it is NOT cached we send anyway:
// the alternative is silently skipping a merge we could not size, which
// is the failure this whole design avoids.
// One stack per group of the same item, as gathered for either merge.
struct Stack {
const uint8_t *item;
int bag, slot;
uint32_t count;
};
struct Group {
uint32_t itemID;
uint32_t maxStack; // 0 when the item's data has not arrived
std::vector<Stack> stacks;
};
// Collects every stackable item across `bags`, grouped by item ID.
// `maxStack` is 0 for an item whose data has not arrived; the callers
// differ in what they do about that.
std::vector<Group> GatherGroups(const int *bags, int bagCount) {
std::vector<Group> groups;
for (int b = 0; b < bagCount; ++b) {
const int bag = bags[b];
BagView view;
if (!ViewForBag(bag, &view))
continue;
for (int i = 0; i < view.slotCount; ++i) {
const uint8_t *item = ItemAt(view, i);
if (item == nullptr)
continue;
const int itemID = Item::ID::FromCGItem(item);
if (itemID <= 0)
continue;
const uint32_t id = static_cast<uint32_t>(itemID);
uint32_t maxStack = 0;
auto *rec = Item::PeekRecord(id);
if (rec != nullptr) {
maxStack =
Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_STACKABLE);
if (maxStack <= 1)
continue; // cannot stack at all, so never a merge candidate
}
Group *group = nullptr;
for (auto &candidate : groups) {
if (candidate.itemID == id) {
group = &candidate;
break;
}
}
if (group == nullptr) {
groups.push_back({id, maxStack, {}});
group = &groups.back();
}
group->stacks.push_back({item, bag, i + 1, StackCount(item)});
}
}
return groups;
}
// Why this is computed here rather than delegated to the server's own
// autostore, which can merge a stack into several destinations for one
// packet and would otherwise be cheaper:
//
// `CMSG_AUTOSTORE_BAG_ITEM` names its destination with a container byte,
// and the only value the engine's builder will emit for "the player" is
// `INVENTORY_SLOT_BAG_0` (255). Server-side that is NOT the search-every-
// bag path — that one is `NULL_BAG` (0), which the builder cannot
// express. 255 takes the specific-container branch, which searches the
// keyring and the backpack and nothing else. So autostore can never
// merge a stack in one equipped bag with a stack in another, which is
// most of the real cases.
//
// Addressing both endpoints explicitly costs a packet per transfer
// instead of per emptied source — occasionally one more, when a stack
// has to be split across several destinations — and works everywhere.
// Bank consolidation has to be computed here. `CMSG_AUTOSTORE_BANK_ITEM`
// always CROSSES the inventory/bank boundary — a bank-sourced item
// travels out to the bags — so there is no "find this a fuller stack
// inside the bank" packet to delegate to. This is the one place the
// protocol forces a client-side merge, and it carries the cost that
// comes with that: it needs each item's max stack size, which is
// unavailable until that item's data has arrived, so a stack we cannot
// size is left alone.
//
// Two rolling pointers per item: stacks fullest-first, then poured from
// the back into the front until they meet.
bool Consolidate(const int *bags, int bagCount) {
std::vector<Group> groups = GatherGroups(bags, bagCount);
bool fired = false;
for (auto &group : groups) {
if (group.stacks.size() < 2)
continue;
// Pack fully, not just enough to free a slot: 9 and 2 with a cap
// of 10 become 10 and 1, even though that still occupies two
// slots. Topping stacks up is the point of consolidating.
//
// This settles after one pass and does not re-fire on the next
// call, because a maximally packed group has every stack full
// except the last — so `space == 0` walks `lo` straight up to
// `hi` and nothing is sent.
std::sort(group.stacks.begin(), group.stacks.end(),
[](const Stack &a, const Stack &b) { return a.count > b.count; });
size_t lo = 0, hi = group.stacks.size() - 1;
while (lo < hi) {
const uint32_t space = group.maxStack - group.stacks[lo].count;
if (space == 0) {
++lo;
continue;
}
const bool wholeStack = space >= group.stacks[hi].count;
const uint32_t moved =
wholeStack ? group.stacks[hi].count : space;
// Choose the packet from OUR model, never from the item's
// live stack count. The whole batch goes out in one frame,
// so no reply has arrived and every descriptor still holds
// its original count while the model has moved on. Asking
// the descriptor would send a split for what is really the
// last of a stack, and the server rejects a split that would
// empty its source ("Couldn't split those items").
const bool sent =
wholeStack
? Item::Swap::ContainersFrom(
group.stacks[hi].item, group.stacks[hi].bag,
group.stacks[hi].slot, group.stacks[lo].bag,
group.stacks[lo].slot)
: Item::Swap::MoveCountFrom(
group.stacks[hi].item, group.stacks[hi].bag,
group.stacks[hi].slot, group.stacks[lo].bag,
group.stacks[lo].slot, static_cast<int>(moved));
if (sent) {
fired = true;
++g_mergePackets;
// Only a whole-stack transfer empties its source, and
// `mergesPending` means "sources that should be empty by
// now but are not".
if (wholeStack)
g_mergeSources.push_back(
{group.stacks[hi].bag, group.stacks[hi].slot});
}
group.stacks[lo].count += moved;
group.stacks[hi].count -= moved;
if (group.stacks[hi].count == 0)
--hi; // that stack is gone
else
++lo; // this one is full
}
}
return fired;
}
// --- phase two: plan a layout and fire the swaps ----------------------------
// Counters from the most recent Place(), read back by
// `_classicapi_SortBagsStats()`. They exist to tell apart the three ways
// a pass can fail to converge: swaps the engine refused to send, items
// the planner found no cell for, and a plan that was complete and fully
// sent yet still left the bags unsorted (which would mean a swap did
// something other than swap).
struct Stats {
int entries;
int cells;
int pinned;
int planned;
int unplanned;
int swapsSent;
int swapsFailed;
int sameItemAvoided;
int generalCells; // destination cells in general-purpose containers
int specialtyCells; // cells confined to one bag family
int familyItems; // items that want a specialty bag
int mergesPending; // phase-one sources still occupied at plan time
};
Stats g_lastRun{};
void Place(const int *bags, int bagCount) {
Stats stats{};
std::vector<Entry> items;
std::vector<Cell> general;
// Vanilla items carry at most one family bit, so a flat list keyed by
// family is enough — no bitmask intersection needed.
struct FamilyPool {
uint32_t family;
std::vector<Cell> cells;
size_t next;
};
std::vector<FamilyPool> specialty;
for (int b = 0; b < bagCount; ++b) {
const int bag = bags[b];
BagView view;
if (!ViewForBag(bag, &view))
continue;
for (int i = 0; i < view.slotCount; ++i) {
const int slot = i + 1;
const uint8_t *item = ItemAt(view, i);
const uint8_t *rec = nullptr;
int itemID = 0;
if (item != nullptr) {
itemID = Item::ID::FromCGItem(item);
if (itemID > 0)
rec = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (rec == nullptr) {
++stats.pinned;
continue; // pinned: unrankable, and its slot is withheld
}
}
if (view.family == 0) {
general.push_back({bag, slot});
} else {
FamilyPool *pool = nullptr;
for (auto &p : specialty) {
if (p.family == view.family) {
pool = &p;
break;
}
}
if (pool == nullptr) {
specialty.push_back({view.family, {}, 0});
pool = &specialty.back();
}
pool->cells.push_back({bag, slot});
}
if (item == nullptr)
continue; // an empty slot contributes a cell and nothing else
Entry e{};
e.item = item;
e.curBag = bag;
e.curSlot = slot;
e.destBag = -1;
e.destSlot = -1;
e.itemID = static_cast<uint32_t>(itemID);
e.family = Item::BagFamily::BitmaskForRecord(rec);
e.itemClass = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_CLASS);
e.subClass = Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_SUBCLASS);
e.count = StackCount(item);
e.name = *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_ITEMSTATS_NAME);
e.category = CategoryFor(
e.itemID, e.itemClass,
Game::Read<uint32_t>(rec, Offsets::OFF_ITEMSTATS_QUALITY));
items.push_back(e);
}
}
stats.entries = static_cast<int>(items.size());
stats.generalCells = static_cast<int>(general.size());
for (const auto &p : specialty)
stats.specialtyCells += static_cast<int>(p.cells.size());
stats.cells = stats.generalCells + stats.specialtyCells;
for (const auto &e : items)
if (e.family != 0)
++stats.familyItems;
// Any source phase one asked the server to empty that is still
// occupied means this snapshot predates some of the merges.
for (const auto &src : g_mergeSources) {
for (const auto &e : items) {
if (e.curBag == src.bag && e.curSlot == src.slot) {
++stats.mergesPending;
break;
}
}
}
if (items.empty()) {
g_lastRun = stats;
return;
}
std::sort(items.begin(), items.end(), Precedes);
// Right-to-left is expressed entirely as reversed cell lists: the
// passes below still fill from the "front" and the "far end", those
// ends have just swapped which physical slot they mean. So the
// sorted run starts at the last slot of the last bag and junk
// collects at the first slot of the first, both flipping together.
if (RightToLeft()) {
std::reverse(general.begin(), general.end());
for (auto &p : specialty)
std::reverse(p.cells.begin(), p.cells.end());
}
// Forward pass for everything but junk: a matching specialty bag
// first, overflowing into the general cells. Junk then fills the
// general cells from the far end, so it collects away from the rest
// and the two passes cannot claim the same cell.
size_t genFront = 0;
size_t genBack = general.size();
for (auto &e : items) {
if (e.category == kCatJunk)
continue;
Cell *cell = nullptr;
if (e.family != 0) {
for (auto &p : specialty) {
if (p.family == e.family && p.next < p.cells.size()) {
cell = &p.cells[p.next++];
break;
}
}
}
if (cell == nullptr && genFront < genBack)
cell = &general[genFront++];
if (cell == nullptr)
continue; // nowhere left to put it; leave it where it is
e.destBag = cell->bag;
e.destSlot = cell->slot;
}
for (auto it = items.rbegin(); it != items.rend(); ++it) {
if (it->category != kCatJunk)
continue;
if (genBack <= genFront)
break;
const Cell &cell = general[--genBack];
it->destBag = cell.bag;
it->destSlot = cell.slot;
}
// NEVER plan a swap between two stacks of the same item. The server's
// swap handler tries a MERGE first and only falls through to a real
// swap if the merge is impossible: if the two counts fit one stack it
// collapses them into a single slot, and if they do not it
// redistributes the counts and leaves both items where they are.
// Neither is a swap, so the bookkeeping below would be wrong from
// that point on and every later move in the batch would compound it.
//
// Two stacks of one item are interchangeable, though, so this costs
// nothing: within each same-item run, hand each destination cell to
// whichever member already occupies it. The run is contiguous because
// identical items agree on every key ahead of count.
for (size_t i = 0; i < items.size();) {
size_t j = i;
while (j < items.size() && items[j].itemID == items[i].itemID)
++j;
for (size_t m = i; m < j; ++m) {
for (size_t n = i; n < j; ++n) {
if (n == m)
continue; // its own destination matching is just "home"
if (items[n].destSlot < 0)
continue; // unassigned; nothing to trade
if (items[n].destBag == items[m].curBag &&
items[n].destSlot == items[m].curSlot) {
std::swap(items[m].destBag, items[n].destBag);
std::swap(items[m].destSlot, items[n].destSlot);
++stats.sameItemAvoided;
break; // m now targets the slot it is already in
}
}
}
i = j;
}
// Fire the batch. Each entry carries its live position, so the plan
// stays valid as slots shuffle and nothing re-reads the bags. The
// occupant of a destination takes the mover's old slot, which is what
// the engine's swap does anyway.
for (auto &e : items) {
if (e.destSlot < 0) { // see the sentinel note on Entry
++stats.unplanned;
continue;
}
++stats.planned;
if (e.destBag == e.curBag && e.destSlot == e.curSlot)
continue;
Entry *occupant = nullptr;
for (auto &o : items) {
if (&o != &e && o.curBag == e.destBag && o.curSlot == e.destSlot) {
occupant = &o;
break;
}
}
const int fromBag = e.curBag, fromSlot = e.curSlot;
if (!Item::Swap::ContainersFrom(e.item, fromBag, fromSlot, e.destBag,
e.destSlot)) {
++stats.swapsFailed;
continue;
}
++stats.swapsSent;
e.curBag = e.destBag;
e.curSlot = e.destSlot;
if (occupant != nullptr) {
occupant->curBag = fromBag;
occupant->curSlot = fromSlot;
}
}
g_lastRun = stats;
}
// --- sequencing -------------------------------------------------------------
enum Phase { kIdle, kAwaitingMerge };
Phase g_phase = kIdle;
// Which set the in-flight sort is arranging, so the deferred placement
// pass knows what to walk. Points at one of the two static lists, so
// there is nothing to own or free.
const int *g_activeBags = nullptr;
int g_activeBagCount = 0;
void OnBagUpdate() {
if (g_phase != kAwaitingMerge)
return;
// Clear the phase BEFORE placing: the swaps we are about to fire will
// signal this callback again next frame, and we must be idle by then
// or the sort would restart forever.
g_phase = kIdle;
Place(g_activeBags, g_activeBagCount);
}
const Bag::UpdateDelayed::AutoSubscribe _bagSub{&OnBagUpdate};
// Shared entry point. Bags and bank differ only in the list.
void StartSort(const int *bags, int bagCount) {
if (g_phase != kIdle)
return; // a sort is already in flight
g_activeBags = bags;
g_activeBagCount = bagCount;
g_mergePackets = 0;
g_mergeSources.clear();
if (Consolidate(bags, bagCount))
g_phase = kAwaitingMerge; // let the merges land, then place
else
Place(bags, bagCount); // nothing to merge; the layout reads clean
}
// `C_Container.SortBags()` / `C_Container.SortBankBags()` — no
// arguments, no return, matching the modern signatures. Fire-and-forget:
// the work spans at least two frames. A call while a sort is already in
// flight is ignored rather than queued, so a held-down keybind cannot
// stack them up — and neither can sorting bags and bank at once.
// Runs a sort over `bags`, dropping `excluded` when its autosort setting
// says to leave that container alone. Dropping a bag from the set is all
// it takes: the snapshot, the comparator and the placement only ever see
// the bags they are handed, so an excluded bag is neither read from nor
// written to.
void StartSortExcluding(const int *bags, int count, int excluded,
bool excludeIt) {
if (!excludeIt) {
StartSort(bags, count);
return;
}
std::vector<int> kept;
kept.reserve(static_cast<size_t>(count));
for (int i = 0; i < count; ++i) {
if (bags[i] != excluded)
kept.push_back(bags[i]);
}
if (!kept.empty())
StartSort(kept.data(), static_cast<int>(kept.size()));
}
int __fastcall Script_C_Container_SortBags(void * /*L*/) {
StartSortExcluding(kInventoryBags,
sizeof(kInventoryBags) / sizeof(kInventoryBags[0]),
/*excluded*/ 0, BackpackAutosortDisabled());
return 0;
}
int __fastcall Script_C_Container_SortBankBags(void * /*L*/) {
StartSortExcluding(kBankBags, sizeof(kBankBags) / sizeof(kBankBags[0]),
/*excluded*/ -1, BankAutosortDisabled());
return 0;
}
// `_classicapi_SortBagsStats()` -> entries, cells, pinned, planned,
// unplanned, swapsSent, swapsFailed, sameItemAvoided — from the last
// placement pass. Diagnostic only; not part of the API surface.
int __fastcall Script_SortBagsStats(void *L) {
Game::Lua::PushNumber(L, g_lastRun.entries);
Game::Lua::PushNumber(L, g_lastRun.cells);
Game::Lua::PushNumber(L, g_lastRun.pinned);
Game::Lua::PushNumber(L, g_lastRun.planned);
Game::Lua::PushNumber(L, g_lastRun.unplanned);
Game::Lua::PushNumber(L, g_lastRun.swapsSent);
Game::Lua::PushNumber(L, g_lastRun.swapsFailed);
Game::Lua::PushNumber(L, g_lastRun.sameItemAvoided);
Game::Lua::PushNumber(L, g_lastRun.generalCells);
Game::Lua::PushNumber(L, g_lastRun.specialtyCells);
Game::Lua::PushNumber(L, g_lastRun.familyItems);
Game::Lua::PushNumber(L, g_mergePackets);
Game::Lua::PushNumber(L, g_lastRun.mergesPending);
return 13;
}
// `_classicapi_SortBagsBags(which)` -> bagID, slotCount, family for each
// bag that resolves in that set (0 = the inventory set, 1 = the bank
// set). A bag that does not resolve is skipped entirely, so a missing ID
// in the output is itself the answer. Diagnostic only.
int __fastcall Script_SortBagsBags(void *L) {
const bool bank = Game::Lua::IsNumber(L, 1) &&
static_cast<int>(Game::Lua::ToNumber(L, 1)) == 1;
const int *bags = bank ? kBankBags : kInventoryBags;
const int count = bank
? sizeof(kBankBags) / sizeof(kBankBags[0])
: sizeof(kInventoryBags) / sizeof(kInventoryBags[0]);
int pushed = 0;
for (int b = 0; b < count; ++b) {
BagView view;
if (!ViewForBag(bags[b], &view))
continue;
Game::Lua::PushNumber(L, bags[b]);
Game::Lua::PushNumber(L, view.slotCount);
Game::Lua::PushNumber(L, static_cast<double>(view.family));
pushed += 3;
}
return pushed;
}
// `C_Container.SetSortBagsRightToLeft(enable)` — no return, matching the
// modern signature. Takes effect on the next sort; it does not re-sort.
int __fastcall Script_SetSortBagsRightToLeft(void *L) {
CVar::Factory::SetInt(g_rightToLeftCVar,
Game::Lua::ToBoolean(L, 1) != 0 ? 1 : 0);
return 0;
}
// `C_Container.GetSortBagsRightToLeft()` -> isEnabled
int __fastcall Script_GetSortBagsRightToLeft(void *L) {
Game::Lua::PushBool(L, RightToLeft());
return 1;
}
// `C_Container.SetBackpackAutosortDisabled(disable)` /
// `C_Container.SetBankAutosortDisabled(disable)` — keep that container
// out of `SortBags` / `SortBankBags`. No return, matching the modern
// signatures; the paired getters report the stored setting.
int __fastcall Script_SetBackpackAutosortDisabled(void *L) {
CVar::Factory::SetInt(g_backpackAutosortCVar,
Game::Lua::ToBoolean(L, 1) != 0 ? 1 : 0);
return 0;
}
int __fastcall Script_GetBackpackAutosortDisabled(void *L) {
Game::Lua::PushBool(L, BackpackAutosortDisabled());
return 1;
}
int __fastcall Script_SetBankAutosortDisabled(void *L) {
CVar::Factory::SetInt(g_bankAutosortCVar,
Game::Lua::ToBoolean(L, 1) != 0 ? 1 : 0);
return 0;
}
int __fastcall Script_GetBankAutosortDisabled(void *L) {
Game::Lua::PushBool(L, BankAutosortDisabled());
return 1;
}
void RegisterLuaFunctions() {
// Dedups by name, so re-registering each reload is harmless.
g_rightToLeftCVar = CVar::Factory::Register("sortBagsRightToLeft", "0");
g_backpackAutosortCVar =
CVar::Factory::Register("backpackAutosortDisabled", "0");
g_bankAutosortCVar = CVar::Factory::Register("bankAutosortDisabled", "0");
Game::Lua::RegisterTableFunction("C_Container", "SortBags",
&Script_C_Container_SortBags);
Game::Lua::RegisterTableFunction("C_Container", "SetSortBagsRightToLeft",
&Script_SetSortBagsRightToLeft);
Game::Lua::RegisterTableFunction("C_Container", "GetSortBagsRightToLeft",
&Script_GetSortBagsRightToLeft);
Game::Lua::RegisterTableFunction("C_Container", "SortBankBags",
&Script_C_Container_SortBankBags);
Game::Lua::RegisterTableFunction("C_Container", "SetBackpackAutosortDisabled",
&Script_SetBackpackAutosortDisabled);
Game::Lua::RegisterTableFunction("C_Container", "GetBackpackAutosortDisabled",
&Script_GetBackpackAutosortDisabled);
Game::Lua::RegisterTableFunction("C_Container", "SetBankAutosortDisabled",
&Script_SetBankAutosortDisabled);
Game::Lua::RegisterTableFunction("C_Container", "GetBankAutosortDisabled",
&Script_GetBankAutosortDisabled);
Game::Lua::RegisterGlobalFunction("_classicapi_SortBagsStats",
&Script_SortBagsStats);
Game::Lua::RegisterGlobalFunction("_classicapi_SortBagsBags",
&Script_SortBagsBags);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::SortBags
+1 -1
View File
@@ -361,7 +361,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
&Script_GetCreatureTypeIDs);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
&_reserveLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+131
View File
@@ -0,0 +1,131 @@
// 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/>.
// `CURSOR_CHANGED` — fires when what the cursor holds changes, carrying
// the old and new cursor types:
//
// CURSOR_CHANGED: isDefault, newCursorType, oldCursorType, oldCursorVirtualID
//
// Plus the `Enum.UICursorType` table the two type arguments index.
//
// The trigger is a once-per-frame diff of the cursor globals, NOT the
// engine's own `CURSOR_UPDATE`, because that event does not cover the
// cursor. Its only three fire sites are:
//
// FUN_00495190 the cursor CLEAR — fires for every type, but only
// after it has already zeroed the type global, so the
// read at that point always says "empty"
// FUN_00494b60 bag-item set — fires only when a GUID is present
// FUN_004950f0 generic-item set — fires only when an itemID is
// present
//
// So picking up money, a spell, a macro, a pet action or a stabled pet
// produces exactly one `CURSOR_UPDATE`, from the clear, describing an
// empty cursor; the write that puts the new content in place announces
// nothing. Only the two item paths fire again after the set, which is
// why an interceptor on that event reported item cursors correctly and
// nothing else. There is no common choke point on the set side — each
// type writes `VAR_CURSOR_TYPE` in its own path — so a state diff is
// what covers every type, including any path not enumerated above.
//
// `Info::ReadRaw` is the cheap half of that diff: plain global reads, no
// object-manager lookup. Only once it differs do we pay for
// `Info::Current()`, which resolves a bag item's itemID.
//
// `oldCursorVirtualID` is the one argument that is a judgement call: the
// modern client's notion of a "virtual ID" is not something this client
// has, so it carries the previous content's identifying number instead
// (see `Cursor::Info::State::virtualID`). No consumer is known to read
// it — Blizzard's own `CURSOR_CHANGED` users all treat the event as a
// bare notification and re-check state themselves.
#include "Game.h"
#include "cursor/Info.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Cursor::Changed {
namespace {
const Event::Custom::AutoReserve _evtCursorChanged{"CURSOR_CHANGED"};
// The full modern enum, not just the values this client can produce. An
// addon comparing against `Enum.UICursorType.BattlePet` should read a
// number it can never match, rather than index a nil field.
const Game::Lua::EnumIntegerEntry kCursorTypeEntries[] = {
{"Default", 0}, {"Item", 1},
{"Money", 2}, {"Spell", 3},
{"PetAction", 4}, {"Merchant", 5},
{"ActionBar", 6}, {"Macro", 7},
{"Ammo", 8}, {"Pet", 9},
{"GuildBank", 10}, {"GuildBankMoney", 11},
{"EquipmentSet", 12}, {"Currency", 13},
{"Flyout", 14}, {"VoidItem", 15},
{"BattlePet", 16}, {"Mount", 17},
{"Toy", 18}, {"ConduitCollectionItem", 19},
{"PerksProgramVendorItem", 20},
};
// The cursor as of the last event we fired — the raw form for the cheap
// per-frame compare, and the reported form to supply the `old*` arguments
// of the next fire. Deliberately NOT reset on `/reload`: the DLL and the
// engine's cursor globals both outlive the reload, so keeping them means
// a cursor still holding something across a reload reports its real
// previous type rather than a phantom change back from `Default`.
Info::Raw g_lastRaw{};
Info::State g_lastState{0, 0};
void OnTick() {
const Info::Raw raw = Info::ReadRaw();
if (Info::Same(raw, g_lastRaw))
return; // cursor untouched this frame — the common case
g_lastRaw = raw;
const Info::State now = Info::Current();
const Info::State previous = g_lastState;
g_lastState = now;
const int slot = _evtCursorChanged.Slot();
if (slot < 0)
return;
// The dispatcher has no boolean code, and pushing `%d` 0 would hand
// Lua the number 0 — truthy. `%s` with a null string tail-jumps to
// pushnil instead, so `if isDefault then` reads correctly either
// way. Same trick the PLAYER_ENTERING_WORLD payload uses.
auto *const kNil = static_cast<const char *>(nullptr);
if (now.uiType == 0) {
Event::Custom::Fire(slot, "%d%d%d%u", 1, now.uiType, previous.uiType,
previous.virtualID);
} else {
Event::Custom::Fire(slot, "%s%d%d%u", kNil, now.uiType,
previous.uiType, previous.virtualID);
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "UICursorType", kCursorTypeEntries,
sizeof(kCursorTypeEntries) / sizeof(kCursorTypeEntries[0]));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Cursor::Changed
+101 -7
View File
@@ -97,10 +97,25 @@ constexpr uint32_t TYPE_GENERIC_ITEM_LO = 6;
constexpr uint32_t TYPE_GENERIC_ITEM_HI = 7;
constexpr uint32_t TYPE_MACRO = 8;
constexpr uint32_t TYPE_INVENTORY_ITEM = 9;
// Type 10 is stable-pet pickup (`FUN_00495010`, the source of the
// earlier misidentification as "merchant"). Modern WoW has no
// equivalent `GetCursorInfo` return for stable pets, so we leave it
// as nil.
// Type 4 is pet-action pickup and type 10 is stable-pet pickup
// (`FUN_00495010`, the source of the earlier misidentification as
// "merchant"). Neither has an equivalent `GetCursorInfo` return, so
// that function leaves both as nil — but `Enum.UICursorType` does name
// them, so `Current()` below reports them.
constexpr uint32_t TYPE_PET_ACTION = 4;
constexpr uint32_t TYPE_STABLE_PET = 10;
// `Enum.UICursorType` values, for `Current()`. The full enum is
// registered in [[cursor/Changed.cpp]]; only the handful the 1.12
// cursor can actually hold are named here.
constexpr int UI_DEFAULT = 0;
constexpr int UI_ITEM = 1;
constexpr int UI_MONEY = 2;
constexpr int UI_SPELL = 3;
constexpr int UI_PET_ACTION = 4;
constexpr int UI_MERCHANT = 5;
constexpr int UI_MACRO = 7;
constexpr int UI_PET = 9;
constexpr uint32_t GUID_TYPE_FILTER_ITEM = 0x2;
@@ -108,6 +123,23 @@ uint32_t ReadVar(uintptr_t va) {
return *reinterpret_cast<const uint32_t *>(va);
}
// The macro cursor stores a macroID, not the slot Lua deals in (see
// `VAR_CURSOR_MACRO_ID`). Hand it back through the engine's own reverse
// scan of the slot map — the same conversion the legacy `CreateMacro`
// wrapper does to produce the index it returns to Lua. 0 when the
// macroID no longer maps to a slot, which is what an empty cursor
// reports too.
uint32_t CursorMacroSlot() {
const uint32_t macroID = ReadVar(Offsets::VAR_CURSOR_MACRO_ID);
if (macroID == 0)
return 0;
using IdToSlot_t = uint32_t(__fastcall *)(int macroID);
const uint32_t slot0 = reinterpret_cast<IdToSlot_t>(
static_cast<uintptr_t>(Offsets::FUN_MACRO_ID_TO_SLOT))(
static_cast<int>(macroID));
return (slot0 == 0xFFFFFFFFu) ? 0 : slot0 + 1;
}
int PushItem(void *L, const uint8_t *cgItem) {
if (cgItem == nullptr)
return 0;
@@ -222,11 +254,13 @@ int PushSpellCursor(void *L) {
}
int PushMacroCursor(void *L) {
const uint32_t index = ReadVar(Offsets::VAR_CURSOR_MACRO_INDEX);
if (index == 0)
// The 1-based slot, so the value feeds straight into `GetMacroInfo`
// — the cursor global itself holds a macroID.
const uint32_t slot = CursorMacroSlot();
if (slot == 0)
return 0;
Game::Lua::PushString(L, "macro");
Game::Lua::PushNumber(L, static_cast<double>(index));
Game::Lua::PushNumber(L, static_cast<double>(slot));
return 2;
}
@@ -270,4 +304,64 @@ bool HasItem() {
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0;
}
State Current() {
const uint32_t type = ReadVar(Offsets::VAR_CURSOR_TYPE);
switch (type) {
case TYPE_BAG_ITEM: {
const uint32_t lo = ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO);
const uint32_t hi = ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI);
if (lo == 0 && hi == 0)
break; // type set but storage not written yet
// Same resolve `Script_GetCursorInfo` does for this type —
// FrameXML already calls into it from a CURSOR_UPDATE
// handler, so the object manager is queryable at this point
// in the dispatch.
auto resolver =
reinterpret_cast<ObjectFromGUID_t>(Offsets::FUN_OBJECT_FROM_GUID);
const uint8_t *cgItem = resolver(GUID_TYPE_FILTER_ITEM, lo, hi, 0);
const int itemID =
(cgItem != nullptr) ? Item::ID::FromCGItem(cgItem) : 0;
return {UI_ITEM, itemID > 0 ? static_cast<uint32_t>(itemID) : 0u};
}
case TYPE_GENERIC_ITEM_LO:
case TYPE_GENERIC_ITEM_HI:
case TYPE_INVENTORY_ITEM:
return {UI_ITEM, ReadVar(Offsets::VAR_CURSOR_GENERIC_SLOT)};
case TYPE_MONEY:
return {UI_MONEY, ReadVar(Offsets::VAR_CURSOR_MONEY_COPPER)};
case TYPE_SPELL:
return {UI_SPELL, ReadVar(Offsets::VAR_CURSOR_SPELL_ID)};
case TYPE_MERCHANT:
// 1-based to match what `GetCursorInfo` pushes for this type.
return {UI_MERCHANT,
ReadVar(Offsets::VAR_CURSOR_GENERIC_SOURCE) + 1};
case TYPE_MACRO:
return {UI_MACRO, CursorMacroSlot()};
case TYPE_PET_ACTION:
return {UI_PET_ACTION, 0};
case TYPE_STABLE_PET:
return {UI_PET, 0};
case TYPE_EMPTY:
default:
break;
}
return {UI_DEFAULT, 0};
}
Raw ReadRaw() {
return {ReadVar(Offsets::VAR_CURSOR_TYPE),
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO),
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI),
ReadVar(Offsets::VAR_CURSOR_GENERIC_SLOT),
ReadVar(Offsets::VAR_CURSOR_MONEY_COPPER),
ReadVar(Offsets::VAR_CURSOR_SPELL_ID),
ReadVar(Offsets::VAR_CURSOR_MACRO_ID)};
}
bool Same(const Raw &a, const Raw &b) {
return a.type == b.type && a.guidLo == b.guidLo && a.guidHi == b.guidHi &&
a.genericSlot == b.genericSlot && a.money == b.money &&
a.spellID == b.spellID && a.macroID == b.macroID;
}
} // namespace Cursor::Info
+42
View File
@@ -13,8 +13,50 @@
#pragma once
#include <cstdint>
namespace Cursor::Info {
// What the cursor is holding, in the terms `CURSOR_CHANGED` reports.
struct State {
// `Enum.UICursorType` value — `Default` (0) when the cursor is empty.
// Agrees with the type string `GetCursorInfo` returns wherever that
// function returns one; the pet-action and stable-pet cursors have no
// `GetCursorInfo` string but do have an enum value, so they report
// `PetAction` / `Pet` here while `GetCursorInfo` stays nil.
int uiType;
// The held content's identifying number: itemID, spellID, 1-based
// macro slot, 1-based merchant slot, or a money amount in copper. 0
// for an empty cursor and for the two pet cursors, whose payload the
// engine does not keep anywhere readable.
uint32_t virtualID;
};
// Reads the live cursor globals. Side-effect free. Resolving a bag item's
// itemID needs an object-manager lookup, so prefer `ReadRaw` when all you
// need is "did the cursor change".
State Current();
// The identity of whatever the cursor holds, as the raw stored values —
// no object-manager lookup, so this is cheap enough to read every frame.
// Two reads that compare equal mean the cursor has not changed.
//
// Every global that identifies a holding is included, so this catches a
// change of type and a change of content within one type, including
// swapping between two stacks of the same item (their GUIDs differ).
struct Raw {
uint32_t type;
uint32_t guidLo;
uint32_t guidHi;
uint32_t genericSlot;
uint32_t money;
uint32_t spellID;
uint32_t macroID; // the stored macroID, not the slot — cheaper here
};
Raw ReadRaw();
bool Same(const Raw &a, const Raw &b);
// True if a GUID-bearing item is currently on the cursor —
// `VAR_CURSOR_ITEM_GUID != 0`, the same globals `GetCursorInfo`'s bag-item
// path reads and the exact signal the engine's item-pickup path guards on.
+144
View File
@@ -0,0 +1,144 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_CVar.GetCVarBitfield(name, index)` / `C_CVar.SetCVarBitfield(name, index,
// value)` — treat a cvar's numeric value as a bitfield and read or write one
// bit of it, so a single cvar carries many independent booleans. Upstream this
// backs the record of which tutorial popups a player has dismissed: one cvar,
// one bit per popup, persisted by the config machinery for free.
//
// The index is 1-based. Blizzard types it `luaIndex` rather than `number`,
// which is what settles that.
//
// WIDTH. The value is parsed and rewritten as a uint64 in C++, never handed
// through a Lua number, so all 64 bits are exact — Lua 5.0's doubles are not in
// the path. The valid index range is 1..64 because that is the width of the
// type the arithmetic runs in, not a limit chosen here.
//
// Setting refuses, returning false, in the cases Blizzard's own documentation
// names as requirements: an unknown cvar, a read-only one, and an index out of
// range. Both of the first two are things this client can genuinely answer --
// the lookup is the same filtering one behind C_CVar.DoesCVarExist, so a cvar
// preserved out of Config.wtf without being implemented is refused here exactly
// as it is by every other Lua cvar function. Nothing here is ever secure, which
// is the remaining documented requirement.
//
// Writing goes through CVar::Factory::SetString rather than the engine setter
// directly: that wrapper already declares FUN_SET_CVAR_VALUE as the __thiscall
// it is, and getting that wrong corrupts the stack silently rather than
// crashing. It also means a bitfield write fires the cvar's change callback and
// marks the config dirty, exactly as SetCVar would.
#include "Factory.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstdio>
namespace CVar::Bitfield {
namespace {
// The arithmetic type's own width, which is what bounds a valid index.
constexpr int kBitCount = 64;
// Parses the cvar's value as an unsigned base-10 integer. A value that is not a
// number reads as 0, which is how an unset bitfield behaves anyway: every bit
// clear. Stops at the first non-digit rather than rejecting, so a value with
// trailing text still yields its leading number.
uint64_t ValueOf(CVar::Factory::Handle cvar) {
const char *s = CVar::Factory::GetString(cvar);
if (s == nullptr)
return 0;
while (*s == ' ' || *s == '\t')
++s;
uint64_t v = 0;
for (; *s >= '0' && *s <= '9'; ++s) {
const uint64_t digit = static_cast<uint64_t>(*s - '0');
if (v > (UINT64_MAX - digit) / 10)
return UINT64_MAX; // saturate rather than wrap
v = v * 10 + digit;
}
return v;
}
// True when `index` names a bit this can address. 1-based.
bool IndexInRange(double index) {
return index >= 1.0 && index <= static_cast<double>(kBitCount) &&
index == static_cast<double>(static_cast<int>(index));
}
// `C_CVar.GetCVarBitfield(name, index) -> value`
//
// nil for an unknown cvar or an index out of range, matching the contract's
// nilable return, so "no such bit" stays distinguishable from "bit is false".
int __fastcall Script_GetCVarBitfield(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const double index = Game::Lua::ToNumber(L, 2);
CVar::Factory::Handle cvar = CVar::Factory::Find(Game::Lua::ToString(L, 1));
if (cvar == nullptr || !IndexInRange(index)) {
Game::Lua::PushNil(L);
return 1;
}
const uint64_t bit = uint64_t{1} << (static_cast<int>(index) - 1);
Game::Lua::PushBool(L, (ValueOf(cvar) & bit) != 0);
return 1;
}
// `C_CVar.SetCVarBitfield(name, index, value) -> success`
int __fastcall Script_SetCVarBitfield(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushBool(L, false);
return 1;
}
const double index = Game::Lua::ToNumber(L, 2);
CVar::Factory::Handle cvar = CVar::Factory::Find(Game::Lua::ToString(L, 1));
if (cvar == nullptr || !IndexInRange(index)) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
static_cast<const uint8_t *>(cvar) + Offsets::OFF_CVAR_FLAGS);
if ((flags & Offsets::CVAR_FLAG_READ_ONLY) != 0) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint64_t bit = uint64_t{1} << (static_cast<int>(index) - 1);
const uint64_t before = ValueOf(cvar);
const uint64_t after = Game::Lua::ToBoolean(L, 3) != 0 ? (before | bit) : (before & ~bit);
if (after != before) {
char buf[24]; // 20 digits for a uint64 at most, plus the terminator
std::snprintf(buf, sizeof buf, "%llu", static_cast<unsigned long long>(after));
CVar::Factory::SetString(cvar, buf);
}
Game::Lua::PushBool(L, true);
return 1;
}
void Register() {
Game::Lua::RegisterTableFunction("C_CVar", "GetCVarBitfield", &Script_GetCVarBitfield);
Game::Lua::RegisterTableFunction("C_CVar", "SetCVarBitfield", &Script_SetCVarBitfield);
}
const Game::ModuleAutoRegister _autoreg{&Register};
const Game::GlueModuleAutoRegister _glueAutoreg{&Register};
} // namespace
} // namespace CVar::Bitfield
+10 -2
View File
@@ -47,11 +47,15 @@ using FindCVar_t = void *(__fastcall *)(const char *name);
using SetCVarValue_t = char(__thiscall *)(void *cvar, const char *value,
int a3, int a4, int a5, int a6);
const char *ReadValue(void *cvar) {
const char *ReadStringField(void *cvar, uintptr_t offset) {
if (cvar == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
static_cast<const uint8_t *>(cvar) + Offsets::OFF_CVAR_VALUE_STR);
static_cast<const uint8_t *>(cvar) + offset);
}
const char *ReadValue(void *cvar) {
return ReadStringField(cvar, Offsets::OFF_CVAR_VALUE_STR);
}
} // namespace
@@ -76,6 +80,10 @@ const char *GetString(Handle cvar) {
return ReadValue(cvar);
}
const char *GetDefaultString(Handle cvar) {
return ReadStringField(cvar, Offsets::OFF_CVAR_DEFAULT_STR);
}
int GetInt(Handle cvar, int fallback) {
const char *value = ReadValue(cvar);
if (value == nullptr || value[0] == '\0')
+15
View File
@@ -66,6 +66,14 @@ Handle Find(const char *name);
// valid for the cvar's lifetime.
const char *GetString(Handle cvar);
// Read the value the cvar was REGISTERED with, which the engine keeps
// alongside the live value and never rewrites. Comparing the two is how the
// engine's own `cvarlist` decides whether to print "(default: %s)", and it is
// the only way to tell "nobody has ever set this" from "set to exactly the
// default" without a marker of our own. Returns null if `cvar` is null or the
// cvar was registered without a default.
const char *GetDefaultString(Handle cvar);
// Read the value parsed as a base-10 integer, or `fallback` if `cvar` is
// null or the value isn't numeric.
int GetInt(Handle cvar, int fallback = 0);
@@ -75,6 +83,13 @@ int GetInt(Handle cvar, int fallback = 0);
void SetString(Handle cvar, const char *value);
// Convenience: set from an integer.
//
// Both setters go through the engine's own value setter, which REFUSES the
// write when the cvar carries `CVAR_FLAG_READ_ONLY` — silently, since the
// innermost writer opens with `if (flags & 0x4) return;`. Some cvars gain
// that bit partway through boot (`scriptMemory` is writable on the glue
// state and read-only once in the world), so a write that has to land needs
// to happen before the bit appears rather than fight it.
void SetInt(Handle cvar, int value);
} // namespace CVar::Factory
+158
View File
@@ -0,0 +1,158 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_CVar.GetCVarInfo(name)` — a cvar's value, its default, and what may be
// done to it.
//
// value, defaultValue, isStoredServerAccount, isStoredServerCharacter,
// isLockedFromUser, isSecure, isReadOnly
//
// Two of the seven are real here and five are structurally false, which is
// worth stating precisely rather than leaving as "not implemented":
//
// isReadOnly REAL. Flag bit 0x4. Script_SetCVar tests it before
// anything else and raises `"%s" is read only`, so a
// caller checking this gets exactly the answer that
// decides whether SetCVar will work.
// isStoredServerAccount Always false. These describe a cvar the server
// isStoredServerCharacter stores per account or per character, synchronised
// through the `synchronizeConfig` cvar. That whole
// mechanism postdates this client — there is no
// server cvar sync and no flag bit for one, so false
// is the accurate answer, not a placeholder.
// isLockedFromUser Always false. No flag governs it.
// isSecure Always false. Marks cvars that cannot be set while
// in combat, a protection this client has no notion
// of.
//
// Derived by reading 3.3.5's own GetCVarInfo, which returns the first four of
// these: value and default from the cvar struct, then flag bits 4 and 5 for the
// two server-stored booleans. The FLAG OFFSET transfers — +0x1C in both — but
// the string offsets do not (3.3.5 keeps them at +0x28 and +0x40), so the ones
// used here come from this client's own `cvarlist` handler instead.
//
// Unknown names return nil rather than raising, so a caller can tell "no such
// cvar" from "set to empty". FUN_FIND_CVAR searches the cvar registry alone, so
// a console COMMAND's name finds nothing and returns nil — which is the
// contract's "only accepts console variables" without needing a test for it.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace CVar::Info {
namespace {
using FindCVar_t = const uint8_t *(__fastcall *)(const char *name);
int __fastcall Script_C_CVar_GetCVarInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_CVar.GetCVarInfo(\"cvar\")");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
auto findCVar = reinterpret_cast<FindCVar_t>(Offsets::FUN_FIND_CVAR);
const uint8_t *cvar = findCVar(name);
if (cvar == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const char *value =
*reinterpret_cast<const char *const *>(cvar + Offsets::OFF_CVAR_VALUE_STR);
const char *defaultValue =
*reinterpret_cast<const char *const *>(cvar + Offsets::OFF_CVAR_DEFAULT_STR);
const uint32_t flags =
*reinterpret_cast<const uint32_t *>(cvar + Offsets::OFF_CVAR_FLAGS);
// A cvar registered with no default leaves the pointer null; report the
// empty string so every return keeps its declared type.
Game::Lua::PushString(L, value != nullptr ? value : "");
Game::Lua::PushString(L, defaultValue != nullptr ? defaultValue : "");
Game::Lua::PushBool(L, false); // isStoredServerAccount
Game::Lua::PushBool(L, false); // isStoredServerCharacter
Game::Lua::PushBool(L, false); // isLockedFromUser
Game::Lua::PushBool(L, false); // isSecure
Game::Lua::PushBool(L, (flags & Offsets::CVAR_FLAG_READ_ONLY) != 0);
return 7;
}
// `C_CVar.DoesCVarExist(name)` — a ClassicAPI addition, named for the
// `DoesAddOnExist` / `DoesSpellExist` pair. It answers the question callers
// actually have, which is whether GetCVar and SetCVar will work on this name,
// so it uses the same lookup they do. That makes the invariant exact:
//
// C_CVar.DoesCVarExist(name) == (C_CVar.GetCVarInfo(name) ~= nil)
//
// and it holds for a name preserved out of Config.wtf without the client
// implementing it: such a cvar is real and reachable from the console, but no
// Lua getter can see it, so reporting it as existing would be a lie to every
// caller who then tried to read it.
//
// Returns false rather than raising for a non-string, matching the two
// Does*Exist functions it is named after -- a predicate should answer, not
// throw.
int __fastcall Script_C_CVar_DoesCVarExist(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
}
auto findCVar = reinterpret_cast<FindCVar_t>(Offsets::FUN_FIND_CVAR);
Game::Lua::PushBool(L, findCVar(Game::Lua::ToString(L, 1)) != nullptr);
return 1;
}
// `C_CVar.AreCVarsLoaded()` — whether the cvar registry is up.
//
// Reports the registry's own initialised state rather than a constant, so it
// states a fact instead of an assumption. In practice it is always true by the
// time anything can call it: the config system starts from the boot path
// (FUN_0063D380, reached straight out of the entry point), which registers
// commands and replays Config.wtf long before a Lua state exists. So there is
// no window in which Lua could observe false.
//
// It is a real question upstream, where an account's cvars can be stored
// server-side and arrive after login. Nothing here is fetched over the network,
// so once the boot path has run there is nothing further to wait for.
int __fastcall Script_C_CVar_AreCVarsLoaded(void *L) {
const uint32_t mask = *reinterpret_cast<const uint32_t *>(Offsets::VAR_CVAR_HASH_MASK);
Game::Lua::PushBool(L, mask != Offsets::CVAR_HASH_MASK_UNINITIALIZED);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CVar", "GetCVarInfo", &Script_C_CVar_GetCVarInfo);
Game::Lua::RegisterTableFunction("C_CVar", "DoesCVarExist",
&Script_C_CVar_DoesCVarExist);
Game::Lua::RegisterTableFunction("C_CVar", "AreCVarsLoaded",
&Script_C_CVar_AreCVarsLoaded);
}
// Cvar storage is process-global, so the same surface works pre-login.
void RegisterGlueFunctions() {
Game::Lua::RegisterTableFunction("C_CVar", "GetCVarInfo", &Script_C_CVar_GetCVarInfo);
Game::Lua::RegisterTableFunction("C_CVar", "DoesCVarExist",
&Script_C_CVar_DoesCVarExist);
Game::Lua::RegisterTableFunction("C_CVar", "AreCVarsLoaded",
&Script_C_CVar_AreCVarsLoaded);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlueFunctions};
} // namespace
} // namespace CVar::Info
+109
View File
@@ -0,0 +1,109 @@
// 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/>.
// `CVar::ScriptMemory` — turn the interface memory watchdog off by default.
//
// The engine polls Lua's live allocation against the `scriptMemory` cvar
// (KB; registered with default "49152", so 48 MB) in `FUN_00495590`:
//
// limit = *(int *)(scriptMemoryCVar + 0x28); // parsed-int cache
// if (enabled && limit > 0) {
// if (luaKB() > limit) ForceFullGC();
// if (luaKB() > limit) {
// if (!deadline) { deadline = now + 15000;
// Fire(MEMORY_EXHAUSTED, "%d", mb); }
// else if (now >= deadline) Quit(); // FUN_00401560(0)
// } else if (deadline) { deadline = 0; Fire(MEMORY_RECOVERED); }
// }
//
// FrameXML turns MEMORY_EXHAUSTED into the "disable some interface add-ons
// and restart" popup, and 15 seconds later the client EXITS. So the cap is
// not advisory: it ends the session.
//
// 48 MB was a reasonable budget for the addons of the day and is not one for
// a modern set, so the popup and the shutdown land on people whose addons
// are working correctly. The gate is `limit > 0`, so writing 0 disables the
// poll outright — the value Blizzard's own AddOns dialog at character select
// lets a user type, via `SetScriptMemory` (which multiplies by 1024;
// verified `SHL EAX,0xa` at 0x0046DA8D).
//
// Nothing else caps Lua memory. `lmemPool.cpp`'s allocator (`FUN_006FAE90`)
// is six size classes that fall through to the Storm heap for anything
// larger, with no ceiling of its own; its "not enough memory" is a genuine
// allocation failure. So 0 hands the bound to the process address space
// (this client is Large Address Aware) rather than to a number picked in
// 2006 — the engine's real limit instead of an invented one.
//
// TIMING IS THE WHOLE TRICK. The cvar becomes READ-ONLY partway through boot:
// in the world, `SetCVar("scriptMemory", "49152")` raises `"scriptMemory" is
// read only` and `GetCVarInfo` reports `isReadOnly`, which is bit
// `CVAR_FLAG_READ_ONLY` at `+0x1C` — and the innermost setter
// (`FUN_0063E0B0`) opens with `if (flags & 4) return;`, so a write then is a
// silent no-op. But the bit is NOT set yet on the glue state, where the write
// below lands: verified in game by deleting the Config.wtf line, launching,
// and finding 0 in the AddOns dialog and back in Config.wtf on exit.
//
// The bit is not set at registration either: the disassembly at `0x00402E24`
// pushes flags 0 and registerConsole 0, so the registrar computes
// `0 | 1 | 0x80000000`, and nothing in the binary ORs bit 2 into a cvar's
// flags. Something between the glue screen and the world sets it; the
// mechanism is still unidentified. Practical consequence: this must keep
// running from the GLUE registration. Do not "simplify" it to the in-game
// hook alone — the write would silently stop working, and the in-game hook
// stays only as a second chance for a boot that somehow skips glue.
//
// We override the DEFAULT ONLY: if the live value differs from the value the
// cvar was registered with, someone chose it and we leave it alone. The cvar
// carries the archive flag, so our 0 persists to Config.wtf and the
// comparison then fails on every later boot — the write happens once per
// install, not once per session. The one false positive is a user who puts
// exactly the default in Config.wtf, which reads as untouched; recovering the
// watchdog means choosing any other value.
//
// Runs from both the glue and in-game registration flows. Cvar storage is
// process-global and the write is idempotent by construction (afterwards the
// value no longer equals the default), so whichever fires first wins and the
// other is a no-op. Glue fires earliest, which also means the AddOns dialog
// shows 0 rather than 48 on a first launch.
#include "cvar/Factory.h"
#include "Game.h"
#include <cstring>
namespace CVar::ScriptMemory {
namespace {
constexpr const char *kCVarName = "scriptMemory";
void ApplyDefaultOverride() {
Factory::Handle cvar = Factory::Find(kCVarName);
if (cvar == nullptr)
return; // registered at boot by the engine; absent only if that moved
const char *value = Factory::GetString(cvar);
const char *fallback = Factory::GetDefaultString(cvar);
if (value == nullptr || fallback == nullptr)
return;
if (std::strcmp(value, fallback) != 0)
return; // deliberately set — the user's choice stands
Factory::SetInt(cvar, 0); // lands on the glue state; see the timing note
}
const Game::ModuleAutoRegister _autoreg{&ApplyDefaultOverride};
const Game::GlueModuleAutoRegister _glueAutoreg{&ApplyDefaultOverride};
} // namespace
} // namespace CVar::ScriptMemory
+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
+3 -6
View File
@@ -386,7 +386,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr) {
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 0, static_cast<int>(setID));
Game::Lua::PushBoolean(L, 0);
@@ -396,7 +396,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
// Fire PENDING right after the set-exists check, before any of
// the swap work. Addon UI can use this to gate swap-in-progress
// visuals.
const int pendingEvt = Event::Custom::Lookup(kSwapPendingEvent);
const int pendingEvt = kEvtSwapPending.Slot();
if (pendingEvt >= 0)
Event::Custom::Fire(pendingEvt, "%d", static_cast<int>(setID));
@@ -569,7 +569,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
@@ -620,8 +620,5 @@ static void RegisterLuaFunctions() {
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Event::Custom::AutoReserve _reserve{kEventName};
static const Event::Custom::AutoReserve _reservePending{kSwapPendingEvent};
static const Event::Custom::AutoReserve _reserveFinished{kSwapFinishedEvent};
} // namespace EquipmentSet::Api
+20 -1
View File
@@ -75,7 +75,7 @@ uint32_t NextAvailableID() {
}
void FireChanged() {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = kEvtSetsChanged.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
@@ -143,6 +143,25 @@ int IndexOf(uint32_t setID) {
return -1;
}
std::vector<const Set *> SetsContainingItem(uint64_t itemGuid) {
EnsureLoaded();
std::vector<const Set *> found;
// The sentinels are slot markers, not items — a set with an empty or
// ignored slot must not match a caller that hands us one of them.
if (itemGuid == GUID_EMPTY || itemGuid == GUID_IGNORED)
return found;
for (const Set &s : g_sets) {
for (int i = 0; i < SLOT_COUNT; ++i) {
if (s.items[i] == itemGuid) {
found.push_back(&s);
break; // one hit per set, however many slots match
}
}
}
return found;
}
uint32_t Create(const char *name, const char *icon) {
EnsureLoaded();
if (g_path.empty() || name == nullptr || name[0] == '\0')
+12
View File
@@ -31,6 +31,18 @@ const Set *FindByID(uint32_t setID);
const Set *FindByName(const char *name);
int IndexOf(uint32_t setID); // 0-based; -1 if missing
// Every set that includes the item instance `itemGuid`, in set order.
// Empty when the item belongs to no set, and for the reserved
// `GUID_EMPTY` / `GUID_IGNORED` sentinels — those mark a slot as empty
// or skipped, so they are never a real item and must not match.
//
// Matching is by GUID, so it identifies the one physical item that was
// saved into the set rather than any copy of the same itemID: a second
// Arcanite Reaper in the bag next to the set's own does not report as
// part of the set. Pointers reference `All()`'s storage and stay valid
// until the next mutation.
std::vector<const Set *> SetsContainingItem(uint64_t itemGuid);
// Mutations — bump-and-save semantics. Each returns the affected
// set's ID (or 0 on failure), persists to disk, and fires
// `EQUIPMENT_SETS_CHANGED`. Callers don't need to manage the file
+8 -5
View File
@@ -13,6 +13,8 @@
#pragma once
#include "event/Custom.h"
#include <cstdint>
#include <string>
@@ -109,8 +111,9 @@ constexpr int LOC_MISSING = -1;
// coincidence, not load-bearing).
constexpr int LOC_IGNORED = 1;
// Custom event names. All three are reserved at module-init time
// via `Event::Custom::AutoReserve` in `Api.cpp`.
// Custom event reservations, shared by the Data and Api TUs (C++17 inline
// variables — one instance each; the AutoReserve ctor dedups by name anyway).
// Fire via `kEvt*.Slot()`.
//
// EQUIPMENT_SETS_CHANGED — no payload; fires on any mutation
// (create / save / modify / delete /
@@ -130,8 +133,8 @@ constexpr int LOC_IGNORED = 1;
// pickup→equip pair actually completed,
// matching modern's "we dispatched"
// definition of success).
constexpr const char *kEventName = "EQUIPMENT_SETS_CHANGED";
constexpr const char *kSwapPendingEvent = "EQUIPMENT_SWAP_PENDING";
constexpr const char *kSwapFinishedEvent = "EQUIPMENT_SWAP_FINISHED";
inline const Event::Custom::AutoReserve kEvtSetsChanged{"EQUIPMENT_SETS_CHANGED"};
inline const Event::Custom::AutoReserve kEvtSwapPending{"EQUIPMENT_SWAP_PENDING"};
inline const Event::Custom::AutoReserve kEvtSwapFinished{"EQUIPMENT_SWAP_FINISHED"};
} // namespace EquipmentSet
+2 -35
View File
@@ -13,8 +13,7 @@
#include "Storage.h"
#include "Game.h"
#include "Offsets.h"
#include "settings/Paths.h"
#include <cstdint>
#include <cstdio>
@@ -25,22 +24,6 @@ namespace EquipmentSet::Storage {
namespace {
const char *ReadAccountName() {
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
}
const char *ReadCharacterName() {
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
return (p[0] == '\0') ? nullptr : p;
}
const char *ReadRealmName() {
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
if (info == nullptr)
return nullptr;
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
}
// Removes trailing CR/LF/whitespace. Names from the engine are ASCII
// only (the chat input filter rejects high bytes) so we don't have to
// think about multi-byte trailing whitespace.
@@ -74,23 +57,7 @@ bool ParseField(const std::string &line, const std::string &key, std::string *va
} // namespace
std::string ResolveFilePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
const char *character = ReadCharacterName();
if (character == nullptr)
return {};
std::string out = "WTF\\Account\\";
out += account;
out += '\\';
out += realm;
out += '\\';
out += character;
out += "\\ClassicAPI_EquipmentSets.txt";
return out;
return Settings::Paths::CharacterFile("ClassicAPI_EquipmentSets.txt");
}
bool Load(const std::string &path, std::vector<Set> *outSets) {
+12 -10
View File
@@ -244,25 +244,25 @@ void Grow() {
} // namespace
AutoReserve::AutoReserve(const char *name) {
if (name == nullptr || g_reservedCount >= MAX_RESERVED)
if (name == nullptr)
return;
// Dedup: a name reserved twice resolves both instances to the one entry.
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0)
if (std::strcmp(g_reserved[i].name, name) == 0) {
index_ = i;
return;
}
}
if (g_reservedCount >= MAX_RESERVED)
return; // overflow — index_ stays -1, Slot() reports unclaimed
g_reserved[g_reservedCount].name = name;
g_reserved[g_reservedCount].slot = -1;
index_ = g_reservedCount;
++g_reservedCount;
}
int Lookup(const char *name) {
if (name == nullptr)
return -1;
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0)
return g_reserved[i].slot;
}
return -1;
int AutoReserve::Slot() const {
return index_ >= 0 ? g_reserved[index_].slot : -1;
}
int LookupByName(const char *name) {
@@ -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 {
+22 -17
View File
@@ -37,31 +37,36 @@ namespace Event::Custom {
// DLL operate on the table independently of the others.
//
// The name pointer must outlive the engine (a string literal does).
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
// names total (see Custom.cpp) silently drops the overflow — keep that cap
// above the codebase-wide `AutoReserve` count.
// Same name reserved twice is deduped (both instances resolve to the same
// reservation); reserving more than `MAX_RESERVED` names total (see
// Custom.cpp) silently drops the overflow — keep that cap above the
// codebase-wide `AutoReserve` count.
//
// Fire through the instance: `Fire(_r.Slot(), …)`. `Slot()` is the O(1)
// read of the reservation's current slot id — it resolves through the
// shared reservation table, so reload invalidation (`PrepareForReload`)
// and re-claiming (`RetryClaims`) stay centralized and the value is never
// stale. Returns -1 while unclaimed (before the first Lua-side
// `RegisterEvent` triggers `RetryClaims`), which `Fire` treats as a no-op.
// Do NOT cache the return across frames — read it at fire time (the read
// is two loads; there is nothing to save).
struct AutoReserve {
explicit AutoReserve(const char *name);
};
int Slot() const;
// Returns the slot id currently assigned to `name`, or -1 if not yet
// claimed (e.g. before the first Lua-side `RegisterEvent` has
// triggered `RetryClaims`). Slot indices may change across `/reload`,
// so call this at fire time rather than caching the value.
//
// Only consults our reservation cache — for engine-defined events
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
int Lookup(const char *name);
private:
int index_ = -1; // into the reservation table; -1 = dropped (overflow)
};
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
//
// Works for both engine-defined events AND our `AutoReserve`-claimed
// custom events (both populate the same table). Slightly slower than
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
// names we've reserved. Use this for engine-defined events we want to
// fire ourselves (e.g., polyfilling missing event dispatches).
// custom events (both populate the same table). O(table size) — for
// names we've reserved, prefer the O(1) `AutoReserve::Slot()`. Use this
// for engine-defined events we want to fire ourselves (e.g., polyfilling
// missing event dispatches).
int LookupByName(const char *name);
// True iff at least one frame is currently registered for the event in
@@ -88,7 +93,7 @@ bool HasListeners(int slot);
// Fire(slot, "%s%d", keyName, down); // MODIFIER_STATE_CHANGED(key, down)
//
// No-op for `eventID < 0`, which lets callers cheaply guard on the
// `Lookup()` result without an explicit if.
// `Slot()` result without an explicit if.
template <typename... Args>
inline void Fire(int eventID, const char *format, Args... args) {
if (eventID < 0)
+237 -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 *>(
@@ -410,6 +407,30 @@ static int __fastcall Script_C_Reputation_GetWatchedFactionData(void *L) {
return 1;
}
// `C_Reputation.GetFactionDataByID(factionID)` — modern table-shaped
// accessor keyed by faction ID rather than by displayed-list position.
// Returns nil when the ID has no `Faction.dbc` record.
//
// Unlike the index form, this doesn't need the faction to be in the
// player's displayed reputation list: `ReadFactionData` fills an
// unencountered faction cleanly (currentStanding 0, not at war), so a
// lookup works for any real faction.
static int __fastcall Script_C_Reputation_GetFactionDataByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_Reputation.GetFactionDataByID(factionID)");
return 0;
}
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
FactionData d;
if (!ReadFactionData(factionID, &d))
return 0; // nil — no such faction
Game::Lua::SetTop(L, 0);
PushFactionDataTable(L, d);
return 1;
}
// `C_Reputation.GetFactionDataByIndex(factionSortIndex)` — modern
// table-shaped accessor over the displayed reputation list. 1-based
// index covering the same range as vanilla's `GetFactionInfo(index)`
@@ -443,6 +464,182 @@ static int __fastcall Script_C_Reputation_GetFactionDataByIndex(void *L) {
return 1;
}
// `C_Reputation.ToggleFactionAtWarByID(factionID)` — ClassicAPI
// extension. Flips a faction's at-war state by ID rather than by
// displayed-list position.
//
// Mirrors the stock `FactionToggleAtWar(index)` body exactly, minus the
// index resolve: read the current at-war flag, negate it, hand it to the
// engine's own setter. Every rule therefore stays with the engine —
// refusing to make peace below -3000 standing, honouring the
// peace-forced flag, the loot-session bail, and sending
// `CMSG_SET_FACTION_ATWAR`. Going through the engine's setter also means
// `Faction::UnitFactionPolyfill`'s hook on it fires `UNIT_FACTION`
// ("any future C++ caller of the setter inherits the polyfill"), and its
// change-detection keeps that silent when the engine refuses the toggle.
//
// Non-positive IDs are ignored (the stock path likewise skips
// factionID 0, which is what its resolver returns for header rows).
static int __fastcall Script_C_Reputation_ToggleFactionAtWarByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_Reputation.ToggleFactionAtWarByID(factionID)");
return 0;
}
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (factionID <= 0)
return 0;
// `newState` is declared `int`, not `char`, to match the detour
// `Faction::UnitFactionPolyfill` installs on this setter: a `char`
// argument only defines the low byte of EDX, leaving the detour's
// `int` parameter with undefined high bits.
using GetAtWar_t = unsigned int(__fastcall *)(int factionID);
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
auto getAtWar = reinterpret_cast<GetAtWar_t>(
static_cast<uintptr_t>(Offsets::FUN_FACTION_GET_AT_WAR));
auto setAtWar = reinterpret_cast<SetAtWar_t>(
static_cast<uintptr_t>(Offsets::FUN_FACTION_SET_AT_WAR));
setAtWar(factionID, (getAtWar(factionID) != 0) ? 0 : 1);
return 0;
}
// `factionID` → its 0..63 reputation-slot index, or -1 when the faction
// has no slot (no record, or a header category). Same `Faction.dbc`
// `RepListIndex` field `ReadFactionData` reads.
static int RepSlotForFaction(int factionID) {
if (factionID <= 0)
return -1;
const uint8_t *record = FactionRecord(factionID);
if (record == nullptr)
return -1;
const int repListIndex = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
if (repListIndex < 0 || repListIndex >= Offsets::MAX_REP_SLOTS)
return -1;
return repListIndex;
}
// Whether a reputation slot carries the INACTIVE flag.
static bool RepSlotIsInactive(int repSlot) {
auto *slot = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_REP_SLOTS) +
static_cast<uintptr_t>(repSlot) * Offsets::REP_SLOT_STRIDE);
return (*(slot + Offsets::OFF_REP_SLOT_FLAGS) &
Offsets::REP_SLOT_FLAG_INACTIVE) != 0;
}
// `C_Reputation.IsFactionActive(factionSortIndex)` — whether the faction
// at a 1-based displayed-list position is NOT filed under "Inactive".
//
// Runs the same chain as the engine's own inactive check (resolve the
// index to a faction id, then read the slot's INACTIVE bit) and returns
// the inverse as a real boolean. A position that names no faction — out
// of range, or a category header — reports `false`.
static int __fastcall Script_C_Reputation_IsFactionActive(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_Reputation.IsFactionActive(factionSortIndex)");
return 0;
}
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
const int maxIdx = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_FACTION_VISIBLE_MAX_INDEX));
const int factionID = (idx >= 0 && idx <= maxIdx) ? Resolver()(idx) : 0;
const int repSlot = RepSlotForFaction(factionID);
Game::Lua::SetTop(L, 0);
Game::Lua::PushBool(L, repSlot >= 0 && !RepSlotIsInactive(repSlot));
return 1;
}
// `C_Reputation.IsFactionActiveByID(factionID)` — ClassicAPI extension.
// The same answer keyed by faction id. Reports `false` for a faction
// with no reputation slot.
static int __fastcall Script_C_Reputation_IsFactionActiveByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_Reputation.IsFactionActiveByID(factionID)");
return 0;
}
const int repSlot =
RepSlotForFaction(static_cast<int>(Game::Lua::ToNumber(L, 1)));
Game::Lua::SetTop(L, 0);
Game::Lua::PushBool(L, repSlot >= 0 && !RepSlotIsInactive(repSlot));
return 1;
}
// Shared body for the two by-ID inactive setters below. Hands the id to
// the engine's own inactive setter, which flips the rep slot's INACTIVE
// bit, tells the server, and rebuilds the displayed faction list so the
// faction moves into or out of the "Inactive" category. That setter is
// also what `Faction::UnitFactionPolyfill` hooks, so `UNIT_FACTION`
// fires when the flag actually changes.
//
// `newState` is `int` rather than `char` to match that detour's
// signature — see the note in ToggleFactionAtWarByID.
static int SetFactionInactiveByID(void *L, int newState, const char *usage) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, usage);
return 0;
}
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (factionID <= 0)
return 0;
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
auto fn = reinterpret_cast<SetInactive_t>(
static_cast<uintptr_t>(Offsets::FUN_FACTION_SET_INACTIVE));
fn(factionID, newState);
return 0;
}
// `C_Reputation.SetFactionInactiveByID(factionID)` — ClassicAPI
// extension. Moves a faction into the "Inactive" category by ID rather
// than by displayed-list position.
static int __fastcall Script_C_Reputation_SetFactionInactiveByID(void *L) {
return SetFactionInactiveByID(
L, 1, "Usage: C_Reputation.SetFactionInactiveByID(factionID)");
}
// `C_Reputation.SetFactionActiveByID(factionID)` — the inverse: takes a
// faction back out of the "Inactive" category.
static int __fastcall Script_C_Reputation_SetFactionActiveByID(void *L) {
return SetFactionInactiveByID(
L, 0, "Usage: C_Reputation.SetFactionActiveByID(factionID)");
}
// `C_Reputation.SetSelectedFactionByID(factionID)` — ClassicAPI
// extension. Selects a faction in the reputation pane by ID rather
// than by displayed-list position, the same convenience
// `SetWatchedFactionByID` gives over `SetWatchedFactionIndex`.
//
// The engine already stores the selection AS a factionID
// (`VAR_FACTION_SELECTED_ID`) — the stock index form only resolves its
// argument to an id before storing it — so this writes the id straight
// through, with no list walk and no dependence on the faction being
// currently listed. `GetSelectedFaction()` maps it back to an index,
// reporting 0 while the faction isn't in the displayed list.
//
// Negative IDs are ignored; 0 clears the selection.
static int __fastcall Script_C_Reputation_SetSelectedFactionByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_Reputation.SetSelectedFactionByID(factionID)");
return 0;
}
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (factionID < 0)
return 0;
*reinterpret_cast<int32_t *>(
static_cast<uintptr_t>(Offsets::VAR_FACTION_SELECTED_ID)) = factionID;
return 0;
}
// `C_Reputation.SetWatchedFactionByID(factionID)` — sets the faction
// shown above the XP bar by ID rather than by displayed-list index.
// Modern API; vanilla 1.12 only exposes `SetWatchedFactionIndex(idx)`,
@@ -478,10 +675,46 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetFactionParentID", &Script_GetFactionParentID);
Game::Lua::RegisterTableFunction("C_Reputation", "SetWatchedFactionByID",
&Script_C_Reputation_SetWatchedFactionByID);
Game::Lua::RegisterTableFunction("C_Reputation", "SetSelectedFactionByID",
&Script_C_Reputation_SetSelectedFactionByID);
Game::Lua::RegisterTableFunction("C_Reputation", "ToggleFactionAtWarByID",
&Script_C_Reputation_ToggleFactionAtWarByID);
Game::Lua::RegisterTableFunction("C_Reputation", "IsFactionActive",
&Script_C_Reputation_IsFactionActive);
Game::Lua::RegisterTableFunction("C_Reputation", "IsFactionActiveByID",
&Script_C_Reputation_IsFactionActiveByID);
Game::Lua::RegisterTableFunction(
"C_Reputation", "IsFactionInactive",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_IS_FACTION_INACTIVE)));
Game::Lua::RegisterTableFunction("C_Reputation", "SetFactionInactiveByID",
&Script_C_Reputation_SetFactionInactiveByID);
Game::Lua::RegisterTableFunction("C_Reputation", "SetFactionActiveByID",
&Script_C_Reputation_SetFactionActiveByID);
Game::Lua::RegisterTableFunction(
"C_Reputation", "SetFactionInactive",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_FACTION_INACTIVE)));
Game::Lua::RegisterTableFunction(
"C_Reputation", "SetFactionActive",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_FACTION_ACTIVE)));
Game::Lua::RegisterTableFunction(
"C_Reputation", "ToggleFactionAtWar",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_FACTION_TOGGLE_AT_WAR)));
// The stock global, mirrored into the namespace — the engine's own
// handler already has the Lua C ABI, so it registers as-is.
Game::Lua::RegisterTableFunction(
"C_Reputation", "SetSelectedFaction",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_SELECTED_FACTION)));
Game::Lua::RegisterTableFunction("C_Reputation", "GetWatchedFactionData",
&Script_C_Reputation_GetWatchedFactionData);
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionStandings",
&Script_C_Reputation_GetFactionStandings);
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionDataByID",
&Script_C_Reputation_GetFactionDataByID);
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionDataByIndex",
&Script_C_Reputation_GetFactionDataByIndex);
}
+1 -1
View File
@@ -98,7 +98,7 @@ void __fastcall FireNotify_h(int factionID, int delta) {
// any addon relying on the chat event firing first would expect.
FireNotify_o(factionID, delta);
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d%d", factionID, newStanding, delta);
+49 -12
View File
@@ -103,6 +103,9 @@
// (`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 "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
@@ -141,13 +144,7 @@ void LowerCopy(char *dst, const char *src, size_t n) {
dst[i] = '\0';
}
// Bounded, always-terminated copy (avoids the C4996 strncpy/strcpy warnings).
void BoundedCopy(char *dst, const char *src, size_t n) {
size_t i = 0;
for (; src && src[i] && i + 1 < n; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
using Common::BoundedCopy;
// dst = tolower(a .. b .. c). Truncates at n-1.
void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char *c) {
@@ -653,7 +650,7 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
return false;
if (unit)
Spell::AtUnit::CastByName(spell, unit);
Spell::AtUnit::CastByName(spell, unit, /*placeGroundSpell*/ true);
else
Game::Lua::CallGlobalString(L, "CastSpellByName", spell);
return true;
@@ -945,11 +942,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 +1104,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
+178
View File
@@ -0,0 +1,178 @@
// 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 intercept that runner (through `Frame::RunnerHook`, the shared single
// hook on it) and, when the fired slot IS the OnClick slot, re-invoke the
// runner for our PreClick cell first, then let OnClick run, then re-invoke
// for PostClick — reusing the SAME (fmt, varargs) so PreClick/PostClick
// receive arg1 = the button name exactly like OnClick.
//
// 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 "frame/RunnerHook.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 interceptor (fire PreClick before / PostClick after OnClick) ---
// Subscribed to `Frame::RunnerHook`, the single shared hook on
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT (MinHook allows one hook per address).
// Fire the handler (if any) for `kind` on `frame`, reusing the OnClick fire's
// own (fmt, varargs) so arg1 = the button name. We call the engine runner
// directly (its exec-context stamp is valid at this dispatch level). The runner
// balances the Lua stack itself; we snapshot/restore the top as cheap insurance
// against any imbalance leaking to the still-running button click code.
void FireCell(void *frame, int kind, const char *fmt, void *varargs) {
uint32_t *slot = SlotFor(frame, kind, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
Frame::RunnerHook::Original(frame, slot, fmt, varargs);
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
// Falls through (false) for every fire that isn't a top-level OnClick;
// otherwise runs the whole bracket itself and reports it handled (true).
bool OnRun(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
const auto onClickSlot = reinterpret_cast<uint32_t *>(
reinterpret_cast<char *>(frame) + Offsets::OFF_BUTTON_ONCLICK_HANDLER);
if (slotPtr != onClickSlot || g_firing > 0)
return false;
++g_firing;
FireCell(frame, SK_PRECLICK, fmt, varargs);
Frame::RunnerHook::Original(frame, slotPtr, fmt, varargs); // the button's OnClick
FireCell(frame, SK_POSTCLICK, fmt, varargs);
--g_firing;
return true;
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_BUTTON_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Frame::RunnerHook::AutoSubscribe _runnerSub{&OnRun};
} // 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
-58
View File
@@ -331,63 +331,6 @@ int __fastcall Script_HookScript(void *L) {
return 0;
}
// ---- IsEventRegistered (Frame) ---------------------------------------------
// `frame:IsEventRegistered("event")` — vanilla has RegisterEvent /
// UnregisterEvent but not the query. There's no engine Script_* to
// delegate to, so we replicate the membership check that lives inside
// Frame::RegisterEvent (FUN_00702140): find the event by name in the live
// event table, then walk that entry's subscriber chain for this frame.
// The frame pointer we compare against is the same CFrameScriptObject*
// the engine stores (Game::Lua::ResolveObject resolves self exactly as
// Script_RegisterEvent does).
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
bool FrameHasEvent(void *frame, const char *eventName) {
const uint32_t count = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
auto *base = Game::Read<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
if (count == 0 || base == nullptr)
return false;
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(
static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
for (uint32_t i = 0; i < count; ++i) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = Game::Read<const char *>(
entry, Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (sstrcmpi(name, eventName, 0x7FFFFFFF) != 0)
continue;
// Names are unique, so this is the one entry — walk its chain.
uintptr_t node = Game::Read<uintptr_t>(
entry, Offsets::OFF_EVENT_ENTRY_HEAD);
while ((node & 1) == 0 && node != 0) {
if (Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
return true;
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT);
}
return false;
}
return false;
}
int __fastcall Script_IsEventRegistered(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:IsEventRegistered(\"event\")");
return 0;
}
void *frame = Game::Lua::ResolveObject(L, 1);
const char *eventName = Game::Lua::ToString(L, 2);
const bool registered =
frame != nullptr && eventName != nullptr && FrameHasEvent(frame, eventName);
Game::Lua::PushBool(L, registered);
return 1;
}
// ---- SetPoint one-arg form (Script_SetPoint co-hook) -----------------------
// Vanilla's Script_SetPoint accepts every modern form except the bare
@@ -427,7 +370,6 @@ const Game::Lua::FrameMethodEntry g_frameMethods[] = {
Offsets::FUN_SCRIPT_FRAME_HIDE>},
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
{"GetEffectiveAlpha", &Script_GetEffectiveAlpha},
};
+51
View File
@@ -0,0 +1,51 @@
// 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 "frame/RunnerHook.h"
#include "Game.h"
#include "Offsets.h"
namespace Frame::RunnerHook {
namespace {
// Chained at static-init time by each `AutoSubscribe` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init, so
// the head is valid when subscribers link in.
AutoSubscribe *g_head = nullptr;
using Runner_t = void(__cdecl *)(void *frame, uint32_t *slotPtr, const char *fmt,
void *varargs);
Runner_t g_orig = nullptr;
void __cdecl Hook(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
if (s->fn(frame, slotPtr, fmt, varargs))
return; // handled — suppressed, or the subscriber ran Original itself
g_orig(frame, slotPtr, fmt, varargs);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT, reinterpret_cast<void *>(&Hook),
reinterpret_cast<void **>(&g_orig)};
} // namespace
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
void Original(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
g_orig(frame, slotPtr, fmt, varargs);
}
} // namespace Frame::RunnerHook
+56
View File
@@ -0,0 +1,56 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Shared interceptor for the engine's arg'd frame-script runner
// `FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT` (0x00702710) — `__cdecl(frame, slotPtr,
// fmt, vaPtr)`: stamps the exec context from `slotPtr[1]`, runs the handler in
// `slotPtr[0]` through FUN_FRAME_RUN_SCRIPT_ARGS, restores. Exactly two engine
// paths funnel through it: every arg'd input-script fire (OnClick,
// OnMouseWheel, … via the variadic forwarder FUN_007026F0) and the event
// dispatcher FUN_FIRE_EVENT's per-frame OnEvent fire, which passes
// `slotPtr == frame + OFF_FRAME_ONEVENT_SLOT`.
//
// MinHook allows ONE hook per address, and more than one feature needs this
// runner: `Frame::ClickEvents` brackets OnClick with PreClick/PostClick, and
// `Frame::UnitEvent` suppresses OnEvent fires that a RegisterUnitEvent filter
// rejects. So — exactly like `Event::SignalHook` and `Tick::WorldTick` — this
// module owns the single hook and fans out. Declare a file-scope
// static const Frame::RunnerHook::AutoSubscribe _sub{&interceptor};
//
// An interceptor returns `true` when it has fully handled the fire — either
// suppressed it, or already ran the engine itself through `Original` (the
// bracketing case). `false` falls through to the next subscriber and finally
// the original. The FIRST `true` wins, so subscribers must claim disjoint slot
// addresses; subscriber order is static-init order and must not matter.
namespace Frame::RunnerHook {
using Interceptor = bool (*)(void *frame, uint32_t *slotPtr, const char *fmt,
void *varargs);
struct AutoSubscribe {
explicit AutoSubscribe(Interceptor fn);
Interceptor fn;
AutoSubscribe *next;
};
// The engine's runner (the MinHook trampoline) — for interceptors that run the
// fire themselves, e.g. to bracket it, rather than suppress it. Valid only once
// hooks are installed, i.e. from inside an interceptor.
void Original(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs);
} // namespace Frame::RunnerHook
+4 -5
View File
@@ -287,14 +287,13 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
// lua_State: top @ +0x08 (TValue *, next free slot)
// TValue: tag @ +0x00 (int); GC/closure pointer @ +0x08 (16-byte TValue)
// Closure: isC byte @ +0x06; union member (C func / Proto) @ +0x0C
// Proto: numparams (u8) @ +0x45, is_vararg (u8) @ +0x46
// Single-use here, so kept local (like the lua_State field offsets elsewhere).
// The Proto fields this reads (numparams @ +0x45, is_vararg @ +0x46) are shared
// with LuaSyntax::Upvalues and live in Offsets.h as OFF_LUA_PROTO_*.
constexpr uintptr_t OFF_LUASTATE_TOP = 0x08;
constexpr uintptr_t OFF_TVALUE_GC = 0x08;
constexpr uintptr_t OFF_CLOSURE_ISC = 0x06;
constexpr uintptr_t OFF_LCLOSURE_PROTO = 0x0C;
constexpr uintptr_t OFF_PROTO_NUMPARAMS = 0x45;
constexpr uintptr_t OFF_PROTO_IS_VARARG = 0x46;
constexpr int kLuaTFunction = 6;
constexpr uintptr_t kTValueSize = 0x10;
@@ -322,8 +321,8 @@ bool HandlerWantsArgs(void *L, int handlerRef) {
const void *proto = reinterpret_cast<const void *>(
Game::Read<uintptr_t>(cl, OFF_LCLOSURE_PROTO));
if (proto != nullptr)
wants = Game::Read<uint8_t>(proto, OFF_PROTO_NUMPARAMS) > 0 ||
Game::Read<uint8_t>(proto, OFF_PROTO_IS_VARARG) != 0;
wants = Game::Read<uint8_t>(proto, Offsets::OFF_LUA_PROTO_NUMPARAMS) > 0 ||
Game::Read<uint8_t>(proto, Offsets::OFF_LUA_PROTO_IS_VARARG) != 0;
}
}
Game::Lua::SetTop(L, -2); // pop the handler
+374
View File
@@ -0,0 +1,374 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `frame:RegisterUnitEvent(event, ...units)` — register for an event but only
// receive it for the given unit tokens — plus the unit-aware
// `frame:IsEventRegistered(event)` (registered [, unit…]).
//
// Vanilla owns everything except the filter, so the filter is all this adds:
//
// - Chain membership stays the engine's. RegisterUnitEvent delegates to the
// engine's own `Script_RegisterEvent` (stack cut to (self, event)), so the
// frame lands on the event's subscriber chain through the same code every
// RegisterEvent uses — self typecheck, error text, and DllMain's
// RegisterEvent hook included.
// - The filter is stored here, keyed by frame, and dropped exactly where the
// engine drops the frame's subscriber node: co-hooks on the C++ helpers
// `Frame::UnregisterEvent` (FUN_FRAME_UNREGISTER_EVENT) and
// `Frame::UnregisterAllEvents` (FUN_FRAME_UNREGISTER_ALL_EVENTS — also the
// CScriptObject destructor's path, so frame destruction is covered).
// - The check is one gate on the per-frame OnEvent fire. The dispatcher
// FUN_FIRE_EVENT sets `_G.event`, walks the chain, and per frame calls the
// runner FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT(frame, frame+OFF_FRAME_ONEVENT_SLOT,
// fmt, va). We subscribe to that runner through `Frame::RunnerHook` (it is
// shared with Frame::ClickEvents — one MinHook per address), and when the
// frame has a filter for the event being dispatched and arg1 matches none
// of its tokens, we return without invoking the handler. Suppressing at the
// runner also suppresses HookScript chains and Frame::ScriptArgs' positional
// path — they all live downstream of it, which is what retail does too.
//
// Semantics mirror Blizzard's 5.4.8 implementation (Ghidra-verified), where the
// filter lives ON the subscriber node and the node's kind is fixed when it is
// created: RegisterEvent on a filtered registration leaves the filter;
// RegisterUnitEvent on a plain registration stays plain; RegisterUnitEvent on a
// filtered registration replaces the units; RegisterUnitEvent with no usable
// unit is a plain RegisterEvent. Switching kinds takes UnregisterEvent first.
//
// Two places we deliberately don't mirror 5.4.8, both additive:
//
// - UNIT COUNT. 5.4.8 reads exactly two units (a hardcoded two-iteration loop
// over Lua indices 3 and 4) and folds each token's table index into a byte
// of one dword on the node — `filter = filter * 0x100 + idx`. Retail later
// filled that dword and documents "up to four units", which its 5.4.8
// reader already anticipated by walking all four bytes generically. We
// store strings in our own map, so there is no dword to fill and no reason
// to pick a number: every argument past the event name that is a string is
// taken as a unit. Retail's four are covered by definition.
// - TOKEN VALIDATION. 5.4.8 looks each unit up in a static canonical token
// table (case-sensitive) and drops unknowns; we keep the string and compare
// case-insensitively, because ClassicAPI's own tokens (`focus`,
// `nameplateN`, `markN`) are not in the engine's table.
//
// Filter rule: the filter applies only when the event's first argument is a
// string (every UNIT_* event fires as "%s" + token). A first spec that isn't
// `%s`, or a no-arg event (those go through FUN_FIRE_EVENT_NO_ARGS → a runner
// we never see), fires as with RegisterEvent — one rule that holds on every
// dispatch path, whose failure mode is an extra event rather than a lost one.
#include "Game.h"
#include "Offsets.h"
#include "frame/RunnerHook.h"
#include <cstdint>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Frame::UnitEvent {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
// Storm SStrCmpI — `__stdcall`, RET 0xC (see Offsets.h; a __cdecl declaration
// drifts ESP by 12 per call).
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
int CallScript(uintptr_t fn, void *L) { return reinterpret_cast<ScriptFn_t>(fn)(L); }
bool EqualsIgnoreCase(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return false;
auto cmp = reinterpret_cast<SStrCmpI_t>(static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
return cmp(a, b, 0x7FFFFFFF) == 0;
}
// One filtered registration — the unit filter 5.4.8 keeps on the event's
// subscriber node for this frame. `event` is the CANONICAL name copied from the
// event-table entry, so the fire-time compare against the dispatcher-set
// `_G.event` (which is that same entry name) is a plain strcmp.
struct Filter {
std::string event;
std::vector<std::string> units; // never empty; a filter with no unit is no filter
};
// Keyed by frame. Entries die with the frame's subscriber node (the two
// unregister co-hooks) and on /reload (PrepareForReload).
std::unordered_map<const void *, std::vector<Filter>> g_filters;
// ---- event table -----------------------------------------------------------
// The live event-table entry whose name matches `eventName` (case-insensitive,
// as Frame::RegisterEvent matches), or nullptr for an unknown event.
const uint8_t *FindEventEntry(const char *eventName) {
if (eventName == nullptr)
return nullptr;
const uint32_t count = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
auto *base = Game::Read<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
if (count == 0 || base == nullptr)
return nullptr;
for (uint32_t i = 0; i < count; ++i) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = Game::Read<const char *>(entry, Offsets::OFF_EVENT_ENTRY_NAME);
if (name != nullptr && EqualsIgnoreCase(name, eventName))
return entry; // names are unique
}
return nullptr;
}
// Is `frame` on `entry`'s subscriber chain — the membership walk
// Frame::RegisterEvent performs before appending.
bool ChainHas(const uint8_t *entry, const void *frame) {
uintptr_t node = Game::Read<uintptr_t>(entry, Offsets::OFF_EVENT_ENTRY_HEAD);
while ((node & 1) == 0 && node != 0) {
if (Game::Read<const void *>(node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
return true;
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT);
}
return false;
}
// ---- filter map --------------------------------------------------------------
Filter *FindFilter(const void *frame, const char *eventName) {
auto it = g_filters.find(frame);
if (it == g_filters.end())
return nullptr;
for (Filter &f : it->second)
if (EqualsIgnoreCase(f.event.c_str(), eventName))
return &f;
return nullptr;
}
void EraseFilter(const void *frame, const char *eventName) {
auto it = g_filters.find(frame);
if (it == g_filters.end())
return;
std::vector<Filter> &v = it->second;
for (size_t i = 0; i < v.size(); ++i) {
if (EqualsIgnoreCase(v[i].event.c_str(), eventName)) {
v.erase(v.begin() + static_cast<ptrdiff_t>(i));
break;
}
}
if (v.empty())
g_filters.erase(it);
}
// ---- Lua methods --------------------------------------------------------------
// `frame:RegisterUnitEvent(event, ...units)` → registered.
int __fastcall Script_RegisterUnitEvent(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:RegisterUnitEvent(\"event\" [, \"unit1\", ...])");
return 0;
}
void *frame = Game::Lua::ResolveObject(L, 1);
if (frame == nullptr) {
// Not a frame — hand it to the engine so it raises its own error, so
// the message stays the engine's rather than a copy of it.
Game::Lua::SetTop(L, 2);
return CallScript(Offsets::FUN_SCRIPT_FRAME_REGISTEREVENT, L);
}
// Read everything BEFORE reshaping the stack for the delegate — including
// the top, which the reshape destroys. Like 5.4.8 we take only real strings
// as units (a number is not coerced) and silently skip anything else.
const std::string eventName = Game::Lua::ToString(L, 2);
const int argTop = Game::Lua::GetTop(L);
std::vector<std::string> units;
for (int idx = 3; idx <= argTop; ++idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_STRING)
continue;
const char *s = Game::Lua::ToString(L, idx);
if (s != nullptr && *s != '\0')
units.push_back(s);
}
const uint8_t *entry = FindEventEntry(eventName.c_str());
const bool wasRegistered = entry != nullptr && ChainHas(entry, frame);
// Chain membership is the engine's: (self, event) → Script_RegisterEvent.
Game::Lua::SetTop(L, 2);
CallScript(Offsets::FUN_SCRIPT_FRAME_REGISTEREVENT, L);
// 5.4.8 node semantics (see the header). No usable unit → this was a plain
// RegisterEvent and an existing filter is left alone; an unknown event has
// no node to attach a filter to.
if (entry != nullptr && !units.empty()) {
if (Filter *f = FindFilter(frame, eventName.c_str())) {
f->units = units; // filtered → replace the units
} else if (!wasRegistered) {
Filter fresh;
fresh.event = Game::Read<const char *>(entry, Offsets::OFF_EVENT_ENTRY_NAME);
fresh.units = units;
g_filters[frame].push_back(fresh);
}
// else: already registered plainly → stays plain
}
Game::Lua::PushBool(L, entry != nullptr && ChainHas(entry, frame));
return 1;
}
// `frame:IsEventRegistered(event)` → registered [, unit…]. Vanilla has
// RegisterEvent / UnregisterEvent but not the query; the membership check is
// the same chain walk Frame::RegisterEvent performs, so the answer is exactly
// what the dispatcher sees. Units come back in registration order.
int __fastcall Script_IsEventRegistered(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:IsEventRegistered(\"event\")");
return 0;
}
void *frame = Game::Lua::ResolveObject(L, 1);
const char *eventName = Game::Lua::ToString(L, 2);
const uint8_t *entry = (frame != nullptr) ? FindEventEntry(eventName) : nullptr;
const bool registered = entry != nullptr && ChainHas(entry, frame);
Game::Lua::PushBool(L, registered);
if (!registered)
return 1;
const Filter *f = FindFilter(frame, eventName);
if (f == nullptr)
return 1;
if (Game::Lua::CheckStack(L, static_cast<int>(f->units.size()) + 1) == 0)
return 1; // can't grow the stack — report registered, drop the units
int pushed = 1;
for (const std::string &u : f->units) {
Game::Lua::PushString(L, u.c_str());
++pushed;
}
return pushed;
}
// ---- the gate ------------------------------------------------------------------
// True (and `*out` = arg1) when the runner's first conversion spec is `%s` —
// the unit token of every unit event. Anything else means arg1 isn't a string
// and the filter does not apply.
bool FirstArgIsString(const char *fmt, const void *varargs, const char **out) {
if (fmt == nullptr || varargs == nullptr)
return false;
const char *p = std::strchr(fmt, '%');
if (p == nullptr || p[1] != 's')
return false;
*out = *static_cast<const char *const *>(varargs);
return true;
}
// Frame::RunnerHook interceptor. Returns true ONLY to suppress a fire (the
// engine runner is then never called for it); every other path returns false
// and the fire proceeds untouched. Touches no Lua code: `_G.event` is read with
// RawGet so an `__index` metatable on _G can never run script here, and no
// reference into g_filters outlives the call.
bool OnRun(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
// Only the dispatcher's OnEvent fire passes the OnEvent slot.
if (frame == nullptr ||
slotPtr != reinterpret_cast<uint32_t *>(static_cast<uint8_t *>(frame) +
Offsets::OFF_FRAME_ONEVENT_SLOT))
return false;
// Frames without filters pay one probe and leave.
if (g_filters.empty())
return false;
auto it = g_filters.find(frame);
if (it == g_filters.end())
return false;
// Which event? The dispatcher has just set `_G.event` to the entry's name.
void *L = Game::Lua::State();
if (L == nullptr)
return false;
Game::Lua::PushString(L, "event");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
const char *ev = Game::Lua::ToString(L, -1);
const Filter *match = nullptr;
if (ev != nullptr) {
for (const Filter &f : it->second) {
if (std::strcmp(f.event.c_str(), ev) == 0) {
match = &f;
break;
}
}
}
Game::Lua::SetTop(L, -2);
if (match == nullptr)
return false;
// The filter applies only when arg1 is a string.
const char *arg1 = nullptr;
if (!FirstArgIsString(fmt, varargs, &arg1))
return false;
if (arg1 != nullptr) {
for (const std::string &u : match->units)
if (EqualsIgnoreCase(arg1, u.c_str()))
return false;
}
return true; // no token matched — suppress
}
// ---- unregister co-hooks (the filter dies with the node) ------------------------
// `__thiscall(frame, eventName)` mimicked as `__fastcall(ecx, edx_unused, …)`.
using UnregisterEvent_t = void(__fastcall *)(void *frame, void *edx, const char *eventName);
UnregisterEvent_t g_origUnregisterEvent = nullptr;
void __fastcall UnregisterEvent_h(void *frame, void *edx, const char *eventName) {
if (frame != nullptr && !g_filters.empty())
EraseFilter(frame, eventName);
g_origUnregisterEvent(frame, edx, eventName);
}
// `__fastcall(frame)`; the Lua wrapper has dead `(0)` calls after lua_error, so
// tolerate a null frame.
using UnregisterAll_t = void(__fastcall *)(void *frame, void *edx);
UnregisterAll_t g_origUnregisterAll = nullptr;
void __fastcall UnregisterAllEvents_h(void *frame, void *edx) {
if (frame != nullptr && !g_filters.empty())
g_filters.erase(frame);
g_origUnregisterAll(frame, edx);
}
// ---- registration -----------------------------------------------------------------
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"RegisterUnitEvent", &Script_RegisterUnitEvent},
{"IsEventRegistered", &Script_IsEventRegistered},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY), g_frameMethods,
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
}
// Frames survive /reload but the engine rebuilds the event table with empty
// chains and every frame re-registers from scratch, so the filters start over
// too — and a recycled frame pointer can never inherit a stale one.
void PrepareForReload() { g_filters.clear(); }
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
const Frame::RunnerHook::AutoSubscribe _runnerSub{&OnRun};
const Game::HookAutoRegister _hookUnregister{
Offsets::FUN_FRAME_UNREGISTER_EVENT, reinterpret_cast<void *>(&UnregisterEvent_h),
reinterpret_cast<void **>(&g_origUnregisterEvent)};
const Game::HookAutoRegister _hookUnregisterAll{
Offsets::FUN_FRAME_UNREGISTER_ALL_EVENTS,
reinterpret_cast<void *>(&UnregisterAllEvents_h),
reinterpret_cast<void **>(&g_origUnregisterAll)};
} // namespace
} // namespace Frame::UnitEvent
+2 -35
View File
@@ -18,8 +18,7 @@
#include "Notes.h"
#include "Game.h"
#include "Offsets.h"
#include "settings/Paths.h"
#include <cstdint>
#include <cstdio>
@@ -41,43 +40,11 @@ std::unordered_map<uint64_t, Entry> g_notes;
std::string g_path;
bool g_loaded = false;
const char *ReadAccountName() {
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
}
const char *ReadRealmName() {
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
if (info == nullptr)
return nullptr;
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
}
const char *ReadCharacterName() {
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
return (p[0] == '\0') ? nullptr : p;
}
// WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt, or empty until
// the account + realm + character session globals are populated. Per-character
// because the friends list is per-character in vanilla.
std::string ResolveFilePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
const char *character = ReadCharacterName();
if (character == nullptr)
return {};
std::string out = "WTF\\Account\\";
out += account;
out += '\\';
out += realm;
out += '\\';
out += character;
out += "\\ClassicAPI_FriendNotes.txt";
return out;
return Settings::Paths::CharacterFile("ClassicAPI_FriendNotes.txt");
}
// Notes are a single line each; the file is tab-delimited, so a note must
+1 -1
View File
@@ -107,7 +107,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "RequestLoadGameObjectByID",
&Script_RequestLoadGameObjectByID);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE),
kGameObjectDataLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
&_reserveLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};

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