diff --git a/crates/benilla-app/src/char_select/addons.rs b/crates/benilla-app/src/char_select/addons.rs index 62d3f80d..810bc8bf 100644 --- a/crates/benilla-app/src/char_select/addons.rs +++ b/crates/benilla-app/src/char_select/addons.rs @@ -18,7 +18,8 @@ //! coloured **gold when loadable, red when enabled-and-broken — except `DEP_DISABLED`, which reads //! grey** (AddonList.lua's own exception), and the status token (`getglobal("ADDON_"..reason)`) in //! `GlueFontNormalSmall` 30 units right of the title box. Titles render their `|cAARRGGBB` markup -//! as colour, exactly as every FontString does ([`title_spans`]). Hovering a row raises +//! as colour, exactly as every FontString does — as does every other string on this screen, +//! the tooltip included ([`crate::glue::widgets::markup_spans`]). Hovering a row raises //! `AddonTooltip` (title + notes + `ADDON_DEPENDENCIES`) anchored to the row's left; hovering a //! MIXED checkbox raises `ENABLED_FOR_SOME`. **A row has no hover highlight and no row-click** — //! only the checkbox toggles (the entry template carries no `HighlightTexture` and no `OnClick`); @@ -43,14 +44,14 @@ use bevy::prelude::*; use bevy::ui_render::ui_material::MaterialNode; -use benilla_ui::markup::{tokens, TokenKind}; use benilla_ui::script::addon_gate::{can_load, GateRow, Verdict}; use benilla_ui::script::UiScript; +use benilla_ui::widget::{slider_fraction, slider_grab}; use crate::glue::art::{tc_rect, GlueArt, DIM, GOLD}; use crate::glue::backdrop::{backdrop_border, tiled_bg_node}; use crate::glue::widgets::{ - glue_button, outlined_spans, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight, + glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight, }; use crate::glue_strings::GlueStrings; use crate::sound::GlueSound; @@ -100,6 +101,12 @@ const FORCE_TOP: f32 = 42.0; const SCROLL_TOP: f32 = 73.0; const SCROLL_H: f32 = 390.0; const SCROLL_RIGHT: f32 = 49.0 + 510.0; +/// The slider column between the two 16² arrows — the `` the reference's +/// `GlueScrollBarTemplate` authors, and the surface a drag measures itself against ([`ScrollBand`]). +const BAR_TOP: f32 = SCROLL_TOP + 16.0; +const BAR_H: f32 = SCROLL_H - 32.0; +/// The knob's extent along the track (`UI-ScrollBar-Knob`, 16²). +const KNOB: f32 = 16.0; /// The bottom row: 35 tall, 13 off the plate bottom. const BTN_TOP: f32 = BG_H - 13.0 - 35.0; @@ -168,6 +175,15 @@ pub(super) struct AddonsPanel { version_check: bool, /// First visible row (`AddonList.offset`). offset: usize, + /// The scroll bar's in-flight drag: the grab [`slider_grab`] returned at the press, as a + /// fraction of the band's own height ([`ScrollBand`]). + /// + /// Held here rather than read back off `Interaction::Pressed` because the panel **respawns + /// its whole tree** on any repaint — and a drag repaints it every row it crosses. A fresh + /// entity's `Interaction` starts at `None`, so a capture inferred from the widget would drop + /// the moment the drag moved the list. The button's own held state (`ButtonInput`) is the + /// only thing that survives a respawn, so that is what the drag rides on. + drag: Option, /// The spawned tooltip, keyed by what it describes — **the only thing hover drives, and /// never a repaint trigger**. The old model set `dirty` on every hover change and respawned /// the whole tree, which reset the fresh entities' `Interaction` to `None`, which flipped @@ -197,6 +213,7 @@ impl Default for AddonsPanel { // for the frame before the drive system's first CVar read corrects it. version_check: true, offset: 0, + drag: None, tip: None, root: None, spawned_s: 0.0, @@ -314,6 +331,30 @@ impl AddonsPanel { self.list.len().saturating_sub(MAX_ROWS) } + /// Where the knob sits, as a fraction of its travel — the value→pixel direction. Shared by the + /// spawn (which draws the knob there) and the drag (which needs to know where it grabbed), so + /// the two can never disagree about what the bar is showing. + fn thumb_fraction(&self) -> f32 { + match self.max_offset() { + 0 => 0.0, + max => self.offset as f32 / max as f32, + } + } + + /// The pixel→value direction: seat the first visible row at `fraction` of the way down. + /// + /// Rounded to a whole row because this list scrolls by ROWS, not pixels — the reference's + /// AddonList is a faux scroll frame over 19 fixed slots, so the knob rides in row-sized + /// detents. `dirty` only when the row actually changes: a drag inside one detent must not + /// respawn the tree 60 times a second. + fn scroll_to(&mut self, fraction: f32) { + let next = (fraction * self.max_offset() as f32).round() as usize; + if next != self.offset { + self.offset = next; + self.dirty = true; + } + } + /// One row's checkbox state for the current view. A single character's view is plain /// two-state over their column; the All view is the reference's tri-state — its /// `GetAddOnEnableState(nil, i)`: enabled for all / for some (the greyed check) / for none. @@ -388,7 +429,7 @@ impl AddonsPanel { /// which reads grey like a disabled row (the Lua's own `reason ~= "DEP_DISABLED"` guard: the /// player already made that choice one row up, and red would blame the dependent); grey /// otherwise. Any `|c` markup in the title overrides this base per-span, exactly as the - /// reference FontString renders it ([`title_spans`]). + /// reference FontString renders it ([`crate::glue::widgets::markup_spans`]). fn title_colour(&self, i: usize) -> Color { let verdict = self.verdict(i); if verdict.loadable() { @@ -471,53 +512,6 @@ fn status_label<'a>(strings: &'a GlueStrings, token: &'a str) -> &'a str { strings.get(&format!("ADDON_{token}")).unwrap_or(fallback) } -/// Split a title into its coloured spans — WoW's `|cAARRGGBB…|r` inline markup, decoded by the -/// byte-verified grammar (`benilla_ui::markup`, RF-0087) instead of drawn literally. `base` is -/// the row's own colour ([`AddonsPanel::title_colour`]); an escape overrides it until `|r`, and -/// the escape's alpha byte is discarded exactly as the client discards it. Link escapes hide -/// their payload and keep their visible text; `||` draws one `|`; a line break becomes a space -/// (a title is one line). -fn title_spans(text: &str, base: Color) -> Vec<(String, Color)> { - let mut spans: Vec<(String, Color)> = Vec::new(); - let mut cur = String::new(); - let mut colour = base; - for (_, tok) in tokens(text) { - let switch = match tok.kind { - TokenKind::Color(rgba) => { - let [r, g, b, _] = rgba.to_f32_at(1.0); - Some(Color::srgb(r, g, b)) - } - TokenKind::ColorReset => Some(base), - TokenKind::EscapedPipe => { - cur.push('|'); - None - } - TokenKind::LineBreak => { - cur.push(' '); - None - } - TokenKind::LinkOpen { .. } | TokenKind::LinkClose => None, - TokenKind::Char(c) => { - cur.push(c); - None - } - }; - if let Some(next) = switch { - if !cur.is_empty() { - spans.push((std::mem::take(&mut cur), colour)); - } - colour = next; - } - } - if !cur.is_empty() { - spans.push((cur, colour)); - } - if spans.is_empty() { - spans.push((String::new(), base)); - } - spans -} - /// Flip the *Load out of date AddOns* box: the `checkAddonVersion` CVar **inverted** (1292 §2, /// byte-verified — ticked = `"0"`). Written through [`UiScript::set_cvar_engine`] so it rides the /// change queue like a Lua `SetCVar` and the host's sync persists it (the minimap-zoom pattern) — @@ -543,6 +537,10 @@ pub(super) enum AddonsAction { Cancel, ScrollUp, ScrollDown, + /// The slider column — **one surface covering knob and track alike**, because the law has no + /// thumb hit-test to gate capture on: a press anywhere on the bar captures, and where it + /// *grabs* is [`slider_grab`]'s to say, from geometry rather than from picking. + ScrollBar, /// The dropdown control — opens/closes the option list. Also carried by the open list's /// full-screen backdrop, so a click that misses every option closes it (click-away). DropdownToggle, @@ -556,6 +554,17 @@ pub(super) enum AddonsAction { #[derive(Component)] struct AddonsUi; +/// The scroll bar's travel band — the node spanning [`BAR_TOP`]`..`[`BAR_TOP`]`+`[`BAR_H`], with +/// the knob riding inside it as a child. +/// +/// The drag reads its `ComputedNode`/`UiGlobalTransform` rather than re-deriving where the bar +/// landed on screen: this panel is centered by a flex parent at a window-dependent scale, so any +/// hand-rolled screen-rect math here would be a second copy of the layout, wrong the first time +/// the window resizes. Normalizing the cursor into this node's own box is scale-free by +/// construction. +#[derive(Component)] +pub(super) struct ScrollBand; + /// A panel button whose child [`Hilight`] lights on hover (the checkboxes' `UI-CheckBox-Highlight`, /// the close X, the dropdown arrow, the open list's `UI-QuestTitleHighlight` rows, the scroll /// arrows) — driven per frame by [`drive_addons_panel`] with **no respawn**, which is the point. @@ -575,12 +584,14 @@ pub(super) fn drive_addons_panel( strings: Option>, mut script: Option>, keys: Res>, + mouse: Res>, mut wheel: MessageReader, mut sounds: MessageWriter, presses: Query<(&AddonsAction, &Interaction), Changed>, hovers: Query<(Entity, &AddonsAction, &Interaction)>, lit: Query<(&Interaction, &Children), With>, mut hilights: Query<&mut Visibility, With>, + band: Query<(&ComputedNode, &UiGlobalTransform), With>, window: Query<&Window, With>, ) { if !panel.open { @@ -588,6 +599,7 @@ pub(super) fn drive_addons_panel( commands.entity(root).despawn(); } panel.tip = None; + panel.drag = None; wheel.clear(); return; } @@ -595,6 +607,7 @@ pub(super) fn drive_addons_panel( // ── the flows ───────────────────────────────────────────────────────────────────────────── let mut close_and_save = false; let mut close_and_discard = false; + let mut bar_pressed = false; for (action, interaction) in &presses { if *interaction != Interaction::Pressed { continue; @@ -608,6 +621,7 @@ pub(super) fn drive_addons_panel( AddonsAction::Cancel => close_and_discard = true, AddonsAction::ScrollUp => scroll(&mut panel, -1), AddonsAction::ScrollDown => scroll(&mut panel, 1), + AddonsAction::ScrollBar => bar_pressed = true, AddonsAction::DropdownToggle => { // The reference's dropdown button click (`ToggleDropDownMenu` + // `PlaySound("igMainMenuOptionCheckBoxOn")`). @@ -643,6 +657,41 @@ pub(super) fn drive_addons_panel( } } + // ── the scroll bar's drag (B274) ────────────────────────────────────────────────────────── + // Benilla's one slider law, reached from the glue lane: the press grabs ([`slider_grab`] — + // on the knob it keeps the grabbed point, off it the knob's centre warps under the cursor), + // and press and every move after it share the one absolute cursor→value map + // ([`slider_fraction`]). Absolute, so it cannot drift off the cursor over a long drag; and + // the capture rides the held BUTTON, not the widget, so it survives the repaint that every + // crossed row triggers and keeps tracking with the cursor off the bar entirely. + let cursor = window + .single() + .ok() + .and_then(|w| w.physical_cursor_position()); + if !mouse.pressed(MouseButton::Left) || cursor.is_none() { + // Release, or the cursor left the window — the widget lane abandons a capture on the + // same two conditions. + panel.drag = None; + } + if let (Some(cursor), Ok((node, xf))) = (cursor, band.single()) { + // Everything below is a fraction of the band's own height, which makes it free of the + // window scale, the panel's centering, and the retina factor all at once. + if let Some(local) = node.normalize_point(*xf, cursor) { + let cursor_n = local.y + 0.5; + let thumb_n = KNOB / BAR_H; + if bar_pressed && panel.drag.is_none() { + let lead_n = panel.thumb_fraction() * (1.0 - thumb_n); + panel.drag = Some(slider_grab(cursor_n, lead_n, thumb_n)); + } + if let Some(grab) = panel.drag { + // `None` = a knob with nowhere to go; the bar is not spawned in that case. + if let Some(f) = slider_fraction(cursor_n, grab, 1.0, thumb_n) { + panel.scroll_to(f); + } + } + } + } + // The live `checkAddonVersion` mirror (1293 §5): read per frame like the gate's own per-query // read (1292 §2.2), so the ForceLoad click above — or any other writer — repaints the // statuses this same frame with nothing rescanned. Absent VM or table = the registrar @@ -1177,9 +1226,10 @@ fn spawn_panel( )) .with_children(|r| { // Title — `GlueFontNormal`, 220 wide, colour markup rendered - // ([`title_spans`]); clipped at the authored box so a long title + // ([`crate::glue::widgets::markup_spans`], run by every `GlueText`); + // clipped at the authored box so a long title // cannot run into the status column. - outlined_spans( + outlined_text( r, Node { position_type: PositionType::Absolute, @@ -1192,9 +1242,12 @@ fn spawn_panel( }, (), (), - &title_spans(addon.display_title(), colour), - 15.0, // GlueFontNormal - false, + GlueText { + text: addon.display_title(), + size: 15.0, // GlueFontNormal + color: colour, + wrap: false, + }, &font, s, ); @@ -1278,8 +1331,8 @@ fn spawn_panel( } // The slider column: TOPLEFT at scrollframe TOPRIGHT +(6, −16). let bar_left = SCROLL_RIGHT + 6.0; - let bar_top = SCROLL_TOP + 16.0; - let bar_h = SCROLL_H - 32.0; + let bar_top = BAR_TOP; + let bar_h = BAR_H; if let Some(sc) = &art.scroll { for (action, up, top) in [ (AddonsAction::ScrollUp, &sc.up_btn, bar_top - 16.0), @@ -1305,16 +1358,39 @@ fn spawn_panel( )); }); } - let travel = bar_h - 16.0; - let frac = panel.offset as f32 / panel.max_offset() as f32; + // The band IS the slider: pressable end to end, with the knob a + // decorative child riding at the value fraction. Splitting them into two + // pickable surfaces would put a thumb hit-test back into the capture + // decision, which the class does not have (`slider_grab`). b.spawn(( - ImageNode { - image: sc.knob.0.clone(), - rect: Some(tc_rect(sc.knob.1, crate::glue::art::SCROLL_BTN_TC)), - ..default() - }, - abs(bar_left, bar_top + frac * travel, 16.0, 16.0), - )); + AddonsAction::ScrollBar, + ScrollBand, + Button, + abs(bar_left, bar_top, KNOB, bar_h), + )) + .with_children(|band| { + band.spawn(( + // The knob must NOT capture the press it sits under: a UI node + // with no `FocusPolicy` defaults to `Block`, and a blocking knob + // would swallow every press aimed at it — the one gesture this + // whole path exists to serve — leaving only the bare track + // draggable. + bevy::ui::FocusPolicy::Pass, + ImageNode { + image: sc.knob.0.clone(), + rect: Some(tc_rect(sc.knob.1, crate::glue::art::SCROLL_BTN_TC)), + ..default() + }, + Node { + position_type: PositionType::Absolute, + left: px(0.0), + top: px(panel.thumb_fraction() * (bar_h - KNOB)), + width: px(KNOB), + height: px(KNOB), + ..default() + }, + )); + }); } else { for (action, caption, top) in [ (AddonsAction::ScrollUp, "-", bar_top - 16.0), @@ -1787,47 +1863,6 @@ mod tests { ); } - /// `|cAARRGGBB…|r` markup renders as colour, not as text — the MapCoords regression (its - /// `## Title` is `|cff00ff00MapCoords 0.32`, which the screen printed literally, in full). - /// The grammar is the byte-verified `benilla_ui::markup`; the alpha byte is discarded there, - /// so only rgb reaches the span colour. - #[test] - fn colour_escapes_become_spans_not_text() { - let base = GOLD; - let green = Color::srgb(0.0, 1.0, 0.0); - - assert_eq!( - title_spans("|cff00ff00MapCoords 0.32", base), - vec![("MapCoords 0.32".to_string(), green)], - "the whole-title escape colours everything and prints nothing" - ); - assert_eq!( - title_spans("A |cff00ff00B|r C", base), - vec![ - ("A ".to_string(), base), - ("B".to_string(), green), - (" C".to_string(), base) - ], - "|r restores the row's base colour" - ); - assert_eq!( - title_spans("pipe || pipe", base), - vec![("pipe | pipe".to_string(), base)], - "|| draws one literal pipe" - ); - assert_eq!( - title_spans("|xnot an escape", base), - vec![("|xnot an escape".to_string(), base)], - "a | that opens nothing well-formed is an ordinary character (the grammar's \ - fall-through arm)" - ); - assert_eq!( - title_spans("", base), - vec![(String::new(), base)], - "an empty title still yields one span, so the text entity exists" - ); - } - /// The All view's gate input is OUR carve (1293): "enabled = any staged character has it on". #[test] fn the_all_view_feeds_the_gate_any_character_on() { @@ -2011,6 +2046,121 @@ mod tests { ); } + /// **B274** — the knob is draggable, and the drag is the client's own law. + /// + /// This models the press/move arithmetic the drive system runs, in the same band-normalized + /// units: `cursor_n` is the cursor as a fraction of the bar's height, `thumb_n` the knob's + /// share of it. Only the cursor read and the `ComputedNode` lookup are left out — everything + /// that decides where the list lands is here. + /// + /// What it pins is the property the reporter is missing (a grab moves the list at all) plus + /// the two that make it feel right: **offset-preserving** on the knob (grab its bottom edge, + /// and the list does not jump before you have moved), and **absolute, not accumulated**, so a + /// long drag cannot walk away from the cursor. + #[test] + fn the_scroll_knob_drags_the_list_under_the_cursor() { + let thumb_n = KNOB / BAR_H; + // 39 addons over 19 slots = 20 scroll positions. + let mut p = panel( + (0..MAX_ROWS * 2 + 1) + .map(|i| addon(&format!("A{i}"), &[], 11200)) + .collect(), + ); + let max = p.max_offset(); + assert_eq!(max, MAX_ROWS + 1); + + // Press the knob at its BOTTOM edge, at rest (knob at the top of the track). + let grab = slider_grab(thumb_n, 0.0, thumb_n); + assert_eq!(grab, thumb_n, "grabbing the knob's bottom keeps that point"); + let f = slider_fraction(thumb_n, grab, 1.0, thumb_n).expect("the bar has travel"); + p.scroll_to(f); + assert_eq!(p.offset, 0, "the press alone must not jump the list"); + + // Drag to the middle of the track; the grabbed point stays under the cursor, so the + // knob's LEADING edge lands half a knob above it. + let f = slider_fraction(0.5 + thumb_n * 0.5, grab, 1.0, thumb_n).unwrap(); + p.scroll_to(f); + assert_eq!(p.offset, max / 2, "mid-track = mid-list"); + + // Past the bottom end: pinned, never past it (the clamp `SetValue` does in the client). + let f = slider_fraction(4.0, grab, 1.0, thumb_n).unwrap(); + p.scroll_to(f); + assert_eq!(p.offset, max); + assert_eq!(p.visible().count(), MAX_ROWS, "the last page is still full"); + + // Absolute, not accumulated: returning the cursor to where it pressed returns the list + // to where it started, however far it wandered in between. + let f = slider_fraction(thumb_n, grab, 1.0, thumb_n).unwrap(); + p.scroll_to(f); + assert_eq!(p.offset, 0); + } + + /// A press on the TRACK — off the knob — warps the knob's centre under the cursor and drags + /// on from there, one gesture. Byte-verified 1.12 (wow-re `slider-mouse-law.md` §1, and the + /// director's own requirement in 0989); the glue bar runs the same law as every in-game one. + #[test] + fn a_press_on_the_bare_track_warps_the_knob_under_the_cursor() { + let thumb_n = KNOB / BAR_H; + let mut p = panel( + (0..MAX_ROWS * 2 + 1) + .map(|i| addon(&format!("A{i}"), &[], 11200)) + .collect(), + ); + let max = p.max_offset(); + + // Press well below the resting knob, three quarters down the bar. + let grab = slider_grab(0.75, 0.0, thumb_n); + assert_eq!(grab, thumb_n * 0.5, "off the knob = grab it by its centre"); + let f = slider_fraction(0.75, grab, 1.0, thumb_n).unwrap(); + p.scroll_to(f); + assert_eq!( + p.offset, + ((0.75 - thumb_n * 0.5) / (1.0 - thumb_n) * max as f32).round() as usize, + "the press itself moves the list — the knob's centre goes to the cursor" + ); + assert!(p.dirty, "and the move repaints"); + } + + /// The two directions are inverses at every stop: draw the knob where the offset says + /// ([`AddonsPanel::thumb_fraction`]), read the offset back out of that position + /// ([`AddonsPanel::scroll_to`]), and land on the same row. A bar that fails this creeps a row + /// per grab. + #[test] + fn the_knob_position_and_the_row_it_means_are_inverses() { + let mut p = panel( + (0..MAX_ROWS + 7) + .map(|i| addon(&format!("A{i}"), &[], 11200)) + .collect(), + ); + for row in 0..=p.max_offset() { + p.offset = row; + let drawn = p.thumb_fraction(); + p.offset = usize::MAX; // so a no-op `scroll_to` cannot pass by accident + p.scroll_to(drawn); + assert_eq!(p.offset, row, "knob at {drawn} must read back as row {row}"); + } + } + + /// A list that fits has no bar at all, and nothing may divide by its zero travel. + #[test] + fn a_list_that_fits_has_no_scroll_positions() { + let mut p = panel( + (0..MAX_ROWS) + .map(|i| addon(&format!("A{i}"), &[], 11200)) + .collect(), + ); + assert_eq!(p.max_offset(), 0); + assert_eq!(p.thumb_fraction(), 0.0); + p.scroll_to(1.0); + assert_eq!(p.offset, 0, "nowhere to scroll to"); + assert!(!p.dirty); + assert_eq!( + slider_fraction(0.5, 0.0, 1.0, 1.0), + None, + "a knob as long as its track reports no travel rather than dividing by zero" + ); + } + /// Scrolling clamps at both ends and never moves a list that fits. #[test] fn the_offset_clamps_to_the_list() { diff --git a/crates/benilla-app/src/glue/widgets.rs b/crates/benilla-app/src/glue/widgets.rs index db8f5876..44a1a706 100644 --- a/crates/benilla-app/src/glue/widgets.rs +++ b/crates/benilla-app/src/glue/widgets.rs @@ -4,6 +4,7 @@ //! component (`CreateAction`, `SelectAction`, …); each degrades to a plain-fill + text face //! without client art. +use benilla_ui::markup::{tokens, TokenKind}; use benilla_ui::widget::EditBoxState; use bevy::prelude::*; use bevy::text::LineHeight; @@ -93,6 +94,10 @@ pub(crate) fn overlay() -> Node { /// A glue string's look, before the outline treatment: content, authored size, color, and whether /// it may wrap (the ref FontStrings never break a label; only the info paragraphs wrap). +/// +/// `text` is **markup**, not a literal: its `|cAARRGGBB…|r` escapes decode to colour and `color` +/// is the base they override ([`markup_spans`]). Every glue string goes through that decode +/// because every `CSimpleFontString` in 5875 does. pub(crate) struct GlueText<'a> { pub text: &'a str, pub size: f32, @@ -123,7 +128,7 @@ pub(crate) fn outlined_text( node, wrapper_extra, text_extra, - &[(spec.text.to_string(), spec.color)], + &markup_spans(spec.text, spec.color, spec.wrap), spec.size, spec.wrap, font, @@ -131,13 +136,79 @@ pub(crate) fn outlined_text( ) } -/// [`outlined_text`] over **coloured spans** — WoW's `|cAARRGGBB…|r` inline markup rendered the -/// way the reference FontString renders it: one string, colour switching mid-run (the escape -/// grammar is `benilla_ui::markup`, byte-verified). The real text is a span tree (Bevy's -/// `Text` root + `TextSpan` children); the eight outline copies carry the flattened plain string, -/// which is identical geometry because the copies only exist to be a black ring. +/// Split a glue string into its coloured spans — WoW's `|cAARRGGBB…|r` inline markup, decoded by +/// the byte-verified grammar ([`benilla_ui::markup`], RF-0087) instead of drawn literally. +/// +/// **This lives in the primitive on purpose.** It used to be one call site's private helper (the +/// AddOns row title), which is exactly how the same `|cff0055FF…|r` came out coloured in a list +/// row and literal in the tooltip an inch to its left (B273): a FontString property implemented +/// per-caller is a property no caller reliably has. In the real client the decode is +/// `CSimpleFontString`'s own and takes no opt-in — `0x5c2810` parses `|c`/`|r`/`|H`/`|h`/`||` +/// unconditionally for every one of them (the flags word that could disable them has no writer +/// anywhere in the image). So every `GlueText` decodes, and there is no way to spawn one that +/// doesn't. +/// +/// `base` is the string's own colour; an escape overrides it until `|r`, and the escape's alpha +/// byte is discarded exactly as the client discards it (`0x5c2ab2`). Link escapes hide their +/// payload and keep their visible text — the glue has no clickable links, so a `|H…|h` reads as +/// its text. `||` draws one `|`. +/// +/// A line break (`\n`, `|n`) follows the string's own `wrap`: a real break where the ref lets the +/// string wrap (a tooltip paragraph), a space where it authored one line (a row title, a button +/// caption) — the flag is our stand-in for the multi-line bit the client tests, and collapsing +/// beats a label growing a second row inside a 20-unit slot. +/// +/// Never empty: an empty string yields one empty span, so [`outlined_spans`] always has a root. +pub(crate) fn markup_spans(text: &str, base: Color, wrap: bool) -> Vec<(String, Color)> { + let mut spans: Vec<(String, Color)> = Vec::new(); + let mut cur = String::new(); + let mut colour = base; + for (_, tok) in tokens(text) { + let switch = match tok.kind { + TokenKind::Color(rgba) => { + let [r, g, b, _] = rgba.to_f32_at(1.0); + Some(Color::srgb(r, g, b)) + } + TokenKind::ColorReset => Some(base), + TokenKind::EscapedPipe => { + cur.push('|'); + None + } + TokenKind::LineBreak => { + cur.push(if wrap { '\n' } else { ' ' }); + None + } + TokenKind::LinkOpen { .. } | TokenKind::LinkClose => None, + TokenKind::Char(c) => { + cur.push(c); + None + } + }; + if let Some(next) = switch { + if !cur.is_empty() { + spans.push((std::mem::take(&mut cur), colour)); + } + colour = next; + } + } + if !cur.is_empty() { + spans.push((cur, colour)); + } + if spans.is_empty() { + spans.push((String::new(), base)); + } + spans +} + +/// [`outlined_text`]'s span-tree body — the decoded spans of [`markup_spans`] drawn as one string +/// with the colour switching mid-run. The real text is Bevy's `Text` root + `TextSpan` children; +/// the eight outline copies carry the flattened plain string, which is identical geometry because +/// the copies only exist to be a black ring. +/// +/// **Private**: [`outlined_text`] is the one door in, so no caller can hand-build spans and skip +/// the markup decode (B273's shape — see [`markup_spans`]). #[allow(clippy::too_many_arguments)] -pub(crate) fn outlined_spans( +fn outlined_spans( parent: &mut ChildSpawnerCommands, node: Node, wrapper_extra: W, @@ -727,3 +798,90 @@ pub(crate) fn glue_button( ); }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// `|cAARRGGBB…|r` markup renders as colour, not as text — for **every** glue string, which + /// is the whole point of the decode living in the primitive. + /// + /// Two regressions are pinned here. The MapCoords one (a `## Title` of + /// `|cff00ff00MapCoords 0.32`, printed literally in the AddOns list), and B273: the same + /// screen's tooltip printed `|cff0055FFDeadly Boss Mod API|r` raw while the row an inch to + /// its left drew it blue, because only the row had opted into a decode. Nothing opts in now + /// — [`outlined_text`] is the one door and it always decodes — so the case worth testing is + /// the function, once, for both. + /// + /// The grammar is the byte-verified [`benilla_ui::markup`]; the escape's alpha byte is + /// discarded there, so only rgb reaches the span colour. + #[test] + fn colour_escapes_become_spans_not_text() { + let base = GOLD; + let green = Color::srgb(0.0, 1.0, 0.0); + let blue = Color::srgb(0.0, 0x55 as f32 / 255.0, 1.0); + + assert_eq!( + markup_spans("|cff00ff00MapCoords 0.32", base, false), + vec![("MapCoords 0.32".to_string(), green)], + "the whole-title escape colours everything and prints nothing" + ); + assert_eq!( + markup_spans("|cff0055FFDeadly Boss Mod API|r", base, true), + vec![("Deadly Boss Mod API".to_string(), blue)], + "B273: the tooltip's title wraps, and wrapping does not exempt it from the decode" + ); + assert_eq!( + markup_spans("A |cff00ff00B|r C", base, false), + vec![ + ("A ".to_string(), base), + ("B".to_string(), green), + (" C".to_string(), base) + ], + "|r restores the string's base colour" + ); + assert_eq!( + markup_spans("pipe || pipe", base, false), + vec![("pipe | pipe".to_string(), base)], + "|| draws one literal pipe" + ); + assert_eq!( + markup_spans("|xnot an escape", base, false), + vec![("|xnot an escape".to_string(), base)], + "a | that opens nothing well-formed is an ordinary character (the grammar's \ + fall-through arm)" + ); + assert_eq!( + markup_spans("", base, false), + vec![(String::new(), base)], + "an empty string still yields one span, so the text entity exists" + ); + } + + /// A `|H…|h` link keeps its visible text and drops its payload — the glue has no clickable + /// links, so the alternative is an addon note printing `|Hitem:1234|h`. + #[test] + fn a_link_escape_keeps_its_text_and_hides_its_payload() { + assert_eq!( + markup_spans("see |Hitem:1234:0:0:0|h[Thunderfury]|h now", GOLD, true), + vec![("see [Thunderfury] now".to_string(), GOLD)], + ); + } + + /// A line break follows the string's own `wrap`: a real break where the ref lets the string + /// wrap, a space where it authored one line. A 20-unit row title growing a second row is the + /// failure this avoids; a tooltip paragraph losing its author's break is the other. + #[test] + fn a_line_break_follows_the_strings_own_wrap_flag() { + assert_eq!( + markup_spans("one|ntwo", GOLD, true), + vec![("one\ntwo".to_string(), GOLD)], + "a wrapping string breaks where the author asked" + ); + assert_eq!( + markup_spans("one|ntwo", GOLD, false), + vec![("one two".to_string(), GOLD)], + "a one-line label collapses the break to a space" + ); + } +} diff --git a/crates/benilla-app/src/shaders/ui_quad.wgsl b/crates/benilla-app/src/shaders/ui_quad.wgsl index d5c59201..2c2e3766 100644 --- a/crates/benilla-app/src/shaders/ui_quad.wgsl +++ b/crates/benilla-app/src/shaders/ui_quad.wgsl @@ -64,6 +64,18 @@ // The fold runs on the GAMMA byte (after `linear_to_srgb`) because the reference's UI is 8-bit // arithmetic end to end; greying a linearized texel would land a different byte than the client's. @group(2) @binding(7) var desaturate: u32; +// The sampled texture is already PREMULTIPLIED — a portrait/paper-doll/dressing-room booth bake, +// and nothing else (see `UiQuad::premultiplied`). +// +// Every other texture this pass samples is authored STRAIGHT (a BLP's rgb means nothing where its +// alpha is 0), so the premultiply below has to happen here. A booth render target is the opposite +// by construction: its opaque geometry wrote `a = 1`, its alpha batches blended over that, and its +// ADDITIVE particles added light while contributing NO coverage (`wow_effect.wgsl` returns +// `(rgb·a, 0)`). Colour is emitted light, alpha is coverage — premultiplied. Weighting it by its own +// alpha again multiplies exactly the emitted light that sits over EMPTY pane space by zero, which is +// how a transparent-clear pane lost the R14 pauldrons' fire entirely and kept a weapon glow only +// where it overlapped the model's own opaque pixels. +@group(2) @binding(8) var premultiplied: u32; // ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`, // `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first @@ -101,21 +113,29 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { if desaturate != 0u { rgb = vec3(dot(texel, LUMA)); } - var a = t.a * c.a; + // `k` is the COVERAGE the UI itself imposes — the vertex colour's alpha (`SetAlpha`, the frame's + // inherited alpha) and the two masks. It is kept apart from the texel's OWN alpha `t.a` because + // the two premultiply differently: `k` scales a premultiplied source's colour and alpha alike, + // while `t.a` must weight the colour only when the source is straight. + var k = c.a; if circular != 0u { // Soft ~2%-of-width edge: reads as the ref's stencil at portrait size, no jaggies. - a *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2(0.5))); + k *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2(0.5))); } if mask_rect.z > mask_rect.x { let muv = (in.position.xy - mask_rect.xy) / (mask_rect.zw - mask_rect.xy); let inside = f32(all(muv >= vec2(0.0)) && all(muv <= vec2(1.0))); let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2(0.0), vec2(1.0)), 0.0).a; - a *= m * inside; + k *= m * inside; } + let a = t.a * k; // Premultiply in GAMMA (decision 0160's lesson, here for the UI): the hardware `SrcAlpha` factor // would weight a linearized colour and inflate every soft edge and every dim additive skirt. + // An already-premultiplied source (a booth bake) takes `k` alone — folding in `t.a` a second + // time is the double multiply that erased the panes' effects. + let weight = select(a, k, premultiplied != 0u); if additive != 0u { - return vec4(rgb * a, 0.0); + return vec4(rgb * weight, 0.0); } - return vec4(rgb * a, a); + return vec4(rgb * weight, a); } diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index f718d8fa..fc899b96 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -162,6 +162,27 @@ pub(crate) struct UiQuad { /// own `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` lands as greyscale *and* dim. /// Splits the run like `additive`/`circular`. pub desaturated: bool, + /// The sampled texture already carries **premultiplied** colour, so the shader must not weight + /// it by its own alpha a second time — the booth render targets ([`crate::portrait`]), and + /// nothing else. + /// + /// A booth that clears **transparent** (the `` body panes and the dressing room, + /// which composite over the page's own art — decision 1083) builds its target the way any + /// render-to-texture does: opaque geometry writes `a = 1`, alpha batches blend over it, and an + /// **additive particle adds light while contributing no coverage** (`wow_effect.wgsl`'s + /// `(rgb·a, 0)` under a premultiplied state). That buffer is premultiplied by construction — + /// colour is emitted light, alpha is coverage. Sampling it as *straight* alpha and applying the + /// usual `rgb·a` multiplies every effect hanging in EMPTY pane space by zero: the R14 + /// pauldrons' fire vanished outright, and a weapon glow survived only where it happened to + /// overlap the model's own opaque pixels — chopped to a hard edge at the silhouette (the + /// director's paper-doll/dressing-room report). The opaque-cleared round portraits hid it, + /// because `a = 1` everywhere makes the extra multiply a no-op — exactly the way opaque panels + /// hid the identical defect one level up, in this camera's own output blend (see + /// [`spawn_player_ui_camera`]'s note on `rgb·a²`). + /// + /// Splits the run like `additive`/`circular`; a booth target is its own texture anyway, so no + /// batching is lost. + pub premultiplied: bool, /// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped. pub clip: Option, /// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's @@ -207,6 +228,7 @@ impl Default for UiQuad { additive: false, circular: false, desaturated: false, + premultiplied: false, clip: None, rotation: 0.0, mask: None, @@ -287,6 +309,9 @@ pub(crate) struct UiQuadMaterial { /// Fold the texel to luminance — see [`UiQuad::desaturated`]. #[uniform(7)] desaturate: u32, + /// The texel is already premultiplied (a booth bake) — see [`UiQuad::premultiplied`]. + #[uniform(8)] + premultiplied: u32, /// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the /// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`] /// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking. @@ -534,6 +559,7 @@ struct Run { additive: bool, circular: bool, desaturated: bool, + premultiplied: bool, mask: Option, positions: Vec<[f32; 3]>, uvs: Vec<[f32; 2]>, @@ -547,6 +573,7 @@ impl Run { additive: bool, circular: bool, desaturated: bool, + premultiplied: bool, mask: Option, ) -> Self { Self { @@ -554,6 +581,7 @@ impl Run { additive, circular, desaturated, + premultiplied, mask, positions: Vec::new(), uvs: Vec::new(), @@ -638,6 +666,7 @@ type MatKey = ( bool, bool, bool, + bool, Option>, [u32; 4], ); @@ -770,6 +799,7 @@ fn rebuild_ui_mesh( && r.additive == q.additive && r.circular == q.circular && r.desaturated == q.desaturated + && r.premultiplied == q.premultiplied && r.mask == q.mask }); if !same_run { @@ -778,6 +808,7 @@ fn rebuild_ui_mesh( q.additive, q.circular, q.desaturated, + q.premultiplied, q.mask.clone(), )); } @@ -865,6 +896,7 @@ fn rebuild_ui_mesh( run.additive, run.circular, run.desaturated, + run.premultiplied, mask.as_ref().map(bevy::asset::Handle::id), mask_rect.to_array().map(f32::to_bits), ); @@ -877,6 +909,7 @@ fn rebuild_ui_mesh( texture: Some(run.texture), circular: u32::from(run.circular), desaturate: u32::from(run.desaturated), + premultiplied: u32::from(run.premultiplied), mask_rect, mask, }) diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 3ec78127..ec3bdc9f 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -555,12 +555,19 @@ pub(super) fn drive_script( .0 .insert(token.clone(), rect.width() / rect.height()); } - let handle = match booths.images.0.get(token) { - Some(PortraitSource::Live(h)) => Some(h.clone()), - Some(PortraitSource::File(p)) => assets - .as_mut() - .and_then(|a| a.sprite_texture(p, &mut images)), - None => None, + // The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in + // is an ordinary straight-alpha BLP. The quad pass has to be told which, or it + // premultiplies the bake a second time and erases every effect the pane draws + // over empty space (see [`crate::ui_pass::UiQuad::premultiplied`]). + let (handle, premultiplied) = match booths.images.0.get(token) { + Some(PortraitSource::Live(h)) => (Some(h.clone()), true), + Some(PortraitSource::File(p)) => ( + assets + .as_mut() + .and_then(|a| a.sprite_texture(p, &mut images)), + false, + ), + None => (None, false), }; let Some(handle) = handle else { continue; @@ -587,6 +594,7 @@ pub(super) fn drive_script( // rounds the square stand-in art the same way); `BenillaSetBoothTexture` // (the paper-doll model pane, decision 0208 §5) samples the bake square. circular, + premultiplied, clip, ..default() }); @@ -1149,4 +1157,47 @@ mod extract_gate_tests { "a moved frame must re-extract" ); } + + /// **A booth bake reaches the quad pass flagged PREMULTIPLIED, an ordinary region does not** + /// (decision 1347). This is the wiring half of the paper-doll/dressing-room fix, and it is the + /// half that can silently rot: the shader's `select(a, k, premultiplied)` is only correct if + /// exactly the render-target quads carry the flag. Drop it and every additive effect the pane + /// draws over EMPTY space is multiplied by its own zero alpha again — the R14 pauldrons' fire + /// gone, a weapon glow chopped at the model's silhouette (Goudy, `#bugs` 2026-07-27). + #[test] + fn a_booth_bake_quad_is_flagged_premultiplied_and_a_plain_one_is_not() { + let mut app = app_with_marker(); + // The paper doll's own binding — the square booth pane (`BenillaSetBoothTexture`). + app.world_mut() + .non_send_resource_mut::() + .run("BenillaSetBoothTexture(marker, 'paperdoll')") + .unwrap(); + // The booth publishes a live bake for that slot; without an entry the region draws nothing. + let bake = app + .world_mut() + .resource_mut::>() + .add(Image::default()); + app.world_mut().resource_mut::().0.insert( + "paperdoll".to_string(), + crate::portrait::PortraitSource::Live(bake.clone()), + ); + app.update(); + + let quads = &app.world().resource::().quads; + let pane = quads + .iter() + .find(|q| q.texture.as_ref() == Some(&bake)) + .expect("the booth pane's quad reached the pass"); + assert!( + pane.premultiplied, + "a render-target bake carries premultiplied colour — the pass must not re-weight it" + ); + assert!( + quads + .iter() + .filter(|q| q.texture.as_ref() != Some(&bake)) + .all(|q| !q.premultiplied), + "every straight-alpha region stays unflagged — the flag is the booth's alone" + ); + } } diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index ad4fbb79..87197643 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -155,6 +155,7 @@ pub(super) fn emit( additive: false, circular: false, desaturated: false, + premultiplied: false, clip: text_clip, rotation: 0.0, mask: None, @@ -338,6 +339,7 @@ pub(super) fn emit( additive: false, circular: false, desaturated: false, + premultiplied: false, clip: text_clip, rotation: 0.0, mask: None, diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 7622b6c1..52d2b608 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -133,6 +133,30 @@ pub(crate) fn arm_entry_ui_load(mut commands: Commands) { commands.insert_resource(PendingEntryUiLoad::default()); } +/// **Is the in-game UI still owed for this world entry?** True from `OnEnter(InWorld)` until +/// [`run_pending_entry_load`] has built the frame tree — the window in which the VM is still the +/// *boot* VM: strings, emote tokens and fonts, and not one frame. +/// +/// The run condition on [`crate::ui_unit::UnitFeed`], and the reason it exists (1348): the feeds +/// fire the login **one-shots** — `PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE`, the first +/// `UPDATE_EXHAUSTION` — and every one of them is latched by a [`super::VmMemo`] keyed on the VM's +/// *session*, which the entry load does not change (it loads files ONTO this VM). So an event +/// fired in this window is delivered to nobody and then never fires again for the whole session: +/// the frames built moments later do their first paint with no first paint. That is not a +/// hypothetical ordering — it is a RACE against the wire, which is why it took some logins and not +/// others, and why the symptom moved between characters. The self descriptor arriving inside the +/// [`ENTRY_LOAD_COVER_FRAMES`] deferral window is all it takes. +/// +/// The reference has no such window: `UI_Init 0x48fbf0` loads all of FrameXML and *then* fires the +/// world-enter cascade (`PLAYER_LOGIN` at `0x49094b`, `PLAYER_ENTERING_WORLD` at `0x490965`) from +/// inside itself, so a UI-less client never sees a unit event at all. Gating the feed here is that +/// same ordering, expressed against our deferred load: nothing is pushed and nothing is fired until +/// there is a UI to receive it, and the very next frame's feed — running against a fresh, unlatched +/// world — delivers the full set in order. +pub(crate) fn ingame_ui_pending(pending: Option>) -> bool { + pending.is_some() +} + /// `PreUpdate` (chained after [`run_pending_reload`] — same exclusive slot, and a reload must /// not interleave an armed entry load): run the armed entry load once the cover has presented. /// diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index ae59df60..7395d4d8 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -364,8 +364,8 @@ fn arbitrate_pointer_over_ui( /// lives there; this module keeps the per-frame bridge. See its header. mod lifecycle; pub(crate) use lifecycle::{ - end_ui_session, run_pending_reload, setup_script, AddOnIdentity, PendingEntryUiLoad, - ReloadUiPending, + end_ui_session, ingame_ui_pending, run_pending_reload, setup_script, AddOnIdentity, + PendingEntryUiLoad, ReloadUiPending, }; // Consumed only from other modules' test code (the emote-table checks, the harness's UI-init // tail, the quit-once pin) — a plain re-export would warn unused in a non-test build. diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index c783a685..896508be 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -675,3 +675,100 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() { drop(world); let _ = std::fs::remove_dir_all(&tmp); } + +// ───────────── The login one-shots wait for the in-game UI (decision 1348) ───────────── + +/// **The director's white XP bar, from the side that causes it.** +/// +/// The world-entry UI load is deferred a few covered frames (0962/1051), and the unit feed is not: +/// it fires `PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and the first `UPDATE_EXHAUSTION` +/// the moment our own descriptor lands. When that lands *inside* the deferral window the events go +/// to a VM with no frames, and because every one of them is latched by a [`super::VmMemo`] keyed on +/// the VM's session — which the entry load does not change — they never fire again. The frames +/// built moments later do their first paint with no first paint, which is why +/// `ExhaustionTick_Update` had never run and `ExhaustionLevelFillBar` was still wearing its +/// authored opaque white across the whole XP strip, with the tick parked at the strip's centre. +/// +/// It is a RACE against the wire, so it took some logins and not others. +/// +/// The probe is a plain frame in the boot VM registering the event the way FrameXML does — if the +/// feed runs at all in the window, it sees it. +#[test] +fn the_login_one_shots_wait_for_the_in_game_ui() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("oneshot"); + + let mut app = App::new(); + app.add_plugins(crate::ui_unit::UiUnitPlugin); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.insert_resource(crate::net::NetCommands(tx)); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.add_message::(); + super::setup_script(app.world_mut()); + + // The probe frame — FrameXML's own shape, in the VM that exists before the entry load. + app.world() + .non_send_resource::() + .run( + "EnteringWorldSeen = 0 \ + local f = CreateFrame(\"Frame\") \ + f:RegisterEvent(\"PLAYER_ENTERING_WORLD\") \ + f:SetScript(\"OnEvent\", function() \ + EnteringWorldSeen = EnteringWorldSeen + 1 end)", + ) + .expect("probe frame"); + + // Our own descriptor has landed — the condition the feed fires the one-shots on. + app.world_mut().spawn(( + crate::net::SelfPlayer, + crate::net::Guid(1), + crate::net::ObjectStore( + benilla_protocol::messages::ObjectFields::from_pairs(&[]) + .into_created(benilla_protocol::messages::ObjectType::Player), + ), + )); + + let seen = |app: &App| -> i64 { + app.world() + .non_send_resource::() + .eval::("return EnteringWorldSeen") + .expect("probe global") + }; + + // …but the in-game UI is still owed. Three frames inside the deferral window. + app.insert_resource(super::PendingEntryUiLoad::default()); + for frame in 1..=3 { + app.update(); + assert_eq!( + seen(&app), + 0, + "frame {frame}: the feed must not spend PLAYER_ENTERING_WORLD on a UI-less VM" + ); + } + + // The entry load has run (its own tests cover the timing) — the latch is gone, and the very + // next feed delivers the full set to the frames that now exist. + app.world_mut() + .remove_resource::(); + app.update(); + assert_eq!( + seen(&app), + 1, + "with the UI up the one-shot fires — once, on the first feed after the load" + ); + app.update(); + assert_eq!(seen(&app), 1, "and exactly once per world entry"); + + drop(app); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 53969e06..62a81aed 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -148,7 +148,15 @@ impl Plugin for UiUnitPlugin { fn build(&self, app: &mut App) { app.configure_sets( Update, - UnitFeed.after(benilla_world::schedule::WorldStage::Net), // the set's own doc: the why + UnitFeed + .after(benilla_world::schedule::WorldStage::Net) // the set's own doc: the why + // …and never before the in-game UI exists (1348). The whole SET, not just + // `feed_units`: every feed in it either fires a login one-shot or latches a + // per-VM memo, and both are lost forever against the boot VM. The window and + // the reference's own ordering: `ui_script::ingame_ui_pending`. + .run_if(bevy::ecs::schedule::common_conditions::not( + crate::ui_script::ingame_ui_pending, + )), ) .init_resource::() .add_message::() diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index beae61c2..8859a382 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -378,7 +378,19 @@ impl ObjectFields { /// (id, name, multiplier) triple: `1` = rested, `2` = normal (vmangos `REST_STATE_*`, /// `Player.h:673`). The server writes it with hysteresis off the rest pool — rested when the /// pool exceeds 10, normal when it falls to ≤1 (`SetRestBonus`) — so the byte, not the pool, - /// is the state authority. `None` when never sent; a fresh descriptor reads absent as normal. + /// is the state authority. + /// + /// **Absent reads `0`, not "normal"** — the struct's own absent-field law, and it is the + /// truth: vmangos always writes this word (`Player::Create` and `LoadFromDB` seed byte 3 with + /// `REST_STATE_NORMAL` and byte 1 with `0xEE`, so the field is never zero and never masked out + /// of a create block), so on a created store this IS the wire's byte. `0` is what the client's + /// own zero-initialized descriptor holds *before* the create lands, and it is a state + /// `Exhaustion.dbc` cannot map — `GetRestState()` answers the binary's `(nil, nil, nil)` fail + /// path for it, which the reference's own unguarded `exhaustionStateID >= 3` cannot survive. + /// The reference never meets that because FrameXML is up and the descriptor has landed before + /// any handler runs; we hold the same guarantee at the feed's own gate + /// (`ui_script::ingame_ui_pending`, 1348). Do not fabricate a `2` here — that hides an + /// ordering bug behind a value. pub fn player_rest_state(&self) -> Option { self.player_bytes_2().map(|b| (b >> 24) as u8) } diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index ea8a814e..9002f84a 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -28,7 +28,9 @@ use super::region::region_wrapper; use super::{event, Model, RegionData}; use crate::layout::Rect; use crate::order::DrawLayer; -use crate::widget::{FrameHandle, KindState, RegionKind, SliderState}; +use crate::widget::{ + slider_fraction, slider_grab, FrameHandle, KindState, RegionKind, SliderState, +}; /// Registry key of the Slider method table (the MAXCSTACK discipline: Lua-side root, named key). pub(super) const REG_SLIDER_METHODS: &str = "__benilla_slider_methods"; @@ -303,25 +305,26 @@ pub(super) fn thumb_rect( } } -/// The in-flight thumb drag (decision 0250 §5): the slider being dragged + the grab offset (the -/// cursor's distance from the thumb's leading edge along the axis at grab time), so the thumb tracks -/// the cursor without jumping. Engine C++-equivalent — no Lua, like the real client's scrollbar. +/// The in-flight thumb drag (decision 0250 §5): the slider being dragged + the grab offset +/// [`slider_grab`] returned, so the thumb tracks the cursor without jumping. Engine C++-equivalent +/// — no Lua, like the real client's scrollbar. #[derive(Clone, Copy)] pub(crate) struct SliderDrag { pub(crate) slider: FrameHandle, - /// cursor-along-axis − thumb-leading-edge at grab (VERTICAL: `y − thumb.top`; HORIZONTAL: - /// `x − thumb.left`). + /// The grab, in **distance from the track's leading edge** — the axis-neutral frame + /// [`slider_grab`]/[`slider_fraction`] are stated in, so this arena's y-up rects and the glue + /// screens' y-down nodes run the identical arithmetic. pub(crate) grab_offset: f32, } /// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is an **enabled** -/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). A press ON -/// the thumb grabs it **offset-preserving** (the grabbed point stays under the cursor — 0250 §5); -/// a press on the TRACK seats the thumb's **center** under the cursor — the value jumps there and -/// the same capture begins, one continuous press-drag gesture (decision 0989: "the only way to -/// set the bar was the arrows"). Returns `Some((frame id, new value))` when the press itself -/// changed the value (the track jump) — the caller fires `OnValueChanged` outside the model -/// borrow, exactly like [`drag_move`]'s changes — else `None`. +/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). Where the +/// press grabs is [`slider_grab`]'s to say — one law, shared with the glue lane's scrollbars, so +/// an in-game bar and a character-screen bar cannot drift apart on feel. +/// +/// Returns `Some((frame id, new value))` when the press itself changed the value (the track jump) +/// — the caller fires `OnValueChanged` outside the model borrow, exactly like [`drag_move`]'s +/// changes — else `None`. pub(super) fn begin_drag( model: &mut Model, hit: Option, @@ -342,27 +345,17 @@ pub(super) fn begin_drag( .and_then(|d| d.size); let trect = thumb_rect(r, size, vertical, fraction); let on_thumb = point_in_rect(trect, x, y); - let grab_offset = if on_thumb { - // The grabbed thumb point rides under the cursor for the whole drag. - if vertical { - y - trect.top - } else { - x - trect.left - } + // Everything below is stated as distance from the TRACK'S LEADING EDGE — the end the thumb + // sits at when the value is `min`. Vertical tracks run downward from `r.top` in this y-up + // arena, so that distance is `top − y`; horizontal ones run rightward from `r.left`. + let (cursor, thumb_lead, thumb_extent) = if vertical { + (r.top - y, r.top - trect.top, trect.top - trect.bottom) } else { - // Track press: grab the thumb by its center, so the seat-under-cursor jump below and - // every subsequent move share one formula ([`drag_move`]). The offset is - // cursor − leading edge at grab: +tw/2 horizontal; −th/2 vertical (top is the leading - // edge and sits ABOVE the center in y-up, hence bottom − top, which is −th). - if vertical { - (trect.bottom - trect.top) * 0.5 - } else { - (trect.right - trect.left) * 0.5 - } + (x - r.left, trect.left - r.left, trect.right - trect.left) }; model.slider_drag = Some(SliderDrag { slider: h, - grab_offset, + grab_offset: slider_grab(cursor, thumb_lead, thumb_extent), }); if on_thumb { return None; // no value change from the grab itself @@ -371,9 +364,10 @@ pub(super) fn begin_drag( } /// On a pointer move at `(x, y)` while a thumb is captured: recompute the value from the cursor -/// position (absolute, drift-free — the grab offset keeps the same thumb point under the cursor) and -/// store it. Returns `Some((frame id, new value))` if the value actually changed — the caller fires -/// `OnValueChanged` outside the model borrow — else `None` (no capture, no travel, or no change). +/// position via [`slider_fraction`] (absolute, drift-free — the grab offset keeps the same thumb +/// point under the cursor) and store it. Returns `Some((frame id, new value))` if the value +/// actually changed — the caller fires `OnValueChanged` outside the model borrow — else `None` (no +/// capture, no travel, or no change). pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)> { let SliderDrag { slider, @@ -388,19 +382,14 @@ pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)> .and_then(|rh| model.region_data.get(&rh)) .and_then(|d| d.size) .unwrap_or((r.width(), r.height())); - let fraction = if vertical { - let travel = r.height() - th; - if travel <= 0.0 { - return None; // nothing to scroll - } - ((r.top - (y - grab_offset)) / travel).clamp(0.0, 1.0) + // The same leading-edge frame [`begin_drag`] stored the grab in. + let (cursor, track_extent, thumb_extent) = if vertical { + (r.top - y, r.height(), th) } else { - let travel = r.width() - tw; - if travel <= 0.0 { - return None; - } - (((x - grab_offset) - r.left) / travel).clamp(0.0, 1.0) + (x - r.left, r.width(), tw) }; + // `None` = nothing to scroll (a thumb as long as its track). + let fraction = slider_fraction(cursor, grab_offset, track_extent, thumb_extent)?; let value = min + fraction * (max - min); let changed = match model.arena.frame_mut(slider).map(|f| &mut f.kind_state) { Some(KindState::Slider(s)) => s.store_value(value), diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 95b6a6b0..6642b9e1 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -602,6 +602,64 @@ impl Default for SliderState { } } +/// **The slider drag law — the one owner, for every lane.** +/// +/// Two pure functions, stated on a single axis in *distance from the track's leading edge* (the +/// end the thumb sits at when the value is `min`). That framing is what makes them lane-neutral: +/// the Lua widget arena measures y **up** and the Bevy-UI glue screens measure y **down**, and +/// both reduce to "how far along the track is this", so neither has to restate the arithmetic. +/// +/// Restating it is exactly what went wrong. The AddOns glue scrollbar shipped with a *decorative* +/// knob and no drag at all (1297 named the gap; B273's reporter hit it), the char-create glue +/// scrollbar grew its own accumulated-delta drag that drifts off the cursor, and the engine slider +/// held the real formula — three surfaces, one widget, no shared line of code. These two functions +/// are that shared line. +/// +/// **The law is benilla's, and it diverges from 1.12 deliberately in one place.** wow-re's +/// `system/ui/scratch/slider-mouse-law.md` (a §5 1v1, VERIFIED off the bytes of `0x789ba0` / +/// `0x789ca0`) settled `CSimpleSlider`: there is **no thumb hit-test in the class at all** — every +/// press, track or thumb, warps the value to seat the thumb's CENTER under the cursor and begins +/// one continuous drag capture, button-agnostic, clamped by SetValue. We take all of that except +/// the thumb press: ours grabs **offset-preserving**, so the point you grabbed stays under the +/// finger instead of jumping to the thumb's middle (decision 0992 §6 — kept as the less surprising +/// feel, and invisible on a reference-sized thumb either way). If that ever flips to byte-faithful, +/// it flips here, once, for every surface at the same time. +/// +/// [`slider_grab`] runs on the press, [`slider_fraction`] on the press and on every move after it. +/// +/// Where a press grabs the thumb: the offset from the thumb's leading edge that stays under the +/// cursor for the rest of the drag. +/// +/// `cursor` and `thumb_lead` are distances from the track's leading edge; `thumb_extent` is the +/// thumb's length along the axis. A press **on** the thumb keeps its grabbed point (0992 §6); a +/// press **off** it — anywhere on the track — grabs the thumb by its center, which is what makes +/// the value warp under the cursor and the drag continue as one gesture (1.12's whole law, and +/// 0989's directed requirement, which converged with it). +pub fn slider_grab(cursor: f32, thumb_lead: f32, thumb_extent: f32) -> f32 { + if cursor >= thumb_lead && cursor <= thumb_lead + thumb_extent { + cursor - thumb_lead + } else { + thumb_extent * 0.5 + } +} + +/// Cursor → fraction of travel, absolute and drift-free: the thumb's leading edge goes to +/// `cursor − grab`, and that lands at `fraction × (track_extent − thumb_extent)`. +/// +/// `None` when the travel is zero or negative — a thumb as long as its track has nowhere to go, so +/// there is no value to compute and the caller must leave the slider alone rather than divide by +/// zero. Otherwise clamped to `[0, 1]`: dragging past either end pins there, exactly as the +/// client's own out-of-span presses pin through SetValue's clamp. +pub fn slider_fraction( + cursor: f32, + grab: f32, + track_extent: f32, + thumb_extent: f32, +) -> Option { + let travel = track_extent - thumb_extent; + (travel > 0.0).then(|| ((cursor - grab) / travel).clamp(0.0, 1.0)) +} + impl SliderState { /// The value fraction `(value − min) / (max − min)`, clamped to `[0, 1]`; a degenerate range /// (`max <= min`, an unscrollable slider) is `0.0` — the thumb sits at the track's start. diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index ac4a7ac9..44419b64 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -173,11 +173,12 @@ impl Arena { mod kinds; pub use kinds::{ - ButtonFont, ButtonState, ColorSelectState, CooldownState, EditAction, EditBoxState, - EditOutcome, EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine, - MinimapState, RegionKind, ScrollFrameState, ScrollingMessageState, SliderState, StatusBarState, - TooltipState, COOLDOWN_FLASH_SECS, MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS, - TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, + slider_fraction, slider_grab, ButtonFont, ButtonState, ColorSelectState, CooldownState, + EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState, + MessageFrameState, MessageLine, MinimapState, RegionKind, ScrollFrameState, + ScrollingMessageState, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS, + MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, + TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, }; // ─────────────────────────────────────────────────────────────────────────────────────────────