Compare commits

...
7 Commits
Author SHA1 Message Date
Brues b497bb7fb0 fix(attributes): run macrotext as text, not through name-based RunMacro
The secure "macro" action read both `macrotext` (raw text) and the
deprecated `macro` (saved-macro name) into one buffer and ran whichever
through the global RunMacro. RunMacro is name-based -- stock resolves an
index, and SuperCleveRoidMacros' RunMacro calls GetMacroIndexByName --
so a macrotext value like "/cast [hp:<90] Holy Light" resolved to no
macro and silently did nothing. Because the global existed, the native
fallback never ran.

Split the two by their real semantics: macrotext runs through the chat
parser (RunMacroTextC), so each line dispatches via SlashCmdList and
picks up addon slash hooks like SuperCleveRoidMacros' conditional /cast;
the name form keeps preferring RunMacro. Fixes click-cast macros with
conditionals.
2026-08-12 00:32:28 -05:00
Brues 4d5fe8d41b feat(spellbook): add C_SpellBook.GetSpellBookItemInfo
Backport C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank) -> table,
reusing Spell::Info's DBC reader. slotIndex is 1-based across the book;
spellBank is Enum.SpellBookSpellBank (0 = Player, 1 = Pet), resolved via
Spell::Lookup::SpellbookSlotToID. Returns nil for an empty/out-of-range
slot, matching retail.

Fields map from Spell.dbc: itemType is Spell (player) or PetAction (pet)
- vanilla's book holds only real spells, so None/FutureSpell/Flyout never
occur; actionID == spellID (no override system); name/subName from the
localized Name/Rank; isPassive from Attributes; isOffSpec always false.
iconID is the icon PATH string (vanilla has no fileID), the same
deviation as C_Spell.GetSpellInfo.iconID; skillLineIndex is omitted.

Also register the two Enum tables the return contract references
(non-destructively, like the other Enum.* additions) so
info.itemType == Enum.SpellBookItemType.Spell comparisons work:
Enum.SpellBookSpellBank and Enum.SpellBookItemType.

Document in docs/API.md (SpellBook section + both Enum entries) and list
in README.
2026-08-11 01:28:31 -05:00
Brues 46cae9d465 fix(unitmenu): stop stacking Focus/Cancel when a submenu opens
ClassicAPI_ToggleUnitMenu's initialize closure ran the same level-1
customization at every level. When the mouse crossed a nested entry
(Loot Method / Loot Threshold / Raid Target Icon) the menu builds its
level-2 submenu and re-invokes initialize; our AddFocusButton /
AddCancelButton call UIDropDownMenu_AddButton with no level, which
defaults to level 1 (not the current level), appending onto the still-
open level-1 list. Level 1 is not reset while a submenu rebuilds, so the
list grew every hover until "Too many buttons in UIDropDownMenu:
ClassicAPIUnitMenuDropDown", pushing the real entries out of reach.

Make initialize level-aware: at level >= 2 hand off to UnitPopup_ShowMenu
(it resolves the submenu from UIDROPDOWNMENU_MENU_VALUE and adds its own
Cancel); do the scratch-copy + Focus/Cancel append only at level 1 -
mirroring how Blizzard's own unit dropdowns init.
2026-08-10 13:08:58 -05:00
Brues 93360ce482 fix(cast): don't start target-selection casts at the reticle step
Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
FUN_CAST_START_SET before Spell_C_TargetSpell raises the reticle, so a
target-selection spell (Disenchant, ground-target AoE) stamps g_cast at
the green-cursor step - seconds before the item/ground is clicked and the
cast actually begins. The later click re-enters Spell_C_CastSpell only to
hit its "already this spell" short-circuit, so the writer never re-fires
for the real start. Result: UNIT_SPELLCAST_START (and a UnitCastingInfo
cast bar) fired far too early, and a cancel fired a phantom
START/INTERRUPTED/STOP for a cast that never happened.

