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
+95 -240
View File
@@ -25,17 +25,13 @@ use std::collections::HashMap;
use bevy::ecs::entity::EntityHashSet;
use bevy::prelude::*;
use benilla_ui::layout::Rect as GxRect;
use benilla_ui::script::{inset_atlas_bleed, pieces, Backdrop, Insets};
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use benilla_ui::script::{BubbleState, Outline, UiScript};
use crate::entities::StandBoxHeight;
use crate::net::{Embodied, Guid, NetEntity, SelfGuid};
use crate::ui_chat::{default_color, ChatEventKind};
use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, TextSeat, UiFontAtlas};
use crate::ui_text::{measure_text, FontSpec, UiFontAtlas};
use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates};
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
/// The two bubble CVars (registrar `0x603280`), booting at the reference's defaults.
@@ -90,10 +86,9 @@ impl Default for BubbleConfig {
}
}
/// The bubble art (`0x4b0940`).
const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop";
const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail";
// The bubble art (`0x4b0940`) moved with the drawing: it now lives on the widgets
// (`benilla_ui::script::chat_bubble`), where the UI pass resolves and tiles it like any other
// frame's, half-texel bleed inset included.
/// The 0.7 yd lift over the anchor height (`[0x7ffd7c]`).
const LIFT: f32 = 0.7;
@@ -110,20 +105,6 @@ const WRAP_W: f32 = 0.2;
/// The border unit (edge size, insets, tail side): 16/1024 of the screen width.
const BORDER_FRAC: f32 = 16.0 / 1024.0;
/// Paint order inside one bubble's level: the tail covers the bottom edge piece and the text draws
/// last, as the reference's batch drains textures before font strings (`0x76fb00`).
const Z_BG: u64 = 0;
const Z_EDGE: u64 = 1;
const Z_TAIL: u64 = 2;
const Z_TEXT: u64 = 3;
/// The z base for the bubble at `rank`, farthest first: the reference's per-frame
/// `SetFrameLevel(2 + i)` walk (`0x4b12d5`-`0x4b1312`), one level per bubble, under the V-plates.
fn level_z(rank: usize) -> u64 {
let level = (rank as u64).min(overlay_z::BUBBLE_MAX_LEVEL);
overlay_z::BUBBLE + level * overlay_z::BUBBLE_STRIDE
}
/// The CVar gating this kind's bubble (`0x608b0d`); `None` for a kind that does not bubble.
fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option<bool> {
use ChatEventKind as K;
@@ -213,14 +194,6 @@ fn sanitize(text: &str) -> String {
out
}
/// The bleed inset for one border piece; zero texels (no patch chain) leaves the UVs alone.
fn pieces_inset(uvs: [[f32; 2]; 4], texels: Vec2) -> [[f32; 2]; 4] {
if texels.x <= 0.0 || texels.y <= 0.0 {
return uvs;
}
inset_atlas_bleed(uvs, texels.x, texels.y)
}
/// The frame's bottom-left origin, bottom-center on the seat. Deviation: snapped onto the device
/// pixel grid ([`device_snap`]) so the border art blits 1:1; the reference seats it fractionally.
fn seat_origin(seat: Vec2, w: f32, scale: f32) -> Vec2 {
@@ -292,43 +265,6 @@ struct Bubbles(HashMap<u64, Bubble>);
#[derive(Resource, Default)]
pub(crate) struct BubblesActive(pub(crate) EntityHashSet);
/// The bubble art, loaded at boot. The edge strip tiles UVs past 1 (repeat); bg and tail clamp.
#[derive(Resource)]
struct BubbleArt {
bg: Handle<Image>,
edge: Handle<Image>,
tail: Handle<Image>,
/// The edge atlas's size in texels, for the half-texel bleed inset.
edge_texels: Vec2,
}
fn load_bubble_art(
mut commands: Commands,
assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
let Some(mut assets) = assets else {
return; // no game data (a bare test app); `drive_bubbles` tolerates the missing art
};
let bg = assets.sprite_texture(BG_TEXTURE, &mut images);
let edge = assets.sprite_texture_tiled(EDGE_TEXTURE, &mut images);
let tail = assets.sprite_texture(TAIL_TEXTURE, &mut images);
let (Some(bg), Some(edge), Some(tail)) = (bg, edge, tail) else {
warn!("chat_bubble: bubble art missing from the patch chain — bubbles will not draw");
return;
};
let edge_texels = images.get(&edge).map_or(Vec2::ZERO, |i| {
let s = i.texture_descriptor.size;
Vec2::new(s.width as f32, s.height as f32)
});
commands.insert_resource(BubbleArt {
bg,
edge,
tail,
edge_texels,
});
}
/// Every frame: drain the queue through the spawn gate (`0x608ac0`), ramp each bubble's fade
/// against its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and draw.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
@@ -342,8 +278,6 @@ fn drive_bubbles(
self_q: Query<&Transform, With<Embodied>>,
camera: Query<(&Camera, &Transform), With<WorldCamera>>,
mut atlas: Option<ResMut<UiFontAtlas>>,
mut quads: ResMut<UiQuads>,
art: Option<Res<BubbleArt>>,
heights: Query<&StandBoxHeight>,
time: Res<Time>,
// The device scale, for the seat's pixel snap.
@@ -351,6 +285,12 @@ fn drive_bubbles(
// SuperWoW's `ChatBubbleRange` (`docs/ADDON_COMPAT.md`): `range_sq` replaces `MAX_DIST_SQ` at
// both range gates below.
cfg: Res<BubbleConfig>,
// The widgets (`benilla_ui::script::chat_bubble`): this system decides what is on screen and
// hands the result over, exactly as `vplates.rs` does for the plates.
script: Option<NonSendMut<UiScript>>,
// The seam, to divide out: everything below is window px and the widget layer takes FrameXML
// units. The same conversion the plate geometry makes.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
) {
active.0.clear();
let now = time.elapsed_secs();
@@ -413,13 +353,11 @@ fn drive_bubbles(
},
);
}
if bubbles.0.is_empty() {
return;
}
// No early return on an empty set: the widget layer still has to hear it, or last frame's
// bubbles stay on screen. Everything below runs over an empty map for nothing.
// ── Tick + draw ─────────────────────────────────────────────────────────────────────────
// ── Tick + hand over ─────────────────────────────────────────────────────────────────────────
let cam = camera.single().ok();
let art = art.as_deref();
let scale = window.single().map_or(1.0, Window::scale_factor);
let trace = std::env::var("WOW_BUBBLE_TRACE").as_deref() == Ok("1");
let mut dead = Vec::new();
@@ -451,8 +389,8 @@ fn drive_bubbles(
if b.alpha <= 0.0 {
continue;
}
// No camera, atlas or art: lifetimes tick, nothing draws.
let (Some((cam, cam_pose)), true) = (cam, atlas.is_some() && art.is_some()) else {
// No camera or atlas: lifetimes tick, nothing is laid out.
let (Some((cam, cam_pose)), true) = (cam, atlas.is_some()) else {
continue;
};
// The anchor (`0x4b0c30`), from this frame's `Transform` and constants only. A seat behind
@@ -487,29 +425,22 @@ fn drive_bubbles(
// Sorted farthest first (`0x4b1360`), levels stamped head to tail (`0x4b1309`). The reference
// re-sorts only on a change; sorting every frame gives the same list.
stack_sort(&mut pending);
let (Some((_, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art) else {
return; // the tick's same gate kept `pending` empty
};
for (rank, p) in pending.iter().enumerate() {
let Some(b) = bubbles.0.get(&p.guid) else {
continue; // despawned between the tick and the draw
};
draw_bubble(
atlas,
&mut quads,
art,
b,
p.seat,
p.viewport,
scale,
trace,
p.entity,
p.anchor,
p.unit_pos,
cam_pose,
level_z(rank),
);
// The widget layer hears about every frame, bubbles or none: an empty feed is what retires
// the last of them. A VM-less harness still ticks the lifetimes above.
let Some(mut script) = script else { return };
let mut states: Vec<BubbleState> = Vec::with_capacity(pending.len());
if let (Some((_, cam_pose)), Some(atlas)) = (cam, atlas.as_deref_mut()) {
let seam = window.single().map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
for p in &pending {
let Some(b) = bubbles.0.get(&p.guid) else {
continue; // despawned between the tick and the hand-over
};
states.push(bubble_state(atlas, b, p, scale, seam, trace, cam_pose));
}
}
script.sync_chat_bubbles(&states);
}
/// Farthest camera distance first, so the index is the frame level. Ties break on guid, as the
@@ -535,33 +466,27 @@ struct Pending {
unit_pos: Vec3,
}
/// Append one bubble's draw list: the backdrop pieces, the tail and the wrapped, centered text.
fn draw_bubble(
/// One bubble's widget state: the text measurement that decides its size, the seat it hangs from,
/// and the border unit — in FrameXML units, which is what `benilla_ui::script::chat_bubble` takes.
///
/// Every number here was computed for the quad lane before the widgets existed, in the same order
/// and from the same law; what changed is where it lands. The measurement stays here because the
/// *frame* has to be sized to the text, and the widget layer has no measurer of its own.
fn bubble_state(
atlas: &mut UiFontAtlas,
quads: &mut UiQuads,
art: &BubbleArt,
b: &Bubble,
seat: Vec2,
viewport: Vec2,
p: &Pending,
scale: f32,
seam: f32,
trace: bool,
// The `bub` trace's inputs.
entity: Entity,
anchor: Vec3,
unit_pos: Vec3,
cam_pose: &Transform,
// This bubble's frame level as a z base.
z: u64,
) {
let basis = plate_basis(viewport);
let border = border_px(viewport, basis);
) -> BubbleState {
let basis = plate_basis(p.viewport);
let border = border_px(p.viewport, basis);
let margin = gx_px(MARGIN, basis);
// Shaped at the exact window-derived em, so the glyphs are never a rescaled bitmap.
let px = text_px(TEXT_H, basis);
if px <= 0.0 {
return;
}
let mut e = atlas.lock();
let spec = FontSpec {
path: None, // `NAMEPLATE_FONT`: Friz Quadrata, the engine's default face
@@ -575,94 +500,21 @@ fn draw_bubble(
let (line_w, _) = measure_text(&mut e, &b.text, None, spec);
let box_w = if line_w > cap { cap } else { line_w.max(floor) };
let (_, box_h) = measure_text(&mut e, &b.text, Some(box_w), spec);
drop(e);
let (text_w, text_h) = (box_w.ceil(), box_h.ceil());
// The frame hugs the text layout plus the margin, bottom-center on the seat, growing upward.
let w = text_w + 2.0 * margin;
let h = text_h + 2.0 * margin;
let origin = seat_origin(seat, w, scale);
let (left, bottom) = (origin.x, origin.y);
let frame = Rect::new(left, bottom - h, left + w, bottom);
let alpha = b.alpha;
// The seat is snapped in window px, before the seam divides out, so the border art still blits
// 1:1 — the deviation [`seat_origin`] documents, kept now that the layout resolves the rect.
let origin = seat_origin(p.seat, w, scale);
// The backdrop (`0x4b0a35` into `0x76a5d0`): one value feeds the four insets and both
// edge-size fields. `pieces` is y-up, so y negates across the seam.
let bd = Backdrop {
bg_file: Some(BG_TEXTURE.to_string()),
edge_file: Some(EDGE_TEXTURE.to_string()),
tile: false,
tile_size: 0.0,
edge_size: border,
insets: Insets {
left: border,
right: border,
top: border,
bottom: border,
},
bg_color: [1.0; 4],
border_color: [1.0; 4],
};
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
for p in pieces(up, &bd) {
// Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners.
let rect = Rect::new(
p.corners[0][0],
-p.corners[0][1],
p.corners[2][0],
-p.corners[2][1],
);
// The border pieces share one 256×32 atlas: without the half-texel inset, bilinear blends
// in the neighbour's column, white at alpha 0 beside the top slice, a pale line.
let uvs = if p.is_bg {
p.uvs
} else {
pieces_inset(p.uvs, art.edge_texels)
};
quads.overlays.push(UiQuad {
rect,
z_key: z + if p.is_bg { Z_BG } else { Z_EDGE },
texture: Some(if p.is_bg {
art.bg.clone()
} else {
art.edge.clone()
}),
uv: UvRect::from_corners(uvs),
color: [1.0, 1.0, 1.0, alpha],
..default()
});
}
// The tail (`0x4b0af1`): a border-unit square, TOPRIGHT on the bottom lifted border/4.
let tail_top = frame.max.y - border * 0.25;
let cx = (frame.min.x + frame.max.x) * 0.5;
quads.overlays.push(UiQuad {
rect: Rect::new(cx - border, tail_top, cx, tail_top + border),
z_key: z + Z_TAIL,
texture: Some(art.tail.clone()),
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, alpha],
..default()
});
// The text, centered in the margin box and wrapped at the cap.
let center = Vec2::new(cx, (frame.min.y + frame.max.y) * 0.5);
let mut text_quads = layout_text_quads(
&mut e,
&b.text,
Rect::from_center_size(center, Vec2::new(box_w, box_h)),
[b.color[0], b.color[1], b.color[2], alpha],
Justify {
h: JustifyH::Center,
v: JustifyV::Middle,
},
z + Z_TEXT,
spec,
// Exact, so the text stays rigid against the device-snapped frame.
TextSeat::Exact,
);
drop(e);
if trace {
eprintln!(
"bubble-trace: viewport={viewport:?} frame=({:.1},{:.1})..({:.1},{:.1}) border={border:.1} alpha={alpha:.2} text={:?}",
frame.min.x, frame.min.y, frame.max.x, frame.max.y, b.text
"bubble-trace: viewport={:?} seat=({:.1},{:.1}) size=({w:.1}x{h:.1}) \
border={border:.1} alpha={:.2} text={:?}",
p.viewport, origin.x, origin.y, b.alpha, b.text
);
}
// The jitter decomposition (`WOW_MOVE_TRACE` tag `bub`): `anchor=` and `pos=` differ by a
@@ -675,30 +527,47 @@ fn draw_bubble(
&format!(
"e={} vp=({:.0},{:.0}) anchor=[{:.4},{:.4},{:.4}] cam=[{:.4},{:.4},{:.4}] \
fwd=[{:.4},{:.4},{:.4}] pos=[{:.4},{:.4},{:.4}] scr=({:.3},{:.3}) \
frame=({:.2},{:.2}) scale={scale:.2}",
entity.index(),
viewport.x,
viewport.y,
anchor.x,
anchor.y,
anchor.z,
seat=({:.2},{:.2}) scale={scale:.2}",
p.entity.index(),
p.viewport.x,
p.viewport.y,
p.anchor.x,
p.anchor.y,
p.anchor.z,
cp.x,
cp.y,
cp.z,
cf.x,
cf.y,
cf.z,
unit_pos.x,
unit_pos.y,
unit_pos.z,
seat.x,
seat.y,
frame.min.x,
frame.max.y,
p.unit_pos.x,
p.unit_pos.y,
p.unit_pos.z,
p.seat.x,
p.seat.y,
origin.x,
origin.y,
),
);
}
quads.overlays.append(&mut text_quads);
BubbleState {
key: p.guid,
// The bubble's own bottom centre, y flipped into FrameXML's bottom-up space: the same
// conversion `vplates.rs` makes for a plate's top centre.
seat: (
(origin.x + w * 0.5) / seam,
(p.viewport.y - origin.y) / seam,
),
width: w / seam,
height: h / seam,
text: b.text.clone(),
color: b.color,
alpha: b.alpha,
border: border / seam,
font_height: px / seam,
wrap_width: box_w / seam,
}
}
/// The bubble stage; [`crate::nameplates`] orders after it to read [`BubblesActive`].
@@ -734,13 +603,16 @@ impl Plugin for ChatBubblePlugin {
.init_resource::<BubbleConfig>()
.init_resource::<Bubbles>()
.init_resource::<BubblesActive>()
.add_systems(Startup, load_bubble_art.after(AssetSet::Open))
// After the V-plate drive, whose verdict the spawn gate reads.
// After the V-plate drive, whose verdict the spawn gate reads, and **before the
// paint**: this system now writes widgets instead of quads, and
// `ui_script::extract::paint_script` has to see this frame's bubbles to draw them this
// frame. It used to sit inside `UiQuadAppend`, which is the window *after* the paint —
// right for an appender, a frame late for a feed.
.add_systems(
Update,
drive_bubbles
.after(VPlateSet)
.in_set(UiQuadAppend)
.before(crate::ui_script::UiPaint)
.in_set(BubbleSet),
);
}
@@ -941,28 +813,11 @@ mod tests {
);
}
/// One contiguous band per bubble, ascending with rank, so no two bubbles interleave.
#[test]
fn each_bubble_gets_its_own_level_band() {
// The four pieces of one bubble, in paint order, all inside one level.
const { assert!(Z_BG < Z_EDGE && Z_EDGE < Z_TAIL && Z_TAIL < Z_TEXT) };
const { assert!(Z_TEXT < overlay_z::BUBBLE_STRIDE) };
for rank in 0..8usize {
// ...and every piece strictly below the next bubble's background.
assert!(level_z(rank) + Z_TEXT < level_z(rank + 1) + Z_BG);
}
assert!(level_z(1) > level_z(0), "later rank draws later");
}
/// However many bubbles are live, none reaches the V-plates above or the combat text below.
#[test]
fn the_bubble_band_stays_between_its_neighbours() {
for rank in [0, 1, 1_000, usize::MAX] {
let z = level_z(rank);
assert!(z >= overlay_z::BUBBLE, "over the floating numbers");
assert!(z + Z_TEXT < overlay_z::VPLATE, "under the V-plates");
}
}
// The two z-band tests that used to live here are gone with the band: a bubble's paint order
// inside itself is now the widget layer's (the backdrop under the regions, the text in the
// Overlay layer over the tail in Artwork), and its order against the other bubbles and the
// V-plates is frame levels — `benilla_ui::script::chat_bubble` owns both, and the sort test
// below still pins the rank this file hands over.
/// Farthest camera distance first, guid breaking an exact tie.
#[test]
@@ -983,8 +838,8 @@ mod tests {
vec![2, 3, 7, 9],
"farthest first; the 400-yd² pair ordered by guid"
);
// The rank the draw uses is the frame level: the nearest speaker draws last.
assert!(level_z(0) < level_z(p.len() - 1));
// The index in this order IS the frame level the widget layer stamps (`2 + rank`), so the
// nearest speaker's bubble draws over the rest.
}
/// 16 px at 1024-wide 4:3 (width/64), damped past the plate knee.
+2
View File
@@ -1372,6 +1372,8 @@ pub(crate) mod schedule_tests {
"`seen_generation` is a `VmMemo`: a new VM reads `None`, rebuilds and re-fires UPDATE_BINDINGS"),
("capture/probe_bg.rs", "bg_probe", Because::SelfHealing,
"the battleground probe: dev-only (`WOW_PROBE_BG`, `cfg(feature = \"dev\")`) so it is not in a player build at all, and its `mem::take` is of its OWN pending-events string, not a queue anything else fills. Its one real VM dependency is the Lua event tap, which self-heals: `EVENT_DRAIN` returns a `<tap-gone>` sentinel when the tap's globals are missing — the case this window causes, since a tap installed in the boot VM is discarded when `mint_entry_vm` builds the interface — and the probe re-installs on the next frame"),
("chat_bubble.rs", "drive_bubbles", Because::SelfHealing,
"the one-shot is the bubble spawn QUEUE, and its product is app-side state (`Bubbles`), not a VM publish: a line that arrives before the interface exists still becomes a live bubble with its own lifetime, and the widget sync is idempotent per frame — it returns having done nothing while there is no `WorldFrame` and binds the same bubble on the first frame there is one, firing `CREATE_CHATBUBBLE` then. Gating the system instead would be worse: the queue would pile up and every held line would spawn at once, each with a fresh lifetime, when the UI came up"),
("death/mod.rs", "feed_death", Because::MemoLatched,
"`feed.vm: VmMemo<DeathAnnounced>`: a fresh memo makes the first snapshot an edge and re-announces a held offer, confirm and corpse range"),
("screenshot.rs", "ask_for_captures", Because::FilledByVm,
+8 -9
View File
@@ -161,16 +161,15 @@ pub(crate) struct UiQuads {
/// region sets `1 << 20`), so the whole lane paints under the player UI.
pub(crate) mod overlay_z {
/// Floating combat, XP and honor numbers, drawn by the client in the world scene under all UI.
///
/// **The lane's only remaining tenant.** The chat bubbles used to hold a band above this one
/// (and the V-plates a band above that, as a ceiling for it): all three are widgets now —
/// plates in `vplates.rs`, bubbles in `chat_bubble.rs` — so their order comes from frame
/// strata and levels, where the reference's does. A band for one tenant is just 0, and the
/// bubbles' own ordering against the plates improved by the move: as frames they take levels
/// 2, 3, 4… over the plates' 1, which is the reference's own order, where the band layout had
/// them underneath.
pub(crate) const WORLD_TEXT: u64 = 0;
/// The chat bubbles: one frame level per live bubble, farthest from the camera first.
pub(crate) const BUBBLE: u64 = 1 << 8;
/// Keys per bubble level: its bg, edge, tail and text.
pub(crate) const BUBBLE_STRIDE: u64 = 4;
/// The V-plates, over every bubble; a unit never shows both, so this orders only across units.
pub(crate) const VPLATE: u64 = 1 << 16;
/// The highest bubble level below [`VPLATE`], a guard rail far past any real count (a bubble
/// needs a speaker within 20 yd).
pub(crate) const BUBBLE_MAX_LEVEL: u64 = (VPLATE - BUBBLE) / BUBBLE_STRIDE - 1;
}
/// Clear the append lane at the top of the [`UiQuadAppend`] window, before every appender.
@@ -2634,6 +2634,14 @@ mod tests {
"SuperWoW's own event, the one every cast-tracking addon on this realm registers; \
1.12 reports another unit's casts nowhere at all",
),
(
"CREATE_CHATBUBBLE",
"SuperWoW: a chat bubble appeared, `arg1` its frame — the third and last of its \
events, and the one that could not honestly be fired until the bubbles were \
widgets (`benilla-ui`'s `script/chat_bubble.rs`) rather than overlay quads. 1.12 \
publishes no bubble edge at all: pfUI's own workaround is to re-scan the whole \
bubble list the frame after every say, yell and monster line",
),
(
"RAW_COMBATLOG",
"SuperWoW: every combat-log line twice over — as the player reads it, and with \
+8 -1
View File
@@ -54,7 +54,14 @@ fn family(rel: &str) -> Family {
/// - `debug_panel/mod.rs` and `debug_panel/lighting_controls.rs` drive CVars through
/// `UiScript::set_cvar_engine`, the same queue Lua's `SetCVar` feeds, so the dev panel and the
/// interface cannot hold different values for one CVar.
const CEILING: usize = 43;
/// - `chat_bubble.rs` holds it because the chat bubbles **are widgets**, for the same reason
/// `vplates.rs` — already in this census, and this file's exact twin — holds it for the
/// V-plates: a bubble's *existence* is world state (the projection, the lifetime, the 20 yd
/// re-test, the stacking sort) and its *presentation* is a frame pool, and one file knowing both
/// is what keeps the two from disagreeing about a bubble. Moving it under `ui_` would satisfy
/// the census and split a single-sourced feed in half; the alternative the wall offers — "feed a
/// model value instead" — is precisely what it does, [`benilla_ui::script::BubbleState`].
const CEILING: usize = 44;
/// How far under [`CEILING`] the count may sit before the test asks for the ceiling to follow it.
const SLACK: usize = 4;
+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),
+28 -11
View File
@@ -40,7 +40,7 @@ nothing behind it.
| `UNIT_CASTEVENT` | done | `crates/benilla-app/src/superwow_events.rs`: a `CastEventFeed` queue the cast handlers push into, drained once per tick by one VM-holding system that fires the event — the pattern `ui_mirror.rs` already uses, and the only way across, since `spell/net.rs`'s `Lifecycle`/`Scene` bundles deliberately hold no `UiScript` (`game_plugins.rs`'s `every_vm_holder_in_update_declares_its_side_of_the_tick` gates that). Edges wired: `START` from `SMSG_SPELL_START` with its wire cast time as `arg5` (an instant raises none — its only edge is `CAST`, which is what the wiki's own START/CAST split means), `CAST` from `SMSG_SPELL_GO`, `FAIL` from `SMSG_SPELL_FAILED_OTHER`, `CHANNEL` from `MSG_CHANNEL_START` with its duration. GUIDs go out in the same 16-hex-digit spelling every other `compat/` surface uses, so `arg1`/`arg2` compare directly against a `UnitExists`/`UnitGUID` return. Not wired: `MAINHAND`/`OFFHAND` — benilla's melee-swing path raises no cast edge at all, so there is nothing to forward. `MSG_CHANNEL_START` names no caster (1.12 sends it for the local player's own channel only), so `CHANNEL`'s `arg1` is self and its `arg2` is 0 |
| `RAW_COMBATLOG` | **done** | `ui_chat/frames.rs`'s `route` — the one funnel every combat line already passes on its way to a window and to `WoWCombatLog.txt`. Two arguments, as its one consumer in grom's corpus destructures them: the printed sentence, and the same sentence with each unit name spelled as its GUID (`PendingCombat::guid_fills`, composed a second time through the same `compose_line`, so the two can only ever differ in the name slots). Fired **before** the `CHAT_MSG_*`, which is what "raw" means upstream — it hooks the combat-log parse, ahead of the client's own formatting. Gated on `has_listener` — the gate nampower's combat-log family already uses — **or on SuperWoW's raw log being open**, since that file is the same `arg2` form written to disk; the composition is skipped under the same one test (`frames::wants_raw_form`), because a raid's log is tens of lines a second. A variant whose template says "you" keeps saying it, because that word is in the *template* and not in a fill — and its consumer depends on exactly that, reading the word and then asking `UnitExists("player")` for its own guid. **Why this row came first:** of SuperWoW's whole surface, it and `CombatLogAdd` are the only two names grom's addons call that this client lacked (`scripts/dll-surface.py --wanted`), and `SuperCleveRoidMacros/Utility.lua:10206` funnels **five** subsystems through this single event — immunity and resist learning, hidden-CC ("afflicted by") detection, debuff-duration learning off the GUID half, and the reactive-proc window that lights **Overpower** and **Revenge**. Its own comment there is why the last of those can come from nowhere else: "SPELL_GO only provides hit/miss COUNTS, not miss TYPES, so it cannot distinguish dodge (Overpower proc) from parry/block/resist". This does **not** by itself fix the Overpower complaint: that needs the dodged-*ability* sentence to be emitted at all, which is its own open item (`benilla-overpower-aura-spelldodged`) |
| `CombatLogAdd("text" [, addToRawLog])` | **done** | `compat/superwow.rs` queues the line; `ui_chat/logging.rs`'s `sync_chat_logging` appends it to `WoWCombatLog.txt` through the same `record` every routed line uses, so it is stamped identically and one writer owns the file. **The second argument names a FILE**, not the event: `Logs\WoWRawCombatLog.txt` is its own literal in the DLL's string pool, a few strings on from `Logs\WoWCombatLog.txt`, and it is the GUID-form log — the same lines `RAW_COMBATLOG` carries as `arg2`. That corrects a first reading of the name ("also fire the event"), which was an inference from the name alone. Dropped while a log is closed, which is upstream's behaviour too. A non-string argument is ignored rather than raising: its one real caller is a debug-trace helper (`BigWigs/Plugins/MageTools.lua:761`) and a raise there would take the addon down for something the player cannot act on. **It is also a detection name**: `SpellRequests.lua:6` is `if CombatLogAdd then hasSuperWow = true`, which gates that whole module |
| `CREATE_CHATBUBBLE` | **blocked, deliberately not fired** | the last of SuperWoW's three events, and the one that cannot be made good: its payload can only be the bubble, and this client's bubbles are not Lua frames — they are a 2-D world overlay (`chat_bubble.rs`), as the reference's own `CGChatBubbleFrame` is. The same wall `C_ChatBubbles.GetAllChatBubbles` hit, and firing it argless would be *worse* than silence (an addon reaching for `arg1`'s regions raises, once per bubble). See the manifest section below for what the one piece of work behind it would close. `SUPERWOW_COMBAT_LOG_EVENT`, which this row used to carry, is a ghost |
| `CREATE_CHATBUBBLE` | **done** | the last of SuperWoW's three events, and it needed the bubbles to become **widgets** first: its payload can only be the bubble, and this client drew them straight into the overlay quad lane. `benilla-ui`'s `script/chat_bubble.rs` is now a pool of real frames — anonymous `WorldFrame` children with a backdrop, a tail texture and a `FontString`, which is what the reference's own `CGChatBubbleFrame` is — driven by the app's existing feed, the same split `vplates.rs` already makes for the plates. `arg1` is the frame, which is the only sensible payload and a reading rather than a measurement (the DLL publishes no usage string for an event). Fired **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 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 |
| `NameplateRange` | done | one CVar, two readers: `nameplates.rs`'s `NameConfig::range_sq` (the overhead name — introduces a cull where the reference has none at all) and `vplates.rs`'s `VPlateMode::range_sq` (replaces the reference's own hardcoded 20 yd V-key-plate range). Default `80`, the top of the mod's documented 10-80 range — no shipped default is published, so this is the least-restrictive honest choice, not a confirmed number |
| `ChatBubbleRange` | done | `chat_bubble.rs`'s `BubbleConfig::range_sq`, replacing the reference's hardcoded 20 yd. Default `20` (today's exact value) — no shipped default published for this one either, within the mod's documented 10-200 yd range |
| `NameplateMotion` | **done, two of its four values** | the investigation this row asked for had an answer already in the tree: benilla *does* spread plates, with the reference's own SmartScreenRect solver (`crate::smart_rect`, bucket 0). So `0` ("Overlap") is that solver bypassed — the plate sits on its own projected point and nothing is claimed either, since a claim with no solve is half a spread — and `1` ("Default spread") is it, which is today's behaviour and the registered default. `2` ("Smart spread") and `3` ("Compact spread") are the mod's own alternative algorithms, unknowable from here, and **fall back to 1**: a documented silent-equal rather than a refused row, because the benign direction is plates that still do not overlap, and refusing would leave a player no way to turn the spread off at all. The four meanings come from SuperAPI's own panel (`SuperAPIOptions.lua:20-25`), the DLL publishing only the name. `NameplateVerticalSpacing`, which this row used to carry, is **not in the DLL's string pool at all** — another ghost |
@@ -108,15 +108,32 @@ that site can only subtract; the other reading would bubble creatures with the m
which is a surprise no setting advertises. All four are welded to their struct literals in
`cvars.rs`'s own defaults-mirror test, the `benilla-cvar-default-trap` discipline.
**`CREATE_CHATBUBBLE` cannot be made good, and the reason is already written up elsewhere in this
file.** Its payload can only be the bubble, and this client's bubbles are not Lua frames at all —
they are a 2-D world overlay drawn by `chat_bubble.rs`, the same way the reference's own
`CGChatBubbleFrame` is drawn. That is the identical wall `C_ChatBubbles.GetAllChatBubbles` hit, and
it answers an empty list for it. Firing the event argless would be worse than not firing: an addon
written against upstream would reach for `arg1`'s regions and raise, once per bubble. **The real
item behind both** is making the native bubbles addressable from Lua — a frame with a texture and a
`FontString` per live bubble, kept in step with the overlay's own spawn, stacking and expiry rules —
and that one piece of work closes the event, the namespace and pfUI's bubble skinning together.
**`CREATE_CHATBUBBLE` is done, and so is `C_ChatBubbles.GetAllChatBubbles`** — one piece of work
closed both, plus pfUI's bubble skin. The wall was that a bubble's payload can only be the bubble
and this client's bubbles were not Lua frames: they were drawn straight into the overlay quad lane,
faithful on screen and unreachable from Lua. They are a **widget pool** now
(`benilla-ui`'s `script/chat_bubble.rs`): 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 the
result over per live bubble, the same split `vplates.rs` makes for the plates. Three things improved
by the move rather than being preserved by it:
- 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 band had them *underneath*, a deviation
`ui_pass::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
`chat_bubble.rs` no longer carries its own texture resource or its own half-texel inset.
- The system moved **before** the paint. It had been inside `UiQuadAppend`, the window *after* the
extract — right for an appender, a frame late for a feed.
**What a test had to prove, and does**: pfUI's own `ScanBubbles` loop, transcribed, 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 a bubble handed over as state comes out of the *extract* as 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.
And the CVars it registers, as names: `NameplateRange`, `NameplateMotion`, `ChatBubbleRange`,
`ChatBubblesRaid`, `ChatBubblesBattleground`, `ChatBubblesWhisper`, `ChatBubblesCreatures`,
@@ -351,7 +368,7 @@ a loot roll"; it does not, because that module never runs.** The call path was n
| Area | Status | Notes |
|---|---|---|
| `C_ChatBubbles.GetAllChatBubbles` | done, **empty list** | The one genuinely hot absence the sweep found, and reproducible: `pfUI.bubbles:ScanBubbles()` raised `attempt to index global 'C_ChatBubbles'`, and pfUI runs it the frame after **every** `CHAT_MSG_SAY`/`YELL`/`MONSTER_SAY`/`MONSTER_YELL` within earshot. Answers an empty list, which is the truthful count rather than a stub: bubbles here are drawn natively by `chat_bubble.rs` as a 2-D world overlay, exactly as the reference's own `CGChatBubbleFrame` is, so there are no Lua frames to hand over and no regions a script can repaint. The cost is that bubbles keep this client's look instead of pfUI's; making them addressable (a frame + texture + `FontString` per live bubble, kept in step with the native spawn/stack/expiry rules) is the real fix and is not built |
| `C_ChatBubbles.GetAllChatBubbles` | **done, the live bubbles** | The one genuinely hot absence the sweep found, and reproducible: `pfUI.bubbles:ScanBubbles()` raised `attempt to index global 'C_ChatBubbles'`, and pfUI runs it the frame after **every** `CHAT_MSG_SAY`/`YELL`/`MONSTER_SAY`/`MONSTER_YELL` within earshot. It answered an *empty list* for a round — truthful, because bubbles were overlay quads with no Lua frame to hand over — and that row said making them addressable was "the real fix and is not built". It is built: `benilla-ui`'s `script/chat_bubble.rs` is a pool of real frames and this answers them in pool order, live ones 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, and is deliberately **not** the stacking order, which changes every frame with the camera. pfUI's own loop over it is transcribed as a test |
| `C_Spell.GetSpellDispelType` | done | The other hot one: `libs/libdebuff.lua:379` calls it once per debuff while walking aura slots 33-48, so it runs for every debuff on every scanned unit — continuously, in combat, which is when the debuff display matters. Backed by widening [`SpellLookup`] with `Spell.dbc`'s `Dispel` column, the route this file already named for the `C_Spell` predicates; `SpellDisplay` carried it already, so nothing new had to be parsed. Answers the **raw id**, not `SpellDispelType.dbc`'s localized name, because the caller keys its own `dispelTypeMap` on the number and a name would make a localized realm answer something that table cannot match. `0` ("no dispel class") answers **nil**, which the call site's `if dispelId and dispelId > 0` already reads the same way. The book arm takes it from the probe alongside `reagents` — a spellbook slot carries neither column |
| `Item:CreateFromItemLink` | done | Keyed on the id inside the link, which is the same object here: every `ItemMixin` getter reads a `C_Item.*ByID`, and a 1.12 link carries nothing per-item beyond the id — no bonus ids, no modifiers, no scaling — so the link and id forms cannot disagree about anything the mixin exposes. A link naming no item (nil, a spell or quest link, a malformed string) yields an **empty** item rather than raising, which is what the call site's own `if item and not item:IsItemEmpty()` expects. Built for correctness, not for a live error: its only caller is the loot-history module, which never runs |
| `UnitSpellHaste` | done, **0** | 0 is the *answer*, not a stub: 1.12 has no haste stat anywhere — not a `UNIT_FIELD`, not an item mod, not a spell modifier — so every unit genuinely has 0% spell haste and the real DLL has nothing else to read either. pfUI's player module calls it unguarded into a `string.format("%.1f%%")` (`modules/player.lua:87`) whenever its haste readout is on, so the absence raised once per 0.25s tick and took the neighbouring "+N SP" line down with it |