addons come alive: Bagnon draws, hovers and clicks

- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode
- a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error
- Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed
- the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:19:08 +07:00
co-authored by Claude Fable 5
parent b5bc569156
commit 251daa14c5
104 changed files with 14356 additions and 2306 deletions
+53 -15
View File
@@ -28,11 +28,23 @@
//! ## What we deliberately do not enforce: `INTERFACE_VERSION`
//!
//! The reference refuses to load an addon whose `## Interface` does not match the build, unless the
//! glue AddOn list's *Load out of date AddOns* checkbox is ticked. **We have no such checkbox** —
//! decision 0465 hides the glue's AddOns button entirely — so enforcing the rule would silently
//! drop most of a real vanilla corpus (whose manifests say 11100, 10900, anything) with no way for
//! a player to override it. The version is reported (`GetAddOnMetadata(name, "Interface")`) and not
//! acted on. Revisit when the AddOn list lands and there is somewhere to put the toggle.
//! glue AddOn list's *Load out of date AddOns* checkbox is ticked. Enforcing that here would
//! silently drop most of a real vanilla corpus (whose manifests say 11100, 10900, anything), so the
//! version is reported (`GetAddOnMetadata(name, "Interface")`) and never acted on — 1191 §6.
//!
//! **That is a choice, not an absence, and the sentence that used to stand here had gone stale.**
//! It read *"we have no such checkbox — decision 0465 hides the glue's AddOns button entirely …
//! revisit when the AddOn list lands and there is somewhere to put the toggle"*. The list landed
//! (1197: both screens, and 0465's hidden button is the one it un-hid), so the stated precondition
//! expired and the comment went on telling every reader the question was settled. 1212's shape, in
//! an instrument, for the fifth time this arc.
//!
//! What is still true and still open: the mismatch is REPORTED — the ESC-menu list shows
//! `ADDON_INTERFACE_VERSION` beside the row and the char-select panel shows the same string — and
//! `loadable` deliberately stays true, so `not_loadable` below has no interface arm. What is not
//! built is the *Load out of date AddOns* toggle itself, and it needs an RE answer we do not have:
//! how the reference couples `loadable`/`reason` when force-load is ON. Neither `AddonList.lua` nor
//! `GlueXML` is in the wow-re extract, so that question is dispatched, not guessed.
use std::path::PathBuf;
@@ -152,9 +164,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// name, title, notes, url, loadable, reason, security, newVersion
// `newVersion` is always nil: it is the reference's addon-version-check service reporting an
// update is available, and there is no such service to ask.
// `name, title, notes, enabled, loadable, reason, security` — SEVEN values, and slot 4 is
// **`enabled`**, not `url`.
//
// **The client registers `GetAddOnInfo` TWICE, as two different functions**, and this is the
// in-game one. wow-re `system/ui/scratch/addon-version-gate.md`: glue `0x46d460` returns EIGHT
// with `url` at slot 4 and `newVersion` at 8; in-game `0x48e390` returns SEVEN with `enabled`
// at slot 4 and no `url` at all. They differ in behaviour too — the in-game one passes `dl=1`,
// so `NOT_DEMAND_LOADED` is reachable here and never from glue, and it short-circuits an
// already-loaded addon to `1, nil` before the gate.
//
// We shipped the GLUE shape in the IN-GAME VM. Slots 5/6/7 aligned by luck; slot 4 did not, and
// it is the one the ecosystem reads:
//
// local name, _, _, enabled, loadable = GetAddOnInfo(major) -- AceLibrary-1.0:400
//
// `## URL` is rare, so `enabled` came back nil for nearly every addon and Ace's
// `if enabled and loadable` refused to load its own dependency. 70 corpus folders reach it
// (AceLibrary/AceAddon replicated — one library, which is what makes it wide rather than
// narrow). Nothing errored: a nil where a flag belongs is a legal Lua value.
//
// `enabled` is the number 1 or nil, never boolean `true` — every push in these functions is
// `lua_pushnumber(1.0)` via `0x6f3810`, with no `lua_pushboolean` anywhere. Same for
// `loadable`. `flag` already answers in that shape.
//
// `url` is not lost, it moves to where the client keeps it in-game: `GetAddOnMetadata(name,
// "URL")`, which is how our own AddonList tooltip reads it now.
g.set(
"GetAddOnInfo",
lua.create_function(|lua, key: Value| {
@@ -168,14 +203,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua_str(lua, &a.name)?,
opt_str(lua, &a.title)?,
opt_str(lua, &a.notes)?,
opt_str(lua, &a.url)?,
flag(a.enabled),
flag(reason.is_none()),
match reason {
Some(r) => lua_str(lua, r)?,
None => Value::Nil,
},
lua_str(lua, if a.secure { "SECURE" } else { "INSECURE" })?,
Value::Nil,
]))
})?,
)?;
@@ -435,7 +469,11 @@ fn load_saved_variables(lua: &Lua, i: usize) {
let Ok(bytes) = std::fs::read(&path) else {
continue; // absent is the first-run case
};
if let Err(e) = run_chunk(lua, &bytes) {
if let Err(e) = run_chunk(
lua,
&bytes,
&crate::script::addon_chunk_name(&name.to_string(), &format!("{name}.lua")),
) {
// The reference fails this silently. We do not: a settings file that stopped parsing
// is exactly the thing a player needs told, and the file is left on disk untouched.
log_error(lua, &format!("{}: {e}", path.display()));
@@ -459,7 +497,7 @@ fn run_files(lua: &Lua, name: &str, root: &std::path::Path, files: &[String]) {
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("lua"))
{
if let Err(e) = run_chunk(lua, &bytes) {
if let Err(e) = run_chunk(lua, &bytes, &crate::script::addon_chunk_name(name, file)) {
log_error(lua, &format!("{name}/{file}: {e}"));
}
continue;
@@ -471,8 +509,7 @@ fn run_files(lua: &Lua, name: &str, root: &std::path::Path, files: &[String]) {
continue;
}
};
let base = path.rfind('/').map_or("", |i| &path[..i]);
let report = crate::loader::load_into(lua, &doc, base, &provider);
let report = crate::loader::load_into(lua, &doc, &path, &provider);
for e in report.errors {
log_error(lua, &format!("{name}/{file}: {e}"));
}
@@ -496,8 +533,9 @@ fn read_under(root: &std::path::Path, rel: &str) -> Option<Vec<u8>> {
/// Run a chunk that came off disk, from `&Lua` — [`crate::script::UiScript::run_chunk`]'s body for
/// the demand-load path, which never holds a `UiScript` (1191 §4). Bytes and a BOM strip, for the
/// reasons in [`crate::source`].
fn run_chunk(lua: &Lua, bytes: &[u8]) -> mlua::Result<()> {
fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
lua.load(crate::source::chunk(bytes))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
}
+186 -4
View File
@@ -8,19 +8,24 @@
//! is interaction state ([`ButtonState::region_visible`], applied at extract) — faithful to the
//! documented widget model (texture array + current pointer `+0x4c4`), not byte-pinned. 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`/per-state fonts are not modeled.
//! 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, Table, Value};
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
use super::object::{as_f32, frame_handle_of};
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use crate::order::DrawLayer;
use crate::widget::{ButtonState, FrameHandle, FrameKind, KindState, RegionKind};
use crate::widget::{
ButtonFont, ButtonState, 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";
@@ -76,6 +81,48 @@ impl Slot {
}
}
/// Point the ButtonText at the button's NORMAL font object, so the label's *query* surface answers
/// what its paint already shows.
///
/// 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`).
fn link_label_to_font_object(lua: &Lua, text: Option<RegionHandle>, name: Option<&str>) {
let Some(rh) = text else { return };
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return;
};
// 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 Some(fo) = model.font_objects.get(name).cloned() else {
return;
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name.to_string());
super::font::repaint(d, &fo);
}
/// Run `f` over a frame's Button state under one short write borrow.
fn with_button<T>(
lua: &Lua,
@@ -239,6 +286,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?,
)?;
// 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"),
("GetTextHeight", "GetStringHeight"),
] {
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
@@ -254,7 +340,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetTextFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetTextFontObject", &font)?;
with_button(lua, &this, |bs| bs.normal_font = name)
let text = with_button(lua, &this, |bs| {
bs.normal_font.clone_from(&name);
bs.text
})?;
link_label_to_font_object(lua, text, name.as_deref());
Ok(())
})?,
)?;
m.set(
@@ -272,6 +363,97 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// 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_objects.get(&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
+119
View File
@@ -0,0 +1,119 @@
//! `GetChannelName` — the joined-channel lookup, in both directions.
//!
//! **The state this reads already existed and was built for this verb.** `ui_chat::edit`'s
//! `ChannelState` has held `joined: Vec<String>` in join order since the chat arc, and its own doc
//! comment names `GetChannelName(n)` as the law it implements. Only the registration was missing —
//! the same silent-gap shape the loader arc kept finding, one layer up: the capability was built,
//! nothing exposed it, and nothing complained.
//!
//! Corpus demand, counted by reading every line rather than the grep total (1207): **17 sites
//! across 6 addons**, every one an unguarded call, no library replicated —
//! `ChatLog` 7, `Recap` 4, `SmartRes` 2, `Enchantrix` 2, `FuBar_AssistFu` 1, `_LazyPig` 1. Both
//! lookup directions are live in the corpus: by index (`GetChannelName(i)`) and by name
//! (`GetChannelName("world")`, `GetChannelName("Trade - City")`).
//!
//! Signature verified against wow-5875-re `system/ui/scratch/zone-chat-channel-autojoin.md`
//! l.374-380 — `GetChannelName = 0x4a05e0`, **three** returns:
//!
//! | # | the client's | ours |
//! |---|---|---|
//! | 1 | `slot[+0x00]`, the client-local **1-based joined-slot index** (= `CHAT_MSG_*` arg8) | the position in `joined` |
//! | 2 | `slot[+0x04]`, the channel name | the `joined` entry |
//! | 3 | `slot[+0x98]`, FrameXML's `instanceID` (= arg10) | **0** — see below |
//!
//! **Return 3 is 0, not nil, and that is a recorded position rather than a shrug.** It is the split
//! index from `YOU_JOINED`'s second u32, which `ui_chat::event`'s doc already records as "0 on every
//! vanilla emulator" and deliberately does not store. A client that one day meets a server which
//! splits channels would need the field; nothing in the corpus reads it.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The 1-based slot of `name` in join order, case-insensitively — `GetChannelName`'s first return.
fn slot_of(model: &Model, name: &str) -> Option<usize> {
model
.joined_channels
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
.map(|i| i + 1)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetChannelName(indexOrName) → slot, name, instanceID.
//
// THE TRAP, and the whole risk in this verb: the first return is **always a NUMBER, never
// nil** — `0` when the channel is not joined. Verified from both sides. The reference's own
// callers compare it numerically and would raise on a nil: `ChatFrame.lua:2114`
// `if ( channelNum > 0 )` and `l.2232` `if ( channelNum <= 0 ) then return end`; so does the
// corpus, at `_LazyPig/LazyPig.lua:1996` `if id > 0 then`. Returning nil here would convert
// three working call sites into "attempt to compare nil with number".
//
// NOT a shared helper with `JoinChannelByName`, whose first return is a DIFFERENT number — the
// `ChatChannels.dbc` ChannelID, not this local slot index. wow-re calls that pair out
// explicitly (`zone-chat-channel-autojoin.md` l.379) and it is an easy, silent mistake.
g.set(
"GetChannelName",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = match &key {
// The numeric form. The reference bounds-checks `1 <= n <= count` (`0x49bf30`) and
// answers only for a CONFIRMED-joined slot; `joined` holds exactly the confirmed
// ones (`ui_chat::feed` appends on the server's YOU_JOINED, not on the request), so
// the bound is the whole check here.
Value::Integer(_) | Value::Number(_) => {
let n = match &key {
Value::Integer(i) => *i,
Value::Number(n) => *n as i64,
_ => unreachable!(),
};
usize::try_from(n)
.ok()
.filter(|n| *n >= 1 && *n <= model.joined_channels.len())
}
// The name form. A numeric STRING arrives here and must still resolve as a number:
// `ChatFrame.lua:2113` passes the result of a `gsub` — `GetChannelName("1")` — and
// Lua's own coercion is what makes that work on the real client.
Value::String(s) => {
let name = s.to_str()?;
match name.trim().parse::<usize>() {
Ok(n) if n >= 1 && n <= model.joined_channels.len() => Some(n),
Ok(_) => None,
Err(_) => slot_of(&model, &name),
}
}
_ => None,
};
let Some(slot) = slot else {
// Not joined: the number 0, and nothing else. `channelName` is unread by every
// caller on this branch (all four sites bail on the number first).
return Ok(MultiValue::from_vec(vec![Value::Integer(0)]));
};
let name = model.joined_channels[slot - 1].clone();
Ok(MultiValue::from_vec(vec![
Value::Integer(slot as i64),
Value::String(lua.create_string(&name)?),
// instanceID — see the module doc: 0 on every vanilla emulator, and a number
// rather than nil so a caller can compare it like the client's.
Value::Integer(0),
]))
})?,
)?;
Ok(())
}
impl super::UiScript {
/// Mirror the app's confirmed-joined channel list, in join order, for [`install`]'s verb.
///
/// The `model.party` shape (`party.rs:172`), deliberately, and NOT the `open_chat_requests`
/// shape the chat-window work used: that one is a QUEUE the app drains (Lua → app), and this is
/// app state READ BY Lua, which is the opposite direction. `ui_chat::feed` owns both edges that
/// change it — the server's YOU_JOINED and YOU_LEFT notices — so it pushes here from one place.
pub fn set_joined_channels(&mut self, joined: Vec<String>) {
self.model_mut().joined_channels = joined;
}
}
+295
View File
@@ -0,0 +1,295 @@
//! The chat **window** surface: `GetChatWindowInfo` (the engine's per-window settings getter) and
//! `ChatFrame_OpenChat` (the FrameXML verb that opens the edit box prefilled).
//!
//! The windows themselves are frames, declared in the app's shipped `ChatFrame.xml` —
//! `ChatFrame1`..`ChatFrame7`, of which 1 and 2 are docked and 3..7 ship hidden and undocked, the
//! reference's own arrangement. This module is the *settings* half: what an addon learns when it
//! asks about window `i` without touching the frame.
//!
//! ## `GetChatWindowInfo(id)` → `name, fontSize, r, g, b, a, shown, locked, docked`
//!
//! A registered Lua binding in the real client (`0x4a0ba0`, wow-re `system/ui/ledger.tsv`), reading
//! the per-window struct array at `0xb4fe50` — stride 0x98, **10 windows**, colour bytes at
//! +0xd8..+0xdb renormalised by the f32 1/255 (`system/ui/scratch/chat-color-table.md`). Those
//! structs are loaded from the character's `chat-cache.txt`, and a real stock one is on disk
//! (`wow-5875-re/WoW/WTF/Account/ONE/VMaNGOS/Onepaladin/chat-cache.txt`) — every value below is
//! quoted from it:
//!
//! ```text
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
//! WINDOW 2 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 2 SHOWN 0
//! WINDOW 3…10 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 0 SHOWN 0
//! ```
//!
//! **Three traps live in that tuple, and all three are load-bearing:**
//!
//! 1. **`name` is the empty string, not "General".** Nothing writes a window name until the user
//! renames a tab, so a stock client answers `""` for every window including the first two. The
//! familiar labels are a *FrameXML fallback*, not data: `FCF_SetWindowName` (FloatingChatFrame.lua
//! l.680-684) reads `if ( not name or name == "" )` and substitutes `GENERAL` / `COMBAT_LOG` /
//! `format(CHAT_NAME_TEMPLATE, id)`. Our own tab labels are that fallback, hardcoded. An addon
//! proves the contract from the other side: `Enchantrix/EnxConfig.lua:113` does
//! `local name = GetChatWindowInfo(i); if ( name == "" ) then if (i == 1) then name =
//! _ENCH('TextGeneral') …` — it would not have been written if the getter answered "General".
//! Answering "General" here would look friendlier and would be a divergence.
//! 2. **`shown`/`locked`/`docked` are numbers or `nil` — never `0`.** FrameXML branches on them
//! bare (`if ( shown ) then chatFrame:Show()`, `if ( docked ) then FCF_DockFrame(…)` —
//! FloatingChatFrame.lua l.59/69), and `0` is TRUE in Lua. A getter that returned the cache's
//! literal `0` would show every hidden window.
//! 3. **`docked` is a dock POSITION, not a flag.** Window 1 answers `1`, window 2 answers `2` —
//! the order tabs sit in the dock, which is why `FCF_DockFrame(frame, docked)` takes it as an
//! index. Reading it as a boolean happens to work; reading it as "is docked" and writing back
//! `1` would silently reorder the dock.
//!
//! `fontSize` is the cache's `SIZE`, and `0` is what a stock client stores — "use the font's own
//! height". No FrameXML path applies it (`FloatingChatFrame_Update` destructures it and never
//! reads it; the options dropdown checks `FCF_GetCurrentChatFrame():GetFont()` instead), and no
//! corpus addon reads it.
//!
//! Measured demand: **3 of the 5 corpus addons that iterate `NUM_CHAT_WINDOWS` call this on the
//! very next line** — `EnhTooltip/Tooltip.lua:1302`, `MikScrollingBattleText.lua:1951` and
//! `Enchantrix/EnxConfig.lua:110`. The first two are the same idiom (look for a window the user
//! named "debug"/"ettdebug"); the third builds a name→index map for its `/enx print-in` config.
//! Without this getter, declaring `NUM_CHAT_WINDOWS` hands those three a loop that raises on its
//! first iteration, so the constant and the getter are one change, not two.
//!
//! ## `ChatFrame_OpenChat(text, chatFrame)`
//!
//! FrameXML in the reference (ChatFrame.lua l.1545), and the same seam as its neighbour
//! `ChatFrame_SendTell` ([`super::party`]): benilla's chat edit machine is app-side
//! (`benilla_app::ui_chat::edit`), so the verb queues the request and the app opens the box.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// How many chat windows the client's own settings array holds — **10**, both in the engine
/// (`0xb4fe50`, stride 0x98) and in `chat-cache.txt`, which writes `WINDOW 1` … `WINDOW 10`.
/// FrameXML declares `ChatFrame1TabDockRegion`..`ChatFrame10TabDockRegion` to match, and then
/// builds only seven `ChatFrame`s: `NUM_CHAT_WINDOWS = 7` is the *UI's* count, not the engine's.
/// Recorded so the 7 below reads as a deliberate choice rather than a miscount.
const ENGINE_CHAT_WINDOW_SLOTS: usize = 10;
/// The windows benilla actually builds — `ChatFrame1`..`ChatFrame7` in the app's `ChatFrame.xml`,
/// which is also `NUM_CHAT_WINDOWS`. `benilla_app`'s `chat_tests` cross-checks every entry against
/// the shipped XML so the two cannot drift.
const NUM_CHAT_WINDOWS: usize = 7;
/// `(shown, docked)` per window, 1-based, as the tuple's *Lua* values: `None` where the reference
/// answers `nil`. Everything else in the tuple is identical across all seven windows on a stock
/// client — name `""`, size `0`, colour `0,0,0`, alpha `0` — so only the two that differ are
/// tabulated. `locked` is `1` for every window (the cache's `LOCKED 1`), and benilla has no unlock
/// path at all, so it is a constant below rather than a column here.
const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
(Some(1), Some(1)), // 1 "General" — shown, dock position 1
(None, Some(2)), // 2 "Combat Log" — docked at position 2, not shown (the dock shows one)
(None, None), // 3 — hidden, undocked
(None, None), // 4
(None, None), // 5
(None, None), // 6
(None, None), // 7
];
impl super::UiScript {
/// Drain the `ChatFrame_OpenChat` requests queued since the last call — each is the text the
/// caller wants the chat edit box to open prefilled with. The app opens and focuses the box,
/// applies its own sticky-type law, and lets the live parse take the text from there.
pub fn take_open_chat_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().open_chat_requests)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetChatWindowInfo(id) → name, fontSize, r, g, b, a, shown, locked, docked.
//
// Out of range RAISES rather than answering. The reference's array has 10 slots and benilla
// builds 7 windows; a question about window 8 is a question about a window that does not exist
// here, and the two honest answers are "raise" or "invent a row". Every corpus caller loops
// `1, NUM_CHAT_WINDOWS`, so the raise is unreachable from real code and exists to keep the
// next caller from reading a fabricated window as a real one.
lua.globals().set(
"GetChatWindowInfo",
lua.create_function(|lua, id: i64| {
if id < 1 || id as usize > NUM_CHAT_WINDOWS {
return Err(mlua::Error::runtime(format!(
"GetChatWindowInfo: window {id} out of range — benilla builds \
ChatFrame1..ChatFrame{NUM_CHAT_WINDOWS} (the client's settings array holds \
{ENGINE_CHAT_WINDOW_SLOTS} slots, but only {NUM_CHAT_WINDOWS} have frames)"
)));
}
let (shown, docked) = WINDOW_STATE[id as usize - 1];
let num = |v: Option<i64>| match v {
Some(n) => Value::Integer(n),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
// name: "" — never a label. See the module docs' trap 1.
Value::String(lua.create_string("")?),
Value::Integer(0), // fontSize — the cache's SIZE 0
Value::Number(0.0), // r ┐ COLOR 0 0 0 0, renormalised by the engine's 1/255,
Value::Number(0.0), // g │ and the value ChatFrame.xml's load-time
Value::Number(0.0), // b ┘ SetVertexColor(0, 0, 0) already applies
Value::Number(0.0), // a — window alpha 0: the box only exists on hover
num(shown), // 1 or nil, never 0
Value::Integer(1), // locked — LOCKED 1, and benilla has no unlock path
num(docked), // the dock POSITION (1, 2) or nil
]))
})?,
)?;
// ChatFrame_OpenChat(text, chatFrame) — open the chat edit box prefilled with `text`.
//
// **The second argument is inert, and that is the reference's doing, not a shortcut.** The ref
// reaches the box through `chatFrame.editBox`, and in 1.12 every chat frame's `.editBox` is
// the *same* global `ChatFrameEditBox`: `FloatingChatFrame_OnLoad` sets
// `this.editBox = ChatFrameEditBox` for each frame (FloatingChatFrame.lua l.30), and
// ChatFrame1 — which loads before the edit box exists and so skips that `if` — is patched by
// the box's own OnLoad, `DEFAULT_CHAT_FRAME.editBox = this` (FloatingChatFrame.xml l.742).
// Per-frame edit boxes are a 2.x feature. So `ChatFrame_OpenChat(text, ChatFrame5)` opens the
// one and only edit box on a real 1.12 client too, and accepting-and-ignoring the frame here
// is exact rather than approximate. `benilla_app`'s `chat_tests` states that as a claim.
//
// Measured demand: 3 distinct addons, and all three are the same shape — check the box is not
// already up, then open it prefilled, else fall back to SetText/Insert:
// FuBar_HeyFu/Core.lua:281,292 ChatFrame_OpenChat(reply, DEFAULT_CHAT_FRAME)
// FuBar_FriendsFu/FriendsFu.lua:434 ChatFrame_OpenChat(format("/w %s ", name)) -- 1 arg
// TipBuddy/TipBuddy.xml:2715,2717 ChatFrame_OpenChat("", chatFrame) / ("/", chatFrame)
// Two of the three prefill `/w <name> `, which the app's live parse then turns into whisper
// mode with the target extracted — the same path a human typing those characters takes.
//
// The ref's tail — the PARTY/RAID/BATTLEGROUND sticky downgrade (l.1554-1565) — is not skipped,
// it is the app's: `ui_chat::edit`'s open path already applies exactly that law (a sticky PARTY
// with nobody in the party opens as SAY), so running it here would be running it twice.
lua.globals().set(
"ChatFrame_OpenChat",
lua.create_function(|lua, (text, _chat_frame): (Option<String>, Value)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.open_chat_requests.push(text.unwrap_or_default());
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The tuple is nine values wide and in the reference's own order.
#[test]
fn get_chat_window_info_answers_the_nine_value_tuple() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', GetChatWindowInfo(1))")
.unwrap(),
9
);
}
/// Trap 1: a stock client has never been told a window's name, so the getter answers `""` and
/// FrameXML supplies "General"/"Combat Log". Enchantrix's `if ( name == "" )` fallback is only
/// correct because of this.
#[test]
fn every_window_name_is_the_empty_string_not_a_label() {
let s = UiScript::new().unwrap();
for id in 1..=7 {
let name: String = s
.eval(&format!("return (GetChatWindowInfo({id}))"))
.unwrap();
assert_eq!(name, "", "window {id} name");
}
}
/// Trap 2: `shown`/`docked` are `nil` where the cache stores 0, because FrameXML branches on
/// them bare and `0` is true in Lua. Window 1 is shown at dock position 1; window 2 is docked
/// at position 2 but not shown; 3..7 are neither.
#[test]
fn hidden_and_undocked_windows_answer_nil_never_zero() {
let s = UiScript::new().unwrap();
let probe = |id: i32| -> (String, String) {
let shown = s
.eval::<String>(&format!(
"local _,_,_,_,_,_,shown = GetChatWindowInfo({id}) return type(shown)"
))
.unwrap();
let docked = s
.eval::<String>(&format!(
"local _,_,_,_,_,_,_,_,docked = GetChatWindowInfo({id}) return type(docked)"
))
.unwrap();
(shown, docked)
};
assert_eq!(probe(1), ("number".into(), "number".into()));
assert_eq!(probe(2), ("nil".into(), "number".into()));
for id in 3..=7 {
assert_eq!(probe(id), ("nil".into(), "nil".into()), "window {id}");
}
// And the truthiness FrameXML actually branches on.
assert!(s
.eval::<bool>(
"for i = 3, 7 do local _,_,_,_,_,_,shown = GetChatWindowInfo(i) \
if shown then return false end end return true"
)
.unwrap());
}
/// Trap 3: `docked` is the dock POSITION — window 2 answers 2, not 1.
#[test]
fn docked_is_a_dock_position_not_a_flag() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(1) return d")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(2) return d")
.unwrap(),
2
);
}
/// The corpus's own debug-window walk (MikScrollingBattleText, EnhTooltip) runs to completion
/// and finds nothing — `string.lower(name)` needs a string, and no window is named "debug".
#[test]
fn the_corpus_debug_window_walk_completes_and_finds_none() {
let s = UiScript::new().unwrap();
let found: i64 = s
.eval(
"local debugWin = 0\n\
for i = 1, 7 do\n\
local name, _, _, _, _, _, shown = GetChatWindowInfo(i)\n\
if string.lower(name) == 'debug' then debugWin = i break end\n\
end\n\
return debugWin",
)
.unwrap();
assert_eq!(found, 0);
}
/// A window benilla has no frame for raises rather than answering with an invented row.
#[test]
fn a_window_past_the_last_frame_raises() {
let s = UiScript::new().unwrap();
assert!(s.eval::<i64>("return (GetChatWindowInfo(8))").is_err());
assert!(s.eval::<i64>("return (GetChatWindowInfo(0))").is_err());
}
/// `ChatFrame_OpenChat` queues the text for the app; the optional frame argument is accepted
/// and dropped (in 1.12 every chat frame shares one edit box — see the install comment).
#[test]
fn chat_frame_open_chat_queues_its_text_and_ignores_the_frame() {
let mut s = UiScript::new().unwrap();
s.run("ChatFrame_OpenChat('/w Bob ')").unwrap();
s.run("ChatFrame_OpenChat('', 'not even a frame')").unwrap();
assert_eq!(
s.take_open_chat_requests(),
vec!["/w Bob ".to_string(), String::new()]
);
assert!(
s.take_open_chat_requests().is_empty(),
"the drain is a take"
);
}
}
+18 -3
View File
@@ -110,9 +110,24 @@ impl super::UiScript {
/// Set at world entry from the realm the session actually connected to. Idempotent, and the
/// empty string is a legitimate value (no realm yet), not a "clear".
pub fn set_realm_name(&mut self, realm: &str) {
let mut model = self.model_mut();
if model.realm_name != realm {
model.realm_name = realm.to_string();
{
let mut model = self.model_mut();
if model.realm_name != realm {
model.realm_name = realm.to_string();
}
// The `realmName` CVar is the SAME fact, and it is set here so it cannot drift from
// `GetRealmName()`. It is a real 1.12 CVar (`0x83f2d0`, persisted — the client builds
// its SavedVariables path from it, wow-re `savedvariables-protocol.md`), and it had no
// value at all here: `Ace/AceState.lua:27` is
// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
// PLAYER_ENTERING_WORLD, so the nil became `gsub(nil, ...)` and took the family down.
//
// Written straight into the slot rather than through `set_cvar_host` because that
// borrows the model again, and warns on an unknown name — this is the host declaring
// the value, not looking it up.
if let Some(slot) = model.cvars.get_mut("realmname") {
slot.value = realm.to_string();
}
}
}
+33
View File
@@ -349,4 +349,37 @@ mod tests {
s.register_cvars([("MusicVolume", "0.4")]);
assert_eq!(s.cvar("MusicVolume").as_deref(), Some("0.9"));
}
/// **`GetCVar("realmName")` answers the session's realm, set by the one seam that owns it.**
///
/// It is a real 1.12 CVar (`0x83f2d0`, persisted — the client builds its SavedVariables path
/// from it) and it had no value here at all. `Ace/AceState.lua:27` is
/// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
/// PLAYER_ENTERING_WORLD, so nil became `gsub(nil, ...)` and took the whole family down.
///
/// Asserted through `set_realm_name` rather than by writing the CVar directly, because that is
/// the point: the CVar and `GetRealmName()` are the SAME fact and must not be settable apart.
#[test]
fn the_realm_name_cvar_and_get_realm_name_are_one_fact() {
let mut s = UiScript::new().unwrap();
s.register_cvars([("realmName", "")]);
// Before a session: empty, never nil — `ace.trim` must have a string to gsub.
assert_eq!(
s.eval::<String>(r#"return GetCVar("realmName")"#).unwrap(),
""
);
s.set_realm_name("Archimonde");
assert_eq!(
s.eval::<String>(r#"return GetCVar("realmName")"#).unwrap(),
"Archimonde",
"the realm seam must write the CVar too, or the two facts drift"
);
// Ace's own line, run for real.
let trimmed: String = s
.eval(r#"return string.gsub(GetCVar("realmName"), "^%s*", "")"#)
.unwrap();
assert_eq!(trimmed, "Archimonde");
}
}
+86 -21
View File
@@ -144,27 +144,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"GetBlinkSpeed",
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.blink_period))?,
)?;
// SetTextColor(r, g, b [, a]) — the FontInstance half an EditBox inherits in the client
// (`ChatEdit_UpdateHeader` colors the typed text this way): tint the box's text region,
// creating it lazily like every text-touching path.
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let Some(rh) = super::ensure_text_region(lua, h) else {
return Err(mlua::Error::runtime("not an EditBox"));
};
lua.app_data_mut::<Model>()
.expect("model app_data")
.region_data
.entry(rh)
.or_default()
.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
Ok(())
},
)?,
)?;
m.set(
"SetTextInsets",
lua.create_function(
@@ -229,10 +208,96 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
install_font_block(lua, &m)?;
lua.set_named_registry_value(REG_EDITBOX_METHODS, m)?;
Ok(())
}
/// **The font block — entries #0–#15 of the EditBox method table**, and the largest single gap the
/// per-kind widget-method census found in the 218-addon corpus (decision 1229).
///
/// `EditBox` is one of the six text-bearing types, so it re-declares the whole font block in its own
/// flat table (`.data 0x87bb68`, 48 entries, count read from `mov edx,0x30` at `0x799ab5`; there is
/// no `FontInstance` class in the 1.12 Lua chain). We had written every one of these verbs already —
/// on `FontString` and on the `<Font>` object — and never wired them to the kind that wanted them.
/// The census row is `63 EditBox:SetFontObject (on Texture, FontString)`, and its `(on …)` tail is
/// exactly that: *a verb we have, on the wrong kinds*.
///
/// **The 63 is one library, not 63 independent addons.** Every one of those call sites is the same
/// three lines of an embedded `Dewdrop-2.0.lua` —
/// ```lua
/// local editBox = CreateFrame("EditBox", nil, editBoxFrame)
/// editBoxFrame.editBox = editBox
/// editBox:SetFontObject(ChatFontNormal)
/// ```
/// — vendored into 63 addon folders (65 copies of the file: `FuBar` and its ~50 plugins, `BigWigs`,
/// `AtlasLoot`, `oRA2`, …). One library replicated, so 63 chances to hit the *same* next wall
/// (decision 1207).
///
/// Ten of the sixteen are the shared block and come from [`super::super::font_block`], which carries
/// the per-verb byte evidence and the return-shape traps. Two are deliberately absent and four are
/// installed here:
///
/// - **`SetSpacing`/`GetSpacing` (#10–#11) are NOT installed.** Nothing in this client models line
/// spacing, so they could only store a number no renderer reads — the silently-ignored-setter
/// failure of 1203/1205/1211 — while their absence raises a nil-value call that names itself.
/// Corpus demand is zero: `:SetSpacing(`/`:GetSpacing(` appear in **0** of 218 addons.
/// `script::font` withholds the same pair for the same reason.
/// - **The justify four (#12–#15) are installed here**, against the box's own
/// [`EditBoxState::justify`] word rather than its text region — the field's doc has the why (our
/// editbox draw law seats that region LEFT unconditionally) and states the divergence.
fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Every font method acts on the box's implicit FontString: each binding's shim loads
// `[this+0x324]` and hands it to the shared implementation, and that offset is
// `EditBoxState::text_region`. Created on demand, exactly like every other text-touching path.
crate::script::font_block::install(lua, m, |lua, this| {
let h = frame_handle_of(lua, this)?;
super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox"))
})?;
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") / SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") → 0 values.
//
// Two verified traps, both of which a plausible implementation gets wrong, and both of which
// our older FontString/Font copies of these verbs do get wrong (they coerce anything unknown to
// CENTER/MIDDLE — noted, not fixed here):
// · an **unrecognised token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`), rather
// than falling back to a default;
// · a token from the **other axis** parses fine and then masks to nothing — `SetJustifyH("TOP")`
// yields 0x08, `0x08 & 0x07 == 0`, so it CLEARS justifyH and `GetJustifyH()` answers
// `"UNKNOWN"`. No error either way.
//
// `AceGUIWidget-Slider.lua:210` (`editbox:SetJustifyH("CENTER")`, three addons) is the demand.
for (name, mask) in [
("SetJustifyH", EditBoxState::JUSTIFY_H_MASK),
("SetJustifyV", EditBoxState::JUSTIFY_V_MASK),
] {
m.set(
name,
lua.create_function(move |lua, (this, token): (Table, String)| {
let bits = EditBoxState::justify_bit(&token).ok_or_else(|| {
mlua::Error::runtime(format!("Usage: <EditBox>:{name}(\"justify\")"))
})?;
with_editbox(lua, &this, |eb| eb.set_justify_axis(mask, bits))
})?,
)?;
}
// GetJustifyH()/GetJustifyV() → exactly 1 string, the first set bit's token in the reference's
// own table order, or the literal "UNKNOWN".
for (name, mask) in [
("GetJustifyH", EditBoxState::JUSTIFY_H_MASK),
("GetJustifyV", EditBoxState::JUSTIFY_V_MASK),
] {
m.set(
name,
lua.create_function(move |lua, this: Table| {
with_editbox(lua, &this, |eb| eb.justify_token(mask))
})?,
)?;
}
Ok(())
}
/// A `Set<Flag>` name paired with the EditBoxState mutator it drives (Lua truthiness).
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
+125 -5
View File
@@ -5,11 +5,88 @@
use crate::layout::Rect;
use crate::order::{self, ZTarget};
use crate::widget::FrameHandle;
use super::clip::{effective_clip, scroll_clip_sources};
use super::{slider, ExtractedQuad, FontObject, QuadContent, TexCoords, UiScript};
impl UiScript {
/// **Every live draw target in the VM**, whether or not it is currently visible — each frame's
/// own slot followed by its region leaves, in arena order.
///
/// [`Self::extract`] answers *what is being drawn*; this answers *what exists*, and the two
/// together are what let an instrument say **who drew a quad**. Diff this across a load and the
/// new targets are exactly the ones that load created: no name prefixes, no heuristics, and no
/// dependence on an addon naming anything at all. That is the oracle behind the addon
/// harness's render column, and it is written this way because the obvious alternatives are
/// both wrong — a with/without **quad-count diff** reads zero for an addon that *replaces* a
/// window rather than adding one (Bagnon takes the bags over), and a **name-prefix** match
/// cannot see an anonymous frame or a region an addon hangs off one of ours.
///
/// Handles are generational, so a destroyed-and-reused slot can never be mistaken for a
/// survivor of the baseline.
pub fn live_targets(&self) -> Vec<ZTarget> {
let model = self.model_ref();
let mut out = Vec::new();
for (fh, frame) in model.arena.iter_frames() {
out.push(ZTarget::Frame(fh));
out.extend(frame.regions.iter().copied().map(ZTarget::Region));
}
out
}
/// The frame a draw target belongs to — itself for a frame slot, the owner for a region.
///
/// The half of the attribution that separates *created a window of its own* from *painted onto
/// one of ours*: a new `Region` whose owner frame is **not** new is an addon hooking an
/// existing frame, which is precisely what `!OmniCC` does to a cooldown and what a check built
/// only on new frames would score as "drew nothing".
pub fn target_frame(&self, target: ZTarget) -> Option<FrameHandle> {
match target {
ZTarget::Frame(fh) => self.model_ref().arena.frame(fh).map(|_| fh),
ZTarget::Region(rh) => self.model_ref().arena.region(rh).map(|r| r.owner),
}
}
/// A frame's parent, or `None` for a top-level frame (or a stale handle).
///
/// The other half of the attribution walk: a frame being **new** does not make it the addon's
/// own window — `!OmniCC` creates a brand-new anonymous frame *parented to one of our action
/// buttons*, and only the chain tells an overlay from a window.
pub fn frame_parent(&self, frame: FrameHandle) -> Option<FrameHandle> {
self.model_ref().arena.frame(frame)?.parent
}
/// A frame's name, or `None` — it is anonymous, or the handle is stale.
///
/// For reporting only. Nothing above depends on a frame being named; this is what turns an
/// attributed handle into a row a human can act on.
pub fn frame_name(&self, frame: FrameHandle) -> Option<String> {
self.model_ref().arena.frame(frame)?.name.clone()
}
/// The nearest **named** frame at or above `target` — its own frame's name, else the closest
/// named ancestor.
///
/// An addon's slot buttons are usually named and its inner art usually is not, so a bare
/// [`Self::frame_name`] would report a hole exactly where the interesting quads are. Walking up
/// answers "which window is this part of", which is the question a report is asking.
pub fn target_owner_name(&self, target: ZTarget) -> Option<String> {
let model = self.model_ref();
let mut frame = match target {
ZTarget::Frame(fh) => Some(fh),
ZTarget::Region(rh) => Some(model.arena.region(rh)?.owner),
};
while let Some(fh) = frame {
let f = model.arena.frame(fh)?;
if let Some(name) = &f.name {
return Some(name.clone());
}
frame = f.parent;
}
None
}
/// The render list in the client's painter order (decision 0068): the visible-tree
/// [`crate::order::traversal`] zipped with the resolved rects and region visuals. Already sorted
/// ascending by `ZKey`. Call [`UiScript::resolve`] first for populated rects.
@@ -100,6 +177,9 @@ impl UiScript {
// per-state label font swap (UIPanelButtonTemplate's gold/white/gray trio).
let mut state_font: Option<&FontObject> = None;
let mut state_color: Option<[f32; 4]> = None;
// `Button:SetFont` — the face/size/flags written on the button's own embedded
// fonts rather than on any object they inherit.
let mut button_font: Option<&crate::widget::ButtonFont> = None;
if let Some(crate::widget::KindState::Button(bs)) =
owner_frame.map(|f| &f.kind_state)
{
@@ -120,6 +200,7 @@ impl UiScript {
bs.normal_font.as_ref()
};
state_font = name.and_then(|n| model.font_objects.get(n));
button_font = bs.font.as_ref();
// The per-state color override (Button:Set*TextColor) — hover falls
// back to the normal color, mirroring the font fallback above.
state_color = if !bs.enabled {
@@ -147,10 +228,24 @@ impl UiScript {
// FONTINSTANCE+0x38, color bit 0x404).
data.font_path = fo.font.clone().or(data.font_path);
data.font_height = fo.height.or(data.font_height);
data.font_shadow = fo.shadow.or(data.font_shadow);
// Same severance as the colour below: a region that called
// `SetShadowColor`/`SetShadowOffset` keeps its own, or the font object it
// inherits would silently overwrite the value the addon just set.
if !data.font_explicit.shadow {
data.font_shadow = fo.shadow.or(data.font_shadow);
}
data.outline = fo.outline;
if data.vertex_color.is_none() {
data.vertex_color = fo.color;
// Test the severance MASK, which is what the sentence above claims and what
// wow-re pinned, not `vertex_color.is_none()`. The nil-check was an
// equivalent proxy for exactly as long as an explicit `SetTextColor` was the
// only way a button label's colour could be populated at all; the moment
// `SetTextFontObject` began linking the label to its font object (so
// `GetFont`/`GetFontObject` stop answering nil), a label carried the NORMAL
// object's colour and the disabled state's gray silently stopped applying.
// Caught by `button_label_repaints_by_state_font_object`, which is the test
// that exists for this.
if !data.font_explicit.color {
data.vertex_color = fo.color.or(data.vertex_color);
}
// The object's own justify (`<NormalFont inherits=… justifyH="LEFT"/>` —
// how the ref left-aligns a ButtonText).
@@ -161,6 +256,23 @@ impl UiScript {
data.justify_v = j;
}
}
// `Button:SetFont` sits BETWEEN the two: it is a local set on the button's own
// embedded font, so it outranks the font object that font inherits (a locally
// set axis severs inheritance and is never restored — wow-re
// `font-object-lua-surface.md`), but it loses to a face the label FontString
// set for *itself*, which severs one level further down. That is what
// `font_explicit` is, so it is the gate here too.
if let Some(bf) = button_font {
if !data.font_explicit.face {
data.font_path = Some(bf.path.clone());
}
if !data.font_explicit.height {
data.font_height = Some(bf.height);
}
if !data.font_explicit.outline {
data.outline = super::Outline::flags(&bf.flags);
}
}
// The button-level state color wins over the font object AND the region's own
// explicit color — the client's Button color slots repaint the label outright.
if let Some(c) = state_color {
@@ -216,11 +328,19 @@ impl UiScript {
// outlives the art it was tinting") and used to leak out of here as a
// solid plate the moment the art was cleared (the white action buttons on
// a character switch; the 2026-07-10 grey wells were the same class).
let has_texture = data.texture.is_some() || data.fill.is_some();
// A GRADIENT is drawable content in its own right — the client generates it
// into the same texture slot the colour form of `SetTexture` fills, so a
// region carrying only a gradient still paints. Folded to its midpoint here
// because a quad carries ONE tint; the whole gradient stays on the region
// (`RegionData::gradient`) so a renderer that grows a second stop needs no
// API change. The approximation is visible, and it is stated there and here
// rather than discovered later.
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
let has_texture = data.texture.is_some() || fill.is_some();
QuadContent::Texture {
path: data.texture,
color: has_texture
.then(|| texture_color(data.fill, data.vertex_color))
.then(|| texture_color(fill, data.vertex_color))
.flatten(),
additive: data.additive,
tex_coords: data.tex_coords,
+4 -1
View File
@@ -345,7 +345,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
fo.height = Some(h);
}
if let Some(f) = flags {
fo.outline = Outline::parse(&f);
// The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML
// attribute's ("NORMAL"/"THICK") — this read the XML one, so every
// `GameFontNormal:SetFont(f, h, "OUTLINE")` silently cleared the outline.
fo.outline = Outline::flags(&f);
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
+351
View File
@@ -0,0 +1,351 @@
//! **The font block** — the ten font methods a text-bearing widget re-declares in its own method
//! table, implemented once over the [`RegionData`](super::RegionData) the glyphs actually paint
//! from, and installed onto any widget's table by [`install`].
//!
//! ## Why this is a module and not a base class
//!
//! **There is no `FontInstance` class in the 1.12.1 Lua chain.** wow-re's registrar map
//! (`system/ui/scratch/widget-api-batch-benilla.md`, and the per-table carve in
//! `font-object-lua-surface.md` §2) shows 23 flat `{const char* name, void* fn}` `.data` tables and
//! a per-class `vtable[+0x8]` lookup that tail-calls **exactly one** base class's lookup on a miss.
//! The six text-bearing types each *re-declare* the same font names in their own flat table; the
//! sharing happens one level **down**, in C++, where every binding is a thin type-guard shim that
//! tail-calls one shared implementation (`0x79f210` SetFont · `0x79f3b0` GetFont · `0x79f4d0` /
//! `0x79f680` Set/GetTextColor · `0x79f730` / `0x79f910` Set/GetShadowColor · `0x79f9c0` /
//! `0x79fad0` Set/GetShadowOffset · `0x79ef10` / `0x79f090` Set/GetFontObject).
//!
//! So: **which font methods a type has is a per-table fact, never derivable from "it draws text"** —
//! and this module is that shared C++ layer, with each widget's own table naming the subset it
//! really carries. Adding a name a table does not carry is exactly as wrong as missing one.
//!
//! ## The membership, read off the binary rather than assumed
//!
//! `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at
//! the registering site `0x799ab5`, never from a run-length scan (adjacent tables are contiguous and
//! a scan merges them into a bogus blob). Its **first sixteen entries are the font block**, in this
//! order, and each one is a shim that `call`s the shared implementation and returns **without
//! touching `eax`** — so every return arity below is the shared implementation's, verbatim:
//!
//! | # | EditBox entry | binding | tail-calls | returns |
//! |---|---|---|---|---|
//! | 0 | `SetFontObject` | `0x797090` | `0x79ef10` | **0 values** |
//! | 1 | `GetFontObject` | `0x797150` | `0x79f090` | **1 value** (the handle, or nil) |
//! | 2 | `SetFont` | `0x797210` | `0x79f210` | **1 value: the NUMBER 1, or nil** |
//! | 3 | `GetFont` | `0x7972d0` | `0x79f3b0` | **3 values** (path, height, flags) |
//! | 4 | `SetTextColor` | `0x797390` | `0x79f4d0` | **0 values** |
//! | 5 | `GetTextColor` | `0x797450` | `0x79f680` | **4 values** |
//! | 6 | `SetShadowColor` | `0x797510` | `0x79f730` | **0 values** |
//! | 7 | `GetShadowColor` | `0x7975d0` | `0x79f910` | **4 values** (`0x79f9b3 mov eax,0x4`) |
//! | 8 | `SetShadowOffset` | `0x797690` | `0x79f9c0` | **0 values** |
//! | 9 | `GetShadowOffset` | `0x797750` | `0x79fad0` | **2 values** |
//! | 10–11 | `Set/GetSpacing` | `0x797810` / `0x7978d0` | `0x79fb40` / `0x79fbe0` | *not installed — below* |
//! | 12–15 | `Set/GetJustifyH`, `Set/GetJustifyV` | `0x797990`… | `0x79fc20`… | *EditBox-side — below* |
//!
//! **`SetFont` is the trap here, and it points the opposite way to the one this codebase already
//! pinned.** `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** — that
//! divergence has its own test. `EditBox:SetFont` does **not**: `0x7972b2 call 0x79f210` is followed
//! by `pop edi; pop esi; pop ebx; ret` with no `eax` write, so the shared implementation's single
//! value (`lua_pushnumber(1.0)` at `0x79f345`, or `lua_pushnil` at `0x79f361`) passes straight
//! through. It is the number `1`, not `true`, and not zero values.
//!
//! The table above was read off the PE `.data` section directly (with Font's 22-entry table as a
//! positive control) and **independently re-derived by a wow-re §5 trio**, which agrees on all 48
//! names, all 48 binding addresses and all 16 arities —
//! `system/ui/scratch/editbox-font-surface.md`. Its own control for the "nothing is discarded"
//! claim is `SetMultiLine`'s binding, which *does* emit `xor eax,eax` (`0x797e9d`) and returns 0.
//! Entry 47 ends at `0x87bce8`, the first byte of the string `"GetAltArrowKeyMode"` — the table
//! abuts its own string pool, which pins the count at 48 a second way.
//!
//! **A stated divergence, verified and deliberately left consistent rather than fixed on one table:**
//! the reference's `SetTextColor` and `SetShadowColor` read r, g and b with a **bare `lua_tonumber`**
//! (a non-number silently becomes `0.0`) and carry **no usage string at all**, so they *cannot
//! raise* — `SetTextColor()` sets opaque black. Ours take `f32`, so a missing or non-numeric channel
//! errors instead. Alpha matches (`lua_isnumber`-gated, default 1.0). `script::region`'s and
//! `script::font`'s copies are strict in the same way, so correcting only this one would put two
//! answers in the codebase for one contract; it belongs with the collapse below. No corpus site is
//! known to depend on it.
//!
//! ## Where it lands, and why that is the mechanism rather than an approximation
//!
//! Each shim loads **`[this+0x324]`** and hands it to the shared implementation as the target. On a
//! `CSimpleEditBox` that offset is the box's implicit FontString — the same field
//! [`EditBoxState::text_region`](crate::widget::EditBoxState::text_region) models (RF-0082, and it is
//! the EditBox's analogue of `ButtonText`). So `editBox:SetFontObject(ChatFontNormal)` really does
//! paint *the box's font string*, which is exactly what this module writes: resolve the widget to
//! the region its glyphs come from, then set the same [`RegionData`](super::RegionData) fields a
//! `FontString` sets for itself.
//!
//! ## Not installed here, and the reason is not laziness
//!
//! - **`SetSpacing` / `GetSpacing`.** We model no line spacing anywhere — neither `RegionData` nor
//! the text layout carries the field, and the line pitch is the font height — so a setter here
//! could only store a number nobody draws. That is the silently-ignored-setter failure this
//! codebase keeps being bitten by (1203, 1205, 1211), and the alternative failure is the good one:
//! a missing method raises `attempt to call method 'SetSpacing' (a nil value)`, which names itself.
//! Demand is **zero** — `grep -r ':SetSpacing(\|:GetSpacing('` over the 218-addon corpus finds
//! nothing at all. `script::font`'s own table withholds the pair for the same reason; when spacing
//! becomes something the renderer honours, both land together.
//! - **The justify four.** They are real EditBox entries and they *are* wired — but on the EditBox's
//! own state rather than on its text region, because our EditBox draw law forces the region's
//! justification (see `script::editbox::methods`). They live there for that reason, not here.
//!
//! ## The one duplicate this leaves, named rather than left to be found
//!
//! `script::region`'s FontString table still carries its own hand-written copies of these ten. They
//! are the same contract over the same fields and should collapse onto this module — one
//! `font_block::install(lua, &m, region_handle_of)` — when `region.rs` is split (it is over the
//! file-size budget, which is why that half is not done here). The collapse carries one correction
//! with it: that copy's `SetFont` answers the **boolean `true`**, where the bytes above say the
//! number `1` or nil.
use mlua::{Lua, Table, Value};
use super::object::as_f32;
use super::{FontShadow, Model, Outline};
use crate::widget::RegionHandle;
/// How a widget's method table resolves `this` to the region its glyphs are painted from.
///
/// A `FontString` *is* that region; an `EditBox` owns one and creates it on demand. A resolver
/// returning an error is how a method called on the wrong receiver reports it.
pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// Install the ten shared font-block methods onto `m`, reading `this` through `resolve`.
///
/// The caller names the subset: this installs exactly the ten the module doc tabulates, and a
/// widget whose real table lacks one of them must not use this installer for it.
pub(super) fn install(lua: &Lua, m: &Table, resolve: ResolveRegion) -> mlua::Result<()> {
// ── the font object, and the live link to it ────────────────────────────────────────────
// SetFontObject(font | "font" | nil) → 0 values. All three argument forms the reference's own
// usage string names (`.rdata 0x87c5cc`: `Usage: %s:SetFontObject(font or "font" or nil)`).
// `Dewdrop-2.0.lua:1675` — `editBox:SetFontObject(ChatFontNormal)`, two lines after
// `CreateFrame("EditBox", nil, editBoxFrame)` — passes the OBJECT, and that one line is
// replicated into 63 of the corpus's 218 addons.
//
// A frame, a number, or an unknown name is an ERROR, never a silent no-op: every rejection in
// the reference is `luaL_error 0x6f4940`, which longjmps and aborts the call (1203/1205/1211's
// silent-drop class).
m.set(
"SetFontObject",
lua.create_function(move |lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetFontObject", &font)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// The nil form severs the link and leaves the paint standing — the reference stores a
// null parent, and nothing re-reads or clears the resolved values.
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
};
let Some(fo) = model.font_objects.get(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name);
// The severance mask is deliberately NOT reset. §5-verified: the real "stop inheriting
// this property" signal is a CLEARED bit in the inheritMask (`FONTINSTANCE+0x2c`),
// cleared by each local setter and never restored — so a property the widget set for
// itself stays severed across a later `SetFontObject`.
super::font::repaint(d, &fo);
Ok(())
})?,
)?;
// GetFontObject() → exactly 1 value: the font OBJECT last resolved, or nil. The object, never
// its name — `Dewdrop-2.0.lua:2181` indexes the result immediately.
m.set(
"GetFontObject",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.region_data
.get(&rh)
.and_then(|d| d.font_object.clone())
.filter(|n| model.font_objects.contains_key(n))
};
match name {
Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── the face ────────────────────────────────────────────────────────────────────────────
// SetFont(path, height [, flags]) → **the NUMBER 1, or nil** — one value either way, never a
// boolean and never zero values (the module doc's table; `0x7972b2` does not clobber `eax` the
// way `Button:SetFont` does). The shared impl gates arg2 on `lua_isstring` and arg3 on
// `lua_isnumber`, raising `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`)
// otherwise — both accept a numeric string, since `lua_isstring`/`lua_isnumber` coerce.
//
// nil is the *load-failure* answer, not an argument error: `!OmniCC/main.lua:41`'s
// `if not Font:SetFont(saved, size) then revert end` is a font-file validity probe, so an empty
// path must come back falsey rather than raising.
m.set(
"SetFont",
lua.create_function(
move |lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <EditBox>: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 rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
// Every argument supplied is an EXPLICIT set: it must survive a later mutation of
// the font object this region inherits.
let ok = !path.is_empty();
if ok {
d.font_path = Some(path);
d.font_explicit.face = true;
}
d.font_height = Some(height);
d.font_explicit.height = true;
if let Some(f) = flags {
// The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML attribute's
// ("NORMAL"/"THICK") — the shared parse `0x6f1a90` is a case-insensitive
// SUBSTRING scan, which is why "THICKOUTLINE" yields both bits.
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
},
)?,
)?;
// GetFont() → 3 values: path, height, flagsString (`mov eax,3` at `0x79f407`). The flags string
// is `""` when there are none — built into a zeroed static buffer at `0xceea60` — never nil.
m.set(
"GetFont",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
let path = match d.and_then(|d| d.font_path.clone()) {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
let height = d.and_then(|d| d.font_height);
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
Ok((path, height, flags))
})?,
)?;
// ── the text colour ─────────────────────────────────────────────────────────────────────
// SetTextColor(r, g, b [, a]) → 0 values; alpha defaults to 1.0 (`lua_isnumber(L,5)`-gated,
// `0x3f800000`). A FontString has no texel of its own, so its vertex colour IS the colour it
// draws — the same `+0xb8` slot `SetVertexColor` writes.
m.set(
"SetTextColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
// The shared impl clears the inheritMask's colour bit on whatever instance it ran
// on (`0x79dbd0`'s `and [edi+0xd4],0xfffffffb`), so an explicit colour survives a
// later repaint from the font object.
d.font_explicit.color = true;
Ok(())
},
)?,
)?;
// GetTextColor() → 4 values, r, g, b, a. Never set = the white every region draws at.
m.set(
"GetTextColor",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.vertex_color)
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// ── the shadow ──────────────────────────────────────────────────────────────────────────
// **`GetShadowColor` returns FOUR values, not three** (`0x79f9b3`, `mov eax,0x4`). Three is the
// plausible wrong answer and it silently drops the alpha that
// `FuBar_NavigatorFu/NavigatorFu.lua:31` round-trips. `GetShadowOffset` returns two, in UI
// units (the same units as `SetWidth`/`SetPoint`), not device pixels.
//
// Either half may be set before the other, so each starts from whatever is already there —
// shadow colour and offset share one inherit slot in the reference too.
m.set(
"SetShadowColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
});
d.font_explicit.shadow = true;
Ok(())
},
)?,
)?;
m.set(
"GetShadowColor",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// SetShadowOffset(x, y) → 0 values, and **both arguments are required** — the shared impl
// raises `Usage: %s:SetShadowOffset(x, y)` (`0x87c6e8`) rather than defaulting the missing one.
m.set(
"SetShadowOffset",
lua.create_function(move |lua, (this, x, y): (Table, f32, f32)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
d.font_shadow = Some(FontShadow {
offset: [x, y],
color,
});
d.font_explicit.shadow = true;
Ok(())
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let o = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
Ok(())
}
+19 -216
View File
@@ -5,10 +5,7 @@ use crate::order::ZTarget;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
use super::backdrop;
use super::{
ExtractedQuad, FontShadow, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Model,
Outline, QuadContent, UiScript, SCREEN,
};
use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN};
/// The paint a frame slot's derived quads inherit — its `effective_alpha` and `effective_scale`
/// travelling together (backdrop pieces and message-frame ring lines are emitted BY the frame,
@@ -521,9 +518,24 @@ impl UiScript {
}) else {
continue;
};
let Some(owner_rect) = resolved.get(&owner).copied() else {
continue;
};
// An owner with NO resolved rect does not disqualify its regions. `owner_rect`
// is only the fallback for the axes this region's own anchors do not pin (see the
// two `axis(..)` calls below) — a region anchored fully to some OTHER frame needs
// nothing from its owner, and the reference resolves it.
//
// Skipping here made a whole shape silently invisible: a bare container frame
// (`CreateFrame("Frame", n, UIParent)` with no size and no SetPoint) holding a
// region anchored elsewhere. That is ordinary addon code — MapCoords builds three
// of them, and its world-map coordinate readout computed the right string every
// frame and was never positioned, with no error anywhere. Degenerate rather than
// absent: an unpositioned owner contributes a zero rect, which is what an
// unpositioned frame IS.
let owner_rect = resolved.get(&owner).copied().unwrap_or(Rect {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
});
let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0);
// A FontString with no explicit height takes its host-measured wrapped size
// (the measure round-trip — the client's layout↔font-engine seam).
@@ -702,93 +714,6 @@ impl UiScript {
out
}
/// Ring lines whose wrapped **row count** needs a host measurement (new line, or the frame's
/// width/font changed under it) — the message-frame half of the measure round-trip. Call after
/// [`UiScript::resolve`] (widths must be resolved); answer with
/// [`UiScript::set_message_line_rows`] before extract. Cache keys keep this empty on quiet
/// frames.
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
use std::hash::{Hash, Hasher};
let mut model = self.model_mut();
let mut out = Vec::new();
let frames: Vec<(FrameHandle, Rect)> = model
.resolved
.iter()
.filter(|(&fh, _)| {
model.arena.frame(fh).is_some_and(|f| {
matches!(f.kind_state, KindState::ScrollingMessage(_)) && f.effective_visible
})
})
.map(|(&fh, &fr)| (fh, fr))
.collect();
for (fh, fr) in frames {
let wrap_width = fr.right - fr.left;
if wrap_width <= 1.0 {
continue; // unresolved/degenerate width — nothing meaningful to wrap against
}
let scale = model
.arena
.frame(fh)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let (font, height, _, outline) = Self::message_frame_font(&model, fh);
let frame_id = model.frame_id(fh);
let Some(frame) = model.arena.frame(fh) else {
continue;
};
let KindState::ScrollingMessage(smf) = &frame.kind_state else {
continue;
};
for (index, line) in smf.lines.iter().enumerate() {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.text.hash(&mut hasher);
font.hash(&mut hasher);
height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(outline as u8).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
continue;
}
out.push(LineMeasureRequest {
frame: frame_id,
index: index as u32,
font: font.clone(),
height,
wrap_width,
outline,
scale,
text: line.text.clone(),
key,
});
}
}
out
}
/// Store host row-count answers for [`LineMeasureRequest`]s (`(frame, index, rows, key)` —
/// frame/index/key verbatim from the request). The key is stored beside the rows, so a line
/// whose width/font changed again since the request simply re-requests next frame.
pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) {
let mut model = self.model_mut();
for &(frame_id, index, n, key) in rows {
let Some(&fh) = model.id_to_frame.get(&frame_id) else {
continue;
};
let Some(frame) = model.arena.frame_mut(fh) else {
continue;
};
let KindState::ScrollingMessage(smf) = &mut frame.kind_state else {
continue;
};
if let Some(line) = smf.lines.get_mut(index as usize) {
line.rows = n.max(1);
line.rows_key = key;
}
}
}
/// Push one [`QuadContent::Backdrop`] per piece of frame `fh`'s installed backdrop (no-op if the
/// frame has none). Pieces carry the frame slot's `z` — behind the frame's regions (which sort
/// after it) and, among themselves, bg-then-border in paint order (the stable z-sort keeps the
@@ -846,126 +771,4 @@ impl UiScript {
});
}
}
/// Push one [`QuadContent::Text`] per visible message of a ScrollingMessageFrame `fh` (no-op for
/// any other kind). Messages stack bottom-up from `fr`'s bottom edge; each occupies
/// `rows × pitch` (its host-measured wrapped row count — the message-line measure round-trip),
/// so a long line pushes everything above it up by its real height. The pitch is the **font
/// height itself** — the client's own line-step law (`LayoutLines` 0x5cdc20: step =
/// px(size) + spacing, spacing 0), the same law the app's text renderer lays wrapped rows at,
/// so band grid and glyph rows coincide by construction. (The msgframe's own relayout
/// `0x788750/0x788c00` is only partially read in wow-re — if the look pass ever shows the ref
/// spacing chat lines wider than the font height, that residual is the place to pin.)
///
/// `scroll_offset` picks which message sits at the bottom. A message that only partially fits
/// at the frame's top still draws — clipped to the frame rect, so its lower wrapped rows show
/// (a reader mid-scrollback), never ink outside the frame. A fully-faded line draws nothing but
/// its rows still hold their place: the reference's chat never re-packs as old lines fade.
pub(super) fn emit_message_lines(
model: &Model,
fh: FrameHandle,
fr: Rect,
z: u64,
paint: FramePaint,
clip: Option<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
let Some(frame) = model.arena.frame(fh) else {
return;
};
let crate::widget::KindState::ScrollingMessage(smf) = &frame.kind_state else {
return;
};
if smf.lines.is_empty() {
return;
}
let (font, font_height, font_shadow, outline) = Self::message_frame_font(model, fh);
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
// rides the frame scale exactly like the glyphs it must coincide with (the font height
// itself is frame-local).
let pitch = font_height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
return;
}
// Message text never inks outside its frame: a partially-fitting top message is scissored
// at the frame edge (intersected with any ScrollFrame ancestor clip).
let line_clip = Some(match clip {
Some(c) => super::clip::intersect_rect(c, fr),
None => fr,
});
// The newest message the view shows sits `scroll_offset` up from the ring's back.
let top_index = smf.lines.len().saturating_sub(1 + smf.scroll_offset);
let mut used = 0.0f32; // rows already consumed, in px up from fr.bottom
for idx in (0..=top_index).rev() {
if used >= fr.top - fr.bottom {
break; // the next band would start above the frame — nothing more can show
}
let line = &smf.lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let bottom = fr.bottom + used;
used += band_h;
if line.alpha <= 0.0 {
continue; // fully faded — holds its place, draws nothing
}
let band = Rect::new(bottom, fr.left, bottom + band_h, fr.right);
out.push(ExtractedQuad {
target: ZTarget::Frame(fh),
z,
rect: Some(band),
alpha: paint.alpha,
clip: line_clip,
content: QuadContent::Text {
text: Some(line.text.clone()),
color: Some([
f32::from(line.color[0]) / 255.0,
f32::from(line.color[1]) / 255.0,
f32::from(line.color[2]) / 255.0,
line.alpha,
]),
justify_h: JustifyH::Left,
// The band is our own per-message construct (rows × pitch tall, not a
// FontString rect) — keep the block at its top so the band math, not MIDDLE
// centering, owns the vertical rhythm; the band height equals the wrapped
// block height exactly, so Top means "fills the band".
justify_v: JustifyV::Top,
font: font.clone(),
// The chat font object's own drop shadow (ChatFontNormal's `(1,-1)` black) — what
// makes the lines read against the world behind the transparent frame.
shadow: font_shadow,
font_height,
text_height: None, // message lines are never SetTextHeight'd
outline,
alpha_gradient: None,
},
scale: paint.scale,
});
}
}
/// The font a ScrollingMessageFrame's ring lines draw with: its declared `<FontString>` child
/// (ChatFontNormal for the chat window). Shared by [`Self::emit_message_lines`] and the
/// message-line measure round-trip so bands, measures, and glyphs agree. Missing ⇒ the
/// renderer's default face at the ~14px chat default.
pub(super) fn message_frame_font(
model: &Model,
fh: FrameHandle,
) -> (Option<String>, Option<f32>, Option<FontShadow>, Outline) {
model
.arena
.frame(fh)
.and_then(|frame| {
frame
.regions
.iter()
.find(|&&rh| {
matches!(
model.arena.region(rh).map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
)
})
.and_then(|rh| model.region_data.get(rh))
.map(|d| (d.font_path.clone(), d.font_height, d.font_shadow, d.outline))
})
.unwrap_or((None, None, None, Outline::default()))
}
}
@@ -0,0 +1,317 @@
//! The two message-frame widget classes, and the display machinery they share.
//!
//! 1.12 ships **two** of them, and msgframe-runtime.md is emphatic that they are *siblings, not
//! parent and child* (different ctors, different vtables, different Lua type tags, offsets that do
//! not transfer):
//!
//! - [`scrolling`] — `CSimpleMessageScrollFrame` (ctor `0x787670`), the chat window's class: a true
//! ring of `maxLines`, a scrollback cursor, `AddMessage(text[,r,g,b[,id]])` with alpha forced
//! opaque.
//! - [`plain`] — `CSimpleMessageFrame` (ctor `0x785640`), `UIErrorsFrame`'s class: no ring, no
//! scrollback, `insertMode`, and `AddMessage(text[,r,g,b[,a]])` whose fourth numeric is a real
//! **alpha**.
//!
//! Each gets its **own** Lua method table, consulted by the frame `__index` dispatcher only for its
//! own [`FrameKind`](crate::widget::FrameKind) — so a duck-typing addon (`if f.SetInsertMode then`)
//! sees `nil` everywhere else, exactly as against the client's per-class registrar tables.
//!
//! What *is* shared sits here: both classes display a stack of [`MessageLine`] records, so the
//! wrapped-row measure round-trip, the band emit, and the font/justification read are one code path
//! over [`KindState::message_lines`]. Everything that actually differs stays on the two states.
use crate::layout::Rect;
use crate::order::ZTarget;
use crate::widget::{FrameHandle, InsertMode, KindState, MessageLine};
use super::layout::FramePaint;
use super::{
ExtractedQuad, FontShadow, JustifyH, JustifyV, LineMeasureRequest, Model, Outline, QuadContent,
UiScript,
};
mod plain;
mod scrolling;
pub(super) use plain::REG_MESSAGEFRAME_METHODS;
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
/// Install both classes' method tables (and the chat input globals the scrolling one carries).
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
scrolling::install(lua)?;
plain::install(lua)
}
/// The font a message frame's lines draw with — its declared `<FontString>` child, which is exactly
/// how both reference frames spell it (`<FontString inherits="ChatFontNormal" justifyH="LEFT"/>` on
/// a chat frame, `<FontString inherits="ErrorFont" justifyH="CENTER"/>` on `UIErrorsFrame.xml`).
/// Shared by the emit and the measure round-trip so bands, measures and glyphs agree. Missing ⇒ the
/// renderer's default face at the ~14px chat default.
#[derive(Clone)]
pub(super) struct MessageFont {
pub(super) path: Option<String>,
pub(super) height: Option<f32>,
pub(super) shadow: Option<FontShadow>,
pub(super) outline: Outline,
/// The child's `justifyH`. **Load-bearing, not decoration**: it is the only reason the
/// reference's error toasts sit centred in their 512-wide frame while chat lines run flush
/// left, and the class draws its lines through that font instance. A frame with no declared
/// FontString falls back to LEFT (the chat shape), never to the FontString default of CENTER —
/// there is no region here whose own default could apply.
pub(super) justify_h: JustifyH,
}
impl UiScript {
pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont {
model
.arena
.frame(fh)
.and_then(|frame| {
frame
.regions
.iter()
.find(|&&rh| {
matches!(
model.arena.region(rh).map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
)
})
.and_then(|rh| model.region_data.get(rh))
.map(|d| MessageFont {
path: d.font_path.clone(),
height: d.font_height,
shadow: d.font_shadow,
outline: d.outline,
justify_h: d.justify_h,
})
})
.unwrap_or(MessageFont {
path: None,
height: None,
shadow: None,
outline: Outline::default(),
justify_h: JustifyH::Left,
})
}
/// How many display **rows** frame `fh`'s resolved rect holds at its line font's pitch — the
/// client's layout-derived `numLinesDisplayed`. `0` when the frame has no resolved rect.
///
/// Both classes need it and neither can compute it from its own state: it is the page size for
/// the scrolling class's `PageUp`/`PageDown` and the whole capacity law for the plain one.
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
let pitch = Self::message_frame_font(model, fh)
.height
.unwrap_or(14.0)
.max(1.0);
model.resolved.get(&fh).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
})
}
/// Message lines whose wrapped **row count** needs a host measurement (a new line, or the
/// frame's width/font changed under it) — the message-frame half of the measure round-trip.
/// Call after [`UiScript::resolve`] (widths must be resolved); answer with
/// [`UiScript::set_message_line_rows`] before extract. Cache keys keep this empty on quiet
/// frames.
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
use std::hash::{Hash, Hasher};
let mut model = self.model_mut();
let mut out = Vec::new();
let frames: Vec<(FrameHandle, Rect)> = model
.resolved
.iter()
.filter(|(&fh, _)| {
model
.arena
.frame(fh)
.is_some_and(|f| f.kind_state.message_lines().is_some() && f.effective_visible)
})
.map(|(&fh, &fr)| (fh, fr))
.collect();
for (fh, fr) in frames {
let wrap_width = fr.right - fr.left;
if wrap_width <= 1.0 {
continue; // unresolved/degenerate width — nothing meaningful to wrap against
}
let scale = model
.arena
.frame(fh)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let font = Self::message_frame_font(&model, fh);
let frame_id = model.frame_id(fh);
let Some(frame) = model.arena.frame(fh) else {
continue;
};
let Some(lines) = frame.kind_state.message_lines() else {
continue;
};
for (index, line) in lines.iter().enumerate() {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.text.hash(&mut hasher);
font.path.hash(&mut hasher);
font.height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(font.outline as u8).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
continue;
}
out.push(LineMeasureRequest {
frame: frame_id,
index: index as u32,
font: font.path.clone(),
height: font.height,
wrap_width,
outline: font.outline,
scale,
text: line.text.clone(),
key,
});
}
}
out
}
/// Store host row-count answers for [`LineMeasureRequest`]s (`(frame, index, rows, key)` —
/// frame/index/key verbatim from the request). The key is stored beside the rows, so a line
/// whose width/font changed again since the request simply re-requests next frame.
pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) {
let mut model = self.model_mut();
for &(frame_id, index, n, key) in rows {
let Some(&fh) = model.id_to_frame.get(&frame_id) else {
continue;
};
let Some(frame) = model.arena.frame_mut(fh) else {
continue;
};
let Some(lines) = frame.kind_state.message_lines_mut() else {
continue;
};
if let Some(line) = lines.get_mut(index as usize) {
line.rows = n.max(1);
line.rows_key = key;
}
}
}
/// Push one [`QuadContent::Text`] per visible message of either message-frame class (no-op for
/// any other kind). Each message occupies `rows × pitch` (its host-measured wrapped row count —
/// the message-line measure round-trip), so a long line pushes its neighbours by its real
/// height. The pitch is the **font height itself** — the client's own line-step law
/// (`LayoutLines` 0x5cdc20: step = px(size) + spacing, spacing 0), the same law the app's text
/// renderer lays wrapped rows at, so band grid and glyph rows coincide by construction. (The
/// msgframe's own relayout `0x788750/0x788c00` is only partially read in wow-re — if the look
/// pass ever shows the ref spacing chat lines wider than the font height, that residual is the
/// place to pin.)
///
/// **Which edge the stack grows from is the one place the two classes diverge visibly.** A
/// ScrollingMessageFrame always stacks bottom-up with `scroll_offset` picking which message
/// sits on the bottom row. A MessageFrame has no scrollback and instead honours `insertMode`:
/// BOTTOM (the ctor default) is the same bottom-up stack, TOP hangs the newest message off the
/// frame's top edge with older ones stepping *down* — the shape `UIErrorsFrame.xml:4` asks for,
/// and the one every corpus `SetInsertMode` caller asks for. The growth *anchor* per mode is
/// wow-re's own named residual (it lives in the unwalked wrap/layout pass), so this is the
/// INFERRED half: it is the reading that reproduces both shipped usages, and the place to
/// re-pin if a reference A/B ever disagrees.
///
/// A message that only partially fits at the far edge still draws — clipped to the frame rect,
/// so its inner wrapped rows show, never inking outside the frame. A fully-faded scrolling line
/// draws nothing but still holds its rows (the reference's chat never re-packs as old lines
/// fade); a faded MessageFrame line is gone from the state entirely by then, because that class
/// frees its lines instead of holding slots.
pub(super) fn emit_message_lines(
model: &Model,
fh: FrameHandle,
fr: Rect,
z: u64,
paint: FramePaint,
clip: Option<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
let Some(frame) = model.arena.frame(fh) else {
return;
};
// (lines, index of the message on the anchored row, which edge it hangs off).
let (lines, top_index, from_top): (&std::collections::VecDeque<MessageLine>, usize, bool) =
match &frame.kind_state {
KindState::ScrollingMessage(smf) => (
&smf.lines,
smf.lines.len().saturating_sub(1 + smf.scroll_offset),
false,
),
KindState::Message(mf) => (
&mf.lines,
mf.lines.len().saturating_sub(1),
matches!(mf.insert_mode, InsertMode::Top),
),
_ => return,
};
if lines.is_empty() {
return;
}
let font = Self::message_frame_font(model, fh);
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
// rides the frame scale exactly like the glyphs it must coincide with (the font height
// itself is frame-local).
let pitch = font.height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
return;
}
// Message text never inks outside its frame: a partially-fitting outermost message is
// scissored at the frame edge (intersected with any ScrollFrame ancestor clip).
let line_clip = Some(match clip {
Some(c) => super::clip::intersect_rect(c, fr),
None => fr,
});
let mut used = 0.0f32; // rows already consumed, in px in from the anchored edge
for idx in (0..=top_index).rev() {
if used >= fr.top - fr.bottom {
break; // the next band would start outside the frame — nothing more can show
}
let line = &lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let (bottom, top) = if from_top {
(fr.top - used - band_h, fr.top - used)
} else {
(fr.bottom + used, fr.bottom + used + band_h)
};
used += band_h;
if line.alpha <= 0.0 {
continue; // fully faded — holds its place, draws nothing
}
out.push(ExtractedQuad {
target: ZTarget::Frame(fh),
z,
rect: Some(Rect::new(bottom, fr.left, top, fr.right)),
alpha: paint.alpha,
clip: line_clip,
content: QuadContent::Text {
text: Some(line.text.clone()),
color: Some([
f32::from(line.color[0]) / 255.0,
f32::from(line.color[1]) / 255.0,
f32::from(line.color[2]) / 255.0,
line.alpha,
]),
justify_h: font.justify_h,
// The band is our own per-message construct (rows × pitch tall, not a
// FontString rect) — keep the block at its top so the band math, not MIDDLE
// centering, owns the vertical rhythm; the band height equals the wrapped
// block height exactly, so Top means "fills the band".
justify_v: JustifyV::Top,
font: font.path.clone(),
// The font object's own drop shadow (ChatFontNormal's `(1,-1)` black) — what
// makes the lines read against the world behind the transparent frame.
shadow: font.shadow,
font_height: font.height,
text_height: None, // message lines are never SetTextHeight'd
outline: font.outline,
alpha_gradient: None,
},
scale: paint.scale,
});
}
}
}
@@ -0,0 +1,422 @@
//! The `MessageFrame` method surface — `CSimpleMessageFrame` (ctor `0x785640`), the class
//! `UIErrorsFrame` is and the one `CreateFrame("MessageFrame")` makes.
//!
//! Grounded in wow-re's byte-verified reads (msgframe-runtime.md's §5 pair, re-confirmed at the
//! registrar tables in `widget-api-batch-benilla.md` Q4). Three facts decide this whole file, and
//! each is one an implementation would otherwise get wrong in a way nothing would catch:
//!
//! 1. **`AddMessage(text [, r, g, b [, a]])` — the fifth argument is ALPHA and there is no sixth.**
//! Three corpus addons pass six (`EasyCopy.lua:12`, `QuestHistory.lua:1678`, `QuestItem.lua:300`,
//! all `UIErrorsFrame:AddMessage(msg, r, g, b, a, holdTime)`), and the real 1.12.1 binding
//! `0x795590` reads five and stops. Honouring that trailing number as a per-message hold time
//! would be **unfaithful, not generous** — the client shows the message for the *frame's*
//! `displayDuration` and nothing else. It is ignored here, deliberately.
//! 2. **This `AddMessage` is not the scrolling class's.** `0x792900` takes an **id** in the same
//! slot and forces alpha `0xFF` (`792add: or edi,0xffffff00`); `0x795590` packs a real
//! `0xAARRGGBB` with a default of 1.0. Two bindings, two tables, no sharing.
//! 3. **`SetInsertMode`/`GetInsertMode` (`0x794ed0`/`0x794ff0`) live on this class only** — the
//! scrolling class has neither, and no `insertMode` XML attribute either.
//!
//! The state (display lines, fade phases, the vertical cap) is
//! [`MessageFrameState`](crate::widget::MessageFrameState) in `crate::widget`; this is the thin Lua
//! binding over it. The band emit and the wrapped-row measure round-trip are shared with the
//! scrolling class and live in [`super`].
use mlua::{Lua, Table, Value};
use crate::script::object::frame_handle_of;
use crate::script::Model;
use crate::widget::{InsertMode, KindState, MessageFrameState};
/// Registry key of the MessageFrame method table (the MAXCSTACK discipline: Lua-side root, named
/// key).
pub(crate) const REG_MESSAGEFRAME_METHODS: &str = "__benilla_plain_messageframe_methods";
/// Run `f` over a frame's MessageFrame state under one short write borrow. Errors if `this` is not
/// a live MessageFrame (unreachable through the kind dispatcher, but the method table is a plain
/// Lua value — a caller could fish it out and misapply it).
fn with_mf<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut MessageFrameState) -> 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::Message(mf) => Ok(f(mf)),
_ => Err(mlua::Error::runtime("not a MessageFrame")),
}
}
/// A Lua number-ish → f32 (nil/other → 0.0), for the colour args.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// AddMessage(text [, r, g, b [, a]]) — binding 0x795590.
//
// r/g/b are required **as a trio** (three presence checks ANDed at 0x7956xx); absent ⇒ hard
// 0xFFFFFFFF white. The fourth numeric is a real alpha, `lua_isnumber`-gated with a default of
// 1.0 (`795752: mov [ebp-0x18],0x3f800000`).
//
// **The signature stops there, and the closure's arity is the enforcement.** The corpus's three
// blocked callers all pass a sixth argument they believe is a hold time; mlua hands us the
// first five and drops the rest, exactly as the real binding does by reading five stack slots.
// Adding a `holdTime` parameter here would make benilla show those messages for a duration no
// 1.12 client ever showed them for.
m.set(
"AddMessage",
lua.create_function(
|lua, (this, text, r, g, b, a): (Table, String, Value, Value, Value, Value)| {
let has_rgb = !matches!(
(&r, &g, &b),
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
);
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
} else {
(1.0, 1.0, 1.0)
};
// Alpha only counts when rgb came with it — the client reads arg 5 off the same
// parse that required the trio, and a lone `AddMessage(text, nil, nil, nil, 0.5)`
// has no colour to apply it to.
let a = match (&a, has_rgb) {
(Value::Number(_) | Value::Integer(_), true) => num_f32(&a),
_ => 1.0,
};
with_mf(lua, &this, |mf| mf.add(text, r, g, b, a))
},
)?,
)?;
m.set(
"Clear",
lua.create_function(|lua, this: Table| with_mf(lua, &this, MessageFrameState::clear))?,
)?;
// SetInsertMode("TOP"|"BOTTOM") / GetInsertMode — 0x794ed0 / 0x794ff0, THIS CLASS ONLY. The
// client compares the string against "BOTTOM" (0x871404) and stores `streq-BOTTOM`, so anything
// that is not literally BOTTOM lands on TOP; the ctor default is 1 = BOTTOM.
m.set(
"SetInsertMode",
lua.create_function(|lua, (this, mode): (Table, Value)| {
let mode = match &mode {
Value::String(s) => {
if s.to_str()?.trim().eq_ignore_ascii_case("BOTTOM") {
InsertMode::Bottom
} else {
InsertMode::Top
}
}
// A non-string argument fails the streq and therefore reads as TOP, like the
// client's own comparison against a string it never got.
_ => InsertMode::Top,
};
with_mf(lua, &this, |mf| mf.insert_mode = mode)
})?,
)?;
m.set(
"GetInsertMode",
lua.create_function(|lua, this: Table| {
with_mf(lua, &this, |mf| match mf.insert_mode {
InsertMode::Top => "TOP",
InsertMode::Bottom => "BOTTOM",
})
})?,
)?;
// The fade trio — the same names and the same field the scrolling class exposes (the XML attr
// is `displayDuration`, the Lua accessors call it `TimeVisible`), over this class's own state.
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
with_mf(lua, &this, |mf| mf.fading_enabled = on)
})?,
)?;
m.set(
"GetFading",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fading_enabled))?,
)?;
m.set(
"SetTimeVisible",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_mf(lua, &this, |mf| mf.time_visible = s.max(0.0))
})?,
)?;
m.set(
"GetTimeVisible",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.time_visible))?,
)?;
m.set(
"SetFadeDuration",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_mf(lua, &this, |mf| mf.fade_duration = s.max(0.0))
})?,
)?;
m.set(
"GetFadeDuration",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fade_duration))?,
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// How many display lines a named message frame holds. Read off the state rather than through
/// Lua on purpose: `GetNumMessages` is a **ScrollingMessageFrame** binding, and inventing one
/// on this class to make its tests convenient is exactly the drift these tests are pinning
/// against.
fn num_messages(s: &UiScript, name: &str) -> usize {
let m = s.model_ref();
let h = m.arena.lookup(name).expect("named frame");
m.arena
.frame(h)
.and_then(|f| f.kind_state.message_lines())
.map_or(0, |l| l.len())
}
/// The whole point of the arity, pinned: the sixth argument three corpus addons pass is a hold
/// time the real client never had, and it must change **nothing**. The frame's own
/// `displayDuration` is what governs, so the message with a "hold time" of 999 fades on exactly
/// the same tick as the one without.
#[test]
fn the_sixth_addmessage_argument_is_ignored_not_a_holdtime() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
s.run("MF:SetTimeVisible(1)").unwrap();
s.run("MF:SetFadeDuration(0)").unwrap();
// The exact shape of EasyCopy.lua:12 / QuestItem.lua:300 / QuestHistory.lua:1678.
s.run("MF:AddMessage('held', 1.0, 1.0, 1.0, 1.0, 999)")
.unwrap();
s.run("MF:AddMessage('plain', 1.0, 1.0, 1.0, 1.0)").unwrap();
assert_eq!(num_messages(&s, "MF"), 2);
// Past timeVisible with no ramp: both retire together. A honoured holdTime would keep the
// first alive here, which is exactly the divergence this test exists to catch.
s.tick(1.1);
s.tick(0.1);
assert_eq!(
num_messages(&s, "MF"),
0,
"the 6th arg must not extend a message's life"
);
}
/// `SetInsertMode` is a MessageFrame method and **only** a MessageFrame method — a plain Frame
/// and a ScrollingMessageFrame both resolve it to nil, which is what a duck-typing addon reads.
#[test]
fn set_insert_mode_is_messageframe_only() {
let s = UiScript::new().unwrap();
s.run(
"CreateFrame('MessageFrame', 'MF')\n\
CreateFrame('Frame', 'PlainF')\n\
CreateFrame('ScrollingMessageFrame', 'SMF')",
)
.unwrap();
assert_eq!(
s.eval::<String>("return type(MF.SetInsertMode)").unwrap(),
"function"
);
assert_eq!(
s.eval::<String>("return type(PlainF.SetInsertMode)")
.unwrap(),
"nil",
"a plain Frame must not quack like a MessageFrame"
);
assert_eq!(
s.eval::<String>("return type(SMF.SetInsertMode)").unwrap(),
"nil",
"the scrolling class has no SetInsertMode binding at all"
);
// Default BOTTOM (ctor 1), and the round trip.
assert_eq!(
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
"BOTTOM"
);
s.run("MF:SetInsertMode('TOP')").unwrap();
assert_eq!(
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
"TOP"
);
}
/// The two `AddMessage`s are different functions, and the observable tell is the fifth argument:
/// on a MessageFrame it is alpha (0.5 ⇒ a half-transparent line), on a ScrollingMessageFrame it
/// is an id and the line stays opaque.
#[test]
fn messageframe_addmessage_is_not_the_scrolling_ones() {
let s = UiScript::new().unwrap();
s.run(
"CreateFrame('MessageFrame', 'MF')\n\
CreateFrame('ScrollingMessageFrame', 'SMF')",
)
.unwrap();
assert!(
!s.eval::<bool>("return MF.AddMessage == SMF.AddMessage")
.unwrap(),
"one shared AddMessage would give the wrong meaning to the 5th arg on one of them"
);
s.run("MF:AddMessage('half', 1, 1, 1, 0.5)").unwrap();
s.run("SMF:AddMessage('ident', 1, 1, 1, 42)").unwrap();
let alphas = {
let m = s.model_ref();
let pick = |name: &str| {
let h = m.arena.lookup(name).unwrap();
m.arena
.frame(h)
.unwrap()
.kind_state
.message_lines()
.unwrap()[0]
.alpha
};
(pick("MF"), pick("SMF"))
};
assert!(
(alphas.0 - 128.0 / 255.0).abs() < 1e-6,
"MessageFrame's 5th arg is alpha, quantized round-half-up: {alphas:?}"
);
assert_eq!(
alphas.1, 1.0,
"ScrollingMessageFrame's 5th arg is an id; its alpha is forced opaque"
);
}
/// `insertMode` decides which edge the stack grows from: TOP hangs the newest message off the
/// frame's top, BOTTOM (the default) stacks up from its bottom. Same three messages, mirrored.
#[test]
fn insert_mode_picks_the_growth_edge() {
let bands = |mode: &str| {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'MF')\n\
f:SetPoint('BOTTOMLEFT', 0, 100)\n\
f:SetWidth(400)\n\
f:SetHeight(48)",
)
.unwrap();
s.run(&format!("MF:SetInsertMode('{mode}')")).unwrap();
for t in ["one", "two", "three"] {
s.run(&format!("MF:AddMessage('{t}', 1, 1, 1)")).unwrap();
}
s.resolve();
let mut v: Vec<(String, f32)> = s
.extract()
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
Some((t.clone(), r.bottom))
}
_ => None,
})
.collect();
v.sort_by(|a, b| b.1.total_cmp(&a.1)); // top row first
v
};
// Default pitch 14, frame [100, 148).
let top = bands("TOP");
assert_eq!(
top.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
["three", "two", "one"],
"TOP: newest on the frame's top row, older stepping down"
);
assert!((top[0].1 - 134.0).abs() < 0.01, "newest hangs off fr.top");
let bottom = bands("BOTTOM");
assert_eq!(
bottom.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
["one", "two", "three"],
"BOTTOM: newest on the frame's bottom row, older stepping up"
);
assert!(
(bottom[2].1 - 100.0).abs() < 0.01,
"newest sits on fr.bottom"
);
}
/// The class has no `maxLines` — its cap is what fits vertically, applied at the tick. A frame
/// 3 rows tall holds 3 messages however many arrive.
#[test]
fn the_cap_is_what_fits_vertically() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'MF')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(400)\n\
f:SetHeight(42)\n\
f:SetFading(false)",
)
.unwrap();
for n in 0..9 {
s.run(&format!("MF:AddMessage('m{n}', 1, 1, 1)")).unwrap();
}
s.resolve();
s.tick(0.1);
assert_eq!(num_messages(&s, "MF"), 3, "42px / 14px pitch = 3 rows");
// ...and it is the OLDEST that went.
s.resolve();
let texts: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
crate::script::QuadContent::Text { text: Some(t), .. } => Some(t.clone()),
_ => None,
})
.collect();
assert!(texts.contains(&"m8".to_string()) && !texts.contains(&"m0".to_string()));
}
/// A faded line on this class is **freed**, not left blank holding its rows — the difference
/// from the scrolling class, whose ring slots persist. `Clear` is the immediate form.
#[test]
fn a_finished_line_is_retired_and_clear_empties_now() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
s.run("MF:SetTimeVisible(0.5)").unwrap();
s.run("MF:SetFadeDuration(2)").unwrap();
s.run("MF:AddMessage('x', 1, 1, 1)").unwrap();
s.tick(0.6); // spends phase 1
s.tick(1.0); // mid-ramp: still alive, alpha down
assert_eq!(num_messages(&s, "MF"), 1);
s.tick(1.2); // ramp done → retired
assert_eq!(num_messages(&s, "MF"), 0);
s.run("MF:AddMessage('a', 1, 1, 1)").unwrap();
s.run("MF:AddMessage('b', 1, 1, 1)").unwrap();
assert_eq!(num_messages(&s, "MF"), 2);
s.run("MF:Clear()").unwrap();
assert_eq!(num_messages(&s, "MF"), 0);
}
/// The ctor defaults, straight off msgframe-runtime.md's shared-defaults section, plus the
/// setters' round trip.
#[test]
fn ctor_defaults_and_the_fade_accessors() {
let s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
assert!(s.eval::<bool>("return MF:GetFading()").unwrap());
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 10.0);
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 3.0);
s.run("MF:SetTimeVisible(5) MF:SetFadeDuration(1) MF:SetFading(false)")
.unwrap();
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 5.0);
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 1.0);
assert!(!s.eval::<bool>("return MF:GetFading()").unwrap());
}
}
@@ -7,21 +7,25 @@
//! while **AtBottom** (scrolled up freezes every alpha), and 1-slot scrollback. The heavy lifting
//! (the ring, the fade phases, the scroll clamps) lives on [`ScrollingMessageState`]
//! (`crate::widget`), unit-tested there; this module is the thin Lua binding over it, plus the
//! host-facing [`super::UiScript::add_chat_message`]/fade advance the app drives.
//! host-facing [`UiScript::add_chat_message`]/fade advance the app drives.
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for ScrollingMessageFrame frames — so duck-typing addons (`if frame.AddMessage then …`) see `nil`
//! on every other kind, exactly as against the client's per-class method sets.
//! on every other kind, exactly as against the client's per-class method sets. **Its sibling class
//! `MessageFrame` has its own table next door** ([`super::plain`]) carrying a *different*
//! `AddMessage`: merging the two would hand a chat frame a `SetInsertMode` it does not have, and a
//! MessageFrame an id argument where its alpha belongs.
use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::Model;
use crate::script::object::frame_handle_of;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ScrollingMessageState};
/// Registry key of the ScrollingMessageFrame method table (the MAXCSTACK discipline: Lua-side root,
/// named key).
pub(super) const REG_MESSAGEFRAME_METHODS: &str = "__benilla_messageframe_methods";
pub(crate) const REG_SCROLLINGMESSAGEFRAME_METHODS: &str =
"__benilla_scrollingmessageframe_methods";
/// Run `f` over a frame's message-frame state under one short write borrow. Errors if `this` is not a
/// live ScrollingMessageFrame (unreachable through the kind dispatcher, but the method table is a
@@ -59,11 +63,7 @@ fn num_f32(v: &Value) -> f32 {
fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (_, height, _, _) = super::UiScript::message_frame_font(&model, h);
let pitch = height.unwrap_or(14.0).max(1.0);
let viewport_rows = model.resolved.get(&h).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
});
let viewport_rows = UiScript::message_viewport_rows(&model, h);
let frame = model
.arena
.frame_mut(h)
@@ -200,7 +200,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fade_duration))?,
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
// SubmitChatInput(text) — the chat input EditBox's OnEnterPressed hands the typed line here; it
// queues into the model for the app to parse into a chat command (the outbound Lua→app seam, the
+88 -2
View File
@@ -42,8 +42,10 @@ mod backdrop;
mod bank;
mod binding_abi;
mod button;
mod channel;
mod char_stats;
mod chat_send;
mod chat_window;
mod clip;
mod colorselect;
mod container;
@@ -62,6 +64,7 @@ pub(crate) mod event;
mod extract;
mod follow;
pub(crate) mod font;
mod font_block;
mod gossip;
mod inspect;
mod item_stats;
@@ -167,8 +170,8 @@ pub use trainer::{
};
pub use types::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue,
TexCoords,
Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent,
ScriptValue, TexCoords,
};
pub(crate) use types::{MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
@@ -298,6 +301,23 @@ pub struct UiScript {
lua: Lua,
}
/// The chunk name the CLIENT gives an addon's file: `Interface\AddOns\<Folder>\<File>`.
///
/// Backslashes, and the `Interface\AddOns\` prefix, because addons PARSE this. `FuBarPlugin-2.0`
/// derives each plugin's own folder from a stack trace —
/// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")` (`FuBarPlugin-2.0.lua:752`) — and
/// feeds the capture straight into `format("Interface\\AddOns\\%s\\icon", self.folderName)`.
/// With no name set, mlua defaults the chunk to the Rust caller location, the pattern misses,
/// `folderName` is nil, and every FuBar plugin dies formatting it. That was 20 addons.
///
/// The greedy `(.*)` in their pattern is why the FILE has to be in the name too: it captures up to
/// the LAST backslash, so `…\AddOns\FuBar_BagFu\FuBar_BagFu.lua` yields `FuBar_BagFu`. A name
/// stopping at the folder would capture the empty string.
pub fn addon_chunk_name(folder: &str, file: &str) -> String {
// `@` is Lua's "this chunk is a file" marker; the traceback then prints the path plainly.
format!("@Interface\\AddOns\\{folder}\\{}", file.replace('/', "\\"))
}
impl UiScript {
/// Build a fully sandboxed, stdlib- and object-model-equipped host.
pub fn new() -> mlua::Result<UiScript> {
@@ -306,6 +326,8 @@ impl UiScript {
addon::install(&lua)?;
chat_send::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::install(&lua)?;
stdlib::sandbox(&lua)?;
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions (decision 1194).
@@ -456,8 +478,21 @@ impl UiScript {
/// odd glyph. The two front-door transforms the reference's own compiler applies — the UTF-8
/// BOM strip and the `#`-line skip — are applied here ([`crate::source::chunk`]).
pub fn run_chunk(&self, chunk: &[u8]) -> mlua::Result<()> {
self.run_chunk_named(chunk, "(chunk)")
}
/// Run a chunk under the name the CLIENT would give it — `Interface\AddOns\<Folder>\<File>`
/// for an addon file (build it with [`addon_chunk_name`]).
///
/// The name is not cosmetic. Without `set_name`, mlua defaults a chunk to the **Rust** caller
/// location, so every error an addon raised was reported against `crates/benilla-ui/src/...`
/// — wrong in any error an addon shows a player, and load-bearing for the addons that PARSE a
/// traceback. `FuBarPlugin-2.0.lua:752` derives a plugin's own folder with
/// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, which cannot match a Rust path.
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
self.lua
.load(crate::source::chunk(chunk))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
}
@@ -788,6 +823,57 @@ impl UiScript {
self.model_ref().font_objects.contains_key(name)
}
/// The **widget kind of a published name** — `"MessageFrame"`, `"Button"`, `"Texture"`, … — or
/// `None` if nothing by that name is a live widget.
///
/// [`Self::has_framexml_template`]'s sibling, and the same kind of thing: a pure Rust-side query
/// on the live model, for the corpus harness. The harness needs to know what `UIErrorsFrame` in
/// `UIErrorsFrame:AddMessage(…)` actually *is* in our object graph, because a widget-method
/// census that asks "does ANY kind answer this name" cannot see a verb wired to one class and
/// forgotten on its sibling (decision 1228). Attributing the call site to a kind is what makes
/// that question askable, and this is the only honest answer to "what kind is that global": the
/// arena's own record, not a name list.
///
/// **Deliberately NOT a Lua binding.** The reference publishes this as `GetObjectType`, which we
/// do not implement outside font objects; adding it here would put a new verb in front of every
/// addon and move the very numbers the census is measured by. An instrument must be able to
/// claim it perturbed nothing, so it stays on the Rust side where no addon can observe it.
///
/// The spellings are `CreateFrame`'s own (`SimpleHTML`, not `SimpleHtml`), so a caller can
/// compare a name here against a kind it passed to `CreateFrame`.
pub fn widget_kind(&self, name: &str) -> Option<&'static str> {
let model = self.model_ref();
if let Some(h) = model.arena.lookup(name) {
return model.arena.frame(h).map(|f| match f.kind {
crate::widget::FrameKind::Frame => "Frame",
crate::widget::FrameKind::Button => "Button",
crate::widget::FrameKind::CheckButton => "CheckButton",
crate::widget::FrameKind::EditBox => "EditBox",
crate::widget::FrameKind::StatusBar => "StatusBar",
crate::widget::FrameKind::Slider => "Slider",
crate::widget::FrameKind::ScrollFrame => "ScrollFrame",
crate::widget::FrameKind::Model => "Model",
crate::widget::FrameKind::MessageFrame => "MessageFrame",
crate::widget::FrameKind::ScrollingMessageFrame => "ScrollingMessageFrame",
crate::widget::FrameKind::ColorSelect => "ColorSelect",
crate::widget::FrameKind::SimpleHtml => "SimpleHTML",
crate::widget::FrameKind::MovieFrame => "MovieFrame",
crate::widget::FrameKind::GameTooltip => "GameTooltip",
crate::widget::FrameKind::Minimap => "Minimap",
crate::widget::FrameKind::Cooldown => "Cooldown",
});
}
// The region leaves publish into their own name table (`region_names`), not the arena's —
// and they matter here: `GameTooltipTextLeft1:GetText()` is a FontString call, and the
// corpus scrapes those constantly.
let id = *model.region_names.get(name)?;
let h = *model.id_to_region.get(&id)?;
model.arena.region(h).map(|r| match r.kind {
crate::widget::RegionKind::Texture => "Texture",
crate::widget::RegionKind::FontString => "FontString",
})
}
/// A snapshot of the script errors collected so far (from `pcall`'d handlers).
pub fn errors(&self) -> Vec<String> {
self.model_ref().errors.clone()
+10
View File
@@ -221,6 +221,10 @@ pub(crate) struct Model {
/// §2) — `GetNumPartyMembers`/`GetPartyLeaderIndex`/`GetLootMethod`/… read it ([`party`]).
/// `PartyState::default()` = not in a group (every getter reports the solo-player shape).
/// Per-member game state rides the `units` map above, under `"party1"`..`"party4"` tokens.
/// The channels this session has CONFIRMED joining, in join order — the client-side number
/// law `GetChannelName` answers with ([`super::channel`]). Mirrored from `ui_chat`'s
/// `ChannelState` by `set_joined_channels`; empty until the server's first YOU_JOINED.
pub(crate) joined_channels: Vec<String>,
pub(crate) party: party::PartyState,
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
@@ -237,6 +241,10 @@ pub(crate) struct Model {
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
pub(crate) tell_requests: Vec<String>,
/// Prefill texts `ChatFrame_OpenChat` queued since the app's last
/// [`super::UiScript::take_open_chat_requests`] drain — `tell_requests`' sibling, one step
/// less resolved: a name there, a whole draft line here ([`chat_window`] registers the global).
pub(crate) open_chat_requests: Vec<String>,
/// The player's default chat language name, app-resolved from `ChrRaces.BaseLanguage` ×
/// `Languages.dbc` ([`super::UiScript::set_default_language`]). `None` = the reference's
/// no-player-object state, where `GetDefaultLanguage()` returns **zero Lua values**
@@ -960,11 +968,13 @@ impl Model {
target_by_name_requests: Vec::new(),
drop_item_on_unit: Vec::new(),
target_clear: false,
joined_channels: Vec::new(),
party: party::PartyState::default(),
party_requests: Vec::new(),
social: social::SocialState::default(),
social_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
default_language: None,
duel_requests: Vec::new(),
follow_requests: Vec::new(),
+8 -2
View File
@@ -221,7 +221,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::statusbar::install(lua)?;
super::button::install(lua)?;
super::editbox::install(lua)?;
super::messageframe::install(lua)?;
// Shared frame metatable: __index is a Rust dispatcher over the frame method table (RF-0023),
// checking the frame's *kind-specific* method table first. Per-kind resolution matters beyond
@@ -315,7 +314,14 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
match model.arena.frame(*h).map(|f| f.kind) {
Some(FrameKind::StatusBar) => &[super::statusbar::REG_STATUSBAR_METHODS],
Some(FrameKind::EditBox) => &[super::editbox::REG_EDITBOX_METHODS],
Some(FrameKind::ScrollingMessageFrame) => &[super::messageframe::REG_MESSAGEFRAME_METHODS],
Some(FrameKind::ScrollingMessageFrame) => {
&[super::messageframe::REG_SCROLLINGMESSAGEFRAME_METHODS]
}
// Its SIBLING, with its own table — not a fallthrough to the scrolling one. Until this arm
// existed a `<MessageFrame>` resolved no kind-specific method at all, which is what blocked
// `EasyCopy`, `QuestHistory` and `QuestItem` on `UIErrorsFrame:AddMessage` (three of the
// corpus's six method-class blockers) and made `SetInsertMode` unreachable.
Some(FrameKind::MessageFrame) => &[super::messageframe::REG_MESSAGEFRAME_METHODS],
Some(FrameKind::ScrollFrame) => &[super::scrollframe::REG_SCROLLFRAME_METHODS],
Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS],
Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS],
@@ -81,6 +81,36 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetScript",
lua.create_function(|lua, (this, name): (Table, String)| get_script(lua, &this, &name))?,
)?;
// HasScript(name) — "can this widget carry that script kind at all", NOT "does it have one
// set". The distinction is the whole point of the verb: every caller in the corpus uses it to
// decide whether it is safe to hook, then reads the current handler separately.
//
// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
// local script = parent:GetScript("OnMouseDown") -- FuBar-compat:2422
// parent:SetScript("OnMouseDown", function() … end)
// end
//
// It was the top session-start blocker: 32 of the 39 `attempt to call method` failures were
// this one name, across the Tablet/FuBar/oRA2 stack.
//
// ANSWERED FROM THE FLAT `SCRIPT_KINDS`, and that is a deliberate, stated approximation rather
// than the full answer. The reference's table is PER WIDGET TYPE — a plain Frame really does
// answer false for `OnClick` — so ours is over-permissive for the type-specific kinds. It is
// exactly right for the base kinds, which is what every corpus caller asks about
// (`OnMouseDown` here; a Frame genuinely has it). The cost of the over-permission is small and
// worth naming: an addon that asks about a type-specific kind installs a handler that never
// fires, where the reference would have had it skip. That is the same latitude `SetScript`
// already takes — this verb does not widen it, it just stops lying about its existence.
//
// Making it exact means per-type SCRIPT_KINDS, which newly RAISES on 30 sites that work today
// (9 in our own FrameXML). That is behaviour removal and belongs in its own change, with its
// own measurement; it is not a tail on this one.
m.set(
"HasScript",
lua.create_function(|_, (_this, name): (Table, String)| {
Ok(SCRIPT_KINDS.iter().any(|k| k.eq_ignore_ascii_case(&name)))
})?,
)?;
// RegisterForDrag(...varargs of button names) — the drag-gesture twin of `RegisterForClicks`
// (decision 0216 §3), but on the SHARED table: any Frame can be a drag source, not just a
// Button. Replace-the-set semantics (empty varargs clears); `crate::script::cursor`'s
@@ -140,16 +170,35 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// `system/ui/ui.md` l.544-556 summarises it), so what is missing here is never a mystery — it is a
/// deliberate not-yet. What the corpus actually asks for, and why each answer is what it is:
///
/// * **`OnKeyDown` / `OnKeyUp` / `OnChar`** (14 + 1 + 4 corpus sites over 13 addons) — **raising.**
/// They are in the base map, so *every* frame type has them, and the delivery mechanism is
/// `EnableKeyboard` + the hit-test root's **kind-0/kind-1 index** (wow-re
/// `scratch/scripts-auto-enable.md` §1-2: `0x76af00(kind, -1)`, `OnChar` = kind 0,
/// `OnKeyDown`/`OnKeyUp` = kind 1) with the frame-script pre-gate `0x76bba0` consuming the key
/// ahead of the whole binding dispatch (`scratch/keybinding-dispatch-law.md` §1). benilla has
/// none of that: no `EnableKeyboard`, no keyboard index, and its keys go straight to the focused
/// EditBox (whose C++ override replaces these slots anyway — RF-0082 §2). Accepting the names
/// before the index exists is the exact silent-no-op trap above; the ordering/consumption law is
/// dispatched to wow-re.
/// * **`OnKeyDown` / `OnKeyUp` / `OnChar`** (14 + 1 + 4 corpus sites over 13 addons) — **raising,
/// and now unblocked**: the delivery law was the second §5 this work dispatched, and it landed
/// (wow-re `scratch/frame-key-script-delivery.md`). benilla still has none of the machinery it
/// describes — no `EnableKeyboard`, no keyboard index, keys routed straight to the focused
/// EditBox — so the names stay out until it exists, which is the whole rule above. What has to
/// get built, so the next pass does not have to re-derive it:
///
/// The frame must be in the hit-test root's **kind-0 / kind-1 bucket**
/// (`scratch/scripts-auto-enable.md` §1-2: `0x76af00(kind, …)`, `OnChar` = kind 0,
/// `OnKeyDown`/`OnKeyUp` = kind 1; XML `enableKeyboard` enables both, a `<Scripts>` block
/// auto-enables per handler, and Lua `SetScript` auto-enables **nothing** — so a Lua-only frame
/// is not in the bucket at all and its bound handler can never fire). The dispatcher then walks
/// **strata 8 → 0, level high → low, ties oldest-registration-first**, calling each frame's base
/// input virtual (`0x765f10` key-down → vtable `+0x60`; `0x765df0` char → `+0x5c`) and
/// **stopping at the first nonzero return**. `arg1` is a **key-name string** with no modifier
/// prefix, decoded from the *same* table the keybinding chord names use — verified byte-identical
/// over 273 codes, which is the opposite of the mouse case's two-table shape. `OnChar` gets the
/// literal character, UTF-8.
///
/// Two findings that will bite whoever implements it. The consumption gate is **existence, not
/// handling** — a 1.12 handler cannot signal "handled" (the fire's return is discarded at all
/// three sites), so merely having a key script bound suppresses the key's binding; and
/// asymmetrically, a frame with **only** an `OnKeyUp` script consumes every key-down and runs
/// nothing. And `CGWorldFrame` sits at **strata 0 / level 0** — last in the walk — which is
/// precisely why any keyboard-enabled frame pre-empts the entire binding system.
///
/// (The old gloss here called `0x76bba0` a "frame-script pre-gate" walking the index, after
/// `scratch/keybinding-dispatch-law.md` §1. That is refuted: `0x76bba0` is `CSimpleFrame::OnKeyUp`,
/// one frame's base virtual. The walk is one level up, in the dispatcher.)
/// * **`OnCursorChanged`** (4 sites over 3 addons — all of them the Era `ScrollingEdit_OnCursorChanged`
/// auto-scroll idiom) — **raising.** It is the EditBox's own slot (RF-28 `+0x428`), fired by the
/// caret flush `0x77da80` with **four float caret-POSITION args**, and caret geometry is the one
@@ -255,6 +255,82 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(())
})?,
)?;
// `GetBackdrop()` (`0x777370`; wow-re `ui/scratch/widget-api-batch-benilla.md` Q5) — five things
// a plausible implementation gets wrong, so each is spelled out with its reason:
//
// 1. It is a **reconstruction from the struct**, never the caller's table. The reference stores
// no Lua reference anywhere: `SetBackdrop` reads six keys into a fresh 0x68-byte struct at
// `frame+0x1ac` and drops the table, so `0x777426`–`0x7776b9` re-push every key from those
// fields. Handing back a stored clone would leak keys the reader never accepted and would
// keep stale values a later `SetBackdropColor` changed.
// 2. **No backdrop ⇒ ZERO Lua values, not `nil`** — the early bail is `xor eax,eax; ret`, which
// for a *return* path really is "no values" (contrast `binding_abi`'s note: the same two
// bytes after a `luaL_error` are unreachable boilerplate). Observable through `select('#')`,
// and it is the shape our `GetTitleRegion` will *not* have when it lands — that one pushes
// nil, i.e. one value. The client cannot distinguish "never set" from `SetBackdrop(nil)`.
// 3. **A partial `SetBackdrop` omits nothing on the way out.** Every `SetBackdrop` allocates a
// fresh struct (`0x777801`, ctor `0x77e5f0`), so a key the caller left out is a *ctor
// default* here, not an absent key and not the previous backdrop's value: `bgFile`/`edgeFile`
// `""`, `tileSize` 0, `edgeSize` **32**. Our `backdrop_from_table` already builds on
// `Backdrop::default()`, so this falls out — but only because `None` maps to `""` below
// rather than to nil.
// 4. **`tile` is the NUMBER `1`, or the key is ABSENT — never `true`/`false`.** The push is
// `0x3ff00000` (the double 1.0) on true and `lua_pushnil` on false, and `lua_settable` with a
// nil value creates no key (and *erases* one from a recycled table, which is why the nil is
// written rather than skipped). An addon reading `if backdrop.tile then` sees the same truth
// either way; one that round-trips the table into `SetBackdrop` is why the number matters,
// since `tile` there goes through a coercer that takes numbers.
// 5. The undocumented **in-place form** (`0x77740e`): `lua_type(L,2) == LUA_TTABLE` skips
// `lua_newtable` and fills arg 2, reusing an existing `insets` subtable rather than replacing
// it. Implemented — it is four lines, and an addon caching one table across frames would
// otherwise silently get a new one each call. A non-table arg 2 is ignored, not an error.
//
// No `bgColor`/`edgeColor`/`alpha` key exists: the two colors live in the struct but the reader
// never pushes them (`GetBackdropColor`/`GetBackdropBorderColor` are their only accessors).
m.set(
"GetBackdrop",
lua.create_function(|lua, (this, target): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
// Copied out before a single Lua write: filling a *caller-supplied* table can run a
// `__newindex` metamethod, which can re-enter us and would panic on the app-data borrow.
let bd = {
let model = lua.app_data_ref::<Model>().expect("model");
model.backdrops.get(&h).cloned()
};
let Some(bd) = bd else {
return Ok(mlua::MultiValue::new()); // trap 2 — zero values, not nil
};
let t = match target {
Value::Table(t) => t,
_ => lua.create_table()?,
};
t.set("bgFile", bd.bg_file.as_deref().unwrap_or(""))?;
t.set("edgeFile", bd.edge_file.as_deref().unwrap_or(""))?;
t.set(
"tile",
if bd.tile {
Value::Number(1.0) // trap 4
} else {
Value::Nil // trap 4 — erases the key from a recycled table
},
)?;
t.set("tileSize", f64::from(bd.tile_size))?;
t.set("edgeSize", f64::from(bd.edge_size))?;
let insets = match t.get::<Value>("insets") {
Ok(Value::Table(existing)) => existing, // trap 5 — reuse, don't replace
_ => {
let fresh = lua.create_table()?;
t.set("insets", &fresh)?;
fresh
}
};
insets.set("left", f64::from(bd.insets.left))?;
insets.set("right", f64::from(bd.insets.right))?;
insets.set("top", f64::from(bd.insets.top))?;
insets.set("bottom", f64::from(bd.insets.bottom))?;
Ok(mlua::MultiValue::from_vec(vec![Value::Table(t)]))
})?,
)?;
m.set(
"SetBackdropColor",
lua.create_function(
@@ -176,6 +176,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetWidth",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, true))
})?,
@@ -184,6 +185,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetHeight",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, false))
})?,
@@ -198,6 +200,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetCenter",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(match model.resolved.get(&h) {
@@ -238,6 +241,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
name,
lua.create_function(move |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_mut::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(model.resolved.get(&h).map(|r| {
@@ -255,6 +259,33 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
/// The frame's effective scale, ε-guarded (a zero would poison the local-unit division).
/// **Resolve the layout graph NOW if anything has moved since the last pass.**
///
/// The client answers a geometry query against current layout; ours cached rects in a per-frame
/// `resolve()` pass, so a query made EARLIER IN THE SAME CALL STACK than that pass read nil. That
/// is not an edge case — it is what every menu does. `Dewdrop-2.0.lua` (embedded in ~65 corpus
/// addons) creates its menu frame, anchors it, shows it and then measures it inside one `OnClick`:
///
/// ```lua
/// local left = frame:GetLeft() -- l.1942, nil for us
/// frame:SetPoint(point, parent, relativePoint, curX - left - width / 2, 0) -- l.1960, dies
/// ```
///
/// **97 of the 108 addons that drew and then raised on being touched died on that one line.** No
/// missing verb: `GetLeft` was always there, and always answered nil.
///
/// Cheap: `resolve_layout`'s tier-1 gate is one epoch comparison that returns immediately when
/// nothing has been touched, so a settled tree pays a compare per getter.
///
/// **It deliberately does NOT fire `OnSizeChanged`.** That drain runs Lua handlers, and re-entering
/// Lua from inside a binding is how a borrow panic or an unbounded recursion happens. The
/// size-change queue is drained by the next real `UiScript::resolve`, one tick later than the
/// reference's per-rect-application fire (`ApplyRect 0x76b580`). Stated rather than hidden.
fn settle(lua: &Lua) {
let mut model = lua.app_data_mut::<Model>().expect("model");
crate::script::UiScript::resolve_layout(&mut model);
}
pub(super) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
let s = model
.arena
+26 -10
View File
@@ -47,6 +47,7 @@ use mlua::Value;
use crate::layout::Rect;
use crate::order;
use crate::widget::FrameHandle;
use super::clip::{effective_clip, scroll_clip_sources};
use super::{button, cursor, editbox, event, UiScript};
@@ -127,25 +128,40 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
}
impl UiScript {
/// Hit-test the cursor at `(x, y)` and return the **id** of the captured frame (the topmost-drawn
/// mouse-enabled, effective-visible frame whose rect contains the point **and** whose effective
/// ScrollFrame clip, if any, also contains it — decision 0112 §5: a button scrolled out of its
/// ScrollFrame's rect must not hit), or `None`. Pure query — fires nothing, mutates nothing. The
/// id is the same one used across this host (e.g. the layout [`crate::layout::Handle`]); the app
/// can hand it back or map it as it likes.
pub fn hit_test(&self, x: f32, y: f32) -> Option<u32> {
/// Hit-test the cursor at `(x, y)` and return the **handle** of the captured frame (the
/// topmost-drawn mouse-enabled, effective-visible frame whose rect contains the point **and**
/// whose effective ScrollFrame clip, if any, also contains it — decision 0112 §5: a button
/// scrolled out of its ScrollFrame's rect must not hit), or `None`. Pure query — fires nothing,
/// mutates nothing. Call [`Self::resolve`] first; a frame with no resolved rect never captures.
///
/// **The handle form exists because a name is not always available and an id is not always
/// comparable.** [`Self::hit_test`] answers in ids (what the app and the Lua bindings speak) and
/// [`Self::hit_test_name`] answers in names (what a human reads). An instrument that has to ask
/// *whose* frame ate a click needs neither: the frame may be **anonymous**, and the only set it
/// can be checked against is a handle set snapshotted earlier ([`Self::live_targets`]'s). The
/// addon harness's use probe asks exactly that — a pointer event is only an addon's to be
/// judged by when the frame under the cursor is one the addon itself created.
pub fn hit_test_frame(&self, x: f32, y: f32) -> Option<FrameHandle> {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
let hit = order::hit_test(&sorted, |fh| {
order::hit_test(&sorted, |fh| {
model.arena.is_mouse_enabled(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
});
hit.and_then(|h| model.frame_to_id.get(&h).copied())
})
}
/// Hit-test the cursor at `(x, y)` and return the **id** of the captured frame, or `None` —
/// [`Self::hit_test_frame`] with the id lookup every caller in the input path wants. The id is
/// the same one used across this host (e.g. the layout [`crate::layout::Handle`]); the app can
/// hand it back or map it as it likes.
pub fn hit_test(&self, x: f32, y: f32) -> Option<u32> {
let hit = self.hit_test_frame(x, y)?;
self.model_ref().frame_to_id.get(&hit).copied()
}
/// The NAME of the frame [`Self::hit_test`] captures at `(x, y)` — the pointer twin of
+326 -12
View File
@@ -127,6 +127,79 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetParent(frame) — **a Texture/FontString really does have this**, and we were the ones
// missing it. `SetParent` lives in the REGION method table (`0x7a1550`); Texture's class lookup
// falls back to Region's at `0x79c650` and FontString's at `0x79ee50`, so both reach it (wow-re
// `system/ui/scratch/widget-api-batch-benilla.md` Q7, §5-verified). `FuBar_FuXPFu.lua:210`'s
// `self.Spark:SetParent(self.XPBar)` is not a broken addon.
//
// Four contract traps, each spelled out because each is a plausible implementation's silent
// divergence:
//
// · **The argument must be a FRAME.** `0x7a16ea` runs `IsA(FrameTag)` on it, and a Texture or
// Region argument raises `"…Wrong parent object type, expected frame"` (`0x87cb78`). Ours
// raises too — a re-parent that quietly did nothing is the silent-drop class of 1203/1205.
// · **A missing argument is NOT the nil form.** TNONE fails the `== LUA_TNIL` test and falls
// through to `"…Couldn't find region named '%s'"` (`0x87cb48`), so `tex:SetParent()` raises
// while `tex:SetParent(nil)` detaches. That is why this reads a `MultiValue`: mlua cannot
// tell an absent argument from an explicit nil any other way.
// · **Anchors are untouched.** The re-link moves draw-layer/region-list membership only; a
// re-parented Texture still anchors to whatever `SetPoint` named — which is why FuXPFu
// re-points its spark afterwards, and why our anchors (which store the resolved target id)
// need no fixing up at all.
// · **Zero return values**, on every path.
//
// The mechanism half — full re-link, layer and sub-level preserved, `nil` = orphaned but not
// destroyed — is [`crate::widget::WidgetArena::set_region_owner`]'s doc.
m.set(
"SetParent",
lua.create_function(|lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let Some(Value::Table(this)) = it.next() else {
return Err(mlua::Error::runtime("SetParent: expected a region"));
};
let rh = region_handle_of(lua, &this)?;
let Some(parent) = it.next() else {
return Err(mlua::Error::runtime(
"SetParent(): Couldn't find region named '' (no argument)",
));
};
let wrong_type =
|| mlua::Error::runtime("SetParent(): Wrong parent object type, expected frame");
let mut model = lua.app_data_mut::<Model>().expect("model");
let new_owner = match &parent {
Value::Nil => None,
// A region/font-object wrapper decodes to an id that is not a frame's (or to no id
// at all) — both land on the same "expected frame" the reference raises.
Value::Table(t) => Some(
decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied())
.ok_or_else(wrong_type)?,
),
// A name resolves through the frame registry, as every other frame-target argument
// does (`SetPoint`'s relativeTo, `SetParent` on the frame side).
Value::String(s) => {
let name = s.to_str()?;
Some(model.arena.lookup(name.as_ref()).ok_or_else(|| {
mlua::Error::runtime(format!(
"SetParent(): Couldn't find region named '{}'",
name.as_ref()
))
})?)
}
_ => return Err(wrong_type()),
};
if model.arena.set_region_owner(rh, new_owner) {
// An un-anchored region draws relative to its owner, and an anchored one resolves
// against the owner's rect and effective scale (`layout.rs`'s region sweep) — the
// owner is a layout input, so a re-link is a layout change.
model.touch_layout();
}
Ok(())
})?,
)?;
// Region-level visibility — the real VisibleRegion Show/Hide on Textures/FontStrings (the
// ref kit hides tab slices, cooldown swipes, money coins…). A hidden region draws nothing;
// IsVisible additionally requires the owner frame's effective visibility, mirroring the
@@ -234,10 +307,72 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2) and its alpha-less twin
// SetGradient(orientation, r1,g1,b1, r2,g2,b2) — the two-stop linear gradient the client
// generates into the same `+0xcc` texture slot the colour form of SetTexture fills.
//
// These were missing, and they were the single wall in front of the corpus's largest family:
// `FuBar\FuBar_Panel.lua:144` calls SetGradientAlpha while building the bar, so all 20 FuBar
// plugins died there — after the debugstack/chunk-name fix got them that far.
//
// The orientation token is matched case-insensitively and anything that is not "VERTICAL" is
// horizontal, which is the client's own leniency and matters because addons spell it both ways.
// The gradient is stored WHOLE (see `RegionData::gradient`); the paint folds it to its midpoint
// because a quad carries one tint today. That approximation is visible and is recorded at the
// field, not hidden here.
for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] {
m.set(
name,
lua.create_function(move |lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let this: Table = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a region")),
};
let orientation = match it.next() {
Some(Value::String(s)) => s.to_str()?.to_string(),
// A missing/!string orientation is horizontal, like any non-"VERTICAL" token.
_ => String::new(),
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for slot in c.iter_mut().take(n) {
*slot = it.next().as_ref().map(as_f32).unwrap_or(0.0);
}
let (start, end) = if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
} else {
// SetGradient has no alpha stops: both ends are opaque.
([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0])
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.gradient = Some(super::Gradient {
vertical: orientation.eq_ignore_ascii_case("VERTICAL"),
start,
end,
});
Ok(())
})?,
)?;
}
m.set(
"SetTexture",
// The trailing three are `Value`, not `Option<f32>`, and that is a fidelity fix rather than
// laxity. The path form reads ONE argument (`0x770200`); only the colour form
// (`0x770360`) reads up to four. A C function takes what it wants off the Lua stack and
// ignores the rest, so `SetTexture(path, true)` is fine on the real client — and typing
// these as `Option<f32>` made us raise on it, `bad argument #3: error converting Lua
// boolean to f32`, in a call the client accepts silently.
//
// Found by `_LazyPig/LazyPigMenu.lua:182`
// (`texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`), which
// reached us only once the survey started seating the addon registry — the whole point of
// that instrument fix. The stray `true` is meaningless in 1.12 and the addon author
// presumably meant a later client's second parameter; either way the client shrugs.
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Option<f32>, Option<f32>, Option<f32>)| {
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
@@ -260,13 +395,21 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// The colour form, and the ONLY branch that looks at the trailing three. A
// non-numeric there takes the same default a missing one does, which is what
// reading off a C stack does: `lua_tonumber` on a non-number yields 0.
Value::Number(_) | Value::Integer(_) => {
data.fill = Some([
as_f32(&arg),
g.unwrap_or(0.0),
b.unwrap_or(0.0),
a.unwrap_or(1.0),
]);
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
}
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
@@ -281,6 +424,73 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
},
)?,
)?;
// SetDesaturated(flag) -> shaderSupported — Texture only (`0x79c1e0`, wow-re ledger; the
// reference's own `ItemButtonTemplate.lua:69` is `local shaderSupported =
// icon:SetDesaturated(desaturated)`).
//
// **The RETURN is the whole design, and it is the half a plausible implementation drops.**
// 1.12 ran on cards that could not do the shader, so the verb reports whether it took effect
// and FrameXML falls back by hand:
//
// if ( not desaturated ) then r,g,b = 1,1,1
// elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end
// icon:SetVertexColor(r, g, b)
//
// We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real
// 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and
// leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a
// desaturating shader lands, flip the return and this comment with it.
//
// Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being
// used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine`
// (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A
// static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the
// moment anyone right-clicks.
m.set(
"SetDesaturated",
lua.create_function(|lua, (this, flag): (Table, Value)| {
let on = !matches!(flag, Value::Nil | Value::Boolean(false));
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().desaturated = on;
// nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers
// write `not shaderSupported`.
Ok(Value::Nil)
})?,
)?;
// GetTexture() — Texture only (`0x79ba70`), verified in wow-re's widget-method batch
// (`system/ui/scratch/widget-api-batch-benilla.md`). Three contract details are each the kind a
// plausible implementation gets silently wrong, so each is spelled out:
//
// · **Exactly ONE return value**, never a multi-return.
// · **The colour form returns the literal string `"Solid Texture"`** (`0x835708`), NOT nil.
// `SetTexture(r,g,b)` synthesizes an 8x8 solid, and the getter reports that name — so an
// addon's `if not tex then` passes straight through on a colour-filled region. Returning nil
// here would look tidier and would be wrong in the one direction callers test for.
// · **The path is stripped at the LAST `.`** (`0x79baf0`): the loader appends `.blp`/`.tga` to
// what was set and the getter strips an extension back off. Taken verbatim rather than
// "strip only a real extension" — a directory containing a dot is mangled by the real client
// too, and this surface is a transcription, not an improvement.
//
// Four corpus addons: `AtlasQuest.lua:228` (`AQATLASMAP = AtlasMap:GetTexture()`) and
// `FuBarPlugin-2.0.lua:343` (`return self.iconFrame:GetTexture()`), each reached by two addons.
m.set(
"GetTexture",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let Some(data) = model.region_data.get(&rh) else {
return Ok(None);
};
if data.fill.is_some() {
return Ok(Some("Solid Texture".to_string()));
}
Ok(data.texture.as_ref().map(|t| match t.rfind('.') {
Some(i) => t[..i].to_string(),
None => t.clone(),
}))
})?,
)?;
// SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to
// its inscribed circle). The client's portraits are circular (model or icon fallback); the frame
// ring is a thin band whose transparent corners would otherwise show the square texture's edges.
@@ -745,6 +955,114 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
}
})?,
)?;
// SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`).
//
// Two contract details from wow-re's batch, both easy to get wrong:
// · the getter is **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil**,
// not a boolean — 1.12 predates that convention and an addon may compare against 1.
// · a **no-argument call ENABLES** it (the default is on), rather than being a query.
//
// `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached by two addons.
m.set(
"SetNonSpaceWrap",
lua.create_function(|lua, (this, enable): (Table, Value)| {
let on = match &enable {
Value::Nil => true,
Value::Boolean(b) => *b,
_ => true,
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().non_space_wrap = Some(on);
Ok(())
})?,
)?;
m.set(
"CanNonSpaceWrap",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let on = model
.region_data
.get(&rh)
.and_then(|d| d.non_space_wrap)
.unwrap_or(true);
Ok(if on { Some(1i64) } else { None })
})?,
)?;
// ── shadow, on the REGION ────────────────────────────────────────────────────────────────
// These already existed on the font-object table; a FontString made by `CreateFontString` had
// none of them, and that is where the corpus calls them:
// `FuBar_NavigatorFu/NavigatorFu.lua:31` does
// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — the GETTER on a font object,
// the SETTER on a fresh region — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
// `fontstring:SetShadowColor(0,0,0,0.3)`.
//
// **`GetShadowColor` returns FOUR values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's
// widget-method batch). Three is the plausible wrong answer and it silently drops the alpha
// that NavigatorFu is round-tripping. `GetShadowOffset` returns two, in UI units.
//
// Either half may be set before the other, so each starts from whatever is there — the same
// rule the font-object versions already follow.
m.set(
"SetShadowColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(crate::script::FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
});
d.font_explicit.shadow = true;
Ok(())
},
)?,
)?;
m.set(
"GetShadowColor",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
m.set(
"SetShadowOffset",
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
d.font_shadow = Some(crate::script::FontShadow {
offset: [x, y],
color,
});
d.font_explicit.shadow = true;
Ok(())
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let o = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
// SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and
// the XML `font=`/`<FontHeight>`/`outline=` join). `flags` is an OUTLINETYPE-ish string
// ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the
@@ -769,11 +1087,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
d.font_explicit.height = true;
}
if let Some(f) = flags {
d.outline = match f.to_ascii_uppercase() {
f if f.contains("THICK") => Outline::Thick,
f if f.contains("OUTLINE") => Outline::Normal,
_ => Outline::None,
};
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(true)
+203 -2
View File
@@ -38,10 +38,40 @@ pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> {
g.set(name, Value::Nil)?;
}
// A `debugstack` stub: real addons call it in error paths; returning "" keeps them running.
// `debugstack([start [, count1 [, count2]]])` — a REAL traceback, not the `""` stub this used
// to be.
//
// The stub was justified as "real addons call it in error paths; returning \"\" keeps them
// running", and that was true of the addons that only ever DISPLAY it. It is false of the ones
// that PARSE it, and the biggest family in the corpus is one of those: `FuBarPlugin-2.0.lua:752`
// derives every plugin's own folder with
// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, then formats the capture into an
// icon path. Against `""` the find returns nil, `folderName` stays nil, and the plugin dies in
// `format` — 20 corpus addons, every one of them a FuBar plugin, and the error surfaced as a
// `format` complaint three frames away from the cause.
//
// `start` is the stack level to begin at and defaults to 1, matching the client's own callers
// (`debugstack(6, 1, 0)` walks past FuBar's own wrapper frames to the plugin file). The two
// count arguments bound how many frames are shown at the top and bottom; mlua's traceback does
// not take them, so they are accepted and ignored — stated rather than silently dropped, and
// harmless because every corpus caller passes a shape whose whole purpose is "give me the
// frames", never an exact line count.
g.set(
"debugstack",
lua.create_function(|_, _: Variadic<Value>| Ok(String::new()))?,
lua.create_function(|lua, args: Variadic<Value>| {
let start = match args.first() {
Some(Value::Integer(i)) => *i as usize,
Some(Value::Number(n)) => *n as usize,
_ => 1,
};
// A level past the top of the stack is not an error here, it is an empty trace — the
// same shape the old stub returned, so a caller that guessed too deep still gets a
// string rather than a raise.
Ok(lua
.traceback(None, start)
.and_then(|t| t.to_str().map(|s| s.to_owned()))
.unwrap_or_default())
})?,
)?;
// Text-only `loadstring`: compile a *source* string (never bytecode). Returns `function` on
@@ -93,6 +123,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
install_time(lua)?;
lua.load(WOW_STDLIB)
.set_name("=[benilla wow stdlib]")
.set_mode(mlua::ChunkMode::Text)
@@ -388,3 +419,173 @@ do
format = reformat
end
"#;
/// `time()` and `date([format [, when]])` — engine globals in the 1.12 client's own `_G`, slots 34
/// and 33 of its base registry (`0x7035a0` / `0x7033a0`, wow-re `lua-dialect.md`).
///
/// They are Lua 5.0's `os.time`/`os.date` hoisted to globals, and we lacked both because the
/// sandbox strips `os` wholesale. `time` was the top name in the session-start
/// `attempt to call global` row (6 addons); `date` is a handful more. Every corpus `time()` site is
/// the same shape — an epoch stamp persisted into SavedVariables and compared across sessions
/// (`FTC_Save[k].LastCheck = time()`) — so it has to be real wall-clock seconds, not a
/// session-relative clock like `GetTime`.
///
/// **UTC, and that is a stated divergence.** `os.date` uses LOCAL time and this uses UTC, because
/// resolving a local offset needs a timezone database and this tree has no date dependency at all
/// (deliberately — the format crates here are all in-repo). The corpus's uses are a debug
/// timestamp, a "last scanned" string, and `%M:%S` over a DURATION; only the first two shift, and
/// they shift by a constant. Fixing it means a tz source, not a different algorithm.
fn install_time(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set("time", lua.create_function(|_, ()| Ok(unix_seconds()))?)?;
g.set(
"date",
lua.create_function(|_, (fmt, when): (Option<String>, Option<i64>)| {
// Bare `date()` is `%c`, as in Lua — `Recap.lua:2690` calls it with no arguments.
let fmt = fmt.unwrap_or_else(|| "%c".to_string());
Ok(format_epoch(when.unwrap_or_else(unix_seconds), &fmt))
})?,
)?;
Ok(())
}
/// Wall-clock seconds since the Unix epoch. Before the epoch is not a state a game client is in;
/// a clock that somehow reports it clamps to 0 rather than raising inside an addon's file scope.
fn unix_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
const DAYS: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/// Broken-down UTC time from an epoch second: (year, month 1-12, day 1-31, hour, min, sec,
/// weekday 0=Sunday, yearday 1-366).
///
/// The civil-from-days conversion is Howard Hinnant's, shifted to a March-based year so the leap
/// day lands at the end and no month-length table is needed. Chosen over a hand-rolled loop because
/// it is a known-correct closed form with no accumulation error, which matters for the "persisted
/// last session, compared this session" use every corpus caller has.
fn civil_from_epoch(secs: i64) -> (i64, u32, u32, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (hour, min, sec) = (
(rem / 3600) as u32,
((rem % 3600) / 60) as u32,
(rem % 60) as u32,
);
// 1970-01-01 was a Thursday (4).
let weekday = (days + 4).rem_euclid(7) as u32;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if month <= 2 { y + 1 } else { y };
// Day of the year, from the same conversion applied to Jan 1.
let jan1 = days_from_civil(year, 1, 1);
let yearday = (days - jan1 + 1) as u32;
(year, month, day, hour, min, sec, weekday, yearday)
}
/// Days since the epoch for a civil date — the inverse of the above, used only for `%j`.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The `strftime` subset the corpus actually uses, plus the obvious neighbours.
///
/// Bounded on purpose: the specifiers here are the ones read off real call sites —
/// `%H:%M:%S` (AceDebug), `%A, %B %d, %Y - %H:%M` (FuBar_PotHerbFu), `%M:%S` (FuBar_AnkhTimerFu),
/// `%c` (bare `date()`). An unknown specifier is emitted VERBATIM rather than swallowed, so a
/// format this does not know shows up in the output instead of silently vanishing.
fn format_epoch(secs: i64, fmt: &str) -> String {
let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(secs);
let mut out = String::with_capacity(fmt.len() + 16);
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
let Some(spec) = chars.next() else {
out.push('%');
break;
};
let hour12 = match hour % 12 {
0 => 12,
h => h,
};
match spec {
'a' => out.push_str(&DAYS[wday as usize][..3]),
'A' => out.push_str(DAYS[wday as usize]),
'b' | 'h' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
'B' => out.push_str(MONTHS[(month - 1) as usize]),
'c' => out.push_str(&format!(
"{} {} {:2} {:02}:{:02}:{:02} {}",
&DAYS[wday as usize][..3],
&MONTHS[(month - 1) as usize][..3],
day,
hour,
min,
sec,
year
)),
'd' => out.push_str(&format!("{day:02}")),
'H' => out.push_str(&format!("{hour:02}")),
'I' => out.push_str(&format!("{hour12:02}")),
'j' => out.push_str(&format!("{yday:03}")),
'm' => out.push_str(&format!("{month:02}")),
'M' => out.push_str(&format!("{min:02}")),
'p' => out.push_str(if hour < 12 { "AM" } else { "PM" }),
'S' => out.push_str(&format!("{sec:02}")),
'w' => out.push_str(&format!("{wday}")),
'x' => out.push_str(&format!("{month:02}/{day:02}/{:02}", year.rem_euclid(100))),
'X' => out.push_str(&format!("{hour:02}:{min:02}:{sec:02}")),
'y' => out.push_str(&format!("{:02}", year.rem_euclid(100))),
'Y' => out.push_str(&format!("{year}")),
'%' => out.push('%'),
// Unknown: verbatim, so it is visible rather than swallowed.
other => {
out.push('%');
out.push(other);
}
}
}
out
}
@@ -107,3 +107,131 @@ fn setpoint_unresolved_name_warns() {
.unwrap();
assert!(s.take_warnings().is_empty());
}
/// **A region whose OWNER has no rect still resolves from its own anchors.**
///
/// `layout.rs`'s region pass used to `continue` when `resolved` held nothing for the owner frame.
/// But the owner's rect is only the *fallback* for axes the region's own anchors do not pin — a
/// region anchored fully to some other frame needs nothing from its owner, and the reference
/// resolves it. Skipping made an ordinary addon shape silently invisible: a bare container frame
/// (`CreateFrame("Frame", n, parent)` with no size and no `SetPoint`) holding a region anchored
/// elsewhere.
///
/// Found by reproducing the director's MapCoords report. Its world-map readout computed the right
/// string every single frame — `Cursor Coords: … Player Coords: …` — and was never given a
/// position, with no error raised anywhere. Three of its four frames are this shape.
///
/// The control matters as much as the subject: a SIZED owner must be unaffected, or the fix has
/// traded one wrong layout for another.
#[test]
fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
host = CreateFrame("Frame", "AnchorHost")
host:SetWidth(400) host:SetHeight(200)
host:SetPoint("BOTTOMLEFT", 100, 50)
-- the shape that was invisible: owner with NO size and NO SetPoint
bare = CreateFrame("Frame", "BareOwner")
mark = bare:CreateTexture("BareMark", "ARTWORK")
mark:SetWidth(20) mark:SetHeight(10)
mark:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
-- control: identical region, owner that DOES resolve
sized = CreateFrame("Frame", "SizedOwner")
sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER")
ctl = sized:CreateTexture("SizedMark", "ARTWORK")
ctl:SetWidth(20) ctl:SetHeight(10)
ctl:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
"#,
)
.unwrap();
s.resolve();
// The owner genuinely has no rect — that part is correct and must stay true.
assert_eq!(
s.eval::<Option<f32>>("return BareOwner:GetLeft()").unwrap(),
None,
"an unpositioned frame has no rect; the fix must not invent one for it"
);
// ...and its region resolves anyway, from its own anchor: host's BOTTOMLEFT (100,50) + (5,7).
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetLeft()").unwrap(),
Some(105.0),
"a fully-anchored region needs nothing from its owner"
);
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetBottom()")
.unwrap(),
Some(57.0)
);
// The control is unmoved.
assert_eq!(
s.eval::<Option<f32>>("return SizedMark:GetLeft()").unwrap(),
Some(105.0),
"a sized owner's region must be unaffected by the fix"
);
}
/// **Geometry answers inside the same call stack that moved it** — the seam that killed 97 addons.
///
/// A handler creates a frame, anchors it, shows it and then measures it, all before any resolve
/// pass can run. `Dewdrop-2.0.lua` — embedded in ~65 corpus addons — does exactly this in its menu
/// `Open`: `local left = frame:GetLeft()` (l.1942) then `curX - left - width/2` (l.1960). Our
/// getters read a cache filled by the per-frame `resolve()`, so `left` was nil and the arithmetic
/// died. **97 of the 108 addons that drew and then raised on being touched died on that one line**,
/// with no missing verb anywhere.
///
/// The readers settle the graph on demand now. The second half of the test is the one that keeps
/// it honest: a *further* move inside the same stack must also be visible, or the fix is just a
/// one-shot warm-up.
#[test]
fn geometry_answers_within_the_call_stack_that_moved_it() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Dewdrop's shape, in one eval — no resolve() between the writes and the read.
let (left, bottom): (Option<f32>, Option<f32>) = s
.eval(
r#"
local f = CreateFrame("Frame", "MenuLike", UIParent)
f:SetWidth(100) f:SetHeight(50)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 40, 60)
f:Show()
return f:GetLeft(), f:GetBottom()
"#,
)
.unwrap();
assert_eq!(
(left, bottom),
(Some(40.0), Some(60.0)),
"a frame must report its geometry in the stack that anchored it"
);
// ...and a LATER move in the same stack is visible too — not a stale first answer.
let moved: Option<f32> = s
.eval(
r#"
MenuLike:ClearAllPoints()
MenuLike:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 200, 60)
return MenuLike:GetLeft()
"#,
)
.unwrap();
assert_eq!(moved, Some(200.0), "the settle must re-run after each move");
// Width/height and the centre reader take the same path.
assert_eq!(
s.eval::<(f32, f32)>("return MenuLike:GetWidth(), MenuLike:GetHeight()")
.unwrap(),
(100.0, 50.0)
);
assert_eq!(
s.eval::<(Option<f32>, Option<f32>)>("return MenuLike:GetCenter()")
.unwrap(),
(Some(250.0), Some(85.0))
);
}
@@ -44,6 +44,114 @@ fn backdrop_installs_and_extracts_pieces_with_colors() {
.all(|(p, c)| p == "edge" && *c == [1.0, 1.0, 1.0, 1.0]));
}
// `GetBackdrop()` — the four traps of `0x777370` (wow-re `widget-api-batch-benilla.md` Q5), pinned
// where an addon can observe them. The corpus caller is `BuffCheck2.lua:448`, which reads the table
// back, edits `insets`, and feeds it straight to `SetBackdrop` — so a wrong `tile` type or a missing
// ctor default does not just read wrong, it round-trips wrong.
#[test]
fn get_backdrop_reconstructs_from_the_struct() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "GB")
-- TRAP 2: no backdrop => ZERO values, not nil. `select('#')` is the only way to see it.
assert(select('#', f:GetBackdrop()) == 0, "unset backdrop must return no values at all")
-- TRAP 1: the result is rebuilt from the struct, not the caller's table. The alien key is
-- the proof: SetBackdrop never accepted it, so it cannot come back out.
local passed = { bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 16, edgeSize = 24,
insets = { left = 1, right = 2, top = 3, bottom = 4 },
alien = "must not survive" }
f:SetBackdrop(passed)
local b = f:GetBackdrop()
assert(select('#', f:GetBackdrop()) == 1, "a set backdrop is exactly one value")
assert(b ~= passed, "must not hand back the caller's own table")
assert(b.alien == nil, "keys SetBackdrop never read cannot reappear")
assert(b.insets ~= passed.insets, "the insets subtable is rebuilt too")
assert(b.bgFile == "bg" and b.edgeFile == "edge", "files round-trip")
assert(b.tileSize == 16 and b.edgeSize == 24, "sizes round-trip")
assert(b.insets.left == 1 and b.insets.right == 2
and b.insets.top == 3 and b.insets.bottom == 4, "insets round-trip")
-- TRAP 4: `tile` is the NUMBER 1, never the boolean the caller passed.
assert(type(b.tile) == "number", "tile must be a number, got " .. type(b.tile))
assert(b.tile == 1, "tile must be 1")
-- Mutating the caller's table afterwards cannot reach the frame (no reference is kept).
passed.bgFile = "clobbered"
assert(f:GetBackdrop().bgFile == "bg", "no live reference to the caller's table")
-- TRAP 3: a partial SetBackdrop omits NOTHING — a fresh struct is allocated every call, so
-- the keys left out come back as CTOR DEFAULTS, and none of the old backdrop survives.
f:SetBackdrop({ edgeFile = "onlyedge" })
local p = f:GetBackdrop()
assert(p.bgFile == "", "an omitted bgFile is the empty string, not nil")
assert(p.edgeSize == 32, "an omitted edgeSize is the ctor's 32, not the previous 24")
assert(p.tileSize == 0, "an omitted tileSize is 0, not the previous 16")
assert(p.insets.left == 0 and p.insets.right == 0
and p.insets.top == 0 and p.insets.bottom == 0, "omitted insets are 0, not 1/2/3/4")
-- TRAP 4, the other half: tile false pushes nil, so the key is ABSENT — never `false`.
assert(p.tile == nil, "tile false means the key is absent, not `false`")
-- The undocumented in-place form: fills and returns YOUR table, recycling `insets`, and
-- erases a stale `tile` on the way (the nil push is a real `lua_settable`).
local ins = {}
local mine = { insets = ins, tile = true, stale = "kept" }
local got = f:GetBackdrop(mine)
assert(got == mine, "the in-place form returns the table it was given")
assert(mine.insets == ins, "an existing insets subtable is reused, not replaced")
assert(mine.tile == nil, "a stale tile key is erased from a recycled table")
assert(mine.stale == "kept", "keys it does not write are left alone")
assert(mine.edgeSize == 32 and ins.bottom == 0, "the recycled table is filled")
-- SetBackdrop(nil) is indistinguishable from never having set one: back to zero values.
f:SetBackdrop(nil)
assert(select('#', f:GetBackdrop()) == 0, "SetBackdrop(nil) returns to the zero-value shape")
"#,
)
.unwrap();
}
// BuffCheck2.lua:448's exact shape: read the plate back, edit `insets`, push it straight back in.
// It is the whole reason this method exists in our client, so it is the test that must not rot.
#[test]
fn get_backdrop_round_trips_through_set_backdrop() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "BC2Plate")
f:SetPoint("CENTER")
f:SetSize(54, 54)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 } })
local backdrop = f:GetBackdrop()
backdrop["insets"] = { top = 12, bottom = 11, right = 12, left = 11 }
backdrop["tile"] = true
backdrop["tileSize"] = 32
backdrop["edgeSize"] = 32
f:SetBackdrop(backdrop)
local b = f:GetBackdrop()
assert(b.bgFile == "bg" and b.edgeFile == "edge", "files survive the round trip")
assert(b.tile == 1 and b.tileSize == 32 and b.edgeSize == 32, "tiling survives")
assert(b.insets.left == 11 and b.insets.bottom == 11, "insets survive")
"#,
)
.unwrap();
s.resolve();
// The plate still draws: bg + 8 border pieces, unchanged by the round trip.
assert_eq!(
s.extract()
.iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.count(),
9
);
}
// SetBackdrop(nil) tears the plate down (no pieces after).
#[test]
fn set_backdrop_nil_tears_down() {
@@ -458,3 +458,66 @@ fn button_label_repaints_by_state_font_object() {
let c = label_color(&mut s).expect("colored");
assert!((c[0] - 0.1).abs() < 1e-6 && (c[1] - 0.2).abs() < 1e-6);
}
/// **`Button:GetTextWidth` / `GetTextHeight`** — the reference's own Button text-extent readers
/// (`0x782290` / `0x782390`; wow-re `widget-api-batch-benilla.md` Q8 carves them present on Button
/// and `GetStringWidth` **absent**). Both forward to the label FontString's own extent slots, which
/// is what this asserts: answer a host measure for the label, and the BUTTON must report it.
///
/// `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from
/// `button:GetTextWidth() + 40`, so a nil method took the whole dropdown down — the director could
/// not switch characters in Bagnon at all.
#[test]
fn a_button_reports_its_labels_extent() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
b = CreateFrame("Button", "WidthBtn", UIParent)
b:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
b:SetWidth(120) b:SetHeight(24)
b:SetText("A Label")
plain = CreateFrame("Button", "LabellessBtn", UIParent)
"#,
)
.unwrap();
s.resolve();
// Unmeasured is 0 — the same "converges next frame" contract every other metric read has.
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
0.0
);
let req = s
.fontstrings_needing_measure()
.into_iter()
.find(|r| r.text == "A Label")
.expect("the label asks the host for its extent");
s.set_measured_text_unwrapped(&[(req.id, 47.0, 14.0, req.key)]);
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
47.0,
"the Button forwards to its own label"
);
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextHeight()").unwrap(),
14.0
);
// A Button with no label at all answers 0 rather than raising: the reference dereferences a
// FontString pointer (`+0x338`) here that a bare CreateFrame("Button") leaves null, and what it
// does then is not byte-read — so this takes the harmless number instead of guessing a crash.
assert_eq!(
s.eval::<f64>("return LabellessBtn:GetTextWidth()").unwrap(),
0.0
);
// …and it is a BUTTON method: a plain Frame must not have grown one.
assert!(
s.eval::<bool>(r#"return CreateFrame("Frame").GetTextWidth == nil"#)
.unwrap(),
"GetTextWidth is Button's, not Region's (wow-re Q8's own split)"
);
}
@@ -0,0 +1,110 @@
//! `GetChannelName` — the joined-channel lookup ([`crate::script::channel`]).
//!
//! The verb is small; the contract around it is not, and every test here is named after one claim.
//! The load-bearing one is [`a_channel_that_is_not_joined_answers_the_number_zero_never_nil`]: the
//! reference's own callers and the corpus both compare the first return numerically, so a nil there
//! does not degrade — it raises, at four call sites that work today.
use super::common::script;
/// Two channels joined in order, the way `ui_chat::feed` mirrors them after the server's
/// YOU_JOINED. Join ORDER is the whole numbering law, so the fixture is deliberately not sorted.
fn joined() -> crate::script::UiScript {
let mut s = script();
s.set_joined_channels(vec!["World".into(), "Trade - City".into()]);
s
}
/// **A joined channel answers its 1-based slot, its name, and an instanceID.** The slot is the
/// position in JOIN order — not a DBC id, not an alphabetical rank.
#[test]
fn a_joined_channel_answers_its_slot_name_and_instance() {
let s = joined();
let (id, name, instance): (i64, String, i64) = s.eval("return GetChannelName(2)").unwrap();
assert_eq!(id, 2, "the 1-based slot in join order");
assert_eq!(name, "Trade - City");
assert_eq!(
instance, 0,
"instanceID is 0 on every vanilla emulator, and a NUMBER so a caller can compare it"
);
}
/// **The name form resolves, case-insensitively, and yields the same slot as the index form.**
/// Half the corpus sites look up by name (`GetChannelName("world")`, `GetChannelName("Trade -
/// City")`), and `_LazyPig` passes `"world"` for a channel the server names `"World"`.
#[test]
fn the_name_form_resolves_case_insensitively_to_the_same_slot() {
let s = joined();
let by_name: i64 = s.eval("return GetChannelName('world')").unwrap();
let by_index: i64 = s.eval("return GetChannelName(1)").unwrap();
assert_eq!(by_name, 1);
assert_eq!(by_name, by_index, "both directions must agree on the slot");
let name: String = s
.eval("local _, n = GetChannelName('TRADE - CITY') return n")
.unwrap();
assert_eq!(
name, "Trade - City",
"the answer is the JOINED spelling, not the caller's"
);
}
/// **Not joined ⇒ the NUMBER 0. Never nil.** This is the trap, and it is the only way this verb can
/// break working code rather than merely fail to help it.
///
/// Verified from both sides. The reference compares it numerically at `ChatFrame.lua:2114`
/// (`if ( channelNum > 0 )`) and `l.2232` (`if ( channelNum <= 0 ) then return end`); so does the
/// corpus at `_LazyPig/LazyPig.lua:1996` (`if id > 0 then`). A nil first return turns every one of
/// those into "attempt to compare nil with number" — a loud failure in code that works today.
///
/// Asserted by DOING the comparison, not by inspecting the type, because the comparison is the
/// thing that must not raise.
#[test]
fn a_channel_that_is_not_joined_answers_the_number_zero_never_nil() {
let s = joined();
let id: i64 = s.eval("return GetChannelName('NoSuchChannel')").unwrap();
assert_eq!(id, 0);
// The reference's own guard shape, run for real: it must evaluate, not raise.
let guard: bool = s
.eval("local id = GetChannelName('NoSuchChannel') return id > 0")
.unwrap();
assert!(
!guard,
"the reference's `if ( channelNum > 0 )` must run and be false"
);
let out_of_range: bool = s
.eval("local id = GetChannelName(99) return id > 0")
.unwrap();
assert!(!out_of_range, "an out-of-range index takes the same branch");
let zero: bool = s
.eval("local id = GetChannelName(0) return id > 0")
.unwrap();
assert!(
!zero,
"the client bounds-checks 1 <= n <= count, so 0 is not a slot"
);
}
/// **A numeric STRING resolves as a number**, because `ChatFrame.lua:2113` hands one straight in:
/// it `gsub`s `/1` down to `"1"` and calls `GetChannelName(channel)`. On the real client Lua's own
/// coercion makes that work; ours must not treat it as a channel literally named "1".
#[test]
fn a_numeric_string_resolves_as_an_index_not_as_a_name() {
let s = joined();
let id: i64 = s.eval("return GetChannelName('2')").unwrap();
assert_eq!(id, 2, "the `/2` slash-command path depends on this");
}
/// **Empty until the server confirms a join.** `ui_chat::feed` appends on the server's YOU_JOINED
/// notice, never on the request, so a session that has asked and not been answered has no slots —
/// and the verb must still answer 0 rather than raise on an empty list.
#[test]
fn nothing_is_joined_before_the_server_confirms_it() {
let s = script();
let id: i64 = s.eval("return GetChannelName('World')").unwrap();
assert_eq!(id, 0);
}
@@ -112,3 +112,63 @@ fn events_fire_in_registration_order_fifo() {
s.fire_event("E", vec![]);
assert_eq!(s.eval::<String>("return order").unwrap(), "ACB");
}
/// **`HasScript` answers "can this widget CARRY that kind", not "does it have one set".**
///
/// That distinction is the verb's whole purpose, and every corpus caller depends on it: they ask
/// before hooking, precisely when nothing is set yet.
///
/// ```lua
/// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
/// local script = parent:GetScript("OnMouseDown")
/// parent:SetScript("OnMouseDown", function() … end)
/// end
/// ```
///
/// It was the top session-start blocker — 32 of 39 `attempt to call method` failures were this one
/// name — and implementing it took survivors from 41 to 69.
///
/// The known over-permission is asserted too, so it is a recorded divergence rather than a
/// discovery: our table is flat where the reference's is per widget type, so a plain Frame answers
/// true for a Button-only kind. Exact for the base kinds, which is what the corpus asks about.
#[test]
fn has_script_reports_the_kind_is_supported_not_that_one_is_set() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "HasScriptProbe")"#)
.unwrap();
// True with NOTHING set — the case every caller is actually in.
assert!(
s.eval::<bool>(r#"return f:HasScript("OnMouseDown")"#)
.unwrap(),
"a frame must report it can carry OnMouseDown before one is set"
);
assert!(
!s.eval::<bool>(r#"return f:HasScript("OnNotARealScript")"#)
.unwrap(),
"an unknown kind is false, not true"
);
// Tablet's exact idiom, run end to end: guard, read the (absent) handler, install one, fire it.
let fired: bool = s
.eval(
r#"
RAN = false
if f:HasScript("OnMouseDown") then
local prev = f:GetScript("OnMouseDown")
f:SetScript("OnMouseDown", function() RAN = true end)
end
f:GetScript("OnMouseDown")()
return RAN
"#,
)
.unwrap();
assert!(fired, "the guarded hook must install and run");
// The recorded divergence: flat table, so a Frame says true for a Button-only kind. The
// reference says false. Pinned so making SCRIPT_KINDS per-type has to come here and decide.
assert!(
s.eval::<bool>(r#"return f:HasScript("OnClick")"#).unwrap(),
"over-permissive by design today — see the comment at the binding"
);
}
@@ -0,0 +1,223 @@
//! Lua 5.0's iterator-less generic-for, restored in the vendored VM (decision 1215).
//!
//! `for k, v in someTable do` is Lua 5.0 syntax that 5.1 removed at the OPCODE level. 183 of 218
//! corpus addons are reached by it and it is the first session-start error for 60 of them — three
//! quarters of everything that breaks in a running session. The fix is 5.0's `OP_TFORPREP` folded
//! into `OP_TFORLOOP` in `third_party/lua-src/lua-5.1.5/lvm.c`.
//!
//! Each test below pins one detail that was byte-verified in the real 1.12.1 client (wow-5875-re
//! `system/ui/scratch/lua-generic-for.md`), because each is a place where a *reasonable* guess
//! diverges from what the client does. The `__call` case is the sharpest: two of the three
//! conditions somebody would naturally write for this behave differently there.
use super::common::script;
/// **The construct works at all** — the whole point, in the shape the corpus uses.
///
/// `Ace.lua`'s is the canonical one and Ace is embedded across half the ecosystem:
/// `for _, lang in self.langs do … end`, with no `pairs` and no iterator function.
#[test]
fn a_table_generator_iterates_like_lua_50() {
let s = script();
let seen: i64 = s
.eval(
r#"
local t = { a = 1, b = 2, c = 3 }
local sum = 0
for k, v in t do sum = sum + v end
return sum
"#,
)
.unwrap();
assert_eq!(seen, 6, "every pair must be visited exactly once");
// The array shape too, and the KEY must be the real key rather than a counter.
let keys: String = s
.eval(
r#"
local t = { "x", "y" }
local out = ""
for i, v in t do out = out .. i .. v end
return out
"#,
)
.unwrap();
assert_eq!(keys, "1x2y");
}
/// **The condition is a bare type-tag test, so a table carrying `__call` STILL gets `next`.**
///
/// This is the discriminating case, and the reason the patch does not test "is not callable" or
/// "has no `__call`" — both of those are reasonable readings and both diverge from the client here.
/// The 1.12 handler makes **no** metatable access whatsoever (no `luaT_gettmbyobj`, no
/// `tryfuncTM`), so the `__call` is never consulted and the table is iterated.
#[test]
fn a_table_with_a_call_metamethod_is_still_iterated_not_called() {
let s = script();
let out: String = s
.eval(
r#"
local called = false
local t = { only = "pair" }
setmetatable(t, { __call = function() called = true return nil end })
local keys = ""
for k, v in t do keys = keys .. k end
return keys .. "|" .. tostring(called)
"#,
)
.unwrap();
assert_eq!(
out, "only|false",
"the table must be ITERATED and its __call never invoked"
);
}
/// **The callee is the global `next`, read fresh at each loop entry — so an addon can replace it.**
///
/// Observable behaviour of the real client, not an accident: the lookup is a raw read of the
/// thread's globals table, performed once per loop entry. An addon that assigns `next = myfn`
/// changes every later generic-for in the session. Reproduced rather than improved on.
#[test]
fn the_substituted_generator_is_the_live_global_next() {
let s = script();
let out: String = s
.eval(
r#"
local realnext = next
local calls = 0
next = function(t, k) calls = calls + 1 return realnext(t, k) end
local t = { a = 1, b = 2 }
local n = 0
for k, v in t do n = n + 1 end
next = realnext
return n .. "|" .. (calls > 0 and "hooked" or "not hooked")
"#,
)
.unwrap();
assert_eq!(
out, "2|hooked",
"a replaced global `next` must drive the loop, as it does on the client"
);
}
/// **Only a table is substituted.** A function generator is untouched (the ordinary 5.1 path), and
/// a non-table, non-function generator still raises on the FIRST ITERATION rather than at entry —
/// the client's tag mismatch falls through to the normal call path.
#[test]
fn only_a_table_is_substituted_and_other_types_still_raise() {
let s = script();
// The ordinary iterator form is completely unaffected.
let via_pairs: i64 = s
.eval(
r#"
local t = { a = 1, b = 2, c = 3 }
local n = 0
for k, v in pairs(t) do n = n + 1 end
return n
"#,
)
.unwrap();
assert_eq!(via_pairs, 3);
// A custom iterator function still drives its own loop.
let custom: i64 = s
.eval(
r#"
local function upto(state, i)
i = i + 1
if i <= state then return i end
end
local sum = 0
for i in upto, 3, 0 do sum = sum + i end
return sum
"#,
)
.unwrap();
assert_eq!(
custom, 6,
"an explicit generator/state/control triple is untouched"
);
// A number generator is not a table: it must still raise, and say so.
let err = s
.eval::<i64>("local n = 0 for k, v in 42 do n = n + 1 end return n")
.unwrap_err()
.to_string();
assert!(
err.contains("attempt to call a number value"),
"a non-table, non-function generator must still raise: {err}"
);
}
/// **An empty table terminates immediately** rather than looping — `next(t, nil)` on an empty table
/// returns nil, and the loop's own continue test is unchanged by the substitution.
#[test]
fn an_empty_table_generator_terminates() {
let s = script();
let n: i64 = s
.eval("local n = 0 for k, v in {} do n = n + 1 end return n")
.unwrap();
assert_eq!(n, 0);
}
/// **Nested and repeated loops each get their own substitution.** The patch rewrites the loop's own
/// registers, so an inner loop must not disturb an outer one, and a loop entered twice must
/// substitute twice (the registers are reloaded from the expression each entry).
#[test]
fn nested_and_repeated_loops_each_substitute_independently() {
let s = script();
let out: i64 = s
.eval(
r#"
local outer = { a = 1, b = 2 }
local inner = { x = 10, y = 20 }
local total = 0
for k, v in outer do
for k2, v2 in inner do total = total + v2 end
total = total + v
end
return total
"#,
)
.unwrap();
// Each of the 2 outer pairs runs the full inner loop (30) plus its own value.
assert_eq!(out, 30 + 30 + 1 + 2);
// Entering the same loop a second time must work identically.
let twice: i64 = s
.eval(
r#"
local t = { a = 1, b = 2 }
local function count() local n = 0 for k, v in t do n = n + 1 end return n end
return count() + count()
"#,
)
.unwrap();
assert_eq!(twice, 4);
}
/// **5.0's long-string NESTING, restored** — the second dialect divergence in this file's family.
///
/// `[[ ... [[ ... ]] ... ]]` nests in Lua 5.0, which is what 1.12.1 ships. 5.1 kept the machinery
/// (`llex.c` `read_long_string`'s `cont` counter, guarded by `LUA_COMPAT_LSTR == 2`) and put an
/// advisory error in front of it — *"nesting of [[...]] is deprecated"* — which is a 5.1 opinion
/// about a dialect we are not targeting. Two corpus addons stopped LOADING on it.
///
/// The fix is Lua's own documented switch, `LUA_COMPAT_LSTR` 1 -> 2 in our vendored `luaconf.h`,
/// not a patch to the lexer: the code path was already there and already correct.
#[test]
fn a_nested_long_string_parses_as_lua_50_does() {
let s = crate::script::UiScript::new().unwrap();
// The shape an addon writes: a long comment containing a long string.
s.run("--[[ outer [[ inner ]] still comment ]]\nNESTED_OK = 1")
.unwrap();
assert_eq!(s.eval::<i64>("return NESTED_OK").unwrap(), 1);
// And a nested long STRING keeps its inner brackets, 5.0's own semantics: the outer runs to
// the matching close, so the inner `]]` does not terminate it.
let v = s
.eval::<String>("return [[a [[b]] c]]")
.expect("nested long string must parse");
assert_eq!(v, "a [[b]] c");
}
@@ -7,6 +7,7 @@
mod anchors;
mod backdrop;
mod button;
mod channel;
mod common;
mod cooldown;
mod create_frame_template;
@@ -14,6 +15,7 @@ mod end_to_end;
mod events;
mod font_object;
mod frame_api;
mod generic_for;
mod input;
mod layout_gate;
mod measure;
@@ -177,3 +177,794 @@ fn the_exception_list_has_no_dead_entries() {
wrong.join(" ")
);
}
/// **`Texture:GetTexture()` — the three contract details that a plausible implementation gets
/// silently wrong.** Verified in wow-re's widget-method batch (`0x79ba70`/`0x79baf0`/`0x835708`,
/// `system/ui/scratch/widget-api-batch-benilla.md`).
///
/// Four corpus addons reach it: `AtlasQuest.lua:228` is `AQATLASMAP = AtlasMap:GetTexture()` and
/// `FuBarPlugin-2.0.lua:343` is `return self.iconFrame:GetTexture()`, each behind two addons.
///
/// The colour case is the one worth a test of its own. `SetTexture(r,g,b)` synthesizes an 8x8 solid
/// and the getter reports the literal name `"Solid Texture"` — so an addon's `if not tex then`
/// guard passes straight through. Returning nil there would read as tidier and would be wrong in
/// exactly the direction callers test for.
#[test]
fn get_texture_returns_the_stripped_path_and_solid_texture_for_a_fill() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
f = CreateFrame("Frame", "GTF")
tex = f:CreateTexture("GTFTex", "ARTWORK")
fill = f:CreateTexture("GTFFill", "ARTWORK")
"#,
)
.unwrap();
// Never set: nil, and exactly one value.
assert_eq!(
s.eval::<Option<String>>("return GTFTex:GetTexture()")
.unwrap(),
None
);
assert_eq!(
s.eval::<i64>("local a,b = GTFTex:GetTexture() return select('#', GTFTex:GetTexture())")
.unwrap_or(1),
1,
"exactly one return value"
);
// A path with no extension comes back unchanged.
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Spell_Fire_FlameBolt")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Spell_Fire_FlameBolt"
);
// ...and one WITH an extension is stripped at the last `.` — the loader appends it, the getter
// takes it back off.
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Foo.blp")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Foo"
);
// The colour form: the literal name, NOT nil.
s.run("GTFFill:SetTexture(1, 0, 0, 1)").unwrap();
assert_eq!(
s.eval::<String>("return GTFFill:GetTexture()").unwrap(),
"Solid Texture",
"a colour-filled region reports a NAME — `if not tex then` must not fire"
);
// And a plain SetTexture makes it ordinary again.
s.run(r#"GTFFill:SetTexture("Interface\\Buttons\\UI-Quickslot2")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFFill:GetTexture()").unwrap(),
"Interface\\Buttons\\UI-Quickslot2"
);
}
/// **The shadow accessors on a REGION, and `GetShadowColor`'s four values.**
///
/// They existed on the font-object table; a FontString from `CreateFontString` had none, which is
/// exactly where the corpus calls them. `FuBar_NavigatorFu/NavigatorFu.lua:31` is
/// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — getter on a font object, setter on
/// a fresh region, in one line — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
/// `fontstring:SetShadowColor(0,0,0,0.3)`.
///
/// **Four values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's widget-method batch). Three is
/// the plausible wrong answer and it silently drops the alpha NavigatorFu round-trips: the whole
/// point of its line is that whatever `GameFontNormal` carries arrives intact.
#[test]
fn region_shadow_accessors_round_trip_four_values() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "SHF") fs = f:CreateFontString("SHFText", "ARTWORK")"#)
.unwrap();
assert_eq!(
s.eval::<i64>("return select('#', SHFText:GetShadowColor())")
.unwrap(),
4,
"GetShadowColor returns FOUR values — three drops the alpha"
);
assert_eq!(
s.eval::<i64>("return select('#', SHFText:GetShadowOffset())")
.unwrap(),
2
);
// NavigatorFu's line, in shape: a font object's shadow piped straight into a region's.
s.run("SHFText:SetShadowColor(0, 0, 0, 0.3) SHFText:SetShadowOffset(1, -1)")
.unwrap();
let (r, g, b, a) = s
.eval::<(f32, f32, f32, f32)>("return SHFText:GetShadowColor()")
.unwrap();
assert_eq!((r, g, b, a), (0.0, 0.0, 0.0, 0.3), "the alpha survives");
assert_eq!(
s.eval::<(f32, f32)>("return SHFText:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
// Either half may be set first: setting only the offset must not blank the colour.
s.run("SHFText:SetShadowOffset(2, -2)").unwrap();
assert_eq!(
s.eval::<f32>("local _,_,_,a = SHFText:GetShadowColor() return a")
.unwrap(),
0.3,
"setting the offset alone keeps the colour"
);
}
/// **`Region:SetParent(frame)` — it exists on a Texture/FontString, and we were the ones missing
/// it.** `SetParent` is in the Region method table (`0x7a1550`) and both leaf lookups fall back to
/// Region's (`0x79c650` / `0x79ee50`), so `FuBar_FuXPFu.lua:210`'s
/// `self.Spark:SetParent(self.XPBar)` — a texture from `XPBar:CreateTexture` — is a working line on
/// the real client (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q7).
///
/// The two traps pinned here are the ones a plausible implementation gets wrong in opposite
/// directions. **A non-Frame argument RAISES** (`IsA(FrameTag)` at `0x7a16ea`, message
/// `"…Wrong parent object type, expected frame"`) rather than no-opping; and **anchors are NOT
/// touched** by the re-link, which moves draw-layer/region-list membership only — so a re-parented
/// texture keeps resolving against whatever `SetPoint` named, and FuXPFu's re-point on the next
/// line is the correct pattern rather than a workaround.
#[test]
fn region_set_parent_relinks_the_draw_owner_and_leaves_anchors_alone() {
/// The resolved rect of the one texture quad whose path contains `needle`, if it draws at all.
fn tex_rect(s: &crate::script::UiScript, needle: &str) -> Option<crate::layout::Rect> {
s.extract().iter().find_map(|q| match &q.content {
crate::script::QuadContent::Texture { path: Some(p), .. } if p.contains(needle) => {
Some(q.rect.expect("resolved rect"))
}
_ => None,
})
}
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
A = CreateFrame("Frame", "SPOwnerA")
A:SetPoint("BOTTOMLEFT", 0, 0) A:SetSize(100, 50)
B = CreateFrame("Frame", "SPOwnerB")
B:SetPoint("BOTTOMLEFT", 300, 0) B:SetSize(100, 50)
Spark = A:CreateTexture("SPSpark", "ARTWORK")
Spark:SetTexture("Interface\\SPGlow")
Spark:SetSize(24, 24)
Spark:SetPoint("TOPLEFT", 4, -4)
"#,
)
.unwrap();
s.resolve();
// A is [bottom 0, left 0, top 50, right 100]: TOPLEFT +(4,-4), 24x24 ⇒ 4..28 x 22..46.
let before = tex_rect(&s, "SPGlow").expect("the spark draws under A");
assert_eq!(before, crate::layout::Rect::new(22.0, 4.0, 46.0, 28.0));
// FuXPFu's line, and it returns nothing at all.
assert_eq!(
s.eval::<i64>("return select('#', Spark:SetParent(B))")
.unwrap(),
0,
"SetParent returns zero values on every path"
);
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"the anchors are untouched — the spark still resolves against A, 300px from B"
);
// …and the re-link is real, not merely recorded: the spark now draws with B. Hiding B takes it
// off the screen; hiding A (which it is still anchored to) does not.
s.run("B:Hide()").unwrap();
assert_eq!(
tex_rect(&s, "SPGlow"),
None,
"the spark hides with its NEW owner"
);
s.run("B:Show() A:Hide()").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"and not with the frame it is merely anchored to"
);
// A non-Frame argument raises. A texture is the case the reference names, and it is the one an
// addon actually hits (a mixed table of frames and textures walked in a loop).
for bad in ["Spark:SetParent(Spark)", "Spark:SetParent(5)"] {
let e = s
.run(bad)
.expect_err("a non-frame parent must raise")
.to_string();
assert!(
e.contains("expected frame"),
"wanted the reference's 'expected frame' rejection, got: {e}"
);
}
// A MISSING argument is not the nil form: TNONE never reaches the nil branch.
assert!(s.run("Spark:SetParent()").is_err(), "no argument raises");
// The nil form detaches without error: orphaned and unrendered, not destroyed — a later
// re-parent brings the same region back.
s.run("A:Show() Spark:SetParent(nil)").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
None,
"a detached region draws nothing"
);
s.run("Spark:SetParent(A)").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"and re-parenting restores it"
);
}
/// **`Button:SetFont(file, height [, flags])` — it exists, it returns NOTHING, and it never
/// touches the label.** `0x780880`, wow-re's widget-method batch Q8 (§5-verified).
/// `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)` and is blocked
/// on it today.
///
/// Three traps, each pinned here because each is a plausible implementation's silent divergence.
/// **Zero return values**: the shared impl pushes `1`/nil (a real font-load probe on a Font object)
/// and Button *discards* it (`xor eax,eax`), so answering `true` would hand an addon a probe the
/// client does not have. **`GetFont` returns three values**, off the normal embedded font — which
/// resolves through what that state inherits when nothing set it locally. And **a bare Button with
/// no `<ButtonText>` is a silent no-op**: `SetFont`/`GetFont` never dereference the FontString
/// pointer `+0x338`, so there is no error and — the observable half — **no lazy label creation**,
/// which `GetFontString()` would report.
#[test]
fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
Bare = CreateFrame("Button", "SFBare")
Labelled = CreateFrame("Button", "SFLabelled")
Labelled:SetText("Show Keybinds")
"#,
)
.unwrap();
// _LazyPig's line, on a Button that has no label at all.
assert_eq!(
s.eval::<i64>(r#"return select('#', SFBare:SetFont("Fonts\\FRIZQT__.TTF", 8))"#)
.unwrap(),
0,
"SetFont returns ZERO values — the delegate's 1/nil is discarded by a Button"
);
assert!(
s.eval::<bool>("return SFBare:GetFontString() == nil")
.unwrap(),
"no label existed and none was lazily created"
);
assert_eq!(
s.eval::<i64>("return select('#', SFBare:GetFont())")
.unwrap(),
3,
"GetFont returns THREE values"
);
assert_eq!(
s.eval::<(String, f32, String)>("return SFBare:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 8.0, String::new())
);
// Still three when nothing has ever set a font — path and height are nil, not absent.
assert_eq!(
s.eval::<i64>("return select('#', SFLabelled:GetFont())")
.unwrap(),
3
);
assert!(s
.eval::<bool>("local f = SFLabelled:GetFont() return f == nil")
.unwrap());
// The flags argument is normalized to its OUTLINETYPE token, like every other GetFont.
s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF", 12, "THICKOUTLINE")"#)
.unwrap();
assert_eq!(
s.eval::<String>("local _, _, flags = SFBare:GetFont() return flags")
.unwrap(),
"THICKOUTLINE"
);
// Both arguments are required (`lua_isstring` + `lua_isnumber`, else the usage error).
assert!(s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF")"#).is_err());
// Unset locally, GetFont reads through what the NORMAL state inherits — how a
// GameMenuButtonTemplate button answers its template's face before anything calls SetFont.
s.run(
r#"
CreateFont("SFInherited")
SFInherited:SetFont("Fonts\\MORPHEUS.TTF", 14)
SFLabelled:SetTextFontObject(SFInherited)
"#,
)
.unwrap();
assert_eq!(
s.eval::<(String, f32)>("local p, h = SFLabelled:GetFont() return p, h")
.unwrap(),
("Fonts\\MORPHEUS.TTF".to_string(), 14.0)
);
// …and a local SetFont outranks that inherited object on the label's PAINT, not only in the
// getter — the whole point of the call `_LazyPig` makes.
s.run(r#"SFLabelled:SetFont("Fonts\\SKURRI.TTF", 9)"#)
.unwrap();
let (font, height) = s
.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
font,
font_height,
..
} if t == "Show Keybinds" => Some((font.clone(), *font_height)),
_ => None,
})
.expect("the labelled button draws its text");
assert_eq!(
(font.as_deref(), height),
(Some("Fonts\\SKURRI.TTF"), Some(9.0)),
"the button's own font repaints the label over the inherited font object"
);
}
/// **`SetNonSpaceWrap` / `CanNonSpaceWrap`** — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
/// widget-method batch). `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached
/// by two addons.
///
/// Two contract details, both easy to get wrong and both pinned here: the getter is
/// **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil** rather than a
/// boolean — 1.12 predates that convention and an addon may compare against 1. And a **no-argument
/// call ENABLES** it; the default is on, so it is not a query.
#[test]
fn non_space_wrap_defaults_on_and_answers_one_or_nil() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "NSF") fs = f:CreateFontString("NSFText", "ARTWORK")"#)
.unwrap();
// Default is ON, and the answer is the NUMBER 1 — not `true`.
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"default on, answered as 1"
);
assert!(
!s.eval::<bool>("return NSFText:CanNonSpaceWrap() == true")
.unwrap(),
"it must NOT be a boolean — an addon comparing against 1 would break"
);
// oRA2's line.
s.run("NSFText:SetNonSpaceWrap(false)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
None,
"off answers nil, not 0 and not false"
);
// A no-argument call ENABLES rather than querying.
s.run("NSFText:SetNonSpaceWrap()").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"a bare SetNonSpaceWrap() turns it ON"
);
}
/// **The EditBox font block — the first sixteen entries of its own registrar table, and the largest
/// single gap the per-kind widget-method census found** (decision 1229, whose ranking opens
/// `63 EditBox:SetFontObject (on Texture, FontString)`).
///
/// `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at
/// the registering site `0x799ab5`, never from a run-length scan. There is no `FontInstance` class
/// in the 1.12 Lua chain (wow-re `widget-api-batch-benilla.md`): each of the six text-bearing types
/// re-declares the block in its own flat table, so membership is a per-table fact and **a name we
/// add that the table does not carry is exactly as wrong as one we miss**.
///
/// The two halves pinned here are therefore both directions. Present: the fourteen we wire.
/// Absent: `SetSpacing`/`GetSpacing`, which the table *does* carry (#10–#11) and we deliberately
/// withhold because nothing here models line spacing — a stored-but-undrawn setter is the silent
/// divergence of 1203/1205/1211, while a nil-value call names itself, and corpus demand is zero.
/// Absent too: the names that belong to *other* tables and must not leak onto an EditBox through
/// ours — `CopyFontObject` (Font object only), `SetNonSpaceWrap`/`CanNonSpaceWrap` and
/// `SetTextHeight`/`GetStringWidth` (FontString only).
#[test]
fn editbox_carries_the_font_block_its_registrar_table_declares() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"EB = CreateFrame("EditBox", "EBFont")"#).unwrap();
for name in [
"SetFontObject",
"GetFontObject",
"SetFont",
"GetFont",
"SetTextColor",
"GetTextColor",
"SetShadowColor",
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
"GetJustifyV",
] {
assert_eq!(
s.eval::<String>(&format!("return type(EBFont.{name})"))
.unwrap(),
"function",
"EditBox table entry '{name}' is missing"
);
}
for name in [
// On EditBox's real table, withheld on purpose (nothing models spacing).
"SetSpacing",
"GetSpacing",
// Not on EditBox's table at all — adding one is the same class of error as missing one.
"CopyFontObject",
"SetNonSpaceWrap",
"CanNonSpaceWrap",
"SetTextHeight",
"GetStringWidth",
] {
assert!(
s.eval::<bool>(&format!("return EBFont.{name} == nil"))
.unwrap(),
"'{name}' must NOT answer on an EditBox"
);
}
}
/// **Every EditBox font binding is a type-guard shim that returns whatever the shared implementation
/// returned**, so the arities below are the shared ones verbatim — verified at the bytes: each
/// binding ends `call <shared>` then `pop edi; pop esi; pop ebx; ret`, with **no `mov eax,N` and no
/// `xor eax,eax`** after the call.
///
/// The trap is `SetFont`, and it points the opposite way to the one already pinned for Button.
/// `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** —
/// [`button_set_font_returns_nothing_and_is_a_no_op_without_a_label`]. `EditBox:SetFont`
/// (`0x797210` → `0x79f210`) does not discard, so the shared `lua_pushnumber(1.0)` (`0x79f345`) /
/// `lua_pushnil` (`0x79f361`) passes straight through: **one value, the number `1`, not `true` and
/// not zero values**. A probe written `if not eb:SetFont(f, s) then` needs the nil half; a probe
/// written `== 1` needs the number half.
///
/// `GetShadowColor` returns **four** values (`0x79f9b3 mov eax,0x4`); three is the plausible wrong
/// answer and silently drops the alpha.
#[test]
fn editbox_font_block_return_shapes_are_the_shared_implementations() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
EB = CreateFrame("EditBox", "EBShape")
CreateFont("EBProbeFont")
EBProbeFont:SetFont("Fonts\\FRIZQT__.TTF", 14)
"#,
)
.unwrap();
// The mutators push nothing at all.
for call in [
"SetFontObject(EBProbeFont)",
"SetTextColor(1, 0, 0)",
"SetShadowColor(0, 0, 0, 0.3)",
"SetShadowOffset(1, -1)",
r#"SetJustifyH("CENTER")"#,
r#"SetJustifyV("TOP")"#,
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', EBShape:{call})"))
.unwrap(),
0,
"EditBox:{call} must return ZERO values"
);
}
// The getters' exact widths.
for (call, want) in [
("GetFontObject()", 1),
("GetFont()", 3),
("GetTextColor()", 4),
("GetShadowColor()", 4),
("GetShadowOffset()", 2),
("GetJustifyH()", 1),
("GetJustifyV()", 1),
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', EBShape:{call})"))
.unwrap(),
want,
"EditBox:{call} must return {want} value(s)"
);
}
// SetFont: ONE value, and it is the number 1 — not `true`, and not nothing (the Button shape).
assert_eq!(
s.eval::<i64>(r#"return select('#', EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
1,
"EditBox:SetFont returns one value, unlike Button:SetFont"
);
assert_eq!(
s.eval::<String>(r#"return type(EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
"number",
"the number 1, never the boolean true"
);
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("Fonts\\SKURRI.TTF", 12) == 1"#)
.unwrap());
// An unloadable (empty) path is the FALSEY answer, not an error — `!OmniCC`'s probe idiom.
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("", 12) == nil"#)
.unwrap());
// Both arguments are gated (`lua_isstring` + `lua_isnumber`, else the usage error).
assert!(s.run(r#"EBShape:SetFont("Fonts\\SKURRI.TTF")"#).is_err());
// The shadow round-trips all four channels — three would drop the alpha.
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return EBShape:GetShadowColor()")
.unwrap(),
(0.0, 0.0, 0.0, 0.3)
);
assert_eq!(
s.eval::<(f32, f32)>("return EBShape:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
// GetFontObject answers the OBJECT, never its name — the corpus indexes the result immediately.
assert_eq!(
s.eval::<String>("return type(EBShape:GetFontObject())")
.unwrap(),
"table"
);
}
/// **The real `Dewdrop-2.0` shape, which is the whole reason this landed.**
///
/// `Dewdrop-2.0.lua:1673-1675` is verbatim:
/// ```lua
/// local editBox = CreateFrame("EditBox", nil, editBoxFrame)
/// editBoxFrame.editBox = editBox
/// editBox:SetFontObject(ChatFontNormal)
/// ```
/// — an **anonymous** EditBox parented to a frame, then `SetFontObject` on it two lines later. That
/// file is vendored into **63 addon folders** of the 218-addon corpus (65 copies: `FuBar` plus ~50
/// FuBar plugins, `BigWigs`, `AtlasLoot`, `oRA2`, `SnaFu`, …), so the census's `63` is **one library
/// replicated**, not 63 independent addons (decision 1207).
///
/// `AceGUIWidget-Slider.lua:204-210` is the same shape with `SetJustifyH("CENTER")` on the end.
#[test]
fn the_dewdrop_editbox_font_shape_resolves_and_paints() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
CreateFont("ChatFontNormal")
ChatFontNormal:SetFont("Fonts\\ARIALN.TTF", 11)
ChatFontNormal:SetTextColor(0.9, 0.9, 0.9)
editBoxFrame = CreateFrame("Frame", "DewdropEBFrame")
local editBox = CreateFrame("EditBox", nil, editBoxFrame)
editBoxFrame.editBox = editBox
editBox:SetFontObject(ChatFontNormal)
editBox:SetWidth(160)
editBox:SetHeight(13)
"#,
)
.unwrap();
// The link is live and readable back as the object.
assert!(s
.eval::<bool>("return DewdropEBFrame.editBox:GetFontObject() == ChatFontNormal")
.unwrap());
// …and the object's paint really reached the box's implicit FontString (the `[this+0x324]` the
// reference's shim hands the shared implementation).
assert_eq!(
s.eval::<(String, f32)>("local p, h = DewdropEBFrame.editBox:GetFont() return p, h")
.unwrap(),
("Fonts\\ARIALN.TTF".to_string(), 11.0)
);
let (r, g, b, _) = s
.eval::<(f32, f32, f32, f32)>("return DewdropEBFrame.editBox:GetTextColor()")
.unwrap();
assert_eq!((r, g, b), (0.9, 0.9, 0.9));
// The AceGUI slider's tail, on the same box.
s.run(r#"DewdropEBFrame.editBox:SetJustifyH("CENTER")"#)
.unwrap();
}
/// **`SetJustifyH`/`SetJustifyV` mask to their own axis, and a token from the other axis silently
/// clears rather than erroring.** The enum is `.rdata 0x811ad0`, `{bits, token}`: `LEFT` 0x01,
/// `CENTER` 0x02, `RIGHT` 0x04, `TOP` 0x08, `MIDDLE` 0x10, `BOTTOM` 0x20, stored in one dword with
/// bits 0–2 horizontal and 3–5 vertical.
///
/// Two traps, both of which our older FontString/Font copies of these verbs get wrong by coercing
/// anything unrecognised to CENTER/MIDDLE:
/// · **an unknown token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`);
/// · **a valid token from the wrong axis parses and then masks to nothing** — `SetJustifyH("TOP")`
/// yields 0x08 and `0x08 & 0x07 == 0`, so justifyH is CLEARED and `GetJustifyH()` answers the
/// literal `"UNKNOWN"` (`0x6f1a00`, `.data 0x838044`). No error is raised either way.
#[test]
fn editbox_justify_masks_to_its_axis_and_answers_unknown() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"EB = CreateFrame("EditBox", "EBJustify")"#)
.unwrap();
// **LEFT, not the generic font default CENTER.** The `CSimpleFont` ctor really does default to
// `0x212` (CENTER | MIDDLE | 0x200), but the EditBox ctor overrides the horizontal axis right
// after linking its font instance (`0x779bcd … and eax,~6; or eax,1;` stored at `0x779be4`), so
// a fresh box starts at `0x211`. Taking the generic default is the plausible wrong answer, and
// it is exactly what this test asserted until wow-re's §5 trio read that ctor
// (`system/ui/scratch/editbox-font-surface.md` §6.2).
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyH()").unwrap(),
"LEFT"
);
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyV()").unwrap(),
"MIDDLE"
);
// Each axis is replaced independently — setting V leaves H alone.
s.run(r#"EBJustify:SetJustifyH("LEFT") EBJustify:SetJustifyV("BOTTOM")"#)
.unwrap();
assert_eq!(
s.eval::<(String, String)>("return EBJustify:GetJustifyH(), EBJustify:GetJustifyV()")
.unwrap(),
("LEFT".to_string(), "BOTTOM".to_string())
);
// The cross-axis trap: "TOP" parses, masks to 0, and CLEARS justifyH.
s.run(r#"EBJustify:SetJustifyH("TOP")"#).unwrap();
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyH()").unwrap(),
"UNKNOWN",
"a vertical token on SetJustifyH clears the axis rather than centering it"
);
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyV()").unwrap(),
"BOTTOM",
"and it leaves the vertical axis untouched"
);
// An unrecognised token is the usage error, never a silent fallback to CENTER.
assert!(s.run(r#"EBJustify:SetJustifyH("SIDEWAYS")"#).is_err());
}
/// **`EditBox:SetJustifyV` echoes through its getter and never reaches the pixels — on the real
/// client too, permanently and by construction.** wow-re `editbox-font-surface.md` §6 (§5 trio +
/// byte arbitration): `CSimpleFontString+0x124` is a per-bit *inherit* mask over the rendered
/// justify `+0x120`, and `SetMultiLine 0x77a4a0` clears the whole vertical group `0x38` from it on
/// **both** legs while writing the V bits locally — multi-line → TOP, single-line → MIDDLE. The
/// EditBox ctor calls `SetMultiLine` unconditionally at birth (`0x779c2f`, with the ctor's zero
/// register), and a census of all 256 `+0x124` operands image-wide found every `CSimpleFontString`
/// writer to be an AND: **nothing ever ORs an inherit bit back in.** So the value written by
/// `SetJustifyV` is masked out at `0x77086e`, while `GetJustifyV` reads the font *instance*
/// (`0x79fd73`) and answers it faithfully.
///
/// This pins the shape rather than the accident: a future change that "fixes" the getter by wiring
/// V justify through to the region would match neither the client nor our own draw law.
#[test]
fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
EB = CreateFrame("EditBox", "EBJV")
EBJV:SetPoint("CENTER", 0, 0) EBJV:SetWidth(120) EBJV:SetHeight(40)
EBJV:SetText("typed")
"#,
)
.unwrap();
/// The vertical justification the box's text actually draws with.
fn drawn_v(s: &crate::script::UiScript) -> crate::script::JustifyV {
s.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
justify_v,
..
} if t == "typed" => Some(*justify_v),
_ => None,
})
.expect("the box draws its text")
}
// A single-line box renders MIDDLE, the `0x77a599` leg.
assert_eq!(drawn_v(&s), crate::script::JustifyV::Middle);
// SetJustifyV("TOP") is echoed by the getter…
s.run(r#"EBJV:SetJustifyV("TOP")"#).unwrap();
assert_eq!(
s.eval::<String>("return EBJV:GetJustifyV()").unwrap(),
"TOP",
"the getter reads the font instance and echoes faithfully"
);
// …and does NOT move the pixels. This is the disagreement, and it is faithful.
assert_eq!(
drawn_v(&s),
crate::script::JustifyV::Middle,
"SetJustifyV is masked out of the rendered justify — multiLine alone decides it"
);
// multiLine is what actually moves it, on the `0x77a509` leg.
s.run("EBJV:SetMultiLine(true)").unwrap();
assert_eq!(drawn_v(&s), crate::script::JustifyV::Top);
// …and the getter still echoes whatever was last set, unaffected.
assert_eq!(
s.eval::<String>("return EBJV:GetJustifyV()").unwrap(),
"TOP"
);
}
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.**
///
/// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is
/// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5
/// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the
/// verb reporting "no" is a real machine's answer, not a stub.
///
/// We have no desaturating shader, so nil is the honest reply: claiming support would suppress
/// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong.
/// The test pins the return shape rather than the state, because the return is what callers branch
/// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes
/// `not shaderSupported`.
///
/// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via
/// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded.
#[test]
fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#)
.unwrap();
// Dewdrop's exact call — the one 98 addons reach. It must not raise.
s.run("DsTex:SetDesaturated(true)").unwrap();
// ...and it answers nil, so the reference's `not shaderSupported` branch is taken.
assert!(
s.eval::<bool>("return DsTex:SetDesaturated(true) == nil")
.unwrap(),
"shaderSupported must be nil (1|nil C shape), not false"
);
assert_eq!(
s.eval::<i64>("return select('#', DsTex:SetDesaturated(true))")
.unwrap(),
1,
"one return value"
);
// The reference's own consumer, transcribed: a falsy answer greys via vertex colour.
s.run(
r#"
local shaderSupported = DsTex:SetDesaturated(true)
local r, g, b = 1, 1, 1
if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end
DsTex:SetVertexColor(r, g, b)
"#,
)
.unwrap();
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys");
// Both spellings of "off" are off — nil and false.
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
.unwrap();
}
@@ -574,3 +574,91 @@ fn a_vertex_colour_without_a_texture_draws_nothing() {
"no texture at +0xcc -> emit NOTHING, never a solid plate of the surviving tint"
);
}
/// **`SetTexture` ignores arguments past the path**, because the client's does.
///
/// The path form reads ONE argument (`0x770200`); only the colour form (`0x770360`) reads up to
/// four, and a C function takes what it wants off the Lua stack and ignores the rest. We typed the
/// trailing three as `Option<f32>` and so RAISED on a stray extra — `bad argument #3: error
/// converting Lua boolean to f32` — where the client silently accepts.
///
/// The live case is `_LazyPig/LazyPigMenu.lua:182`:
/// `texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`. The `true` is
/// meaningless in 1.12; the addon reached us only once the survey began seating the addon registry.
///
/// Asserted as "does not raise", which is the whole claim — there is no `GetTexture` binding to read
/// the path back through, and inventing one to satisfy a test would be the tail wagging the dog.
/// The colour form is exercised alongside so the fix cannot have made it lax: a non-numeric channel
/// must take the same default a missing one does, exactly as `lua_tonumber` of a non-number is 0.
#[test]
fn set_texture_ignores_arguments_past_the_path_like_the_client_does() {
let s = script();
s.run(
r#"
f = CreateFrame("Frame", "SetTexArgs")
t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header", true)
"#,
)
.expect("a stray extra argument must not raise — the client ignores it");
// The colour form, with a boolean and a numeric string among the channels.
s.run("t:SetTexture(0.25, '0.5', true)")
.expect("the colour form must tolerate what lua_tonumber tolerates");
// And the ordinary shapes still work.
s.run("t:SetTexture(nil) t:SetTexture('') t:SetTexture(1, 0, 0, 1)")
.expect("clear, blank and the plain colour form are unaffected");
}
/// **`SetGradientAlpha` / `SetGradient` exist, store both stops, and paint.**
///
/// These were the single wall in front of the corpus's largest family: `FuBar\FuBar_Panel.lua:144`
/// calls `SetGradientAlpha` while building the bar, so all 20 FuBar plugins died there — right after
/// the chunk-name/`debugstack` fix got them that far.
///
/// Asserted on the region PAINTING (a gradient-only region must emit a quad, because the client
/// generates the gradient into the same texture slot the colour form fills) and on the midpoint
/// being what a one-tint quad shows. The full gradient stays on the region for a renderer that
/// grows a second stop.
#[test]
fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "GradProbe")
f:SetWidth(100) f:SetHeight(20)
f:SetPoint("TOPLEFT", 0, 0)
t = f:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(f)
-- FuBar's own call: white at both stops, ALPHA only, vertical.
t:SetGradientAlpha("VERTICAL", 1, 1, 1, 0, 1, 1, 1, 0.5)
"#,
)
.expect("SetGradientAlpha must exist and accept the client's argument shape");
s.resolve();
let painted = s.extract().iter().any(|q| {
matches!(&q.content, crate::script::QuadContent::Texture { color: Some(c), .. }
// midpoint of alpha 0.0 and 0.5
if (c[3] - 0.25).abs() < 1e-6 && c[0] == 1.0)
});
assert!(
painted,
"a region carrying only a gradient must paint, at the midpoint of its two stops"
);
// The alpha-less twin: both stops opaque, and a non-"VERTICAL" token is horizontal.
s.run("t:SetGradient('HORIZONTAL', 1, 0, 0, 0, 0, 1)")
.expect("SetGradient takes six colour arguments and no alpha");
s.resolve();
let mid = s.extract().iter().find_map(|q| match &q.content {
crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c),
_ => None,
});
assert_eq!(
mid.map(|c| [c[0], c[1], c[2], c[3]]),
Some([0.5, 0.0, 0.5, 1.0]),
"SetGradient's stops are opaque, so the midpoint alpha is 1"
);
}
+111 -2
View File
@@ -92,8 +92,60 @@ fn sandbox_removes_dangerous_globals() {
)
.unwrap();
assert!(all_nil);
// debugstack stub survives and returns "".
assert_eq!(s.eval::<String>("return debugstack()").unwrap(), "");
// `debugstack` survives the sandbox — and now returns a REAL traceback, not the `""` this
// used to assert. The stub was fine for addons that only DISPLAY it and wrong for the ones
// that PARSE it: `FuBarPlugin-2.0.lua:752` finds each plugin's own folder in
// `debugstack(6, 1, 0)`, and against `""` that returned nil and killed 20 corpus addons.
let trace = s.eval::<String>("return debugstack()").unwrap();
assert!(
trace.contains("traceback"),
"debugstack must return a real traceback: {trace:?}"
);
// A level far past the top of the stack yields the bare header and never raises — a caller
// that guesses too deep still gets a string it can `string.find` against, which is exactly how
// every corpus caller uses it.
assert_eq!(
s.eval::<String>("return debugstack(99)").unwrap().trim(),
"stack traceback:"
);
}
/// **An addon's chunk is named the way the CLIENT names it**, because addons parse that name.
///
/// `FuBarPlugin-2.0.lua:752` is `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, and it
/// feeds the capture into `format("Interface\\AddOns\\%s\\icon", folderName)`. Without a name
/// mlua defaults the chunk to the RUST caller location, that pattern misses, and every FuBar plugin
/// dies formatting a nil three frames from the cause.
///
/// The greedy `(.*)` is why the file has to be in the name too: it captures to the LAST backslash.
#[test]
fn an_addon_chunk_is_named_the_way_the_client_names_it() {
let name = crate::script::addon_chunk_name("FuBar_BagFu", "FuBar_BagFu.lua");
assert_eq!(name, "@Interface\\AddOns\\FuBar_BagFu\\FuBar_BagFu.lua");
// FuBar's own pattern, run for real against a traceback from a chunk loaded under that name.
let s = script();
s.run_chunk_named(
b"function BenillaProbeFolder() return debugstack(1, 1, 0) end",
&name,
)
.unwrap();
let folder: String = s
.eval(
"local _, _, f = string.find(BenillaProbeFolder(), \"\\\\AddOns\\\\(.*)\\\\\") \
return f or '<no match>'",
)
.unwrap();
assert_eq!(
folder, "FuBar_BagFu",
"FuBarPlugin's own capture must yield the folder name"
);
// A nested path in the manifest keeps its separators as backslashes.
assert_eq!(
crate::script::addon_chunk_name("Big", "libs/Thing/Thing.lua"),
"@Interface\\AddOns\\Big\\libs\\Thing\\Thing.lua"
);
}
#[test]
@@ -211,3 +263,60 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
)
.unwrap();
}
/// **`time()` is real epoch seconds and `date()` formats them** — engine globals in the 1.12
/// client's own `_G` (slots 34/33 of its base registry) that we lacked entirely, because the
/// sandbox strips `os`.
///
/// `time` was the top name in the session-start `attempt to call global` row. Every corpus site
/// persists it into SavedVariables and compares across sessions
/// (`FTC_Save[k].LastCheck = time()`), so a session-relative clock like `GetTime` would be wrong in
/// a way that only shows up on the SECOND login.
///
/// The formats asserted are the ones read off real call sites, against a FIXED epoch so the
/// conversion is checked rather than the machine's clock: 2001-09-09 01:46:40 UTC, a Sunday.
#[test]
fn time_is_epoch_seconds_and_date_formats_them() {
let s = script();
// A plausible wall clock: after 2020, before 2100. Pins that this is not GetTime's 0-based one.
let now: i64 = s.eval("return time()").unwrap();
assert!(
(1_577_836_800..4_102_444_800).contains(&now),
"time() must be wall-clock epoch seconds, got {now}"
);
// 1_000_000_000 = 2001-09-09 01:46:40 UTC, a Sunday, day 252 of the year.
for (fmt, want) in [
("%Y-%m-%d", "2001-09-09"),
("%H:%M:%S", "01:46:40"),
(
"%A, %B %d, %Y - %H:%M",
"Sunday, September 09, 2001 - 01:46",
),
("%a %b %y", "Sun Sep 01"),
("%I:%M %p", "01:46 AM"),
("%j", "252"),
("%w", "0"),
("%c", "Sun Sep 9 01:46:40 2001"),
("100%%", "100%"),
// An unknown specifier is emitted verbatim rather than swallowed.
("%Q", "%Q"),
] {
let got: String = s
.eval(&format!(r#"return date("{fmt}", 1000000000)"#))
.unwrap();
assert_eq!(got, want, "date({fmt:?})");
}
// Bare `date()` is `%c` (Recap.lua:2690 calls it with no arguments) and must not raise.
let bare: String = s.eval("return date()").unwrap();
assert!(
bare.len() > 10,
"bare date() must format something: {bare:?}"
);
// A leap day, because the civil conversion is where a date implementation goes wrong.
let leap: String = s.eval(r#"return date("%Y-%m-%d %A", 951782400)"#).unwrap();
assert_eq!(leap, "2000-02-29 Tuesday");
}
+36 -1
View File
@@ -78,7 +78,21 @@ impl UiScript {
.map(|(&h, _)| h)
.filter(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible))
.collect();
frames.into_iter().map(|h| model.frame_id(h)).collect()
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
// **Deterministic order, by frame id — i.e. creation order.**
//
// `model.scripts` is a hash map, so this sweep used to fire OnUpdate in an order that
// varied per process. That was invisible for as long as every geometry getter answered
// from the PREVIOUS frame's resolve: each handler saw the same stale world regardless
// of when it ran. The moment the getters began settling on demand (so a handler can
// observe work an earlier handler did this same sweep), the order became load-bearing
// and the macro window's tab width started flipping 164/167 run to run.
//
// Random order was always wrong — the reference sweeps a defined order — the staleness
// was merely hiding it. Creation order is stable, matches FrameXML declaration order,
// and is what a handler reading a sibling's geometry should see.
ids.sort_unstable();
ids
};
for id in ids {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
@@ -90,6 +104,27 @@ impl UiScript {
// AtBottom freeze gate lives inside `ScrollingMessageState::tick`.
let now = self.now();
let mut model = self.model_mut();
// The sibling class's OnUpdate (`0x786200`): the same two-phase fade with no scroll gate,
// plus the capacity law that is this class's stand-in for `maxLines` — the cap is what fits
// vertically, so it needs the frame's resolved rect and is collected first (the arena walk
// below holds a mutable borrow that cannot also read `model.resolved`).
let message_frames: Vec<(FrameHandle, usize)> = model
.arena
.iter_frames()
.filter(|(_, f)| matches!(f.kind_state, crate::widget::KindState::Message(_)))
.map(|(h, _)| h)
.collect::<Vec<_>>()
.into_iter()
.map(|h| (h, Self::message_viewport_rows(&model, h)))
.collect();
for (h, viewport_rows) in message_frames {
if let Some(crate::widget::KindState::Message(mf)) =
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
{
mf.tick(elapsed);
mf.trim_to_viewport(viewport_rows);
}
}
let mut finished_cooldowns: Vec<FrameHandle> = Vec::new();
for (h, frame) in model.arena.iter_frames_mut() {
if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state {
+79 -1
View File
@@ -210,7 +210,8 @@ pub enum Outline {
}
impl Outline {
/// Parse an OUTLINETYPE token (`"NONE"`/`"NORMAL"`/`"THICK"`, case-insensitive); unknown ⇒ `None`.
/// Parse the **XML** OUTLINETYPE attribute (`outline="NONE"/"NORMAL"/"THICK"`,
/// case-insensitive); unknown ⇒ `None`. This is *not* the Lua spelling — see [`Outline::flags`].
pub fn parse(s: &str) -> Outline {
match s.to_ascii_uppercase().as_str() {
"NORMAL" => Outline::Normal,
@@ -219,6 +220,33 @@ impl Outline {
}
}
/// Parse a **Lua `SetFont` flags string** — a different vocabulary from the XML attribute
/// above, and mixing the two is a silent no-op: `SetFont(path, h, "OUTLINE")` through
/// [`Outline::parse`] matches nothing and lands on `NONE`.
///
/// The reference reads the argument as a *set* of substrings against
/// `{0x1 OUTLINE, 0x4 THICKOUTLINE, 0x2 MONOCHROME}` (`0x811b10`, wow-re
/// `system/ui/scratch/widget-api-batch-benilla.md` Q8), which is why this is `contains` and not
/// equality — `"OUTLINE, MONOCHROME"` is a real thing addons write. MONOCHROME has no field
/// here (we model no glyph AA mode) and is ignored; `THICK` is checked first because
/// `"THICKOUTLINE"` contains `"OUTLINE"`.
///
/// One function, three callers: `Button:SetFont`, `FontString:SetFont` and `Font:SetFont` all
/// delegate to the *same* impl in the reference (`0x79f210`), so they cannot be allowed to
/// disagree here. They did: the Font-object binding parsed its Lua flags with the XML reader,
/// so `GameFontNormal:SetFont(f, h, "OUTLINE")` silently cleared the outline instead of setting
/// it, while the FontString binding carried its own inline copy of this match.
pub fn flags(s: &str) -> Outline {
let s = s.to_ascii_uppercase();
if s.contains("THICK") {
Outline::Thick
} else if s.contains("OUTLINE") {
Outline::Normal
} else {
Outline::None
}
}
/// The OUTLINETYPE token (for `GetFont`'s flags return).
pub fn as_str(self) -> &'static str {
match self {
@@ -299,6 +327,28 @@ pub struct ExtractedQuad {
/// The visual state of a region leaf (`Texture`/`FontString`), stored beside the arena because
/// [`crate::widget::Region`] models only structure (kind/owner/layer), not paint. See [`object`]'s
/// `SetTexture`/`SetVertexColor`/`SetText`.
/// A two-stop linear gradient (`SetGradientAlpha`/`SetGradient`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gradient {
/// `true` for `"VERTICAL"`, `false` for `"HORIZONTAL"` — the client matches the token
/// case-insensitively and treats anything else as horizontal.
pub vertical: bool,
/// The gradient's two stops, RGBA. `SetGradient` (no alpha) sets both alphas to 1.
pub start: [f32; 4],
pub end: [f32; 4],
}
impl Gradient {
/// The single colour a one-tint quad can show for this gradient — the midpoint.
pub fn midpoint(&self) -> [f32; 4] {
let mut out = [0.0; 4];
for (i, chan) in out.iter_mut().enumerate() {
*chan = (self.start[i] + self.end[i]) / 2.0;
}
out
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct RegionData {
/// Region-level visibility (`Show`/`Hide` on a Texture/FontString, XML `hidden=` — the real
@@ -333,6 +383,21 @@ pub(crate) struct RegionData {
/// ARGB), which is why the XML alpha rides in the **texel** and multiplies with
/// [`Self::vertex_color`] rather than being replaced by it.
pub(crate) fill: Option<[f32; 4]>,
/// `SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2)` / `SetGradient(...)` — the two-stop
/// linear gradient the client generates into the same texture slot the colour form fills.
///
/// **Stored in full, painted as its midpoint, and that gap is stated rather than implied.** The
/// UI renderer has one tint per quad (a colour region is the shared 1x1 white image tinted), so
/// there is nowhere yet to put a second stop; folding to the average is a *visible*
/// approximation and belongs to the director to judge (method §7). It is kept whole HERE so the
/// renderer can honour it later without another API change — the data is already right, only
/// the paint is coarse.
///
/// Measured cost of the approximation on the corpus's biggest consumer: `FuBar_Panel.lua:144`
/// is `SetGradientAlpha("VERTICAL", 1,1,1,0, 1,1,1,0.5*t)` — **white at both stops, alpha
/// only**, i.e. a soft fade, and both textures are `Hide()`n on the next two lines. A uniform
/// half-alpha band instead of a fade is the whole difference there.
pub(crate) gradient: Option<Gradient>,
/// The **vertex colour** (`SetVertexColor 0x79abd0` → `0x77f750`, writing `+0xb8`; a StatusBar's
/// `SetStatusBarColor`; a FontString's `SetTextColor` and its font object's colour) — storage
/// distinct from [`Self::fill`]/[`Self::texture`]'s `+0xcc`.
@@ -379,6 +444,19 @@ pub(crate) struct RegionData {
/// from the owner frame's rect.
pub(crate) anchors: Vec<Anchor>,
/// FontString horizontal justification (`SetJustifyH`/XML `justifyH`); default CENTER.
/// `SetNonSpaceWrap` / `CanNonSpaceWrap` — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
/// widget-method batch). **State only here.** The real client's gx flag `0x40` feeds a
/// mid-word wrap carry and the fit-count terminator whose one consumer is the ellipsis
/// truncate at `0x771ec0`; our text layout has no ellipsis path, so honouring the value in
/// layout would mean inventing one. Stored and answered faithfully, unread by the renderer —
/// which is the honest position and is stated rather than left to be discovered.
///
/// Default is ON (a no-arg `SetNonSpaceWrap()` also enables), so `None` reads as enabled.
/// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in
/// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the
/// binding answers "shader unsupported" — see `region.rs`'s `SetDesaturated`.
pub(crate) desaturated: bool,
pub(crate) non_space_wrap: Option<bool>,
pub(crate) justify_h: JustifyH,
/// FontString vertical justification (`SetJustifyV`/XML `justifyV`); default MIDDLE.
pub(crate) justify_v: JustifyV,