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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user