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:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user