From e7f6a9dd80c1768e48eb7b904b26978383e8e252 Mon Sep 17 00:00:00 2001
From: Petr Komogortsev <44289657+peterkmg@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:51:35 +0200
Subject: [PATCH] feat(bindings): backport direct-action and override binding
APIs
Add SetBindingSpell/Item/Macro/Click plus the owner-scoped
SetOverrideBinding family. Store permanent commands through vanilla
SetBinding, execute SPELL/ITEM/MACRO/CLICK in the resolved-command hook,
and layer priority overrides ahead of the native binding table.
Factor raw and saved macro dispatch into Macro::Execute so click
attributes and binding commands share the same parser fallback. Preserve
key-down/key-up pairing when overrides change during an action.
Document return values, persistence, precedence, macro-text usage, and
the deliberate 1.12 differences in README and docs/API.md.
---
README.md | 15 +-
docs/API.md | 102 +++++++-
src/Offsets.h | 23 ++
src/bindings/Api.cpp | 541 +++++++++++++++++++++++++++++++++++++++
src/frame/Attributes.cpp | 79 +-----
src/macro/Execute.cpp | 196 ++++++++++++++
src/macro/Execute.h | 27 ++
7 files changed, 905 insertions(+), 78 deletions(-)
create mode 100644 src/bindings/Api.cpp
create mode 100644 src/macro/Execute.cpp
create mode 100644 src/macro/Execute.h
diff --git a/README.md b/README.md
index 273dbe9..4a5feb9 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
+| [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` |
@@ -201,6 +202,17 @@ when launching with `-console`), not as Lua functions. See the
### Bindings
+ClassicAPI backports the later-client 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.
+
+See the [Bindings API reference](docs/API.md#bindings) for signatures,
+precedence, execution behavior, macro-text usage, and 1.12 compatibility
+notes.
+
+#### Predefined focus bindings
+
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
@@ -210,7 +222,8 @@ native targeting bindings instead of orphaned at the bottom.
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
-See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
+See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
+for the implementation note.
### Behaviors
diff --git a/docs/API.md b/docs/API.md
index 4334a2e..42e374b 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -3763,7 +3763,107 @@ after `"target"` (matching retail order), so a focused unit's GUID
reverse-resolves to `"focus"` only if it isn't already addressable
as `"player"` / `"party*"` / `"raid*"` / `"nameplate*"` / `"target"`.
-### Bindings (`FOCUSTARGET` / `TARGETFOCUS`)
+## Bindings
+
+Backports the direct-action and temporary override binding APIs added in WoW
+2.0. The permanent helpers write later-client action command strings into
+vanilla's native binding table; the override helpers add a separate,
+session-only layer in front of that table.
+
+### Permanent direct-action bindings
+
+| Function | Equivalent command |
+|----------|--------------------|
+| `ok = SetBindingSpell(key, spell)` | `SetBinding(key, "SPELL " .. spell)` |
+| `ok = SetBindingItem(key, item)` | `SetBinding(key, "ITEM " .. item)` |
+| `ok = SetBindingMacro(key, macroNameOrIndex)` | `SetBinding(key, "MACRO " .. macroNameOrIndex)` |
+| `ok = SetBindingClick(key, buttonName [, mouseButton])` | `SetBinding(key, "CLICK " .. buttonName .. (mouseButton and ":" .. mouseButton or ""))` |
+
+`key` is any binding string accepted by the client, such as `"CTRL-F"`,
+`"SHIFT-BUTTON4"`, or `"F8"`. `spell`, `item`, `macroNameOrIndex`, and
+`buttonName` identify the action to perform. `macroNameOrIndex` accepts either
+a saved macro's name or its decimal index. `mouseButton` is passed to the
+button's click handler and defaults to `"LeftButton"`; when supplied, it is
+stored as the `:MouseButton` suffix of the `CLICK` command.
+
+Each helper returns `1` if vanilla's `SetBinding` changed the binding and
+`nil` otherwise. It changes whichever binding set is currently loaded, fires
+vanilla's normal `UPDATE_BINDINGS` notification, and is not persisted until
+the addon calls `SaveBindings`. Use vanilla `SetBinding(key)` to unbind a key;
+the typed helpers always require an action argument.
+
+The command hook also understands manually constructed `SPELL`, `ITEM`,
+`MACRO`, and `CLICK` strings passed directly to vanilla `SetBinding`.
+
+### Temporary override bindings
+
+```lua
+SetOverrideBinding(owner, isPriority, key [, command])
+SetOverrideBindingSpell(owner, isPriority, key, spell)
+SetOverrideBindingItem(owner, isPriority, key, item)
+SetOverrideBindingMacro(owner, isPriority, key, macroNameOrIndex)
+SetOverrideBindingClick(owner, isPriority, key, buttonName [, mouseButton])
+ClearOverrideBindings(owner)
+```
+
+`owner` must be a frame and `isPriority` must be a Boolean. The typed helpers
+construct the same four action strings shown above. The generic
+`SetOverrideBinding` also accepts ordinary Bindings.xml command names, which
+are delegated to vanilla's command executor. Override setters and
+`ClearOverrideBindings` return no values.
+
+Passing no `command` (or `nil`) to `SetOverrideBinding` removes that owner's
+override for `key`. `ClearOverrideBindings(owner)` removes every override
+belonging to that owner. Overrides never change or save the native binding
+table and are cleared whenever the FrameScript Lua state is recreated, such
+as on `/reload` or logout.
+
+Resolution order is:
+
+1. Priority override bindings.
+2. Non-priority override bindings.
+3. The normal native binding.
+
+When multiple owners set the same key at the same priority, the most recently
+set override wins. Removing it immediately reveals the next matching override
+or the unchanged native binding.
+
+### Action execution
+
+| Command | Execution path |
+|---------|----------------|
+| `SPELL name` | Calls vanilla `CastSpellByName(name)`. The usual `/cast` spell-name syntax is accepted. |
+| `ITEM item` | Calls `C_Item.UseItemByName(item)`, accepting the names, item strings, and item IDs supported by that API. |
+| `MACRO nameOrIndex` | Resolves a saved macro by name or decimal index. An addon-provided `RunMacro` is used when available; otherwise ClassicAPI resolves it with `GetMacroInfo` and dispatches its body through `ChatEdit_ParseText`. |
+| `CLICK ButtonName[:MouseButton]` | Resolves the named global frame and calls its ordinary `:Click(mouseButton)` method. The default is `LeftButton`. |
+
+`SetBindingMacro` and `SetOverrideBindingMacro` take a saved macro identifier,
+not literal macro text. To bind literal macro text, bind a named button whose
+ClassicAPI attributes describe a macro action:
+
+```lua
+local button = CreateFrame("Button", "MyMacroTextBindingButton")
+button:SetAttribute("type", "macro")
+button:SetAttribute("macrotext", "/say Bound macro text")
+
+SetBindingClick("CTRL-F7", button:GetName())
+```
+
+### 1.12 compatibility notes
+
+- There is no combat lockdown or secure execution. These functions remain
+ callable in combat, and `CLICK` invokes the frame's ordinary click path.
+- Direct actions run on key-down. The corresponding key-up is consumed; a
+ click binding does not emulate later clients' separate mouse-down and
+ mouse-up delivery.
+- The override layer is not reflected by vanilla `GetBindingAction`.
+ In particular, the later-client `GetBindingAction(key, true)` override query
+ is not backported.
+- Override ownership is explicit. Call `ClearOverrideBindings(owner)` when the
+ addon's owner is no longer needed; all remaining overrides are also cleared
+ when the Lua state is recreated.
+
+### Predefined focus bindings (`FOCUSTARGET` / `TARGETFOCUS`)
Two key bindings appear in the keybind UI (`Esc` → Key Bindings)
under the **Targeting Functions** group, between `PETATTACK` and the
diff --git a/src/Offsets.h b/src/Offsets.h
index c0b4ce5..0e5420c 100644
--- a/src/Offsets.h
+++ b/src/Offsets.h
@@ -40,6 +40,29 @@ enum Offsets {
// state, so `FrameScript_RegisterFunction` writes land on it.
FUN_LOAD_GLUE_SCRIPT_FUNCTIONS = 0x0046ABB0,
+ // Binding manager internals used by the direct-action/override binding backport.
+ //
+ // Stock Lua `SetBinding` wrapper (`int __fastcall(lua_State *)`).
+ // The SetBindingSpell/Item/Macro/Click wrappers delegate to it after
+ // constructing their action command, preserving punctuation-key
+ // normalization, current-set selection, the Boolean return value, and the
+ // native UPDATE_BINDINGS notification.
+ FUN_SCRIPT_SET_BINDING = 0x004B8000,
+
+ // Hardware-key dispatch, before the normal binding table is resolved:
+ // int __thiscall(manager, key, isDown)
+ // Hooking here lets owner-scoped override bindings remain a separate
+ // layer rather than temporarily mutating the character/account binding
+ // table. The normal dispatcher is called unchanged when no override wins.
+ FUN_BINDING_KEY_DISPATCH = 0x004B7990,
+
+ // Executes a resolved binding command:
+ // int __thiscall(manager, command, isDown)
+ // Vanilla has no SPELL/ITEM/MACRO/CLICK direct-action command handlers. The
+ // hook recognizes those four families and delegates every native command
+ // to the client.
+ FUN_BINDING_COMMAND_EXECUTE = 0x004B7B50,
+
// GameTooltip script-method prologue helpers (used to resolve self → CFrameScriptObject*).
// Underlying Lua C API names per [[docs/LuaCAPI.md]] are `lua_rawgeti`
// (0x6F3BC0) and `lua_touserdata` (0x6F3740). The Set* method prologue's
diff --git a/src/bindings/Api.cpp b/src/bindings/Api.cpp
new file mode 100644
index 0000000..b3f3729
--- /dev/null
+++ b/src/bindings/Api.cpp
@@ -0,0 +1,541 @@
+// 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 .
+
+// Backports the direct-action and override binding APIs introduced in 2.0.
+// Normal bindings are stored by vanilla's SetBinding implementation using the
+// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
+// hook executes those four command families; every native binding command is
+// passed through unchanged.
+//
+// Override bindings live in a separate owner-scoped layer in front of the
+// native key lookup. They are deliberately never written to the saved binding
+// table, so clearing an owner immediately reveals the next override or the
+// unchanged native binding underneath it.
+
+#include "Game.h"
+#include "Offsets.h"
+#include "macro/Execute.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace Bindings::Api {
+
+namespace {
+
+constexpr const char kSpellPrefix[] = "SPELL ";
+constexpr const char kItemPrefix[] = "ITEM ";
+constexpr const char kMacroPrefix[] = "MACRO ";
+constexpr const char kClickPrefix[] = "CLICK ";
+constexpr const char kDefaultMouseButton[] = "LeftButton";
+
+using ScriptSetBinding_t = int(__fastcall *)(void *L);
+using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
+using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
+
+KeyDispatch_t KeyDispatch_o = nullptr;
+CommandExecute_t CommandExecute_o = nullptr;
+
+enum class CommandType {
+ None,
+ Spell,
+ Item,
+ Macro,
+ Click,
+};
+
+struct ParsedCommand {
+ CommandType type = CommandType::None;
+ const char *argument = nullptr;
+};
+
+struct OverrideEntry {
+ const void *owner = nullptr;
+ std::string key;
+ std::string command;
+ bool priority = false;
+ uint64_t sequence = 0;
+};
+
+std::vector g_overrides;
+std::unordered_map g_activeOverrides;
+uint64_t g_nextSequence = 0;
+
+std::string CanonicalKey(const char *key) {
+ std::string result = key ? key : "";
+ for (char &character : result) {
+ if (character >= 'a' && character <= 'z')
+ character = static_cast(character - 'a' + 'A');
+ }
+
+ return result;
+}
+
+std::string MakeCommand(const char *prefix, const char *arg) {
+ std::string cmd = prefix;
+ cmd += arg;
+ return cmd;
+}
+
+std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
+ std::string cmd = MakeCommand(kClickPrefix, btnName);
+ if (mouseBtn != nullptr) {
+ cmd += ':';
+ cmd += mouseBtn;
+ }
+
+ return cmd;
+}
+
+ParsedCommand ParseCommand(const char *cmd) {
+ if (cmd == nullptr)
+ return {};
+
+ struct PrefixEntry {
+ const char *prefix;
+ size_t length;
+ CommandType type;
+ };
+
+ static constexpr PrefixEntry prefixes[] = {
+ {kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
+ {kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
+ {kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
+ {kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
+ };
+
+ for (const PrefixEntry &entry : prefixes) {
+ if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
+ return {entry.type, cmd + entry.length};
+ }
+
+ return {};
+}
+
+bool CallGlobalString(void *L, const char *func, const char *arg) {
+ const int top = Game::Lua::GetTop(L);
+ if (!Game::Lua::PushGlobalFunction(L, func)) {
+ Game::Lua::SetTop(L, top);
+ return false;
+ }
+
+ Game::Lua::PushString(L, arg);
+ const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
+ Game::Lua::SetTop(L, top);
+
+ return ok;
+}
+
+bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
+ const int top = Game::Lua::GetTop(L);
+
+ Game::Lua::PushString(L, table);
+ Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
+ if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
+ Game::Lua::SetTop(L, top);
+ return false;
+ }
+
+ const int tableIndex = Game::Lua::GetTop(L);
+
+ Game::Lua::PushString(L, func);
+ Game::Lua::GetTable(L, tableIndex);
+ if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
+ Game::Lua::SetTop(L, top);
+ return false;
+ }
+
+ Game::Lua::PushString(L, arg);
+ const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
+ Game::Lua::SetTop(L, top);
+
+ return ok;
+}
+
+void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
+ const int top = Game::Lua::GetTop(L);
+
+ Game::Lua::PushLString(L, btnName, static_cast(btnNameLength));
+ Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
+ if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
+ Game::Lua::SetTop(L, top);
+ return;
+ }
+
+ const int frameIndex = Game::Lua::GetTop(L);
+ Game::Lua::PushString(L, "Click");
+ Game::Lua::GetTable(L, frameIndex);
+ if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
+ Game::Lua::PushValue(L, frameIndex);
+ Game::Lua::PushString(L, mouseBtn);
+ Game::Lua::PCall(L, 2, 0, 0);
+ }
+
+ Game::Lua::SetTop(L, top);
+}
+
+void ExecuteClick(void *L, const char *arg) {
+ const char *separator = std::strrchr(arg, ':');
+ if (separator == arg || (separator != nullptr && separator[1] == '\0'))
+ return;
+
+ const char *mouseBtn = kDefaultMouseButton;
+ size_t btnNameLength = std::strlen(arg);
+ if (separator != nullptr) {
+ btnNameLength = static_cast(separator - arg);
+ mouseBtn = separator + 1;
+ }
+
+ if (btnNameLength == 0)
+ return;
+
+ InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
+}
+
+// Returns true for every recognized direct-action command, including malformed
+// or stale commands. Vanilla cannot execute these namespaces, so a bad command
+// is consumed as a no-op instead of falling through to an unrelated handler.
+bool ExecuteSpecialCommand(const char *cmd, int isDown) {
+ const ParsedCommand parsed = ParseCommand(cmd);
+ if (parsed.type == CommandType::None)
+ return false;
+
+ if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
+ return true;
+
+ void *L = Game::Lua::State();
+ if (L == nullptr)
+ return true;
+
+ switch (parsed.type) {
+ case CommandType::Spell:
+ CallGlobalString(L, "CastSpellByName", parsed.argument);
+ break;
+ case CommandType::Item:
+ CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
+ break;
+ case CommandType::Macro:
+ Macro::Execute::Saved(L, parsed.argument);
+ break;
+ case CommandType::Click:
+ ExecuteClick(L, parsed.argument);
+ break;
+ default:
+ break;
+ }
+
+ return true;
+}
+
+const OverrideEntry *FindEffectiveOverride(const std::string &key) {
+ const OverrideEntry *best = nullptr;
+ for (const OverrideEntry &entry : g_overrides) {
+ if (entry.key != key)
+ continue;
+
+ if (best == nullptr || entry.priority > best->priority ||
+ (entry.priority == best->priority && entry.sequence > best->sequence))
+ best = &entry;
+ }
+
+ return best;
+}
+
+void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
+ auto existing =
+ std::find_if(g_overrides.begin(), g_overrides.end(),
+ [&](const OverrideEntry &entry) {
+ return entry.owner == owner && entry.key == key;
+ });
+
+ if (existing == g_overrides.end()) {
+ g_overrides.push_back(
+ {owner, key, cmd, priority, ++g_nextSequence});
+ return;
+ }
+
+ existing->command = cmd;
+ existing->priority = priority;
+ existing->sequence = ++g_nextSequence;
+}
+
+void RemoveOverride(const void *owner, const std::string &key) {
+ g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
+ [&](const OverrideEntry &entry) {
+ return entry.owner == owner &&
+ entry.key == key;
+ }),
+ g_overrides.end());
+}
+
+int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
+ if (ExecuteSpecialCommand(cmd, isDown))
+ return 1;
+
+ return CommandExecute_o(mgr, cmd, isDown);
+}
+
+int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
+ return ExecuteCommand(mgr, cmd, isDown);
+}
+
+int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
+ const std::string canonicalKey = CanonicalKey(key);
+
+ // Keep a key-down paired with its override command even if that override is
+ // cleared while the command executes. The newly revealed native binding
+ // must not receive only the key-up half of the same physical press.
+ if (!isDown) {
+ auto active = g_activeOverrides.find(canonicalKey);
+ if (active != g_activeOverrides.end()) {
+ ExecuteCommand(mgr, active->second.c_str(), 0);
+ g_activeOverrides.erase(active);
+ return 1;
+ }
+ }
+
+ const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
+ if (entry == nullptr)
+ return KeyDispatch_o(mgr, key, isDown);
+
+ if (isDown)
+ g_activeOverrides[canonicalKey] = entry->command;
+
+ return ExecuteCommand(mgr, entry->command.c_str(), isDown);
+}
+
+int SetBindingCommand(void *L, const char *prefix, const char *usage) {
+ if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
+ Game::Lua::Error(L, usage);
+ return 0;
+ }
+
+ const std::string key = Game::Lua::ToString(L, 1);
+ const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
+
+ // Preserve the stock SetBinding return value, binding-set selection,
+ // key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
+ Game::Lua::SetTop(L, 0);
+ Game::Lua::PushString(L, key.c_str());
+ Game::Lua::PushString(L, command.c_str());
+ auto setBinding =
+ reinterpret_cast(Offsets::FUN_SCRIPT_SET_BINDING);
+
+ return setBinding(L);
+}
+
+const char *OptionalMouseButton(void *L, int index, const char *usage) {
+ if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
+ return nullptr;
+
+ if (!Game::Lua::IsString(L, index)) {
+ Game::Lua::Error(L, usage);
+ return nullptr;
+ }
+
+ return Game::Lua::ToString(L, index);
+}
+
+int __fastcall Script_SetBindingSpell(void *L) {
+ return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
+}
+
+int __fastcall Script_SetBindingItem(void *L) {
+ return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
+}
+
+int __fastcall Script_SetBindingMacro(void *L) {
+ return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
+}
+
+int __fastcall Script_SetBindingClick(void *L) {
+ constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
+ if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
+ Game::Lua::Error(L, usage);
+ return 0;
+ }
+
+ const std::string key = Game::Lua::ToString(L, 1);
+ const char *buttonName = Game::Lua::ToString(L, 2);
+ const char *mouseButton = OptionalMouseButton(L, 3, usage);
+ const std::string command = MakeClickCommand(buttonName, mouseButton);
+
+ Game::Lua::SetTop(L, 0);
+ Game::Lua::PushString(L, key.c_str());
+ Game::Lua::PushString(L, command.c_str());
+ auto setBinding =
+ reinterpret_cast(Offsets::FUN_SCRIPT_SET_BINDING);
+
+ return setBinding(L);
+}
+
+bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
+ bool *priority, std::string *key) {
+ *owner = Game::Lua::ResolveObject(L, 1);
+ if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
+ !Game::Lua::IsString(L, 3)) {
+ Game::Lua::Error(L, usage);
+ return false;
+ }
+
+ *priority = Game::Lua::ToBoolean(L, 2) != 0;
+ *key = CanonicalKey(Game::Lua::ToString(L, 3));
+ return true;
+}
+
+int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
+ const void *owner = nullptr;
+ bool priority = false;
+
+ std::string key;
+ if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
+ return 0;
+
+ if (!Game::Lua::IsString(L, 4)) {
+ Game::Lua::Error(L, usage);
+ return 0;
+ }
+
+ SetOverride(owner, priority, key,
+ MakeCommand(prefix, Game::Lua::ToString(L, 4)));
+
+ return 0;
+}
+
+int __fastcall Script_SetOverrideBinding(void *L) {
+ constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
+ const void *owner = nullptr;
+ bool priority = false;
+
+ std::string key;
+ if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
+ return 0;
+
+ if (Game::Lua::GetTop(L) < 4 ||
+ Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
+ RemoveOverride(owner, key);
+ return 0;
+ }
+
+ if (!Game::Lua::IsString(L, 4)) {
+ Game::Lua::Error(L, usage);
+ return 0;
+ }
+
+ SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
+ return 0;
+}
+
+int __fastcall Script_SetOverrideBindingSpell(void *L) {
+ return SetOverrideBindingCommand(
+ L, kSpellPrefix,
+ "Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
+}
+
+int __fastcall Script_SetOverrideBindingItem(void *L) {
+ return SetOverrideBindingCommand(
+ L, kItemPrefix,
+ "Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
+}
+
+int __fastcall Script_SetOverrideBindingMacro(void *L) {
+ return SetOverrideBindingCommand(
+ L, kMacroPrefix,
+ "Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
+}
+
+int __fastcall Script_SetOverrideBindingClick(void *L) {
+ constexpr const char *usage =
+ "Usage: SetOverrideBindingClick(owner, "
+ "isPriority, key, buttonName [, mouseButton])";
+
+ const void *owner = nullptr;
+ bool priority = false;
+
+ std::string key;
+ if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
+ return 0;
+
+ if (!Game::Lua::IsString(L, 4)) {
+ Game::Lua::Error(L, usage);
+ return 0;
+ }
+
+ const char *buttonName = Game::Lua::ToString(L, 4);
+ const char *mouseButton = OptionalMouseButton(L, 5, usage);
+ SetOverride(owner, priority, key,
+ MakeClickCommand(buttonName, mouseButton));
+
+ return 0;
+}
+
+int __fastcall Script_ClearOverrideBindings(void *L) {
+ const void *owner = Game::Lua::ResolveObject(L, 1);
+ if (owner == nullptr) {
+ Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
+ return 0;
+ }
+
+ g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
+ [&](const OverrideEntry &entry) {
+ return entry.owner == owner;
+ }),
+ g_overrides.end());
+ return 0;
+}
+
+void RegisterLuaFunctions() {
+ // A fresh FrameScript state makes every owner pointer from the previous
+ // state stale. Permanent bindings remain in vanilla's binding table.
+ g_overrides.clear();
+ g_activeOverrides.clear();
+ g_nextSequence = 0;
+
+ Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
+ Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
+ Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
+ Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
+ Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
+ &Script_SetOverrideBinding);
+ Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
+ &Script_SetOverrideBindingSpell);
+ Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
+ &Script_SetOverrideBindingItem);
+ Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
+ &Script_SetOverrideBindingMacro);
+ Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
+ &Script_SetOverrideBindingClick);
+ Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
+ &Script_ClearOverrideBindings);
+}
+
+const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+
+const Game::HookAutoRegister _hookKeyDispatch{
+ Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast(&KeyDispatch_h),
+ reinterpret_cast(&KeyDispatch_o)};
+
+const Game::HookAutoRegister _hookCommandExecute{
+ Offsets::FUN_BINDING_COMMAND_EXECUTE,
+ reinterpret_cast(&CommandExecute_h),
+ reinterpret_cast(&CommandExecute_o)};
+
+} // namespace
+
+} // namespace Bindings::Api
diff --git a/src/frame/Attributes.cpp b/src/frame/Attributes.cpp
index 4dc5dc5..fd72343 100644
--- a/src/frame/Attributes.cpp
+++ b/src/frame/Attributes.cpp
@@ -98,6 +98,7 @@
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "cursor/Info.h"
+#include "macro/Execute.h"
#include "object/Resolve.h"
#include "spell/AtCursor.h"
#include "spell/AtUnit.h"
@@ -299,78 +300,6 @@ bool CallTableFunc2Str(void *L, const char *table, const char *method,
return called;
}
-// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
-//
-// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
-// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
-// To run macro text without depending on an addon, we replicate pfUI's shim in
-// C: build a throwaway "edit box" whose `GetText` returns the line and whose
-// every other method is a harmless no-op (via an `__index` metamethod), then
-// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
-// path the real chat box uses to dispatch a slash command / send a line.
-
-// GetText: returns upvalue(1), the captured line.
-int __fastcall MacroGetText_c(void *L) {
- Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
- return 1;
-}
-
-// A no-op standing in for any edit-box method the parser happens to call.
-int __fastcall MacroNoop_c(void *) { return 0; }
-
-// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
-int __fastcall MacroIndex_c(void *L) {
- Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
- return 1;
-}
-
-// Runs a single macro line through the stock chat parser.
-void RunMacroLineC(void *L, const char *line, size_t len) {
- if (len == 0) return;
- const int top = Game::Lua::GetTop(L);
-
- Game::Lua::NewTable(L); // fake editBox
- const int obj = Game::Lua::GetTop(L);
-
- Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
- Game::Lua::PushLString(L, line, static_cast(len));
- Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
- Game::Lua::SetTable(L, obj);
-
- Game::Lua::NewTable(L); // metatable { __index = noop }
- const int mt = Game::Lua::GetTop(L);
- Game::Lua::PushString(L, "__index");
- Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
- Game::Lua::SetTable(L, mt);
-
- // No lua_setmetatable binding — use the Lua global.
- if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
- Game::Lua::PushValue(L, obj);
- Game::Lua::PushValue(L, mt);
- Game::Lua::Call(L, 2, 0);
- }
-
- if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
- Game::Lua::PushValue(L, obj);
- Game::Lua::PushNumber(L, 1); // send = 1
- Game::Lua::Call(L, 2, 0);
- }
-
- Game::Lua::SetTop(L, top);
-}
-
-// Runs macro text one line at a time — a real macro is line-delimited, and a
-// single ChatEdit_ParseText call only dispatches one command.
-void RunMacroTextC(void *L, const char *text) {
- if (!text) return;
- for (const char *p = text; *p;) {
- const char *nl = p;
- while (*nl && *nl != '\n') ++nl;
- RunMacroLineC(L, p, static_cast(nl - p));
- p = (*nl == '\n') ? nl + 1 : nl;
- }
-}
-
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
@@ -676,16 +605,14 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
// (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);
+ Macro::Execute::Text(L, macro);
handled = 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.
else if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
- if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
- RunMacroTextC(L, macro);
- handled = true;
+ handled = Macro::Execute::Saved(L, macro);
}
// Restore the target the click swapped away from. `FUN_TARGET_BY_GUID`
// validates the GUID resolves to a live unit and bails otherwise, so a
diff --git a/src/macro/Execute.cpp b/src/macro/Execute.cpp
new file mode 100644
index 0000000..7cd6be1
--- /dev/null
+++ b/src/macro/Execute.cpp
@@ -0,0 +1,196 @@
+// 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 .
+
+// Vanilla 1.12 has no RunMacro or RunMacroText global. Raw macro text is
+// dispatched one line at a time through FrameXML's ChatEdit_ParseText using a
+// small throwaway edit-box table. Saved macros are resolved with the stock
+// GetMacroIndexByName/GetMacroInfo globals before entering that same path.
+
+#include "macro/Execute.h"
+
+#include "Game.h"
+
+#include
+#include
+
+namespace Macro::Execute {
+
+namespace {
+
+int __fastcall Script_GetText(void *L) {
+ Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
+ return 1;
+}
+
+int __fastcall Script_Noop(void *) { return 0; }
+
+int __fastcall Script_Index(void *L) {
+ Game::Lua::PushCClosure(L, &Script_Noop, 0);
+ return 1;
+}
+
+void Line(void *L, const char *line, size_t length) {
+ if (length == 0)
+ return;
+ if (line[length - 1] == '\r')
+ --length;
+ if (length == 0)
+ return;
+
+ const int top = Game::Lua::GetTop(L);
+
+ Game::Lua::NewTable(L);
+ const int objectIndex = Game::Lua::GetTop(L);
+
+ Game::Lua::PushString(L, "GetText");
+ Game::Lua::PushLString(L, line, static_cast(length));
+ Game::Lua::PushCClosure(L, &Script_GetText, 1);
+ Game::Lua::SetTable(L, objectIndex);
+
+ Game::Lua::NewTable(L);
+ const int metatableIndex = Game::Lua::GetTop(L);
+ Game::Lua::PushString(L, "__index");
+ Game::Lua::PushCClosure(L, &Script_Index, 0);
+ Game::Lua::SetTable(L, metatableIndex);
+
+ if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
+ Game::Lua::PushValue(L, objectIndex);
+ Game::Lua::PushValue(L, metatableIndex);
+ Game::Lua::PCall(L, 2, 0, 0);
+ }
+
+ if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
+ Game::Lua::PushValue(L, objectIndex);
+ Game::Lua::PushNumber(L, 1);
+ Game::Lua::PCall(L, 2, 0, 0);
+ }
+
+ Game::Lua::SetTop(L, top);
+}
+
+bool ParseIndex(const char *value, int *index) {
+ if (value == nullptr || *value == '\0')
+ return false;
+
+ int result = 0;
+ for (const char *p = value; *p != '\0'; ++p) {
+ if (*p < '0' || *p > '9')
+ return false;
+
+ const int digit = *p - '0';
+ if (result > (0x7FFFFFFF - digit) / 10)
+ return false;
+
+ result = result * 10 + digit;
+ }
+
+ if (result <= 0)
+ return false;
+
+ *index = result;
+ return true;
+}
+
+bool CallRunMacro(void *L, const char *macro) {
+ const int top = Game::Lua::GetTop(L);
+ if (!Game::Lua::PushGlobalFunction(L, "RunMacro")) {
+ Game::Lua::SetTop(L, top);
+ return false;
+ }
+
+ int index = 0;
+ if (ParseIndex(macro, &index))
+ Game::Lua::PushNumber(L, index);
+ else
+ Game::Lua::PushString(L, macro);
+
+ const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
+ Game::Lua::SetTop(L, top);
+
+ return ok;
+}
+
+int ResolveIndex(void *L, const char *macro) {
+ int index = 0;
+ if (ParseIndex(macro, &index))
+ return index;
+
+ const int top = Game::Lua::GetTop(L);
+ if (!Game::Lua::PushGlobalFunction(L, "GetMacroIndexByName")) {
+ Game::Lua::SetTop(L, top);
+ return 0;
+ }
+
+ Game::Lua::PushString(L, macro);
+ if (Game::Lua::PCall(L, 1, 1, 0) == 0 && Game::Lua::IsNumber(L, -1))
+ index = static_cast(Game::Lua::ToNumber(L, -1));
+
+ Game::Lua::SetTop(L, top);
+
+ return index;
+}
+
+bool GetBody(void *L, int index, std::string *body) {
+ const int top = Game::Lua::GetTop(L);
+ if (!Game::Lua::PushGlobalFunction(L, "GetMacroInfo")) {
+ Game::Lua::SetTop(L, top);
+ return false;
+ }
+
+ Game::Lua::PushNumber(L, index);
+ const bool called = Game::Lua::PCall(L, 1, 3, 0) == 0;
+ const bool found = called && Game::Lua::IsString(L, -1);
+ if (found)
+ *body = Game::Lua::ToString(L, -1);
+
+ Game::Lua::SetTop(L, top);
+
+ return found;
+}
+
+} // namespace
+
+void Text(void *L, const char *text) {
+ if (L == nullptr || text == nullptr)
+ return;
+
+ for (const char *line = text; *line != '\0';) {
+ const char *end = line;
+ while (*end != '\0' && *end != '\n')
+ ++end;
+ Line(L, line, static_cast(end - line));
+ line = *end == '\n' ? end + 1 : end;
+ }
+}
+
+bool Saved(void *L, const char *macro) {
+ if (L == nullptr || macro == nullptr || *macro == '\0')
+ return false;
+
+ if (CallRunMacro(L, macro))
+ return true;
+
+ const int index = ResolveIndex(L, macro);
+ if (index <= 0)
+ return false;
+
+ std::string body;
+ if (!GetBody(L, index, &body))
+ return false;
+
+ Text(L, body.c_str());
+ return true;
+}
+
+} // namespace Macro::Execute
diff --git a/src/macro/Execute.h b/src/macro/Execute.h
new file mode 100644
index 0000000..eb20050
--- /dev/null
+++ b/src/macro/Execute.h
@@ -0,0 +1,27 @@
+// 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 .
+
+#pragma once
+
+namespace Macro::Execute {
+
+// Runs raw macro text through vanilla FrameXML's ChatEdit_ParseText dispatcher.
+void Text(void *L, const char *text);
+
+// Runs a saved macro by name or decimal index. Prefers an addon-provided
+// RunMacro implementation when present, then falls back to GetMacroInfo plus
+// the stock text dispatcher.
+bool Saved(void *L, const char *macro);
+
+} // namespace Macro::Execute