Drop the client stamp in OnWorldTick while the reticle is up for that
spell. On selection the server's SMSG_SPELL_START re-stamps g_cast via
the fresh-stamp path (it is 0), so START fires with the correct time and
the SENT castGUID; on cancel it stays cleared. Gated on !g_castFromServer
(never touches the server re-stamp) and on g_cast.spellID ==
VAR_PENDING_CAST_SPELL (only the spell whose reticle is actually up).
Normal casts have no targeting phase and are unaffected.
2026-08-10 00:11:21 -05:00
Brues b879730dfb ci: attach ClassicAPI.map to GitHub releases
Add build/Release/ClassicAPI.map to the release step's asset list so
every vX.Y.Z release ships the linker map beside the DLL. It resolves
the `0001:offset` logical addresses in a shipped build's ERROR #132
crash dump back to function names for that exact build. Dev-build
artifacts stay DLL-only.
2026-08-09 23:27:53 -05:00
Brues 9cbf185b3c fix(frame): make ScriptArgs global save/restore longjmp-safe
RunModern reimplements the frame-script runner tail, saving the
this/arg1..argN globals it clobbers so it can restore them after the
handler pcall. The old save used luaL_ref into the registry, mirroring
the engine's own FUN_00704f10 - but any push before the protected call
can longjmp (out-of-memory being the real case), which unwinds past the
C frame and skips the restore: the refs leak and the globals stay
clobbered for later handlers.

Save the old values on the Lua stack instead. An unwinding error resets
L->top past them for free, so there is nothing to leak and no clobbered
state to strand. This also drops the per-call luaL_ref/luaL_unref churn
and the per-arg re-read from globals on the OnUpdate hot path; the call
args are pushed directly from a pure-C parse of the vararg buffer.

The stack save is deeper than the registry save (~2N slots live at
once), so CheckStack up front; if it cannot grow (or there is no Lua
state) RunModern returns false before any side effect and the thunk
falls back to the original engine runner, never double-running.

