Files
benilla-octo/crates/benilla-ui/src/script/button.rs
T
SamandClaude Opus 5.5 293b8e57fb creatures keep their first corner, and characters never share files
- a creature walking a path no longer cuts its first corner: the path's first waypoint was dropped at decode (2342)
- two characters whose names differ only in accented or non-Latin letters no longer share macros, bindings and SavedVariables; a file that failed to load is not overwritten at logout (2342)
- custom chat channels survive a logout, a collapsed quest header keeps its quests watched, and a trackpad swipe scrolls as far as it adds up to (2342)
- a hearth off a zeppelin no longer lands back on its deck, and an opened lockbox with loot left in it unlocks (2342)
- every snapshot runs on Vulkan, in a container, before it ships (2344)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:19:22 +07:00

1379 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `Button`/`CheckButton` method surface — per-kind behavior over the frame arena
//! (`CSimpleButton` `0x6eeab0` / `CSimpleCheckbox` `0x6eeb30`).
//!
//! Grounded in wow-re's byte-verified LoadXML tables (RF-28): the four state textures
//! (`Normal/Pushed/Disabled/Highlight`), the `ButtonText` fontstring + `text` attribute, the
//! `OnClick` script slot (`+0x4cc`); CheckButton runs Button's loader first and adds
//! `CheckedTexture`/`DisabledCheckedTexture` + the `checked` bool (`+0x4dc`). Which texture *shows*
//! is **latched on the transition**, not resolved at paint: [`ButtonState::set_state`] is the
//! client's `SetState 0x779790` and [`settle`] is where the derived inputs reach it. Two stated v1
//! gaps: the highlight draws with normal blending (the client ADD-blends it; the quad pass has no
//! blend modes yet), and `PushedTextOffset` is not modeled. Per-state label fonts *are*: the
//! `*FontObject` trio picks which object each state inherits and `extract` re-resolves it every
//! frame, while `SetFont` writes the button's own face/size/flags over all of them
//! ([`crate::widget::ButtonFont`]).
//!
//! Method resolution: CheckButton's table is consulted first, then Button's, then the shared frame
//! table — mirroring the client's class chain, and keeping duck-typing honest (`frame.SetChecked`
//! is nil on a plain Button; `frame.GetText` is nil on a plain Frame).
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
use super::object::{as_f32, frame_handle_of};
use super::region::region_wrapper;
use super::{event, JustifyH, Model, RegionData};
use crate::justify::Justify;
use crate::order::DrawLayer;
use crate::widget::{
ButtonFont, ButtonState, ButtonVisualState, FrameHandle, FrameKind, KindState, RegionHandle,
RegionKind,
};
pub(super) const REG_BUTTON_METHODS: &str = "__benilla_button_methods";
pub(super) const REG_CHECKBUTTON_METHODS: &str = "__benilla_checkbutton_methods";
/// The Button state-texture/text slots (each an arena region created on first set).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Slot {
Normal,
Pushed,
Disabled,
Highlight,
Checked,
DisabledChecked,
Text,
}
impl Slot {
fn get(self, bs: &ButtonState) -> Option<crate::widget::RegionHandle> {
match self {
Slot::Normal => bs.normal,
Slot::Pushed => bs.pushed,
Slot::Disabled => bs.disabled,
Slot::Highlight => bs.highlight,
Slot::Checked => bs.checked_tex,
Slot::DisabledChecked => bs.disabled_checked,
Slot::Text => bs.text,
}
}
/// The three STATE slots go through [`ButtonState::set_state_slot`] — the client's
/// `0x778fd0` family, which stores the slot *and* pushes it to the shown pointer when it is
/// the current state's. Every other slot is a plain field: the highlight, the checked pair
/// and the label are not in the state array and have show rules of their own.
fn set(self, bs: &mut ButtonState, rh: Option<crate::widget::RegionHandle>) {
match self {
Slot::Normal => bs.set_state_slot(ButtonVisualState::Normal, rh),
Slot::Pushed => bs.set_state_slot(ButtonVisualState::Pushed, rh),
Slot::Disabled => bs.set_state_slot(ButtonVisualState::Disabled, rh),
Slot::Highlight => bs.highlight = rh,
Slot::Checked => bs.checked_tex = rh,
Slot::DisabledChecked => bs.disabled_checked = rh,
Slot::Text => bs.text = rh,
}
}
/// The slot's region kind + default draw layer: state textures under the text, the highlight
/// in its own top layer (drawLayer table `0x811a84`), the checked marks above the state.
fn shape(self) -> (RegionKind, DrawLayer) {
match self {
Slot::Normal | Slot::Pushed | Slot::Disabled => {
(RegionKind::Texture, DrawLayer::Artwork)
}
Slot::Checked | Slot::DisabledChecked => (RegionKind::Texture, DrawLayer::Overlay),
Slot::Highlight => (RegionKind::Texture, DrawLayer::Highlight),
Slot::Text => (RegionKind::FontString, DrawLayer::Overlay),
}
}
}
/// The NORMAL embedded font's justify word — `[button+0x390]` (`+0x33c`, the normal `CSimpleFont`,
/// plus its `+0x54` justify cell): the element-level `<NormalFont justifyH=>` when the template
/// wrote one (a local write on that instance, severed from what it inherits), else what the
/// instance inherits from its font object, else the ctor default (CENTER). The word the label
/// adopter anchors by — decision 1996.
fn normal_font_justify(model: &Model, bs: &ButtonState) -> Justify {
let mut word = Justify::default();
let inherited = || {
bs.normal_font
.as_deref()
.and_then(|n| model.font_object(n))
.and_then(|fo| fo.justify_h)
};
if let Some(j) = bs.normal_justify_h.or_else(inherited) {
word.set_h(j);
}
word
}
/// `CSimpleButton::SetFontString 0x778d20`'s tail — the ONE path a button label enters by,
/// whether `SetText` made it (`0x778dc0` allocates, then calls this) or Lua handed one over
/// (`SetFontString`): anchor it to the button **only if it has no anchor of its own** and **by the
/// NORMAL font's justify word** (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER — never
/// the string's own `+0x120`, which on a fresh string is the ctor's CENTER), then apply the
/// button's per-state font to it on the spot (`0x779810`). wow-re
/// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5.2, every clause VERIFIED.
///
/// The word's source is the whole bug this fixes (decision 1996): a `UIMenuButtonTemplate` row
/// has no `<ButtonText>` — its label is born from the reference's `UIMenu_AddButton` →
/// `button:SetText(text)` — and its `<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>` is
/// the only thing that puts the label at the row's left edge. Reading the fresh string's own
/// word instead seated every row CENTER, and with the shortcut string anchored RIGHT in a fixed
/// 104-wide row, "Macro" ran into "/macro".
fn adopt_label(model: &mut Model, owner: FrameHandle, rh: RegionHandle) {
let point = {
let Some(frame) = model.arena.frame(owner) else {
return;
};
let KindState::Button(bs) = &frame.kind_state else {
return;
};
super::region::justify_anchor_point(normal_font_justify(model, bs).0)
};
super::region::anchor_unanchored_at(model, rh, point);
apply_normal_font(model, owner);
}
/// Point the ButtonText at the button's NORMAL font — object and local justify — so the label's
/// *query* surface answers what its paint already shows. The live link the reference's per-state
/// applier `0x779810` → `0x770c60` installs: the instance's field block flows onto the label
/// behind the label's own severance mask.
///
/// Before this, the two disagreed. `extract` resolves the per-state font object every frame and
/// overlays it onto a CLONE of the region's data (`extract.rs` l.114-122), so the label painted
/// correctly while `region_data.font_object` stayed `None` — and a dropdown row's
/// `GetFontObject()` answered **nil**, `GetFont()` answered **nil, nil**. Nothing errored: a
/// FontString with no font of its own is a legal state, so this was invisible in exactly the way
/// 1205's silent-drop class predicts.
///
/// The measured consequence is smaller than it first looks, and that is stated rather than implied.
/// The corpus's 65 `GetFontObject` sites are on FontStrings the addon created and linked itself
/// (Dewdrop-2.0 calls `text:SetFontObject(GameFontHighlightSmall)` one line before it reads the
/// object back), so none of them were reaching this. What WAS reaching it is the reference's own
/// `DropDownList1` OnLoad, which derives `UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT` from
/// `DropDownList1Button1NormalText:GetFont()` and got nil.
///
/// **Normal only, deliberately.** The highlight and disabled objects are transient states that
/// extract overlays at paint time; linking either here would make a resting button report its hover
/// font. The remaining divergence, stated: while a button is disabled or hovered, the reference's
/// label reports that state's font and ours still reports the normal one. The paint is unaffected
/// either way, and no corpus site reads a label's font mid-state.
///
/// `font::repaint` honours the severance mask, so a `<FontHeight>` or `SetTextColor` the label set
/// for itself survives the link (the rule wow-re pinned in `font-object-lua-surface.md`), and the
/// local justify goes behind the same mask.
fn apply_normal_font(model: &mut Model, owner: FrameHandle) {
let (rh, name, local_justify) = {
let Some(frame) = model.arena.frame(owner) else {
return;
};
let KindState::Button(bs) = &frame.kind_state else {
return;
};
let Some(rh) = bs.text else {
return;
};
(rh, bs.normal_font.clone(), bs.normal_justify_h)
};
// An unregistered name is not an error here: `SetTextFontObject` already accepted it, and the
// loader's own log-and-continue rule (0068) owns the reporting.
let fo = name.as_deref().and_then(|n| model.font_object(n).cloned());
let d = model.region_data.entry(rh).or_default();
match (&name, fo) {
(None, _) => d.font_object = None,
(Some(_), None) => {}
(Some(name), Some(fo)) => {
d.font_object = Some(name.clone());
super::font::repaint(d, &fo);
}
}
if let Some(j) = local_justify {
if !d.font_explicit.justify_h {
d.justify.set_h(j);
}
}
model.touch_measure(rh);
}
/// Which of the button's three embedded font instances a `<…Font>` element writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LabelFont {
Normal,
Highlight,
Disabled,
}
/// `<NormalFont justifyH=>` / `<HighlightFont justifyH=>` / `<DisabledFont justifyH=>` — the
/// loader-facing write of a per-state embedded font's own justify (`CSimpleButton::LoadXML
/// 0x7788c0` → the `<Font>` loader `0x783c30` on `+0x33c`/`+0x3b8`/`+0x434`: a `<Font>`-typed
/// element, so the attribute lands on the instance itself, severed from whatever it inherits).
/// No 1.12 Lua verb writes this — the API trio sets the OBJECT each instance inherits, never its
/// local fields — which is why it is crate-internal rather than a method. A label already
/// adopted keeps its anchor (the reference decided that at adoption) while its own justify word
/// follows the normal instance, as the live link propagates the write.
pub(crate) fn set_label_font_justify_h_lua(
lua: &Lua,
wrapper: &Table,
which: LabelFont,
j: JustifyH,
) -> mlua::Result<()> {
let owner = frame_handle_of(lua, wrapper)?;
with_button(lua, wrapper, |bs| match which {
LabelFont::Normal => bs.normal_justify_h = Some(j),
LabelFont::Highlight => bs.highlight_justify_h = Some(j),
LabelFont::Disabled => bs.disabled_justify_h = Some(j),
})?;
if which == LabelFont::Normal {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
apply_normal_font(&mut model, owner);
}
Ok(())
}
/// Run `f` over a frame's Button state under one short write borrow.
///
/// **It no longer settles a state machine afterwards, because there is no longer one to settle**
/// (decision 2134). The button's state is a latch the engine's own edges write; a Lua write moves
/// it only when the write IS an edge (`Enable`/`Disable`, `SetButtonState`), and those call the
/// edge themselves. `RegisterForClicks` and the texture setters no longer disturb the state at
/// all — which is the reference's behaviour: re-registering a held button's clicks does not
/// un-press it.
fn with_button<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ButtonState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Button(bs) => Ok(f(bs)),
_ => Err(mlua::Error::runtime("not a Button")),
}
}
/// `Enable()` / `Disable()` — and the second thing they do, which is not the state texture.
///
/// `Disable 0x77ffd0` ends `0x78009a call [vtbl+0x90](0)`, reaching the shared helper
/// `0x779160`; the constructor reaches the same helper with `1` (`0x778766`), which is what puts
/// a fresh button in NORMAL. That helper does **two** things:
///
/// - `[vtbl+0x9c](state, 0)` = `SetButtonState 0x779790` — [`ButtonState::set_enabled`]'s half,
/// whose two early-outs (nothing happens if the button is already in the state being asked for)
/// are the helper's own; and
/// - `0x7791bb push 4; call 0x76a730` — the per-layer enable for layer **4 = HIGHLIGHT**, written
/// into the very `[frame+0x198]` array that `Enable/DisableDrawLayer` writes and that
/// `0x76b3a0` reads back at draw time.
///
/// So a disabled button's highlight is switched off **at the layer**, which takes every region
/// the frame owns there with it — not only the HighlightTexture. That is why the highlight is no
/// longer gated on `enabled` inside [`ButtonState::region_visible`]: one mechanism, in the place
/// the client keeps it, instead of a second rule that agreed with it on the common case and
/// disagreed on `<Layer level="HIGHLIGHT">` art the button did not put there itself.
///
/// **The restore is NOT symmetric, and this comment used to claim it was** — *"one array and one
/// writer, so an addon's `DisableDrawLayer("HIGHLIGHT")` is undone by the next `Enable()`"*. Both
/// halves are false (wow-re `scratch/button-disabled-state-texture-law.md` §7, sharpened
/// 2026-09-09 off `0x779160` read contiguously for exactly this question). The indexed form
/// `[reg + 4*reg + 0x198]` has **three** sites image-wide — `0x76a717` (=1), `0x76a737` (=0) and
/// `DrawLayer 0x76b3a6`'s read — so there are **two** writers; and the enable arm calls
/// *neither* helper. It reaches the ON one only one hop away and only when the button is the
/// frame under the cursor:
///
/// ```text
/// 779183 mov ecx,[esi+0xa0] ; the CSimpleTop singleton
/// 779189 cmp esi,[ecx+0x7c] ; am I the hover target?
/// 77918c jne 0x7791ce ; no -> return, layer untouched
/// 779192 call [edx+0x4c] ; = 0x779490 -> 0x76b6a0 -> 0x76b6c9 push 4; call 0x76a710
/// ```
///
/// — i.e. `Enable()` on a hovered button restores the layer **by re-running the frame's Lua
/// `<OnEnter>`**, and on a non-hovered one restores nothing (the next hover-enter does it). Both
/// restore paths are gated on the LockHighlight flag `[frame+0xf8]`, and the disable arm's
/// `0x7791bb call 0x76a730(4)` is the one site in the image that is *not* — so
/// `LockHighlight()` → `Disable()` → `Enable()` leaves the layer off in the reference, and only
/// `UnlockHighlight()`+`LockHighlight()` or an explicit `EnableDrawLayer` brings it back.
///
/// **Ours is the symmetric bit-flip**, which draws the same in the ordinary case — a highlight is
/// only visible while hovered, and every hover-enter sets it — and diverges in two script-visible
/// ways: we do not fire the `<OnEnter>` the reference synthesises when you `Enable()` a hovered
/// button, and we restore a locked-then-disabled highlight the reference leaves dark. Named here
/// rather than fixed, because the faithful shape needs the layer restore moved onto the hover
/// path where the reference keeps it.
fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
// The state half is [`ButtonState::set_enabled`], whose two early-outs are the client's:
// `Enable()` on a button that is not DISABLED does nothing at all, and neither does
// `Disable()` on one that already is.
match &mut frame.kind_state {
KindState::Button(bs) => bs.set_enabled(on),
_ => return Err(mlua::Error::runtime("not a Button")),
};
let bit = 1u8 << DrawLayer::Highlight.index();
if on {
frame.disabled_layers &= !bit;
} else {
frame.disabled_layers |= bit;
}
Ok(())
}
/// Run one of the engine's own state edges over frame `h`, if it is a live Button.
///
/// The reference's edge set is exactly seven `SetButtonState` call sites and **six of them are
/// engine-side** (wow-re `scratch/button-state-edge-set.md`, VERIFIED): Enable, Disable, the hide
/// notify, mouse-down, mouse-up and the drag-threshold crossing. There is **no enter or leave
/// edge** — which is why the pointer path no longer calls anything here when the cursor crosses a
/// button's boundary (decision 2134).
///
/// Harmless on a non-Button handle, and on a stale one.
pub(super) fn edge(model: &mut Model, h: FrameHandle, f: impl FnOnce(&mut ButtonState)) {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Button(bs) = &mut frame.kind_state {
f(bs);
}
}
}
/// Does a hover edge on frame `h` reach the **base** enter/leave notify — the HIGHLIGHT layer and
/// the Lua `<OnEnter>`/`<OnLeave>` script slots — or is it swallowed on the way there?
///
/// `CSimpleButton::OnEnter 0x779490` and `OnLeave 0x7794e0` both open on the state word, and the
/// branch skips the *whole* body except the sound:
///
/// ```text
/// 779493 mov eax,[esi+0x328]
/// 779499 test eax,eax
/// 77949b je 0x7794b0 ; DISABLED -> skip the base notify AND the label restyle
/// 77949d call 0x76b6a0 ; CSimpleFrame::OnEnter — highlight layer on, <OnEnter> at +0x140
/// ```
///
/// — so **a DISABLED Button runs neither script**, and `0x7794b0`'s hover sound is the only thing
/// past the branch (wow-re `scratch/button-state-edge-set.md` §3.1, VERIFIED; the base pair's
/// script slots `+0x140`/`+0x148` are `ledger.tsv:7944/7945`). Every path that delivers a hover
/// edge dispatches through the object's own vtable, so there is no way around the guard: the hover
/// walk (`0x766218`–`0x76623c`, `[vt+0x50]` then `[vt+0x4c]`), `SetMouseFocus 0x764dc0`
/// (`0x764ddd push 0`), and the hide/removal tail (`0x764cce mov edx,[edi]; push 1;
/// call [edx+0x50]`). `CheckButton` inherits both slots; the Hyperlink and NamePlate buttons
/// override them and tail straight back into this pair (`0x7cb8a5`), so the guard covers the
/// family.
///
/// The hover **target** still moves — the walk reassigns `[root+0x7c]` *before* it fires either
/// notify — so `GetMouseFocus()` answers a disabled button; it is the scripts alone that stay
/// quiet. This is what makes AtlasLoot's unset QuickLook buttons safe on the reference: their
/// `<OnEnter>` guards with `if this:IsEnabled() then`, which is a NUMBER `1`/`0` and therefore
/// always truthy (`scratch/button-enabled-state.md`), so the body would index a nil
/// `QuickLooks[n]` — the reference never runs it at all.
///
/// `None` (the cursor over no frame) and every non-Button kind notify normally.
pub(super) fn hover_notify_runs(model: &Model, h: Option<FrameHandle>) -> bool {
let Some(h) = h else { return true };
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Button(bs)) => bs.enabled(),
_ => true,
}
}
/// Write [`ButtonState::loot_slot`] — `LootButton:SetSlot`'s whole body, kept here beside the
/// other `ButtonState` writers rather than reaching into the arena from `script::loot`.
pub(super) fn set_loot_slot(lua: &Lua, this: &Table, slot: Option<u32>) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match (&frame.kind, &mut frame.kind_state) {
(crate::widget::FrameKind::LootButton, KindState::Button(bs)) => {
bs.loot_slot = slot;
Ok(())
}
// The reference's own guard: `SetSlot` type-checks `this` against the LootButton tag via
// vtable slot 4 (`0x4c18ee`), so calling it on a plain Button raises rather than writing a
// field nothing would ever read.
_ => Err(mlua::Error::runtime(
"SetSlot: 'this' is not a LootButton widget",
)),
}
}
/// Get-or-create the region behind `slot`; returns its id.
fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = match &model
.arena
.frame(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?
.kind_state
{
KindState::Button(bs) => slot.get(bs),
_ => return Err(mlua::Error::runtime("not a Button")),
};
let rh = match existing {
Some(rh) => rh,
None => {
let (kind, layer) = slot.shape();
let rh = model
.arena
.create_region(h, kind, layer, 0)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
// The highlight draws ADD by contract (the live API's SetHighlightTexture default
// blend; highlight art is authored for it — under straight alpha it reads as a
// dark box over the icon).
model.region_data.insert(
rh,
RegionData {
blend: if slot == Slot::Highlight {
crate::script::BlendMode::Add
} else {
crate::script::BlendMode::default()
},
..Default::default()
},
);
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Button(bs) = &mut frame.kind_state {
slot.set(bs, Some(rh));
}
}
// A freshly built slot region gets its creation-path implicit anchor (decision 1310)
// — an EXISTING slot region is never touched here; the get half of get-or-create
// changes no geometry. Which anchor depends on the slot: the reference's C++ string
// setters SetAllPoints a fresh state texture outright (`0x778f9d`/`0x7790db` — fresh
// means zero anchors, so the conditional form is equivalent), while a fresh LABEL is
// handed to the adopter (`SetText 0x778dc0` → `0x778d20`), which anchors it by the
// button's normal font and links it (decision 1996). The XML loader re-derives after
// applying authored `<Anchors>` (see `loader/widgets.rs`).
match slot {
Slot::Text => adopt_label(&mut model, h, rh),
_ => super::region::implicit_creation_anchor(&mut model, rh),
}
rh
}
};
Ok(model.region_id(rh))
}
/// Point `slot` at `rh` (or at nothing) and hand back whatever it held before, when that is a
/// different region. The reference's slot store `0x778fd0` ends in the outgoing object's
/// vtable-slot-0 dtor, so the caller frees it.
fn swap_slot(
model: &mut Model,
h: FrameHandle,
slot: Slot,
rh: Option<RegionHandle>,
) -> mlua::Result<Option<RegionHandle>> {
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
let KindState::Button(bs) = &mut frame.kind_state else {
return Err(mlua::Error::runtime("not a Button"));
};
let outgoing = slot.get(bs).filter(|old| Some(*old) != rh);
slot.set(bs, rh);
model.touch_layout();
Ok(outgoing)
}
/// Free the region a slot just gave up — never the one that just moved in.
fn free_outgoing(lua: &Lua, outgoing: Option<RegionHandle>, incoming: RegionHandle) {
if let Some(old) = outgoing.filter(|old| *old != incoming) {
super::region::free_region(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
old,
);
}
}
/// The shared body of `Set<State>Texture(texture | "path" | nil)` / `(r, g, b [, a])`.
///
/// The reference forks on `lua_type(L, 2)` into four legs (`0x781970`, carved instruction by
/// instruction in wow-re `button-state-texture-path-setter.md` §1). **Two of them never touch the
/// slot's own region at all**, so they are resolved *before* [`ensure_slot`] — which would
/// otherwise lazily build a region for a call whose entire purpose is to replace or drop one.
fn set_slot_texture(lua: &Lua, this: &Table, slot: Slot, args: &MultiValue) -> mlua::Result<()> {
match args.front() {
// **A widget object installs THAT region into the slot** (`0x781b0b`: `push the Texture
// object; push idx; call 0x778fd0`) — it does not load a path into the slot's own region.
// Three corpus addons build a highlight exactly this way and every one of them was a
// silent no-op here: Bongos' bar-drag button (`bar.lua:64-71`), _Nameplates' mouseover
// glow (`_Nameplates.lua:217-222`) and Quiver's option rows (`Quiver.bundle.lua:8949-53`)
// each `CreateTexture` on the button, colour it, `SetAllPoints`, and hand it over.
Some(Value::Table(t)) => {
let rh = super::region::region_handle_of(lua, t)?;
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The reference type-checks the handed object and raises rather than installing a
// FontString into a texture slot (`0x781ab1`, `0x87a1e0`).
if model.arena.region(rh).map(|r| r.kind) != Some(RegionKind::Texture) {
return Err(mlua::Error::runtime("Wrong object type, expected texture"));
}
let outgoing = swap_slot(&mut model, h, slot, Some(rh))?;
drop(model);
free_outgoing(lua, outgoing, rh);
return Ok(());
}
// **nil CLEARS the slot** (`0x781b5a`: `push 0; push idx; call 0x778fd0`, whose tail runs
// the outgoing object's dtor). Clearing the pointer alone would be worse than doing
// nothing: `ButtonState::region_visible` shows any region that is not one of the button's
// own slots unconditionally, so an unhooked state texture would start drawing in *every*
// state instead of none. `TheoryCraft/TheoryCraftUI.lua:215-217` strips a talent-rank
// button's normal/pushed/highlight art with three of these.
//
// A call with NO argument is left alone: `LUA_TNONE` is not `LUA_TNIL`, and the
// reference's own nil test is `0x781b4f call 0x6f3400 == 0`.
Some(Value::Nil) => {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let outgoing = swap_slot(&mut model, h, slot, None)?;
drop(model);
if let Some(old) = outgoing {
super::region::free_region(
&mut lua.app_data_mut::<Model>().expect("model app_data"),
old,
);
}
return Ok(());
}
_ => {}
}
let id = ensure_slot(lua, this, slot)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("slot region id");
let data = model.region_data.entry(rh).or_default();
match args.front() {
// `Set<State>Texture("")` CLEARS — the live API's blank form (ref QuestLogFrame.lua:165
// clears the quest rows' +/- slot with exactly this), same as SetTexture(nil)/("") on a
// plain region.
Some(Value::String(s)) if s.to_str()?.is_empty() => {
data.texture = None;
data.fill = None;
}
Some(Value::String(s)) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// The colour form generates a solid texture into the same slot the path form loads into
// ([`RegionData::fill`]) — never a tint, so each clears the other.
Some(v @ (Value::Number(_) | Value::Integer(_))) => {
let arg = |i: usize| args.get(i).map(as_f32);
data.fill = Some([
as_f32(v),
arg(1).unwrap_or(0.0),
arg(2).unwrap_or(0.0),
arg(3).unwrap_or(1.0),
]);
data.texture = None;
}
_ => {}
}
Ok(())
}
/// The shared body of `Get<State>Texture` — the region wrapper, or nil while unset.
fn get_slot_texture(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<Value> {
let rh = with_button(lua, this, |bs| slot.get(bs))?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
rh.map(|rh| model.region_id(rh))
};
match id {
Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
}
/// Register one `Set<X>Texture`/`Get<X>Texture` pair on `m`.
fn texture_pair(lua: &Lua, m: &Table, name: &str, slot: Slot) -> mlua::Result<()> {
m.set(
format!("Set{name}Texture"),
lua.create_function(move |lua, (this, args): (Table, MultiValue)| {
set_slot_texture(lua, &this, slot, &args)
})?,
)?;
m.set(
format!("Get{name}Texture"),
lua.create_function(move |lua, this: Table| get_slot_texture(lua, &this, slot))?,
)?;
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
texture_pair(lua, &m, "Normal", Slot::Normal)?;
texture_pair(lua, &m, "Pushed", Slot::Pushed)?;
texture_pair(lua, &m, "Disabled", Slot::Disabled)?;
texture_pair(lua, &m, "Highlight", Slot::Highlight)?;
// SetText/GetText target the ButtonText fontstring (RF-28: the `text` attr routes there).
m.set(
"SetText",
lua.create_function(|lua, (this, text): (Table, Option<Value>)| {
let text = super::binding_abi::text_arg(lua, text)?;
// `CSimpleButton::SetText 0x778dc0` opens `if (!text) return;` (`0x778dcc`, wow-re
// `button-label-build-and-anchor-order.md`) — a nil never reaches the label at all, so
// it neither clears the text nor lazily creates the FontString. That guard is the
// BUTTON's own: a `FontString:SetText(nil)` is not a no-op, it truncates the cell
// (`0x771d80`, decision 2110). Below the guard the button is a pass-through to
// `[button+0x338]->0x771d80`.
let Some(text) = text else { return Ok(()) };
let id = ensure_slot(lua, &this, Slot::Text)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rh = *model.id_to_region.get(&id).expect("text region id");
model.region_data.entry(rh).or_default().text = Some(text);
model.touch_measure(rh);
Ok(())
})?,
)?;
// GetText — like the FontString's own (`region::text`, decision 2110), an **empty label reads
// back nil**: `Button:GetText 0x780e10` carries the same first-byte substitution at `0x780ec5`,
// over three nil conditions rather than two (no label region, a NULL cell, an empty cell).
// Its EditBox neighbour deliberately does not — the law is per binding.
m.set(
"GetText",
lua.create_function(|lua, this: Table| {
let rh = with_button(lua, &this, |bs| bs.text)?;
let text = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
rh.and_then(|rh| model.region_data.get(&rh))
.and_then(|d| d.text.clone())
.filter(|t| !t.is_empty())
};
match text {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
None => Ok(Value::Nil),
}
})?,
)?;
m.set(
"GetFontString",
lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?,
)?;
// SetFontString(fontString) — ADOPT a caller-made FontString as this Button's label.
//
// Byte-carved end to end by wow-re (decision 1505,
// `system/ui/scratch/resize-bounds-and-button-fontstring.md`
// §5): the binding `0x780a60` is gates + a delegate to `CSimpleButton::SetFontString
// 0x778d20`, which is the SAME function `SetText`'s lazy creation path funnels through — so
// adopting and creating share their whole tail, and only the allocation differs.
//
// The idiom it exists for: build the label yourself so you can style and place it, then hand
// it to the button so `SetText` and the per-state font machinery drive it. Quiver's
// `Component/Select.wow.lua` builds every dropdown option row that way, and with the method
// nil the row died on its first line — the second of B267's three walls.
//
// **It raises, in three distinct ways, and never silently no-ops on a bad argument** — the
// first shape I built here was a lenient no-op and the bytes say otherwise:
//
// · a **missing** argument, `nil`, a number, a string or a boolean → `lua_type(L,2) != TABLE`
// → `Usage: %s:SetFontString(fontstring)` (`0x87a100`). Missing and nil take the identical
// leg (`index2adr` answers NULL → type −1), so `SetFontString(nil)` **cannot clear the
// label from Lua**; the C++ clear exists but no binding reaches it.
// · a **table that is not a widget** → `%s:SetFontString(): Couldn't find 'this' in
// fontstring` (`0x87a160`).
// · a **Frame or a Texture** — anything that is not a FontString → `%s:SetFontString():
// Wrong object type, expected fontstring` (`0x87a124`), gated by an `IsA` against the
// FontString token, which a Texture fails.
//
// `%s` is the BUTTON's name (or `<unnamed>`), never the argument's. Zero return values.
//
// What `0x778d20` then does, all four clauses VERIFIED and all four here:
//
// · **`new == old` is a total no-op** — the first compare, before anything is touched.
// · **the previous label is DESTROYED**, not orphaned (`old->vtable[0](1)`, the scalar
// deleting destructor, returning the storage to the FontString pool). So a button whose
// `SetText` already lazily made one does not leak a stray string behind the new label.
// · **the string is RE-PARENTED to the button and its draw layer forced to ARTWORK**
// (`0x77fd10(parent, 2, 1)` — layer id 2 in the client's own `.rdata 0x811a80` name table).
// Always, whatever it was parented to or drawn in before.
// · **it is anchored only if it has NO anchors of its own** (a scan of all nine
// `anchorPoints` slots): LEFT/RIGHT/CENTER by the justify bits of the **button's normal
// font** (`[button+0x390]`), to the matching point on the button — [`adopt_label`], the
// same `& 7` → LEFT(1)/RIGHT(4)/else-CENTER chain the FontString post-step runs, over the
// other word. (It used to reuse the post-step and read the string's own word; the two
// only agree when the normal font has no justify — decision 1996.)
// · **the button's per-state font is applied immediately** (`0x779810`) — the same
// [`adopt_label`] links the label to the normal font, so its query surface answers.
m.set(
"SetFontString",
lua.create_function(|lua, args: MultiValue| {
let mut it = args.into_iter();
let this = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a button")),
};
let who = {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string())
};
let Some(Value::Table(fs)) = it.next() else {
return Err(mlua::Error::runtime(format!(
"Usage: {who}:SetFontString(fontstring)"
)));
};
// The two remaining legs are DIFFERENT questions and the reference asks them in this
// order: first "is this a framescript object at all" (`0x780b27`), then "is it a
// FontString" (`0x780b90`'s `IsA` against the FontString token). A Frame or a Texture
// passes the first and fails the second — so resolving straight to a region handle
// would collapse both onto the first message and mislabel every widget argument.
let rh = {
let not_an_object = || {
mlua::Error::runtime(format!(
"{who}:SetFontString(): Couldn't find 'this' in fontstring"
))
};
let id = super::object::decode_id(&fs).map_err(|_| not_an_object())?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let known_widget =
model.id_to_frame.contains_key(&id) || model.id_to_region.contains_key(&id);
if !known_widget {
return Err(not_an_object());
}
match model.id_to_region.get(&id).copied() {
Some(rh)
if model.arena.region(rh).map(|r| r.kind)
== Some(RegionKind::FontString) =>
{
rh
}
_ => {
return Err(mlua::Error::runtime(format!(
"{who}:SetFontString(): Wrong object type, expected fontstring"
)))
}
}
};
let old = with_button(lua, &this, |bs| bs.text)?;
if old == Some(rh) {
return Ok(());
}
let owner = frame_handle_of(lua, &this)?;
with_button(lua, &this, |bs| bs.text = Some(rh))?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(dead) = old {
// DESTROYED, not orphaned — the shared region-lifetime law, whose doc carries the
// reason (`0x778d3c`'s scalar deleting destructor).
super::region::free_region(&mut model, dead);
}
model.arena.set_region_owner(rh, Some(owner));
if let Some(r) = model.arena.region_mut(rh) {
r.draw_layer = DrawLayer::Artwork;
}
adopt_label(&mut model, owner, rh);
// A new label, a new owner and a possible new anchor — the layout's read set moved,
// and the string's extents are what the button's own `GetTextWidth` reports.
model.touch_layout();
model.touch_measure(rh);
Ok(())
})?,
)?;
// GetTextWidth / GetTextHeight — the Button's OWN text-extent readers (`0x782290` / `0x782390`,
// wow-re `system/ui/scratch/item9-firing34-merge.md` l.36 and the Button method carve in
// `widget-api-batch-benilla.md` Q8, which lists both present on Button and `GetStringWidth`
// absent). Both are thin forwards onto the label FontString's own extent vtable slots
// (`0x1c` / `0x20`), which is exactly what this delegation is.
//
// **Who asks.** `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from
// `button:GetTextWidth() + 40` over every saved character — with the method nil the whole
// dropdown died on the first row, so the director could not switch characters in Bagnon at all.
// The idiom generalises: it is how a 1.12 kit fits a button to its label, and the reference's
// own `MoneyFrame.lua` l.202 (`SetWidth(GetTextWidth() + iconWidth)`) is the same shape.
//
// **Which measurement**, since a FontString carries two. The label's `GetStringWidth` — the
// NATURAL, unwrapped extent — never the laid-out `GetWidth`. A button label does not wrap, so
// the two agree in the ordinary case; where they differ, serving the laid-out width would hand
// every "size the button to its text" caller its own previous output as its next input, which
// is decision 0997's measured feedback loop (the macro window's tab that changed width every
// frame). The unwrapped extent is the one that settles.
//
// `0` before the host has measured the string (a frame's latency, as everywhere else), and `0`
// for a Button with no label at all — the reference dereferences its FontString pointer `+0x338`
// here where `SetFont`/`GetFont` deliberately do not, and what a null one does is NOT byte-read,
// so this answers the harmless number rather than raising on a guess.
for (name, region_getter) in [
("GetTextWidth", "GetStringWidth"),
// `GetHeight`, not a `GetStringHeight` — 1.12 has no such method (byte-verified absent
// in every encoding), and the reference's own `Button:GetTextHeight 0x782390` is this
// same call on the embedded FontString rather than a separate API.
("GetTextHeight", "GetHeight"),
] {
m.set(
name,
lua.create_function(move |lua, this: Table| {
let label = get_slot_texture(lua, &this, Slot::Text)?;
match label {
Value::Table(t) => t.call_method::<f32>(region_getter, ()),
_ => Ok(0.0),
}
})?,
)?;
}
// The per-state label fonts (the 1.12 API trio; XML `<NormalFont>/<HighlightFont>/
// <DisabledFont>` route here through the loader). Stored as font-object NAMES — extract
// re-points the ButtonText to the current state's object each frame, so Enable/Disable and
// hover swap the label's paint with no Lua involvement (the client's own behavior:
// UIPanelButtonTemplate's gold/white/gray label states).
//
// Each takes the font OBJECT, its name, or nil, like `FontString:SetFontObject` — across the
// trio the corpus splits 5 object-form to 4 string-form, so accepting one shape only would
// break about half the callers, and nil clears the state back to the default. Because the
// state is stored as a NAME and re-resolved at every `extract`, a later mutation of that font
// object reaches these labels for free.
m.set(
"SetTextFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetTextFontObject", &font)?;
let owner = frame_handle_of(lua, &this)?;
with_button(lua, &this, |bs| bs.normal_font = name)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
apply_normal_font(&mut model, owner);
Ok(())
})?,
)?;
m.set(
"SetHighlightFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetHighlightFontObject", &font)?;
with_button(lua, &this, |bs| bs.highlight_font = name)
})?,
)?;
m.set(
"SetDisabledFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetDisabledFontObject", &font)?;
with_button(lua, &this, |bs| bs.disabled_font = name)
})?,
)?;
// SetFont(file, height [, flags]) / GetFont() — the Button's own font, `0x780880`/`0x79f3b0`
// (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q8, §5-verified).
// `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)`, and it is
// that addon's only blocker.
//
// Three contract details, each a plausible implementation's silent divergence:
//
// · **It returns ZERO values.** The shared impl `0x79f210` pushes `1`/nil (a real font-load
// probe on a Font object — `!OmniCC` uses it), but `Button:SetFont` DISCARDS it
// (`xor eax,eax`), so a failed font load is undetectable from a Button. Returning `true`
// here would look harmless and would hand an addon a probe the client does not have.
// · **It never touches the label.** `SetFont`/`GetFont` do not dereference the FontString
// pointer `+0x338` at all, so styling a bare `CreateFrame("Button")` with no `<ButtonText>`
// is no error, no crash and **no lazy creation** — which is why the style is stored on the
// button ([`ButtonState::font`]) and `extract` applies it to whatever label exists. A later
// `SetText` (which *does* lazily create one, `0x778dc0`) then picks it up, so either call
// order works, exactly as the reference's does.
// · **It retunes all three embedded fonts** (normal `+0x33c`, disabled `+0x434`, highlight
// `+0x3b8`) and `GetFont` reads back only the normal one — see [`ButtonFont`] for why one
// record is the faithful shape for three identical writes.
//
// The argument gate is the shared impl's: arg2 `lua_isstring` and arg3 `lua_isnumber`, else
// `luaL_error("Usage: %s:SetFont(\"font\", fontHeight [, flags])")` (`0x87c69c`). Both
// predicates are the *coercing* ones in 5.0 — `lua_isstring` takes a number, `lua_isnumber`
// takes a numeric string — so the leniency is transcribed rather than tightened.
m.set(
"SetFont",
lua.create_function(
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <Button>:SetFont(\"font\", fontHeight [, flags])".to_string(),
)
};
let path = match &file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(),
_ => return Err(usage()),
};
let height = match &height {
Value::Number(_) | Value::Integer(_) => as_f32(&height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
let flags = super::Outline::flags(flags.as_deref().unwrap_or(""))
.as_str()
.to_string();
with_button(lua, &this, |bs| {
bs.font = Some(ButtonFont {
path,
height,
flags,
})
})
// …and nothing is returned: `with_button` yields `()`.
},
)?,
)?;
// GetFont() → file, height, flags — **3 values**, read off the NORMAL embedded font. Unset
// locally, that font still resolves through what the normal state inherits
// (`<NormalFont>`/`SetTextFontObject`), which is how a `GameMenuButtonTemplate` button answers
// GameFontNormal's face before anything calls `SetFont`. Nil path / nil height when neither
// exists, still 3 values.
m.set(
"GetFont",
lua.create_function(|lua, this: Table| {
let (own, inherits) =
with_button(lua, &this, |bs| (bs.font.clone(), bs.normal_font.clone()))?;
let (path, height, flags) = match own {
Some(f) => (Some(f.path), Some(f.height), f.flags),
None => {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let fo = inherits.and_then(|n| model.font_object(&n));
(
fo.and_then(|f| f.font.clone()),
fo.and_then(|f| f.height),
fo.map(|f| f.outline)
.unwrap_or_default()
.as_str()
.to_string(),
)
}
};
let path = match path {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
Ok((path, height, flags))
})?,
)?;
// The per-state label COLORS (Button:SetTextColor + the Highlight/Disabled pair): the state's
// color, when set, repaints the ButtonText over the state font object's paint at extract —
// the dropdown kit's rows use all three (info.textR/G/B rows, isTitle's NORMAL-yellow and
// notClickable's HIGHLIGHT-white recolors of a disabled row).
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
);
with_button(lua, &this, |bs| {
bs.normal_color = Some([r, g, b, a.unwrap_or(1.0)])
})
},
)?,
)?;
// GetTextColor() → r, g, b, a — **FOUR values** (`0x781100`, table `0x879d00`, argc 1, arity 4,
// kinds `(number,number,number,number)`), the same shape as the FontString/Font-object twins
// (`0x79d840` table `0x87c1d8`, `0x79f680`) this codebase already answers. Three is the
// plausible wrong answer, and the shapes table calls the name `name-not-unique` precisely
// because it is registered on seven tables; this is the Button one.
//
// **Read like `GetFont` right above, not like `SetTextColor` right above that.** The reference
// reads the colour back off the button's NORMAL embedded `CSimpleFont`, and an unset local
// colour there still resolves through what the normal state INHERITS
// (`<NormalFont>`/`SetTextFontObject`) — which is how a `GameMenuButtonTemplate` button answers
// `GameFontNormal`'s colour before anything calls `SetTextColor`. Falling back to white would
// be wrong in exactly the case the corpus cares about: reading a stock button's colour to
// restore it after a temporary recolour. White is the last resort, matching every other colour
// getter here (`GetVertexColor`, `FontString:GetTextColor`) — the untinted default a region
// with no colour anywhere draws at.
//
// Completeness rather than a live break: the census found no corpus site with a Button
// receiver (every measured `GetTextColor` call is on a FontString or a font object). It is here
// because the reference registers it and the widget shape gate can then cover it.
m.set(
"GetTextColor",
lua.create_function(|lua, this: Table| {
let (own, inherits) =
with_button(lua, &this, |bs| (bs.normal_color, bs.normal_font.clone()))?;
let c = own.unwrap_or_else(|| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
inherits
.and_then(|n| model.font_object(&n))
.and_then(|f| f.color)
.unwrap_or([1.0, 1.0, 1.0, 1.0])
});
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
m.set(
"SetHighlightTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
);
with_button(lua, &this, |bs| {
bs.highlight_color = Some([r, g, b, a.unwrap_or(1.0)])
})
},
)?,
)?;
m.set(
"SetDisabledTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, Value, Value, Value, Option<f32>)| {
// Shape C on the channels (`Button:SetTextColor 0x780ee0`, `2=C 3=C 4=C 5=B`).
let (r, g, b) = (
super::object::as_f32(&r),
super::object::as_f32(&g),
super::object::as_f32(&b),
);
with_button(lua, &this, |bs| {
bs.disabled_color = Some([r, g, b, a.unwrap_or(1.0)])
})
},
)?,
)?;
// LockHighlight/UnlockHighlight — pin the HighlightTexture on regardless of hover (ref
// CButton::LockHighlight; the dropdown kit's checked rows stay lit).
m.set(
"LockHighlight",
lua.create_function(|lua, this: Table| {
with_button(lua, &this, |bs| bs.locked_highlight = true)
})?,
)?;
m.set(
"UnlockHighlight",
lua.create_function(|lua, this: Table| {
with_button(lua, &this, |bs| bs.locked_highlight = false)
})?,
)?;
m.set(
"Enable",
lua.create_function(|lua, this: Table| set_enabled(lua, &this, true))?,
)?;
m.set(
"Disable",
lua.create_function(|lua, this: Table| set_enabled(lua, &this, false))?,
)?;
// IsEnabled() → the NUMBER 1 or the NUMBER 0 — never a boolean, and never nil.
//
// wow-re `ui/scratch/button-enabled-state.md`, VERIFIED off the Button method table at
// `0x879d10`: `IsEnabled 0x7800b0` reads the three-valued STATE at `[obj+0x328]`
// (0 DISABLED / 1 NORMAL / 2 PUSHED), does `setne cl` for "not disabled", and pushes that as a
// NUMBER. So the reference's own `IsEnabled() == 0` and `== 1` tests (FriendsFrame.lua l.404,
// StaticPopup.lua l.713) are live code.
//
// **This answered a Lua boolean until now, and the difference is not cosmetic, because 0 is
// TRUTHY in Lua.** A caller writing the reference's `== 0` got false forever; a caller writing
// `IsEnabled() > 0` got "attempt to compare number with boolean" — which is where pfUI's
// `api/ui-widgets.lua:812` icon-fade died, taking the widget module with it. The divergence was
// known and worked around by hand in three of our own files (FriendsFrame.xml's
// `guildButtonDisabled` read BOTH spellings and said so); those workarounds go with this
// change, which is 1719's bar — a prior call revisited because a new measurement arrived.
//
// Slider used to carry its own `IsEnabled` (slider.rs), left alone here because "whether the
// slider widget shares the contract is unverified". It does not share it — it does not HAVE
// one: 1.12 registers `Enable`/`Disable`/`IsEnabled` on this table alone, and the Slider's
// LoadXML `0x789580` has no `enabled` attribute either, so ours was a superset. The trio came
// off the Slider rather than being brought into line with this contract; see the note at its
// old site in `slider.rs`.
m.set(
"IsEnabled",
lua.create_function(|lua, this: Table| {
with_button(lua, &this, |bs| i64::from(bs.enabled()))
})?,
)?;
// RegisterForClicks(...): replaces the button's registered-click set outright (the live API's
// contract — not additive) with its varargs, verbatim strings (`"LeftButtonUp"`,
// `"RightButtonDown"`, …). [`UiScript::mouse_button`] consults the set via [`wants_click`] to
// decide whether a press or a release reaches `OnClick`.
m.set(
"RegisterForClicks",
lua.create_function(|lua, (this, args): (Table, MultiValue)| {
let mut set = std::collections::HashSet::new();
for v in args.iter() {
if let Value::String(s) = v {
set.insert(s.to_str()?.to_string());
}
}
with_button(lua, &this, |bs| bs.registered_clicks = set)
})?,
)?;
// **`SetButtonState(state[, lock]) 0x780270` — TWO arguments**, and the second is not
// cosmetic (wow-re `scratch/binding-shape-arity-law.md` §3, VERIFIED). It reads three stack
// indices and returns none; the state maps case-insensitively over exactly
// `{"DISABLED", "NORMAL", "PUSHED"}` (`0x780390`, `SStrCmpI`) and an unrecognised string
// raises `Usage: %s:SetButtonState("state", lock)`; the flag is `GetBoolOrDefault` with
// **default 0** (`0x78032c push 0`), whose number arm truncates through `_ftol`, so the
// literal `1` that `MainMenuBarMicroButtons.lua` passes arms it.
//
// **The lock is the whole of decision 2134.** While it is set the button ignores the mouse
// press/release transitions; while it is clear, the next release un-pushes a scripted push —
// which is what Tablet-2.0's rows depend on, since the library pushes them and never calls
// `SetButtonState("NORMAL")` anywhere.
//
// DISABLED really is accepted here — the mapper takes it — even though the 1.12 FrameXML
// never passes it.
m.set(
"SetButtonState",
lua.create_function(|lua, (this, state, lock): (Table, String, MultiValue)| {
let new = if state.eq_ignore_ascii_case("PUSHED") {
ButtonVisualState::Pushed
} else if state.eq_ignore_ascii_case("NORMAL") {
ButtonVisualState::Normal
} else if state.eq_ignore_ascii_case("DISABLED") {
ButtonVisualState::Disabled
} else {
return Err(mlua::Error::runtime(format!(
"Usage: SetButtonState(\"state\", lock) — unknown state '{state}'"
)));
};
let first = lock.into_iter().next();
let locked = super::binding_abi::bool_or_default(first.as_ref(), false);
with_button(lua, &this, |bs| bs.set_button_state(new, locked))
})?,
)?;
m.set(
"GetButtonState",
lua.create_function(|lua, this: Table| {
// ONE variable, read back (`0x780180` reads `[obj+0x328]`). This used to OR in the
// live mouse-held press, because the state was derived and the press lived outside
// the widget; now the press *wrote* the state at its edge, so a mouse-held button
// answers "PUSHED" for the same reason the reference does — and cannot disagree with
// the art the way a re-derived answer could (decision 2134). The chat scroll buttons'
// hold-repeat (ref `MessageFrameScrollButton_OnUpdate`) still reads what it needs.
with_button(lua, &this, |bs| match bs.button_state() {
ButtonVisualState::Disabled => "DISABLED",
ButtonVisualState::Pushed => "PUSHED",
ButtonVisualState::Normal => "NORMAL",
})
})?,
)?;
// Click([button]) — the programmatic click: same path as a physical one (toggle-then-OnClick).
m.set(
"Click",
lua.create_function(|lua, (this, button): (Table, Option<String>)| {
let h = frame_handle_of(lua, &this)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
let btn = button.unwrap_or_else(|| "LeftButton".to_string());
// A programmatic Click() always emulates a completed (released) click.
// A programmatic Click() — `scripted = true`, which only a LootButton reads.
click_button(lua, id, &btn, false, true);
Ok(())
})?,
)?;
lua.set_named_registry_value(REG_BUTTON_METHODS, m)?;
// CheckButton's own table (consulted before Button's — the class chain).
let c = lua.create_table()?;
texture_pair(lua, &c, "Checked", Slot::Checked)?;
texture_pair(lua, &c, "DisabledChecked", Slot::DisabledChecked)?;
c.set(
"SetChecked",
lua.create_function(|lua, (this, v): (Table, Value)| {
// Numeric coercion, NOT Lua truthiness — byte-verified (decision 0227; wow-re
// `system/ui/scratch/button-check-and-state-texture.md`, `SetChecked 0x799bf0` →
// `0x6f1c10`): a number goes through `lua_tonumber` then a truncate-to-int (`fistp`,
// round-toward-zero, `0x40a2b0`) and the C++ setter tests `!= 0`. So `SetChecked(0)`
// UNchecks (0 is Lua-truthy — only a numeric read gets this right) and `SetChecked(1)`
// checks; the type-dispatched helper also honors the keyword strings the reference
// passes ("true"/"false" — SpellBookFrame.lua l.296-303), while a non-keyword string
// falls to tonumber (→ 0 → unchecked). nil/other → false; booleans honored as-is for
// this codebase's own Era-style callers.
let checked = match v {
Value::Boolean(b) => b,
Value::Integer(i) => i != 0,
Value::Number(n) => n.trunc() != 0.0,
Value::String(s) => s.to_str().ok().is_some_and(|s| {
let t = s.trim();
t.eq_ignore_ascii_case("true")
|| t.parse::<f64>().is_ok_and(|n| n.trunc() != 0.0)
}),
_ => false,
};
with_button(lua, &this, |bs| bs.checked = checked)
})?,
)?;
c.set(
// GetChecked() -> the NUMBER 1 or nil, never a boolean (1830). Its neighbour
// `IsEnabled` two entries up was corrected for the same reason under 1719, whose comment
// records that a boolean here killed pfUI's widget module outright; this is that call
// finished. The reference proves the shape in its own source rather than only at the
// bytes: stock `UIOptionsFrame.xml:310` saves a checkbox as
// `tostring(this:GetChecked())` and stock `BuffFrame.lua:71` reads it back as `== "1"`,
// a round-trip that only closes on the number.
"GetChecked",
lua.create_function(|lua, this: Table| {
let checked = with_button(lua, &this, |bs| bs.checked)?;
Ok(crate::script::binding_abi::flag(checked))
})?,
)?;
lua.set_named_registry_value(REG_CHECKBUTTON_METHODS, c)?;
Ok(())
}
/// Whether frame `h` should fire `OnClick` for the input transition `name`
/// (`"<Button>ButtonUp"`/`"<Button>ButtonDown"`, e.g. `"RightButtonDown"`) — the
/// [`UiScript::mouse_button`](super::UiScript::mouse_button) gate. A Button/CheckButton consults
/// its `RegisterForClicks` set (case-insensitively — the live API is forgiving of case); any
/// other kind with an `OnClick` fires exactly as it always has, before `RegisterForClicks`
/// existed: on release only (`name` ending `"Up"`), for any button, never on press.
pub(super) fn wants_click(model: &Model, h: FrameHandle, name: &str) -> bool {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Button(bs)) => bs
.registered_clicks
.iter()
.any(|s| s.eq_ignore_ascii_case(name)),
_ => name.ends_with("Up"),
}
}
/// Whether frame `h` is registered for `button` (`"LeftButton"`, `"RightButton"`, …) in **either**
/// variant — the press-visual gate, which is a strictly weaker test than [`wants_click`]'s.
///
/// `CButton::OnMouseDown 0x779210` runs the two as separate tests in one pass, and the difference
/// between them is the whole of this:
///
/// ```text
/// 0x779238 eax = event.buttonMask
/// 0x779243 ecx = eax << 8 | eax
/// 0x77924b test [this+0x330], ecx ; registered EITHER WAY -> keep going, else nothing at all
/// 0x779256 hit-test the point ; else nothing
/// 0x77926b test event.buttonMask, [this+0x330] ; the DOWN bits alone -> fire OnClick
/// 0x7792ad push 2 ; SetState(PUSHED) ; unconditional past the gates above
/// ```
///
/// So `[this+0x330]` is one dword holding the `"…ButtonDown"` registrations in byte 0 and the
/// `"…ButtonUp"` registrations in byte 1, and the mask `m | m << 8` asks "this button, either
/// variant". **The pushed art is not conditional on the click firing, or on the handler doing
/// anything** — it is conditional only on the button being registered for that mouse button at
/// all, which is why a right-click on an action, spellbook or pet slot lights up even when the
/// right-click does nothing (they all register `RightButtonUp`), and why a right-click on a plain
/// `LeftButtonUp` button does not.
pub(super) fn wants_press_visual(model: &Model, h: FrameHandle, button: &str) -> bool {
match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Button(bs)) => bs.registered_clicks.iter().any(|s| {
s.strip_suffix("Up")
.or_else(|| s.strip_suffix("Down"))
.is_some_and(|b| b.eq_ignore_ascii_case(button))
}),
_ => false,
}
}
/// The click behavior shared by the input path and `Click()`: gated on `enabled`; a CheckButton
/// **toggles before OnClick fires** (the documented widget contract — a handler reading
/// `self:GetChecked()` sees the new state); then `OnClick(self, button, down)` — `down` is `true`
/// only for a press that fired via a `"<Button>ButtonDown"` registration, `false` for every
/// release-fired or programmatic click (the Era signature: `down` mirrors which transition fired).
///
/// `scripted` says the click came from Lua `:Click()` rather than from hardware — the reference's
/// second `OnClick` argument (`0` from `0x779280`/`0x7793a4`, `1` from the `Button:Click` binding
/// `0x7826c0`). Only [`crate::widget::FrameKind::LootButton`] reads it, and it reads it as a hard
/// gate: `0x4c182b` returns before the base call, so **a scripted click on a loot row does
/// nothing at all — not even run the row's own Lua `OnClick`**. Every other kind ignores it, which
/// is the reference's shape too (the base `0x779540` never looks at the flag).
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripted: bool) {
let mut take_loot = None;
let fire = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(&h) = model.id_to_frame.get(&id) else {
return;
};
let is_loot = model
.arena
.frame(h)
.is_some_and(|f| f.kind == crate::widget::FrameKind::LootButton);
if is_loot {
if scripted {
return;
}
// Read the take BEFORE the handler runs, but queue it after: the reference reads
// `[esi+0x4dc]` after the base call, and a handler that re-slots its own row mid-click
// is not a case the shipped UI produces — reading first keeps the borrow simple and
// the two cannot disagree. The modifier gate is `0x4c183a`/`0x4c1848`/`0x4c1856`:
// shift, ctrl OR alt suppresses, and it is the whole reason the shipped
// `LootFrameItem_OnClick` never calls a take itself.
let (shift, ctrl, alt) = model.modifiers;
if !shift && !ctrl && !alt {
take_loot = model.arena.frame(h).and_then(|f| match &f.kind_state {
KindState::Button(bs) => bs.loot_slot,
_ => None,
});
}
}
let Some(frame) = model.arena.frame_mut(h) else {
return;
};
let is_check = frame.kind == FrameKind::CheckButton;
match &mut frame.kind_state {
// A Button/CheckButton click is gated on its enabled flag…
KindState::Button(bs) => {
if bs.enabled() {
if is_check {
bs.checked = !bs.checked;
}
true
} else {
false
}
}
// …every other kind with an OnClick just fires (plain frames can carry one too).
_ => true,
}
};
if !fire {
return;
}
let btn = match lua.create_string(button) {
Ok(s) => Value::String(s),
Err(_) => return,
};
if let Err(e) = event::fire_widget_handler(lua, id, "OnClick", vec![btn, Value::Boolean(down)])
{
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
// **A nameplate's click is the engine's too** (decision 2148): the reference's plate overrides
// the button click slot (`0x7cb910`) *and* chains the base, so its unit is selected whether the
// click came from the pointer or from Lua's own `Click()` — this funnel is both. Recorded after
// the handler, like the loot take below and for the same reason: an addon hook that errors
// cannot silently eat the selection. A press (`down`) records nothing — the plate registers for
// `LeftButtonUp | RightButtonUp` only (`RegisterForClicks(0x500)` at `0x7cb637`).
if !down {
super::nameplate::note_click(lua, id, button);
}
// …and then the take, unconditionally on the handler's outcome. `0x4c1867` is not guarded by
// anything the Lua side did: the base call at `0x4c1833` returns void and its result is never
// tested. A row whose `OnClick` errored still loots, which is the reference's behaviour and
// the reason a broken addon hook cannot silently eat your loot.
if let Some(slot) = take_loot {
// The 0-based store, back to the 1-based display row the take queue speaks.
lua.app_data_mut::<Model>()
.expect("model app_data")
.loot_picks
.push(slot + 1);
}
}