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>
This commit is contained in:
Sam
2026-09-11 20:49:50 +07:00
co-authored by Claude Fable 5.1
parent 99b5600e25
commit 321bcbb393
26 changed files with 1256 additions and 90 deletions
@@ -1073,3 +1073,96 @@ fn a_lazily_made_label_is_anchored_by_the_normal_fonts_justify() {
s.run("left:SetTextFontObject(ProbeFontRight)").unwrap();
assert_eq!(point(&s, "left"), ("LEFT".into(), "LEFT".into(), 0.0, 0.0));
}
/// **A label that set its own face keeps it — the severance mask covers every axis, not three of
/// six** (decision 2112).
///
/// `font_explicit` is the client's explicitly-set mask (`FONTINSTANCE+0x38`): an axis a widget
/// writes for *itself* severs inheritance from the font instance it reads, and is never restored
/// (wow-re `font-object-lua-surface.md`; the `button_font` block in `script::extract` cites it by
/// name). `font::repaint` honours it on all seven axes. The extract's per-state re-point honoured
/// it on shadow, colour and both justifies — and not on **face, height or outline**: the face and
/// height read `fo.x.or(data.x)`, which makes the object outrank an explicit `SetFont`, and the
/// outline was written unconditionally. A `<ButtonText>` that called
/// `SetFont(path, h, "OUTLINE")` for itself therefore had all three silently put back from the
/// button's font object on the very next extract — every frame, so no Lua could win the race.
#[test]
fn a_button_labels_own_setfont_survives_the_state_font_repoint() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"TemplateFont",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(12.0),
color: Some([1.0, 0.82, 0.0, 1.0]),
outline: Outline::None,
..Default::default()
},
);
s.run(
r#"
b = CreateFrame("Button", "SkinnedBtn")
b:SetPoint("CENTER", 0, 0); b:SetSize(100, 20)
b:SetText("Label")
b:SetTextFontObject("TemplateFont")
b:GetFontString():SetFont("Interface\\Addons\\Skin\\Fonts\\porky.ttf", 18, "OUTLINE")
"#,
)
.unwrap();
let painted = |s: &mut crate::script::UiScript| {
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
ref font,
font_height,
outline,
..
} if t == "Label" => Some((font.clone(), font_height, outline)),
_ => None,
})
.expect("label text quad")
};
assert_eq!(
painted(&mut s),
(
Some("Interface\\Addons\\Skin\\Fonts\\porky.ttf".to_string()),
Some(18.0),
Outline::Normal
),
"the label's own SetFont severs face, height AND outline from the state font object"
);
// …and it survives a state change, which is what re-runs the re-point.
s.run("b:Disable()").unwrap();
assert_eq!(
painted(&mut s),
(
Some("Interface\\Addons\\Skin\\Fonts\\porky.ttf".to_string()),
Some(18.0),
Outline::Normal
),
"…and a disable re-points the instance without restoring what the label severed"
);
// The axes the label did NOT set still follow the object: the colour is the template's gold.
s.resolve();
let color = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "Label" => Some(color),
_ => None,
})
.expect("label text quad");
assert_eq!(
color,
Some([1.0, 0.82, 0.0, 1.0]),
"an axis the label never set still inherits — severance is per-axis"
);
}
@@ -1081,3 +1081,96 @@ fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
);
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()
)
);
}
@@ -1233,3 +1233,103 @@ fn set_portrait_texture_folds_the_token_to_lowercase() {
assert_eq!(bound.as_deref(), Some("npc"));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **`FontString:GetText` substitutes nil for an EMPTY string, and that substitution is the
/// getter's own** — `0x79d690` reads the cell, and eleven bytes before the pushstring it does a
/// FIRST-BYTE test and zeroes the pointer on `'\0'`:
///
/// ```text
/// 79d735 test eax,eax / je 0x79d740 ; NULL -> substitute
/// 79d73b cmp byte ptr [eax],0x0 ; the first-byte test
/// 79d73e jne 0x79d742 ; non-empty -> keep
/// 79d740 xor eax,eax ; EMPTY -> NULL
/// 79d746 call 0x6f3890 ; NULL -> pushnil
/// ```
///
/// So it cannot return `""`, whatever the cell holds — and the cell really does hold non-NULL
/// empty strings, because `SetText 0x771d80` never writes NULL to `+0xf0` on any leg: NULL and
/// `""` share one leg that truncates the buffer in place. Per binding, not per family:
/// `Button:GetText 0x780e10` carries the same substitution (`0x780ec5`), `EditBox:GetText
/// 0x7985c0` carries none (wow-re `fontstring-text-cell-and-gettext-contract.md`, a §5 round;
/// decision 2110).
///
/// The director's shape (Cartographer 2.02, the world map's hover label): the stock
/// `WorldMapFrameAreaDescription` is blanked with `SetText("")` by `WorldMapPOI_OnEnter`/`_OnLeave`
/// on **every** POI hover, and Cartographer's ZoneInfo reads
/// `if WorldMapFrameAreaDescription:GetText() then` as "this POI has a status line, so put the
/// zone's level range there instead of on the label". Answering `""` made that true forever after
/// the first POI touch — the name lost its faction colour, the range moved to the description's own
/// line beneath it, and nothing ever cleared it again.
#[test]
fn an_empty_fontstring_reads_back_nil_and_an_edit_box_does_not() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "TextCell")
fresh = f:CreateFontString(nil, "OVERLAY")
held = f:CreateFontString(nil, "OVERLAY")
"#,
)
.unwrap();
let text_of = |s: &UiScript, which: &str| {
s.eval::<Option<String>>(&format!("return {which}:GetText()"))
.unwrap()
};
// Never written, and every shape of an empty write, all nil.
assert_eq!(text_of(&s, "fresh"), None);
for write in [
r#"fresh:SetText("")"#,
r#"fresh:SetText(nil)"#,
r#"fresh:SetFormattedText("%s", "")"#,
] {
s.run(write).unwrap();
assert_eq!(text_of(&s, "fresh"), None, "after `{write}`");
}
// A string that HAS held text still reads nil once blanked — the substitution is the getter's,
// so it does not matter that the cell keeps a (truncated) buffer.
s.run(r#"held:SetText("In Conflict")"#).unwrap();
assert_eq!(text_of(&s, "held").as_deref(), Some("In Conflict"));
s.run(r#"held:SetText("")"#).unwrap();
assert_eq!(
text_of(&s, "held"),
None,
"a blanked FontString reads back nil, not an empty string"
);
s.run(r#"held:SetText("back"); held:SetText(nil)"#).unwrap();
assert_eq!(text_of(&s, "held"), None);
// Button:GetText carries the SAME substitution; Button:SetText(nil) is its own no-op guard.
s.run(
r#"
local b = CreateFrame("Button", "TextCellButton")
b:SetText("Accept")
b:SetText(nil)
"#,
)
.unwrap();
assert_eq!(
text_of(&s, "TextCellButton").as_deref(),
Some("Accept"),
"a nil never reaches the button's label (`0x778dcc`)"
);
s.run(r#"TextCellButton:SetText("")"#).unwrap();
assert_eq!(text_of(&s, "TextCellButton"), None, "an empty label is nil");
// And the EditBox is the counter-example that keeps this from being hoisted: stock
// `MailFrame.lua` compares `GetText() == ""` and calls `strlen(GetText())` on one.
s.run(
r#"
local e = CreateFrame("EditBox", "TextCellEdit")
e:SetText("")
"#,
)
.unwrap();
assert_eq!(
text_of(&s, "TextCellEdit").as_deref(),
Some(""),
"EditBox:GetText 0x7985c0 reads [edit+0x32c] straight through — no substitution"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -375,3 +375,66 @@ fn slider_onload_range_does_not_run_an_unarmed_onvaluechanged() {
)
.unwrap();
}
#[test]
fn a_thumb_with_no_authored_size_takes_its_arts_texel_span_and_still_drags() {
// Dewdrop-2.0's `OpenSlider` (Cartographer 2.02 → Look 'n' Feel → Overlay transparency), in
// its own construction order — the shape that has no `<ThumbTexture><Size>` anywhere:
// `SetThumbTexture(path)` from Lua and nothing else. The reference asks the thumb for its own
// `GetWidth`/`GetHeight` (`0x789ba0` → `[thumb_vt+0x1c]/[+0x20]` = `CSimpleTexture::0x770720`
// /`0x770790`), which falls back to the art's native texel span — 32×32 for
// `UI-SliderBar-Button-Vertical` — so the thumb is a 32-unit knob on a 128-unit track with
// 96 units of travel. Ours read the *authored* size only and, finding none, sized the thumb to
// the whole track: zero travel, a thumb smeared over the bar, and a drag that cannot move.
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.set_texture_size_probe(Box::new(|p| {
p.contains("UI-SliderBar-Button-Vertical")
.then_some((32, 32))
}));
s.run(
r#"
-- The popout is a parentless FULLSCREEN_DIALOG frame that takes the mouse itself, with
-- the slider one frame level above it — so this also pins that the press resolves to the
-- Slider and not to the mouse-enabled host sitting under it.
host = CreateFrame("Frame", "DdHost", nil)
host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
host:SetSize(80, 170)
host:SetFrameStrata("FULLSCREEN_DIALOG")
host:EnableMouse(true)
bar = CreateFrame("Slider", "DdSlider", host)
bar:SetFrameLevel(host:GetFrameLevel() + 1)
bar:SetOrientation("VERTICAL")
bar:SetMinMaxValues(0, 1)
bar:SetValueStep(0.01)
bar:SetValue(0.5)
bar:SetWidth(16)
bar:SetHeight(128)
bar:SetPoint("LEFT", host, "LEFT", 15, 0)
bar:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Vertical")
-- Dewdrop then seats the open value: overlayAlpha is 100 % of a 25 %..100 % range, and
-- the popout inverts it (`1 - (value-min)/(max-min)`), so the engine value is 0 = the
-- TOP of a vertical track.
bar:SetValue(0)
"#,
)
.unwrap();
s.resolve();
// host y ∈ [100, 270] ⇒ the bar is centred on y=185, 128 tall ⇒ track y ∈ [121, 249];
// x: host left 100 + 15 ⇒ [115, 131], so the thumb centres on x=123.
assert_eq!(
thumb_rect(&s.extract(), "UI-SliderBar-Button-Vertical"),
(107.0, 139.0, 217.0, 249.0),
"a sizeless thumb is its art's 32×32, flush at the track top — not the whole 16×128 track"
);
// And it drags: grab the thumb at its centre and pull to the bottom of the track.
s.mouse_button(123.0, 233.0, "LeftButton", true);
s.mouse_move(123.0, 137.0);
let v: f32 = s.eval("return DdSlider:GetValue()").unwrap();
assert!(
(v - 1.0).abs() < 1e-3,
"travel is 128−32 = 96, so a 96-unit pull runs the value min→max (got {v})"
);
}