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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user