From eec1804d37cd6a689ff11cc2e4b52c1901cc756f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:06:36 +0700 Subject: [PATCH] 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 --- crates/benilla-app/src/chat_bubble.rs | 1111 +++++++++-------- crates/benilla-app/src/ui_chat.rs | 2 + crates/benilla-app/src/ui_chat/bubbles.rs | 19 + crates/benilla-app/src/ui_pass.rs | 9 - .../benilla-app/src/ui_script/extract/mod.rs | 5 +- .../benilla-assets/src/world_assets/images.rs | 2 +- crates/benilla-assets/src/world_assets/mod.rs | 9 - crates/benilla-ui/src/script/backdrop.rs | 157 +-- crates/benilla-ui/src/script/chat_bubble.rs | 463 +++++++ crates/benilla-ui/src/script/measure.rs | 7 +- crates/benilla-ui/src/script/mod.rs | 4 +- crates/benilla-ui/src/script/model.rs | 3 + crates/benilla-ui/src/script/nameplate.rs | 8 +- .../benilla-ui/src/script/tests/backdrop.rs | 1 + .../src/script/tests/chat_bubble.rs | 472 +++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + docs/MAP.md | 1 - 17 files changed, 1600 insertions(+), 674 deletions(-) create mode 100644 crates/benilla-app/src/ui_chat/bubbles.rs create mode 100644 crates/benilla-ui/src/script/chat_bubble.rs create mode 100644 crates/benilla-ui/src/script/tests/chat_bubble.rs diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 1a91b740..71565963 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -1,13 +1,19 @@ -//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker, a -//! 2-D overlay like the V-plate ([`crate::vplates`]). +//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker. This +//! file owns the world side (gate, lifetime, fade, anchor, projection, stacking order, sizes); the +//! widgets, real `WorldFrame` children an addon can skin, live in +//! [`benilla_ui::script::chat_bubble`]. //! //! - Spawn (`0x608ac0`, from the `SMSG_MESSAGECHAT` display path): the sender resolves to a live //! unit, its CVar is on, the text is non-empty, the unit carries no V-plate (`0x608adc`; a live //! bubble in turn hides the overhead name, [`BubblesActive`]), and the local player is within //! 20 yd (3-D). Self is not excluded. A new line replaces the old bubble, never queues. -//! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, bottom-seated, growing upward. The -//! height is the Stand sequence box's Z extent from the model header (`0x711a20`), not the posed -//! attachment the overhead name reads (`0x608640`), latched once per chat line. +//! - Lifetime and fade (`0x4b0c30`): a 250 ms fade in, the line's [`duration_ms`], a 250 ms fade +//! out, then the frame goes back to the pool. Out of range, or with the speaker not drawn in the +//! last render ([`speaker_drawn`]), it fades out and hides, and fades back in on return. +//! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, the text's BOTTOM on the projected +//! point (`0x4b157a`), growing upward. The height is the Stand sequence box's Z extent from the +//! model header (`0x711a20`), not the posed attachment the overhead name reads (`0x608640`), +//! latched once per chat line. //! - Stacking (`0x4b1060`): every frame the bubbles sort farthest-first by camera distance and //! take frame levels 2, 3, 4…, so whole cards stack with the nearest speaker's on top. //! @@ -17,25 +23,20 @@ //! Deviation: sizes ride the plates' damped diagonal basis ([`plate_basis`]), not the unbounded //! reference law, so bubble text and plate text keep one em at every window size. //! -//! Not built: the reference also fades a bubble whose speaker's model data is not resident or whose -//! scene-entity flag is clear (`0x7103d0`/`0x6704c0`); only distance is re-tested here. +//! Not built: the reference also ends a bubble whose speaker's model data is not resident +//! (`0x7103d0`); here a speaker with no model yet only counts as not drawn. 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::{BubbleGeometry, BubbleState}; 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::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates}; -use benilla_assets::{AssetSet, WorldAssets}; +use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates}; use benilla_world::view::WorldCamera; /// The two bubble CVars (registrar `0x603280`), booting at the reference's defaults. @@ -56,40 +57,22 @@ 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 0.7 yd lift over the anchor height (`[0x7ffd7c]`). const LIFT: f32 = 0.7; -/// The spawn and per-frame range gate, 20 yd squared (`[0x806798]`, the plates' too). +/// The range gate, 20 yd squared (`[0x806798]`, the plates' too): a spawn passes at or under it +/// (`0x608ba0`), a live bubble stays in range only under it (`0x4b0cd2`). const MAX_DIST_SQ: f32 = 400.0; -/// The 250 ms linear fade, in and out (`0x4b0ea0`/`0x4b0ee0`). -const FADE_SECS: f32 = 0.25; -/// `NAMEPLATE_FONT` at 0.01 gx, the plate name's em. +/// The linear fade, in and out, in ms (`0x4b0d3e`, `0x4b0d78`). +const FADE_MS: u64 = 250; +/// `NAMEPLATE_FONT` at 0.01 gx (`0x4b0b21`), the plate name's em. const TEXT_H: f32 = 0.01; -/// The body margin around the text layout, all four sides (`0x3c23d70a`). +/// The frame's margin around the text, all four sides (`0x3c23d70a`). const MARGIN: f32 = 0.01; /// The text wrap hard cap, gx (`[0x80679c]` = 0.2). 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 { use ChatEventKind as K; @@ -100,87 +83,73 @@ fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option { } } -/// The word count (`0x4b1810`): maximal runs of characters other than space and tab. -fn word_count(text: &str) -> u32 { - let mut words = 0u32; - let mut in_word = false; - for b in text.bytes() { - let sep = b == b' ' || b == b'\t'; - if !sep && !in_word { - words += 1; - } - in_word = !sep; - } - words -} - -/// The lifetime (`0x4b1810`): `base + perWord·(words−1)` ms, 2750/750 for others and 1500/500 -/// for the local player. -fn duration_secs(words: u32, is_self: bool) -> f32 { - if words == 0 { - return 0.0; +/// The lifetime in ms (`0x4b1810`) over the displayed text: the base, plus the per-word step for +/// each space or tab that ends a word, 2750/750 for others and 1500/500 for the local player. An +/// empty text lasts 0; separators alone, the base. +fn duration_ms(text: &str, is_self: bool) -> u64 { + if text.is_empty() { + return 0; } let (base, per) = if is_self { (1500, 500) } else { (2750, 750) }; - (base + per * (words - 1)) as f32 / 1000.0 + let mut ms = base; + // Starts as if after a separator, so a leading run adds nothing. + let mut after_sep = true; + for b in text.bytes() { + let sep = b == b' ' || b == b'\t'; + if sep && !after_sep { + ms += per; + } + after_sep = sep; + } + ms } -/// Bubble text is plain, coloured only by chat type: colour escapes and hyperlink wrappers strip -/// (their display text stays), and `||` stays escaped for the glyph layout's markup parser. +/// The bubble's copy of the line (`0x4b1600`, into a 0x800-byte buffer): `||` stays escaped for the +/// text's markup parser; any other `|` jumps to the next `[`, or ends the text if there is none; +/// from there the brackets drop, and a `|h` then a `|r` right after the `]` skip. So a link shows +/// its bare name and a colour code with no link after it cuts the line short. fn sanitize(text: &str) -> String { + const BUF: usize = 0x800 - 1; let b = text.as_bytes(); - let mut out = String::with_capacity(text.len()); + let mut out: Vec = Vec::with_capacity(b.len()); let mut i = 0; - while i < b.len() { + while i < b.len() && out.len() < BUF { if b[i] != b'|' { - // Copy the whole non-escape run at once (UTF-8 safe: '|' is single-byte). - let start = i; - while i < b.len() && b[i] != b'|' { - i += 1; - } - out.push_str(&text[start..i]); + out.push(b[i]); + i += 1; continue; } - match b.get(i + 1) { - Some(b'|') => { - out.push_str("||"); - i += 2; - } - Some(b'c') | Some(b'C') if i + 10 <= b.len() => i += 10, - Some(b'r') | Some(b'R') => i += 2, - Some(b'H') => { - // Skip the opener through its `|h`; the closing `|h` drops in the arm below. - i += 2; - while i < b.len() && !(b[i] == b'|' && b.get(i + 1) == Some(&b'h')) { + if b.get(i + 1) == Some(&b'|') { + out.extend_from_slice(b"||"); + i += 2; + continue; + } + let Some(open) = b[i..].iter().position(|&c| c == b'[') else { + break; + }; + i += open; + while i < b.len() { + match b[i] { + b'[' => i += 1, + b']' => { + i += 1; + for skip in [b"|h", b"|r"] { + if b[i..].starts_with(skip) { + i += 2; + } + } + break; + } + c => { + out.push(c); i += 1; } - i += 2; - } - Some(b'h') => i += 2, - // A dangling or unknown escape renders as a literal pipe. - _ => { - out.push_str("||"); - i += 1; } } } - 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 { - Vec2::new( - device_snap(seat.x - w * 0.5, scale), - device_snap(seat.y, scale), - ) + out.truncate(BUF); + // Cut only at ASCII bytes, so whole characters, but for the buffer cap. + String::from_utf8_lossy(&out).into_owned() } /// The border unit in logical px: 16/1024 of the screen width, in the damped size basis. @@ -221,69 +190,161 @@ impl BubbleQueue { } } +/// `[bubble+0x320]` bit 1: fading in. +const FADING_IN: u8 = 0x1; +/// Bit 2: fading out. +const FADING_OUT: u8 = 0x2; +/// Bit 4: the fade out is the last, at the end of the line's life. +const FINAL: u8 = 0x4; + +/// A bubble's fade (`0x4b0ea0`, `0x4b0ee0`, the middle of `0x4b0c30`): the flags, the fade's start +/// time, the frame's alpha byte and its shown bit. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Fade { + flags: u8, + /// `[bubble+0x33c]`, the current fade's start. + since: u64, + /// The frame's alpha byte (`+0xc8`); a new frame starts opaque. + alpha: u8, + /// The own-shown bit (`+0xd0`). + shown: bool, +} + +impl Fade { + /// A line's start (`0x4b17da`, then `0x4b17f7`): the flags reset, then the fade in. The alpha + /// is a new frame's (`0x7690d2`); a reused frame keeps what its last fade left, which this + /// does not track, and which matters only to a line whose first frame fades out. + fn spawn(now: u64) -> Self { + let mut f = Fade { + flags: 0, + since: now, + alpha: 255, + shown: true, + }; + f.fade_in(now); + f + } + + /// `0x4b0ea0`: unless fading in or on the last fade out, start fading in and show. + fn fade_in(&mut self, now: u64) { + if self.flags & (FADING_IN | FINAL) != 0 { + return; + } + self.flags = (self.flags & !FADING_OUT) | FADING_IN; + self.since = now; + self.shown = true; + } + + /// `0x4b0ee0`: unless on the last fade out, mark it final if asked, and start fading out + /// unless already. + fn fade_out(&mut self, now: u64, last: bool) { + if self.flags & FINAL != 0 { + return; + } + if last { + self.flags |= FINAL; + } + if self.flags & FADING_OUT == 0 { + self.flags = (self.flags & !FADING_IN) | FADING_OUT; + self.since = now; + } + } + + /// One frame of `0x4b0c30` past its residency gates; `false` when the last fade out ends and + /// the bubble goes back to the pool. + fn tick(&mut self, now: u64, in_range: bool, born: u64, duration: u64) -> bool { + if !in_range { + if self.alpha != 0 { + self.fade_out(now, false); + } + } else if self.alpha != 255 { + self.fade_in(now); + } + if self.flags & FADING_OUT == 0 && now.saturating_sub(born) > duration + FADE_MS { + self.fade_out(now, true); + } + // `elapsed · 255 / 250`, truncated (`0x4b0d5b`-`0x4b0d68`). + let ramp = |e: u64| (e * 255 / FADE_MS) as u8; + let e = now.saturating_sub(self.since); + if self.flags & FADING_IN != 0 { + if e > FADE_MS { + self.alpha = 255; + self.flags &= !FADING_IN; + } else { + self.alpha = ramp(e); + } + } else if self.flags & FADING_OUT != 0 { + if e > FADE_MS { + if self.flags & FINAL != 0 { + return false; + } + // An out-of-range fade ends hidden (`0x4b0d95`). + self.flags &= !(FADING_OUT | FINAL); + self.shown = false; + } else { + self.alpha = 255 - ramp(e); + } + } + true + } +} + /// One live bubble: the `CGChatBubbleFrame` and its `CGUnit+0xe64` handle. struct Bubble { + /// This line's number, which tells the widget layer a replacement from a repeat. + line: u64, /// The [`sanitize`]d display text. text: String, /// The chat-type colour (the 94-entry table), sRGB 0..1. color: [f32; 3], - /// `Time::elapsed_secs` at spawn. - born: f32, - /// The steady window after the fade-in ([`duration_secs`]). - duration: f32, + /// The spawn time in ms (`[bubble+0x338]`). + born: u64, + /// The steady window after the fade-in ([`duration_ms`], `[bubble+0x340]`). + duration: u64, /// Stand-box height × model scale, latched at spawn as the reference does (`bubble+0x354`). lift: f32, - /// Fade alpha 0..1, ramped toward the eligibility verdict. - alpha: f32, + fade: Fade, } -/// The live bubbles by speaker guid, at most one per unit. +/// Whether the speaker was drawn in the last render: the reference's scene-node test `0x6704c0` +/// (`node && [node+0x94] == 0`, read at `0x4b0cab`). Every frame sets `[node+0x94]` on every node +/// in the scene list `[0xca7d70]` (`0x698a07`) and clears it only for a node that passes the +/// frustum test `0x682ef0` and the horizon test `0x686000` (`0x6833dd`-`0x6833ec`, and +/// `0x68359b`-`0x6835aa` in WMO groups). Here it is any of the unit's meshes passing last frame's +/// visibility check; benilla has no horizon cull yet, so that is the frustum verdict alone. +fn speaker_drawn( + unit: Entity, + children: &Query<&Children>, + meshes: &Query<&ViewVisibility, With>, +) -> bool { + children + .get(unit) + .is_ok_and(|kids| kids.iter().any(|c| meshes.get(c).is_ok_and(|v| v.get()))) +} + +/// The live bubbles by speaker guid, at most one per unit, and the next line's number. #[derive(Resource, Default)] -struct Bubbles(HashMap); +struct Bubbles { + live: HashMap, + next_line: u64, +} + +/// This frame's live bubbles for the widget layer, farthest from the camera first, and the sizes +/// the window gives them; `None` with no viewport to lay out against. [`crate::ui_chat::bubbles`] +/// hands them to the VM. +#[derive(Resource, Default)] +pub(crate) struct BubbleFeed { + pub(crate) geometry: Option, + pub(crate) states: Vec, +} /// Units with a live bubble this frame, whose overhead name is suppressed (`+0xe64` ≠ 0). #[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, - edge: Handle, - tail: Handle, - /// 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>, - mut images: ResMut>, -) { - 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. +/// Every frame: drain the queue through the spawn gate (`0x608ac0`), run each bubble's fade against +/// its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and lay out the +/// live set for the widget layer ([`BubbleFeed`]), farthest from the camera first. #[allow(clippy::type_complexity)] // one Bevy system's full input set fn drive_bubbles( mut queue: ResMut, @@ -294,17 +355,17 @@ fn drive_bubbles( units: Query<(Entity, &Guid, &Transform), With>, self_q: Query<&Transform, With>, camera: Query<(&Camera, &Transform), With>, - mut atlas: Option>, - mut quads: ResMut, - art: Option>, heights: Query<&StandBoxHeight>, time: Res