superwow: the chat bubbles are widgets — CREATE_CHATBUBBLE and pfUI's skin

The last real SuperWoW feature, and one piece of work closed three
things: `CREATE_CHATBUBBLE` (which could not honestly be fired, because
its payload can only be the bubble), `C_ChatBubbles.GetAllChatBubbles`
(which answered an empty list, truthfully, for the same reason) and
pfUI's whole bubble skin, which walks that list.

`benilla-ui`'s `script/chat_bubble.rs` is a pool of real frames —
anonymous WorldFrame children with a backdrop (`0x4b0a35` IS
`SetBackdrop`), a tail texture and a FontString, which is what the
reference's own `CGChatBubbleFrame` is and how addons of the era found
bubbles before SuperWoW published the event. The app keeps everything a
bubble's existence depends on — the spawn gate, the lifetime, the fade,
the range re-test, the stacking sort and the text measurement that sizes
the frame — and hands it over as `BubbleState`: the same split
`vplates.rs` already makes for the plates, so the engine decides what is
on screen, the widget layer decides what Lua sees, and neither can
disagree about a bubble because there is one feed.

Three things improved by the move rather than being preserved by it:

- The bubbles sit at frame levels 2, 3, 4… over the V-plates at level 1,
  which is the reference's own order (`0x4b12d5`-`0x4b1312`). The quad
  band had them UNDERNEATH — a deviation `overlay_z` carried in a
  comment. That band is gone and the lane is down to one tenant.
- The art is loaded, tiled and bleed-inset by the UI pass like any other
  frame's, so this file drops its own texture resource and its own
  half-texel inset helper.
- The system moved BEFORE the paint. It sat in `UiQuadAppend`, the window
  *after* the extract: right for an appender, a frame late for a feed.

The test that matters transcribes pfUI's own `ScanBubbles` loop — it
finds the FontString with the line and its chat-type colour, blanks the
one Texture region and parents its own frame to the bubble — and then
checks the EXTRACT for drawn quads: both backdrop halves, the tail, and
the text at the em the app measured the box with. A test that only
checked the widgets would have passed with nothing on screen.

Two architectural gates caught this and both were answered rather than
silenced. The one-shot gate: `drive_bubbles` now holds the VM and drains
a queue, so it is EXEMPT with the argument — the queue's product is
app-side state, the widget sync is idempotent and binds on the first
frame there is a WorldFrame, and gating the system instead would pile
lines up and spawn them all at once with fresh lifetimes. The VM wall:
its ceiling rises by one with the reason, which is that this file is
`vplates.rs`'s exact twin.

