Files
benilla-octo/crates/benilla-ui/src/script/tests/font_object.rs
T
SamandClaude Fable 5 d7f4c59fa8 our API surface is diffed against the client's 19,572 globals
- `--surface` re-runs the comparison against the captured in-world global table on demand, and `--surface-dump` writes our raw side for any tool to join — a one-shot measurement stops drifting
- CreateFrame with an unresolvable template raises exactly as the client does, instead of handing back a bare frame; `inherits=` is one name, matched case-insensitively
- CreateFontString and CreateTexture honour their third argument — 49 corpus call sites were passing an inherit into the void
- the addon-message inbound byte map is corrected (a test had agreed with the wrong constants), and the group-loot roll template publishes under the reference's own name

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:08:30 +07:00

1032 lines
41 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");
}