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
@@ -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())")