//! **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). `attachmentHeight` is `0x4b0e38 call 0x711a20` — a read of //! the **MD20 header image**, i.e. the **Stand sequence CAaBox's Z extent**, a file constant //! with no bone matrix anywhere in its call tree — and the scaled product is **latched** at //! `bubble+0x354` behind a parity guard, so it is queried exactly **once per chat line** //! ([`crate::entities::StandBoxHeight`], wow-re's anchor cross-check 2026-08-17; 1406). //! - **Stacking** (`0x4b1060`, the per-frame pass over ALL live bubbles — one caller, `0x4817a3`): //! each bubble is its own frame and its frame LEVEL is restamped every frame from the CAMERA's //! distance to the speaker. The list re-sorts farthest-first (`0x4b1360`, comparing //! `[bubble+0x350]` = |cam − unit|², written at `0x4b12c0`), then a head→tail walk stamps //! `SetFrameLevel(2), (3), (4)…` (`0x4b1309`). Level outranks draw layer in the client's total //! order, so overlapping bubbles stack as WHOLE cards with the nearest speaker's on top — //! nothing interleaves. ([`level_z`]; benilla read the bytes for this one, 1504 — wow-re's //! `chat-bubble.md` covers the spawn/geometry/anchor and never reached the manager tick.) //! //! Named divergences (all deliberate): //! - ~~**`ChatBubblesParty` defaults ON**~~ — **no longer a divergence (1804).** It shipped `"1"` //! against the binary's `"0"` from 0598, on the director's `/p` ask; both CVars now boot at the //! registrar's own values and the Chat page's row is the way back on. Same walk-back as //! [`crate::vplates::VPlateMode`], whose enemy plates were the same shape of pin. //! - **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**~~ — **REFUTED and removed (1406).** //! This shipped as an INFERRED equivalence ("both are the head-region attachment height, //! model-scaled") between the overhead-name chain `0x608640` and the bubble's `0x711a20`. They //! are not equivalent, and they differ on exactly the axis that mattered: `0x608640` reads the //! live posed palette and tracks the pose, `0x711a20` reads file bytes. The overhead *name* //! keeps `0x608640`; the bubble now takes the Stand-box constant the bytes actually specify. It //! sits **0.199 model units lower** on a human male (2.0128 vs the attachment's 2.2120) — a //! deliberate, measured move toward the reference, not a regression. //! - **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::{inset_atlas_bleed, pieces, Backdrop, Insets}; use benilla_ui::script::{JustifyH, JustifyV, Outline}; 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 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 page to put /// them on, which is exactly the shape 1134 calls a row over a frozen gate. /// /// **Both defaults are the binary's own** (`ChatBubbles` `"1"`, `ChatBubblesParty` `"0"` — wow-re /// `object-layer/scratch/chat-bubble.md`). `party` shipped ON from 0598 to 1804 on the director's /// `/p` ask; the row is on the Chat page, one click from where it was. #[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: false, } } } /// 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 one bubble's frame level** — the four pieces, back→front. The tail draws /// over the frame's bottom edge piece: its art carries the border lines that make the seam read /// continuous, and the reference gets the same result from its own layer key (the tail is an /// ARTWORK texture, the text an ARTWORK font string, and a batch drains every texture before /// every font string — wow-re `ui/scratch/draw-order-law.md` §4). 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` in this frame's farthest→nearest order — the port of the /// reference's per-frame `SetFrameLevel(2 + i)` walk (`0x4b12d5`–`0x4b1312`, decision 1504). One /// LEVEL per bubble, [`overlay_z::BUBBLE_STRIDE`] keys wide, so a whole bubble stacks over a whole /// bubble; the whole band stays under the V-plates ([`crate::ui_pass::overlay_z`]). 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 } /// 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 bleed inset for one border piece, `Vec2::ZERO` texels meaning "art size unknown, leave the /// UVs alone" (a bare test app with no patch chain). 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 for a projected seat: BOTTOM-CENTER on the seat, then both /// coordinates snapped onto the **device** pixel grid ([`device_snap`], the plate's law). /// /// Split out so the snap law is nameable and pinned. It shipped as a plain logical `round()`, /// which is 1 device pixel only at scale 1: on the 2× display we play on it stepped the bubble /// two physical pixels per axis over a continuously-sliding world, and at a fractional scale /// (1.25/1.5) it never landed on a texel boundary at all — so it paid the stepping without even /// buying the crispness it exists for. Same bug, same fix as the plate (0188's snap → the plate's /// device grid); the bubble was the site that never got it (1398). 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), ) } /// 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 Chat-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) { if benilla_assets::trace::enabled_for("bub") { let why = match (sender_guid, bubble_cvar(kind, cfg)) { (0, _) => "senderless".to_string(), (_, None) => format!("{kind:?}-never-bubbles"), (_, Some(false)) => format!("{kind:?}-cvar-off"), _ => unreachable!("the guard above admits nothing else"), }; benilla_assets::trace::line( "bub", &format!("refuse guid={sender_guid:#x} push:{why}"), ); } 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, /// The **latched** anchor height above the speaker's feet: the Stand box's Z extent /// ([`crate::entities::StandBoxHeight`]) already multiplied by the unit's model scale, queried /// ONCE here and never re-read — the reference caches exactly this product at `bubble+0x354` /// behind a parity guard, so it is one query per chat line (1406). lift: 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, /// The edge atlas's size in texels, for the half-texel bleed inset /// ([`benilla_ui::script::backdrop::inset_atlas_bleed`] — 1402). Stamped at load because the /// draw has no `Assets` and this never changes. 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 (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; }; 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, }); } /// 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 latched anchor height, read once per bubble at spawn ([`StandBoxHeight`]). heights: Query<&StandBoxHeight>, time: Res