diff --git a/crates/benilla-ui/src/script/chat_bubble.rs b/crates/benilla-ui/src/script/chat_bubble.rs index 41a5e61cd..b9a8d4f72 100644 --- a/crates/benilla-ui/src/script/chat_bubble.rs +++ b/crates/benilla-ui/src/script/chat_bubble.rs @@ -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::("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> = { + let built = { + let model = lua.app_data_ref::().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::().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::>(); + let arriving: HashSet = 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, + fonts: &mut VecDeque>, ) -> 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, + fonts: &mut VecDeque>, ) -> 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); diff --git a/crates/benilla-ui/src/script/font.rs b/crates/benilla-ui/src/script/font.rs index 1623e3e88..64625dd89 100644 --- a/crates/benilla-ui/src/script/font.rs +++ b/crates/benilla-ui/src/script/font.rs @@ -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 { + let value = lua.globals().get::("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`). /// diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index cc177b97f..db010ac83 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -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> = { + let built = { + let model = lua.app_data_ref::().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::().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 = 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>, ) -> 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>, + ) -> 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, +) -> 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 diff --git a/crates/benilla-ui/src/script/tests/chat_bubble.rs b/crates/benilla-ui/src/script/tests/chat_bubble.rs index 7db032c15..8eea06fd9 100644 --- a/crates/benilla-ui/src/script/tests/chat_bubble.rs +++ b/crates/benilla-ui/src/script/tests/chat_bubble.rs @@ -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::("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::("return hits").unwrap(), 1); + drive(&mut s, &[say(1, 2, "b"), say(2, 1, "c")]); + assert_eq!(s.eval::("return hits").unwrap(), 2); +} + /// The bubble text's justification as a Lua reader sees it. fn text_justify(s: &mut UiScript) -> String { s.eval::( diff --git a/crates/benilla-ui/src/script/tests/nameplate.rs b/crates/benilla-ui/src/script/tests/nameplate.rs index 361248048..4dda1988d 100644 --- a/crates/benilla-ui/src/script/tests/nameplate.rs +++ b/crates/benilla-ui/src/script/tests/nameplate.rs @@ -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, Option) { + let read = |s: &mut UiScript, region: &str| { + s.eval::>(&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::("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::("return hits").unwrap(), 2); + drive( + &mut s, + &[plate("Bear", 10.0, 90.0), plate("Wolf", 30.0, 40.0)], + ); + assert_eq!(s.eval::("return hits").unwrap(), 4); + assert!(s.take_errors().is_empty()); +} diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 80da9d8f2..4229be680 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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))