Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #39
2026-08-31 01:56:03 -05:00
9 changed files with 1902 additions and 1571 deletions
@@ -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();
+14 -18
View File
@@ -22,18 +22,18 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. 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. |
| [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. — 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. |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [Event-driven nameplates](docs/API.md#nameplate) | 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. |
| [Focus target](docs/API.md#focus) | 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. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs, like a modern client. 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. A stock client needs a full restart for all of these. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
## Full API reference
@@ -50,7 +50,6 @@ reference in **[docs/API.md](docs/API.md)**.
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [AuraUtil](docs/API.md#aurautil) | `AuraUtil.FindAura`, `AuraUtil.FindAuraByName`, `AuraUtil.ForEachAura`, `AuraUtil.UnpackAuraData` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
@@ -68,7 +67,7 @@ reference in **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `SecureCmdOptionParse`, `RegisterStateDriver`, `UnregisterStateDriver`, `RegisterAttributeDriver`, `UnregisterAttributeDriver`, `RegisterUnitWatch`, `UnregisterUnitWatch`, `UnitWatchRegistered`, `SecureButton_GetAttribute`, `SecureButton_GetUnit`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
@@ -117,9 +116,7 @@ reference in **[docs/API.md](docs/API.md)**.
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
in the first row are *glue-only* — they exist to support GlueXML
patches that need a small persistence surface across sessions, since
vanilla 1.12 glue ships no general-purpose persistence API beyond
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
patches that need a small persistence surface across sessions.
The rest of the table is in-game calls that we also mirror onto
the glue state because GlueXML had no way to reach them otherwise.
@@ -138,7 +135,7 @@ the glue state because GlueXML had no way to reach them otherwise.
<summary><b>Macros</b> — engine-level parsing extensions</summary>
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
functions. See the
[Macros section in the Lua reference](docs/API.md#macros) for details.
| Form | What it does |
@@ -228,7 +225,7 @@ when launching with `-console`), not as Lua functions. See the
| Group | Constants |
|-------|-----------|
| Version | `CLASSIC_API_VERSION` |
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
@@ -256,7 +253,7 @@ when launching with `-console`), not as Lua functions. See the
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
ClassicAPI backports the later-client direct-action and temporary override
ClassicAPI backports the direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
session-only, frame-owned, and support the usual priority flag.
@@ -337,7 +334,7 @@ identically in both cases.
## Bundled addon: DebugTools
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
(originally shipped with the 3.0 client) lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
It's an independent addon — it doesn't use anything ClassicAPI adds, and
ClassicAPI works fine without it. It's bundled here because it's the
natural companion for testing and debugging anything written against the
@@ -352,12 +349,11 @@ Slash commands provided:
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
Lua globals provided (backports of 3.3.5 helpers that don't exist in
1.12):
Lua globals provided (backports of Blizzard helpers):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
+1334 -1454
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -46,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) \
+4
View File
@@ -123,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);
@@ -178,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;
+75 -1
View File
@@ -299,7 +299,12 @@ enum Offsets {
// the pointer at +0xB8 (both verified from the setters below). We read a
// line's text/color and re-apply them one slot down, then set line 0.
FUN_FONTSTRING_SET_TEXT = 0x00771D80, // __thiscall(fs, text, flag=0)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(fs, colorBGRA*)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(region, colorBGRA*) — generic region color setter (fontstrings + textures)
// Generic region color GETTER — `void __thiscall(region, uint32* outBGRA)`,
// writes the packed {b,g,r,a} (0xFFFFFFFF when the region has no explicit
// color). The read half of Texture:GetVertexColor (FUN_0079aa50); paired
// with FUN_FONTSTRING_SET_COLOR above. Backs EditBox:GetHighlightColor.
FUN_REGION_GET_COLOR = 0x0077F8C0,
OFF_FONTSTRING_TEXT = 0xF0, // char* — current text buffer
OFF_FONTSTRING_COLOR_PTR = 0xB8, // ptr → 4-byte {b,g,r,a} color storage
// A line's FontString is only positioned once shown: set the desired
@@ -1901,6 +1906,11 @@ enum Offsets {
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
// EditBox — 48 methods (table 0x0087BB68), ctx per
// docs/BlizzardScriptAPI.md. Backs `EditBox:SetCursorPosition` /
// `GetCursorPosition` (`EditBox::Methods`), the modern cursor / focus /
// text-state methods vanilla's EditBox lacks.
VAR_EDITBOX_METHOD_REGISTRY = 0x00CF5378,
// Model frame — backs `Model:SetDisplayInfo(creatureDisplayID)`
// (`Model::DisplayInfo`). Registry ctx per docs/raw_methods.txt (table
@@ -4866,6 +4876,11 @@ enum Offsets {
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
// functions that call it. Used by the `__environment` env-protection hook.
LUA_GET_METATABLE = 0x6F3CF0,
// `lua_setmetatable(L, objindex)` — pops the table at the top of the
// stack and installs it as the metatable of the value at objindex;
// returns 1. Catalogued (★-verified) in docs/LuaCAPI.md. Used by
// Table::Length to build its weak-keyed mark table from C.
LUA_SET_METATABLE = 0x6F4020,
// `lua_getfenv(L, idx)` — pushes the environment table of the function
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
// it to read a function's environment before the protection check.
@@ -4945,6 +4960,16 @@ enum Offsets {
// (named there since the table.insert trace). Used by the 5.1
// `unpack(list, i, j)` upgrade for the default range end.
LUAL_GETN = 0x6F5050,
// `table.insert` (5.0 `luaB_tinsert`), `int __fastcall(L)`. Found two
// independent ways: the table-lib luaL_reg pair in .data (entry
// {name 0x008821B4 "insert", func} at 0x00822D70) and the LUAL_SETN
// caller trace above. Decompile matches 5.0.2 tinsert exactly:
// lua_gettop → luaL_checktype(1, TABLE) → n = luaL_getn(1)+1 →
// optional-pos shift-up loop (rawgeti/rawseti) → luaL_setn(1, n) →
// lua_pushvalue(value) → lua_rawseti(1, pos). Both `table.insert` and
// the FrameXML `tinsert` alias resolve to this one C function. Hooked
// by Table::Length to mark deliberate trailing-nil appends (#36/#39).
FUN_LUA_TABLE_INSERT = 0x7FB6B0,
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
@@ -7559,6 +7584,55 @@ enum Offsets {
// *(this+0xF0) via SStrDup.
OFF_EDITBOX_TEXT_FONTSTRING = 0x328, // CSimpleFontString* — the display text object
// CSimpleEditBox cursor / selection internals (backs EditBox::Methods'
// SetCursorPosition / GetCursorPosition). Verified from the engine's own
// HighlightText (FUN_00798870 → FUN_0077cca0) and Insert (FUN_00798400 →
// FUN_0077bee0) paths:
// +0x31c dirty-flag dword — bit 2 = selection/highlight changed, bit 4 =
// cursor changed (both trigger the next-paint relayout).
// +0x338 text length in BYTES (the clamp bound below).
// +0x35c / +0x360 selection start / end (byte offsets).
// +0x36c cursor insertion point, a BYTE offset — matches modern WoW's
// byte-based EditBox cursor (GetCursorPosition returns bytes; for
// ASCII text byte == character).
OFF_EDITBOX_FLAGS = 0x31c,
OFF_EDITBOX_TEXT_LENGTH = 0x338, // current text length in bytes
OFF_EDITBOX_CURSOR_BYTE = 0x36c,
// `void __thiscall(editbox, byteOffset)` — sets the cursor to a byte
// offset, clamping to [0, textByteLen] and OR-ing the +0x31c cursor-dirty
// bit. The engine's own "restore cursor" primitive (called from the insert
// path FUN_0077bee0 to put the caret back after splicing text).
FUN_EDITBOX_SET_CURSOR_BYTE = 0x0077e360,
// `void __thiscall(editbox)` — collapses the selection onto the cursor
// (selStart = selEnd = +0x36c). The engine's own "clear selection to the
// caret" helper (FUN_0077ccf0), used before extending a shift-selection.
FUN_EDITBOX_COLLAPSE_SELECTION = 0x0077ccf0,
// `int __thiscall(editbox, startByteIndex, byteCount)` — counts CHARACTERS
// (UTF-8 codepoints) in a byte range, walking the per-byte char-class table
// at +0x330 (a byte begins a codepoint when its class is 2/3/6). So
// `COUNT_CHARS(eb, 0, cursorByteOffset)` is the cursor's character index —
// backs GetUTF8CursorPosition. Verified from the arrow-key mover
// FUN_0077cb20's own `FUN_0077bc80(this, byteOff, cursorByte - byteOff)`.
FUN_EDITBOX_COUNT_CHARS = 0x0077bc80,
// Selection-highlight regions — three consecutive CSimpleTexture pointers
// (start-line +0x350, middle-block +0x354, end-line +0x358) the editbox
// shows/positions to paint the selection (FUN_0077d950). Their vertex
// color IS the highlight color — Set/GetHighlightColor read/write it via
// the generic region color accessors. The blinking caret is a SEPARATE
// region at +0x3d4, deliberately left alone.
OFF_EDITBOX_HIGHLIGHT_REGION = 0x350,
// Input-history ring buffer: max lines (ring capacity) at +0x388 (what
// GetHistoryLines reads), write position at +0x38c, container
// {size@+0x390, alloc@+0x394, data@+0x398} of {char* text, int exec}
// entries. AddHistoryLine writes at +0x38c and advances modulo +0x388.
OFF_EDITBOX_HISTORY_MAX = 0x388,
// `void __thiscall(editbox, int maxLines)` — the SetHistoryLines internal
// (FUN_0077cd90). Reallocates the history ring to maxLines; maxLines == 0
// frees the whole buffer and zeroes the container. ClearHistory calls it
// with 0 then the saved max — empties every entry and resets the write
// position while keeping the limit (so recording still works after).
FUN_EDITBOX_SET_HISTORY_MAX = 0x0077cd90,
// --- FontString measure internals (GetStringWidth icon fix + GetStringHeight) ---
// CSimpleFontString::GetStringWidthInternal — `float(__fastcall)(fs /*ecx*/)`,
// returns in x87 ST0. Lazily computes into the fs+0xFC cache (ANCHOR units,
+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
+46
View File
@@ -0,0 +1,46 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `INTERFACE_VERSION` global — the client's interface (TOC) version number,
// 11200 on this client. Read from the engine's own accessor (the same value
// the addon loader compares each `## Interface:` against) rather than a
// literal, and re-published on every `/reload` via the standard
// `ModuleAutoRegister` flow. It is a true fixed client constant, so it is a
// global number like `CLASSIC_API_VERSION`, not a function.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Interface::Version {
namespace {
using ClientIfaceVer_t = uint32_t(__cdecl *)();
void RegisterLuaFunctions() {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const uint32_t version = reinterpret_cast<ClientIfaceVer_t>(
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
Game::Lua::SetGlobalNumber(L, "INTERFACE_VERSION",
static_cast<double>(version));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Interface::Version
+113 -28
View File
@@ -49,31 +49,49 @@
// `n` field is the 5.0 vararg-table contract (`arg` = {n=3, holes}),
// where trailing nils are intentional — healing it would corrupt
// vararg counts.
// 3. `t[n]` nil, no `t.n`: scan down for the true border `b`. Heal to `b`
// ONLY when the stored length overshoots it by more than one slot
// (`n - b > 1`) — a cleared-and-reused table (Dewdrop's menus) is stale
// by many slots. When it overshoots by exactly one (`n - b == 1`), the
// nil at slot `n` is a legitimate 5.0 append slot — `table.insert(t, nil)`
// writes `t[n]=nil` and `setn(n)` — so keep the stored length; healing it
// would make the NEXT append overwrite that slot and shift every element
// after it down by one (issue #36: Ace2/Waterfall's `{key,val,…}` builders
// that append a nil value corrupt silently). Read-only — no write-back,
// so a length read never mutates state; the engine's own `table.insert`
// calls `luaL_setn(n+1)` on the next append, which re-syncs the stored
// length by itself.
// 3. `t[n]` nil, no `t.n`: scan down for the true border `b` and heal to
// it (the 5.1 answer) — UNLESS the nil at slot `n` is a deliberate
// `table.insert(t, nil)` append slot, detected by the writer-side mark
// below, in which case keep the stored length. Read-only — no
// write-back, so a length read never mutates state; the engine's own
// `table.insert` calls `luaL_setn(n+1)` on the next append, which
// re-syncs the stored length by itself.
//
// Why the gap test and not caller-gating: both Dewdrop (clear + rebuild via
// `table.insert`, Length.cpp's raison d'être) and Waterfall reach here THROUGH
// `table.insert`, so the caller can't tell them apart — only the staleness gap
// can. A cleared table is empty below `n` (large gap); a deliberate nil append
// is dense below `n` (gap 1). The gap is a heuristic (two consecutive nil
// appends would read as a cleared table), but consecutive nil appends are
// pathological, while single nil appends between real values are the common
// Ace2 idiom.
// Why a writer-side mark (the tinsert co-hook) and not a state heuristic: a
// deliberate 5.0 nil append (`table.insert(t, nil)` — writes `t[n]=nil` +
// `setn(n)`; issue #36: Waterfall's `{key,val,…}` builders append a nil for
// every option without a passValue, and the next insert must land AFTER the
// reserved slot) and a cleared-and-reused table that happens to be stale by
// exactly one (a one-element table pairs-cleared, or a recycled table whose
// previous life was one slot longer — Tablet-2.0's `del()`/`copy()` pool
// recycles per tooltip refresh, so this is everyday traffic) leave the table
// in BYTE-IDENTICAL states: stored `n`, dense below, `t[n]` nil, no `t.n`.
// No inspection of the table can split them, and caller-gating can't either
// (both reach here through `table.insert`). v1.12.8 tried a staleness-gap
// heuristic (keep when `n - b == 1`) and it corrupted the second group —
// issue #39, FuBar/Tablet/Dewdrop menus and tooltips shifting by one with a
// nil hole at slot 1. Only the WRITER knows which dialect the table speaks,
// and the write is observable: a co-hook on `table.insert`
// (FUN_LUA_TABLE_INSERT) records `t → storedN` in a weak-keyed registry
// table whenever the two-arg form appends a literal nil. Rule 3 keeps the
// stored length only for a marked table whose mark still equals `n`; every
// unmarked off-by-one table heals. A stale mark dies on its own: any later
// append/remove moves the stored length away from the recorded value, and
// the weak key lets a dead table's entry be collected.
//
// Note real Lua 5.1 gives the HEAL answer even for the deliberate append
// (`#`-based tinsert drops a trailing nil and the next insert overwrites
// it), so keeping the slot is deliberately 5.0-native behavior for
// 5.0-dialect writers — Waterfall's builder runs on the 5.0 `arg` vararg
// table and predates 5.1. 5.1-dialect code never relies on insert(t, nil)
// reserving a slot, because on real 5.1 it doesn't.
//
// Positional nil insertion (`table.insert(t, pos, nil)`) is not marked and
// heals; no consumer is known, and 5.0 itself makes it a shifted no-op.
//
// `table.setn` itself still works for code that calls it — the heal only
// changes the answer when the stored length points more than one slot past
// the border, which is the same answer real 5.1 would give.
// changes the answer when the stored length points past the border and no
// mark vouches for it, which is the same answer real 5.1 would give.
#include "Game.h"
#include "Offsets.h"
@@ -86,6 +104,11 @@ using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
LuaLGetN_t g_getnOriginal = nullptr;
Game::Lua::CFunction g_tinsertOriginal = nullptr;
// Registry key of the weak-keyed mark table: `marks[t] = storedN` recorded
// at the moment `table.insert(t, nil)` reserved slot `storedN`.
constexpr char kMarkKey[] = "ClassicAPI_TrailingNilMark";
// True if t[k] (rawgeti) is nil. Balances the stack.
bool SlotIsNil(void *L, int absIdx, int k) {
@@ -95,6 +118,64 @@ bool SlotIsNil(void *L, int absIdx, int k) {
return isNil;
}
// Pushes the mark table, creating `registry[kMarkKey] = setmetatable({},
// {__mode = "k"})` on first use (per Lua state — the registry survives
// `/reload` because the state is reused, and the marked tables survive with
// it). Leaves exactly one value on the stack.
void PushMarkTable(void *L) {
Game::Lua::PushString(L, kMarkKey);
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2); // pop the nil. []
Game::Lua::NewTable(L); // [marks]
Game::Lua::NewTable(L); // [marks, meta]
Game::Lua::PushString(L, "__mode");
Game::Lua::PushString(L, "k");
Game::Lua::RawSet(L, -3); // meta.__mode = "k". [marks, meta]
Game::Lua::SetMetatable(L, -2);// pops meta. [marks]
Game::Lua::PushString(L, kMarkKey);
Game::Lua::PushValue(L, -2); // [marks, key, marks]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // [marks]
}
// True when `marks[t] == n` — the nil at slot `n` was written by a
// deliberate `table.insert(t, nil)` and nothing has moved the stored
// length since. Balances the stack.
bool HasTrailingNilMark(void *L, int absIdx, int n) {
PushMarkTable(L); // [marks]
Game::Lua::PushValue(L, absIdx); // [marks, t]
Game::Lua::RawGet(L, -2); // [marks, marks[t]]
const bool match =
Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER &&
static_cast<int>(Game::Lua::ToNumber(L, -1)) == n;
Game::Lua::SetTop(L, -3); // pop both
return match;
}
// Co-hook on the engine's `table.insert` (both `table.insert` and the
// `tinsert` alias are this one C function). The two-arg form appending a
// literal nil is the 5.0 idiom that must keep its reserved slot; record it
// so rule 3 can tell it apart from an identically-shaped stale table. The
// detection runs before the original (the arg stack is caller-owned, so
// index 1 still holds the table afterwards); the mark reads the raw stored
// length the original's `luaL_setn` just wrote.
int __fastcall TableInsert_h(void *L) {
const bool nilAppend = Game::Lua::GetTop(L) == 2 &&
Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE &&
Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL;
const int ret = g_tinsertOriginal(L);
if (nilAppend) {
const int storedN = g_getnOriginal(L, 1);
PushMarkTable(L); // [marks]
Game::Lua::PushValue(L, 1); // [marks, t]
Game::Lua::PushNumber(L, storedN);
Game::Lua::RawSet(L, -3); // marks[t] = storedN. [marks]
Game::Lua::SetTop(L, -2); // pop marks
}
return ret;
}
int __fastcall LuaLGetN_h(void *L, int idx) {
const int n = g_getnOriginal(L, idx);
if (n <= 0)
@@ -125,20 +206,24 @@ int __fastcall LuaLGetN_h(void *L, int idx) {
while (b > 0 && SlotIsNil(L, absIdx, b))
--b;
// Overshoots the border by exactly one slot: a legitimate 5.0 nil append
// (`table.insert(t, nil)`) sits in a dense array, not a cleared table.
// Keep the stored length so the next append lands after the nil, not on it.
if (n - b <= 1)
// Stored length exactly one past the border: either a deliberate
// `table.insert(t, nil)` reserved slot (keep — issue #36) or a cleared /
// recycled table stale by one (heal — issue #39). The states are
// identical; only the writer-side mark recorded by `TableInsert_h` can
// tell them apart.
if (n - b == 1 && HasTrailingNilMark(L, absIdx, n))
return n;
// Stale by more than one slot — a cleared-and-reused table. Heal to the
// border, the 5.1 answer.
// Stale — heal to the border, the 5.1 answer.
return b;
}
const Game::HookAutoRegister _hook{Offsets::LUAL_GETN,
reinterpret_cast<void *>(&LuaLGetN_h),
reinterpret_cast<void **>(&g_getnOriginal)};
const Game::HookAutoRegister _hookInsert{
Offsets::FUN_LUA_TABLE_INSERT, reinterpret_cast<void *>(&TableInsert_h),
reinterpret_cast<void **>(&g_tinsertOriginal)};
} // namespace