Files
benilla-octo/crates/benilla-ui/src/script/tests/font_object.rs
T
SamandClaude Fable 5.1 321bcbb393 the cooldown pie survives a /reload, and Bagnon's bags keep their order
- the cooldown pie, the GCD sweep, minimap pings and the map arrow draw again after a logout, login or /reload; they went blank until a restart, which read as OmniCC killing the cooldowns (2113)
- an addon's own font on a button label keeps its face, size and outline instead of being undone every frame, MikScrollingBattleText's reported look (2112)
- an empty label reads as nothing rather than an empty string, so Cartographer's map label keeps its colour; a slider thumb with no authored size takes its art's own size, so Dewdrop's Look'n'Feel slider moves (2109, 2110)
- Bagnon's saved bag list comes back in the order it was written, so the keyring stops jumping to the front after a restart; hovering a Bagnon bag costs a third of what it did (2111, 2114)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:49:50 +07:00

1177 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Font objects as first-class Lua objects ([`crate::script::font`]).
//!
//! Every test here is driven **the way the 218-addon corpus drives it** — the bare-global argument,
//! the no-arg `CreateFontString`, `CreateFont` — rather than the way our own shipped XML does,
//! because the string-name form our XML uses was the only one we accepted and it is the form
//! almost nobody writes (6 corpus sites against 3,180).
use super::common::script;
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) {
let doc = crate::framexml::parse(xml).expect("valid FrameXML");
let report = crate::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
}
/// The resolved `(face, height, colour)` of the one text quad on screen.
fn painted(s: &UiScript, text: &str) -> (Option<String>, Option<f32>, Option<[f32; 4]>) {
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(ref t),
ref font,
font_height,
color,
..
} if t == text => Some((font.clone(), font_height, color)),
_ => None,
})
.unwrap_or_else(|| panic!("no text quad reading {text:?}"))
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Publication
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A `<Font name="X">` in FrameXML becomes the **global `X`**, an object of type `Font`.
///
/// This is the whole gap: we registered the resolved record and never named it, so every
/// `SetFontObject(GameFontNormal)` in the ecosystem was handed nil.
#[test]
fn a_declared_font_is_published_as_a_global_object() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF" justifyH="LEFT">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0.82" b="0"/>
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
</Font>
</Ui>"#,
);
assert_eq!(
s.eval::<String>("return GameFontNormal:GetObjectType()")
.unwrap(),
"Font"
);
assert!(s
.eval::<bool>("return GameFontNormal:IsObjectType('Font')")
.unwrap());
assert_eq!(
s.eval::<String>("return GameFontNormal:GetName()").unwrap(),
"GameFontNormal"
);
// Object identity is stable: two reads of the global are the same object, which is what makes
// `if fs:GetFontObject() == GameFontNormal` work at all.
assert!(s
.eval::<bool>("return GameFontNormal == GameFontNormal")
.unwrap());
// The declared paint reads back through the FontInstance getters.
assert_eq!(
s.eval::<(String, f32, String)>("return GameFontNormal:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 12.0, String::new())
);
let (r, g, b, _) = s
.eval::<(f32, f32, f32, f32)>("return GameFontNormal:GetTextColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.82, 0.0));
assert_eq!(
s.eval::<(f32, f32)>("return GameFontNormal:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return GameFontNormal:GetShadowColor()")
.unwrap(),
(0.0, 0.0, 0.0, 1.0)
);
assert_eq!(
s.eval::<String>("return GameFontNormal:GetJustifyH()")
.unwrap(),
"LEFT"
);
}
/// **`Tablet-2.0.lua:289`, verbatim.** `_, headerSize = GameTooltipHeaderText:GetFont()` — the
/// single most-called font-object read in the corpus (268 sites across the four embedded Ace
/// libraries). Until the global existed, Tablet fell to its hardcoded 14/12 guard branch.
#[test]
fn tablet_reads_the_tooltip_header_size_off_the_global() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameTooltipText" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Font name="GameTooltipHeaderText" inherits="GameTooltipText">
<FontHeight><AbsValue val="14"/></FontHeight>
</Font>
</Ui>"#,
);
let (header, normal) = s
.eval::<(f32, f32)>(
r#"
local headerSize, normalSize
if GameTooltipHeaderText then
_, headerSize = GameTooltipHeaderText:GetFont()
else
headerSize = 14
end
if GameTooltipText then
_, normalSize = GameTooltipText:GetFont()
else
normalSize = 12
end
return headerSize, normalSize
"#,
)
.unwrap();
assert_eq!((header, normal), (14.0, 12.0));
}
/// **Requirement 5: publishing must not change what `inherits=` resolves to.** The chain is still
/// flattened once, at load, and the published object carries the *flattened* values — the derived
/// font sees the parent's face and its own height override.
#[test]
fn an_inheriting_font_still_flattens_to_the_same_values() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="MasterFont" font="Fonts\FRIZQT__.TTF" justifyH="CENTER">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="1" g="1" b="1"/>
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
</Font>
<Font name="DerivedFont" inherits="MasterFont" outline="NORMAL">
<FontHeight><AbsValue val="18"/></FontHeight>
</Font>
</Ui>"#,
);
// The Rust-side record — unchanged by publication.
let derived = s.font_object("DerivedFont").expect("registered");
assert_eq!(derived.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
assert_eq!(derived.height, Some(18.0));
assert_eq!(derived.color, Some([1.0, 1.0, 1.0, 1.0]));
assert_eq!(derived.outline, Outline::Normal);
assert_eq!(derived.justify_h, Some(JustifyH::Center));
assert!(derived.shadow.is_some(), "the shadow inherits too");
// …and the same values through the published object.
assert_eq!(
s.eval::<(String, f32, String)>("return DerivedFont:GetFont()")
.unwrap(),
(
"Fonts\\FRIZQT__.TTF".to_string(),
18.0,
"OUTLINE".to_string()
)
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// SetFontObject / GetFontObject
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// **`Gratuity-2.0.lua:47-59`, verbatim** — the block that took five corpus addons down at load
/// with `bad argument #2: error converting Lua nil to String`.
///
/// Three separate things have to hold for it: `CreateFontString()` with **no arguments**,
/// `SetFontObject` taking the **object**, and `AddFontStrings` existing at all (fixing only the
/// first two moves the death one line down).
#[test]
fn gratuity_builds_its_thirty_line_scan_tooltip() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0.82" b="0"/>
</Font>
</Ui>"#,
);
s.run(
r#"
vars = { Llines = {}, Rlines = {} }
local tt = CreateFrame("GameTooltip")
vars.tooltip = tt
tt:SetOwner(tt, "ANCHOR_NONE")
for i = 1, 30 do
vars.Llines[i], vars.Rlines[i] = tt:CreateFontString(), tt:CreateFontString()
vars.Llines[i]:SetFontObject(GameFontNormal)
vars.Rlines[i]:SetFontObject(GameFontNormal)
tt:AddFontStrings(vars.Llines[i], vars.Rlines[i])
end
"#,
)
.expect("Gratuity's CreateTooltip must not raise");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The font object actually landed on the lines it made.
assert_eq!(
s.eval::<(String, f32)>("return vars.Llines[7]:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 12.0)
);
assert_eq!(
s.eval::<String>("return vars.Rlines[30]:GetFontObject():GetName()")
.unwrap(),
"GameFontNormal"
);
// And Gratuity's own next move — `ClearLines` over the grown stack — still works.
s.run("vars.tooltip:ClearLines()").unwrap();
}
/// The object form and the string form must resolve to **the same paint**. Both are kept: the
/// object is what 3,180 of 3,186 corpus sites pass, the string is what our own `assets/ui` and 6
/// corpus sites pass.
#[test]
fn set_font_object_takes_the_object_or_the_name() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GameFontHighlightSmall",
FontObject {
font: Some("Fonts\\ARIALN.TTF".into()),
height: Some(11.0),
color: Some([0.25, 0.5, 0.75, 1.0]),
outline: Outline::Thick,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "TwoWays")
f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER")
byObject = f:CreateFontString(nil, "ARTWORK")
byObject:SetText("obj")
byObject:SetFontObject(GameFontHighlightSmall)
byName = f:CreateFontString(nil, "ARTWORK")
byName:SetText("str")
byName:SetFontObject("GameFontHighlightSmall")
"#,
)
.unwrap();
s.resolve();
assert_eq!(painted(&s, "obj"), painted(&s, "str"));
assert_eq!(
painted(&s, "obj"),
(
Some("Fonts\\ARIALN.TTF".into()),
Some(11.0),
Some([0.25, 0.5, 0.75, 1.0])
)
);
// …and both name the same object back.
assert!(s
.eval::<bool>("return byObject:GetFontObject() == byName:GetFontObject()")
.unwrap());
// nil is the THIRD form the reference's own usage string names
// (`SetFontObject(font or "font" or nil)`, `.rdata 0x87c5cc`): it severs the link and leaves
// the paint standing.
s.run("byName:SetFontObject(nil)").unwrap();
assert!(s
.eval::<bool>("return byName:GetFontObject() == nil")
.unwrap());
s.resolve();
assert_eq!(
painted(&s, "str").1,
Some(11.0),
"unlinking must not repaint"
);
// A frame or an unknown name is still an ERROR, never a silent no-op (1203/1205/1211's class).
assert!(s.run("byName:SetFontObject(f)").is_err());
assert!(s.run("byName:SetFontObject('NoSuchFont')").is_err());
}
/// `GetFontObject` returns the **object**, not a name — because `Dewdrop-2.0.lua:2181` indexes the
/// result immediately: `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())`.
/// 65 sites across 62 corpus addons do exactly this.
#[test]
fn dewdrop_recolors_a_row_from_its_own_font_object() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GameFontHighlightSmall",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(10.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "DdRow")
f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER")
button = { text = f:CreateFontString(nil, "ARTWORK") }
button.text:SetText("row")
button.text:SetFontObject(GameFontHighlightSmall)
button.text:SetTextColor(button.text:GetFontObject():GetTextColor())
"#,
)
.expect("Dewdrop's row recolor must not raise");
s.resolve();
assert_eq!(painted(&s, "row").2, Some([1.0, 1.0, 1.0, 1.0]));
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Mutability — the decision, pinned
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// **The mutability decision.** A font object is a shared record with a **live** link: mutating it
/// re-paints every FontString that inherits it, which is why addons mutate one at all. What a
/// FontString set *for itself* since its own `SetFontObject` survives that re-paint — our
/// `FONTINSTANCE+0x038 explicitlySetMask`.
///
/// If this ever regresses to a one-shot copy, `inherited` stops moving and the test says so; if the
/// mask is dropped, `overridden` loses its red.
#[test]
fn mutating_a_font_object_repaints_everything_that_inherits_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"ThemeFont",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(12.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "ThemeHost")
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
a = f:CreateFontString(nil, "ARTWORK"); a:SetText("inherited")
a:SetFontObject(ThemeFont)
b = f:CreateFontString(nil, "ARTWORK"); b:SetText("overridden")
b:SetFontObject(ThemeFont)
b:SetTextColor(1, 0, 0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(painted(&s, "inherited").1, Some(12.0));
assert_eq!(painted(&s, "overridden").2, Some([1.0, 0.0, 0.0, 1.0]));
// The mutation the whole feature exists for.
s.run(
r#"
ThemeFont:SetFont("Fonts\\MORPHEUS.TTF", 20)
ThemeFont:SetTextColor(0, 1, 0)
"#,
)
.unwrap();
s.resolve();
// Both followed the face and size…
assert_eq!(
painted(&s, "inherited"),
(
Some("Fonts\\MORPHEUS.TTF".into()),
Some(20.0),
Some([0.0, 1.0, 0.0, 1.0])
)
);
assert_eq!(
painted(&s, "overridden").0,
Some("Fonts\\MORPHEUS.TTF".into())
);
assert_eq!(painted(&s, "overridden").1, Some(20.0));
// …but the one that had set its own colour kept it.
assert_eq!(
painted(&s, "overridden").2,
Some([1.0, 0.0, 0.0, 1.0]),
"an explicitly-set property must survive a font-object mutation"
);
// §5-verified: severance SURVIVES a re-point. The reference's inheritMask bit (`+0x2c`,
// FontString `+0xd4`) is cleared by the local setter and never restored, so "a FontString that
// set its own colour stays severed even across a later SetFontObject". Our first cut reset the
// mask here and this assertion was its inverse.
s.run("b:SetFontObject(ThemeFont)").unwrap();
s.resolve();
assert_eq!(
painted(&s, "overridden").2,
Some([1.0, 0.0, 0.0, 1.0]),
"a re-point must not restore inheritance of a severed property"
);
// Dewdrop still works, because it re-reads the colour off the new object explicitly every
// refresh (`Dewdrop-2.0.lua:2181`) rather than relying on inheritance.
s.run("b:SetTextColor(b:GetFontObject():GetTextColor())")
.unwrap();
s.resolve();
assert_eq!(painted(&s, "overridden").2, Some([0.0, 1.0, 0.0, 1.0]));
}
/// A mutation of a font object nobody inherits touches nothing, and a FontString that inherits a
/// *different* object is not caught in the sweep.
#[test]
fn propagation_is_scoped_to_the_object_that_changed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
for (name, face) in [("FontOne", "Fonts\\ONE.TTF"), ("FontTwo", "Fonts\\TWO.TTF")] {
s.register_font_object(
name,
FontObject {
font: Some(face.into()),
height: Some(12.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
..FontObject::default()
},
);
}
s.run(
r#"
f = CreateFrame("Frame", "ScopeHost")
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
one = f:CreateFontString(nil, "ARTWORK"); one:SetText("one"); one:SetFontObject(FontOne)
two = f:CreateFontString(nil, "ARTWORK"); two:SetText("two"); two:SetFontObject(FontTwo)
FontOne:SetFont("Fonts\\CHANGED.TTF", 30)
"#,
)
.unwrap();
s.resolve();
assert_eq!(painted(&s, "one").0, Some("Fonts\\CHANGED.TTF".into()));
assert_eq!(painted(&s, "two").0, Some("Fonts\\TWO.TTF".into()));
assert_eq!(painted(&s, "two").1, Some(12.0));
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// CreateFont
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// **`_Nameplates.lua:149` + `:129` + `:212`, end to end** — mint a font object at runtime, set it
/// up, then paint a FontString with it. Plus `!OmniCC/main.lua:40-41`'s half: the name is published
/// as a global even when the return value is thrown away, and `SetFont`'s return is the font-file
/// validity probe OmniCC uses it as.
#[test]
fn create_font_mints_publishes_and_paints() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Nameplate = { Font = CreateFont("_NameplatesNameplateFont") }
Nameplate.Font:SetFont("Fonts\\SKURRI.TTF", 10)
Nameplate.Font:SetTextColor(0, 0.5, 1)
f = CreateFrame("Frame", "PlateHost")
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
Name = f:CreateFontString(nil, "ARTWORK")
Name:SetText("plate")
Name:SetFontObject(Nameplate.Font)
"#,
)
.expect("_Nameplates' font setup must not raise");
s.resolve();
assert_eq!(
painted(&s, "plate"),
(
Some("Fonts\\SKURRI.TTF".into()),
Some(10.0),
Some([0.0, 0.5, 1.0, 1.0])
)
);
// The return value and the published global are the SAME object — OmniCC reads only the global.
assert!(s
.eval::<bool>("return _NameplatesNameplateFont == Nameplate.Font")
.unwrap());
assert_eq!(
s.eval::<String>("return _NameplatesNameplateFont:GetObjectType()")
.unwrap(),
"Font"
);
// `!OmniCC/main.lua:40-41` verbatim: create, discard the return, use the global, and branch on
// SetFont's return to detect a saved font path that is no longer usable.
let reverted = s
.eval::<bool>(
r#"
local reverted = false
if not OmniCCFont then
CreateFont("OmniCCFont")
if not OmniCCFont:SetFont("", 20) then
reverted = true
OmniCCFont:SetFont("Fonts\\FRIZQT__.TTF", 20)
end
end
return reverted
"#,
)
.unwrap();
assert!(reverted, "SetFont must report an unusable path as false");
assert_eq!(
s.eval::<(String, f32, String)>("return OmniCCFont:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 20.0, String::new())
);
// A name that already names a font object hands the existing one back UNCHANGED — the
// non-destructive reading (see `create_font`'s doc). The alternative would let
// `CreateFont("GameFontNormal")` blank the shipped registry entry the whole UI inherits.
s.run("again = CreateFont('_NameplatesNameplateFont')")
.unwrap();
assert!(s.eval::<bool>("return again == Nameplate.Font").unwrap());
assert_eq!(
s.eval::<(String, f32, String)>("return again:GetFont()")
.unwrap(),
("Fonts\\SKURRI.TTF".to_string(), 10.0, String::new())
);
// A nameless CreateFont is an error: the name IS the publication. The EMPTY name is not —
// the reference's `lua_isstring` gate takes it (unlike the XML path), so we do too.
assert!(s.run("CreateFont()").is_err());
s.run("CreateFont('')").unwrap();
}
/// **`FonzAppraiser/mods/gui/gui.lua:27-30`** — `CreateFont` then `CopyFontObject(<a shipped
/// object>)` then override the face. The corpus's only Font-on-Font call.
#[test]
fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
let s = script();
s.register_font_object(
"GameFontHighlightSmall",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(10.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: Some(JustifyH::Right),
justify_v: None,
shadow: None,
},
);
s.run(
r#"
small_number_font = CreateFont("FonzAppraiser_NumberFontNormalSmall")
small_number_font:CopyFontObject(GameFontHighlightSmall)
small_number_font:SetFont("Fonts\\ARIALN.TTF", 12)
"#,
)
.expect("FonzAppraiser's font setup must not raise");
// The copy brought the colour and justification over; the SetFont replaced the face and size.
assert_eq!(
s.eval::<(String, f32, String)>("return small_number_font:GetFont()")
.unwrap(),
("Fonts\\ARIALN.TTF".to_string(), 12.0, String::new())
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return small_number_font:GetTextColor()")
.unwrap(),
(1.0, 1.0, 1.0, 1.0)
);
assert_eq!(
s.eval::<String>("return small_number_font:GetJustifyH()")
.unwrap(),
"RIGHT"
);
// …and the object it copied FROM is untouched.
assert_eq!(
s.eval::<(String, f32, String)>("return GameFontHighlightSmall:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 10.0, String::new())
);
}
/// The `Spacing` pair is **deliberately absent** (we model no line spacing, and a stored-but-never
/// drawn setter is this codebase's recurring silent-drop bug). It must fail LOUDLY rather than
/// quietly accept a number nobody honours — this test is the tripwire on that choice, and it flips
/// the day spacing becomes real.
#[test]
fn the_unmodelled_spacing_pair_fails_loudly() {
let s = script();
s.register_font_object("SomeFont", FontObject::default());
assert!(s.run("SomeFont:SetSpacing(4)").is_err());
assert!(s.eval::<bool>("return SomeFont.SetSpacing == nil").unwrap());
}
/// A button's per-state label fonts take the object or the name too (the corpus splits 5 to 4
/// across the trio), and — because they are stored as NAMES and re-resolved at every extract — a
/// later mutation of that font object reaches the label with no further call.
#[test]
fn button_state_fonts_take_the_object_and_follow_its_mutation() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GameFontNormal",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(12.0),
color: Some([1.0, 0.82, 0.0, 1.0]),
..FontObject::default()
},
);
s.run(
r#"
b = CreateFrame("Button", "StateFontButton")
b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER")
b:SetText("go")
b:SetTextFontObject(GameFontNormal)
"#,
)
.expect("the object form must be accepted");
s.resolve();
assert_eq!(painted(&s, "go").1, Some(12.0));
s.run("GameFontNormal:SetFont('Fonts\\\\MORPHEUS.TTF', 22)")
.unwrap();
s.resolve();
assert_eq!(
painted(&s, "go"),
(
Some("Fonts\\MORPHEUS.TTF".into()),
Some(22.0),
Some([1.0, 0.82, 0.0, 1.0])
)
);
// The string form still works alongside it.
s.run("b:SetHighlightFontObject('GameFontNormal')").unwrap();
}
/// **A `CreateFont` object holds nothing, so pointing a FontString at it copies nothing.**
/// §5-verified: the reference gates every merge on the source's own has-a-value mask, and a fresh
/// font has `mask == 0` — the FontString keeps exactly what it had, no blanking and no fallback to
/// a default. Our first cut wrote face/height through unconditionally and would have wiped it.
#[test]
fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"DressedFont",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(14.0),
color: Some([0.2, 0.4, 0.6, 1.0]),
..FontObject::default()
},
);
s.run(
r#"
f = CreateFrame("Frame", "EmptyFontHost")
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
fs = f:CreateFontString(nil, "ARTWORK")
fs:SetText("keep")
fs:SetFontObject(DressedFont)
blank = CreateFont("ABlankFont")
fs:SetFontObject(blank)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
painted(&s, "keep"),
(
Some("Fonts\\FRIZQT__.TTF".into()),
Some(14.0),
Some([0.2, 0.4, 0.6, 1.0])
),
"an unset property must copy nothing, not blank the region"
);
// The link still moved, so once the blank font is dressed the string follows it.
assert_eq!(
s.eval::<String>("return fs:GetFontObject():GetName()")
.unwrap(),
"ABlankFont"
);
s.run("blank:SetFont('Fonts\\\\MORPHEUS.TTF', 22)").unwrap();
s.resolve();
assert_eq!(painted(&s, "keep").0, Some("Fonts\\MORPHEUS.TTF".into()));
}
/// `SetFont` returns **1 or nil**, not a boolean — the reference's exact return shape, and what
/// `!OmniCC/main.lua:41` branches on.
#[test]
fn set_font_returns_one_or_nil() {
let s = script();
s.run("f = CreateFont('ReturnShapeFont')").unwrap();
assert_eq!(
s.eval::<f32>("return f:SetFont('Fonts\\\\FRIZQT__.TTF', 12)")
.unwrap(),
1.0
);
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"
);
}
/// `CreateFontString`'s **third argument** applies a font object — the argument we accepted and
/// then dropped on the floor (decision 1255).
///
/// 49 corpus call sites across 5 distinct addons pass one, and every one names a font object:
/// AckisRecipeList (28), CustomNameplates (10), _LazyPig (6), LibAboutPanel (4), ColorPickerPlus.
/// Five separate addons, so this is not one library file replicated. Ignoring it was 1203's class —
/// the addon asks for a font, the call succeeds, and the text comes out in the default with no
/// failure anywhere to point at.
///
/// The **order** is the part a reimplementation loses: the font-object registry is tried FIRST and
/// the template registry only on a font miss (`0x773d39` then `0x773d47`). A template-first
/// resolver would miss all 49 of these, because `inherits=` is one argument over two namespaces.
#[test]
fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameFontNormalSmall" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0.82" b="0"/>
</Font>
<Frame name="Host"/>
</Ui>"#,
);
// Exactly `_LazyPig/LazyPigMenu.lua:88`'s line.
s.run(r#"FS = Host:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")"#)
.expect("the font-object form must be accepted");
let (face, height, _flags) = s
.eval::<(String, f32, String)>("return FS:GetFont()")
.expect("the region must carry the font object's face and height");
assert_eq!(face, "Fonts\\FRIZQT__.TTF");
assert_eq!(height, 12.0);
// A name in NEITHER registry raises — the same contract 1253 set for CreateFrame, and the same
// bytes (`luaL_error`, which never returns).
let err = s
.run(r#"Bad = Host:CreateFontString(nil, "ARTWORK", "NoSuchFontOrTemplate")"#)
.expect_err("a name in neither registry must raise");
assert!(
err.to_string().contains("NoSuchFontOrTemplate"),
"the raise must name what was looked up: {err}"
);
// Texture takes the same argument through the same resolver (1 real corpus site).
s.run(r#"TX = Host:CreateTexture(nil, "OVERLAY")"#)
.expect("the two-argument form still works");
}
/// **`SetFont`'s nil is a LOAD failure, and only the host knows** (decision 2103).
///
/// The reference answers the number 1 or nil (`0x79f345`/`0x79f361`), and the nil originates in
/// the font factory at `0x5c1ae0` — a path that names no readable file. `!OmniCC/main.lua:41`
/// reads it exactly that way (`if not Font:SetFont(saved, size) then revert end`), and an addon
/// that ships its own faces (MSBT ships thirty-one, under `Interface\Addons\…\Fonts\`) is the
/// case that makes the answer depend on a store rather than on the string.
///
/// The engine-less default is the other half: with no probe a non-empty path answers 1, because a
/// VM with no font backend has nothing for a load to fail against. That is the opposite default to
/// `SetTexture`'s (1322) and the reason is in `Model::font_probe`.
#[test]
fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
let mut s = script();
s.run("FS = CreateFrame('Frame'):CreateFontString()")
.unwrap();
// No probe: any non-empty path is 1, an empty one is nil.
assert!(s
.eval::<bool>(r"return FS:SetFont('Interface\\Addons\\Nope\\Fonts\\x.ttf', 12) == 1")
.unwrap());
assert!(s.eval::<bool>("return FS:SetFont('', 12) == nil").unwrap());
// With one, the store decides — and the face it refused is NOT adopted, so the region keeps
// the last font that did load.
s.set_font_probe(Box::new(|path| {
path.eq_ignore_ascii_case("interface\\addons\\msbt\\fonts\\porky.ttf")
|| path.eq_ignore_ascii_case("fonts\\frizqt__.ttf")
}));
assert!(s
.eval::<bool>(r"return FS:SetFont('Fonts\\FRIZQT__.TTF', 12) == 1")
.unwrap());
assert!(
s.eval::<bool>(r"return FS:SetFont('Interface\\AddOns\\MSBT\\Fonts\\porky.ttf', 18) == 1")
.unwrap(),
"an addon's own face, spelled in its own case, must load"
);
assert!(
s.eval::<bool>(r"return FS:SetFont('Interface\\AddOns\\MSBT\\Fonts\\gone.ttf', 18) == nil")
.unwrap(),
"a path the store does not hold is the reference's falsey load failure"
);
let (face, height, _) = s
.eval::<(String, f32, String)>("return FS:GetFont()")
.unwrap();
assert_eq!(
face, "Interface\\AddOns\\MSBT\\Fonts\\porky.ttf",
"the refused face must not replace the one that loaded"
);
assert_eq!(height, 18.0, "…while the height, which never fails, is set");
}
/// **Mik's Scrolling Battle Text, end to end** — the exact sequence the addon runs per event,
/// pinned because it is the shape a whole class of "the addon's font did not take" reports wears
/// (decisions 2103, 2112).
///
/// MSBT's twenty scroll-area FontStrings are declared `inherits="MasterFont"` — the reference's
/// root font object, which carries a `<Shadow>` and **nothing else**: no face, no height, no
/// outline. Each animation then does, in this order, on the string it recycles:
/// `ClearAllPoints · SetFont(<its own TTF>, 18, "OUTLINE") · SetTextColor · SetText · SetAlpha ·
/// SetPoint`. Every one of those five paint axes has to reach the extracted quad *unmodified* by
/// the object the string inherits — the addon's face over the object's absent one, the addon's
/// height over the renderer default, the addon's outline over the object's `NONE` (which is
/// indistinguishable from "unset", see [`Outline`]), its colour over the object's absent one, and
/// the animation's fade as the quad's own alpha. The `<Shadow>` is the one thing that IS inherited,
/// and it must still be there.
#[test]
fn msbt_paints_its_own_face_size_outline_and_fade_over_the_font_object_it_inherits() {
let mut s = script();
s.set_screen_size(1600.0, 900.0);
load(
&s,
r#"<Ui>
<Font name="MasterFont" virtual="true">
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
</Font>
<Frame name="MSBTFrameIncoming" parent="UIParent">
<Size><AbsDimension x="24" y="24"/></Size>
<Anchors><Anchor point="BOTTOM" relativePoint="CENTER" relativeTo="UIParent"/></Anchors>
<Layers><Layer level="ARTWORK">
<FontString name="$parentText1" inherits="MasterFont">
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</FontString>
</Layer></Layers>
</Frame>
</Ui>"#,
);
s.run(
r#"
local fs = getglobal("MSBTFrameIncomingText1")
fs:ClearAllPoints()
fs:SetFont("Interface\\Addons\\MikScrollingBattleText\\Fonts\\porky.ttf", 18, "OUTLINE")
fs:SetTextColor(1, 1, 1)
fs:SetText("-64")
fs:SetAlpha(0.5)
fs:SetPoint("BOTTOMRIGHT", 0, 0)
"#,
)
.unwrap();
s.resolve();
let q = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "-64"))
.expect("MSBT's text quad");
assert_eq!(
q.alpha, 0.5,
"the scroll animation's fade is the quad's alpha"
);
match q.content {
QuadContent::Text {
ref font,
font_height,
outline,
color,
shadow,
..
} => {
assert_eq!(
font.as_deref(),
Some("Interface\\Addons\\MikScrollingBattleText\\Fonts\\porky.ttf"),
"the addon's own face, not the fallback"
);
assert_eq!(font_height, Some(18.0), "the profile's master size");
assert_eq!(outline, Outline::Normal, "its OUTLINE flag");
assert_eq!(color, Some([1.0, 1.0, 1.0, 1.0]));
assert!(
shadow.is_some(),
"MasterFont's <Shadow> is the one axis the string never set, so it inherits"
);
}
ref other => panic!("expected a Text quad, got {other:?}"),
}
// The Lua-visible echo agrees with the quad — the readback an addon branches on.
assert_eq!(
s.eval::<(String, f32, String)>("return MSBTFrameIncomingText1:GetFont()")
.unwrap(),
(
"Interface\\Addons\\MikScrollingBattleText\\Fonts\\porky.ttf".to_string(),
18.0,
"OUTLINE".to_string()
)
);
}