text justification becomes one law across every font table

- the justify verbs had been written three times and drifted three ways; one implementation now serves the region, font-object and message-frame tables, and a cleared axis paints centre, as the client's draw path really does
- MessageFrame and ScrollingMessageFrame gain the ten-verb font block they were missing entirely, so font calls on message frames stop raising
- EnableKeyboard and IsKeyboardEnabled land as real Frame methods — eight addons were dying on their absence
- the harness's METHODS ranking states its own limits instead of reading as a build queue

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:50:00 +07:00
co-authored by Claude Fable 5
parent c8ed53c4fe
commit 37998deccf
26 changed files with 1064 additions and 368 deletions
+14 -1
View File
@@ -365,7 +365,20 @@ fn main() {
// `--why <name>` now reads any row back by name.
let methods = addon_harness::method_demand(&reports);
if !methods.is_empty() {
println!("\n most-wanted missing METHODS (addons calling each as obj:Name(), no widget has it):");
// The header states BOTH limits, because the list read as a build queue and is not one
// (decision 1240). It counts addons that NAME the verb, which is neither "addons blocked
// by it" nor "call sites": its top three were once RegisterTabCompletion/IsModule/
// IsModuleActive — AceConsole-2.0's and AceAddon-2.0's own methods on their own objects,
// one library file replicated into 56 and 38 addons — and its fourth, EnableKeyboard, was
// a real absent Frame verb that was blocking none of its 8, every one of which died
// earlier and elsewhere.
println!("\n most-wanted missing METHODS — addons that NAME each as obj:Name(), and");
println!(" no widget answers. NOT a blocker list and NOT a build queue:");
println!(" · a big number is usually ONE library file replicated (1207/1210), and");
println!(
" · a third-party library's own method on its own object is not ours to write."
);
println!(" Open the call site before ranking it (decision 1240).");
for (name, count) in methods.into_iter().take(16) {
println!(" {count:>4} {name}");
}
+318
View File
@@ -0,0 +1,318 @@
//! The justify enum — **one** transcription of `.rdata 0x811ad0`, shared by every table that
//! speaks it.
//!
//! ## Why this is a module and not three sets of `match` arms
//!
//! Justification is set on **three** widget tables — a `FontString` (`0x87c1d8`), a `<Font>`
//! object (`0x87c7c8`) and an `EditBox` — and each had been transcribed *independently* from the
//! same byte law. Each drifted its own way, and each looked right alone:
//!
//! | | FontString ([`crate::script::region`]) | Font ([`crate::script::font`]) | EditBox ([`crate::widget::kinds::editbox`]) | the bytes |
//! |---|---|---|---|---|
//! | `GetJustifyH`/`V` | **absent** | present | present | present (`0x79e5f0`/`0x79e7f0`) |
//! | space-padded token | rejected | `.trim()`ed, **accepted** | rejected | rejected (whole-string) |
//! | unmatched string | silently `CENTER` | silently `CENTER` | **raises** | **raises** |
//! | cross-axis token | silently `CENTER` | silently `CENTER` | **clears the axis** | clears the axis |
//! | axis storage | resolved enum → **the dword** (1239) | resolved enum | **the raw dword** | one dword |
//!
//! That is the seam class the addon-lane round trip named: halves transcribed separately from one
//! law drift where they meet, while every test on every side still passes. **The EditBox column is
//! the one that was right** — written last, straight from the bytes, and never consulted by the two
//! older tables. So this module is not new law: it is that column, lifted to where all three reach
//! it. It sits at the crate root rather than under `script` because `widget::kinds` needs it too.
//!
//! ## The law
//!
//! Storage is a single dword — `CSimpleFont+0x54`, `CSimpleFontString+0x120` — with **bits 0–2
//! horizontal and bits 3–5 vertical**. `SetJustifyH 0x79fc20` computes `(cur ^ parsed) & 0x07 ^
//! cur`, i.e. `(cur & !0x07) | (parsed & 0x07)`, replacing only its own axis (VERIFIED at
//! `0x79fc5d`–`0x79fc64`); `SetJustifyV 0x79fce0` uses mask `0x38`. The ctor default is `0x212` =
//! `CENTER | MIDDLE | 0x200`, bit 9 being outside both masks and read by neither accessor.
//!
//! Two consequences fall out of that arithmetic, and both are transcribed below:
//!
//! - **A cross-axis token is accepted and clears.** `SetJustifyH("TOP")` parses fine (`0x08`) but
//! `0x08 & 0x07 == 0`, so it erases the horizontal justification with **no error raised**, and a
//! following `GetJustifyH()` answers `"UNKNOWN"` — while the glyphs keep drawing **centred**.
//! The two readers genuinely disagree, which is why [`Justify`] stores the dword rather than a
//! resolved enum: [`Justify::name_h`] is the getter's answer and [`Justify::paint_h`] is the
//! draw path's, and they differ only in this state.
//! - **A non-token raises.** `0x6f1990` scans all six entries with `SStrCmpI` — whole-string,
//! case-insensitive, **no trimming** — and returns 0 on a miss, which is the caller's cue to
//! raise `Usage: %s:SetJustifyH("justify")` (`.rdata 0x87c77c`) / `…SetJustifyV…`
//! (`0x87c7a0`). Per `script::binding_abi`, that arm does not return: it abandons the caller's
//! statement.
//!
//! Recorded in wow-re `system/ui/scratch/font-object-lua-surface.md` §9.4 and, for the
//! fall-through and the unconditional inherit clear, `system/ui/scratch/justify-fallthrough-law.md`
//! (§5 quad, arbitrated).
use crate::script::{JustifyH, JustifyV};
/// `.rdata 0x811ad0` — six `{u32 bits, const char* name}` entries, in image order. The order is
/// load-bearing twice over: `0x6f1990` scans it linearly, and `0x6f1a00` answers with the **first**
/// entry whose bit is set.
const TOKENS: [(u32, &str); 6] = [
(0x01, "LEFT"),
(0x02, "CENTER"),
(0x04, "RIGHT"),
(0x08, "TOP"),
(0x10, "MIDDLE"),
(0x20, "BOTTOM"),
];
/// The literal `0x6f1a00` answers when no bit in the requested axis is set (`.data 0x838044`).
pub const UNKNOWN: &str = "UNKNOWN";
/// The horizontal axis — bits 0–2, `SetJustifyH`'s mask.
pub const H_MASK: u32 = 0x07;
/// The vertical axis — bits 3–5, `SetJustifyV`'s mask.
pub const V_MASK: u32 = 0x38;
/// What a justify argument turned out to be — the three outcomes of `0x6f1990` followed by the
/// setter's mask.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Set<J> {
/// A token carrying a bit on *this* axis: the new justification.
To(J),
/// A token that matched the table but carries no bit on this axis — `SetJustifyH("TOP")`.
/// The reference **clears** the axis and raises nothing; `GetJustifyH()` then answers
/// `"UNKNOWN"` while the glyphs keep drawing centred ([`Justify::paint_h`]).
///
/// Not hypothetical: 13 corpus sites call `SetJustifyV("CENTER")` meaning "middle"
/// (`FonzAppraiser` ×12, `Roid-Macros`), which parses and then erases the vertical axis.
Clears,
/// No entry matched. The caller raises its own `Usage:` string; it does not coerce.
NoMatch,
}
/// The ctor default — `CENTER | MIDDLE | 0x200`. Bit 9 is outside both axis masks and is read by
/// neither accessor nor the gx translator. `CSimpleFontString`'s **own** ctor writes it at
/// `0x770dd3`, so a FontString that has never been told otherwise reads `CENTER`/`MIDDLE`.
const CTOR_DEFAULT: u32 = 0x212;
/// The justify **dword** — `CSimpleFont+0x54`, `CSimpleFontString+0x120`, `CSimpleEditBox`'s own —
/// with bits 0–2 horizontal and bits 3–5 vertical.
///
/// Stored as the dword rather than a pair of resolved enums because **an axis with no bit set is a
/// real, reachable state** and a resolved enum cannot hold it: `SetJustifyH("TOP")` parses `0x08`,
/// contributes nothing to mask `0x07`, and erases the axis with no error raised.
///
/// The two readers then disagree, faithfully, and that is the whole reason this type exists:
/// [`Justify::name_h`] (the Lua getter) answers `"UNKNOWN"`, while [`Justify::paint_h`] (the draw
/// path) answers `CENTER`. Anything that collapses the two loses one of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Justify(pub u32);
impl Default for Justify {
fn default() -> Self {
Justify(CTOR_DEFAULT)
}
}
impl Justify {
/// Replace the horizontal bits — `(cur & !0x07) | (parsed & 0x07)`.
pub fn set_h(&mut self, j: JustifyH) {
self.0 = set_axis(self.0, H_MASK, bits_h(j));
}
/// Replace the vertical bits — `(cur & !0x38) | (parsed & 0x38)`.
pub fn set_v(&mut self, j: JustifyV) {
self.0 = set_axis(self.0, V_MASK, bits_v(j));
}
/// Erase the horizontal axis, as a cross-axis token does.
pub fn clear_h(&mut self) {
self.0 = set_axis(self.0, H_MASK, 0);
}
/// Erase the vertical axis.
pub fn clear_v(&mut self) {
self.0 = set_axis(self.0, V_MASK, 0);
}
/// `GetJustifyH`'s answer — `0x6f1a00`, so `"UNKNOWN"` for a cleared axis.
pub fn name_h(self) -> &'static str {
name_of(self.0, H_MASK)
}
/// `GetJustifyV`'s answer.
pub fn name_v(self) -> &'static str {
name_of(self.0, V_MASK)
}
/// What the glyphs actually draw at horizontally — the ui→gx translator `0x44d420`, whose one
/// call site image-wide is `0x772693`.
///
/// **It is a priority ladder, not a switch, and the fall-through is `CENTER` — not `LEFT`.**
/// Each axis's result register is pre-set to `1` *between* the `test` and the `jcc`
/// (`0x44d4fb mov ebx,0x1`, then `0x44d516 je`), so an all-clear axis exits with `1` still in
/// it, and `1` is bit-identical to CENTER. A cleared axis is therefore visually
/// indistinguishable from the ctor default.
///
/// **The trap, quoted from the finding:** the gx layer's `else` arms genuinely *are* LEFT and
/// BOTTOM, but both are unreachable from a FontString — `0x44d420` emits only `{0,1,2}` and
/// `0x5c1c30` rejects `>= 3`. *Mapping the ui bitmask onto the gx enum with a `0` default
/// inverts both axes.*
pub fn paint_h(self) -> JustifyH {
if self.0 & 0x04 != 0 {
JustifyH::Right
} else if self.0 & 0x01 != 0 {
JustifyH::Left
} else {
JustifyH::Center // 0x02 set, or NOTHING set — the pre-set `1`
}
}
/// What the glyphs draw at vertically — the same ladder, `TOP > BOTTOM > MIDDLE`, falling
/// through to `MIDDLE`. `0x44d536`'s redundant `mov esi,0x1` on the MIDDLE arm is MSVC's own
/// tell that `default:` and `MIDDLE` share a constant.
pub fn paint_v(self) -> JustifyV {
if self.0 & 0x08 != 0 {
JustifyV::Top
} else if self.0 & 0x20 != 0 {
JustifyV::Bottom
} else {
JustifyV::Middle
}
}
}
fn bits_h(j: JustifyH) -> u32 {
match j {
JustifyH::Left => 0x01,
JustifyH::Center => 0x02,
JustifyH::Right => 0x04,
}
}
fn bits_v(j: JustifyV) -> u32 {
match j {
JustifyV::Top => 0x08,
JustifyV::Middle => 0x10,
JustifyV::Bottom => 0x20,
}
}
/// `0x6f1990` — a linear scan of all six entries with `SStrCmpI`: **whole-string,
/// case-insensitive, and without trimming**, so `"left "` is a miss.
pub fn parse_bits(s: &str) -> Option<u32> {
TOKENS
.iter()
.find(|(_, name)| s.eq_ignore_ascii_case(name))
.map(|(bits, _)| *bits)
}
/// `0x6f1a00` — the **first** entry whose bit is set within `mask`, else the literal `UNKNOWN`.
pub fn name_of(bits: u32, mask: u32) -> &'static str {
TOKENS
.iter()
.find(|(b, _)| bits & mask & b != 0)
.map_or(UNKNOWN, |(_, name)| *name)
}
/// Replace one axis's bits with `parsed`'s, masked to that axis — `0x79fc5d`'s
/// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`. This is the operation
/// that makes a cross-axis token *clear* rather than raise.
pub fn set_axis(cur: u32, mask: u32, parsed: u32) -> u32 {
(cur & !mask) | (parsed & mask)
}
/// `SetJustifyH`'s argument, resolved against mask `0x07`.
pub fn parse_h(s: &str) -> Set<JustifyH> {
match parse_bits(s) {
None => Set::NoMatch,
Some(0x01) => Set::To(JustifyH::Left),
Some(0x02) => Set::To(JustifyH::Center),
Some(0x04) => Set::To(JustifyH::Right),
Some(_) => Set::Clears,
}
}
/// `SetJustifyV`'s argument, resolved against mask `0x38`.
pub fn parse_v(s: &str) -> Set<JustifyV> {
match parse_bits(s) {
None => Set::NoMatch,
Some(0x08) => Set::To(JustifyV::Top),
Some(0x10) => Set::To(JustifyV::Middle),
Some(0x20) => Set::To(JustifyV::Bottom),
Some(_) => Set::Clears,
}
}
/// `GetJustifyH`'s answer for a resolved horizontal justification, through the same table.
pub fn name_h(j: JustifyH) -> &'static str {
name_of(bits_h(j), H_MASK)
}
/// `GetJustifyV`'s answer for a resolved vertical justification, through the same table.
pub fn name_v(j: JustifyV) -> &'static str {
name_of(bits_v(j), V_MASK)
}
/// The `Usage:` string `SetJustifyH` raises on a miss (`.rdata 0x87c77c`). The `%s` is spelled as
/// the widget type, matching the shipped convention in `script::font_block`.
pub fn usage_h(widget: &str) -> mlua::Error {
mlua::Error::runtime(format!("Usage: <{widget}>:SetJustifyH(\"justify\")"))
}
/// The `Usage:` string `SetJustifyV` raises on a miss (`.rdata 0x87c7a0`).
pub fn usage_v(widget: &str) -> mlua::Error {
mlua::Error::runtime(format!("Usage: <{widget}>:SetJustifyV(\"justify\")"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_match_is_case_insensitive_and_whole_string() {
assert_eq!(parse_h("LEFT"), Set::To(JustifyH::Left));
assert_eq!(parse_h("left"), Set::To(JustifyH::Left));
assert_eq!(parse_h("LeFt"), Set::To(JustifyH::Left));
// `SStrCmpI` compares the whole string; the client does not trim, and neither do we —
// the Font-object path used to, which is the divergence this module exists to end.
assert_eq!(parse_h("left "), Set::NoMatch);
assert_eq!(parse_h(" left"), Set::NoMatch);
assert_eq!(parse_h("leftmost"), Set::NoMatch);
assert_eq!(parse_h(""), Set::NoMatch);
}
#[test]
fn an_unmatched_string_does_not_coerce_to_center() {
// The whole point of the raise: the reference tells the addon it passed nonsense, where we
// used to silently answer CENTER on both tables.
assert_eq!(parse_h("MIDDLE_LEFT"), Set::NoMatch);
assert_eq!(parse_v("CENTERED"), Set::NoMatch);
}
#[test]
fn a_cross_axis_token_matches_but_carries_no_bit_for_this_axis() {
for v in ["TOP", "MIDDLE", "BOTTOM"] {
assert_eq!(parse_h(v), Set::Clears, "SetJustifyH({v:?})");
}
for h in ["LEFT", "CENTER", "RIGHT"] {
assert_eq!(parse_v(h), Set::Clears, "SetJustifyV({h:?})");
}
}
#[test]
fn every_token_round_trips_through_the_one_table() {
assert_eq!(name_h(JustifyH::Left), "LEFT");
assert_eq!(name_h(JustifyH::Center), "CENTER");
assert_eq!(name_h(JustifyH::Right), "RIGHT");
assert_eq!(name_v(JustifyV::Top), "TOP");
assert_eq!(name_v(JustifyV::Middle), "MIDDLE");
assert_eq!(name_v(JustifyV::Bottom), "BOTTOM");
}
#[test]
fn the_formatter_is_first_bit_set_within_the_axis_else_unknown() {
// `0x6f1a00`'s two documented edges, neither reachable through our resolved enums but both
// part of the law being transcribed: no bit in the axis answers the literal UNKNOWN, and a
// dword with two bits set answers the *first* table entry rather than the largest.
assert_eq!(name_of(0x00, H_MASK), "UNKNOWN");
assert_eq!(name_of(0x38, H_MASK), "UNKNOWN"); // vertical bits only
assert_eq!(name_of(0x05, H_MASK), "LEFT"); // LEFT | RIGHT
assert_eq!(name_of(0x30, V_MASK), "MIDDLE"); // MIDDLE | BOTTOM
// The ctor default `0x212` reads CENTER/MIDDLE, and bit 9 is invisible to both axes.
assert_eq!(name_of(0x212, H_MASK), "CENTER");
assert_eq!(name_of(0x212, V_MASK), "MIDDLE");
}
}
+1
View File
@@ -23,6 +23,7 @@
pub mod bindings_xml;
pub mod framexml;
pub mod justify;
pub mod layout;
pub mod loader;
pub mod markup;
+4 -7
View File
@@ -78,14 +78,11 @@ impl Loader<'_> {
if el.attr_bool("toplevel") {
self.call(wrapper, "SetToplevel", true, dbg);
}
// Still a genuine gap — the window-behaviour surface (input arbitration, decision 0068).
// Flagged rather than silently dropped or faked: accepting an attribute without
// implementing it is strictly worse than saying so.
// `enableKeyboard="true"` — the XML half of the flag, which enables BOTH key kinds
// (`scripts-auto-enable.md` §1-2). The flag is now real and round-trips; key delivery is
// still not gated on it, which the method's own doc states rather than this warning.
if el.attr_bool("enableKeyboard") {
self.warn_once(
"EnableKeyboard",
"`enableKeyboard` ignored: EnableKeyboard is not in the v1 object model (gap)",
);
self.call(wrapper, "EnableKeyboard", true, dbg);
}
}
+12 -1
View File
@@ -265,7 +265,18 @@ impl Loader<'_> {
.and_then(abs_value);
let outline = region.attr("outline").map(str::to_string);
if font.is_some() || height.is_some() || outline.is_some() {
self.call_region(wrapper, "SetFont", (font, height, outline), dbg);
// NOT the `SetFont` binding. XML supplies any subset of the three, while the
// reference's `SetFont` requires a path AND a height (raising `0x87c69c` otherwise),
// because the real client applies XML font attributes in C++ and never through the Lua
// method. Routing the loader through the binding is exactly what forced that binding to
// stay lenient — one name doing two jobs. This is the XML job.
if let Err(e) =
crate::script::apply_font_parts(self.lua, wrapper, font, height, outline)
{
self.report
.errors
.push(format!("{dbg}: region font attrs: {e}"));
}
}
}
}
+3 -3
View File
@@ -480,12 +480,12 @@ pub(super) fn refresh_text_region_justify(lua: &Lua, this: &Table) -> mlua::Resu
/// `<FontString inherits="ChatFontNormal"/>` inherits the FontString CENTER/MIDDLE defaults and
/// an empty focused box parks its caret mid-box (the mail send tab's original sin).
fn apply_text_region_justify(data: &mut RegionData, multi_line: bool) {
data.justify_h = crate::script::JustifyH::Left;
data.justify_v = if multi_line {
data.justify.set_h(crate::script::JustifyH::Left);
data.justify.set_v(if multi_line {
crate::script::JustifyV::Top
} else {
crate::script::JustifyV::Middle
};
});
}
// ── small shared helpers ─────────────────────────────────────────────────────────────────────
@@ -251,16 +251,21 @@ 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"))
})?;
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"))
},
"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):
// Two verified traps, both of which a plausible implementation gets wrong. This table was the
// only one that got them right; the FontString and `<Font>` copies coerced anything unknown to
// CENTER/MIDDLE until 1237 lifted this law into [`crate::justify`], which all three now share:
// · 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")`
+6 -4
View File
@@ -250,10 +250,10 @@ impl UiScript {
// The object's own justify (`<NormalFont inherits=… justifyH="LEFT"/>` —
// how the ref left-aligns a ButtonText).
if let Some(j) = fo.justify_h {
data.justify_h = j;
data.justify.set_h(j);
}
if let Some(j) = fo.justify_v {
data.justify_v = j;
data.justify.set_v(j);
}
}
// `Button:SetFont` sits BETWEEN the two: it is a local set on the button's own
@@ -311,8 +311,10 @@ impl UiScript {
QuadContent::Text {
text: data.text,
color: data.vertex_color,
justify_h: data.justify_h,
justify_v: data.justify_v,
// The gx translator's answer, not the getter's: a cleared axis draws
// CENTER/MIDDLE (`0x44d420`), where `GetJustifyH` says "UNKNOWN".
justify_h: data.justify.paint_h(),
justify_v: data.justify.paint_v(),
font: data.font_path,
font_height: data.font_height,
text_height: data.text_height,
+38 -27
View File
@@ -72,7 +72,9 @@
use mlua::{Lua, Table, Value};
use super::object::publish_global;
use super::{FontObject, FontShadow, JustifyH, JustifyV, Model, Outline, RegionData};
use crate::justify;
use super::{FontObject, FontShadow, Model, Outline, RegionData};
/// The shared metatable of every font-object handle.
const REG_FONT_META: &str = "__benilla_font_meta";
@@ -210,12 +212,12 @@ pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) {
}
if !ex.justify_h {
if let Some(j) = fo.justify_h {
d.justify_h = j;
d.justify.set_h(j);
}
}
if !ex.justify_v {
if let Some(j) = fo.justify_v {
d.justify_v = j;
d.justify.set_v(j);
}
}
}
@@ -335,15 +337,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetFont",
lua.create_function(
|lua, (this, path, height, flags): (Table, Option<String>, Option<f32>, Option<String>)| {
let ok = path.as_deref().is_some_and(|p| !p.is_empty());
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
// The same gate the FontString and EditBox tables enter — one `0x79f210`, three
// entry points. This side used to take `Option`s, so `SetFont()` answered **nil**
// where the reference raises `0x87c69c`; the FontString's copy answered the boolean
// `true` for everything. Neither was the shared routine's contract.
let (path, height) = super::font_block::set_font_args(&file, &height, "Font")?;
let ok = !path.is_empty();
edit(lua, &this, |fo| {
if ok {
fo.font = path;
}
if let Some(h) = height {
fo.height = Some(h);
fo.font = Some(path);
}
fo.height = Some(height);
if let Some(f) = flags {
// The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML
// attribute's ("NORMAL"/"THICK") — this read the XML one, so every
@@ -454,13 +459,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── justification ───────────────────────────────────────────────────────────────────────
//
// The token table, the whole-string match, the raise on a miss and the cross-axis clear are
// [`crate::justify`]'s, shared with the FontString table's identical pair. This side used to
// `.trim()` its argument and the other did not — one law, two transcriptions, and at most one
// of them could be right (the bytes say no trim: `SStrCmpI` compares the whole string).
//
// `justify_h`'s `Option` here is *inheritance*, not the reference's cleared axis: `None` means
// "this font object does not specify justification", which is what the resolve at
// [`resolve_font_object`] and `extract.rs` test. A fresh object therefore reads CENTER/MIDDLE
// through `unwrap_or_default`, which is also the client's ctor default `0x212`.
m.set(
"SetJustifyH",
lua.create_function(|lua, (this, j): (Table, String)| {
let jh = match j.trim().to_ascii_uppercase().as_str() {
"LEFT" => JustifyH::Left,
"RIGHT" => JustifyH::Right,
_ => JustifyH::Center,
let jh = match justify::parse_h(&j) {
justify::Set::To(jh) => jh,
justify::Set::Clears => return Ok(()),
justify::Set::NoMatch => return Err(justify::usage_h("Font")),
};
edit(lua, &this, |fo| fo.justify_h = Some(jh))
})?,
@@ -468,20 +483,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"GetJustifyH",
lua.create_function(|lua, this: Table| {
Ok(match read(lua, &this)?.justify_h.unwrap_or_default() {
JustifyH::Left => "LEFT",
JustifyH::Center => "CENTER",
JustifyH::Right => "RIGHT",
})
Ok(justify::name_h(
read(lua, &this)?.justify_h.unwrap_or_default(),
))
})?,
)?;
m.set(
"SetJustifyV",
lua.create_function(|lua, (this, j): (Table, String)| {
let jv = match j.trim().to_ascii_uppercase().as_str() {
"TOP" => JustifyV::Top,
"BOTTOM" => JustifyV::Bottom,
_ => JustifyV::Middle,
let jv = match justify::parse_v(&j) {
justify::Set::To(jv) => jv,
justify::Set::Clears => return Ok(()),
justify::Set::NoMatch => return Err(justify::usage_v("Font")),
};
edit(lua, &this, |fo| fo.justify_v = Some(jv))
})?,
@@ -489,11 +502,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"GetJustifyV",
lua.create_function(|lua, this: Table| {
Ok(match read(lua, &this)?.justify_v.unwrap_or_default() {
JustifyV::Top => "TOP",
JustifyV::Middle => "MIDDLE",
JustifyV::Bottom => "BOTTOM",
})
Ok(justify::name_v(
read(lua, &this)?.justify_v.unwrap_or_default(),
))
})?,
)?;
+64 -27
View File
@@ -60,10 +60,13 @@
//! 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.
//! errors instead. Alpha matches (`lua_isnumber`-gated, default 1.0).
//!
//! **The collapse below has now landed, which is what was blocking this**: the FontString and
//! EditBox tables both reach these two verbs *here*, so the correction is a one-place change rather
//! than three copies drifting. It is deliberately still not made, for a reason of its own: it turns
//! a loud error into a silent opaque black, which is faithful but is a real behaviour change and
//! belongs in its own landing with its own corpus A/B. No corpus site is known to depend on it.
//!
//! ## Where it lands, and why that is the mechanism rather than an approximation
//!
@@ -89,14 +92,18 @@
//! 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
//! ## Who installs this, and the duplicates it replaced
//!
//! `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.
//! Two tables: the `EditBox`'s (`script::editbox::methods`) and the **`FontString`'s**
//! (`script::region::text`). The FontString's used to hand-write its own copy of all ten; 1238
//! collapsed them, deleting 226 lines and carrying the correction that copy had been hiding — its
//! `SetFont` answered the boolean `true`, where the bytes above say the number `1` or nil.
//!
//! The `<Font>` object's table (`script::font`) is **not** installed from here, and that is not an
//! oversight: it writes a [`FontObject`](super::FontObject) record rather than a
//! [`RegionData`](super::RegionData), so it shares the *marshalling* ([`set_font_args`]) rather
//! than the body. Its `SetFont` had drifted the other way — `Option` arguments, so `SetFont()`
//! answered nil where `0x87c69c` raises — which is why the gate is one function now.
use mlua::{Lua, Table, Value};
@@ -110,11 +117,55 @@ use crate::widget::RegionHandle;
/// returning an error is how a method called on the wrong receiver reports it.
pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// `SetFont(path, fontHeight [, flags])`'s shared **argument gate** — the entry of `0x79f210`,
/// which Font `0x7a0270`, FontString `0x79d4f0` and EditBox `0x797210` all reach.
///
/// arg 2 must pass `lua_isstring` and arg 3 `lua_isnumber` — both of which coerce, so a numeric
/// string is accepted for either — else `Usage: %s:SetFont("font", fontHeight [, flags])`
/// (`.rdata 0x87c69c`). That arm is `luaL_error`, which longjmps, so it is an `Err` here and
/// **never** a nil answer (`super::binding_abi`).
///
/// The returned path may be **empty**, which is the caller's *load-failure* edge rather than an
/// argument error: it answers nil with nothing raised, because `!OmniCC/main.lua:41`'s
/// `if not f:SetFont(saved, size) then revert end` is a font-file validity probe. The height is
/// still applied on that edge — only the face is rejected.
///
/// Shared because it was not: the `<Font>` table took `Option<String>, Option<f32>` and so answered
/// nil for `SetFont()` where the reference raises, while the FontString's copy answered the boolean
/// `true` for everything. One gate is the only way three entry points stay one routine.
pub(super) fn set_font_args(
file: &Value,
height: &Value,
widget: &str,
) -> mlua::Result<(String, f32)> {
let usage = || {
mlua::Error::runtime(format!(
"Usage: <{widget}>:SetFont(\"font\", fontHeight [, flags])"
))
};
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()),
};
Ok((path, height))
}
/// 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<()> {
pub(super) fn install(
lua: &Lua,
m: &Table,
resolve: ResolveRegion,
widget: &'static str,
) -> 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)`).
@@ -187,21 +238,7 @@ pub(super) fn install(lua: &Lua, m: &Table, resolve: ResolveRegion) -> mlua::Res
"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 (path, height) = set_font_args(&file, &height, widget)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
@@ -60,6 +60,53 @@ pub(super) struct MessageFont {
pub(super) justify_h: JustifyH,
}
/// The FontString region a message frame's **style** lives on — found, or created on demand.
///
/// Both classes draw their lines through one font instance, and [`UiScript::message_frame_font`]
/// reads it off the frame's first `FontString` region. That region normally comes from the
/// declared direct-child `<FontString>` (LoadXML's "special" font string — our ChatFrame has one).
/// A frame built by `CreateFrame("MessageFrame")` has none, so the ten shared font-block verbs
/// need somewhere to write: this is that somewhere, and it is created **only** when one of them is
/// actually called, so a frame nobody styles never grows a region.
///
/// **It is seated LEFT deliberately.** A frame with no declared FontString falls back to LEFT (the
/// chat shape) per [`MessageFont::justify_h`]'s law — but a freshly created `RegionData` defaults
/// to the *FontString* default of CENTER. Creating the region at the default would silently
/// re-justify a frame the moment an addon touched its font, which is a visible change nobody
/// asked for.
pub(super) fn ensure_font_region(
lua: &mlua::Lua,
fh: FrameHandle,
) -> Option<crate::widget::RegionHandle> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = 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)
)
})
.copied()
});
if existing.is_some() {
return existing;
}
let rh = model.arena.create_region(
fh,
crate::widget::RegionKind::FontString,
crate::order::DrawLayer::Overlay,
0,
)?;
let mut data = crate::script::RegionData::default();
data.justify.set_h(JustifyH::Left);
model.region_data.insert(rh, data);
model.touch_layout();
Some(rh)
}
impl UiScript {
pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont {
model
@@ -81,7 +128,7 @@ impl UiScript {
height: d.font_height,
shadow: d.font_shadow,
outline: d.outline,
justify_h: d.justify_h,
justify_h: d.justify.paint_h(),
})
})
.unwrap_or(MessageFont {
@@ -170,6 +170,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fade_duration))?,
)?;
// ── the shared font block ───────────────────────────────────────────────────────────────
//
// `Set/GetFontObject · Set/GetFont · Set/GetTextColor · Set/GetShadowColor ·
// Set/GetShadowOffset` are real entries on this class's table, not a courtesy. wow-re's
// registrar carve is explicit about the membership — *"Exposed on: FontString, Font object,
// EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* — and names this
// class's own shims calling the shared implementations (`GetShadowColor 0x794810`). We shipped the block on two
// of the six and this is the third and fourth; SimpleHTML is a widget kind we do not have at
// all.
//
// Demand is observed, not counted: `BigWigs/Plugins/Messages.lua:212` is
// `self.msgframe:SetFontObject(GameFontNormalLarge)` on a frame it has just given
// `SetInsertMode("TOP")`, and BigWigs dies there every session.
crate::script::font_block::install(
lua,
&m,
|lua, this| {
let h = frame_handle_of(lua, this)?;
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a MessageFrame"))
},
"MessageFrame",
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
Ok(())
}
@@ -200,6 +200,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fade_duration))?,
)?;
// ── the shared font block ───────────────────────────────────────────────────────────────
//
// `Set/GetFontObject · Set/GetFont · Set/GetTextColor · Set/GetShadowColor ·
// Set/GetShadowOffset` are real entries on this class's table, not a courtesy. wow-re's
// registrar carve is explicit about the membership — *"Exposed on: FontString, Font object,
// EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* — and names this
// class's own shims calling the shared implementations (`GetShadowColor 0x792240`). We shipped the block on two
// of the six and this is the third and fourth; SimpleHTML is a widget kind we do not have at
// all.
//
// Demand is observed, not counted: `BigWigs/Plugins/Messages.lua:212` is
// `self.msgframe:SetFontObject(GameFontNormalLarge)` on a frame it has just given
// `SetInsertMode("TOP")`, and BigWigs dies there every session.
crate::script::font_block::install(
lua,
&m,
|lua, this| {
let h = frame_handle_of(lua, this)?;
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a ScrollingMessageFrame"))
},
"ScrollingMessageFrame",
)?;
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
// SubmitChatInput(text) — the chat input EditBox's OnEnterPressed hands the typed line here; it
+1
View File
@@ -153,6 +153,7 @@ pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo};
pub use pet::{PetActionView, PetStats};
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub(crate) use region::apply_font_parts;
pub use session::SessionRequest;
pub use shapeshift::ShapeshiftFormView;
pub use skills::{SkillEntry, SkillsState};
@@ -173,8 +173,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// * **`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
/// describes — `EnableKeyboard`/`IsKeyboardEnabled` now exist and the flag round-trips, but there
/// is still no keyboard index and no strata walk, and keys are routed straight to the focused
/// EditBox — so the names stay out until that 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**
@@ -409,6 +409,40 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(model.arena.is_mouse_enabled(h))
})?,
)?;
// EnableKeyboard(flag) / IsKeyboardEnabled() — `0x776f90` / `0x776ff0`, real Frame entries in
// wow-re's frame-API carve, beside the mouse pair above.
//
// 8 corpus addons call this and were RAISING on it (`ColorPickerPlus:258`,
// `Dewdrop-2.0.lua:2021-2022` — which is in ~65 addons — `AckisRecipeList/ARLFrame.lua:1650`).
// The flag round-trips; **key delivery is not gated on it**, exactly as the wheel's flag
// shipped in 1198, because the machinery it would gate does not exist here yet.
//
// Why that is the right half rather than a silent stub, which this codebase has been bitten by
// three times (1203/1205/1211): the majority of the corpus calls pass **false**, and for those
// our behaviour is already the reference's — we deliver no keys to arbitrary frames either. The
// `true` callers want delivery we do not do, but they did not get it before this landed; they
// got a raise that killed the enclosing function. And per
// `frame-key-script-delivery.md` §3.2 being enabled genuinely is separable from having a
// handler, so the flag is a real piece of the model, not a placeholder for one.
m.set(
"EnableKeyboard",
lua.create_function(|lua, (this, enable): (Table, bool)| {
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.arena
.set_keyboard_enabled(h, enable);
Ok(())
})?,
)?;
m.set(
"IsKeyboardEnabled",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.arena.is_keyboard_enabled(h))
})?,
)?;
// `EnableMouseWheel(flag)` / `IsMouseWheelEnabled()` — the wheel's own gate, a separate flag
// from `EnableMouse` in the reference and separate here (decision 1198).
//
+40
View File
@@ -22,6 +22,46 @@ pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result<RegionHa
.ok_or_else(|| mlua::Error::runtime("stale or invalid region handle"))
}
/// Apply the font parts an **XML element** supplies — `font=`, `<FontHeight>`, `outline=` — any
/// subset of which may be absent.
///
/// **This is deliberately not the `SetFont` binding, and that separation is the point.** The
/// reference applies XML font attributes in C++ (`LoadXML`), never through the Lua method, and its
/// `SetFont` therefore *requires* both a path and a height, raising
/// `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`) without them. Our loader used to
/// call the binding with three `Option`s — `SetFont(nil, nil, "OUTLINE")` for an outline-only
/// `<FontString>` — which is why that binding could not be made faithful: one name was doing two
/// jobs, and the more lenient job won. Splitting them lets `SetFont` be the reference's `SetFont`
/// and lets the loader keep the partial application XML actually needs.
///
/// Each part supplied is an **explicit** set (`FontExplicit`), so it survives a later mutation of
/// the font object this region inherits. An empty `font=` is treated as absent — it keeps the
/// inherited face, rather than being the binding's load-failure edge.
pub(crate) fn apply_font_parts(
lua: &Lua,
this: &Table,
path: Option<String>,
height: Option<f32>,
flags: Option<String>,
) -> mlua::Result<()> {
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();
if let Some(p) = path.filter(|p| !p.is_empty()) {
d.font_path = Some(p);
d.font_explicit.face = true;
}
if let Some(h) = height {
d.font_height = Some(h);
d.font_explicit.height = true;
}
if let Some(f) = flags {
d.outline = super::Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(())
}
/// Get-or-create the wrapper table for a region id (distinct metatable — the region "tag").
pub(super) fn region_wrapper(lua: &Lua, id: u32) -> mlua::Result<Table> {
let wrappers: Table = lua.named_registry_value(REG_WRAPPERS)?;
+76 -239
View File
@@ -1,13 +1,24 @@
//! Region method-table cluster: **text** — a FontString's string, its faces, colour, shadow
//! and justification. Split out of `region.rs` at the 0716 file-size budget.
//! Region method-table cluster: **text** — what a `FontString` carries that other text-bearing
//! widgets do not. Split out of `region.rs` at the 0716 file-size budget.
//!
//! **These duplicate names the `<Font>` object table also carries, deliberately for now**: a region
//! may override its inherited font object per-property, and the severance mask (`FontExplicit`) is
//! what keeps the two apart. Collapsing them onto one shared block is the follow-on 1231 named.
//! **This file is now the FontString-only half.** The ten names it used to hand-write a second copy
//! of — `Set/GetFontObject`, `Set/GetFont`, `Set/GetTextColor`, `Set/GetShadowColor`,
//! `Set/GetShadowOffset` — are not FontString-specific at all: in the binary each is a thin
//! type-guard shim tail-calling one shared implementation, so they come from
//! [`crate::script::font_block`] via one `install` at the bottom of this file, exactly as the
//! EditBox's table gets them. The justify law likewise lives once in [`crate::justify`].
//!
//! What legitimately stays here is the surface a FontString alone has: the string itself
//! (`SetText`/`SetFormattedText`/`GetText`), the measured extents, `Set/GetJustifyH`/`V`,
//! `SetNonSpaceWrap`/`CanNonSpaceWrap`, and `SetTextHeight`.
//!
//! The per-property override a region has over its inherited font object is unaffected — that is
//! the severance mask (`FontExplicit`), which the shared block writes the same way this file did.
use mlua::{Lua, Table, Value};
use crate::script::{JustifyH, JustifyV, Model, Outline};
use crate::justify;
use crate::script::Model;
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
use super::{measured_wh, region_handle_of};
@@ -107,19 +118,30 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
)?;
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML `justifyH`).
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML
// `justifyH`). The token table, the whole-string match, the raise on a miss and the cross-axis
// clear are all [`crate::justify`]'s, shared with the `<Font>` object's identical pair
// so the two cannot drift apart again.
m.set(
"SetJustifyH",
lua.create_function(|lua, (this, j): (Table, String)| {
let rh = region_handle_of(lua, &this)?;
let jh = match j.to_ascii_uppercase().as_str() {
"LEFT" => JustifyH::Left,
"RIGHT" => JustifyH::Right,
_ => JustifyH::Center,
};
let parsed = justify::parse_h(&j);
if parsed == justify::Set::NoMatch {
return Err(justify::usage_h("FontString"));
}
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.justify_h = jh;
match parsed {
justify::Set::To(jh) => d.justify.set_h(jh),
// A cross-axis token erases the axis. `GetJustifyH()` then answers "UNKNOWN"
// while the glyphs draw centred — `0x44d420`'s pre-set `1`.
justify::Set::Clears => d.justify.clear_h(),
justify::Set::NoMatch => unreachable!("returned above"),
}
// Severance is UNCONDITIONAL on a successful parse — the FontString's own setter
// `0x79e6b0` stores the per-axis mask `+0x124` at `0x79e780`, *before* the `je`, so
// the erasing path severs too. Only a failed parse escapes, and that raised already.
d.font_explicit.justify_h = true;
Ok(())
})?,
@@ -130,81 +152,46 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetJustifyV",
lua.create_function(|lua, (this, j): (Table, String)| {
let rh = region_handle_of(lua, &this)?;
let jv = match j.to_ascii_uppercase().as_str() {
"TOP" => JustifyV::Top,
"BOTTOM" => JustifyV::Bottom,
_ => JustifyV::Middle,
};
let parsed = justify::parse_v(&j);
if parsed == justify::Set::NoMatch {
return Err(justify::usage_v("FontString"));
}
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.justify_v = jv;
match parsed {
justify::Set::To(jv) => d.justify.set_v(jv),
// 13 corpus sites reach this arm: `SetJustifyV("CENTER")` meaning "middle".
justify::Set::Clears => d.justify.clear_v(),
justify::Set::NoMatch => unreachable!("returned above"),
}
d.font_explicit.justify_v = true;
Ok(())
})?,
)?;
// SetFontObject(GameFontNormal) — re-point this FontString at a Font object: its resolved paint
// (face/height/color/outline/shadow) becomes the region's, and the link is kept live, so a later
// `GameFontNormal:SetFont(…)` re-paints this region too ([`super::font`]'s module doc).
// GetJustifyH()/GetJustifyV() → **1 string**. Real entries on the reference's FontString table
// (`0x79e5f0` / `0x79e7f0`, the FontString column of wow-re's two-column accessor table) that
// this side had simply never grown: a FontString could set its justification and not read it
// back, while the `<Font>` object — the same law, transcribed separately — could do both.
//
// All three argument forms the reference's own usage string names (`.rdata 0x87c5cc`:
// `SetFontObject(font or "font" or nil)`) — the **object**, which is what 3,180 of the corpus's
// 3,186 call sites pass (`Gratuity-2.0.lua:57`, every FuBar/Ace label); a **name string**, for
// our own shipped XML and the 6 sites that use it; and **nil**, which severs the link. A frame,
// a number, or an unknown name is an error — never a silent no-op (1203/1205/1211's class).
// An untouched FontString answers CENTER/MIDDLE, which is `RegionData`'s default *and* the
// client's ctor default `0x212` (`CENTER | MIDDLE | 0x200`) read through each axis mask.
fn justify_of(lua: &Lua, this: &Table) -> mlua::Result<justify::Justify> {
let rh = region_handle_of(lua, this)?;
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model
.region_data
.get(&rh)
.map(|d| d.justify)
.unwrap_or_default())
}
m.set(
"SetFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = crate::script::font::resolve("SetFontObject", &font)?;
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// The nil form: unlink, and leave the paint standing (the reference stores a null
// parent; nothing re-reads and nothing is cleared).
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 here. §5-verified: the real "stop
// inheriting this property" signal is a CLEARED bit in the inheritMask at
// `FONTINSTANCE+0x2c` (FontString `+0xd4`, per-axis justify at `+0x124`), cleared by
// each local setter and never restored — "a FontString that set its own colour stays
// severed even across a later SetFontObject" (wow-re
// `system/ui/scratch/font-object-lua-surface.md`). This corrects our first cut, which
// reset it.
crate::script::font::repaint(d, &fo);
Ok(())
})?,
"GetJustifyH",
lua.create_function(|lua, this: Table| Ok(justify_of(lua, &this)?.name_h()))?,
)?;
// GetFontObject() → the font OBJECT this FontString last resolved (or nil).
//
// The object, not its name: `Dewdrop-2.0.lua:2181` is
// `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())` — 65 sites across 62
// corpus addons that index the result immediately. A name string there raises.
m.set(
"GetFontObject",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(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(crate::script::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
"GetJustifyV",
lua.create_function(|lua, this: Table| Ok(justify_of(lua, &this)?.name_v()))?,
)?;
// SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`).
@@ -244,114 +231,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// ── 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
// current face (so a FontString with only `<FontHeight>` retains its inherited object's font).
// Returns true (the live API returns whether the font loaded; we always accept — face
// availability is the renderer's concern).
m.set(
"SetFont",
lua.create_function(
|lua, (this, path, height, flags): (Table, Option<String>, Option<f32>, Option<String>)| {
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();
// Each argument actually supplied is an EXPLICIT set: it must survive a later
// mutation of the font object this region inherits (`FontExplicit`).
if let Some(p) = path.filter(|p| !p.is_empty()) {
d.font_path = Some(p);
d.font_explicit.face = true;
}
if let Some(h) = height {
d.font_height = Some(h);
d.font_explicit.height = true;
}
if let Some(f) = flags {
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(true)
},
)?,
)?;
// SetTextHeight(height) — switch the FontString to the scaled-string regime (§5-verified,
// wow-re `fontstring-overflow.md`: `0x771600` is the ONLY clearer of the one-to-one bit
// `0x200`; the literal size then flows through UNCAPPED, magnified from the raster). Stored
@@ -371,58 +250,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GetFont() → path, height, flags — the resolved face/size/outline (nil path if never set).
m.set(
"GetFont",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
let path = d.and_then(|d| d.font_path.clone());
let height = d.and_then(|d| d.font_height);
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
let path = match path {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
Ok((path, height, flags))
})?,
)?;
// SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the
// same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own
// to multiply against, so its vertex colour IS the colour it draws.
m.set(
"SetTextColor",
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();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
d.font_explicit.color = true;
Ok(())
},
)?,
)?;
// GetTextColor() → r, g, b, a — `SetTextColor`'s missing pair, and a real binding in the same
// FontInstance family. 11 corpus sites read it off a FontString directly (`CustomNameplates`
// re-tints a level tag from the name's colour; `TipBuddy` snapshots every tooltip line), on top
// of the 65 that reach it through `GetFontObject()`. Never set = the white every region draws
// at, same convention as `GetVertexColor` (the same slot).
m.set(
"GetTextColor",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(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]))
})?,
)?;
Ok(())
// ── the shared font block ───────────────────────────────────────────────────────────────
//
// `SetFontObject · GetFontObject · SetFont · GetFont · Set/GetTextColor · Set/GetShadowColor ·
// Set/GetShadowOffset` are **not** FontString-specific. In the binary each is a thin type-guard
// shim that tail-calls one shared implementation (`0x79f210` SetFont, `0x79f3b0` GetFont, …),
// which is what [`super::super::font_block`] models — so a FontString and an EditBox are two
// entry points to one routine, not two routines that happen to agree.
//
// They lived here as a hand-written second copy because `region.rs` was over the file-size
// budget when the block was written; that split has since landed, and `font_block`'s own doc
// named this collapse as the follow-on. 226 lines of duplicate go with it.
super::super::font_block::install(lua, m, region_handle_of, "FontString")
}
@@ -6,7 +6,7 @@
//! almost nobody writes (6 corpus sites against 3,180).
use super::common::script;
use crate::script::{FontObject, JustifyH, Outline, QuadContent, UiScript};
use crate::script::{FontObject, JustifyH, JustifyV, Outline, QuadContent, UiScript};
/// Load a FrameXML fragment through the real loader, asserting it reported no errors.
fn load(s: &UiScript, xml: &str) {
@@ -714,3 +714,268 @@ fn set_font_returns_one_or_nil() {
);
assert!(s.eval::<bool>("return f:SetFont('', 12) == nil").unwrap());
}
/// The justify law is **one** table in the binary (`.rdata 0x811ad0`) and must be one
/// transcription here: a `FontString` and a `<Font>` object have to answer identically, token for
/// token and raise for raise.
///
/// They did not. Both sides were written separately from the same law and each drifted its own
/// way — the FontString had no `GetJustifyH`/`GetJustifyV` at all (real entries `0x79e5f0` /
/// `0x79e7f0`), the Font object silently `.trim()`ed its argument where `SStrCmpI` compares the
/// whole string, and both quietly answered CENTER for a string the reference raises on. Every
/// test on both sides passed throughout. This is the net that makes the next drift fail.
#[test]
fn both_tables_speak_one_justify_law() {
let s = script();
s.run(
"seam = CreateFrame('Frame', 'SeamFrame')\n\
fs = seam:CreateFontString()\n\
fo = CreateFont('SeamFont')",
)
.unwrap();
for obj in ["fs", "fo"] {
// The client's ctor default `0x212` = CENTER | MIDDLE | 0x200, read through each mask.
let h = s
.eval::<String>(&format!("return {obj}:GetJustifyH()"))
.unwrap();
let v = s
.eval::<String>(&format!("return {obj}:GetJustifyV()"))
.unwrap();
assert_eq!((obj, h.as_str()), (obj, "CENTER"));
assert_eq!((obj, v.as_str()), (obj, "MIDDLE"));
// Every token round trips, and the match is case-insensitive.
for (set, get, tokens) in [
("SetJustifyH", "GetJustifyH", ["LEFT", "CENTER", "RIGHT"]),
("SetJustifyV", "GetJustifyV", ["TOP", "MIDDLE", "BOTTOM"]),
] {
for t in tokens {
s.run(&format!("{obj}:{set}('{}')", t.to_lowercase()))
.unwrap();
let got = s.eval::<String>(&format!("return {obj}:{get}()")).unwrap();
assert_eq!(got, t, "{obj}:{set} then {get}");
}
}
// A non-token raises the reference's own usage string rather than coercing to CENTER.
for verb in ["SetJustifyH", "SetJustifyV"] {
let err = s
.run(&format!("{obj}:{verb}('MIDDLE_LEFT')"))
.expect_err("a string outside the six-entry table must raise");
let err = format!("{err}");
assert!(
err.contains("Usage:") && err.contains(verb),
"{obj}:{verb} raised {err:?}"
);
}
// Whole-string: a trailing space is a miss. The Font object used to trim it away.
assert!(
s.run(&format!("{obj}:SetJustifyH('LEFT ')")).is_err(),
"{obj}:SetJustifyH must not trim its argument"
);
// A cross-axis token matches the table, so it is accepted with no error — the one place
// we knowingly stop short of the law (the reference clears the axis; see `script::justify`).
s.run(&format!("{obj}:SetJustifyH('TOP')")).unwrap();
s.run(&format!("{obj}:SetJustifyV('LEFT')")).unwrap();
}
}
/// `SetFont` is one routine (`0x79f210`) with three entry points — Font `0x7a0270`, FontString
/// `0x79d4f0`, EditBox `0x797210` — so all three must answer identically, and none of them did.
///
/// The FontString's hand-written copy answered the boolean `true` for *everything*, including the
/// empty-path load failure that `!OmniCC/main.lua:41`'s `if not f:SetFont(saved, size)` probes;
/// the `<Font>` object's took `Option` arguments and so answered **nil** for `SetFont()` where the
/// reference raises `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`). A behavioural
/// differential across both tables is what surfaced it — arity alone had matched.
#[test]
fn set_font_is_one_routine_on_both_tables() {
let s = script();
s.run("seam = CreateFrame('Frame', 'SetFontSeamFrame')\nfs = seam:CreateFontString()\nfo = CreateFont('SetFontSeamFont')")
.unwrap();
for obj in ["fs", "fo"] {
// Success is the NUMBER 1 — never a boolean, and never zero values.
let n = s
.eval::<f32>(&format!(
"return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', 12)"
))
.unwrap();
assert_eq!(n, 1.0, "{obj}:SetFont success");
assert!(
s.eval::<bool>(&format!(
"return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', 12) == 1"
))
.unwrap(),
"{obj}:SetFont must answer the number 1, not true"
);
// An empty path is the LOAD FAILURE edge: nil, falsey, and nothing raised.
assert!(
s.eval::<bool>(&format!("return {obj}:SetFont('', 12) == nil"))
.unwrap(),
"{obj}:SetFont('') must answer nil"
);
// A missing or non-string path, or a missing height, is an ARGUMENT error — it raises,
// which is a different thing from the nil above and used to be conflated with it.
for bad in ["", "'Fonts\\\\FRIZQT__.TTF'", "nil, 12", "{}, 12"] {
let err = s
.run(&format!("{obj}:SetFont({bad})"))
.expect_err("a bad SetFont argument must raise, not answer nil");
assert!(
format!("{err}").contains("Usage:") && format!("{err}").contains("SetFont"),
"{obj}:SetFont({bad}) raised {err}"
);
}
// Both `lua_isstring` and `lua_isnumber` coerce, so a numeric string is accepted for either.
assert!(
s.eval::<bool>(&format!(
"return {obj}:SetFont('Fonts\\\\FRIZQT__.TTF', '14') == 1"
))
.unwrap(),
"{obj}:SetFont must accept a numeric string height"
);
}
}
/// A cross-axis token **erases** its axis, and the two readers then disagree — faithfully.
///
/// `SetJustifyH("TOP")` parses (`0x08`), contributes nothing to mask `0x07`, and raises nothing.
/// `GetJustifyH()` afterwards answers the literal `"UNKNOWN"` (`0x6f1a00` → `.data 0x838044`),
/// while the glyphs keep drawing **centred**: the ui→gx translator `0x44d420` is a priority ladder
/// whose per-axis register is pre-set to `1` between the `test` and the `jcc`, so an all-clear axis
/// exits with CENTER still in it. Reading the getter's answer into the draw path — or mapping the
/// bitmask onto the gx enum with a `0` default — inverts the axis to LEFT.
///
/// Reached by 13 corpus sites (`FonzAppraiser` ×12, `Roid-Macros`) writing
/// `SetJustifyV("CENTER")` when they mean MIDDLE.
#[test]
fn a_cross_axis_token_erases_the_axis_but_still_draws_centred() {
let mut s = script();
s.run(
"f = CreateFrame('Frame', 'ClearAxisFrame')\n\
f:SetWidth(200) f:SetHeight(40) f:SetPoint('CENTER')\n\
fs = f:CreateFontString()\n\
fs:SetAllPoints(f)\n\
fs:SetText('erased')",
)
.unwrap();
// A real token lands, so the erase below is visibly an erase and not a no-op.
s.run("fs:SetJustifyH('LEFT') fs:SetJustifyV('TOP')")
.unwrap();
assert_eq!(s.eval::<String>("return fs:GetJustifyH()").unwrap(), "LEFT");
assert_eq!(s.eval::<String>("return fs:GetJustifyV()").unwrap(), "TOP");
// The cross-axis pair: each erases the OTHER axis it was aimed at, raising nothing.
s.run("fs:SetJustifyH('TOP')").unwrap();
s.run("fs:SetJustifyV('CENTER')").unwrap();
assert_eq!(
s.eval::<String>("return fs:GetJustifyH()").unwrap(),
"UNKNOWN",
"a cleared axis reads UNKNOWN, not the value it used to hold"
);
assert_eq!(
s.eval::<String>("return fs:GetJustifyV()").unwrap(),
"UNKNOWN"
);
// ...and the draw path answers CENTER/MIDDLE for that same state.
s.resolve();
let (h, v) = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(ref t),
justify_h,
justify_v,
..
} if t == "erased" => Some((justify_h, justify_v)),
_ => None,
})
.expect("the erased string draws");
assert_eq!(
(h, v),
(JustifyH::Center, JustifyV::Middle),
"a cleared axis draws CENTER/MIDDLE — the gx translator's pre-set 1, NOT LEFT/TOP"
);
}
/// The font block is a **per-table membership fact**, and wow-re's registrar carve names the six:
/// *"Exposed on: FontString, Font object, EditBox, MessageFrame, ScrollingMessageFrame,
/// SimpleHTML. NOT on Button."* We shipped it on two; these are the third and fourth.
///
/// `BigWigs/Plugins/Messages.lua:212` — `self.msgframe:SetFontObject(GameFontNormalLarge)` on a
/// frame it has just given `SetInsertMode("TOP")` — died there every session.
///
/// The second assertion is the trap: a message frame's lines fall back to **LEFT** when it has no
/// declared `<FontString>`, while a freshly created `RegionData` defaults to the FontString's own
/// CENTER. Creating the style region on demand must not silently re-justify the frame.
#[test]
fn the_font_block_reaches_both_message_frame_tables() {
let mut s = script();
s.run(
"fo = CreateFont('MsgBlockFont')\n\
fo:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
mf = CreateFrame('MessageFrame', 'MsgBlockPlain')\n\
mf:SetWidth(300) mf:SetHeight(80) mf:SetPoint('CENTER')\n\
smf = CreateFrame('ScrollingMessageFrame', 'MsgBlockScroll')\n\
smf:SetWidth(300) smf:SetHeight(80) smf:SetPoint('TOPLEFT')",
)
.unwrap();
for obj in ["mf", "smf"] {
s.run(&format!("{obj}:SetFontObject(fo)")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {obj}:GetFontObject():GetName()"))
.unwrap(),
"MsgBlockFont",
"{obj}:GetFontObject must answer the OBJECT it was given"
);
// The shared `0x79f210` contract, same as every other table that carries it.
assert!(
s.eval::<bool>(&format!(
"return {obj}:SetFont('Fonts\\\\MORPHEUS.TTF', 16) == 1"
))
.unwrap(),
"{obj}:SetFont answers the number 1"
);
let (_, height, _) = s
.eval::<(mlua::Value, f32, String)>(&format!("return {obj}:GetFont()"))
.unwrap();
assert_eq!(height, 16.0, "{obj}:GetFont reads back what SetFont wrote");
// The four-value getters the carve pins at 4 for every one of the six tables.
assert_eq!(
s.eval::<i64>(&format!("return select('#', {obj}:GetShadowColor())"))
.unwrap(),
4
);
}
// Styling the frame must not move its text: the lines still run flush LEFT.
s.run("mf:AddMessage('flush left please')").unwrap();
s.resolve();
let j = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(ref t),
justify_h,
..
} if t == "flush left please" => Some(justify_h),
_ => None,
})
.expect("the message draws");
assert_eq!(
j,
JustifyH::Left,
"creating the style region must not re-justify the frame to the FontString default"
);
}
+20 -5
View File
@@ -91,10 +91,13 @@ fn the_flag_round_trips_and_defaults_off() {
/// this law existed, which left every window authored toplevel (thirteen of our own frames, 82
/// corpus addons) unable to come to the front while `SetToplevel` did not exist at all.
///
/// `enableKeyboard` is deliberately still warned: implementing one clause is not a licence to go
/// quiet about the other.
/// `enableKeyboard` used to be warned here on the same principle — implementing one clause is not
/// a licence to go quiet about the other. It now lands on a real `EnableKeyboard`, so the warning
/// is gone and this asserts the flag instead. What is still NOT built is key *delivery*, which the
/// method's own doc states; the flag and the delivery are separable in the reference too
/// (`frame-key-script-delivery.md` §3.2).
#[test]
fn the_xml_toplevel_attribute_reaches_the_method_and_enable_keyboard_still_warns() {
fn the_xml_toplevel_and_enable_keyboard_attributes_both_reach_their_methods() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
@@ -114,11 +117,23 @@ fn the_xml_toplevel_attribute_reaches_the_method_and_enable_keyboard_still_warns
report.warnings
);
assert!(
report.warnings.iter().any(|w| w.contains("EnableKeyboard")),
"the clause we did NOT build stays loud: {:?}",
!report.warnings.iter().any(|w| w.contains("EnableKeyboard")),
"enableKeyboard is built now, not warned: {:?}",
report.warnings
);
assert!(s.eval::<bool>("return XmlTop:IsToplevel()").unwrap());
assert!(
s.eval::<bool>("return XmlTop:IsKeyboardEnabled()").unwrap(),
"the XML attribute must reach the flag"
);
// Default false, and the setter round-trips both ways — a frame that never asked is not in the
// bucket (`0x76af00` is reached from the attribute or an explicit call, never a ctor).
s.run("Plain = CreateFrame('Frame', 'PlainKbd')").unwrap();
assert!(!s.eval::<bool>("return Plain:IsKeyboardEnabled()").unwrap());
s.run("Plain:EnableKeyboard(true)").unwrap();
assert!(s.eval::<bool>("return Plain:IsKeyboardEnabled()").unwrap());
s.run("Plain:EnableKeyboard(false)").unwrap();
assert!(!s.eval::<bool>("return Plain:IsKeyboardEnabled()").unwrap());
}
// ── The raise on Show ───────────────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -182,7 +182,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
// Left-justified like the template's line stack — invisible while the rect hugs
// the measured text, load-bearing once the wrap consumer pins a region to the
// wrap column.
d.justify_h = super::JustifyH::Left;
d.justify.set_h(super::JustifyH::Left);
d.anchors = vec![match prev_left {
None => Anchor::new(
Point::TopLeft,
@@ -227,7 +227,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
d
};
d.hidden = true;
d.justify_h = super::JustifyH::Right;
d.justify.set_h(super::JustifyH::Right);
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
model.region_data.insert(right, d);
model.touch_layout(); // a line row entered the layout graph (decision 0740)
@@ -259,13 +259,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
{
let d = model.region_data.entry(lh).or_default();
d.hidden = true;
d.justify_h = crate::script::JustifyH::Left;
d.justify.set_h(crate::script::JustifyH::Left);
d.anchors = vec![anchor];
}
{
let d = model.region_data.entry(rh).or_default();
d.hidden = true;
d.justify_h = crate::script::JustifyH::Right;
d.justify.set_h(crate::script::JustifyH::Right);
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
}
model.touch_layout(); // two line rows entered the layout graph (decision 0740)
+10 -8
View File
@@ -443,7 +443,10 @@ pub(crate) struct RegionData {
/// non-empty ⇒ resolved in [`UiScript::resolve`], any edge the anchors leave unset inherited
/// from the owner frame's rect.
pub(crate) anchors: Vec<Anchor>,
/// FontString horizontal justification (`SetJustifyH`/XML `justifyH`); default CENTER.
/// `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,
/// `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
@@ -452,14 +455,13 @@ pub(crate) struct RegionData {
/// 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,
/// FontString justification, as the client's own **dword** rather than a pair of resolved
/// enums — `CSimpleFontString+0x120`, bits 0–2 horizontal and 3–5 vertical, defaulting to the
/// ctor's `0x212` (CENTER|MIDDLE). See [`crate::justify::Justify`]: an axis can be *cleared*,
/// which no resolved enum can hold, and the Lua getter and the draw path then answer that
/// state differently (`"UNKNOWN"` vs centred) — both faithfully.
pub(crate) justify: crate::justify::Justify,
/// The `<TexCoords>`/`SetTexCoord` UV mapping ([`TexCoords`]: the 4-edge crop, or the 8-arg
/// affine quad). `None` = the full texture. Slices the quadrant/atlas art (decision 0084).
pub(crate) tex_coords: Option<TexCoords>,
+12 -31
View File
@@ -192,52 +192,33 @@ pub struct EditBoxState {
pub justify: u32,
}
/// The justify enum, `.rdata 0x811ad0` — `{bits, token}`, in the reference's own table order, which
/// is the order [`EditBoxState::justify_token`] scans (the getter returns the **first** set bit's
/// token).
const JUSTIFY_TOKENS: [(u32, &str); 6] = [
(0x01, "LEFT"),
(0x02, "CENTER"),
(0x04, "RIGHT"),
(0x08, "TOP"),
(0x10, "MIDDLE"),
(0x20, "BOTTOM"),
];
impl EditBoxState {
/// The horizontal axis mask (bits 0–2) — `SetJustifyH`'s `(cur & ~7) | (parsed & 7)`.
pub const JUSTIFY_H_MASK: u32 = 0x07;
pub const JUSTIFY_H_MASK: u32 = crate::justify::H_MASK;
/// The vertical axis mask (bits 3–5) — `SetJustifyV`'s.
pub const JUSTIFY_V_MASK: u32 = 0x38;
pub const JUSTIFY_V_MASK: u32 = crate::justify::V_MASK;
/// Parse a justify token to its bit, case-insensitively and whole-string (`0x6f1990`, a linear
/// `SStrCmpI` scan of all six). `None` = no match, which is what makes the caller raise
/// `Usage: %s:SetJustifyH("justify")` — the reference checks this one, unlike `SetTextColor`.
/// Parse a justify token to its bit — [`crate::justify::parse_bits`]. `None` = no match, which
/// is what makes the caller raise `Usage: %s:SetJustifyH("justify")`; the reference checks this
/// one, unlike `SetTextColor`.
pub fn justify_bit(token: &str) -> Option<u32> {
JUSTIFY_TOKENS
.iter()
.find(|(_, name)| token.eq_ignore_ascii_case(name))
.map(|(bits, _)| *bits)
crate::justify::parse_bits(token)
}
/// Replace one axis's bits with `parsed`'s, masked to that axis (`0x79fc5d`:
/// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`).
/// Replace one axis's bits with `parsed`'s — [`crate::justify::set_axis`].
///
/// **The verified trap this reproduces:** `SetJustifyH("TOP")` *parses* (0x08) but
/// `0x08 & 0x07 == 0`, so it **clears** justifyH and a later `GetJustifyH()` answers
/// `"UNKNOWN"` — no error is raised. A plausible implementation that maps unknown-to-CENTER
/// silently answers something the client never would.
/// silently answers something the client never would; the FontString and `<Font>` tables both
/// did exactly that until this law was lifted out of here and shared with them.
pub fn set_justify_axis(&mut self, mask: u32, parsed: u32) {
self.justify = (self.justify & !mask) | (parsed & mask);
self.justify = crate::justify::set_axis(self.justify, mask, parsed);
}
/// The token for one axis: the **first** entry in table order whose bit is set, else the
/// literal `"UNKNOWN"` (`0x6f1a00`, string `.data 0x838044`).
/// The token for one axis — [`crate::justify::name_of`].
pub fn justify_token(&self, mask: u32) -> &'static str {
JUSTIFY_TOKENS
.iter()
.find(|(bits, _)| self.justify & mask & bits != 0)
.map_or("UNKNOWN", |(_, name)| *name)
crate::justify::name_of(self.justify, mask)
}
}
+17
View File
@@ -236,6 +236,20 @@ pub struct Frame {
/// window behind it while keeping its handler installed — a frame with a handler it has not
/// enabled is deliberately transparent to the wheel.
pub mouse_wheel_enabled: bool,
/// Whether the frame is **keyboard-enabled** (`EnableKeyboard` `0x776f90` / XML
/// `enableKeyboard`) — the kind-0/kind-1 bucket membership `0x76af00` writes as
/// `[frame+0xcc] |= 1<<kind`.
///
/// Default **false**, the client's own default. **The flag round-trips; key DELIVERY is not
/// gated on it yet** — the same shape [`Self::mouse_wheel_enabled`] shipped in (1198), and for
/// the same reason: the machinery it gates (the strata 8→0 walk, the kind buckets, the
/// `OnKeyDown`/`OnKeyUp`/`OnChar` script kinds) does not exist here, so gating on the flag
/// would change nothing while pretending otherwise. wow-re's
/// `scratch/frame-key-script-delivery.md` §3.2 is explicit that the two are separable:
/// `EnableKeyboard(true)` on a script-less frame "puts it in the walk where it is called and
/// declines — transparent to everything downstream", so **being enabled is not being a
/// handler**, and storing the flag alone is the faithful half rather than a stub of the whole.
pub keyboard_enabled: bool,
/// Clamp-to-screen (`SetClampedToScreen` / XML `clampedToScreen` — the client's geometry
/// flags **bit4**, applied inside rect assembly `0x767a20`, wow-re `layout.md`): the layout
/// resolve shifts this frame's assembled rect back inside the screen, size preserved.
@@ -517,6 +531,9 @@ impl WidgetArena {
kind,
FrameKind::ScrollingMessageFrame | FrameKind::ScrollFrame
),
// Nothing is keyboard-enabled by construction: `0x76af00` is only ever reached from the
// XML attribute or an explicit call, never a ctor (`scripts-auto-enable.md` §1-2).
keyboard_enabled: false,
// A tooltip clamps to the screen by construction (its XML never sets the attribute,
// yet the reference plate observably never leaves the window — the class supplies
// geometry flags bit4; decision 0352's law: no tooltip off-screen, ever).
@@ -374,6 +374,19 @@ impl WidgetArena {
}
}
/// `EnableKeyboard` (`0x776f90`) — kind-0/kind-1 bucket membership. Stored and answered; the
/// key path is not gated on it yet (see [`crate::widget::WidgetState::keyboard_enabled`]).
pub fn set_keyboard_enabled(&mut self, h: FrameHandle, enabled: bool) {
if let Some(f) = self.frame_mut(h) {
f.keyboard_enabled = enabled;
}
}
/// Whether the frame is keyboard-enabled. A stale handle reads as `false`.
pub fn is_keyboard_enabled(&self, h: FrameHandle) -> bool {
self.frame(h).is_some_and(|f| f.keyboard_enabled)
}
/// `EnableMouseWheel` — the wheel's own gate, separate from the mouse's (decision 1198).
pub fn set_mouse_wheel_enabled(&mut self, h: FrameHandle, enabled: bool) {
if let Some(f) = self.frame_mut(h) {