Behavior unchanged: default-off passthrough, and identical
(self, [event,] arg1..argN) semantics when enabled.
2026-08-09 23:26:47 -05:00
Brues 68f6a687cc build: emit linker .map for crash-dump triage
Add /MAP to the shared-linker flags so the Release build produces
ClassicAPI.map, which resolves the `0001:offset` logical addresses in
WoW's ERROR #132 crash dumps back to function names. Linker-only output;
no effect on code layout.
2026-08-09 23:26:47 -05:00
9 changed files with 326 additions and 120 deletions
+3 -1
View File
@@ -109,5 +109,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: build/Release/ClassicAPI.dll
files: |
build/Release/ClassicAPI.dll
build/Release/ClassicAPI.map
generate_release_notes: true
+8 -6
View File
@@ -101,12 +101,14 @@ function ClassicAPI_ToggleUnitMenu(unit)
end
local which = ResolveMenu(unit);
local dd = EnsureDropdown();
dd.initialize = function()
-- Copy the base menu without its trailing CANCEL so we can place Focus
-- and Cancel ourselves, in the right order. Blizzard's own
-- UnitPopupMenus[which] is left untouched. ShowMenu adds nothing when
-- every base entry is hidden (e.g. an NPC's markers while solo) -- our
-- appended buttons still open the menu.
dd.initialize = function(level)
level = level or UIDROPDOWNMENU_MENU_LEVEL or 1;
if level > 1 then
UnitPopup_ShowMenu(dd, which, unit);
return;
end
local base = UnitPopupMenus[which];
local scratch = {};
for _, value in ipairs(base) do
+6 -1
View File
@@ -144,5 +144,10 @@ set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG")
set(CMAKE_SHARED_LINKER_FLAGS
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT"
# /MAP emits build/Release/ClassicAPI.map — a linker-only output (no effect
# on code layout) that maps `0001:offset` addresses from WoW's ERROR #132
# crash dumps back to functions. Published as a build/release artifact so
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
# identical functions, so a few map symbols share an address — harmless.)
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP"
)
+3 -1
View File
@@ -60,7 +60,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [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` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
@@ -188,6 +188,8 @@ 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) |
| 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`) |
### Unit tokens
+63
View File
@@ -217,6 +217,8 @@ build instructions.
- [`Enum.ItemClass`](#enumitemclass)
- [`Enum.ItemQuality`](#enumitemquality)
- [`Enum.PowerType`](#enumpowertype)
- [`Enum.SpellBookSpellBank`](#enumspellbookspellbank)
- [`Enum.SpellBookItemType`](#enumspellbookitemtype)
- [Glue](#glue)
- [`C_Glue.IsFirstLoadThisSession()`](#c_glueisfirstloadthissession)
@@ -467,6 +469,7 @@ build instructions.
- [SpellBook](#spellbook)
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
- [`C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)`](#c_spellbookgetspellbookiteminfoslotindex-spellbank)
- [`C_SpellBook.GetSpellLevelLearned(spellID)`](#c_spellbookgetspelllevellearnedspellid)
- [`C_SpellBook.GetCurrentLevelSpells([level])`](#c_spellbookgetcurrentlevelspellslevel)
- [`C_SpellBook.GetSkillLineName(skillLineID)`](#c_spellbookgetskilllinenameskilllineid)
@@ -5333,6 +5336,32 @@ not modern's `ComboPoints` reuse of the same number.
local rage = UnitPower("player", Enum.PowerType.Rage)
```
### `Enum.SpellBookSpellBank`
Selects which spellbook a `C_SpellBook.*` slot query reads. Passed as the
`spellBank` argument to
[`C_SpellBook.GetSpellBookItemInfo`](#c_spellbookgetspellbookiteminfoslotindex-spellbank).
| Value | Field |
|------:|----------|
| `0` | `Player` |
| `1` | `Pet` |
### `Enum.SpellBookItemType`
Categorizes a spellbook slot. Vanilla's spellbook only ever holds real
spells, so `C_SpellBook.GetSpellBookItemInfo` returns `Spell` for a
player-book slot and `PetAction` for a pet-book slot. The other values
exist for signature parity with retail but never occur in 1.12.
| Value | Field | Notes |
|------:|---------------|-------|
| `0` | `None` | Empty slot. Never returned — an empty slot yields `nil` instead. |
| `1` | `Spell` | A player-book spell. |
| `2` | `FutureSpell` | A not-yet-learned trainer spell. Never occurs in 1.12. |
| `3` | `PetAction` | A pet-book spell. |
| `4` | `Flyout` | A flyout group — a later-expansion concept. Never occurs. |
## Glue
Backports of two `C_Glue` session helpers. `C_Glue` is registered on the glue
@@ -11697,6 +11726,40 @@ end
Equivalent to the legacy function of the same name introduced in 3.0
(later renamed to `FindSpellBookSlotBySpellID` in 5.x).
### `C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)`
Returns a table describing the spell in a spellbook slot. `slotIndex` is
1-based across the whole book. `spellBank` picks the book —
[`Enum.SpellBookSpellBank.Player`](#enumspellbookspellbank) (`0`) or
`Enum.SpellBookSpellBank.Pet` (`1`).
```lua
local info = C_SpellBook.GetSpellBookItemInfo(1, Enum.SpellBookSpellBank.Player)
-- info.name, info.spellID, info.itemType, info.isPassive, info.iconID, ...
```
Table fields:
| Field | Type | Notes |
|-------|------|-------|
| `itemType` | [`Enum.SpellBookItemType`](#enumspellbookitemtype) | `Spell` (1) for the player book, `PetAction` (3) for the pet book. |
| `actionID` | number | The spellID. Equals `spellID` — vanilla has no spell-override system. |
| `spellID` | number | The spellID in the slot. |
| `name` | string | The localized spell name. |
| `subName` | string | The rank text (e.g. `"Rank 3"`), or `""` when the spell has no rank. |
| `iconID` | string | The icon **path** — feed it straight to `texture:SetTexture(...)`. |
| `isPassive` | boolean | `true` for a passive spell. |
| `isOffSpec` | boolean | Always `false`. |
Returns `nil` for an empty or out-of-range slot, matching the modern API.
> **Deviations from retail, forced by 1.12's data.** `iconID` is a
> texture path, not a `fileID` (vanilla has no fileID system) — the same
> deviation as [`C_Spell.GetSpellInfo`](#c_spellgetspellinfospellid).
> `skillLineIndex` is not returned (`nil`); spellbook tabs aren't
> SkillLines. `isOffSpec` is always `false` — vanilla has no
> specializations.
### `C_SpellBook.GetSpellLevelLearned(spellID)`
Returns the level at which a spell becomes available — the
+18 -8
View File
@@ -647,15 +647,25 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
}
if (Ascii::EqualCI(verb, "macro")) {
char macro[512];
if (!ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro) &&
!ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro))
return false;
// Prefer an addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) — it
// handles named macros and extended macro text; fall back to the stock
// ChatEdit_ParseText path when no RunMacro global is present.
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
// `macrotext` is raw macro text (the modern default). Run it as text
// through the stock chat parser so each line dispatches via
// SlashCmdList — that routes /cast etc. through any addon slash hooks
// (e.g. SuperCleveRoidMacros' conditional /cast). A name-based RunMacro
// (which is what SuperCleveRoidMacros ships) can't interpret text and
// would silently resolve nothing.
if (ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro)) {
RunMacroTextC(L, macro);
return true;
return true;
}
// Deprecated `macro` form: a saved-macro name/index. Prefer an
// addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) to run the
// named macro's body; fall back to the stock parser when none exists.
if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
RunMacroTextC(L, macro);
return true;
}
return false;
}
if (Ascii::EqualCI(verb, "stop") || Ascii::EqualCI(verb, "stopcasting")) {
Game::Lua::CallGlobal(L, "SpellStopCasting");
+136 -103
View File
@@ -29,8 +29,9 @@
// purely additive; Lua silently drops the extra positional args a zero-param
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
// exact global save/set/restore and its message-handler errfunc; only the
// final push+pcall differs.
// message-handler errfunc, but saves the clobbered globals on the Lua stack
// (not registry refs like the engine) so an out-of-memory longjmp mid-push
// unwinds them for free — no leaked refs, no per-call ref churn. See RunModern.
//
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
@@ -67,17 +68,11 @@ constexpr int kMaxArgs = 19;
// push a value stored under an integer ref (handler / frame object / message
// handler / luaL_ref'd saved globals).
using RawGetI_t = void(__fastcall *)(void *L, int idx, int ref);
// luaL_ref(L, t) → int — pop the top, store it under a fresh int key in table
// `t`, return the key. luaL_unref frees it.
using LuaLRef_t = int(__fastcall *)(void *L, int t);
using LuaLUnref_t = void(__fastcall *)(void *L, int t, int ref);
// FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER — __thiscall(frame, nameOrNull);
// lazily materializes the frame's Lua-registry ref at frame+OFF_COBJECT_LUA_REF.
using ScriptRegister_t = void(__thiscall *)(void *frame, void *nameOrNull);
const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
const auto LuaLRef = reinterpret_cast<LuaLRef_t>(Offsets::LUA_REF_REF);
const auto LuaLUnref = reinterpret_cast<LuaLUnref_t>(Offsets::LUA_REF_UNREF);
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
@@ -104,40 +99,93 @@ void ArgName(char *buf, int k) {
}
}
// Sets _G[name] = <value currently on the stack top>, capturing the previous
// _G[name] into *outOldRef (a luaL_ref for later restore). Stack-neutral:
// consumes the value, leaves the stack as it was minus that value.
void SetGlobalSaving(void *L, const char *name, int *outOldRef) {
using namespace Game::Lua;
PushString(L, name); // [.., newVal, name]
GetTable(L, GLOBALS_INDEX); // [.., newVal, oldVal]
*outOldRef = LuaLRef(L, REGISTRY_INDEX); // [.., newVal]
PushString(L, name); // [.., newVal, name]
Insert(L, -2); // [.., name, newVal]
SetTable(L, GLOBALS_INDEX); // [..]
// One parsed positional argument from the runner's printf-style format.
// Numbers ('d'/'u'/'f') land in `num`; strings ('s') keep the char* the engine
// handed us (stable for the duration of the call).
struct Arg {
char name[6]; // "arg1".."arg19"
char kind; // 'd' | 'u' | 'f' | 's'
double num;
const char *str;
};
// Decodes `fmt`/`vaPtr` (the runner's %d/%u/%f/%s specs over a packed vararg
// buffer) into `out`, returning the count (capped at kMaxArgs). Unknown specs
// are skipped and consume no vararg — mirroring the engine runner's switch.
// Pure C, touches no Lua state, so it can never throw.
int ParseArgs(const char *fmt, const void *vaPtr, Arg *out) {
if (fmt == nullptr)
return 0;
const char *cur = static_cast<const char *>(vaPtr);
int n = 0;
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
Arg &a = out[n];
switch (*p) {
case 'd':
a.num = static_cast<double>(*reinterpret_cast<const int *>(cur));
cur += 4;
break;
case 'u':
a.num = static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur));
cur += 4;
break;
case 'f':
a.num = *reinterpret_cast<const double *>(cur);
cur += 8;
break;
case 's':
a.str = *reinterpret_cast<const char *const *>(cur);
cur += 4;
break;
default:
continue; // unknown spec — consume nothing
}
a.kind = *p;
ArgName(a.name, n + 1);
++n;
}
return n;
}
// Restores _G[name] to the value at oldRef, then frees the ref.
void RestoreGlobal(void *L, const char *name, int oldRef) {
using namespace Game::Lua;
RawGetI(L, REGISTRY_INDEX, oldRef); // [.., oldVal]
PushString(L, name); // [.., oldVal, name]
Insert(L, -2); // [.., name, oldVal]
SetTable(L, GLOBALS_INDEX); // [..]
LuaLUnref(L, REGISTRY_INDEX, oldRef);
// Pushes a parsed argument's value onto the Lua stack.
void PushArg(void *L, const Arg &a) {
if (a.kind == 's')
Game::Lua::PushString(L, a.str);
else
Game::Lua::PushNumber(L, a.num);
}
// Reimplements the runner tail: set globals (with save/restore), then invoke
// the handler with modern positional args `(self, arg1..argN)` under the
// engine's own message-handler errfunc. `fmt`/`vaPtr` are null for the no-arg
// runner (N == 0).
void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
// Reimplements the runner tail: set the `this`/`arg1..argN` globals, invoke the
// handler with modern positional args `(self, [event,] arg1..argN)` under the
// engine's own message-handler errfunc, then restore the globals. `fmt`/`vaPtr`
// are null for the no-arg runner (N == 0).
//
// Unlike the engine (and our first cut), the old global values are saved on the
// Lua STACK, not in the registry via luaL_ref. A registry save leaks its ref if
// any push before the protected call longjmps — and out-of-memory, the one
// failure that actually fires here, does exactly that. Stack slots need no
// cleanup: an unwinding error resets `L->top` past them for free, so nothing
// leaks and there's no per-call ref churn on the OnUpdate hot path. The cost is
// a deeper stack (up to ~2*N slots live at once), so we CheckStack up front.
//
// Returns false WITHOUT side effects when it declines (no Lua state, or the
// stack can't grow); the caller then runs the original. Every false path is
// before the first push, so falling back never double-runs the handler.
bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
using namespace Game::Lua;
void *L = State();
if (L == nullptr)
return;
return false;
// Headroom for the saved-old block plus the transient set/call pushes.
if (CheckStack(L, 2 * kMaxArgs + 16) == 0)
return false;
// --- _G.this = frame -----------------------------------------------------
const bool haveThis = frame != nullptr;
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
// (frame+0x0C); matching the handler ref against that slot identifies it
@@ -148,65 +196,46 @@ void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
handlerRef;
int selfRef = 0;
int oldThisRef = 0;
if (haveThis) {
selfRef = EnsureLuaRef(frame);
RawGetI(L, REGISTRY_INDEX, selfRef); // push frame object
SetGlobalSaving(L, "this", &oldThisRef);
}
const int selfRef = haveThis ? EnsureLuaRef(frame) : 0;
// --- _G.arg1..argN from the format --------------------------------------
int oldArgRefs[kMaxArgs];
char argNames[kMaxArgs][6];
int n = 0;
if (fmt != nullptr) {
const char *cur = static_cast<const char *>(vaPtr);
for (const char *p = fmt; *p != '\0' && n < kMaxArgs; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
bool consumed = true;
switch (*p) {
case 'd':
PushNumber(L, static_cast<double>(*reinterpret_cast<const int *>(cur)));
cur += 4;
break;
case 'u':
PushNumber(L, static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur)));
cur += 4;
break;
case 'f':
PushNumber(L, *reinterpret_cast<const double *>(cur));
cur += 8;
break;
case 's':
PushString(L, *reinterpret_cast<const char *const *>(cur));
cur += 4;
break;
default:
consumed = false; // unknown spec — skip, consume nothing
break;
}
if (!consumed)
continue;
ArgName(argNames[n], n + 1);
SetGlobalSaving(L, argNames[n], &oldArgRefs[n]);
++n;
}
}
Arg args[kMaxArgs];
const int n = ParseArgs(fmt, vaPtr, args);
// --- call handler(self, arg1..argN) -------------------------------------
// --- save the globals we're about to clobber, onto the stack ------------
// Absolute indices, live until the closing SetTop(base):
// base+1 = old _G.this (if haveThis)
// savedArgBase+1 .. +n = old _G.arg1..argN
const int base = GetTop(L);
if (haveThis) {
PushString(L, "this");
GetTable(L, GLOBALS_INDEX); // [.. oldThis]
}
const int savedArgBase = base + (haveThis ? 1 : 0);
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
GetTable(L, GLOBALS_INDEX); // [.. oldArgk]
}
// --- set the new globals (each op is stack-neutral) ---------------------
if (haveThis) {
PushString(L, "this");
RawGetI(L, REGISTRY_INDEX, selfRef); // frame object
SetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
PushArg(L, args[k]);
SetTable(L, GLOBALS_INDEX);
}
// --- call handler(self, [event,] arg1..argN) ---------------------------
const int callBase = GetTop(L); // == base + saved-block size
const int errRef = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
int errIdx = 0;
if (errRef > 0) {
RawGetI(L, REGISTRY_INDEX, errRef); // message handler
errIdx = base + 1; // absolute index of the errfunc
errIdx = callBase + 1; // absolute index of the errfunc
}
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
if (haveThis)
@@ -217,18 +246,24 @@ void RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
PushString(L, "event");
GetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k) { // positional args, re-read from the globals
PushString(L, argNames[k]);
GetTable(L, GLOBALS_INDEX);
}
for (int k = 0; k < n; ++k)
PushArg(L, args[k]);
PCall(L, 1 + (isOnEvent ? 1 : 0) + n, 0, errIdx);
SetTop(L, base); // drop the message handler + any leftover error object
SetTop(L, callBase); // drop the message handler + any leftover error object
// --- restore globals (reverse order) ------------------------------------
for (int k = n - 1; k >= 0; --k)
RestoreGlobal(L, argNames[k], oldArgRefs[k]);
if (haveThis)
RestoreGlobal(L, "this", oldThisRef);
// --- restore globals from the saved block, then drop it -----------------
for (int k = 0; k < n; ++k) {
PushString(L, args[k].name);
PushValue(L, savedArgBase + 1 + k);
SetTable(L, GLOBALS_INDEX);
}
if (haveThis) {
PushString(L, "this");
PushValue(L, base + 1);
SetTable(L, GLOBALS_INDEX);
}
SetTop(L, base);
return true;
}
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
@@ -238,11 +273,10 @@ RunArgs_t g_origRunArgs = nullptr;
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
if (!g_enabled || handlerRef == 0 || fmt == nullptr) {
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
if (g_enabled && handlerRef != 0 && fmt != nullptr &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
}
RunModern(handlerRef, frame, fmt, vaPtr);
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
@@ -252,11 +286,10 @@ using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
Invoke_t g_origInvoke = nullptr;
void __fastcall Invoke_h(int handlerRef, void *frame) {
if (!g_enabled || handlerRef == 0 || frame == nullptr) {
g_origInvoke(handlerRef, frame);
if (g_enabled && handlerRef != 0 && frame != nullptr &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
}
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr);
g_origInvoke(handlerRef, frame);
}
const Game::HookAutoRegister _runArgsHook{
+20
View File
@@ -665,6 +665,26 @@ void OnWorldTick() {
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
g_cast.spellID = 0;
// A target-selection spell (Disenchant, ground-target AoE, …) stamps
// g_cast at the green-cursor step, NOT when the cast actually begins:
// Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
// (FUN_CAST_START_SET) BEFORE Spell_C_TargetSpell raises the reticle, and
// the later item/ground click re-enters Spell_C_CastSpell only to hit its
// "already this spell" short-circuit — so the writer never re-fires for the
// real start. Left alone, g_cast would surface a premature cast (UnitCasting-
// Info + UNIT_SPELLCAST_START) during targeting, seconds early, and a cancel
// would even fire a phantom START/INTERRUPTED/STOP. So drop the client stamp
// while the reticle is up for that spell: on selection the server's
// SMSG_SPELL_START re-stamps it cleanly (g_cast is 0, so HandleSpellStart
// takes the fresh-stamp path and START fires with the correct time + the
// SENT castGUID); on cancel it simply stays cleared. Gated on
// !g_castFromServer so it can never touch that server re-stamp.
if (g_cast.spellID != 0 && !g_castFromServer &&
*reinterpret_cast<const int *>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0 &&
g_cast.spellID ==
*reinterpret_cast<const int *>(Offsets::VAR_PENDING_CAST_SPELL))
g_cast.spellID = 0;
// Detect a player CHANNEL that ended before its computed endMs — the
// summon-ritual case (UNIT_FIELD_CHANNEL_SPELL = 698, cleared when the
// summon fills), and equally an interrupted / cancelled / LoS-broken
+69
View File
@@ -617,6 +617,69 @@ static const Game::HookAutoRegister _hookUnlearnSpell{
Offsets::FUN_UNLEARN_SPELL, reinterpret_cast<void *>(&UnlearnSpell_h),
reinterpret_cast<void **>(&g_origUnlearnSpell)};
// Enum.SpellBookItemType values (retail). Vanilla's spellbook only ever
// produces real spells, so we emit Spell (player book) or PetAction (pet
// book); None/FutureSpell/Flyout don't occur in 1.12.
static constexpr int kSpellBookItemTypeSpell = 1;
static constexpr int kSpellBookItemTypePetAction = 3;
static const Game::Lua::EnumIntegerEntry kSpellBookSpellBankEntries[] = {
{"Player", 0}, {"Pet", 1},
};
static const Game::Lua::EnumIntegerEntry kSpellBookItemTypeEntries[] = {
{"None", 0}, {"Spell", 1}, {"FutureSpell", 2}, {"PetAction", 3},
{"Flyout", 4},
};
// `C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)` -> table.
// `slotIndex` is 1-based across the whole book; `spellBank` is
// Enum.SpellBookSpellBank (0 = Player, 1 = Pet). Returns nil for an empty or
// out-of-range slot. Vanilla's spellbook holds only real spells, so `itemType`
// is always Spell (player) or PetAction (pet).
//
// Field deviations from retail, all forced by 1.12 lacking the data:
// - `iconID` is the icon PATH string (vanilla has no fileID), same as
// C_Spell.GetSpellInfo.iconID -- feed it straight to texture:SetTexture.
// - `isOffSpec` is always false (vanilla has no specializations).
// - `skillLineIndex` is omitted (nil) -- spellbook tabs aren't SkillLines.
static int __fastcall Script_C_SpellBook_GetSpellBookItemInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // modern returns nil (not an error) for a bad index
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int bank = Game::Lua::IsNumber(L, 2)
? static_cast<int>(Game::Lua::ToNumber(L, 2))
: 0;
const int bookType = (bank == 1) ? 1 : 0; // 1 = pet book, else player
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
if (spellID <= 0)
return 0; // empty slot / out of range -> nil
SpellInfoData info;
if (!ReadSpellInfo(spellID, info))
return 0;
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
const uint32_t attr =
record ? *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES)
: 0u;
Game::Lua::NewTable(L);
SetField(L, "itemType",
static_cast<double>(bookType == 1 ? kSpellBookItemTypePetAction
: kSpellBookItemTypeSpell));
SetField(L, "actionID", static_cast<double>(spellID));
SetField(L, "spellID", static_cast<double>(spellID));
SetField(L, "name", info.name);
// subName carries the rank text ("Rank N"); modern uses "" when absent.
SetField(L, "subName", info.rank ? info.rank : "");
SetField(L, "iconID", info.iconPath); // path string -- see note above
SetFieldBool(L, "isPassive", (attr & Offsets::SPELL_ATTR_PASSIVE) != 0);
SetFieldBool(L, "isOffSpec", false);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSpellInfo", &Script_GetSpellInfo);
Game::Lua::RegisterGlobalFunction("GetSpellLink", &Script_GetSpellLink);
@@ -637,6 +700,12 @@ static void RegisterLuaFunctions() {
&Script_C_Spell_IsSpellHarmful);
Game::Lua::RegisterTableFunction("C_Spell", "IsSpellHelpful",
&Script_C_Spell_IsSpellHelpful);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSpellBookItemInfo",
&Script_C_SpellBook_GetSpellBookItemInfo);
Game::Lua::RegisterIntegerEnum("Enum", "SpellBookSpellBank",
kSpellBookSpellBankEntries, 2);
Game::Lua::RegisterIntegerEnum("Enum", "SpellBookItemType",
kSpellBookItemTypeEntries, 5);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};