pfUI's Who list raised on every row, because 1.12's font colours are tables and it calls methods

`NORMAL_FONT_COLOR:GetRGB()` is how pfUI colours each row of the /who list
(`modules/socialmod.lua:145`) and the master-loot menu (`modules/loot.lua`, five
more). 1.12 ships that global as a loose `{ r, g, b }` table; every client from
Legion on makes it a `ColorMixin` object. grom's session carried 26 `attempt to
call method 'GetRGB' (a nil value)` raises, all of them from opening the Who tab,
and the list drew uncoloured.

The six `*_FONT_COLOR` tables 1.12 declares now answer `ColorMixin`'s methods
through a metatable rather than a `Mixin` copy: these tables are FrameXML's, and
a 1.12 addon is free to walk one with `pairs`, so `__index` leaves
`pairs(NORMAL_FONT_COLOR)` answering exactly the keys it answers on the
reference. `WHITE_FONT_COLOR`, which 1.12 has no global for at all and pfUI's
totem tooltip and minimap label both index, is created here too.

In `manifest.rs` beside the other load-time repairs rather than next to
`ColorMixin` itself, for the same mechanical reason as `/focus`: the tables do
not exist until `Fonts.xml` has run, and the compat layer installs before
FrameXML loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-08 12:58:11 -07:00
co-authored by Claude Opus 5
parent e6c15868fc
commit acc2b5c1f6
@@ -71,6 +71,10 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
error!("ui_script: focus slash commands: {e}");
return vec![format!("focus slash commands: {e}")];
}
if let Err(e) = install_font_colour_objects(script) {
error!("ui_script: font colour objects: {e}");
return vec![format!("font colour objects: {e}")];
}
Vec::new()
}
@@ -109,6 +113,65 @@ if SlashCmdList then
end
"#;
/// ClassicAPI (`docs/ADDON_COMPAT.md`): 1.12's `*_FONT_COLOR` globals answer `ColorMixin`'s
/// methods, and `WHITE_FONT_COLOR` exists at all.
///
/// 1.12 ships these as loose `{ r, g, b }` tables (`Fonts.xml`); every client from Legion on
/// makes them `ColorMixin` objects, and addon code written in that dialect calls methods on them
/// rather than reading the fields. pfUI does, in both directions at once:
///
/// - `modules/socialmod.lua:145` colours each Who row with `NORMAL_FONT_COLOR:GetRGB()`, and
/// `modules/loot.lua` does the same five times for the master-loot menu. grom's session carried
/// **26** `attempt to call method 'GetRGB' (a nil value)` raises, every one of them from opening
/// the Who tab — the list drew uncoloured and each row cost a traceback.
/// - `modules/totems.lua:22` and `modules/minimap.lua:198` call `WHITE_FONT_COLOR:GetRGB()` and
/// `:GetRGBA()`, and 1.12 has no such global at all, so those index a nil.
///
/// Both are a one-line gap in the same place, so both are closed here.
///
/// **A metatable, not `Mixin`.** Upstream's `CreateColor` copies the methods in as fields, which is
/// right for an object it owns. These tables are not ours: FrameXML built them, FrameXML reads
/// them, and a 1.12 addon is free to walk one with `pairs`. `__index` leaves `pairs(NORMAL_FONT_COLOR)`
/// answering exactly the three keys it answers on the reference while the methods still resolve.
/// `a` is the one field added, because `GetRGBA` has to answer something and modern WoW's own
/// `NORMAL_FONT_COLOR` is opaque; `.a` on a 1.12 colour table is nil, and nothing in the stock UI
/// reads it.
///
/// Here rather than in `benilla-ui`'s `compat/classic_api/color_mixin.rs` for the same mechanical
/// reason as [`install_focus_slash_commands`]: the tables do not exist until `Fonts.xml` has run,
/// and the compat layer installs before FrameXML loads.
pub(super) fn install_font_colour_objects(script: &UiScript) -> Result<(), String> {
script.run(FONT_COLOUR_OBJECTS).map_err(|e| e.to_string())
}
/// The `*_FONT_COLOR` tables 1.12 declares (`reference/1.12-globals.tsv`), named one by one: a
/// sweep of `_G` for a key ending in `_FONT_COLOR` would also catch an addon's own table of the
/// same name and give it a metatable it did not ask for.
const FONT_COLOUR_OBJECTS: &str = r#"
if ColorMixin then
local meta = { __index = ColorMixin }
local function upgrade(c)
-- An existing metatable is somebody's own arrangement; leave it be.
if type(c) ~= "table" or getmetatable(c) then return c end
if c.a == nil then c.a = 1 end
setmetatable(c, meta)
return c
end
upgrade(NORMAL_FONT_COLOR)
upgrade(HIGHLIGHT_FONT_COLOR)
upgrade(GRAY_FONT_COLOR)
upgrade(GREEN_FONT_COLOR)
upgrade(RED_FONT_COLOR)
upgrade(PASSIVE_SPELL_FONT_COLOR)
-- Not a 1.12 global: modern WoW's `WHITE_FONT_COLOR = CreateColor(1, 1, 1)`.
if not WHITE_FONT_COLOR then
WHITE_FONT_COLOR = upgrade({ r = 1, g = 1, b = 1 })
end
end
"#;
/// SuperWoW (`docs/ADDON_COMPAT.md`): the macro body limit is 255 letters in 1.12 and 510 under
/// SuperWoW. [`benilla_ui::script::MAX_MACRO_BODY`] already stores the raised cap; two things in
/// the stock UI still carry the old number from the player's own install and need resetting at
@@ -351,6 +414,76 @@ pub(crate) fn load_ingame_ui(
mod tests {
use super::*;
/// pfUI's Who list and master-loot menu colour their rows with `NORMAL_FONT_COLOR:GetRGB()`,
/// and its totem tooltip and minimap zone label with `WHITE_FONT_COLOR`'s. 1.12 has loose
/// tables for the first and no global at all for the second, which is 26 raises in one of
/// grom's sessions, all of them from opening the Who tab.
#[test]
fn the_font_colour_globals_answer_colormixin_methods() {
let s = UiScript::new().unwrap();
// The shape `Fonts.xml` leaves behind.
s.run("NORMAL_FONT_COLOR = { r = 1, g = 0.82, b = 0 }")
.unwrap();
install_font_colour_objects(&s).unwrap();
assert_eq!(
s.eval::<(f64, f64, f64)>("return NORMAL_FONT_COLOR:GetRGB()")
.unwrap(),
(1.0, 0.82, 0.0),
"pfUI `modules/socialmod.lua:145`"
);
assert_eq!(
s.eval::<(f64, f64, f64, f64)>("return WHITE_FONT_COLOR:GetRGBA()")
.unwrap(),
(1.0, 1.0, 1.0, 1.0),
"pfUI `modules/minimap.lua:198`; 1.12 has no such global, so this one is ours"
);
// The fields FrameXML itself reads are untouched, and `pairs` still answers the keys a
// 1.12 addon expects — which is why this is a metatable and not a `Mixin` copy.
assert_eq!(
s.eval::<f64>("return NORMAL_FONT_COLOR.g").unwrap(),
0.82,
"`OptionsFrameAdapters.lua` reads `.r/.g/.b` directly"
);
let mut keys: Vec<String> = s
.eval::<Vec<String>>(
"local t = {} for k in pairs(NORMAL_FONT_COLOR) do table.insert(t, k) end return t",
)
.unwrap();
keys.sort();
assert_eq!(
keys,
vec!["a", "b", "g", "r"],
"no methods leak into `pairs`"
);
}
/// A table that already carries a metatable is somebody else's object, and a second run of
/// the patch (a `ReloadUI`) must not stack anything.
#[test]
fn upgrading_a_colour_table_leaves_an_existing_metatable_alone() {
let s = UiScript::new().unwrap();
s.run(
"NORMAL_FONT_COLOR = setmetatable({ r = 1, g = 1, b = 1 }, \
{ __index = function() return \"mine\" end })",
)
.unwrap();
install_font_colour_objects(&s).unwrap();
assert_eq!(
s.eval::<String>("return NORMAL_FONT_COLOR.anything")
.unwrap(),
"mine",
"an existing metatable is left as it was found"
);
assert!(
s.eval::<bool>("return rawget(NORMAL_FONT_COLOR, \"a\") == nil")
.unwrap(),
"and no field is added to it either (rawget: this table's own `__index` answers \
every key)"
);
}
/// ClassicAPI's `/focus` and `/clearfocus` (`docs/ADDON_COMPAT.md`): registered into the
/// table the stock `ChatFrame.lua` declares, and reachable by the same walk every typed or
/// macro line takes ([`UiScript::run_slash_command`]).