Chat bubbles are WorldFrame child frames, as in 1.12.1 (#925)

Chat bubbles were quads drawn straight into an overlay with no widget behind them, so `WorldFrame:GetChildren()` never returned them and bubble-skinning addons such as pfUI's found nothing.

In the reference, a bubble is an anonymous Frame under WorldFrame from a reused pool: eleven regions (the nine backdrop pieces, the tail, the text in the `NAMEPLATE_FONT` global), the text's bottom on the speaker's projected point, its own fade, and an OnShow on each reuse. It fades out and hides when the speaker is out of range or was not drawn last frame.

Bubbles are now built that way, fed by the app and drawn by the frame pass; the overlay path is gone. Item links show their bare names, and a speaker behind the camera fades out.

Tests cover the child, its regions, a pfUI-style skin, reuse and the fades.

Fixes #884

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 02:06:36 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d65fd19095
commit eec1804d37
17 changed files with 1600 additions and 674 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,8 @@ mod ace_gate_tests;
mod away;
/// The world broadcasts: `SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`.
mod broadcast;
/// The chat bubbles' hand-off to their `WorldFrame` widgets.
pub(crate) mod bubbles;
mod channels;
/// The combat log's chat lines: classification, chat type and each sentence's GlobalString key.
pub(crate) mod combat;
+19
View File
@@ -0,0 +1,19 @@
//! The chat bubbles' feed: this frame's live bubbles, gated, faded and seated by
//! [`crate::chat_bubble`], handed to the widget layer, which keeps them as `WorldFrame` children.
use bevy::prelude::*;
use crate::chat_bubble::BubbleFeed;
/// Every frame, after the bubble drive and before the script paint: write the live set into the
/// VM's bubble pool, which hides and pools the frames of lines that ended.
pub(crate) fn feed_bubbles(
feed: Res<BubbleFeed>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
) {
let (Some(mut script), Some(geometry)) = (script, feed.geometry) else {
return;
};
script.sync_chat_bubbles(geometry, &feed.states);
}
-9
View File
@@ -162,15 +162,6 @@ pub(crate) struct UiQuads {
pub(crate) mod overlay_z {
/// Floating combat, XP and honor numbers, drawn by the client in the world scene under all UI.
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.
@@ -114,8 +114,9 @@ pub(crate) fn tiling_axes(uv: &UvRect) -> (bool, bool) {
}
/// The [`UiQuad::uv_clamp`] window of an atlas crop, inset half a texel so a magnified cell does
/// not filter in its neighbour. A tiling axis is left alone, as
/// [`benilla_ui::script::inset_atlas_bleed`] does; a whole-texture axis needs no window.
/// not filter in its neighbour (in `ChatBubble-Backdrop`, a white alpha-0 column: a pale line
/// through the speech bubble). A tiling axis is left alone, or each period would land short and
/// walk the art; a whole-texture axis needs no window.
fn uv_clamp_window(uv: &UvRect, size: (u32, u32)) -> Option<[f32; 4]> {
let mut out = [1.0, 1.0, 0.0, 0.0]; // both axes off: `min > max`
let mut any = false;
@@ -294,7 +294,7 @@ pub fn portrait_image(width: u32, height: u32, mut rgba: Vec<u8>) -> Image {
/// argument twice into the load descriptor (`0x770200`), read as U and V wrap but not traced to
/// the sampler: an edge strip runs UVs `[0..N]` (N = frame side / edgeSize − 2) and a tiled
/// background `[0..w/period]` (`0x77e8d0`, `0x77f0c0`). The edge strips are atlas crops kept off
/// the image edge (`inset_atlas_bleed`), so their bounded axis does not bleed.
/// the image edge by half a texel, so their bounded axis does not bleed.
pub fn sprite_image_tiled(width: u32, height: u32, rgba: Vec<u8>) -> Image {
sprite_image_wrapped(width, height, rgba, (true, true))
}
@@ -362,15 +362,6 @@ impl WorldAssets {
made
}
/// A UI sprite repeating on both axes, for a frame `Backdrop`'s tiled pieces (`0x77f0c0`).
pub fn sprite_texture_tiled(
&mut self,
path: &str,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
self.sprite_texture_wrapped(path, (true, true), images)
}
/// A UI sprite repeating on the axes `wrap` names ([`sprite_image_wrapped`]).
pub fn sprite_texture_wrapped(
&mut self,
+66 -91
View File
@@ -3,8 +3,7 @@
//! Texture regions on the frame from it (`0x77e8d0`), the background on BACKGROUND and the eight
//! border pieces on BORDER. They are ordinary regions: `GetRegions` returns them, the region draw
//! path paints them, and every Texture verb works on them. The spec drives them: the colour
//! setters tint them and a resize re-runs their texcoords (`0x77f0c0`). [`pieces`] is the same
//! geometry as screen quads, for a backdrop the app draws itself (the chat bubble).
//! setters tint them and a resize re-runs their texcoords (`0x77f0c0`).
use std::collections::HashMap;
@@ -36,7 +35,7 @@ pub struct Backdrop {
/// The 8-slice border texture (`+0x30`); none or empty builds no border.
pub edge_file: Option<String>,
/// Tile the background, else stretch it (`+0x40`).
pub tile: bool,
pub(crate) tile: bool,
/// `tileSize` in logical px (`+0x4c`); 0 tiles at `edge_size` (`0x77f0c0`).
pub tile_size: f32,
/// `edgeSize` in logical px (`+0x48`): the border piece size and the edge tiling period.
@@ -77,17 +76,18 @@ impl Backdrop {
/// One drawable piece, the background or one of the 8 border pieces: its corners, axis-aligned,
/// and per-corner UVs, since the top and bottom edges are rotated 90° (`0x77f0c0`). Both arrays
/// run `[TL, TR, BR, BL]`, y-up.
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BackdropPiece {
pub(crate) struct BackdropPiece {
/// Corner positions in screen px.
pub corners: [[f32; 2]; 4],
pub(crate) corners: [[f32; 2]; 4],
/// Corner UVs in texture space.
pub uvs: [[f32; 2]; 4],
pub(crate) uvs: [[f32; 2]; 4],
/// The texture needs repeat addressing. The corners stay in `[0,1]` but share the edge texture,
/// so they carry `true` too and the border batches as one texture (`0x77e8d0`).
pub tile: bool,
/// Drawn with `bg_file` and `bg_color`, else with `edge_file` and `border_color`.
pub is_bg: bool,
pub(crate) is_bg: bool,
}
const SLICE: f32 = 0.125;
@@ -212,36 +212,8 @@ fn run_uvs(bd: &Backdrop, rect: Rect, scale: f32) -> (Option<Uvs>, [Uvs; 4]) {
(bg, edges)
}
/// Pull a piece's UVs half a texel inward on every axis that does not tile, so bilinear
/// magnification cannot sample the neighbouring slice of the shared border atlas (in
/// `ChatBubble-Backdrop`, a white alpha-0 column: a pale line through the speech bubble). A tiling
/// axis, a strip's `v` past 1, is left exact, or each period would land short and walk the art.
#[must_use]
pub fn inset_atlas_bleed(uvs: [[f32; 2]; 4], tex_w: f32, tex_h: f32) -> [[f32; 2]; 4] {
let mut out = uvs;
for (axis, tex) in [(0usize, tex_w), (1usize, tex_h)] {
if tex < 2.0 {
continue; // a 1-texel axis has no interior
}
let (lo, hi) = uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), c| {
(lo.min(c[axis]), hi.max(c[axis]))
});
// A range of exactly 1, the corners' `v`, is bounded and gets the inset.
if hi - lo > 1.0 + f32::EPSILON {
continue;
}
let half = 0.5 / tex;
if hi - lo <= 2.0 * half {
continue; // degenerate: the inset would invert the range
}
for c in &mut out {
c[axis] += if c[axis] <= lo { half } else { -half };
}
}
out
}
/// Axis-aligned corners `[TL, TR, BR, BL]`, y-up.
#[cfg(test)]
fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
[
[x0, y_top], // TL
@@ -253,9 +225,11 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`), as the regions `install` builds
/// would resolve and draw. Their geometry is in the frame's own units, so it is multiplied by the
/// frame's effective `scale` (`0x76ac90`), as any region's is.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
/// would resolve and draw, the oracle the region tests check them against. Their geometry is in
/// the frame's own units, so it is multiplied by the frame's effective `scale` (`0x76ac90`), as
/// any region's is.
#[cfg(test)]
pub(crate) fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size * scale;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
@@ -360,12 +334,7 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>
let rh = model
.arena
.create_region(fh, RegionKind::Texture, layer, 0)?;
let anchors = anchors
.into_iter()
.flatten()
.map(|(p, x, y)| Anchor::new(p, owner, p, x, y))
.collect();
let size = (size != (0.0, 0.0)).then_some(size);
let (anchors, size) = piece_geometry(owner, (anchors, size));
model.region_data.insert(
rh,
RegionData {
@@ -412,6 +381,58 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>
}
}
/// A piece's anchors on frame `owner` and its explicit size, `None` where the anchors span both
/// axes.
fn piece_geometry(owner: u32, (anchors, size): PieceLayout) -> (Vec<Anchor>, Option<(f32, f32)>) {
let anchors = anchors
.into_iter()
.flatten()
.map(|(p, x, y)| Anchor::new(p, owner, p, x, y))
.collect();
(anchors, (size != (0.0, 0.0)).then_some(size))
}
/// Re-size an installed backdrop in place, its nine regions kept: the edge size, the tile period
/// and the insets, each piece re-anchored as `0x77e8d0` would build it, then the texcoords re-run.
/// No 1.12 call does this; the engine's chat bubbles take it on a window resize, where the
/// reference's sizes are window-relative and never need it (`0x4b0940`).
pub(crate) fn set_metrics(
model: &mut Model,
fh: FrameHandle,
edge_size: f32,
tile_size: f32,
insets: Insets,
) {
let Some(inst) = model.backdrops.get_mut(&fh) else {
return;
};
if inst.spec.edge_size == edge_size
&& inst.spec.tile_size == tile_size
&& inst.spec.insets == insets
{
return;
}
inst.spec.edge_size = edge_size;
inst.spec.tile_size = tile_size;
inst.spec.insets = insets;
inst.ran_for = None;
let (spec, pieces) = (inst.spec.clone(), inst.pieces);
let owner = model.frame_id(fh);
let layouts = std::iter::once(bg_layout(&spec)).chain(EDGES.map(|e| edge_layout(e, edge_size)));
for (slot, layout) in pieces.into_iter().zip(layouts) {
let Some(d) = slot.and_then(|rh| model.region_data.get_mut(&rh)) else {
continue;
};
(d.anchors, d.size) = piece_geometry(owner, layout);
}
for rh in pieces.iter().flatten() {
model.touch_layout_region(*rh);
}
if let Some(rect) = model.resolved.get(&fh).copied() {
run_texcoords(model, fh, rect);
}
}
/// `0x77f0c0` for frame `fh` at `rect`, from `install`.
fn run_texcoords(model: &mut Model, fh: FrameHandle, rect: Rect) {
let scale = super::object::eff_scale(model, fh);
@@ -522,52 +543,6 @@ pub(crate) fn after_resolve(model: &mut Model) {
mod tests {
use super::*;
/// On the `ChatBubble-Backdrop` geometry: 256×32, eight 32-texel slices.
#[test]
fn the_bleed_inset_lands_on_texel_centres_and_spares_tiling() {
const W: f32 = 256.0;
const H: f32 = 32.0;
let half_u = 0.5 / W;
let corner = [[0.5, 0.0], [0.625, 0.0], [0.625, 1.0], [0.5, 1.0]];
let got = inset_atlas_bleed(corner, W, H);
// 128.5 and 159.5 are the centres of the slice's first and last texels.
let us: Vec<f32> = got.iter().map(|c| c[0] * W).collect();
assert!((us[0] - 128.5).abs() < 1e-3, "left u at {} texels", us[0]);
assert!((us[1] - 159.5).abs() < 1e-3, "right u at {} texels", us[1]);
let vs: Vec<f32> = got.iter().map(|c| c[1] * H).collect();
assert!((vs[0] - 0.5).abs() < 1e-3, "top v at {} texels", vs[0]);
assert!((vs[2] - 31.5).abs() < 1e-3, "bottom v at {} texels", vs[2]);
let strip = [[0.25, 8.0], [0.25, 0.0], [0.375, 0.0], [0.375, 8.0]];
let got = inset_atlas_bleed(strip, W, H);
for (g, s) in got.iter().zip(strip.iter()) {
assert_eq!(g[1], s[1], "the tiling axis must be bit-exact");
}
assert!((got[0][0] - (0.25 + half_u)).abs() < 1e-6);
assert!((got[2][0] - (0.375 - half_u)).abs() < 1e-6);
for i in 0u8..8 {
let (u0, u1) = (f32::from(i) * SLICE, f32::from(i + 1) * SLICE);
let piece = [[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]];
for c in inset_atlas_bleed(piece, W, H) {
assert!(
c[0] > u0 && c[0] < u1,
"slice {i}: u {} escaped ({u0},{u1})",
c[0]
);
}
}
assert_eq!(inset_atlas_bleed(corner, 0.0, 0.0), corner);
let sliver = [[0.0, 0.0], [0.001, 0.0], [0.001, 1.0], [0.0, 1.0]];
assert_eq!(
inset_atlas_bleed(sliver, W, H)[0][0],
0.0,
"too thin to inset"
);
}
fn tooltip_backdrop(edge_size: f32, inset: f32) -> Backdrop {
Backdrop {
bg_file: Some("Interface\\Tooltips\\UI-Tooltip-Background".into()),
+463
View File
@@ -0,0 +1,463 @@
//! The chat bubble widgets: the reference's `CGChatBubbleFrame` (ctor `0x4b0940`), an anonymous
//! `Frame` the pool `0x4b1870` builds with the `WorldFrame` as its parent (the spawn passes
//! `[0xb4b2bc]`, `0x608bb9`), so it takes the `WorldFrame`'s stratum (`SetParent 0x76ab10`).
//! Bubble addons (pfUI's `bubbles.lua`) find it in `WorldFrame:GetChildren()` by its first
//! region's texture and read it positionally: eleven regions in creation order, the backdrop's
//! background and eight border pieces (`0x4b0a35` → `0x77e8d0`), the tail (`0x4b0a57`) and the
//! text (`0x4b0b14`).
//!
//! The app owns each line's lifetime, fade and seat ([`BubbleState`]); this layer owns the frames:
//! it hands one out per line, takes it back when the line ends, and writes what moved.
use std::collections::{HashMap, VecDeque};
use crate::layout::{Anchor, Point};
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
use super::backdrop::{self, Backdrop, Insets};
use super::model::Model;
use super::UiScript;
/// Region 1's art, the backdrop's background, and the path bubble addons identify a bubble by.
pub const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
/// Regions 2 to 9, the backdrop's eight border pieces.
pub const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop";
/// Region 10, the tail.
pub const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail";
/// The first frame level the per-frame pass stamps, farthest bubble first (`0x4b12de`).
const FIRST_LEVEL: u16 = 2;
/// The bubble geometry that follows from the window rather than the line, in the `WorldFrame`'s
/// units. Written into a bubble when it changes, so an addon's own anchors survive between
/// resizes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BubbleGeometry {
/// The border unit (`0x4b09d2`-`0x4b0a2f`): the backdrop's edge size, tile size and four
/// insets, the tail's side, and half the text's width floor.
pub border: f32,
/// The frame's margin around the text on all four sides (`0x4b0b6b`, `0x4b0b94`).
pub margin: f32,
/// The text's width cap, past which it wraps (`0x4b175d`).
pub wrap_cap: f32,
/// The text's font height (`0x4b0b21`).
pub text_height: f32,
/// Device pixels per unit, the grid the seat snaps to.
pub device_px: f32,
}
/// One live bubble's state for this frame, in the active list's order: farthest from the camera
/// first, which is the frame level order.
#[derive(Clone, Debug, PartialEq)]
pub struct BubbleState {
/// The speaker's GUID: a speaker has at most one bubble (`[unit+0xe64]`).
pub key: u64,
/// The chat line, unique per spawn: a new value for a live key is a new line, which replaces
/// the bubble (`0x608bb4`, `0x608bbf`).
pub line: u64,
/// The display text, already through the bubble's escape strip.
pub text: String,
/// The chat type's colour (`0x49e990`).
pub colour: [f32; 3],
/// The own-shown bit: a line is shown when it spawns and while it fades, hidden when an
/// out-of-range fade ends (`0x4b0d9f`), shown again on the way back (`0x4b0ea0`).
pub shown: bool,
/// The fade's frame alpha (`0x4b0d4f`, `0x4b0dc3`).
pub alpha: f32,
/// The projected speaker point the text's BOTTOM sits on, from the `WorldFrame`'s
/// BOTTOMLEFT (`0x4b157a`).
pub seat: (f32, f32),
}
/// One pooled bubble's widgets, in the reference's creation order after the backdrop's nine.
#[derive(Debug)]
struct Bubble {
frame: FrameHandle,
tail: RegionHandle,
text: RegionHandle,
/// The `(key, line)` it shows; `None` in the free list.
line: Option<(u64, u64)>,
/// The own-shown bit as this layer last set it (`[frame+0xd0]`); a new frame is born shown by
/// `CSimpleFrame`'s ctor (`0x76934a`).
shown: bool,
/// The anchor offset last written, the change gate's cache (`[bubble+0x324]`, `+0x328`).
seat: Option<(f32, f32)>,
/// The geometry its sizes were written under.
laid_out: Option<BubbleGeometry>,
}
/// The bubble pool: grown on demand, never shrunk or reordered, so every bubble keeps its place in
/// the `WorldFrame`'s child list for the session.
#[derive(Debug, Default)]
pub(crate) struct ChatBubbles {
bubbles: Vec<Bubble>,
/// The free list (`[0xb6f064]`): a finished bubble joins the tail (`0x4b15e5`-`0x4b15f7`) and a
/// new line takes the head (`0x4b1870`), so the bubble retired first is reused first.
free: VecDeque<usize>,
/// Each live speaker's pool slot, by [`BubbleState::key`].
assigned: HashMap<u64, usize>,
/// Frame to pool slot, never removed.
by_frame: HashMap<FrameHandle, usize>,
/// The active list (`[0xb6f038]`) as the last sync left it: the live slots, farthest first.
active: Vec<usize>,
}
impl UiScript {
/// Drive the pool from the app's live bubbles: end the lines that are gone, hand a frame to
/// each new one, then write each live bubble's level, alpha, visibility and seat.
pub fn sync_chat_bubbles(&mut self, geometry: BubbleGeometry, states: &[BubbleState]) {
let lua = self.lua();
// The font a new bubble takes: `FrameScript_GetText` on `NAMEPLATE_FONT` (`0x4b0b36` →
// `0x703bf0`), whose `_G[key]` arm (`0x703c12`) answers the global's string, else the
// shared "" a missing or non-string global gives, which sets no font.
let font = || match lua.globals().get::<mlua::Value>("NAMEPLATE_FONT") {
Ok(mlua::Value::String(s)) => Some(s.to_string_lossy()).filter(|s| !s.is_empty()),
_ => None,
};
let visibility = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// No WorldFrame (no interface loaded), no bubbles: no addon walks an invented root.
let Some(world) = model.arena.lookup("WorldFrame") else {
return;
};
sync(&mut model, world, geometry, states, &font)
};
// Each a full transition with its handlers, in the order the sync issued them.
for (frame, shown) in visibility {
super::visibility::set_shown(lua, frame, shown);
}
}
/// How many bubble widgets the pool holds.
pub fn chat_bubble_pool_len(&self) -> usize {
let lua = self.lua();
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.chat_bubbles.bubbles.len()
}
}
/// Whether `frame` is a pooled bubble.
pub(super) fn is_bubble(model: &Model, frame: FrameHandle) -> bool {
model.chat_bubbles.by_frame.contains_key(&frame)
}
/// The per-frame drive; returns the `Show`/`Hide` calls to run, in order.
fn sync(
model: &mut Model,
world: FrameHandle,
geometry: BubbleGeometry,
states: &[BubbleState],
font: &dyn Fn() -> Option<String>,
) -> Vec<(FrameHandle, bool)> {
let mut visibility = Vec::new();
// Lines that ended, in the active list's order, farthest first, as the reference's walk
// (`0x4b1138`) meets and destroys them (`0x4b1186`, `0x4b11c0`), so they join the free list in
// that order, before any new line this frame looks for a frame.
let live = |key: u64, line: u64| states.iter().any(|s| s.key == key && s.line == line);
for i in std::mem::take(&mut model.chat_bubbles.active) {
if let Some((key, line)) = model.chat_bubbles.bubbles[i].line {
if !live(key, line) {
retire(model, i, &mut visibility);
}
}
}
let world_id = model.frame_id(world);
for (rank, state) in states.iter().enumerate() {
let slot = match model.chat_bubbles.assigned.get(&state.key) {
Some(&i) => i,
None => {
// A new line: the spawn's acquire (`0x4b1870`), then its text (`0x4b1600`).
let i = acquire(model, world, geometry, font);
model.chat_bubbles.assigned.insert(state.key, i);
if model.chat_bubbles.bubbles[i].laid_out != Some(geometry) {
lay_out(model, i, geometry);
}
set_line(model, i, state, geometry, &mut visibility);
i
}
};
if model.chat_bubbles.bubbles[slot].laid_out != Some(geometry) {
lay_out(model, slot, geometry);
fit_width(model, slot, geometry);
}
drive(
model,
slot,
world_id,
rank,
state,
geometry,
&mut visibility,
);
model.chat_bubbles.active.push(slot);
}
visibility
}
/// Take a frame for a new line: the free list's head, else a new one at the pool's tail.
fn acquire(
model: &mut Model,
world: FrameHandle,
geometry: BubbleGeometry,
font: &dyn Fn() -> Option<String>,
) -> usize {
if let Some(i) = model.chat_bubbles.free.pop_front() {
return i;
}
let bubble = create(model, world, geometry, font());
let i = model.chat_bubbles.bubbles.len();
model.chat_bubbles.by_frame.insert(bubble.frame, i);
model.chat_bubbles.bubbles.push(bubble);
i
}
/// End a bubble's line (`0x4b1590`): clear its shown bit and hide it, then put it on the free
/// list's tail. It keeps its parent and its place in the child list.
fn retire(model: &mut Model, i: usize, visibility: &mut Vec<(FrameHandle, bool)>) {
let b = &mut model.chat_bubbles.bubbles[i];
let Some((key, _)) = b.line.take() else {
return;
};
if b.shown {
b.shown = false;
visibility.push((b.frame, false));
}
model.chat_bubbles.assigned.remove(&key);
model.chat_bubbles.free.push_back(i);
}
/// Build one bubble (`0x4b0940`): the Frame under the `WorldFrame`, the backdrop's nine regions,
/// the tail, the text, and the frame's two anchors on the text.
fn create(
model: &mut Model,
world: FrameHandle,
g: BubbleGeometry,
font: Option<String>,
) -> Bubble {
// Anonymous: the pool passes no name.
let frame = model.arena.create(FrameKind::Frame, None, Some(world));
// `SetParent 0x76ab10`: the parent's stratum and its level plus one, until the first level
// stamp.
let (strata, level) = model
.arena
.frame(world)
.map(|f| (f.strata, f.level))
.unwrap_or_default();
model.arena.set_frame_strata(frame, strata);
model
.arena
.set_frame_level(frame, level.saturating_add(1), false);
// The backdrop first (`0x4b0a35` → `0x76a5d0`): edge mask 0xff and tile on (`0x4b09bf`,
// `0x4b09c6`), one border unit for the four insets (`0x4b09ec`-`0x4b09f5`), the edge size
// (`0x4b0a12`, `+0x48`) and the tile size (`0x4b0a2f`, `+0x4c`).
backdrop::install(
model,
frame,
Some(Backdrop {
bg_file: Some(BG_TEXTURE.to_string()),
edge_file: Some(EDGE_TEXTURE.to_string()),
tile: true,
tile_size: g.border,
edge_size: g.border,
insets: uniform(g.border),
..Backdrop::default()
}),
);
// Then the tail on ARTWORK (`0x4b0a57`, layer 2) and the text on ARTWORK (`0x4b0b14`, layer 2).
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::Artwork, 0)
.expect("live bubble");
// The `NAMEPLATE_FONT` global's font with no flags (`0x4b0b2b`-`0x4b0b42`): no outline, no
// shadow.
let data = model.region_data.entry(text).or_default();
data.font_path = font;
data.font_explicit.face = true;
model.touch_layout_frame(frame);
model.touch_layout_region(tail);
model.touch_layout_region(text);
Bubble {
frame,
tail,
text,
line: None,
shown: true,
seat: None,
laid_out: None,
}
}
fn uniform(v: f32) -> Insets {
Insets {
left: v,
right: v,
top: v,
bottom: v,
}
}
/// Write the window-derived sizes and anchors: the backdrop's unit, the tail (`0x4b0a7d`-
/// `0x4b0af1`), the font height and the frame's margin anchors on the text.
fn lay_out(model: &mut Model, i: usize, g: BubbleGeometry) {
let b = &model.chat_bubbles.bubbles[i];
let (frame, tail, text) = (b.frame, b.tail, b.text);
backdrop::set_metrics(model, frame, g.border, g.border, uniform(g.border));
let frame_id = model.frame_id(frame);
let text_id = model.region_id(text);
// A border-unit square, its TOPRIGHT on the frame's BOTTOM lifted a quarter unit.
let tail_data = model.region_data.entry(tail).or_default();
tail_data.size = Some((g.border, g.border));
tail_data.anchors = vec![Anchor::new(
Point::TopRight,
frame_id,
Point::Bottom,
0.0,
g.border / 4.0,
)];
model.region_data.entry(text).or_default().font_height = Some(g.text_height);
model.touch_measure(text);
// The frame hugs the text: TOPLEFT and BOTTOMRIGHT on the text's, out by the margin.
let input = model.layout_inputs.entry(frame).or_default();
input.anchors = vec![
Anchor::new(Point::TopLeft, text_id, Point::TopLeft, -g.margin, g.margin),
Anchor::new(
Point::BottomRight,
text_id,
Point::BottomRight,
g.margin,
-g.margin,
),
];
// Once per geometry change: each node it wrote re-derives.
model.touch_layout_frame(frame);
model.touch_layout_region(tail);
model.touch_layout_region(text);
model.chat_bubbles.bubbles[i].laid_out = Some(g);
}
/// Show a new line (`0x4b1600`): its text and colour, its width fitted, then `Show` through the
/// fade-in's start (`0x4b17f7` → `0x4b0ea0` → vtable `+0x88`), which fires `OnShow` on a reused,
/// hidden frame.
fn set_line(
model: &mut Model,
i: usize,
state: &BubbleState,
g: BubbleGeometry,
visibility: &mut Vec<(FrameHandle, bool)>,
) {
let b = &mut model.chat_bubbles.bubbles[i];
b.line = Some((state.key, state.line));
let (frame, text, was_shown) = (b.frame, b.text, b.shown);
b.shown = true;
let c = state.colour;
let data = model.region_data.entry(text).or_default();
data.text = Some(state.text.clone());
data.vertex_color = Some([c[0], c[1], c[2], 1.0]);
fit_width(model, i, g);
if !was_shown {
visibility.push((frame, true));
}
}
/// The width law (`0x4b16c5`-`0x4b1796`): with width and height cleared, the text's own width;
/// past the cap the cap, so it wraps, else at least two border units. The height stays cleared,
/// so it is the wrapped text's.
fn fit_width(model: &mut Model, i: usize, g: BubbleGeometry) {
let text = model.chat_bubbles.bubbles[i].text;
if let Some(d) = model.region_data.get_mut(&text) {
d.size = None;
}
model.touch_measure(text);
super::measure::ensure_measured_in(model, text);
let natural = super::region::virtual_span(model, text).0;
let floor = 2.0 * g.border;
let width = if natural > g.wrap_cap {
g.wrap_cap
} else {
natural.max(floor)
};
if let Some(d) = model.region_data.get_mut(&text) {
d.size = Some((width, 0.0));
}
model.touch_measure(text);
model.touch_layout_region(text);
}
/// The per-frame write, each property only when it moved.
fn drive(
model: &mut Model,
i: usize,
world_id: u32,
rank: usize,
state: &BubbleState,
g: BubbleGeometry,
visibility: &mut Vec<(FrameHandle, bool)>,
) {
let b = &model.chat_bubbles.bubbles[i];
let (frame, text, shown, last_seat) = (b.frame, b.text, b.shown, b.seat);
// `0x76a4f0(2 + i, 0)` down the list: no carry to children (`0x4b1306`).
let level = u16::try_from(rank)
.unwrap_or(u16::MAX)
.saturating_add(FIRST_LEVEL);
model.arena.set_frame_level(frame, level, false);
if model
.arena
.frame(frame)
.is_some_and(|f| f.alpha != state.alpha)
{
model.arena.set_alpha(frame, state.alpha);
}
if shown != state.shown {
model.chat_bubbles.bubbles[i].shown = state.shown;
visibility.push((frame, state.shown));
}
// The seat (`0x4b14a0`): the text's BOTTOM on the `WorldFrame`'s BOTTOMLEFT, re-anchored only
// when the snapped point moved. Deviation: the frame's bottom-left origin is snapped onto the
// device grid so the border art blits 1:1; the reference ceils the anchor point itself onto
// it (`0x7666a0`, `0x766750`).
let width = model
.region_data
.get(&text)
.and_then(|d| d.size)
.map_or(0.0, |s| s.0)
+ 2.0 * g.margin;
let snap = |v: f32| {
if g.device_px > 0.0 {
(v * g.device_px).round() / g.device_px
} else {
v
}
};
let seat = (
snap(state.seat.0 - width * 0.5) + width * 0.5,
snap(state.seat.1 - g.margin) + g.margin,
);
if last_seat != Some(seat) {
let data = model.region_data.entry(text).or_default();
data.anchors = vec![Anchor::new(
Point::Bottom,
world_id,
Point::BottomLeft,
seat.0,
seat.1,
)];
model.touch_layout_region(text);
model.chat_bubbles.bubbles[i].seat = Some(seat);
}
}
+6 -1
View File
@@ -36,10 +36,15 @@ pub trait TextMeasure {
/// one is left alone, so a per-frame poll does not re-measure.
pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
ensure_measured_in(&mut model, rh);
}
/// [`ensure_measured`] on a borrowed model, for the engine's own widgets.
pub(super) fn ensure_measured_in(model: &mut Model, rh: RegionHandle) {
if model.measurer.is_none() {
return;
}
let Some(req) = super::layout::measure_request_for(&mut model, rh) else {
let Some(req) = super::layout::measure_request_for(model, rh) else {
return; // not a FontString, no text, or already current
};
// Taken out and put back: the measurer needs `&mut self` while the model is borrowed, and it
+3 -1
View File
@@ -34,6 +34,8 @@ mod calls;
mod camera_view;
mod channel;
mod char_stats;
pub mod chat_bubble;
pub use chat_bubble::{BubbleGeometry, BubbleState};
mod chat_misc;
mod chat_send;
mod chat_types;
@@ -158,7 +160,7 @@ pub use auction::{
AuctionStartRequest, AuctionState, AuctionSubCategory, BIDDER, LIST, OWNER, SORT_KEYS,
};
pub use aura::{AuraState, TrackingState};
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use backdrop::{Backdrop, Insets};
pub use bank::BankState;
pub use battlefield_positions::{BattlefieldFlagView, BattlefieldPositionView};
pub use battlefield_queue::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot};
+3
View File
@@ -926,6 +926,8 @@ 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,
/// Chat bubble `Frame`s under `WorldFrame`, pooled the same way.
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
@@ -1422,6 +1424,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),
+4 -4
View File
@@ -760,11 +760,11 @@ fn font_string(model: &mut Model, frame: FrameHandle, justify_v: JustifyV) -> Re
rh
}
/// Whether a text's owner is a plate. A plate's rect snaps to device pixels as it slides, and its
/// text must follow that rect, not the UI's coarser grid, or it jumps. Asked of the frame so an
/// addon's strings on the plate get it too; a string on the plate's health bar does not.
/// Whether a text's owner is a plate or a chat bubble. Their rects snap to device pixels as they
/// slide, and their text must follow that rect, not the UI's coarser grid, or it jumps. Asked of
/// the frame so an addon's strings on one get it too; a string on the plate's health bar does not.
pub(super) fn is_world_seated(model: &Model, frame: FrameHandle) -> bool {
model.nameplates.by_frame.contains_key(&frame)
model.nameplates.by_frame.contains_key(&frame) || super::chat_bubble::is_bubble(model, frame)
}
/// Whether this is the plate's own glow art on its ADD mode, which the quad walk
@@ -2,6 +2,7 @@
//! `0x77e5f0`, and its pieces, `0x77e8d0`).
use super::common::script;
use crate::script::backdrop::pieces;
use crate::script::*;
// SetBackdropColor tints only the bg, SetBackdropBorderColor the 8 border pieces.
@@ -0,0 +1,472 @@
//! The chat bubble ABI as a bubble addon reads it: pfUI's `modules/bubbles.lua` walks
//! `WorldFrame:GetChildren()`, takes an unnamed child whose first region's texture is
//! `ChatBubble-Background`, blanks every Texture, reads the FontString's text and colour, parents
//! its own frame to the bubble with `SetAllPoints` and hooks `OnShow`.
use crate::script::{
BubbleGeometry, BubbleState, MeasureRequest, QuadContent, TextMeasure, UiScript,
};
/// Each character `PER_CHAR` wide and each line `LINE_H` tall, wrapped greedily.
struct BlockFont;
const PER_CHAR: f32 = 6.0;
const LINE_H: f32 = 13.0;
impl TextMeasure for BlockFont {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
let natural = req.text.chars().count() as f32 * PER_CHAR;
match req.wrap_width {
Some(w) if w > 0.0 && natural > w => {
let rows = (natural / w).ceil();
(w, rows * LINE_H, natural)
}
_ => (natural, LINE_H, natural),
}
}
}
/// A VM with a `WorldFrame` to hang bubbles off and a font engine to measure their text.
fn vm() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(BlockFont));
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
.unwrap();
s.resolve();
s
}
const BORDER: f32 = 16.0;
const MARGIN: f32 = 13.0;
fn geometry() -> BubbleGeometry {
BubbleGeometry {
border: BORDER,
margin: MARGIN,
wrap_cap: 256.0,
text_height: LINE_H,
device_px: 1.0,
}
}
fn say(key: u64, line: u64, text: &str) -> BubbleState {
BubbleState {
key,
line,
text: text.to_string(),
colour: [1.0, 1.0, 1.0],
shown: true,
alpha: 1.0,
seat: (500.0, 300.0),
}
}
/// Drive the bubbles and settle the layout, the way one app frame does.
fn drive(s: &mut UiScript, states: &[BubbleState]) {
s.sync_chat_bubbles(geometry(), states);
s.resolve();
}
/// A say puts an anonymous `Frame` under the `WorldFrame`, in its stratum, shown.
#[test]
fn a_say_puts_an_anonymous_frame_in_the_worldframe_child_list() {
let mut s = vm();
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
0
);
drive(&mut s, &[say(7, 1, "Well met!")]);
assert_eq!(
s.eval::<i64>("return WorldFrame:GetNumChildren()").unwrap(),
1
);
assert!(s
.eval::<bool>(
r#"local b = WorldFrame:GetChildren()
return b:GetObjectType() == "Frame" and b:GetName() == nil
and b:IsShown() == 1 and b:GetParent() == WorldFrame
and b:GetFrameStrata() == WorldFrame:GetFrameStrata()
and b:GetFrameLevel() == 2"#
)
.unwrap());
}
/// Eleven regions in creation order: the backdrop's background and eight border pieces
/// (`0x4b0a35` → `0x77e8d0`), the tail (`0x4b0a57`), the text (`0x4b0b14`).
#[test]
fn the_eleven_regions_run_backdrop_then_tail_then_text() {
let mut s = vm();
drive(&mut s, &[say(7, 1, "Well met!")]);
let rows: Vec<String> = s
.eval::<Vec<String>>(
r#"local b = WorldFrame:GetChildren()
local out, regions = {}, { b:GetRegions() }
for i = 1, table.getn(regions) do
local r = regions[i]
local tex = r:GetObjectType() == "Texture" and (r:GetTexture() or "nil") or "-"
table.insert(out, r:GetObjectType() .. " " .. r:GetDrawLayer() .. " " .. tex)
end
return out"#,
)
.unwrap();
let mut want =
vec!["Texture BACKGROUND Interface\\Tooltips\\ChatBubble-Background".to_string()];
want.extend(std::iter::repeat_n(
"Texture BORDER Interface\\Tooltips\\ChatBubble-Backdrop".to_string(),
8,
));
want.push("Texture ARTWORK Interface\\Tooltips\\ChatBubble-Tail".to_string());
want.push("FontString ARTWORK -".to_string());
assert_eq!(rows, want);
}
/// `0x4b1600`: the line's text in its chat-type colour, wrapped at the cap and floored at two
/// border units. The frame hugs the text by the margin (`0x4b0b6b`, `0x4b0b94`), the text's
/// BOTTOM sits on the seat (`0x4b157a`), and the tail hangs TOPRIGHT from the frame's BOTTOM
/// lifted a quarter border (`0x4b0af1`).
#[test]
fn the_text_is_the_line_in_its_colour_and_the_frame_hugs_it() {
let mut s = vm();
let mut yell = say(7, 1, "Well met!");
yell.colour = [1.0, 0.25, 0.25];
drive(&mut s, &[yell]);
let (text, r, g, b): (String, f32, f32, f32) = s
.eval(
r#"local regions = { (WorldFrame:GetChildren()):GetRegions() }
local fs = regions[11]
local r, g, b = fs:GetTextColor()
return fs:GetText(), r, g, b"#,
)
.unwrap();
assert_eq!(text, "Well met!");
assert_eq!((r, g, b), (1.0, 0.25, 0.25));
let rect = |s: &UiScript, what: &str| -> (f32, f32, f32, f32) {
s.eval(&format!(
"local b = WorldFrame:GetChildren() local regions = {{ b:GetRegions() }}
local o = {what}
return o:GetLeft(), o:GetBottom(), o:GetWidth(), o:GetHeight()"
))
.unwrap()
};
// Nine characters, 54 wide: over the two-border floor and under the cap.
let fs = rect(&s, "regions[11]");
assert_eq!(
fs,
(473.0, 300.0, 54.0, LINE_H),
"the text's BOTTOM on the seat"
);
let frame = rect(&s, "b");
assert_eq!(
frame,
(460.0, 287.0, 54.0 + 2.0 * MARGIN, LINE_H + 2.0 * MARGIN),
"the margin on all four sides"
);
let tail = rect(&s, "regions[10]");
assert_eq!(
tail,
(
500.0 - BORDER,
287.0 + BORDER / 4.0 - BORDER,
BORDER,
BORDER
),
"TOPRIGHT on the frame's BOTTOM, a quarter border up"
);
// Short text takes the floor, long text the cap and a second line.
drive(&mut s, &[say(7, 2, "hi")]);
assert_eq!(rect(&s, "regions[11]").2, 2.0 * BORDER);
let long = "x".repeat(50);
drive(&mut s, &[say(7, 3, &long)]);
let fs = rect(&s, "regions[11]");
assert_eq!((fs.2, fs.3), (256.0, 2.0 * LINE_H));
// The frame's left edge lands on the device grid, wherever the seat falls.
let mut off = say(7, 4, "Well met!");
off.seat = (500.3, 300.3);
s.sync_chat_bubbles(
BubbleGeometry {
device_px: 2.0,
..geometry()
},
&[off],
);
s.resolve();
let frame = rect(&s, "b");
assert_eq!(
((frame.0 * 2.0).fract(), (frame.1 * 2.0).fract()),
(0.0, 0.0)
);
}
/// pfUI's skin: blank every Texture, keep the text, cover the bubble with its own frame. Nothing
/// of the stock art draws afterwards, and the blanking survives the bubble's reuse.
#[test]
fn a_pfui_skin_leaves_none_of_the_stock_art_drawn() {
let mut s = vm();
drive(&mut s, &[say(7, 1, "Well met!")]);
let stock = |s: &UiScript| -> Vec<String> {
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains("ChatBubble") => {
Some(p.clone())
}
_ => None,
})
.collect()
};
assert_eq!(
stock(&s).len(),
10,
"the stock bubble draws its ten textures"
);
s.run(
r#"
function PfSkin()
local kids = { WorldFrame:GetChildren() }
for i = 1, table.getn(kids) do
local f = kids[i]
local first = not f:GetName() and f:GetRegions()
if first and first.GetTexture
and first:GetTexture() == "Interface\\Tooltips\\ChatBubble-Background"
and not f.pfSkin then
local regions = { f:GetRegions() }
for j = 1, table.getn(regions) do
local r = regions[j]
if r:GetObjectType() == "Texture" then r:SetTexture(nil)
elseif r:GetObjectType() == "FontString" then f.pfText = r end
end
f.pfSkin = CreateFrame("Frame", nil, f)
f.pfSkin:SetAllPoints(f)
PfRead = f.pfText:GetText()
PfR, PfG, PfB = f.pfText:GetTextColor()
f:SetScript("OnShow", function() PfShows = (PfShows or 0) + 1 end)
end
end
end
PfSkin()
"#,
)
.unwrap();
s.resolve();
assert_eq!(s.eval::<String>("return PfRead").unwrap(), "Well met!");
assert!(stock(&s).is_empty(), "every stock texture blanked");
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Well met!"
)),
"the text still draws"
);
assert!(s
.eval::<bool>(
r#"local b = WorldFrame:GetChildren()
return b.pfSkin:GetLeft() == b:GetLeft() and b.pfSkin:GetTop() == b:GetTop()
and b.pfSkin:GetWidth() == b:GetWidth() and b.pfSkin:GetHeight() == b:GetHeight()"#
)
.unwrap());
// The line ends and a new one reuses the skinned bubble: OnShow fires, the art stays blank.
drive(&mut s, &[]);
drive(&mut s, &[say(9, 2, "Hail.")]);
assert_eq!(s.eval::<i64>("return PfShows").unwrap(), 1);
assert!(
stock(&s).is_empty(),
"a reused bubble keeps the skin's blanking"
);
}
/// The pool (`0x4b1870`): a finished bubble is hidden (`0x4b1590`), keeps its place in the child
/// list and its parent, and the next line takes the oldest retired one back, firing `OnShow`
/// (`0x4b0ea0`'s `Show`).
#[test]
fn a_bubble_is_reused_never_reparented_and_hidden_when_done() {
let mut s = vm();
drive(&mut s, &[say(1, 1, "one"), say(2, 1, "two")]);
s.run(
r#"Shows, Hides = {}, {}
local a, b = WorldFrame:GetChildren()
a.mark, b.mark = "A", "B"
for _, f in ipairs({ a, b }) do
local f = f
f:SetScript("OnShow", function() table.insert(Shows, f.mark) end)
f:SetScript("OnHide", function() table.insert(Hides, f.mark) end)
end"#,
)
.unwrap();
// The first line ends, then the second: each frame hides, keeps its parent and its slot.
drive(&mut s, &[say(2, 1, "two")]);
drive(&mut s, &[]);
assert!(s
.eval::<bool>(
r#"local a, b = WorldFrame:GetChildren()
return WorldFrame:GetNumChildren() == 2 and a.mark == "A" and b.mark == "B"
and not a:IsShown() and not b:IsShown()
and a:GetParent() == WorldFrame and b:GetParent() == WorldFrame
and Hides[1] == "A" and Hides[2] == "B""#
)
.unwrap());
// A new line takes the bubble retired first, and its OnShow fires.
drive(&mut s, &[say(3, 1, "three")]);
assert!(s
.eval::<bool>(
r#"local a, b = WorldFrame:GetChildren()
local regions = { a:GetRegions() }
return a:IsShown() == 1 and not b:IsShown() and Shows[1] == "A"
and regions[11]:GetText() == "three""#
)
.unwrap());
assert_eq!(
s.chat_bubble_pool_len(),
2,
"no frame was made for the reuse"
);
// A new line from the same speaker replaces the bubble: its frame goes to the free list's
// tail and the line takes the head, here the frame retired before it (`0x608bb4`, `0x608bbf`).
drive(&mut s, &[say(3, 2, "again")]);
assert!(s
.eval::<bool>(
r#"local a, b = WorldFrame:GetChildren()
return Hides[3] == "A" and Shows[2] == "B" and not a:IsShown()"#
)
.unwrap());
// Out of range the fade ends hidden; back in range it shows again (`0x4b0d9f`, `0x4b0ea0`).
let mut away = say(3, 2, "again");
away.shown = false;
drive(&mut s, &[away]);
assert!(s
.eval::<bool>(
r#"local _, b = WorldFrame:GetChildren() return not b:IsShown() and Hides[4] == "B""#
)
.unwrap());
drive(&mut s, &[say(3, 2, "again")]);
assert!(s
.eval::<bool>(
r#"local _, b = WorldFrame:GetChildren() return b:IsShown() == 1 and Shows[3] == "B""#
)
.unwrap());
assert_eq!(s.chat_bubble_pool_len(), 2);
}
/// The per-frame pass (`0x4b12d5`-`0x4b1312`): levels 2, 3, … down the list, farthest first,
/// without carrying children (`0x76a4f0(level, 0)`); the fade is the frame's own alpha.
#[test]
fn bubbles_take_levels_from_two_in_list_order_and_their_fade_alpha() {
let mut s = vm();
let mut near = say(1, 1, "near");
near.alpha = 0.5;
drive(&mut s, &[say(2, 1, "far"), near.clone()]);
let levels = |s: &UiScript| -> (i64, i64) {
s.eval(
r#"local far, near = WorldFrame:GetChildren()
return far:GetFrameLevel(), near:GetFrameLevel()"#,
)
.unwrap()
};
assert_eq!(levels(&s), (2, 3));
assert_eq!(
s.eval::<f32>("local _, near = WorldFrame:GetChildren() return near:GetAlpha()")
.unwrap(),
0.5
);
drive(&mut s, &[near, say(2, 1, "far")]);
assert_eq!(
levels(&s),
(3, 2),
"the order is the app's, re-stamped every frame"
);
}
/// The text slides with the seat on the device grid, so it skips the UI grid snap, as a plate's.
#[test]
fn the_bubble_text_carries_the_world_seat() {
let mut s = vm();
drive(&mut s, &[say(7, 1, "Well met!")]);
assert!(s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), world_seat: true, .. } if t == "Well met!"
)));
}
/// A window resize re-sizes a live bubble in place: the backdrop's unit, the tail, the margin and
/// the width floor follow, and its eleven regions stay the same objects.
#[test]
fn a_resize_re_sizes_the_live_bubble_in_place() {
let mut s = vm();
drive(&mut s, &[say(7, 1, "hi")]);
s.run(r#"local r = { (WorldFrame:GetChildren()):GetRegions() } First, Tail = r[1], r[10]"#)
.unwrap();
let wider = BubbleGeometry {
border: 20.0,
margin: 15.0,
..geometry()
};
s.sync_chat_bubbles(wider, &[say(7, 1, "hi")]);
s.resolve();
let (same, frame_w, tail_w, edge_w): (bool, f32, f32, f32) = s
.eval(
r#"local b = WorldFrame:GetChildren() local r = { b:GetRegions() }
return r[1] == First and r[10] == Tail, b:GetWidth(), r[10]:GetWidth(),
r[6]:GetWidth()"#,
)
.unwrap();
assert!(same, "the regions are kept, not rebuilt");
assert_eq!(frame_w, 2.0 * 20.0 + 2.0 * 15.0, "the new floor and margin");
assert_eq!(tail_w, 20.0);
assert_eq!(edge_w, 20.0, "the corner piece takes the new edge size");
}
/// Lines that end together go back to the pool in the active list's order, farthest first, as
/// the reference's walk destroys them (`0x4b1138`); the next line takes the first of them.
#[test]
fn ended_lines_retire_farthest_first() {
let mut s = vm();
// Pooled A then B, but B is the farther, so first in the list.
drive(&mut s, &[say(1, 1, "a")]);
drive(&mut s, &[say(2, 1, "b"), say(1, 1, "a")]);
s.run(r#"local a, b = WorldFrame:GetChildren() a.mark, b.mark = "A", "B""#)
.unwrap();
drive(&mut s, &[]);
drive(&mut s, &[say(3, 1, "c")]);
assert_eq!(
s.eval::<String>(
r#"local a, b = WorldFrame:GetChildren()
if a:IsShown() then return a.mark end return b.mark"#
)
.unwrap(),
"B",
"the farther bubble retired first, so it is reused first"
);
}
/// The text's font is the `NAMEPLATE_FONT` global's, read when the bubble is built (`0x4b0b36` →
/// `0x703bf0`); no global, no font.
#[test]
fn the_text_takes_the_nameplate_font_global() {
let mut s = vm();
s.run(r#"NAMEPLATE_FONT = "Fonts\\ARIALN.TTF""#).unwrap();
drive(&mut s, &[say(1, 1, "a")]);
assert_eq!(
s.eval::<String>(
"local r = { (WorldFrame:GetChildren()):GetRegions() } return (r[11]:GetFont())"
)
.unwrap(),
"Fonts\\ARIALN.TTF"
);
let mut bare = vm();
drive(&mut bare, &[say(1, 1, "a")]);
assert!(bare
.eval::<bool>(
"local r = { (WorldFrame:GetChildren()):GetRegions() } return r[11]:GetFont() == nil"
)
.unwrap());
}
@@ -9,6 +9,7 @@ mod backdrop;
mod button;
mod capture;
mod channel;
mod chat_bubble;
mod common;
mod cooldown;
mod create_frame_template;
-1
View File
@@ -171,7 +171,6 @@
- `WOW_BLOB_VIS_UID` — benilla-world/src/terrain_stream/merge.rs
- `WOW_BOOTH_DUMP` — benilla-app/src/portrait/test_bake.rs
- `WOW_BOOTH_LOG` — benilla-app/src/portrait/mod.rs
- `WOW_BUBBLE_TRACE` — benilla-app/src/chat_bubble.rs
- `WOW_CAM_CHANGED` — benilla-app/src/perf/mod.rs
- `WOW_CAM_DUMP` — benilla-app/src/player/camera.rs
- `WOW_CAPTURE` — benilla-app/src/capture/mod.rs, benilla-app/src/capture/scenarios.rs, benilla-app/src/char_select/addons.rs, benilla-app/src/cvars/mod.rs, benilla-app/src/lib.rs, benilla-app/src/local_state.rs, benilla-app/src/login/mod.rs, benilla-app/src/name_persist.rs, benilla-app/src/net.rs, benilla-app/src/player/camera_saved.rs, benilla-app/src/run_mode.rs, benilla-app/src/sound/mod.rs, benilla-app/src/ui_chat/settings.rs, benilla-app/src/ui_chat/tests.rs, benilla-app/src/ui_macro/tests.rs, benilla-app/src/ui_saved.rs, benilla-app/src/ui_script/addons.rs, benilla-app/src/ui_script/layer_tests.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/ui_script/world_entry_tests.rs, benilla-app/src/video.rs, benilla-world/src/bgwin.rs, benilla-world/src/clouds/mod.rs, benilla-world/src/dev_state.rs