//! **Chat bubbles** — `CGChatBubbleFrame` (wow-re `object-layer/scratch/chat-bubble.md`, //! §5-verified 2026-07-11; decision 0288's phase-9 tail, landed by decision 0598): the //! over-the-head speech bubble a chat line spawns, a **2-D overlay** like the V-plate //! ([`crate::vplates`]) and unlike the world-pass overhead *names* ([`crate::nameplates`]). //! //! The pinned law, transcribed: //! - **Spawn** (`0x608ac0`, one caller — the SMSG_MESSAGECHAT display path): the sender GUID //! typed-looks-up to a live unit or no bubble; CVar select `ChatBubbles` (party lines: //! `ChatBubblesParty`); non-empty text; **no active V-plate on the unit** (`0x608adc` — //! mutual exclusion both ways: a live bubble in turn suppresses the floating overhead name, //! [`BubblesActive`]); the local player resolves; 3-D dist² ≤ 400 (20 yd). Self is NOT //! excluded. **Replace, never queue**: a new line tears the old bubble down first. //! - **Lifetime** (`0x4b1810`): word-count-scaled, self-vs-other asymmetric — //! `2750 + 750·(words−1)` ms for others, `1500 + 500·(words−1)` for the local player //! (your own bubbles are shorter-lived); words = space/tab-run count. 250 ms linear fade-in; //! at `create + duration + 250` a permanent 250 ms fade-out, then the frame recycles. The //! 20 yd gate re-tests **every frame**: out of range fades out *recoverable*, back in range //! fades back in. //! - **Geometry** (`0x4b0940`/`0x4b1600`): the classic `Backdrop` construct — //! `ChatBubble-Background` flat fill, `ChatBubble-Backdrop` 8-piece edge //! ([`benilla_ui::script::backdrop::pieces`], the same engine geometry FrameXML backdrops //! use), `ChatBubble-Tail` a separate square of side = the border-unit, TOPRIGHT on the //! frame's BOTTOM + (0, border/4). Border-unit = insets = edge-size = **16/1024 of the //! screen width** (`G44·16/(S·1024)`); text = `NAMEPLATE_FONT` at 0.01 gx, wrap hard-capped //! at **0.2 gx**, floor 2·border-unit; the frame hugs the text layout ± **0.01 gx** on all //! four sides; text colored by chatType (the 94-entry table ≡ [`default_color`]), rendered //! PLAIN (`||`→`|`, color/hyperlink escapes stripped — [`sanitize`]). //! - **Anchor** (`0x4b0c30`): `worldZ = unit.z + attachmentHeight·modelScale + 0.7`, projected //! by the plate/name projector, seated **BOTTOM at the point, growing upward** (the mirror //! of the plate, which hangs down). //! //! Named divergences (all deliberate, decisions 0598/0599): //! - **`ChatBubblesParty` defaults ON** (binary default "0") — the director asked for `/p` //! bubbles; same posture as [`crate::vplates::VPlateMode`]. //! - **The v1 kind set is SAY/YELL/PARTY + monster say/yell.** The byte gate is //! "sender resolves", not a type whitelist, which *implies* guild/officer/whisper/emote //! bubbles too — but that category claim is INFERRED on an OPEN wire-type remap //! (chat-bubble.md §1) and contradicts the remembered reference look, so the uncontested //! set ships and a capture can widen it ([`bubble_cvar`]). //! - **The anchor height is the posed overhead attachment** ([`overhead_anchor`], the //! `0x608640` chain) rather than the client's `0x711a20` model-attachment query — the //! attachment behind `0x711a20` is not identified in the note (INFERRED equivalence; both //! are "the head-region attachment height, model-scaled"). //! - **Sizes ride the plates' damped diagonal basis** ([`plate_basis`], decisions 0185/0186) //! so bubble text and plate text stay the same em at every window — the same director-pinned //! deviation from the unbounded byte law. //! - **No per-frame occlusion fade**: the client also fades a bubble whose speaker model isn't //! render-visible (`0x7103d0`/`0x6704c0`); v1 re-tests distance only. Residual named in 0598. //! //! (0598 briefly shipped the plate-blocks-bubble gate UN-transcribed — bubbles stacked over our //! then-always-on plates. 0599 restored the faithful gate and booted friendly plates OFF //! instead, so friendly/party bubbles have room the reference way.) use std::collections::HashMap; use bevy::ecs::entity::EntityHashSet; use bevy::prelude::*; use benilla_ui::layout::Rect as GxRect; use benilla_ui::script::{pieces, Backdrop, Insets}; use benilla_ui::script::{JustifyH, JustifyV, Outline}; use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback}; use crate::net::{Guid, NetEntity, SelfGuid, SelfPlayer}; use crate::ui_chat::{default_color, ChatEventKind}; use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect}; use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas}; use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates}; use benilla_assets::{AssetSet, WorldAssets}; use benilla_world::view::WorldCamera; /// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** — /// they were a pair of `const bool` from 0598 until the options window had a Social page to put /// them on, which is exactly the shape 1134 calls a row over a frozen gate. The defaults are the /// values that were frozen: `ChatBubbles` the reference's own "1"; `ChatBubblesParty` flipped ON /// against the binary's "0" (the director's ask: `/p` bubbles), the same deliberate deviation /// [`crate::vplates::VPlateMode`]'s boot default carries. #[derive(Resource)] pub(crate) struct BubbleConfig { /// `ChatBubbles` — say/yell and their monster variants. pub(crate) all: bool, /// `ChatBubblesParty` — party lines, which the client gates separately. pub(crate) party: bool, } impl Default for BubbleConfig { fn default() -> Self { Self { all: true, party: true, } } } /// The bubble art (`0x4b0940` ctor) — the shared tooltip-family textures. 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 attachment height (`[0x7ffd7c]` = 0.699999988). const LIFT: f32 = 0.7; /// The spawn + per-frame range gate: dist² ≤ 400 (20 yd, `[0x806798]` — same as the plates). const MAX_DIST_SQ: f32 = 400.0; /// The fade ramps: 250 ms linear, in and out (`0x4b0ea0`/`0x4b0ee0`). const FADE_SECS: f32 = 0.25; /// `NAMEPLATE_FONT` at 0.01 gx — the same em law as the plate name. const TEXT_H: f32 = 0.01; /// The auto-fit body margin: text layout ± 0.01 gx on all four sides (`0x3c23d70a`). const MARGIN: f32 = 0.01; /// The text wrap hard cap, gx (`[0x80679c]` = 0.2). const WRAP_W: f32 = 0.2; /// Border-unit = edge-size = insets = tail side: 16/1024 of the screen WIDTH /// (`G44·16.0/(S·1024.0)` — G44/S nets the width in the diagonal gx basis). const BORDER_FRAC: f32 = 16.0 / 1024.0; /// Paint order inside the overlays lane: the whole bubble sits UNDER the V-plates' 4..8 band /// (same-unit overlap can't happen — the mutual exclusion — so this only orders cross-unit /// stacking). The tail draws over the frame's bottom edge piece: its art carries the border /// lines that make the seam read continuous. const Z_BG: u64 = 0; const Z_EDGE: u64 = 1; const Z_TAIL: u64 = 2; const Z_TEXT: u64 = 3; /// Which CVar gates this chat kind's bubble — `None` = the kind never bubbles in v1. /// PARTY selects `ChatBubblesParty`, every other bubbling kind `ChatBubbles` (`0x608b0d`). /// Guild/officer/whisper/emote are structurally implied by the byte gate but INFERRED on an /// OPEN remap (module doc) — out until a capture confirms. fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option { use ChatEventKind as K; match kind { K::Party => Some(cfg.party), K::Say | K::Yell | K::MonsterSay | K::MonsterYell => Some(cfg.all), _ => None, } } /// The space/tab-run word counter (`0x4b1810`): maximal runs of non-space/tab characters. 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 duration law (`0x4b1810`): `base + perWord·(words−1)` ms — 2750/750 for someone else, /// 1500/500 for the local player (your own bubbles are shorter-lived). Empty → 0. fn duration_secs(words: u32, is_self: bool) -> f32 { if words == 0 { return 0.0; } let (base, per) = if is_self { (1500, 500) } else { (2750, 750) }; (base + per * (words - 1)) as f32 / 1000.0 } /// The bubble-text sanitize: `||` stays an escaped literal pipe, `|cAARRGGBB`/`|r` color /// escapes and the `|H…|h`/`|h` hyperlink wrappers strip (their display text stays) — bubble /// text is always plain, colored solely by chatType. Output remains in ESCAPED form (literal /// pipes as `||`) because the glyph layout's own markup parser consumes it downstream. fn sanitize(text: &str) -> String { let b = text.as_bytes(); let mut out = String::with_capacity(text.len()); let mut i = 0; while i < b.len() { 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]); 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 display text then flows until the // closing `|h`, which the arm below drops. i += 2; while i < b.len() && !(b[i] == b'|' && b.get(i + 1) == Some(&b'h')) { i += 1; } i += 2; } Some(b'h') => i += 2, // A dangling or unknown escape renders as a literal pipe + the rest. _ => { out.push_str("||"); i += 1; } } } out } /// The border-unit in logical px for this viewport: 16/1024 of the screen width, carried /// through the damped size basis (so it shrinks in step with plate/text sizes past the knee). fn border_px(viewport: Vec2, basis: f32) -> f32 { let width_gx = viewport.x / viewport.length(); gx_px(width_gx * BORDER_FRAC, basis).max(1.0) } /// The bubble-spawn requests this frame — fed by the chat feed's wire arm (the reference /// spawns in the SMSG display path, the same moment the line routes) and drained by /// [`drive_bubbles`]. Push-side filtered so guild/system spam never queues strings. #[derive(Resource, Default)] pub(crate) struct BubbleQueue(Vec<(u64, ChatEventKind, String)>); impl BubbleQueue { /// Queue a routed wire line for a bubble. Drops non-bubbling kinds, disabled CVars, and /// senderless lines here; the live-unit/range/plate gates run in the driver. The switch is /// read at PUSH time, so a Social-page click takes the very next line either way. pub(crate) fn push( &mut self, cfg: &BubbleConfig, sender_guid: u64, kind: ChatEventKind, text: &str, ) { if sender_guid == 0 || !bubble_cvar(kind, cfg).unwrap_or(false) { return; } self.0.push((sender_guid, kind, text.to_string())); } } /// One live bubble (the `CGChatBubbleFrame` + its `CGUnit+0xe64` handle, folded together). struct Bubble { /// Sanitized display text ([`sanitize`] — plain, escapes stripped). text: String, /// The chatType color (the 94-entry table), client-space sRGB 0..1. color: [f32; 3], /// `Time::elapsed_secs` at spawn. born: f32, /// The steady window past the fade-in ([`duration_secs`]). duration: f32, /// Current fade alpha 0..1 — ramped 250 ms linear toward the eligibility verdict. alpha: f32, } /// The live bubbles, keyed by speaker guid (a unit carries at most one — replace, never queue). #[derive(Resource, Default)] struct Bubbles(HashMap); /// The units carrying a live bubble this frame — the `ShouldShowName` exclusivity verdict the /// overhead-name driver reads (`+0xe64` ≠ 0 suppresses the floating name), the mirror of /// [`VPlates`]. #[derive(Resource, Default)] pub(crate) struct BubblesActive(pub(crate) EntityHashSet); /// The bubble art, warmed at boot like the plate art (a first bubble must not draw texture-less). /// The edge strip needs REPEAT addressing (its runs tile UVs past 1); bg and tail clamp. #[derive(Resource)] struct BubbleArt { bg: Handle, edge: Handle, tail: Handle, } fn load_bubble_art( mut commands: Commands, assets: Option>, mut images: ResMut>, ) { let Some(mut assets) = assets else { return; // no game data (bare test app) — drive_bubbles tolerates the missing resource }; 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; }; commands.insert_resource(BubbleArt { bg, edge, tail }); } /// Spawn, tick, and draw, every frame: drain the queue through the spawn gate (`0x608ac0`), /// ramp each live bubble's fade against its lifetime + the per-frame 20 yd re-test /// (`0x4b0c30`), publish the name-suppression verdict, and append the draw list — backdrop /// pieces, tail, wrapped chatType-colored text — bottom-seated on the projected anchor. /// Runs after [`VPlateSet`] (the spawn gate reads this frame's plate verdict), inside the /// [`UiQuadAppend`] window. #[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set fn drive_bubbles( mut queue: ResMut, mut bubbles: ResMut, mut active: ResMut, vplates: Res, self_guid: Res, units: Query<(Entity, &Guid, &Transform), With>, self_q: Query<&Transform, With>, camera: Query<(&Camera, &Transform), With>, mut atlas: Option>, mut quads: ResMut, art: Option>, // The overhead-anchor inputs ([`overhead_anchor`]). anchor_q: ( Query<&BoneAttach>, Query<&OverheadFallback>, Query<&GlobalTransform>, Query<(), With>, ), time: Res