From 9d0b4d068a255e695b4370f1cf038b6f7996366e Mon Sep 17 00:00:00 2001 From: grom Date: Thu, 29 Oct 2026 10:04:17 -0600 Subject: [PATCH] The stock chat bubble drew under pfUI's, because its art had no handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit His screenshot shows two plates: a rounded grey one with pfUI's square box laid across the middle of it. pfUI's bubble module blanks every Texture it finds in the bubble's regions and lays its own backdrop over the result (modules/bubbles.lua:30-48). Our bubble held its two art paths only in the Backdrop struct, so the loop found the tail and nothing else. The note in this file reasoned that pfUI's own box covers the plate anyway — it does not: pfUI anchors that box 10 px wider and 10 px *shorter* than the bubble and fills it at 75% alpha, so the stock plate goes on showing above and below, through a translucent square. That is exactly the photograph. The background and the border are now also Texture regions, listed first as the reference lists a frame's engine regions first. They are anchorless and draw nothing themselves; the backdrop still paints. drive() reads them back each frame, and a region that has been blanked or hidden clears that half of the backdrop — never restored, since a skin blanks a pool slot once and keeps managing that frame for the session. This is the reference's own shape rather than a deviation from it: a 1.12 backdrop's pieces are CSimpleTexture objects the frame owns, which GetRegions (0x773f60) walks. Keeping the geometry in one struct is our shortcut; these put the handles back. The transcribed-pfUI test now expects three textures instead of one, and goes on to assert the thing that actually matters — after the blank, the extract carries none of the bubble's own art. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-ui/src/script/chat_bubble.rs | 98 +++++++++++++++++++-- 1 file changed, 91 insertions(+), 7 deletions(-) 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(),