feat(action): action range and usable events

ACTION_RANGE_CHECK_UPDATE(slot, isInRange, checksRange) and
C_ActionBar.EnableActionRangeCheck(slot, enable). Enabled slots are
checked on each world tick while a frame is registered for the event,
with IsActionInRange's own answer (Action::Range::Answer, the macro
unit redirect included), and fire on change. Enabling is counted per
slot, and each enable makes the slot's next check fire, because retail
buttons take their first state from the event. A new Lua state starts
with no slot enabled.

ACTION_USABLE_CHANGED(changes) carries { slot, usable, noMana } for
each slot that changed. The engine's bulk refresh 0x004E5C00 already
compares each slot's pair against the caches IsUsableAction reads
(0x00BC6B60 / 0x00BC67A0). Its per-slot call at 0x004E5C15 is
redirected to record the slots whose pair differs, and its
ACTIONBAR_UPDATE_USABLE signal at 0x004E5C53 to send that event
unchanged and then the new one. No hook.
This commit is contained in:
Brues
2026-11-02 14:03:27 -06:00
parent 84c936faa4
commit ceb6aaf46b
7 changed files with 406 additions and 1 deletions
+3 -1
View File
@@ -51,7 +51,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `HasBonusActionBar` |
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `HasBonusActionBar`, `C_ActionBar.EnableActionRangeCheck` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnInterfaceVersion`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [Animation](docs/API.md#animation) | `region:CreateAnimationGroup`, `region:GetAnimationGroups`, `region:StopAnimating`, `SetMaxAnimFramerate`, `GetMaxAnimFramerate`, `AnimationGroup` and `Animation` / `Alpha` / `Translation` / `Scale` / `Rotation` / `Path` / `ControlPoint` methods |
@@ -280,6 +280,8 @@ when launching with `-console`), not as Lua functions. See the
| Event | Payload |
|-------|---------|
| `ACTION_RANGE_CHECK_UPDATE` | `slot, isInRange, checksRange` (slots with `C_ActionBar.EnableActionRangeCheck` on) |
| `ACTION_USABLE_CHANGED` | `changes` (a `{ slot, usable, noMana }` table for each slot that changed) |
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `CHAT_MSG_*` | adds `lineID, guid` as `arg11, arg12` after the existing ten ([details](docs/API.md#chat_msg_-arguments-lineid-arg11-and-guid-arg12)) |
+93
View File
@@ -14,6 +14,9 @@ build instructions.
- [`IsItemAction(slot)`](#isitemactionslot)
- [`HasBonusActionBar()`](#hasbonusactionbar)
- [`C_ActionBar.ShouldShowKeyring()`](#c_actionbarshouldshowkeyring)
- [`C_ActionBar.EnableActionRangeCheck(slot, enable)`](#c_actionbarenableactionrangecheckslot-enable)
- [`ACTION_RANGE_CHECK_UPDATE` event](#action_range_check_update-event)
- [`ACTION_USABLE_CHANGED` event](#action_usable_changed-event)
- [AddOns](#addons)
- [`C_AddOns.GetAddOnName(indexOrName)`](#c_addonsgetaddonnameindexorname)
@@ -1310,6 +1313,96 @@ if C_ActionBar.ShouldShowKeyring() then
end
```
### `C_ActionBar.EnableActionRangeCheck(slot, enable)`
Starts or stops the range check of a 1-based action-bar slot for
[`ACTION_RANGE_CHECK_UPDATE`](#action_range_check_update-event). A slot
that is not in the range 1 to 120 is ignored.
The client counts the calls for each slot. Each call with `enable` set to
`true` adds one. Each call with `enable` set to `false` removes one. The
client checks the slot while its count is more than zero. Thus, if two
buttons show the same slot, each button can enable and disable the slot
for itself.
After each call with `enable` set to `true`, the next check of the slot
fires the event, also if the state did not change. This gives a new
button the current state of its slot. You do not have to call
`IsActionInRange` when you show a button.
A `/reload` sets the count of all slots to zero.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("ACTION_RANGE_CHECK_UPDATE")
f:SetScript("OnEvent", function()
print("Slot", arg1, arg3 and (arg2 and "in range" or "out of range") or "no range check")
end)
C_ActionBar.EnableActionRangeCheck(1, true)
```
### `ACTION_RANGE_CHECK_UPDATE` event
Fires when an action-bar slot that has
[`EnableActionRangeCheck`](#c_actionbarenableactionrangecheckslot-enable)
on goes into or out of range of its target.
```
ACTION_RANGE_CHECK_UPDATE: slot, isInRange, checksRange
```
- **`slot`** (number) — the 1-based action-bar slot.
- **`isInRange`** (boolean) — `true` when the target is in range. Do not
use this value when `checksRange` is `false`.
- **`checksRange`** (boolean) — `false` when the client did not do a range
check. For example, the slot is empty, its action has no range, or there
is no target.
The values are the values that `IsActionInRange(slot)` gives: `1` is
`isInRange = true`, `0` is `isInRange = false`, and `nil` is
`checksRange = false`. A macro that is aimed at a unit (for example
`[@focus]`) uses that unit as its target.
The client checks the enabled slots on each frame, but only while a frame
is registered for the event. The event fires only when the state of a
slot changes, and after each enable call. When the action in a slot
changes, the client checks the new action. The event fires if its state
is different.
### `ACTION_USABLE_CHANGED` event
Fires when the usable state of one or more action-bar slots changes. The
payload gives only the slots that changed. Thus, a handler does not have
to read `IsUsableAction` for each button.
```
ACTION_USABLE_CHANGED: changes
```
- **`changes`** (table) — a list with one table for each slot that
changed:
| Field | Type | Meaning |
|---|---|---|
| `slot` | number | The 1-based action-bar slot. |
| `usable` | boolean | The new first return value of `IsUsableAction(slot)`, as a boolean. |
| `noMana` | boolean | The new second return value of `IsUsableAction(slot)`, as a boolean. |
The event fires immediately after `ACTIONBAR_UPDATE_USABLE`, which does
not change. When the action in a slot changes, the client fires
`ACTIONBAR_SLOT_CHANGED` for that slot, not this event. Thus, read
`IsUsableAction` for a slot on `ACTIONBAR_SLOT_CHANGED`.
```lua
local f = CreateFrame("Frame")
f:RegisterEvent("ACTION_USABLE_CHANGED")
f:SetScript("OnEvent", function()
for _, change in ipairs(arg1) do
print("Slot", change.slot, change.usable, change.noMana)
end
end)
```
## AddOns
`C_AddOns.*` getters that splat the
+13
View File
@@ -7024,6 +7024,19 @@ enum Offsets {
// ACTIONBAR_UPDATE_USABLE (0xD7, via FUN_FIRE_EVENT_NO_ARGS) when one did.
// Disassembled; it is 4.3.4's `CGActionBar::UpdateUsable` line for line.
FUN_ACTION_UPDATE_USABLE = 0x004E5C00,
// The uint[120] caches FUN_ACTION_UPDATE_USABLE compares and stores:
// FUN_ACTION_SLOT_USABLE's return at `0x004E5C1A` / `0x004E5C2F`, its
// `*outNoMana` at `0x004E5C26` / `0x004E5C36`. `IsUsableAction` reads them.
VAR_ACTION_USABLE = 0x00BC6B60,
VAR_ACTION_NO_MANA = 0x00BC67A0,
// FUN_ACTION_UPDATE_USABLE's per-slot `call FUN_ACTION_SLOT_USABLE` (ecx
// slot0, edx &noMana), compared against the caches above before they are
// stored. E8 call, disassembled.
CALL_UPDATE_USABLE_SLOT_USABLE = 0x004E5C15,
// FUN_ACTION_UPDATE_USABLE's `mov ecx, 0xD7; call FUN_FIRE_EVENT_NO_ARGS`
// (ACTIONBAR_UPDATE_USABLE), reached only when a slot changed. E8 call,
// disassembled.
CALL_UPDATE_USABLE_FIRE = 0x004E5C53,
// Drop the cursor onto a slot — `__fastcall(uint slot0)`. Sole entry
// for `PlaceAction`, and the tail of both FUN_ACTION_PICKUP and
// nampower's detoured `CGActionBar_UseAction` (`0x004E5EE0`) when the
+4
View File
@@ -28,6 +28,8 @@
// The function's one caller, Script_IsActionInRange, is redirected rather than
// the function hooked.
#include "action/Range.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
@@ -107,4 +109,6 @@ const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
int Answer(int slot0, int *inRange) { return SlotRange_h(slot0, inRange); }
} // namespace Action::Range
+24
View File
@@ -0,0 +1,24 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Action::Range {
// IsActionInRange's answer for a slot (0-based, 0..119): 0 when it has none
// (IsActionInRange's nil), else 1 with `*inRange` 0 or 1. A macro aimed at a
// unit is checked against that unit.
int Answer(int slot0, int *inRange);
} // namespace Action::Range
+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_ActionBar.EnableActionRangeCheck(slot, enable)` and
// `ACTION_RANGE_CHECK_UPDATE(slot, isInRange, checksRange)`.
//
// Nothing in the client reacts to a distance (see Unit::InRangeEvent), so the
// enabled slots are checked every world tick, while a frame is registered for
// the event, and a slot fires when its answer changes. The answer is
// IsActionInRange's (Action::Range::Answer), macro unit included, so a slot
// whose action changes fires when the new action's answer differs.
//
// Enabling is counted per slot, as retail's ActionBarButtonRangeCheckFrame
// expects: it enables once for each button on a slot and disables once as
// each button leaves. Retail's buttons never call IsActionInRange; they take
// their first state from this event. So each enable makes the slot's next
// check fire, changed or not. A new Lua state (`/reload`) starts with no slot
// enabled.
#include "Game.h"
#include "Offsets.h"
#include "action/Range.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Action::RangeCheck {
namespace {
enum class State : uint8_t { NoCheck, OutOfRange, InRange };
struct Slot {
int refs = 0;
bool pending = false; // fire on the next check, changed or not
State last = State::NoCheck;
};
Slot g_slots[Offsets::ACTION_TABLE_MAX_SLOTS];
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("isInRange", "bool",
"Whether the action's target is in range. Should not be used if checksRange "
"is false."),
Game::Doc::Req("checksRange", "bool",
"False when no range check was made, for example when there is no target."),
};
const Game::Doc::Event kDoc{
"ActionBar",
"Fires when an action that has EnableActionRangeCheck on goes in or out of range of its "
"target.",
kPayload};
const Event::Custom::AutoReserve kEvent{"ACTION_RANGE_CHECK_UPDATE", &kDoc};
State Evaluate(int slot0) {
int inRange = 0;
if (Action::Range::Answer(slot0, &inRange) == 0)
return State::NoCheck;
return inRange != 0 ? State::InRange : State::OutOfRange;
}
void OnWorldTick() {
const int event = kEvent.Slot();
if (!Event::Custom::HasListeners(event))
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
Slot &slot = g_slots[slot0];
if (slot.refs == 0)
continue;
const State state = Evaluate(slot0);
if (!slot.pending && state == slot.last)
continue;
slot.pending = false;
slot.last = state;
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
Game::Lua::PushBool(L, state == State::InRange);
Game::Lua::PushBool(L, state != State::NoCheck);
Event::Custom::FireValues(event, L, top + 1, 3);
Game::Lua::SetTop(L, top);
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// --- C_ActionBar.EnableActionRangeCheck(slot, enable) -----------------------
int __fastcall Script_EnableActionRangeCheck(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.EnableActionRangeCheck(slot, enable)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
Slot &slot = g_slots[slot0];
if (Game::Lua::ToBoolean(L, 2) != 0) {
++slot.refs;
slot.pending = true;
} else if (slot.refs > 0) {
--slot.refs;
}
return 0;
}
const Game::Doc::Field kEnableArgs[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("enable", "bool",
"True if changes in range for the action should dispatch "
"ACTION_RANGE_CHECK_UPDATE. False if the action no longer needs the event."),
};
const Game::Doc::Function kEnableDoc{
"Used with ACTION_RANGE_CHECK_UPDATE to be informed when an action goes in or out of range "
"of its target.",
kEnableArgs};
void RegisterLuaFunctions() {
for (Slot &slot : g_slots)
slot = Slot{};
Game::Lua::RegisterTableFunction("C_ActionBar", "EnableActionRangeCheck",
&Script_EnableActionRangeCheck, &kEnableDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Action::RangeCheck
+125
View File
@@ -0,0 +1,125 @@
// 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/>.
// `ACTION_USABLE_CHANGED(changes)` — the slots whose usable / noMana pair
// changed, as `{ slot, usable, noMana }` entries.
//
// The engine's bulk refresh FUN_ACTION_UPDATE_USABLE already finds them: it
// computes each slot with FUN_ACTION_SLOT_USABLE, compares the pair against
// the caches IsUsableAction reads, stores a changed pair, and signals the
// argument-less ACTIONBAR_UPDATE_USABLE once when any changed. Two of its
// calls are redirected. The per-slot call records the slots whose pair differs
// from the caches, the same compare the engine makes next. The signal sends
// ACTIONBAR_UPDATE_USABLE unchanged, then this event with the recorded slots.
//
// The per-slot refresh in FUN_ACTION_SLOT_CHANGED_NOTIFY stores its pair
// without this event; it signals ACTIONBAR_SLOT_CHANGED for the slot instead.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace Action::UsableChanged {
namespace {
using SlotUsable_t = uint32_t(__fastcall *)(uint32_t slot0, uint32_t *outNoMana);
using FireEvent_t = void(__fastcall *)(int eventID);
struct Change {
uint32_t slot0;
bool usable;
bool noMana;
};
// This refresh's changes. The loop runs slots 0..119 in order, so slot 0
// starts a new list.
Change g_changes[Offsets::ACTION_TABLE_MAX_SLOTS];
int g_count = 0;
const Game::Doc::Field kStateFields[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("usable", "bool"),
Game::Doc::Req("noMana", "bool"),
};
const Game::Doc::Structure kState{"ActionUsableState", "ActionBar", kStateFields};
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("changes", "table", "An ActionUsableState for each slot that changed."),
};
const Game::Doc::Event kDoc{
"ActionBar", "Fires when the usable or noMana state of one or more action slots changes.",
kPayload};
const Event::Custom::AutoReserve kEvent{"ACTION_USABLE_CHANGED", &kDoc};
uint32_t __fastcall SlotUsable_h(uint32_t slot0, uint32_t *outNoMana) {
const uint32_t usable =
reinterpret_cast<SlotUsable_t>(Offsets::FUN_ACTION_SLOT_USABLE)(slot0, outNoMana);
if (slot0 == 0)
g_count = 0;
const auto *usableCache =
reinterpret_cast<const uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_USABLE));
const auto *noManaCache =
reinterpret_cast<const uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_NO_MANA));
if (slot0 < static_cast<uint32_t>(Offsets::ACTION_TABLE_MAX_SLOTS) &&
g_count < Offsets::ACTION_TABLE_MAX_SLOTS &&
(usable != usableCache[slot0] || *outNoMana != noManaCache[slot0]))
g_changes[g_count++] = {slot0, usable != 0, *outNoMana != 0};
return usable;
}
void __fastcall FireUsable_h(int eventID) {
// Copied first: the handlers below can run another refresh.
Change changes[Offsets::ACTION_TABLE_MAX_SLOTS];
const int count = g_count;
std::memcpy(changes, g_changes, sizeof(Change) * count);
g_count = 0;
reinterpret_cast<FireEvent_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS)(eventID);
const int event = kEvent.Slot();
void *L = Game::Lua::State();
if (count == 0 || !Event::Custom::HasListeners(event) || L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "slot", static_cast<double>(changes[i].slot0 + 1));
Game::Lua::SetFieldBool(L, "usable", changes[i].usable);
Game::Lua::SetFieldBool(L, "noMana", changes[i].noMana);
Game::Lua::RawSet(L, -3);
}
Game::Lua::SetN(L, -1, count);
Event::Custom::FireValues(event, L, top + 1, 1);
Game::Lua::SetTop(L, top);
}
void Register() {
Common::RedirectCall(Offsets::CALL_UPDATE_USABLE_SLOT_USABLE, Offsets::FUN_ACTION_SLOT_USABLE,
reinterpret_cast<const void *>(&SlotUsable_h));
Common::RedirectCall(Offsets::CALL_UPDATE_USABLE_FIRE, Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<const void *>(&FireUsable_h));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::UsableChanged