diff --git a/crates/benilla-ui/src/script/chat_bubble.rs b/crates/benilla-ui/src/script/chat_bubble.rs index 17684d70..611fc3c7 100644 --- a/crates/benilla-ui/src/script/chat_bubble.rs +++ b/crates/benilla-ui/src/script/chat_bubble.rs @@ -26,10 +26,18 @@ //! - 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. +//! - The art's **geometry** is a backdrop, which is what `0x4b0a35` makes it, but its two paths +//! are also carried as Texture regions ([`Bubble::bg`]) so `GetRegions` lists them. A skin that +//! blanks the bubble's textures — pfUI's does — clears the backdrop with them. +//! +//! The regions are the reference's shape, not a deviation from it: a 1.12 backdrop's pieces are +//! `CSimpleTexture` objects the frame owns and `GetRegions` (`0x773f60`) walks. Holding the +//! geometry in one struct is benilla's shortcut, and these put the handles back. +//! +//! The earlier reading — that pfUI's own backdrop covers the plate anyway — was wrong, and +//! grom's screenshot is why: pfUI anchors its box 10 px *wider* and 10 px *shorter* than the +//! bubble and fills it at 75% alpha, so the stock rounded plate went on showing above and below +//! it, with pfUI's square one laid across the middle. use std::collections::HashMap; @@ -87,6 +95,22 @@ pub struct BubbleState { #[derive(Debug)] struct Bubble { frame: FrameHandle, + /// The backdrop's two art paths, as the Texture regions an addon can reach. + /// + /// They are anchorless and sized nowhere, so they draw nothing themselves — the backdrop + /// paints both. They exist so `GetRegions` lists the bubble's art, which is the only handle a + /// skin has on it: pfUI's bubble module walks `C_ChatBubbles.GetAllChatBubbles()`, blanks + /// every Texture it finds and lays its own backdrop over the result + /// (`modules/bubbles.lua:30-48`). With the art reachable only as a `Backdrop` struct it found + /// the tail and nothing else, so the stock rounded plate kept drawing under pfUI's own + /// 75%-alpha box — which is what grom photographed. + /// + /// This is closer to the reference, not further from it: there a backdrop's pieces *are* + /// `CSimpleTexture` objects the frame owns, and `GetRegions` (`0x773f60`) walks that list. + /// benilla keeps the backdrop's geometry in one struct instead, so these two stand in for the + /// objects. [`drive`] reads them back: blanked or hidden art clears that half of the backdrop. + bg: RegionHandle, + edge: RegionHandle, /// The tail (`0x4b0af1`), a border-unit square hanging under the bubble's bottom centre. tail: RegionHandle, text: RegionHandle, @@ -261,6 +285,18 @@ fn create(model: &mut Model, world: FrameHandle) -> Bubble { }, ); + // The art's two stand-in regions, before any other: the reference lists a frame's engine + // regions first (as `CSimpleEditBox`'s four are listed before its authored ones), and an + // addon that blanks "the first textures it finds" must find the plate, not the tail. + let [bg, edge] = [BG_TEXTURE, EDGE_TEXTURE].map(|path| { + let rh = model + .arena + .create_region(frame, RegionKind::Texture, DrawLayer::Background, 0) + .expect("live bubble"); + model.region_data.entry(rh).or_default().texture = Some(path.to_string()); + rh + }); + let tail = model .arena .create_region(frame, RegionKind::Texture, DrawLayer::Artwork, 0) @@ -286,6 +322,8 @@ fn create(model: &mut Model, world: FrameHandle) -> Bubble { Bubble { frame, + bg, + edge, tail, text, last: None, @@ -316,6 +354,7 @@ fn drive( ) { let b = &model.chat_bubbles.bubbles[i]; let (frame, tail, text) = (b.frame, b.tail, b.text); + let (b_bg, b_edge) = (b.bg, b.edge); let last = b.last.clone(); let world_id = model.frame_id(world); let frame_id = model.frame_id(frame); @@ -352,6 +391,26 @@ fn drive( model.touch_layout_frame(frame); } + // An addon's blank of the art, read back onto the backdrop: `SetTexture("")`/`SetTexture(nil)` + // clears the region's texture, `Hide()` sets `hidden`, and either means "do not draw this". + // Checked every frame rather than on a change, because the write comes from Lua and this file + // has no event for it; it is two map lookups. + // + // Never restored once cleared. A skin blanks a pool slot once (pfUI's `if not f.frame then`) + // and keeps managing that same frame for the rest of the session, so a later speaker reusing + // the slot must not get the stock plate back under the skin. + let blanked = |model: &Model, rh: RegionHandle| { + model + .region_data + .get(&rh) + .is_none_or(|d| d.hidden || d.texture.is_none()) + }; + let (bg_blank, edge_blank) = (blanked(model, b_bg), blanked(model, b_edge)); + if let Some(bd) = model.backdrops.get_mut(&frame) { + bd.bg_file = (!bg_blank).then(|| BG_TEXTURE.to_string()); + bd.edge_file = (!edge_blank).then(|| EDGE_TEXTURE.to_string()); + } + // 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) { @@ -459,6 +518,13 @@ mod tests { /// 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. + /// + /// This once asserted that it found **one** texture — the tail — on the reasoning that the + /// plate is a `Backdrop` and pfUI's own box covers it anyway. It does not: pfUI anchors its + /// backdrop 10 px *wider* and 10 px *shorter* than the bubble and fills it at 75% alpha, so + /// the stock rounded plate goes on showing through, above and below. That is the screenshot + /// grom sent. The art is reachable now, and the second half of this test is the claim that + /// matters — blanking it stops it drawing. #[test] fn pfuis_own_bubble_scan_finds_the_text_and_the_textures() { let mut s = script(); @@ -500,9 +566,27 @@ mod tests { ); assert_eq!( s.eval::("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" + 3, + "the plate's background, its border and the tail — all three reachable, which is the \ + whole point: with the art held only as a `Backdrop` struct this found the tail alone \ + and the stock rounded plate kept drawing under pfUI's own 75%-alpha box" + ); + // And the blank reached the art: the plate stops drawing, the way it does on the client + // this skin was written for. + s.sync_chat_bubbles(&[bubble(0x42, "Hail, traveller")]); + s.resolve(); + let after = s.extract(); + let painted: Vec<&str> = after + .iter() + .filter_map(|q| match &q.content { + crate::script::QuadContent::Texture { path: Some(p), .. } => Some(p.as_str()), + _ => None, + }) + .filter(|p| p.contains("ChatBubble")) + .collect(); + assert!( + painted.is_empty(), + "a blanked bubble must draw none of its own art; still painting {painted:?}" ); assert!( s.eval::("return Parented").unwrap(),