`CREATE_CHATBUBBLE` fires per APPEARANCE, not per pool slot ever built —
the opposite of `NAME_PLATE_CREATED`'s rule, because a recycled frame has
been re-anchored, re-sized and re-texted since it was last seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-25 16:11:54 -06:00
co-authored by Claude Opus 5
parent bead2cb22b
commit 434d514879
11 changed files with 910 additions and 283 deletions
+715
View File
@@ -0,0 +1,715 @@
//! The chat bubbles as **widgets**: the reference's `CGChatBubbleFrame` (ctor `0x4b0940`), an
//! anonymous `Frame` parented to the `WorldFrame` with a backdrop (`0x4b0a35` into `0x76a5d0`,
//! which is `SetBackdrop`), one tail texture and one `FontString`.
//!
//! **Why this exists at all.** `benilla-app`'s `chat_bubble.rs` drew bubbles straight into the
//! overlay quad lane: faithful on screen and invisible to Lua, which cost two addon-facing things
//! at once. ClassicAPI's `C_ChatBubbles.GetAllChatBubbles` could only answer an empty list — pfUI
//! walks that list, blanks each bubble's regions and draws its own backdrop and text over it
//! (`modules/bubbles.lua:30-48`), so its whole bubble skin was dead — and SuperWoW's
//! `CREATE_CHATBUBBLE` had no frame to carry, so it could not honestly be fired. One pool of real
//! widgets closes both, and it is also what the reference has: its bubbles are `WorldFrame`
//! children, which is how addons of the era found them before SuperWoW published the event.
//!
//! The app keeps everything a bubble's *existence* depends on — the spawn gate, the lifetime, the
//! fade, the 20 yd re-test, the stacking sort and the text measurement that sizes the frame — and
//! hands the result over as [`BubbleState`] per live bubble. This file owns the widgets alone, the
//! same split [`super::nameplate`] already makes for the V-plates, and for the same reason: the
//! engine decides what is on screen, the widget layer decides what Lua sees, and neither can
//! disagree with the other about a bubble because there is one feed.
//!
//! **The pool is never shrunk or reordered**, as the plate pool is not: an addon that remembers
//! "I have already skinned this frame" (pfUI's `if not f.frame then`) must keep being right about
//! a frame it saw, and a compacted list would hand it a different bubble under the same identity.
//!
//! Two deviations worth naming, both in the direction of the reference:
//! - The bubbles now sit at frame **levels 2, 3, 4…** over the V-plates at level 1, which is the
//! reference's own order (`0x4b12d5`-`0x4b1312`). The quad lane had them *under* the plates,
//! which was a deviation the band layout (`ui_pass::overlay_z`) carried in a comment.
//! - The art is a **backdrop**, not regions, which is what `0x4b0a35` makes it. pfUI's skin
//! blanks whatever textures it finds in `GetRegions()` — it finds the tail, not the backdrop —
//! and then covers the plate with its own backdrop anchored 10 px outside the bubble on every
//! side, so the result reads as pfUI's bubble either way.
use std::collections::HashMap;
use crate::layout::{Anchor, Point};
use crate::order::DrawLayer;
use crate::script::backdrop::{Backdrop, Insets};
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
use super::model::Model;
use super::UiScript;
use super::{JustifyH, JustifyV, ScriptValue};
/// The bubble art (`0x4b0940`), the same three paths the quad lane drew.
const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop";
const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail";
/// `NAMEPLATE_FONT`: Friz Quadrata, the face the bubble text has always been shaped in.
const BUBBLE_FONT: &str = "Fonts\\FRIZQT__.TTF";
/// The first frame level a bubble takes (`0x4b12d5`: `2 + i` over the plates' 1).
const FIRST_LEVEL: u16 = 2;
/// One live bubble's whole per-frame state, in **FrameXML units** (the app divides the seam out,
/// exactly as it does for a plate's geometry).
///
/// Everything here is decided by the app: this file writes it onto widgets and nothing more.
#[derive(Clone, Debug, PartialEq)]
pub struct BubbleState {
/// The speaker's GUID. A bubble stays bound to its speaker for its whole life, as the
/// reference's `[unit+0xe64]` does, so an addon can cache per bubble on it.
pub key: u64,
/// The bubble's **bottom centre**, from the `WorldFrame`'s BOTTOMLEFT: it hangs from the seat
/// and grows upward (`0x4b0c30`).
pub seat: (f32, f32),
/// The frame's size, the text layout plus the margin on each side.
pub width: f32,
pub height: f32,
/// The sanitized display text, as the window shows it.
pub text: String,
/// The chat-type colour, sRGB 0..1.
pub color: [f32; 3],
/// The fade (`0x4b0ea0`/`0x4b0ee0`), 0..1; the frame's own alpha, so an addon's child frames
/// fade with it.
pub alpha: f32,
/// The backdrop's edge size, which is also all four insets: the reference feeds one value to
/// both (`0x4b0a35`).
pub border: f32,
/// The `FontString`'s height, and the width it wraps at — the same numbers the app measured
/// the box with, so the region's own measure cannot disagree with the frame's size.
pub font_height: f32,
pub wrap_width: f32,
}
/// One pooled bubble's widgets.
#[derive(Debug)]
struct Bubble {
frame: FrameHandle,
/// The tail (`0x4b0af1`), a border-unit square hanging under the bubble's bottom centre.
tail: RegionHandle,
text: RegionHandle,
/// Last frame's state, so [`Bubble::drive`] writes only what moved; `None` while retired.
last: Option<BubbleState>,
}
impl Bubble {
fn key(&self) -> Option<u64> {
self.last.as_ref().map(|s| s.key)
}
}
/// The bubble pool, grown on demand and never shrunk or reordered (see the module header).
#[derive(Debug, Default)]
pub(crate) struct ChatBubbles {
bubbles: Vec<Bubble>,
/// Each live speaker's pool slot, the reference's `[unit+0xe64]`.
assigned: HashMap<u64, usize>,
/// Frame to pool slot, never removed: bubbles are recycled, never destroyed.
by_frame: HashMap<FrameHandle, usize>,
}
impl ChatBubbles {
/// Every live bubble's frame, in pool order: ClassicAPI's `C_ChatBubbles.GetAllChatBubbles`.
/// A retired slot has no state and is skipped, so the list is the bubbles actually on screen.
pub(crate) fn live(&self) -> Vec<FrameHandle> {
self.bubbles
.iter()
.filter_map(|b| b.key().map(|_| b.frame))
.collect()
}
/// Whether `frame` is one of the pool's, live or retired. For the text layout's world-seat
/// rule, which a bubble shares with a plate.
pub(crate) fn holds(&self, frame: FrameHandle) -> bool {
self.by_frame.contains_key(&frame)
}
}
/// What a sync owes once the model borrow is released: `OnShow`/`OnHide` for frames whose
/// visibility flipped, and one `CREATE_CHATBUBBLE` per bubble that appeared. Queue-and-drain, for
/// the reason every other queue here exists — a handler re-entering the VM must not run under a
/// live [`Model`] borrow.
#[derive(Default)]
struct SyncEffects {
visibility: Vec<FrameHandle>,
/// `CREATE_CHATBUBBLE`, `arg1` the bubble's frame.
created: Vec<FrameHandle>,
}
impl UiScript {
/// Drive the pool from the app's per-frame feed, one entry per live bubble; a speaker with no
/// entry has its bubble retired (hidden, never destroyed).
///
/// Retire before bind, so a bubble that just expired frees its slot for one appearing in the
/// same tick — the reference's one free list behaves that way, and the pool is the only thing
/// a bubble's identity rests on.
pub fn sync_chat_bubbles(&mut self, bubbles: &[BubbleState]) {
let effects = {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// No WorldFrame (no interface loaded) means no addon is walking an invented root, and
// the app's own draw is gone — so there is nothing to do and nothing to owe.
let Some(world) = model.arena.lookup("WorldFrame") else {
return;
};
sync(&mut model, world, bubbles)
};
self.fire_effects(effects);
}
/// Retire every live bubble: the app's early returns owe this, since a widget stays up until
/// something hides it. No `CREATE_CHATBUBBLE` has a removal twin — SuperWoW publishes none,
/// and an addon learns a bubble is gone the way it always has, from `IsShown()`.
pub fn retire_chat_bubbles(&mut self) {
let effects = {
let lua = self.lua();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mut effects = SyncEffects::default();
model.chat_bubbles.assigned.clear();
for i in 0..model.chat_bubbles.bubbles.len() {
retire(&mut model, i, &mut effects);
}
effects
};
self.fire_effects(effects);
}
fn fire_effects(&mut self, effects: SyncEffects) {
let lua = self.lua();
super::event::fire_visibility_changes(lua, effects.visibility);
for frame in effects.created {
let id = lua
.app_data_mut::<Model>()
.expect("model app_data")
.frame_id(frame);
super::event::fire_global(lua, "CREATE_CHATBUBBLE", &[ScriptValue::Frame(id)]);
}
}
}
/// Bind, lay out and write the pool for this frame's feed.
fn sync(model: &mut Model, world: FrameHandle, bubbles: &[BubbleState]) -> SyncEffects {
let mut effects = SyncEffects::default();
let gone: Vec<u64> = model
.chat_bubbles
.assigned
.keys()
.copied()
.filter(|k| !bubbles.iter().any(|b| b.key == *k))
.collect();
for key in gone {
if let Some(i) = model.chat_bubbles.assigned.remove(&key) {
retire(model, i, &mut effects);
}
}
for (rank, state) in bubbles.iter().enumerate() {
let slot = match model.chat_bubbles.assigned.get(&state.key) {
Some(&i) => i,
None => {
let free = (0..model.chat_bubbles.bubbles.len())
.find(|i| model.chat_bubbles.bubbles[*i].last.is_none());
let i = free.unwrap_or_else(|| {
let bubble = create(model, world);
model.chat_bubbles.bubbles.push(bubble);
model.chat_bubbles.bubbles.len() - 1
});
model.chat_bubbles.assigned.insert(state.key, i);
// Per **appearance**, not per pool slot ever built, which is the opposite of
// `NAME_PLATE_CREATED`'s rule and deliberate: what an addon wants from this event
// is "here is a bubble to skin or move, now", and a recycled frame has been
// re-anchored, re-sized and re-texted since it was last seen. pfUI's own guard
// (`if not f.frame then`) makes the repeat free for the skinning case.
effects.created.push(model.chat_bubbles.bubbles[i].frame);
i
}
};
// The stacking level: farthest-first rank, as the app sorted it (`0x4b1309`).
drive(model, slot, world, state, rank, &mut effects);
}
effects
}
/// Build one bubble: the Frame, its backdrop, the tail and the text.
fn create(model: &mut Model, world: FrameHandle) -> Bubble {
// Anonymous and `WorldFrame`-parented, as the reference's is: bubble addons find it by walking
// the child list, so a name would be a surface the reference does not have.
let frame = model.arena.create(FrameKind::Frame, None, Some(world));
let strata = model
.arena
.frame(world)
.map(|f| f.strata)
.unwrap_or_default();
model.arena.set_frame_strata(frame, strata);
// The backdrop (`0x4b0a35` into `0x76a5d0`): one value feeds the edge size and all four
// insets. The sizes are written per frame in `drive`, since the border scales with the window.
model.backdrops.insert(
frame,
Backdrop {
bg_file: Some(BG_TEXTURE.to_string()),
edge_file: Some(EDGE_TEXTURE.to_string()),
tile: false,
tile_size: 0.0,
edge_size: 0.0,
insets: Insets::default(),
bg_color: [1.0; 4],
border_color: [1.0; 4],
},
);
let tail = model
.arena
.create_region(frame, RegionKind::Texture, DrawLayer::Artwork, 0)
.expect("live bubble");
model.region_data.entry(tail).or_default().texture = Some(TAIL_TEXTURE.to_string());
let text = model
.arena
.create_region(frame, RegionKind::FontString, DrawLayer::Overlay, 0)
.expect("live bubble");
{
let data = model.region_data.entry(text).or_default();
data.font_path = Some(BUBBLE_FONT.to_string());
data.font_explicit.face = true;
data.justify.set_h(JustifyH::Center);
data.justify.set_v(JustifyV::Middle);
}
model
.chat_bubbles
.by_frame
.insert(frame, model.chat_bubbles.bubbles.len());
Bubble {
frame,
tail,
text,
last: None,
}
}
/// Hide a bubble and drop its state; the widgets stay, as the reference recycles rather than
/// destroys.
fn retire(model: &mut Model, i: usize, effects: &mut SyncEffects) {
let b = &mut model.chat_bubbles.bubbles[i];
if b.last.take().is_none() {
return;
}
let frame = b.frame;
effects
.visibility
.extend(model.arena.set_shown(frame, false));
}
/// Write one bubble's state, touching only what moved.
fn drive(
model: &mut Model,
i: usize,
world: FrameHandle,
state: &BubbleState,
rank: usize,
effects: &mut SyncEffects,
) {
let b = &model.chat_bubbles.bubbles[i];
let (frame, tail, text) = (b.frame, b.tail, b.text);
let last = b.last.clone();
let world_id = model.frame_id(world);
let frame_id = model.frame_id(frame);
// Re-seated whenever it moves. A fresh seat adds a graph edge, so it retargets; a moved offset
// keeps the edges, so the cheaper touch — the same split `nameplate::Plate::drive` makes.
if last.as_ref().is_none_or(|l| l.seat != state.seat) {
let fresh = model
.layout_inputs
.get(&frame)
.is_none_or(|i| i.anchors.is_empty());
let input = model.layout_inputs.entry(frame).or_default();
input.anchors = vec![Anchor::new(
Point::Bottom,
world_id,
Point::BottomLeft,
state.seat.0,
state.seat.1,
)];
if fresh {
model.touch_layout_retarget_frame(frame, &[], &[world_id]);
} else {
model.touch_layout_frame(frame);
}
}
if last
.as_ref()
.is_none_or(|l| l.width != state.width || l.height != state.height)
{
let input = model.layout_inputs.entry(frame).or_default();
input.width = state.width;
input.height = state.height;
model.touch_layout_frame(frame);
}
// The border scales with the window, so it rides the state rather than the creation.
if last.as_ref().is_none_or(|l| l.border != state.border) {
if let Some(bd) = model.backdrops.get_mut(&frame) {
bd.edge_size = state.border;
bd.insets = Insets {
left: state.border,
right: state.border,
top: state.border,
bottom: state.border,
};
}
// The tail (`0x4b0af1`): a border-unit square, its TOPRIGHT on the bubble's bottom centre
// lifted a quarter border, so it overlaps the frame's own bottom edge.
model.region_data.entry(tail).or_default().size = Some((state.border, state.border));
model.region_data.entry(tail).or_default().anchors = vec![Anchor::new(
Point::TopRight,
frame_id,
Point::Bottom,
0.0,
state.border * 0.25,
)];
model.touch_layout_region(tail);
}
if last
.as_ref()
.is_none_or(|l| l.font_height != state.font_height || l.wrap_width != state.wrap_width)
{
let data = model.region_data.entry(text).or_default();
data.font_height = Some(state.font_height);
data.font_explicit.height = true;
// A pinned width is what makes the FontString wrap (`MeasureRequest::wrap_width`), at the
// cap the app already measured the box against.
data.size = Some((state.wrap_width, 0.0));
data.anchors = vec![Anchor::new(
Point::Center,
frame_id,
Point::Center,
0.0,
0.0,
)];
model.touch_layout_region(text);
}
if last.as_ref().is_none_or(|l| l.text != state.text) {
model.region_data.entry(text).or_default().text = Some(state.text.clone());
model.touch_layout_region(text);
}
if last.as_ref().is_none_or(|l| l.color != state.color) {
model.region_data.entry(text).or_default().vertex_color =
Some([state.color[0], state.color[1], state.color[2], 1.0]);
}
if last.is_none() {
effects
.visibility
.extend(model.arena.set_shown(frame, true));
}
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
model.arena.set_alpha(frame, state.alpha);
}
// Farthest first, levels stamped head to tail: the app hands them over in that order, so the
// rank IS the level. Over the V-plates' level 1, as the reference's are.
let level = FIRST_LEVEL.saturating_add(u16::try_from(rank).unwrap_or(u16::MAX - FIRST_LEVEL));
if last.is_none() || model.arena.frame(frame).is_some_and(|f| f.level != level) {
model.arena.set_frame_level(frame, level, true);
}
model.chat_bubbles.bubbles[i].last = Some(state.clone());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
/// A bubble over a speaker, in the units the app hands over.
fn bubble(key: u64, text: &str) -> BubbleState {
BubbleState {
key,
seat: (400.0, 300.0),
width: 120.0,
height: 40.0,
text: text.to_string(),
color: [1.0, 1.0, 0.0],
alpha: 1.0,
border: 16.0,
font_height: 12.0,
wrap_width: 100.0,
}
}
/// The `WorldFrame` the pool hangs off, as `script/tests/nameplate.rs` builds it.
fn script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
.unwrap();
s.resolve();
s
}
/// **pfUI's own `ScanBubbles` loop, transcribed** (`modules/bubbles.lua:30-48`), which is the
/// whole reason this file exists: it walks `C_ChatBubbles.GetAllChatBubbles()`, blanks every
/// Texture it finds in the bubble's regions, keeps the `FontString` as `f.text`, and parents
/// its own frame to the bubble. Every one of those steps has to work on our widgets.
#[test]
fn pfuis_own_bubble_scan_finds_the_text_and_the_textures() {
let mut s = script();
s.sync_chat_bubbles(&[bubble(0x42, "Hail, traveller")]);
s.resolve();
assert_eq!(
s.eval::<i64>("return table.getn(C_ChatBubbles.GetAllChatBubbles())")
.unwrap(),
1,
"one live bubble, one frame — this answered an empty list before the pool existed"
);
s.run(
r#"
Found, Blanked, Parented = nil, 0, nil
for _, f in ipairs(C_ChatBubbles.GetAllChatBubbles()) do
local regions = {f:GetRegions()}
for _, object in pairs(regions) do
if object:GetObjectType() == "Texture" then
object:SetTexture('')
Blanked = Blanked + 1
elseif object:GetObjectType() == "FontString" then
f.text = object
PfBubbleText = object
end
end
Found = f.text and f.text:GetText()
f.frame = CreateFrame("Frame", nil, f)
f.frame:SetAllPoints(f)
Parented = f.frame:GetParent() == f
end
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return Found").unwrap(),
"Hail, traveller",
"the FontString carries the line, which is what pfUI re-fonts and re-colours"
);
assert_eq!(
s.eval::<i64>("return Blanked").unwrap(),
1,
"the tail is the one Texture region; the backdrop is a backdrop, as `0x4b0a35` makes \
it, and pfUI covers it with its own anchored 10 px outside the bubble"
);
assert!(
s.eval::<bool>("return Parented").unwrap(),
"an addon frame parents to the bubble, which is how it follows it and fades with it"
);
// The colour is the chat type's, and pfUI reads it off the string with `GetTextColor`
// before re-applying it to its own (`modules/bubbles.lua:22-24`).
assert_eq!(
s.eval::<(f64, f64, f64)>("local r, g, b = PfBubbleText:GetTextColor() return r, g, b")
.unwrap(),
(1.0, 1.0, 0.0),
"the chat-type colour, which is the thing pfUI carries over to its own string"
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// The whole chain, which is the claim that matters: a bubble handed over as state comes out
/// of the extract as **drawn quads** — the backdrop's own two art paths, the tail, and the
/// line in the chat type's colour at the em the app measured it with.
///
/// Before the pool this art came off the overlay quad lane, so an extract carried none of it;
/// a test that only checked the widgets would pass with nothing on screen.
#[test]
fn a_bubble_reaches_the_extract_as_backdrop_tail_and_text() {
use crate::script::QuadContent;
let mut s = script();
s.sync_chat_bubbles(&[bubble(0x42, "Hail, traveller")]);
s.resolve();
let quads = s.extract();
let text = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color: Some(c),
font_height,
..
} if t == "Hail, traveller" => Some((*c, *font_height)),
_ => None,
})
.expect("the bubble's line reaches the extract");
assert_eq!(
(text.0[0], text.0[1], text.0[2]),
(1.0, 1.0, 0.0),
"in the chat type's colour"
);
assert_eq!(
text.1,
Some(12.0),
"at the em the app measured the box with"
);
let backdrops: Vec<&str> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Backdrop { path, .. } => Some(path.as_str()),
_ => None,
})
.collect();
assert!(
backdrops.contains(&BG_TEXTURE) && backdrops.contains(&EDGE_TEXTURE),
"both halves of the reference's backdrop draw, got {backdrops:?}"
);
assert!(
quads.iter().any(|q| matches!(
&q.content,
QuadContent::Texture { path: Some(p), .. } if p == TAIL_TEXTURE
)),
"and the tail, which is a region rather than part of the backdrop"
);
// Nothing is left on screen once the speaker stops: the retire hides the frame, and a
// hidden frame emits no quads at all.
s.sync_chat_bubbles(&[]);
s.resolve();
assert!(
!s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Backdrop { path, .. } if path == BG_TEXTURE
)),
"a retired bubble draws nothing"
);
}
/// `CREATE_CHATBUBBLE` fires with the bubble's frame, per appearance — SuperWoW's third and
/// last event, which could not honestly be fired while there was no frame to carry.
#[test]
fn create_chatbubble_fires_with_the_frame_per_appearance() {
let mut s = script();
s.run(
r#"
N, Same = 0, nil
Spy = CreateFrame("Frame", "BenillaBubbleSpy")
Spy:SetScript("OnEvent", function()
N = N + 1
Same = arg1 == C_ChatBubbles.GetAllChatBubbles()[1]
end)
Spy:RegisterEvent("CREATE_CHATBUBBLE")
"#,
)
.unwrap();
s.sync_chat_bubbles(&[bubble(0x42, "one")]);
assert_eq!(s.eval::<i64>("return N").unwrap(), 1);
assert!(
s.eval::<bool>("return Same").unwrap(),
"arg1 is the frame, and the list already holds it when the handler runs"
);
// The same bubble, moved and re-texted: no second event, the frame is already theirs.
let mut moved = bubble(0x42, "one");
moved.seat = (401.0, 302.0);
s.sync_chat_bubbles(&[moved]);
assert_eq!(s.eval::<i64>("return N").unwrap(), 1);
// It expires, and a new speaker takes the recycled frame: that IS an appearance.
s.sync_chat_bubbles(&[]);
s.sync_chat_bubbles(&[bubble(0x99, "two")]);
assert_eq!(
s.eval::<i64>("return N").unwrap(),
2,
"per appearance, not per pool slot ever built: a recycled frame has been re-anchored \
and re-texted, so an addon has work to do on it again"
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// The pool recycles rather than destroys, keeps its order, and an expired bubble leaves the
/// list while its frame stays (hidden) for the next speaker.
#[test]
fn the_pool_recycles_and_keeps_its_order() {
let mut s = script();
s.sync_chat_bubbles(&[bubble(0x10, "a"), bubble(0x20, "b")]);
s.resolve();
let frames = |s: &UiScript| {
s.eval::<i64>("return table.getn(C_ChatBubbles.GetAllChatBubbles())")
.unwrap()
};
assert_eq!(frames(&s), 2);
// Both shown, at the levels the rank gives (2 and 3), over the V-plates' 1.
assert!(s
.eval::<bool>(
"local l = C_ChatBubbles.GetAllChatBubbles()
return l[1]:IsShown() and l[2]:IsShown()
and l[1]:GetFrameLevel() == 2 and l[2]:GetFrameLevel() == 3"
)
.unwrap());
// The first speaker stops talking: one bubble left, and the retired frame is hidden but
// still a `WorldFrame` child, as the reference recycles rather than destroys.
s.run("Kept = C_ChatBubbles.GetAllChatBubbles()[1]")
.unwrap();
s.sync_chat_bubbles(&[bubble(0x20, "b")]);
s.resolve();
assert_eq!(frames(&s), 1, "the retired slot leaves the list");
assert!(
!s.eval::<bool>("return Kept:IsShown()").unwrap(),
"and its frame is hidden rather than destroyed"
);
assert!(
s.eval::<bool>(r#"return Kept:GetParent() == WorldFrame"#)
.unwrap(),
"still a WorldFrame child: an addon that cached it keeps a live handle"
);
// A third speaker takes the free slot: the pool is two frames, not three.
s.sync_chat_bubbles(&[bubble(0x20, "b"), bubble(0x30, "c")]);
s.resolve();
assert_eq!(frames(&s), 2);
assert!(
s.eval::<bool>("return C_ChatBubbles.GetAllChatBubbles()[1] == Kept")
.unwrap(),
"the recycled frame is the one that was free, in its own pool position"
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// The widgets carry what the app measured: the frame's size, the fade as the frame's alpha
/// (so an addon's child frames fade with it) and the text's own wrap width.
#[test]
fn the_widgets_carry_the_apps_own_numbers() {
let mut s = script();
let mut b = bubble(0x42, "a longer line that wraps");
b.alpha = 0.5;
s.sync_chat_bubbles(&[b]);
s.resolve();
assert!(s
.eval::<bool>(
"local f = C_ChatBubbles.GetAllChatBubbles()[1]
return f:GetWidth() == 120 and f:GetHeight() == 40 and f:GetAlpha() == 0.5"
)
.unwrap());
assert!(
s.eval::<bool>(
"local f = C_ChatBubbles.GetAllChatBubbles()[1]
for _, r in pairs({f:GetRegions()}) do
if r:GetObjectType() == \"FontString\" then
return r:GetWidth() == 100
end
end
return false"
)
.unwrap(),
"the FontString's pinned width is the wrap cap the app measured the box against — a \
pinned width is also what makes it wrap at all (`MeasureRequest::wrap_width`)"
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
}
@@ -1,25 +1,21 @@
//! ClassicAPI's `C_ChatBubbles`: the speech bubbles an addon wants to restyle.
//!
//! **Answers an empty list, and that is the truthful answer here, not a stub.** The function's
//! contract is "hand me the bubble *frames*", so an addon can walk their regions and repaint
//! them — pfUI's does exactly that, replacing each bubble's textures with its own backdrop and
//! re-fonting the `FontString` it finds inside (`modules/bubbles.lua:30-48`). This client has no
//! such frames to hand over: bubbles are drawn natively by `benilla-app`'s `chat_bubble.rs` as a
//! 2-D world overlay, the same way the reference's own `CGChatBubbleFrame` is drawn — they are
//! not Lua frames and have no regions a script can reach.
//! **Answers the live bubbles.** The function's contract is "hand me the bubble *frames*", so an
//! addon can walk their regions and repaint them — pfUI's does exactly that, blanking each
//! bubble's textures and re-fonting the `FontString` it finds inside
//! (`modules/bubbles.lua:30-48`). This file used to answer an empty list, truthfully, because the
//! bubbles were drawn straight into the overlay quad lane and were not Lua frames at all; they
//! are now a widget pool (`script/chat_bubble.rs`), which is also what the reference has.
//!
//! So the honest report is zero, and the consequence is that bubbles render in this client's own
//! style rather than pfUI's. The alternative was leaving the namespace absent, which is strictly
//! worse: `ipairs(nil)` raises, and the call sits in `ScanBubbles`, which pfUI runs the frame
//! after **every** `CHAT_MSG_SAY`/`YELL`/`MONSTER_SAY`/`MONSTER_YELL` — so the absence raised on
//! any NPC or player speaking within earshot, repeatedly, for a cosmetic feature.
//!
//! Making the native bubbles addressable from Lua is the real fix and is not built: it would mean
//! giving each live bubble a frame with a texture and a `FontString`, kept in step with the
//! native overlay's own spawn, stacking and expiry rules. `docs/ADDON_COMPAT.md` carries the row.
//! Pool order, live bubbles only, a fresh table per call. The order is stable for a bubble's whole
//! life, which is what an addon keying a cache on the frame needs; it is **not** the stacking
//! order, which changes every frame with the camera (`chat_bubble::BubbleState`).
use mlua::{Lua, Table};
use crate::script::object::frame_wrapper;
use crate::script::Model;
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
let t = lua.create_table()?;
@@ -27,7 +23,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
// let a caller's own writes into it survive into the next caller's loop.
t.set(
"GetAllChatBubbles",
lua.create_function(|lua, ()| lua.create_table())?,
lua.create_function(|lua, ()| {
let live = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.chat_bubbles.live()
};
let out = lua.create_table()?;
for (i, frame) in (1..).zip(live) {
let id = lua
.app_data_mut::<Model>()
.expect("model app_data")
.frame_id(frame);
out.set(i, frame_wrapper(lua, id)?)?;
}
Ok(out)
})?,
)?;
Ok(t)
@@ -37,9 +47,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
mod tests {
use crate::script::UiScript;
/// The shape the call site needs: `ipairs` over it must run zero times and not raise.
/// Nobody talking is an **empty list, not nothing**: `ipairs(nil)` raises, and the call sits
/// in pfUI's `ScanBubbles`, which it runs the frame after *every*
/// `CHAT_MSG_SAY`/`YELL`/`MONSTER_SAY`/`MONSTER_YELL` — so an absent namespace raised on any
/// NPC or player speaking within earshot, repeatedly, for a cosmetic feature. The live case,
/// and pfUI's own loop over it, are in `script/chat_bubble.rs`'s tests.
#[test]
fn it_answers_an_empty_list_rather_than_nothing() {
fn a_silent_world_answers_an_empty_list_rather_than_nothing() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return table.getn(C_ChatBubbles.GetAllChatBubbles())")
+7 -3
View File
@@ -349,9 +349,13 @@ impl UiScript {
shadow: data.font_shadow,
outline: data.outline,
alpha_gradient: data.alpha_gradient,
// The owner's property: an addon's strings on a plate slide with it.
world_seat: owner
.is_some_and(|o| super::nameplate::is_world_seated(&model, o)),
// The owner's property: an addon's strings on a plate or a chat
// bubble slide with it. Both are world-seated — the app projects a
// world anchor for each every frame — so both want the one snap.
world_seat: owner.is_some_and(|o| {
super::nameplate::is_world_seated(&model, o)
|| model.chat_bubbles.holds(o)
}),
}
} else {
// The draw gate is the texture slot, never the colour (`0x7706e0`: empty
+2
View File
@@ -34,6 +34,7 @@ mod camera_view;
mod cast_events;
mod channel;
mod char_stats;
pub mod chat_bubble;
mod chat_misc;
mod chat_send;
mod chat_types;
@@ -186,6 +187,7 @@ pub use char_stats::{
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ItemRawFields,
UnitCombatStats, BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_bubble::BubbleState;
pub use chat_misc::EmoteRequest;
pub use chat_send::ChatSend;
pub use chat_types::ChatTypeColor;
+4
View File
@@ -1453,6 +1453,9 @@ pub(crate) struct Model {
pub(crate) worldmap: super::worldmap::WorldMapState,
/// Nameplate `Button`s under `WorldFrame`, never shrunk, visible to `GetChildren()`.
pub(crate) nameplates: super::nameplate::NamePlates,
/// The chat-bubble widget pool (`super::chat_bubble`), driven by `benilla-app`'s
/// `chat_bubble.rs` feed. One pool for the session: bubbles are recycled, never destroyed.
pub(crate) chat_bubbles: super::chat_bubble::ChatBubbles,
/// The world-state readout's rows, gated and resolved by the app.
pub(crate) worldstate: super::worldstate::WorldStateUiState,
/// Events bindings raise. Deviation: fired at the next tick, not synchronously as in the
@@ -2217,6 +2220,7 @@ impl Model {
server_unix_time: None,
worldmap: super::worldmap::WorldMapState::default(),
nameplates: super::nameplate::NamePlates::default(),
chat_bubbles: super::chat_bubble::ChatBubbles::default(),
worldstate: super::worldstate::WorldStateUiState::default(),
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),