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 924cdd903f
commit 8c19ed0836
14 changed files with 782 additions and 179 deletions
@@ -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);
}