Nameplates read NAMEPLATE_FONT when built, as 1.12.1 does (#1464)

Nameplates took a fixed Friz Quadrata path whatever `NAMEPLATE_FONT` held, so an addon or a localized GlobalStrings could not restyle them.

1.12.1's plate constructor reads the global for each FontString it builds: the name at `0x7cb3f9` and the level at `0x7cb49b`. The read is `lua_gettable` on the globals, so `__index` runs (`0x704373`). A number reads as its text; anything else, or a missing global, gives "". `SetFont` refuses "" (`0x44d05c`) and leaves the string with no font.

Plates and chat bubbles now read the global through one lookup, when each is built. The read happens before the UI model is borrowed: an addon's `_G` `__index` that touched the model panicked the bubble's read.

Tests: a plate built after the global is set takes it, an older plate keeps its own font, the non-string cases, and an `__index` that calls `CreateFrame`.

Fixes #921

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-16 17:17:27 +07:00
committed by GitHub
parent d404e4e9e4
commit baf729cee6
6 changed files with 224 additions and 26 deletions
+39 -12
View File
@@ -9,7 +9,7 @@
//! The app owns each line's lifetime, fade and seat ([`BubbleState`]); this layer owns the frames:
//! it hands one out per line, takes it back when the line ends, and writes what moved.
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use crate::layout::{Anchor, Anchors, Point};
use crate::order::DrawLayer;
@@ -110,12 +110,20 @@ impl UiScript {
/// each new one, then write each live bubble's level, alpha, visibility and seat.
pub fn sync_chat_bubbles(&mut self, geometry: BubbleGeometry, states: &[BubbleState]) {
let lua = self.lua();
// The font a new bubble takes: `FrameScript_GetText` on `NAMEPLATE_FONT` (`0x4b0b36` →
// `0x703bf0`), whose `_G[key]` arm (`0x703c12`) answers the global's string, else the
// shared "" a missing or non-string global gives, which sets no font.
let font = || match lua.globals().get::<mlua::Value>("NAMEPLATE_FONT") {
Ok(mlua::Value::String(s)) => Some(s.to_string_lossy()).filter(|s| !s.is_empty()),
_ => None,
// One lookup per bubble about to be built (`0x4b0b36`), before the Model is borrowed: a
// lookup honours an `_G` `__index` (`lua_gettable` at `0x704373`), which may call any verb.
let mut fonts: VecDeque<Option<String>> = {
let built = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// No WorldFrame, no bubbles, and no lookups for them.
if model.arena.lookup("WorldFrame").is_none() {
return;
}
bubbles_to_build(&model, states)
};
(0..built)
.map(|_| super::font::nameplate_font(lua))
.collect()
};
let visibility = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -123,7 +131,7 @@ impl UiScript {
let Some(world) = model.arena.lookup("WorldFrame") else {
return;
};
sync(&mut model, world, geometry, states, &font)
sync(&mut model, world, geometry, states, &mut fonts)
};
// Each a full transition with its handlers, in the order the sync issued them.
for (frame, shown) in visibility {
@@ -144,13 +152,32 @@ pub(super) fn is_bubble(model: &Model, frame: FrameHandle) -> bool {
model.chat_bubbles.by_frame.contains_key(&frame)
}
/// How many new bubbles [`sync`] builds for `states`: the new lines (a key without a live line of
/// the same number) less the free list and the bubbles whose lines end this frame.
fn bubbles_to_build(model: &Model, states: &[BubbleState]) -> usize {
let cb = &model.chat_bubbles;
let live = |key: u64, line: u64| states.iter().any(|s| s.key == key && s.line == line);
let ended = cb
.active
.iter()
.filter_map(|&i| cb.bubbles[i].line)
.filter(|&(key, line)| !live(key, line))
.collect::<Vec<_>>();
let arriving: HashSet<u64> = states
.iter()
.map(|s| s.key)
.filter(|k| !cb.assigned.contains_key(k) || ended.iter().any(|(e, _)| e == k))
.collect();
arriving.len().saturating_sub(cb.free.len() + ended.len())
}
/// The per-frame drive; returns the `Show`/`Hide` calls to run, in order.
fn sync(
model: &mut Model,
world: FrameHandle,
geometry: BubbleGeometry,
states: &[BubbleState],
font: &dyn Fn() -> Option<String>,
fonts: &mut VecDeque<Option<String>>,
) -> Vec<(FrameHandle, bool)> {
let mut visibility = Vec::new();
@@ -172,7 +199,7 @@ fn sync(
Some(&i) => i,
None => {
// A new line: the spawn's acquire (`0x4b1870`), then its text (`0x4b1600`).
let i = acquire(model, world, geometry, font);
let i = acquire(model, world, geometry, fonts);
model.chat_bubbles.assigned.insert(state.key, i);
if model.chat_bubbles.bubbles[i].laid_out != Some(geometry) {
lay_out(model, i, geometry);
@@ -204,12 +231,12 @@ fn acquire(
model: &mut Model,
world: FrameHandle,
geometry: BubbleGeometry,
font: &dyn Fn() -> Option<String>,
fonts: &mut VecDeque<Option<String>>,
) -> usize {
if let Some(i) = model.chat_bubbles.free.pop_front() {
return i;
}
let bubble = create(model, world, geometry, font());
let bubble = create(model, world, geometry, fonts.pop_front().flatten());
let i = model.chat_bubbles.bubbles.len();
model.chat_bubbles.by_frame.insert(bubble.frame, i);
model.chat_bubbles.bubbles.push(bubble);
+12
View File
@@ -456,6 +456,18 @@ fn copy_from(lua: &Lua, this: &Table, other: &Value, verb: &str) -> mlua::Result
edit(lua, this, |fo| *fo = paint)
}
/// The font a new plate or chat bubble takes: `FrameScript_GetText` on `NAMEPLATE_FONT`
/// (`0x703bf0`; plates `0x7cb3ee`/`0x7cb490`, bubbles `0x4b0b36`). Its `_G[key]` arm (`0x703c12` →
/// `0x704350`) gates on `lua_isstring` (`0x6f3510`) and reads `lua_tostring` (`0x6f3690`), so a
/// string or a number answers its text and any other value the shared "". `None` is that empty
/// path, which `SetFont` rejects (`0x44d05c`, `0x44d040` answers 0) and leaves the FontString with
/// no font.
pub(super) fn nameplate_font(lua: &Lua) -> Option<String> {
let value = lua.globals().get::<Value>("NAMEPLATE_FONT").ok()?;
let text = lua.coerce_string(value).ok()??;
Some(text.to_string_lossy()).filter(|s| !s.is_empty())
}
/// `CreateFont(name)`: mint a font object, publish it as the global `name` and return it; addons
/// use both. An existing name returns that object unchanged and unrepublished (`0x7839ab`).
///
+58 -11
View File
@@ -8,6 +8,8 @@
//! Also the four verbs that switch plates on and off, which write two bits of the engine's
//! runtime dword `[0xc4da34]` and nothing else.
use std::collections::{HashSet, VecDeque};
use mlua::Lua;
use crate::layout::{Anchor, Anchors, Point};
@@ -28,8 +30,6 @@ pub const SKULL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-Sk
pub const RAID_ICON_TEXTURE: &str = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
/// The health bar's fill, which `GetStatusBarTexture()` returns.
pub const BAR_FILL_TEXTURE: &str = "Interface\\TargetingFrame\\UI-TargetingFrame-BarFill";
/// `NAMEPLATE_FONT`: Friz Quadrata.
pub const PLATE_FONT: &str = "Fonts\\FRIZQT__.TTF";
/// The plate's level; the bar's is one less, set absolutely (`0x7cb33e` → `0x76a4f0`). The draw
/// walks levels outermost and each level's layers inside (`0x765920`), so the whole bar draws
@@ -255,13 +255,29 @@ impl UiScript {
/// has its plate retired (hidden, never destroyed). An unchanged `geometry` costs nothing.
pub fn sync_nameplates(&mut self, geometry: PlateGeometry, states: &[PlateState]) {
let lua = self.lua();
// The constructor's two lookups (`0x7cb3f9`, `0x7cb49b`), for each plate about to be built
// and none for a built one. Done before the Model is borrowed: a lookup honours an `_G`
// `__index` (`lua_gettable` at `0x704373`), which may call any verb.
let mut fonts: VecDeque<Option<String>> = {
let built = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// No WorldFrame, no plates, and no lookups for them.
if model.arena.lookup("WorldFrame").is_none() {
return;
}
plates_to_build(&model, states)
};
(0..2 * built)
.map(|_| super::font::nameplate_font(lua))
.collect()
};
let effects = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// No WorldFrame (no interface loaded), no plates: no addon walks an invented root.
let Some(world) = model.arena.lookup("WorldFrame") else {
return;
};
sync(&mut model, world, geometry, states)
sync(&mut model, world, geometry, states, &mut fonts)
};
apply_visibility(lua, effects.visibility);
for (bar, value) in effects.values {
@@ -360,12 +376,31 @@ pub(super) fn note_click(lua: &mlua::Lua, id: u32, button: &str) {
});
}
/// How many new plates [`sync`] builds for `states`: the units without a plate, less the free
/// slots, which are the retired ones and those the departing units are about to free.
fn plates_to_build(model: &Model, states: &[PlateState]) -> usize {
let np = &model.nameplates;
let mut free = np.plates.iter().filter(|p| p.last.is_none()).count();
free += np
.assigned
.iter()
.filter(|(k, &i)| !states.iter().any(|s| s.key == **k) && np.plates[i].last.is_some())
.count();
let arriving: HashSet<u64> = states
.iter()
.map(|s| s.key)
.filter(|k| !np.assigned.contains_key(k))
.collect();
arriving.len().saturating_sub(free)
}
/// The per-frame drive: retire departed units, bind the rest, lay out, write what changed.
fn sync(
model: &mut Model,
world: FrameHandle,
geometry: PlateGeometry,
states: &[PlateState],
fonts: &mut VecDeque<Option<String>>,
) -> SyncEffects {
let mut effects = SyncEffects::default();
let relayout = model.nameplates.geometry != Some(geometry);
@@ -396,7 +431,7 @@ fn sync(
let free = (0..model.nameplates.plates.len())
.find(|i| model.nameplates.plates[*i].last.is_none());
let i = free.unwrap_or_else(|| {
let plate = Plate::create(model, world);
let plate = Plate::create(model, world, fonts);
model.nameplates.plates.push(plate);
model.nameplates.plates.len() - 1
});
@@ -424,7 +459,11 @@ fn sync(
impl Plate {
/// Build one plate: the Button, its six regions in the reference's order, and the health bar.
/// Written once, so a region an addon blanks stays blank.
fn create(model: &mut Model, world: FrameHandle) -> Plate {
fn create(
model: &mut Model,
world: FrameHandle,
fonts: &mut VecDeque<Option<String>>,
) -> Plate {
// Anonymous, as the reference's name is zeroed (`0x76c50b`): plate addons reject a named
// `WorldFrame` child.
let frame = model.arena.create(FrameKind::Button, None, Some(world));
@@ -462,8 +501,10 @@ impl Plate {
GLOW_TEXTURE,
BlendMode::Add,
);
let name = font_string(model, frame, JustifyV::Bottom);
let level = font_string(model, frame, JustifyV::Middle);
// Each FontString looks `NAMEPLATE_FONT` up itself, name then level (`0x7cb3f9`,
// `0x7cb49b`), at construction only.
let name = font_string(model, frame, JustifyV::Bottom, fonts.pop_front().flatten());
let level = font_string(model, frame, JustifyV::Middle, fonts.pop_front().flatten());
let skull = texture(
model,
frame,
@@ -807,15 +848,21 @@ fn texture(
}
/// One of the plate's two FontStrings, OVERLAY as the ctor re-layers them (`0x7cb438`,
/// `0x7cb4ea`). `justify_v` seats the name by its ink: our shaper's Friz line box is ascent-heavy
/// and would drop it ~8 px below where the reference's metrics put it.
fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> RegionHandle {
/// `0x7cb4ea`), with the `NAMEPLATE_FONT` lookup's answer: `None` sets no font, as `SetFont`
/// rejects the empty path (`0x44d05c`). `justify_v` seats the name by its ink: our shaper's Friz
/// line box is ascent-heavy and would drop it ~8 px below where the reference's metrics put it.
fn font_string(
model: &mut Model,
frame: FrameHandle,
justify_v: JustifyV,
font: Option<String>,
) -> RegionHandle {
let rh = model
.arena
.create_region(frame, RegionKind::FontString, DrawLayer::Overlay, 0)
.expect("live plate");
let data = model.region_data.entry(rh).or_default();
data.font_path = Some(PLATE_FONT.to_string());
data.font_path = font;
data.font_explicit.face = true;
data.justify.set_v(justify_v);
rh
@@ -471,6 +471,31 @@ fn the_text_takes_the_nameplate_font_global() {
.unwrap());
}
/// The lookup honours an `_G` `__index` (`lua_gettable`, `0x704373`), which may call any verb, the
/// Model's included; it is made for a new bubble only, and outside the Model's borrow.
#[test]
fn a_global_index_metamethod_may_touch_the_model_during_a_bubbles_font_lookup() {
let mut s = vm();
s.run(
r#"hits = 0
setmetatable(_G, { __index = function(t, k)
if k == "NAMEPLATE_FONT" then
hits = hits + 1
CreateFrame("Frame", nil, UIParent):SetWidth(UIParent:GetWidth())
end
return nil
end })"#,
)
.unwrap();
drive(&mut s, &[say(1, 1, "a")]);
assert_eq!(s.eval::<i64>("return hits").unwrap(), 1);
// The same speaker's next line takes the freed frame; a second speaker needs a new one.
drive(&mut s, &[say(1, 2, "b")]);
assert_eq!(s.eval::<i64>("return hits").unwrap(), 1);
drive(&mut s, &[say(1, 2, "b"), say(2, 1, "c")]);
assert_eq!(s.eval::<i64>("return hits").unwrap(), 2);
}
/// The bubble text's justification as a Lua reader sees it.
fn text_justify(s: &mut UiScript) -> String {
s.eval::<String>(
@@ -1058,3 +1058,90 @@ fn a_slot_retired_and_retaken_in_one_sync_draws_at_once() {
assert!(texts.iter().any(|t| t == "Bear"), "{texts:?}");
assert!(s.take_errors().is_empty());
}
/// The name's and level's `GetFont` for the plate at child `n`, as `nil` or the path.
fn plate_fonts(s: &mut UiScript, n: usize) -> (Option<String>, Option<String>) {
let read = |s: &mut UiScript, region: &str| {
s.eval::<Option<String>>(&format!(
"local _, _, name, level = ({{ WorldFrame:GetChildren() }})[{n}]:GetRegions() \
local r = {region} return (r:GetFont())"
))
.unwrap()
};
(read(s, "name"), read(s, "level"))
}
/// The constructor looks `NAMEPLATE_FONT` up for each FontString (`0x7cb3f9`, `0x7cb49b` →
/// `0x703bf0`) and only then, so a plate built after the global is set takes it, and a plate built
/// before keeps its font when the global changes.
#[test]
fn a_new_plate_takes_the_nameplate_font_global_and_a_built_one_keeps_its_font() {
let mut s = vm();
s.run(r#"NAMEPLATE_FONT = "Fonts\\ARIALN.TTF""#).unwrap();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
let arial = Some("Fonts\\ARIALN.TTF".to_string());
assert_eq!(plate_fonts(&mut s, 1), (arial.clone(), arial.clone()));
s.run(r#"NAMEPLATE_FONT = "Fonts\\MORPHEUS.TTF""#).unwrap();
drive(
&mut s,
&[plate("Wolf", 30.0, 40.0), plate("Bear", 10.0, 90.0)],
);
let morph = Some("Fonts\\MORPHEUS.TTF".to_string());
assert_eq!(plate_fonts(&mut s, 1), (arial.clone(), arial));
assert_eq!(plate_fonts(&mut s, 2), (morph.clone(), morph));
assert!(s.take_errors().is_empty());
}
/// The lookup answers the global's text for a string or a number (`lua_isstring` `0x6f3510`,
/// `lua_tostring` `0x6f3690`) and "" for anything else; `SetFont` rejects the empty path
/// (`0x44d05c`), so such a plate's FontStrings get no font.
#[test]
fn a_missing_or_non_string_nameplate_font_leaves_the_plate_strings_without_a_font() {
for (setup, want) in [
("", None),
("NAMEPLATE_FONT = nil", None),
(r#"NAMEPLATE_FONT = """#, None),
("NAMEPLATE_FONT = true", None),
("NAMEPLATE_FONT = {}", None),
("NAMEPLATE_FONT = function() end", None),
("NAMEPLATE_FONT = 12", Some("12")),
] {
let mut s = vm();
s.run(setup).unwrap();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
let want = want.map(str::to_string);
assert_eq!(plate_fonts(&mut s, 1), (want.clone(), want), "{setup:?}");
}
}
/// The lookup is a `lua_gettable` on the globals (`0x704373`), so an `_G` `__index` runs inside
/// it and may call any verb, the Model's included: strict-mode and global-tracking addons do. The
/// lookup is made for a new plate only, and outside the Model's borrow.
#[test]
fn a_global_index_metamethod_may_touch_the_model_during_a_plates_font_lookup() {
let mut s = vm();
s.run(
r#"hits = 0
setmetatable(_G, { __index = function(t, k)
if k == "NAMEPLATE_FONT" then
hits = hits + 1
CreateFrame("Frame", nil, UIParent):SetWidth(UIParent:GetWidth())
end
return nil
end })"#,
)
.unwrap();
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
assert_eq!(s.eval::<i64>("return hits").unwrap(), 2, "name and level");
// A built plate is not looked up again, and a retaken slot is not a new plate.
drive(&mut s, &[plate("Wolf", 30.0, 40.0)]);
drive(&mut s, &[plate("Bear", 10.0, 90.0)]);
assert_eq!(s.eval::<i64>("return hits").unwrap(), 2);
drive(
&mut s,
&[plate("Bear", 10.0, 90.0), plate("Wolf", 30.0, 40.0)],
);
assert_eq!(s.eval::<i64>("return hits").unwrap(), 4);
assert!(s.take_errors().is_empty());
}
+3 -3
View File
@@ -645,7 +645,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A hovered or disabled label still reports the normal font, since the state fonts are overlaid at paint and a link made here would outlive the state. `font::repaint` keeps what the label set itself (its severance mask, `CSimpleFontString+0xd4`), and the local justify obeys the same mask. ([`src/script/button.rs:130`](../crates/benilla-ui/src/script/button.rs#L130))
- The slot is read before the handler, where the reference reads it after (`[esi+0x4dc]`), because reading first keeps the borrow simple and no stock handler re-slots its row. ([`src/script/button.rs:1042`](../crates/benilla-ui/src/script/button.rs#L1042))
- The reference composes the Shortcut with `GetRealZoneText` alone (`0x49efe8`), so in a capital `/leave Trade` sends `Trade - Stormwind City` and leaves nothing; ours uses the join's city-aware name, because the reference's form cannot leave the channel it joined. ([`src/script/channel.rs:480`](../crates/benilla-ui/src/script/channel.rs#L480))
- The frame's bottom-left origin is snapped onto the device grid so the border art blits 1:1; the reference ceils the anchor point itself onto it (`0x7666a0`, `0x766750`). ([`src/script/chat_bubble.rs:449`](../crates/benilla-ui/src/script/chat_bubble.rs#L449))
- The frame's bottom-left origin is snapped onto the device grid so the border art blits 1:1; the reference ceils the anchor point itself onto it (`0x7666a0`, `0x766750`). ([`src/script/chat_bubble.rs:476`](../crates/benilla-ui/src/script/chat_bubble.rs#L476))
- No truncation at 255 characters (`0x49f604`), because vmangos refuses a longer line itself (`Chat.cpp:2177`): it is dropped where the reference sends its first 255. ([`src/script/chat_send.rs:23`](../crates/benilla-ui/src/script/chat_send.rs#L23))
- A value outside 0..1 clamps, where the reference keeps the low byte (alpha 2.0 stores 254, -1.0 stores 1), because no FrameXML or addon caller passes one. ([`src/script/chat_window.rs:17`](../crates/benilla-ui/src/script/chat_window.rs#L17))
- An id outside the seven built windows raises, where the reference serves ten records and silently ignores any other id (`0x4a0be1`), because benilla keeps only the seven and every caller loops `1, NUM_CHAT_WINDOWS`. ([`src/script/chat_window.rs:220`](../crates/benilla-ui/src/script/chat_window.rs#L220))
@@ -687,8 +687,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
- A flag, not the reference's append to every event's list (`0x7023e0`, which orders by registration time and lets `UnregisterEvent` drop one event), because events register here by name and no list of every name exists. ([`src/script/model.rs:284`](../crates/benilla-ui/src/script/model.rs#L284))
- A path that does not resolve is stored, where the reference raises `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the call cannot know yet. ([`src/script/modelframe.rs:335`](../crates/benilla-ui/src/script/modelframe.rs#L335))
- Benilla plays no pre-rendered movie (#968), so `StartMovie` answers nil, the reference's "could not start" (`0x78d861`), which the stock `MovieFrame_PlayMovie` answers by hiding the frame and so by returning to the login screen. ([`src/script/movieframe.rs:4`](../crates/benilla-ui/src/script/movieframe.rs#L4))
- The reference's free list is FIFO; either keeps every index stable, all an addon can observe. ([`src/script/nameplate.rs:394`](../crates/benilla-ui/src/script/nameplate.rs#L394))
- The reference adds this rim over the bar; we brighten the bar (`LIT_BOOST`), because the rim reads as hard edge lines in our linear-blending pipeline. ([`src/script/nameplate.rs:832`](../crates/benilla-ui/src/script/nameplate.rs#L832))
- The reference's free list is FIFO; either keeps every index stable, all an addon can observe. ([`src/script/nameplate.rs:429`](../crates/benilla-ui/src/script/nameplate.rs#L429))
- The reference adds this rim over the bar; we brighten the bar (`LIT_BOOST`), because the rim reads as hard edge lines in our linear-blending pipeline. ([`src/script/nameplate.rs:879`](../crates/benilla-ui/src/script/nameplate.rs#L879))
- A wrapper that decodes to no live widget takes `default_parent`, because the reference, with no `IsA` check, reads a wild pointer. ([`src/script/object/anchor_args.rs:160`](../crates/benilla-ui/src/script/object/anchor_args.rs#L160))
- Real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`, `OnInputLanguageChanged`), because an accepted handler that never runs fails silently. ([`src/script/object/events_regions.rs:222`](../crates/benilla-ui/src/script/object/events_regions.rs#L222))
- An `OnSizeChanged` that resolve produces fires at the next `UiScript::resolve`, because a Lua handler must not run in the middle of a level compaction. ([`src/script/object/toplevel.rs:74`](../crates/benilla-ui/src/script/object/toplevel.rs#L74))