addon-screen polish, and item glows return to the paper doll

- every glue-screen string renders its colour markup — the addon tooltip included, which used to print its |cff… codes raw
- the addon list's scroll knob finally drags, on one shared slider law: press anywhere on the track and the knob warps under your cursor and follows, exactly as 1.12's slider behaves
- item effects survive the character sheet and dressing room again — a booth bake is premultiplied by construction and is no longer premultiplied twice, which had been erasing every glow hanging in the space beside the model
- the login one-shot announcements wait for the in-game UI to exist before they fire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:47:33 +07:00
co-authored by Claude Fable 5
parent 77b54ab3cc
commit 38d8502bb7
14 changed files with 782 additions and 179 deletions
+258 -108
View File
@@ -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 `<Slider>` 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<f32>,
/// 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<Res<GlueStrings>>,
mut script: Option<NonSendMut<UiScript>>,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
mut sounds: MessageWriter<GlueSound>,
presses: Query<(&AddonsAction, &Interaction), Changed<Interaction>>,
hovers: Query<(Entity, &AddonsAction, &Interaction)>,
lit: Query<(&Interaction, &Children), With<HoverLit>>,
mut hilights: Query<&mut Visibility, With<Hilight>>,
band: Query<(&ComputedNode, &UiGlobalTransform), With<ScrollBand>>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
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() {
+165 -7
View File
@@ -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<W: Bundle, T: Bundle>(
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<W: Bundle, T: Bundle>(
)
}
/// [`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<W: Bundle, T: Bundle>(
fn outlined_spans<W: Bundle, T: Bundle>(
parent: &mut ChildSpawnerCommands,
node: Node,
wrapper_extra: W,
@@ -727,3 +798,90 @@ pub(crate) fn glue_button<A: Component>(
);
});
}
#[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"
);
}
}
+25 -5
View File
@@ -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<uniform> 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<uniform> 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<f32> {
if desaturate != 0u {
rgb = vec3<f32>(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<f32>(0.5)));
k *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2<f32>(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<f32>(0.0)) && all(muv <= vec2<f32>(1.0)));
let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2<f32>(0.0), vec2<f32>(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<f32>(rgb * a, 0.0);
return vec4<f32>(rgb * weight, 0.0);
}
return vec4<f32>(rgb * a, a);
return vec4<f32>(rgb * weight, a);
}
+33
View File
@@ -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 `<PlayerModel>` 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<Rect>,
/// 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<UiQuadMask>,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
@@ -547,6 +573,7 @@ impl Run {
additive: bool,
circular: bool,
desaturated: bool,
premultiplied: bool,
mask: Option<UiQuadMask>,
) -> 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<AssetId<Image>>,
[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,
})
@@ -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::<UiScript>()
.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::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
"paperdoll".to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
app.update();
let quads = &app.world().resource::<UiQuads>().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"
);
}
}
@@ -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,
@@ -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<Res<PendingEntryUiLoad>>) -> 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.
///
+2 -2
View File
@@ -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.
@@ -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::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
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::<benilla_ui::script::UiScript>()
.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::<benilla_ui::script::UiScript>()
.eval::<i64>("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::<super::PendingEntryUiLoad>();
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);
}
+9 -1
View File
@@ -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::<UnitFeedState>()
.add_message::<UnitCombatFeedback>()
@@ -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<u8> {
self.player_bytes_2().map(|b| (b >> 24) as u8)
}
+33 -44
View File
@@ -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<FrameHandle>,
@@ -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),
+58
View File
@@ -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<f32> {
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.
+6 -5
View File
@@ -173,11 +173,12 @@ impl<T> Arena<T> {
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,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────