the loading tip draws, its backdrop holds, and indoor trails go warm

- the loading screen's tip of the day is on screen: it was picked and saved but never painted, and once painted it was wiped by the login round trip (2083, 2081)
- the backdrop no longer switches mid-load — logging back in to an expired dungeon held its art for a second and then flipped to the continent's; the art latches at the raise, keyed on the texture, not the map id (2081, 2087)
- a weapon trail indoors takes the room's warm light instead of the sky's ambient, the wearer's own light being the reference's term (2086)
- the glue dialog moves out of the login screen into the shared glue layer, so the select screen's refusals and the login's errors are one widget (2084)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-08 16:22:57 +07:00
co-authored by Claude Fable 5.1
parent 663851b17d
commit 6e3483c1e7
15 changed files with 1504 additions and 720 deletions
+7 -1
View File
@@ -51,10 +51,11 @@ pub(super) fn seed_ui_fixture(
mut selection: ResMut<crate::target::Selection>,
mut player: ResMut<crate::player::Player>,
// Bundled: Bevy systems cap at 16 top-level params — a nested tuple is one param.
(mut actions, mut bank, mut exit): (
(mut actions, mut bank, mut exit, mut loading): (
ResMut<crate::ui_action::PlayerActions>,
ResMut<crate::ui_bank::BankOpen>,
MessageWriter<AppExit>,
ResMut<crate::loading_screen::LoadingScreen>,
),
) {
// A glue-screen capture has no world scenario, and no glue screen opens a UI fixture.
@@ -156,6 +157,11 @@ pub(super) fn seed_ui_fixture(
// IS the fixture. The `script.is_none()` refusal above still guards it: these scenarios
// photograph the player UI, so a run without a VM is as wrong for them as for any other.
UiFixture::Bare => {}
// The one fixture that opens no window: it raises the world-entry loading screen over the
// settled scene and pins it there, tip and all. `hold_for_capture` sets the `Pick` edge;
// `crate::game_tip::drive_game_tip` paints it on the next frame, and the stability watch
// settles on the held image.
UiFixture::LoadingTip => loading.hold_for_capture(scenario.map),
UiFixture::Merchant => {
names.insert_creature(
NPC_ENTRY,
@@ -79,6 +79,15 @@ pub(super) enum UiFixture {
/// render): slot icons, the attribute/resistance panes with buff coloring, melee + ranged
/// blocks, the ammo count, the level line.
Character,
/// **The loading screen, held up, with the tip of the day on it** (decision 2083).
///
/// Not a window: it raises the world-entry loading screen — per-map backdrop, progress bar,
/// and the `TipEdge::Pick` that chooses a `GameTips.dbc` row — and pins it so the harness has
/// a still frame to settle on. Every other screen in the client can be photographed by
/// standing in front of it; this one is up for a second on a path nobody can pause, which is
/// exactly why its tip could ship dead, log `tip 0 of 74` twice in a live smoke run, and be
/// caught by nothing at all.
LoadingTip,
/// The shared **StaticPopup** plate, shown as the group-invite dialog (`PARTY_INVITE`).
///
/// Every other StaticPopup customer in the tree raises its dialog over another window's opaque
@@ -1042,6 +1051,17 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::Social),
},
// The world-entry loading screen, held up with its tip of the day. Run with
// `WOW_CAPTURE=loading-tip`. The backdrop is whatever `Map.dbc` → `LoadingScreens.dbc` gives
// this scenario's map, so it photographs the art chain and the bar as well as the tip.
Scenario {
name: "loading-tip",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::LoadingTip),
},
// The era Options window over the ground scene. Run with
// `WOW_CAPTURE=ui-options`.
Scenario {
+1 -1
View File
@@ -38,7 +38,7 @@ pub(super) fn select_input(
mut next: ResMut<NextState<ClientState>>,
mut sounds: MessageWriter<GlueSound>,
mut intent: ResMut<crate::login::LoginIntent>,
glue_dialog: Res<crate::login::LoginDialog>,
glue_dialog: Res<crate::glue::dialog::GlueDialog>,
time: Res<Time>,
mut last_click: Local<Option<(usize, f32)>>,
) {
+11 -9
View File
@@ -132,12 +132,12 @@ impl Plugin for CharSelectPlugin {
input::rotate_model,
debug_select_dialog,
dialog::drive_delete_dialog,
// The shared glue dialog (`crate::login::drive_dialog`) — the reference's
// one `GlueDialog`, which on this screen carries the refused character
// login. Its home is the login module only because that is where it was
// first needed; the widget is the glue layer's, and the two screens run
// the same instance of it rather than each keeping a copy.
crate::login::drive_dialog,
// The reference's one `GlueDialog` (`crate::glue::dialog`), which on this
// screen carries the refused character login. Both glue screens run the
// same system over the same resource; what a press *means* is answered
// per-screen, and an `Error` — the only kind reachable here — needs no
// answer at all.
crate::glue::dialog::drive_glue_dialog,
// Before the list refresh, and before `select_input` reads a click that
// landed on the panel rather than the screen (decision 1196).
debug_select_addons,
@@ -608,7 +608,7 @@ fn back_on_login_refused(
mut msgs: MessageReader<CharacterLoginFailedMessage>,
mut roster: ResMut<Roster>,
mut next: ResMut<NextState<ClientState>>,
mut dialog: ResMut<crate::login::LoginDialog>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
strings: Option<Res<GlueStrings>>,
) {
let Some(msg) = msgs.read().last().copied() else {
@@ -1248,7 +1248,7 @@ mod tests {
.init_resource::<crate::ui_script::UiKeyboardCapture>()
.add_message::<EnteredWorldMessage>()
.add_message::<CharacterLoginFailedMessage>()
.init_resource::<crate::login::LoginDialog>()
.init_resource::<crate::glue::dialog::GlueDialog>()
.add_systems(Update, (enter_on_connected, back_on_login_refused).chain());
app.world_mut().resource_mut::<Roster>().pending_pick = Some(7);
@@ -1276,7 +1276,9 @@ mod tests {
// And the player is told. No GlueStrings in this App, so this is the fallback literal —
// the shipped sentence for vmangos's `1` is asserted against the real chain below.
assert_eq!(
app.world().resource::<crate::login::LoginDialog>().text,
app.world()
.resource::<crate::glue::dialog::GlueDialog>()
.text,
"World server is down",
"a refusal the player cannot see is the bug this whole path exists to end",
);
+2 -5
View File
@@ -657,7 +657,7 @@ pub(super) fn exit_select(
roots: Query<Entity, With<CharSelectUi>>,
mut preview: ResMut<GluePreview>,
mut dialog: ResMut<super::dialog::DeleteDialog>,
mut glue_dialog: ResMut<crate::login::LoginDialog>,
mut glue_dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
for e in &roots {
commands.entity(e).despawn();
@@ -672,8 +672,5 @@ pub(super) fn exit_select(
// `exit_login` closes it on the same edge for the same reason. The tree goes with it **here**
// rather than being left to the driver: the driver runs on the glue screens only, so on the
// edges out of the glue layer there would be nobody left to despawn the root.
if let Some(root) = glue_dialog.root.take() {
commands.entity(root).despawn();
}
glue_dialog.close();
glue_dialog.dismiss(&mut commands);
}
+265 -81
View File
@@ -28,7 +28,15 @@
//! **Only on the glue→world transition.** The setter `0x406630` (a seven-byte
//! `mov [0x882e10],ecx; ret`) has exactly one caller, and neither `SMSG_TRANSFER_PENDING` arm sets
//! it — so an in-world portal or worldport screen carries **no tip**. That is why this module hangs
//! off the loading screen's `world entry` raise and clears on every other one.
//! off the loading screen's `world entry` raise.
//!
//! **It comes down with the screen, never with a raise.** `[0x882e10]`'s only other writer
//! image-wide is `0x407f2b`, inside the dismiss `0x407e80` — so the two edges are *pick at the
//! entry raise* and *clear at the dismiss*, and nothing that happens mid-load can take a tip away.
//! This module used to clear on every non-entry raise, which read the same for an in-world screen
//! (the tip was already down) and was wrong for exactly one case: the login snap
//! `SMSG_LOGIN_VERIFY_WORLD`, which benilla was treating as a fresh load, wiped the tip a server
//! round-trip into **every** world entry.
//!
//! The reference lays the text out **once per raise** and draws it every frame, between the
//! background quad and the progress bar. Its placement, in the screen's own `[0,1]` ortho:
@@ -37,14 +45,28 @@
//! at 16:9); position `(0.5 − s·0.5, 0.1 + yoff, 0)`; `justifyH = 0` (left); wrap width `s`; body
//! `0xd7c8c8c8` ARGB with an opaque black shadow at `(+0.001, −0.001)`.
//!
//! **`yoff` is `(1 − a)·0.5` and we do not apply it, on purpose.** wow-re left that term
//! *unreconciled*: `0x406a60` already applies the letterbox as a viewport, and `yoff` is
//! bit-for-bit the same `(1 − a)·0.5` added again inside it, which may double-count on a display
//! narrower than 4:3. benilla's loading screen is a **4:3 content box by construction** (the root
//! letterboxes and the area is `100vh × 4/3`), and at 4:3 the reference's own `yoff` is exactly
//! zero — so placing the tip in that box at `y = 0.1` is the reference's layout on the one aspect
//! where the ambiguity cannot bite. If wow-re settles the term differently the fix is this comment
//! and one constant.
//! **Placing it in the 4:3 box is not an approximation of that — it is the same numbers.** The
//! reference's `[0,1]` ortho spans the whole window, so its `s` and `left` both carry `a`; ours are
//! percentages of a box that is `H` tall and `4H/3` wide. Multiply them out and the `a` cancels:
//!
//! ```text
//! ref wrap = s·W = 515·W / (a·1024) = 515·H / (1024·0.75) = 0.6706·H
//! our wrap = s₁·(4H/3) = (515/1024)·(4H/3) = 0.6706·H
//! ref left = (0.5 − s/2)·W = 0.5·W − 0.3353·H
//! our left = (W − 4H/3)/2 + (0.5 − s₁/2)·(4H/3) = 0.5·W − 0.3353·H
//! ```
//!
//! …for **every** aspect, not just 4:3 — the reference's own wrap column is a constant multiple of
//! the window HEIGHT, which is exactly what a height-fit letterboxed box makes it. And `yoff` is
//! settled rather than dodged: `0x4066b9`'s `fcom; fnstsw; test ah,5; jp` takes the `(1 − a)·0.5`
//! arm **only for `a < 1`**, so on any window at least 4:3 wide the term is zero and the two
//! layouts agree bit for bit. Narrower than 4:3 they part — but so does the whole loading screen
//! there (our box overflows where the reference letterboxes top and bottom), and that is the
//! screen's difference to answer, not the tip's.
//!
//! `TIP_BOTTOM`'s `0.1` is not a round number either: `0x4066ee` builds it as `0.05·0.5 + 0.075`,
//! which is the bar border's own `halfH·0.5 + cy` — **the tip's baseline IS the top edge of the
//! progress bar**, so the block always sits directly on it.
//!
//! The `|cffffd100Tip:|r ` prefix and the trailing `\r\n` are **in the DBC data**, and the escapes
//! *are* interpreted (`0x5c28af`, flags bit `0x800` clear), so the gold "Tip:" is markup rather
@@ -69,7 +91,13 @@ const TIP_SCALE: f32 = 515.0 / 1024.0;
/// just above the progress bar's `cy = 0.075`.
const TIP_BOTTOM: f32 = 0.1;
/// `0.018 · [0x832a48]` — the font height as a fraction of the viewport height.
/// **The font height is 1.8 % of the viewport HEIGHT**, and the `[0x832a48]` in `0x406659`'s
/// `0.018 · [0x832a48]` is the unit conversion, not part of the number. `0x41ad10` writes that
/// slot as `1/√(x²+1)` and its neighbour `[0x832a44]` as `x/√(x²+1)` — height/diagonal and
/// width/diagonal, i.e. the pair that turns a height- or width-fraction into the diagonal-
/// normalized units `0x44d040` and the placement round trip both work in (`0x41ae70 = x·[832a48]`
/// beside `0x41ae60 = x·[832a44]`). Reading the 0.6 the shipped `.data` holds as a factor to apply
/// would shrink the line to five-eighths of the reference's.
const TIP_FONT_FRACTION: f32 = 0.018;
/// The body colour `0xd7c8c8c8` (ARGB) and its shadow offset, in the same `[0,1]` space.
@@ -213,6 +241,30 @@ pub(crate) const fn base_color() -> Color {
TIP_COLOR
}
/// **The tip node's components, in one place.** The loading screen spawns this under its 4:3
/// content box; [`drive_game_tip`] queries it back. They were written twice — the tuple in
/// `loading_screen.rs`, the query here — with nothing tying the two together.
pub(crate) fn tip_bundle() -> impl Bundle {
(
Text::new(String::new()),
TextLayout {
linebreak: LineBreak::WordBoundary,
justify: Justify::Left,
},
TextFont::default(),
TextColor(TIP_COLOR),
TextShadow::default(),
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
bottom: Val::Percent(0.0),
width: Val::Percent(0.0),
..default()
},
Visibility::Hidden,
)
}
pub(crate) struct GameTipPlugin;
impl Plugin for GameTipPlugin {
@@ -262,79 +314,91 @@ fn drive_game_tip(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut persist: ResMut<crate::cvars::CvarPersist>,
) {
if let Some(edge) = screen.take_tip_edge() {
let next = match edge {
crate::loading_screen::TipEdge::Pick => raise(
&mut tips,
setting.show,
roster
.as_ref()
.is_some_and(|r| r.pending_level() == Some(0)),
setting.next,
),
crate::loading_screen::TipEdge::Clear => {
clear(&mut tips);
None
}
};
if let Some(next) = next {
// The cursor moves only when a tip was actually shown — a suppressed tip freezes it.
setting.next = i64::from(next);
if let Some(mut script) = script {
crate::cvars::write_host_cvar(
&mut script,
&mut persist,
"gameTip",
&next.to_string(),
);
}
// The run's own evidence: which row this screen carries and where the cursor lands.
// A tip is a picture on a screen that is up for a second, so "did it pick one" is a
// question for a log line, not for a capture.
info!("loading screen: tip {} of {}", next - 1, tips.catalog.len());
let Some(edge) = screen.take_tip_edge() else {
return;
};
let next = match edge {
crate::loading_screen::TipEdge::Pick => raise(
&mut tips,
setting.show,
roster
.as_ref()
.is_some_and(|r| r.pending_level() == Some(0)),
setting.next,
),
crate::loading_screen::TipEdge::Clear => {
clear(&mut tips);
None
}
// The text is laid out once per raise, not per frame, exactly as the reference does.
let Ok((entity, mut n, mut font, mut shadow, mut vis)) = node.single_mut() else {
return;
};
let Some(tip) = tips.shown() else {
*vis = Visibility::Hidden;
commands.entity(entity).despawn_related::<Children>();
return;
};
// The 4:3 content box is `100vh` tall and `100vh · 4/3` wide, so the window's height is the
// box's height and the box's width follows from it.
let height = windows.iter().next().map_or(768.0, |w| w.height());
let l = layout(height * 4.0 / 3.0, height);
let (left, bottom, width) = geometry();
n.left = Val::Percent(left);
n.bottom = Val::Percent(bottom);
n.width = Val::Percent(width);
font.font = crate::char_select::wow_font(&assets);
font.font_size = l.font_size;
shadow.offset = l.shadow;
shadow.color = Color::BLACK;
*vis = Visibility::Inherited;
};
if let Some(next) = next {
// The cursor moves only when a tip was actually shown — a suppressed tip freezes it. This
// is `EnterWorld`'s own bookkeeping and belongs to the PICK, not to the draw: it stands
// even on a frame the paint below cannot complete.
setting.next = i64::from(next);
if let Some(mut script) = script {
crate::cvars::write_host_cvar(&mut script, &mut persist, "gameTip", &next.to_string());
}
}
// The coloured runs: the first is the `Text` root's own, the rest are `TextSpan` children.
let runs = spans(tip, base_color());
let mut e = commands.entity(entity);
e.despawn_related::<Children>();
match runs.split_first() {
Some(((head, head_color), rest)) => {
e.insert((Text::new(head.clone()), TextColor(*head_color)));
let (rest, tf) = (rest.to_vec(), font.clone());
e.with_children(|c| {
for (text, color) in rest {
c.spawn((TextSpan::new(text), tf.clone(), TextColor(color)));
}
});
}
None => {
e.insert(Text::new(String::new()));
*vis = Visibility::Hidden;
}
// The text is laid out once per raise, not per frame, exactly as the reference does.
let Ok((entity, mut n, mut font, mut shadow, mut vis)) = node.single_mut() else {
// Our own `setup_loading_screen` spawns this node, so a miss is a STRUCTURAL bug — the
// bundle no longer answering the shape of this query — and never a state a run can
// legitimately be in. It was a silent `return` for one release, and it cost the whole
// feature while every other instrument read green (decision 2083).
warn!("loading screen: the tip node is missing — no tip can draw");
return;
};
let Some(tip) = tips.shown() else {
*vis = Visibility::Hidden;
commands.entity(entity).despawn_related::<Children>();
return;
};
// The 4:3 content box is `100vh` tall and `100vh · 4/3` wide, so the window's height is the
// box's height and the box's width follows from it.
let height = windows.iter().next().map_or(768.0, |w| w.height());
let l = layout(height * 4.0 / 3.0, height);
let (left, bottom, width) = geometry();
n.left = Val::Percent(left);
n.bottom = Val::Percent(bottom);
n.width = Val::Percent(width);
font.font = crate::char_select::wow_font(&assets);
font.font_size = l.font_size;
shadow.offset = l.shadow;
shadow.color = Color::BLACK;
*vis = Visibility::Inherited;
// The coloured runs: the first is the `Text` root's own, the rest are `TextSpan` children.
let runs = spans(tip, base_color());
let painted = !runs.is_empty();
let mut e = commands.entity(entity);
e.despawn_related::<Children>();
match runs.split_first() {
Some(((head, head_color), rest)) => {
e.insert((Text::new(head.clone()), TextColor(*head_color)));
let (rest, tf) = (rest.to_vec(), font.clone());
e.with_children(|c| {
for (text, color) in rest {
c.spawn((TextSpan::new(text), tf.clone(), TextColor(color)));
}
});
}
None => {
e.insert(Text::new(String::new()));
*vis = Visibility::Hidden;
}
}
// The run's own evidence — and it sits HERE, past the query, the layout and the runs, because
// the line it replaces sat in front of all three: it reported a row picked over a screen that
// drew nothing, and a live smoke run printed it twice while the feature was entirely dead.
if let (Some(next), true) = (next, painted) {
info!(
"loading screen: tip {} of {} on screen",
next - 1,
tips.catalog.len()
);
}
}
@@ -380,7 +444,7 @@ pub(crate) fn raise(
}
}
/// Every other raise — an in-world portal or worldport — carries no tip.
/// The dismiss (`0x407f2b`, inside `0x407e80`) — the only thing that takes a tip down.
pub(crate) fn clear(tips: &mut GameTips) {
tips.shown = None;
}
@@ -484,4 +548,124 @@ mod tests {
"0.018 of the box height"
);
}
/// An app holding the real node and the real system, one tip in the table.
fn tip_app() -> (App, Entity) {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
app.init_asset::<bevy::text::Font>();
app.init_resource::<crate::cvars::CvarPersist>();
app.init_resource::<GameTipSetting>();
app.insert_resource(GameTips {
catalog: GameTipsCatalog::from_tips(vec![
"|cffffd100Tip:|r Talk to the innkeeper.\r\n".to_string(),
]),
shown: None,
});
app.init_resource::<crate::loading_screen::LoadingScreen>();
let tip = app
.world_mut()
.spawn((crate::loading_screen::LoadingTip, tip_bundle()))
.id();
app.add_systems(Update, drive_game_tip);
(app, tip)
}
fn set_edge(app: &mut App, edge: crate::loading_screen::TipEdge) {
app.world_mut()
.resource_mut::<crate::loading_screen::LoadingScreen>()
.tip_edge = Some(edge);
}
/// **The tip has to reach the node, and only a run of the real system can say that.** Every
/// pure-function test here covers the *pick* — and the pick is exactly the half that worked: it
/// advanced the cursor, wrote `gameTip`, and logged "tip N of 74" over a screen that drew
/// nothing, because the spawned bundle was one component short of what [`drive_game_tip`]'s
/// query asks for and the missed `single_mut()` returned in silence. So this drives the
/// system over the bundle the loading screen actually spawns and looks at the glass end.
#[test]
fn a_pick_paints_the_node_the_loading_screen_spawns() {
let (mut app, tip) = tip_app();
set_edge(&mut app, crate::loading_screen::TipEdge::Pick);
app.update();
let w = app.world();
assert_eq!(
w.get::<Visibility>(tip),
Some(&Visibility::Inherited),
"the tip node never came out of hiding"
);
assert_eq!(
w.get::<Text>(tip).map(|t| t.0.as_str()),
Some("Tip:"),
"the gold prefix is the root run"
);
let kids = w
.get::<Children>(tip)
.expect("the body sentence is a TextSpan child");
assert_eq!(kids.len(), 1, "one run after the prefix");
assert_eq!(
w.get::<TextSpan>(kids[0]).map(|t| t.0.as_str()),
Some(" Talk to the innkeeper.")
);
// …and the geometry the paint writes, which is what puts it above the bar rather than at
// the box's corner where the bundle leaves it.
let node = w.get::<Node>(tip).expect("Node");
assert_eq!(node.bottom, Val::Percent(geometry().1));
assert_eq!(node.width, Val::Percent(geometry().2));
assert!(
w.get::<TextFont>(tip).is_some_and(|f| f.font_size > 0.0),
"the font height is resolved from the window"
);
// The cursor moved with it: `gameTip` holds the NEXT row.
assert_eq!(w.resource::<GameTipSetting>().next, 1);
}
/// **A tip outlives every raise and dies with the screen** — [`crate::loading_screen`]'s law
/// (decision 2081), seen from the painting end. That module's own test asserts which *edges*
/// the state machine emits; this one asserts what the painter does with them, and the case that
/// matters is the one with no edge at all: a raise landing on a live screen must leave the line
/// exactly where it is, same text and same cursor. Between them the two cover the login snap
/// that used to wipe the tip a server round-trip into every world entry.
#[test]
fn the_tip_outlives_a_second_raise_and_dies_with_the_screen() {
let (mut app, tip) = tip_app();
set_edge(&mut app, crate::loading_screen::TipEdge::Pick);
app.update();
assert_eq!(
app.world().get::<Visibility>(tip),
Some(&Visibility::Inherited)
);
// The destination snap: `drive_loading_screen` raises again and sets NO edge.
app.update();
app.update();
assert_eq!(
app.world().get::<Visibility>(tip),
Some(&Visibility::Inherited),
"a raise with no edge must leave the line where it is"
);
assert_eq!(
app.world().get::<Text>(tip).map(|t| t.0.as_str()),
Some("Tip:")
);
assert_eq!(
app.world().resource::<GameTipSetting>().next,
1,
"and it must not re-pick: one screen, one row"
);
// The dismiss.
set_edge(&mut app, crate::loading_screen::TipEdge::Clear);
app.update();
assert_eq!(
app.world().get::<Visibility>(tip),
Some(&Visibility::Hidden),
"the pending tip dies with the screen"
);
assert!(app
.world()
.get::<Children>(tip)
.is_none_or(|c| c.is_empty()));
}
}
+579
View File
@@ -0,0 +1,579 @@
//! **The glue layer's one dialog** — the reference's shared `GlueDialog`.
//!
//! `GlueDialog.xml` is a `GlueParent` child, not a screen: `GlueDialog_Show` texts it, sizes it to
//! its content and `Show()`s it over whichever glue screen happens to be up, and every screen
//! raises the same frame through `OPEN_STATUS_DIALOG`. The realm list is built the same way and
//! for the same reason (2072).
//!
//! Ours lived inside [`crate::login`] until 2084, because the login screen was the only thing that
//! had ever needed one. That stopped being true when a refused *character* login had to be said on
//! the select screen (2071): the choice was a second copy of the widget over there, or this — the
//! move `crate::glue`'s own charter asks for, so "what the screens share" cannot fork.
//!
//! **The split, and where the line is.** This module owns the *widget*: the tree, the text, the
//! spawn/despawn, which key answers which button, the click sound. It does not own what a press
//! *means* — a realmlist Okay that refuses a bad address and stays open, a status Cancel that
//! abandons a login attempt in flight. Those belong to the screen that opened the dialog, so a
//! press is published as [`GlueDialogAnswer`] and answered by [`crate::login::answer_dialog`].
//! The one exception is [`DialogKind::Error`], whose entire behaviour is "say a thing, and its
//! Okay dismisses it" — [`drive_glue_dialog`] ends those itself, which is what lets a screen raise
//! one without also writing its ending.
//!
//! [`drive_glue_dialog`] runs inside **each screen's own chain** rather than being hoisted out
//! with a `before`/`after` pair. That is not a style choice: the shared painters
//! ([`crate::glue::art_swaps`] and friends) sit in those chains, and ordering this both after a
//! screen's input and before those painters from outside makes a painter transitively ordered
//! against its own `SystemTypeSet`, which Bevy rejects at schedule build. It panics at boot, and
//! no unit test can see it (2071).
use bevy::prelude::*;
use benilla_ui::widget::EditBoxState;
use crate::glue::art::{GlueArt, GOLD};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
glue_button, glue_edit_box, outlined_text_centered, overlay, paint_glue_field, GlueBtnKind,
GlueFieldPart, GlueText,
};
use crate::glue_strings::GlueStrings;
use crate::sound::GlueSound;
use crate::char_select::wow_font;
/// `DEFAULT_TOOLTIP_COLOR` (AccountLogin.lua): the edit box's backdrop tint (border rgb, bg rgb) —
/// the login screen's own boxes, so the realmlist editor matches the two behind it.
const BOX_BORDER: Color = Color::srgb(0.8, 0.8, 0.8);
const BOX_FILL: Color = Color::srgb(0.09, 0.09, 0.09);
/// The dialog's two buttons — the ref's `GlueDialogButton1`/`GlueDialogButton2`. Its own
/// component rather than a variant of any screen's action enum: the buttons belong to the widget,
/// and the widget belongs to no screen.
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GlueDialogAction {
/// The affirmative one — Cancel on the status dialog, Okay on the others.
Button1,
/// The second, on the kinds that declare one (Cancel on the realmlist editor).
Button2,
}
/// A button was pressed on the dialog, for the screen that opened it to answer. Carries both
/// flags rather than one enum because the realmlist editor's ESCAPE answers *button 2*, and
/// nothing but the kind's own table knows that.
#[derive(Message, Clone, Copy)]
pub(crate) struct GlueDialogAnswer {
pub(crate) kind: DialogKind,
pub(crate) button1: bool,
pub(crate) button2: bool,
}
/// Which dialog is up: the connecting status (Cancel button, text driven by the stages), an
/// error (Okay button), or the realmlist editor (Okay + Cancel over an edit box).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DialogKind {
Status,
Error,
/// **Queued for a full realm** (decision 1681). The reference has no queue dialog *type*: it
/// opens the ordinary `CANCEL` status dialog and re-texts it every frame, relabelling the one
/// button to `CHANGE_REALM`. This is that, as a kind — the relabel is the only thing that
/// distinguishes it from [`Self::Status`], and a kind is how this screen spells "different
/// button caption".
Queued,
/// The realmlist editor (decision 1667) — the reference's `GlueDialog` `hasEditBox` shape,
/// which is how the shipped dialog asks for a typed value (`GlueDialog.lua`: it shows
/// `GlueDialogEditBox` and re-heights the box to
/// `16 + text + 8 + editbox + 8 + button + 16`). The reference never opens this particular
/// dialog — it has no realmlist UI at all — but the widget is its own.
Realmlist,
}
impl DialogKind {
/// Whether this dialog carries the ref's `GlueDialogEditBox` (its `hasEditBox` flag).
pub(crate) fn has_edit_box(self) -> bool {
matches!(self, DialogKind::Realmlist)
}
/// `(button1, button2)` captions — `GlueDialogTypes`' own two fields. A `None` second button
/// is the ref's centred single-button layout; `Some` is its BOTTOMRIGHT/LEFT pair.
pub(crate) fn buttons(self, strings: &GlueStrings) -> (&str, Option<&str>) {
match self {
DialogKind::Status => (strings.text("CANCEL", "Cancel"), None),
// The reference's own relabel: leaving a queue is "Change Realm", not "Cancel".
DialogKind::Queued => (strings.text("CHANGE_REALM", "Change Realm"), None),
DialogKind::Error => (strings.text("OKAY", "Okay"), None),
DialogKind::Realmlist => (
strings.text("OKAY", "Okay"),
Some(strings.text("CANCEL", "Cancel")),
),
}
}
}
/// Which of the dialog's two buttons a key press answers, as `(button1, button2)` — the keyboard
/// half of [`drive_glue_dialog`]'s buttons; the mouse half is OR-ed in beside it. Button 1 is the
/// affirmative one on every kind (Cancel on the status dialog, Okay on the others); button 2
/// exists only where the kind declares it. ENTER confirms, ESCAPE dismisses; on a one-button
/// dialog they are the same button.
///
/// **`on_screen` is the whole of the second bug fixed on 2026-08-29.** Pressing ENTER on an empty
/// login form played the sound and showed nothing, while *clicking* Login showed the dialog
/// (director's report). The two systems poll the same [`ButtonInput`]: [`login_input`] opened the
/// error dialog on the ENTER edge, and [`drive_glue_dialog`] — later in the same chained frame, with
/// `keys` untouched — read *the same* `just_pressed(Enter)` as that dialog's own Okay and closed
/// it before it had ever been drawn. Both sounds played, which is exactly what was heard.
///
/// The reference cannot have this bug because it is event-driven: the ENTER that fires
/// `AccountLogin_Login` is *dispatched* to the focused edit box (`OnEnterPressed`), and a dialog
/// that does not exist yet is not in the dispatch. Polling is our seam, so the gate has to be
/// ours too, and it says the same thing the dispatch does — **a dialog answers only keys pressed
/// while it was already on screen**. Not "not this frame": on screen. It is passed the same
/// `fresh` flag that drives the (re)spawn, so the two cannot drift apart.
fn dialog_keys(kind: DialogKind, on_screen: bool, enter: bool, escape: bool) -> (bool, bool) {
if !on_screen {
return (false, false);
}
let button1 = match kind {
// The status dialog's one button IS Cancel, so ESCAPE is it. The queue's button is the
// same act under another name, so it answers to ESCAPE the same way.
DialogKind::Status | DialogKind::Queued => escape,
DialogKind::Error => escape || enter,
DialogKind::Realmlist => enter,
};
(button1, kind == DialogKind::Realmlist && escape)
}
/// The glue layer's one dialog (the ref's shared `GlueDialog`): kind + text; the driver spawns/
/// despawns the tree (respawning on a kind change — the button caption differs) and updates the
/// text in place.
#[derive(Resource, Default)]
pub(crate) struct GlueDialog {
pub(crate) kind: Option<DialogKind>,
pub(crate) text: String,
pub(crate) dirty: bool,
pub(crate) root: Option<Entity>,
/// The ref's `GlueDialogEditBox` — a real [`EditBoxState`] like the two on the screen behind
/// it, so the realmlist box gets the same caret, selection, Ctrl+A and clipboard law
/// (decision 0704). Only meaningful while a [`DialogKind::has_edit_box`] dialog is up; it is
/// rebuilt from the current value on every open, so a cancelled edit leaves nothing behind.
pub(crate) edit: EditBoxState,
/// The queue's sample ring and the realm it is for — live only while [`DialogKind::Queued`]
/// is up, and rebuilt from empty on every fresh queue so a second login never inherits the
/// first one's estimate.
pub(crate) queue: crate::login::queue::QueueEstimate,
pub(crate) queue_realm: Option<String>,
/// The kind the spawned tree was built for.
spawned: Option<DialogKind>,
/// The glue scale the spawned tree was built at — a resize rebuilds it.
spawned_s: f32,
}
impl GlueDialog {
/// Raise the one-button error dialog over whichever glue screen is up.
///
/// `pub(crate)` for the character-select screen: a refused *character* login
/// (`SMSG_CHARACTER_LOGIN_FAILED`) is said there, not here, and it is the same dialog — the
/// reference has exactly one, under `GlueParent`.
pub(crate) fn open_error(&mut self, text: &str) {
self.kind = Some(DialogKind::Error);
self.set_text(text);
}
/// Is a dialog up? The glue screens' modal test — while one is, the screen behind it does not
/// answer clicks or keys (the select screen's own delete confirm and AddOns panel are read the
/// same way).
pub(crate) fn is_open(&self) -> bool {
self.kind.is_some()
}
pub(crate) fn open_status(&mut self, text: &str) {
self.kind = Some(DialogKind::Status);
self.set_text(text);
}
/// Enter the queue: a fresh ring (a second login must not inherit the first one's estimate)
/// and the realm's name for the `_NAME` text variants.
pub(crate) fn open_queued(&mut self, realm: Option<String>) {
self.kind = Some(DialogKind::Queued);
self.queue = crate::login::queue::QueueEstimate::default();
self.queue_realm = realm;
self.set_text("");
}
/// Open the realmlist editor over `current`, with the caret at the end of it and the whole
/// value selected — the reference's `hasEditBox` dialogs open ready to be typed over, and a
/// player changing servers is replacing the address far more often than editing it.
pub(crate) fn open_realmlist(&mut self, prompt: &str, current: &str) {
self.kind = Some(DialogKind::Realmlist);
self.edit = crate::textinput::field(crate::realmlist::MAX_LETTERS, false);
self.edit.set_text(current);
// `HighlightText(0, -1)` — the client's own select-all (`0x77cca0`), which resets the
// blink on its way so the box opens on a solid caret.
self.edit.highlight_text(0, -1);
self.set_text(prompt);
}
pub(crate) fn set_text(&mut self, text: &str) {
if self.text != text {
self.text = text.to_string();
self.dirty = true;
}
}
/// Close **and take the tree down in the same frame** — what a button press does. Closing
/// alone would leave the spawned root standing until [`drive_glue_dialog`] next runs, and on
/// the edges out of the glue layer that is never.
pub(crate) fn dismiss(&mut self, commands: &mut Commands) {
self.close();
if let Some(root) = self.root.take() {
commands.entity(root).despawn();
}
}
pub(crate) fn close(&mut self) {
self.kind = None;
self.text.clear();
self.dirty = false;
}
}
/// The dialog's message text (updated in place on stage changes).
#[derive(Component)]
pub(crate) struct DialogText;
/// The dialog's edit box row items — the ref's `GlueDialogEditBox`, painted from
/// [`GlueDialog::edit`] by [`refresh_dialog_box`].
#[derive(Component, Clone)]
pub(crate) struct DialogEditText;
/// Build the dialog tree (the ref's shared `GlueDialog`): the 512-wide `UI-DialogBox` backdrop
/// centered on the screen, the message (`GlueFontNormalLarge`, wrapping at 440), an optional edit
/// box, and one or two `GlueDialogButtonTemplate` 200×40 buttons — Cancel for the connecting
/// status, Okay for an error, Okay + Cancel over the box for the realmlist editor.
/// Content-sized vertically (the ref's own `GlueDialog_OnShow` resize, by layout).
///
/// The edit box and the second button are both the reference's own (`GlueDialog.lua`'s
/// `hasEditBox` and `button2`); benilla simply had no dialog that used either until 1667. The two
/// authored geometries they bring with them: the button pair is Button1 BOTTOMRIGHT ← the
/// backdrop's BOTTOM at (−6, 16) with Button2 LEFT ← Button1's RIGHT at (13, 0) — a centred pair
/// with a 13 gap — and the box re-heights the backdrop to
/// `16 + text + 8 + editbox + 8 + button + 16`, which the column below is already shaped as.
///
/// **One stated divergence:** the ref's `GlueDialogEditBox` is 130×32, sized for the short values
/// its own dialogs ask for. A realmlist is a hostname, so ours is 300 wide inside the same 512
/// backdrop; the height, insets and border treatment are the login screen's boxes unchanged.
pub(crate) fn spawn_dialog(
commands: &mut Commands,
art: &GlueArt,
assets: &AssetServer,
strings: &GlueStrings,
kind: DialogKind,
text: &str,
s: f32,
) -> Entity {
let px = |v: f32| Val::Px(v * s);
let font = wow_font(assets);
let edit_font: Handle<Font> = assets.load("mpq://Fonts/ARIALN.ttf");
let (caption, caption2) = kind.buttons(strings);
commands
.spawn((
GlobalZIndex(1200), // over the screen's 1100
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|overlay_ui| {
let mut boxed = overlay_ui.spawn(Node {
width: px(512.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(0.0), px(16.0)),
row_gap: px(13.0),
..default()
});
boxed.with_children(|b| {
// Backdrop: bg tiled at 32 inside (11,12,12,11), the 32-edge border over it.
if let (Some(bg), Some(border)) = (&art.dialog_bg, &art.dialog_border) {
b.spawn((
tiled_bg_node(bg.clone(), 32.0, s, Color::WHITE),
Node {
position_type: PositionType::Absolute,
left: px(11.0),
right: px(12.0),
top: px(12.0),
bottom: px(11.0),
..default()
},
));
backdrop_border(b, border, 32.0, Color::WHITE);
} else {
b.spawn((
BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.95)),
overlay(),
));
}
// The message (GlueFontNormalLarge 18 at TOP (0,−16), width 440, wrapping) —
// **centred**, which `GlueDialogText` gets by omitting `justifyH` (a FontString's
// default is CENTER; every other wrapped glue string in the shipped XML asks for
// LEFT explicitly). It read left-aligned until the director's eye caught it.
outlined_text_centered(
b,
Node {
width: px(440.0),
justify_content: JustifyContent::Center,
..default()
},
(),
DialogText,
GlueText {
text,
size: 18.0,
color: GOLD,
wrap: true,
},
&font,
s,
);
// The edit box, on the kinds that declare one (`hasEditBox`).
if kind.has_edit_box() {
glue_edit_box(
b,
art,
&edit_font,
(),
DialogEditText,
(300.0, 32.0),
(BOX_BORDER, BOX_FILL),
(15.0, 0.0, 0.0, 5.0), // the login boxes' TextInsets
s,
);
}
// The buttons (GlueDialogButtonTemplate 200×40) — one centred, or the authored
// pair with its 13 gap.
b.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: px(13.0),
..default()
})
.with_children(|row| {
glue_button(
row,
art,
&font,
GlueDialogAction::Button1,
caption,
200.0,
40.0,
GlueBtnKind::Dialog,
s,
);
if let Some(caption2) = caption2 {
glue_button(
row,
art,
&font,
GlueDialogAction::Button2,
caption2,
200.0,
40.0,
GlueBtnKind::Dialog,
s,
);
}
});
});
})
.id()
}
/// Spawn/despawn the dialog tree with the resource, keep its text current, and read its buttons
/// from the mouse and the keyboard.
///
/// A press plays the click sound, publishes a [`GlueDialogAnswer`] for the screen that opened the
/// dialog, and — for [`DialogKind::Error`] alone — dismisses it here, because that kind's whole
/// behaviour *is* its dismissal. Everything else a press might mean belongs to the opener; see
/// this module's header for where that line falls and why.
///
/// Runs inside each glue screen's own system chain (again: the header says why it cannot be
/// hoisted out with an ordering pair).
#[allow(clippy::too_many_arguments)]
pub(crate) fn drive_glue_dialog(
mut commands: Commands,
mut dialog: ResMut<GlueDialog>,
art: Res<crate::glue::art::GlueArt>,
assets: Res<AssetServer>,
strings: Option<Res<GlueStrings>>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Query<(Entity, &GlueDialogAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut texts: Query<&mut Text, With<DialogText>>,
mut sounds: MessageWriter<GlueSound>,
mut answers: MessageWriter<GlueDialogAnswer>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
let Some(kind) = dialog.kind else {
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
}
dialog.spawned = None;
return;
};
// (Re)spawn on the open edge, a kind change (the button caption differs), or a window resize
// (the tree bakes the glue scale); a text-only change updates the message line in place.
//
// `fresh` is *this dialog appearing*, as opposed to a resize rebuilding a dialog already up —
// and it is the same flag [`dialog_keys`] takes as `!on_screen`, so the frame the dialog is
// drawn on and the frame it starts answering keys on are one decision, not two.
let s = crate::glue::screen_scale(window.single().ok());
let fresh = dialog.root.is_none() || dialog.spawned != Some(kind);
if fresh || dialog.spawned_s != s {
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
}
dialog.root = Some(spawn_dialog(
&mut commands,
&art,
&assets,
strings,
kind,
&dialog.text,
s,
));
dialog.edit.reset_blink();
dialog.spawned = Some(kind);
dialog.spawned_s = s;
dialog.dirty = false;
} else if dialog.dirty {
for mut t in &mut texts {
if t.0 != dialog.text {
t.0 = dialog.text.clone();
}
}
dialog.dirty = false;
}
// The buttons, from the mouse or the keys. A click needs no `fresh` guard of its own: the
// button it would hit did not exist to be clicked on the frame the tree spawned.
let hit = |want: GlueDialogAction| buttons.iter().any(|(e, a)| *a == want && clicks.hit(e));
let (key1, key2) = dialog_keys(
kind,
!fresh,
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter),
keys.just_pressed(KeyCode::Escape),
);
let button1 = hit(GlueDialogAction::Button1) || key1;
let button2 = hit(GlueDialogAction::Button2) || key2;
// **Every glue dialog button clicks** — `GlueDialog_OnClick` ends with
// `PlaySound("gsTitleOptionOK")` for button 1 and button 2 alike, whatever the dialog's type.
// Ours played nothing at all. Emitted here, once, before the kinds diverge, so no arm can
// forget it (and so the realmlist editor's Okay-that-refuses still clicks: the reference plays
// the sound on the press, not on the outcome).
if button1 || button2 {
sounds.write(GlueSound("gsTitleOptionOK"));
// **What a press MEANS belongs to whoever opened the dialog**, not to the widget. A
// realmlist Okay may refuse the typed value and stay open; a status Cancel has a login
// attempt to abandon. Both are the login screen's business, and neither is reachable from
// any other screen — so the press is published and [`crate::login::answer_dialog`]
// answers it.
answers.write(GlueDialogAnswer {
kind,
button1,
button2,
});
// The one kind with no owner: an `Error` states a thing and its Okay dismisses it, which
// is the whole of its behaviour. Ending it here is what lets ANY glue screen raise one
// without also having to write its ending — the character-select screen's refused login
// (2071) raises an error and answers nothing.
if kind == DialogKind::Error {
dialog.dismiss(&mut commands);
}
}
}
/// Paint the dialog's edit box from [`GlueDialog::edit`], through the same
/// [`paint_glue_field`] the two screen boxes use (decision 0704) — so the realmlist box gets the
/// identical caret, selection highlight and scrolling.
///
/// Runs **after** [`drive_glue_dialog`], which is what spawns the box: on the frame a dialog
/// opens the entities do not exist until that system has run, and painting before it would show
/// one frame of empty box.
#[allow(clippy::type_complexity)]
pub(crate) fn refresh_dialog_box(
dialog: Res<GlueDialog>,
mut boxes: Query<(&GlueFieldPart, Option<&mut Text>, &mut Visibility), With<DialogEditText>>,
) {
if dialog.kind.is_some_and(DialogKind::has_edit_box) {
paint_glue_field(&dialog.edit, true, boxes.iter_mut());
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The dialog kinds' own shape: only the realmlist editor carries the ref's `hasEditBox`, and
/// only it declares a second button. The caret clock and the keyboard routing both branch on
/// the first of those, and `spawn_dialog` lays out from the second.
#[test]
fn only_the_realmlist_dialog_has_a_box_and_two_buttons() {
let strings = GlueStrings::default();
assert!(!DialogKind::Status.has_edit_box());
assert!(!DialogKind::Error.has_edit_box());
assert!(DialogKind::Realmlist.has_edit_box());
assert_eq!(DialogKind::Status.buttons(&strings), ("Cancel", None));
assert_eq!(DialogKind::Error.buttons(&strings), ("Okay", None));
assert_eq!(
DialogKind::Realmlist.buttons(&strings),
("Okay", Some("Cancel")),
);
}
/// **A dialog never answers the key press that opened it.** ENTER on an empty login form
/// opened the error dialog in `login_input` and then, later in the *same* frame with the same
/// `ButtonInput`, the driver read that identical `just_pressed(Enter)` as the dialog's own
/// Okay — so the popup appeared and vanished inside one frame and only the two sounds were
/// heard (director's report, 2026-08-29). The gate is being on screen, not a frame count.
#[test]
fn a_dialog_does_not_answer_the_key_that_opened_it() {
// The frame it appears on: the key that opened it is not its answer.
assert_eq!(
dialog_keys(DialogKind::Error, false, true, false),
(false, false),
);
assert_eq!(
dialog_keys(DialogKind::Error, false, false, true),
(false, false),
);
// Once it is up, the same press is.
assert_eq!(
dialog_keys(DialogKind::Error, true, true, false),
(true, false),
);
}
/// Which key answers which button, per kind: ENTER and ESCAPE are the same (single) button on
/// an error dialog; ESCAPE alone works the Cancel-shaped ones; the two-button editor splits
/// them, ENTER to Okay and ESCAPE to Cancel.
#[test]
fn each_dialog_kind_maps_its_own_keys() {
for (kind, enter, escape, want) in [
(DialogKind::Error, true, false, (true, false)),
(DialogKind::Error, false, true, (true, false)),
(DialogKind::Status, true, false, (false, false)),
(DialogKind::Status, false, true, (true, false)),
(DialogKind::Queued, false, true, (true, false)),
(DialogKind::Queued, true, false, (false, false)),
(DialogKind::Realmlist, true, false, (true, false)),
(DialogKind::Realmlist, false, true, (false, true)),
] {
assert_eq!(
dialog_keys(kind, true, enter, escape),
want,
"{kind:?} enter={enter} escape={escape}",
);
}
}
}
+7 -2
View File
@@ -8,6 +8,7 @@
pub(crate) mod add_material;
pub(crate) mod art;
pub(crate) mod backdrop;
pub(crate) mod dialog;
pub(crate) mod widgets;
use bevy::ecs::entity::EntityHashSet;
@@ -31,8 +32,10 @@ use widgets::{ArtSwap, GlueBtn, GlueCaption, GlueDisabled, OutlineCopy};
pub(crate) struct GlueVisuals;
/// The shared glue infrastructure both screens stand on: the ADD-mode UI material pipeline, the
/// [`GlueArt`] resource (loaded on first screen entry), and the GlueStrings table. Registered
/// before either screen plugin (`main.rs`).
/// [`GlueArt`] resource (loaded on first screen entry), the GlueStrings table, and the one
/// [`dialog::GlueDialog`] every glue screen raises. Registered before either screen plugin
/// (`main.rs`) — which is also why the dialog's resource and message live here rather than in a
/// screen: they outlive any single screen's plugin.
pub(crate) struct GluePlugin;
impl Plugin for GluePlugin {
@@ -46,6 +49,8 @@ impl Plugin for GluePlugin {
crate::glue_strings::load_glue_strings.after(benilla_assets::AssetSet::Open),
)
.init_resource::<GlueClicks>()
.init_resource::<dialog::GlueDialog>()
.add_message::<dialog::GlueDialogAnswer>()
.add_systems(PreUpdate, glue_clicks.after(bevy::ui::UiSystems::Focus))
.add_systems(
Update,
+280 -61
View File
@@ -21,9 +21,15 @@
//! by a per-frame readiness poll. Which is this file's shape. What an async-streaming client has to
//! build explicitly is not the lifecycle after all: it is the **input suppression** the blocking
//! stretch does not provide either (see [`input`], decision 1990). The screen
//! rises at the explicit edges — the character pick's `Connected` edge (before the glue tears
//! down), `SMSG_TRANSFER_PENDING` (the portal walk-in), the worldport snap — all observed *here*,
//! from the messages/resources the net bridge already publishes; a residency backstop catches any
//! rises at the **transition edges and only those** — the character pick's `Connected` edge (before
//! the glue tears down) and `SMSG_TRANSFER_PENDING` (the portal walk-in), which are precisely the
//! reference's two entries into `0x406640`'s tail — all observed *here*, from the
//! messages/resources the net bridge already publishes. The destination **snap** is not one of
//! them: `SMSG_NEW_WORLD` and `SMSG_LOGIN_VERIFY_WORLD` are two handlers there (`0x401b00` /
//! `0x401de0`) and neither can reach the raise `0x406800` or the screen's map global `[0x82f00c]`,
//! so a snap only ends the wait its raise armed and can never re-point a live screen
//! ([`LoadingScreen::far_snap`]). And what a live screen *shows* is latched harder still: the
//! resolved texture, not the map id ([`LoadingScreen::art_resolved`], decision 2087). A residency backstop catches any
//! path with no edge, **in world only** — it used to fire at boot too, and that is what warmed the
//! world's pipelines behind the glue (0540), which only worked while a world was being streamed
//! there at all. Since 0777 none is, and the warm-up rides the entry cover instead. It clears when
@@ -196,10 +202,35 @@ pub(crate) struct LoadingScreen {
/// login-vista tiles are fully resident the moment the glue tears down, and without this hold
/// that residency would clear the screen seconds before `SMSG_LOGIN_VERIFY_WORLD` arrives.
awaiting_snap: bool,
/// Destination art before `CurrentMap` catches up: the roster's `Character.map` at the pick
/// edge, the transfer's map at `SMSG_TRANSFER_PENDING`. Cleared by the snap (from which frame
/// `CurrentMap` itself is correct).
pending_map: Option<u32>,
/// **The map to resolve this screen's art from** — the reference's `[0x82f00c]`, set by every
/// raise: the roster's `Character.map` at the pick edge, the transfer's map at
/// `SMSG_TRANSFER_PENDING`, the map we are on for a backstop raise. `None` is the reference's
/// `-1`: *this screen draws no backdrop* — the boot default, and what a failed resolve leaves
/// behind ([`Self::art_resolved`]).
///
/// It is deliberately not `CurrentMap`, which moves under a live screen. Log out inside a
/// dungeon whose instance is gone by the time you come back and vmangos relocates you *during*
/// `Player::LoadFromDB` (`Player.cpp`, the `GetGoBackTrigger` arm) — no
/// `SMSG_TRANSFER_PENDING`, no `SMSG_NEW_WORLD`, just a `SMSG_LOGIN_VERIFY_WORLD` naming a
/// different map than the roster row the screen was raised from. Reading the art per frame
/// from `CurrentMap` made the dungeon backdrop switch to the continent one mid-load; nothing
/// on the wire can do that in the reference ([`Self::snap_landed`]).
///
/// **This field is not the latch, though — [`Self::art_resolved`] is** (decision 2087). Five
/// writers touch `[0x82f00c]` image-wide, all inside the `LoadingScreen.cpp` TU: `0x4067e6`
/// and `0x4072d3` store a map id (the two transition entries), and `0x406d0e`/`0x4073d4`/
/// `0x407e59` store `-1`. A raise landing on a screen that is already up re-points it
/// *unconditionally* — and repaints nothing, because the texture is already resolved.
map: Option<u32>,
/// **Has this screen resolved its backdrop yet?** — the reference's `[0x882e04]`, the loaded
/// texture handle, and the thing the art is actually latched by (decision 2087). `0x406cf0`
/// reads the map id only to reject `-1`, then `0x406cfc`/`0x406d01`/`0x406d03` skip the
/// resolver `0x406e20` outright whenever the handle is non-null; the handle's three writers
/// image-wide are the resolver's two stores and `0x407ed7 = 0` inside the dismiss `0x407e80`.
///
/// So the map id decides the picture on **exactly one frame per screen**, and nothing that
/// happens afterwards can change it. Cleared by [`Self::dismiss`], never by a raise.
art_resolved: bool,
/// **Plain black cover, no art, no bar** — the logout transition (decision 0738). The logout
/// teardown despawns the avatar and the streamed world in the same frame's Net stage, but
/// `CharSelect` (and the glue screen with it) only applies at the NEXT frame's state
@@ -220,6 +251,11 @@ pub(crate) struct LoadingScreen {
/// other raise, taken by [`crate::game_tip::drive_game_tip`] on the same frame. A field rather
/// than a message because it is a property OF the raise, and the raise is this struct's.
pub(crate) tip_edge: Option<TipEdge>,
/// **Capture only** — the clear below is skipped while this is set. A loading screen is a
/// picture that is up for a second and then gone: nothing in the tree could photograph one,
/// which is how the tip of the day shipped dead and stayed dead through a live smoke run
/// (decision 2083). Written by [`Self::hold_for_capture`] and by nothing a player can reach.
held: bool,
/// `Time::elapsed_secs` at the last raise + at the last wait-instrument line (see
/// [`WAIT_LOG_AFTER`]).
@@ -254,19 +290,93 @@ impl LoadingScreen {
self.tip_edge.take()
}
/// Raise (or re-arm) the screen for a fresh load. `awaiting_snap` marks a raise whose
/// destination snap is still in flight; `map` is the destination when the edge knows it.
/// **The capture instrument** (`WOW_CAPTURE=loading-tip`): raise the screen the way the
/// glue→world entry does — art, bar and a `Pick` edge for the tip — and then never let go, so
/// the harness's settle window has something to hold still on. The one screen in the client
/// whose whole existence is measured in the seconds before it disappears, made photographable.
/// `map` is the destination the art latches to, exactly as a real raise's is ([`Self::map`]):
/// the harness knows it from the scenario, and with `None` the shot would be a black box with a
/// tip on it rather than the real `Map.dbc` → `LoadingScreens.dbc` → BLP chain.
pub(crate) fn hold_for_capture(&mut self, map: Option<u32>) {
self.active = true;
self.blackout = false;
self.awaiting_snap = false;
self.map = map;
self.held = true;
self.tip_edge = Some(TipEdge::Pick);
}
/// Raise the screen for a fresh load. `awaiting_snap` marks a raise whose destination snap is
/// still in flight; `map` is the destination this screen's art is LATCHED to for its whole life
/// ([`Self::map`]) — every caller states what it knows, and a caller with no announced
/// destination passes the map we are on rather than leaving the art to follow `CurrentMap`.
///
/// **Only an edge that would reach the reference's own transition entries calls this.** The
/// destination *snap* is not one of them ([`Self::snap_landed`]).
fn raise(&mut self, reason: &str, awaiting_snap: bool, map: Option<u32>, now: f32) {
self.active = true;
self.blackout = false;
self.awaiting_snap = awaiting_snap;
self.pending_map = map;
self.map = map;
self.ready_frames = 0;
self.displayed = 0.0; // restart the fill for the new load
self.active_since = now;
self.last_wait_log = now;
info!("loading screen: up ({reason}, map {map:?})");
}
/// **The destination snap landed** — `SMSG_NEW_WORLD` or `SMSG_LOGIN_VERIFY_WORLD`. It ends the
/// wait a raise armed, and it does **nothing else**: not the art, not the tip, not the bar.
///
/// The two are one [`crate::net::WorldportMessage`] here and two handlers in the reference
/// (`0x401b00` and `0x401de0`, `re/net/opcode-handlers.tsv`), and *neither touches the screen*:
/// the raise `0x406800` has three call sites and all three are inside the `LoadingScreen.cpp`
/// TU, so nothing on the wire can re-raise or re-point a screen that is already up. Treating
/// the snap as a fresh load is what swapped the backdrop mid-load ([`Self::map`]), restarted
/// the bar, and — a round-trip into *every* world entry — wiped the tip of the day that
/// decision 2077 had just picked.
///
/// `map` is the destination the snap names — `None` for a same-map teleport ack, which cannot
/// disagree with the screen by construction.
fn snap_landed(&mut self, map: Option<u32>) {
// Worth a line on its own: the destination the server actually seated us on is not the one
// this screen was raised for, i.e. we were relocated between the roster and the login.
if let (Some(raised), Some(dest)) = (self.map, map) {
if raised != dest {
info!("loading screen: snap landed on map {dest} — raised for map {raised}, art holds");
}
}
self.awaiting_snap = false;
}
/// **The far snap's whole rule** — the one place it lives, so the test seam and the system
/// cannot drift apart (the [`EntryCover::tick`] pattern, two structs up).
///
/// A screen that is already up **is this snap's screen** — the entry raise waiting for
/// `SMSG_LOGIN_VERIFY_WORLD`, the portal's waiting for `SMSG_NEW_WORLD` — so the snap ends its
/// wait and nothing more. Returns `true` only when no screen is up at all, which is the
/// caller's cue to raise one: our backstop for a server-initiated port that arrived with no
/// announcing edge (the reference needs no such backstop — its own world load blocks).
#[must_use]
fn far_snap(&mut self, map: u32) -> bool {
if !self.active {
return true;
}
self.snap_landed(Some(map));
false
}
/// **The dismiss** (the reference's `0x407e80`) — the one place the screen comes down, so the
/// things a screen owns for its lifetime come down with it exactly once. The tip is one of
/// those: `[0x882e10]`'s only writers image-wide are the `EnterWorld` setter `0x406630` and
/// `0x407f2b` inside this dismiss, so a *raise* never clears a tip — only this does.
fn dismiss(&mut self) {
self.active = false;
self.blackout = false;
self.tip_edge = Some(TipEdge::Clear);
// `0x407ed7 mov ds:0x882e04,edi` — the next screen resolves its own art, and only here.
self.art_resolved = false;
}
}
// UI entity markers.
@@ -396,24 +506,7 @@ fn setup_loading_screen(
// `crate::game_tip`'s constants are in; only the font size and the shadow need pixels,
// and those follow the window each frame. Empty and hidden until a glue->world raise
// fills it.
area.spawn((
LoadingTip,
Text::new(String::new()),
TextLayout {
linebreak: LineBreak::WordBoundary,
justify: Justify::Left,
},
TextFont::default(),
TextColor(crate::game_tip::base_color()),
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
bottom: Val::Percent(0.0),
width: Val::Percent(0.0),
..default()
},
Visibility::Hidden,
));
area.spawn((LoadingTip, crate::game_tip::tip_bundle()));
// Fill — left-anchored; width = progress·FILL_MAX_WIDTH (set each frame). y from the
// BOTTOM (verified by screenshot + binary). The fill art is a horizontally-uniform
// gradient, so width-scaling reads as a left→right reveal.
@@ -483,7 +576,13 @@ fn drive_loading_screen(
),
>,
player: Option<Res<crate::player::Player>>,
time: Res<Time>,
// **Real, not virtual** — this clock measures a LOAD, and `Time<Virtual>` clamps any frame
// longer than 250 ms (`transport.rs` names the same constant for the same reason). A loading
// screen is made of exactly those frames, so the default clock under-reports it: measured on
// one smoke re-entry, a cover that was up for 2.24 s wall reported 1.2 s. An instrument whose
// whole job is "how long was this up, and what was blocking it" cannot run on a clock that
// discards the stalls.
time: Res<Time<Real>>,
// The 0837 warm pass: the cover holds until the menagerie's pipelines have compiled — the
// point of the cover is that NOTHING first-sight-compiles after it lifts.
warm: Res<crate::pipe_warm::WarmPass>,
@@ -548,7 +647,6 @@ fn drive_loading_screen(
match &t.0 {
Some(info) if info.transport_entry.is_none() => {
screen.raise("transfer pending", true, Some(info.map_id), now);
screen.tip_edge = Some(TipEdge::Clear);
}
// The latch cleared with no snap in flight = `SMSG_TRANSFER_ABORTED` (a worldport
// clears it too, but that path also lands below this frame): stop awaiting, and the
@@ -557,17 +655,20 @@ fn drive_loading_screen(
Some(_) => {}
}
}
// The snap: a worldport IS a fresh cross-map load (raise covers server-initiated ports that
// had no preceding edge); a same-map teleport just ends any awaited snap — whether it needs a
// screen at all is the backstop's call (a summon across the room shouldn't flash one).
// The snap ([`LoadingScreen::far_snap`]): it never re-raises a live screen, so the art that
// screen was raised with holds even when the server seats us on a different map than the one
// it announced. That is the reference exactly — `0x406800` is reachable from nothing outside
// the loading-screen TU, so no packet can re-point a screen that is up.
for w in worldports.read() {
screen.raise("worldport", false, Some(w.map_id), now);
screen.tip_edge = Some(TipEdge::Clear);
if screen.far_snap(w.map_id) {
screen.raise("worldport", false, Some(w.map_id), now);
}
}
// A same-map teleport just ends any awaited snap — whether it needs a screen at all is the
// backstop's call below (a summon across the room shouldn't flash one).
let teleported = teleports.read().next().is_some();
if teleported {
screen.awaiting_snap = false;
screen.pending_map = None;
screen.snap_landed(None);
}
// Logout (decision 0738): the same frame's Net stage despawned the avatar and tore the world
// down, but `CharSelect` only applies at the NEXT frame's state transition — cover the dead
@@ -587,7 +688,6 @@ fn drive_loading_screen(
screen.active = true;
screen.blackout = true;
screen.awaiting_snap = false;
screen.pending_map = None;
screen.ready_frames = 0;
info!(
"loading screen: blackout ({})",
@@ -602,8 +702,7 @@ fn drive_loading_screen(
}
if screen.blackout && *state.get() != crate::char_select::ClientState::InWorld {
// The glue is up (it renders above this root); the cover's job is done.
screen.blackout = false;
screen.active = false;
screen.dismiss();
}
// --- Backstop trigger: the ground under the view focus isn't resident and nothing raised us
@@ -633,8 +732,8 @@ fn drive_loading_screen(
&& focus.follows_body()
&& *state.get() == crate::char_select::ClientState::InWorld
{
screen.raise("focus not resident", false, None, now);
screen.tip_edge = Some(TipEdge::Clear);
// Same map by construction — the destination this covers is the ground under the body.
screen.raise("focus not resident", false, map_id, now);
}
// --- …and the same test at the SNAP, on everything the reveal actually needs. The backstop
@@ -662,14 +761,13 @@ fn drive_loading_screen(
&& *state.get() == crate::char_select::ClientState::InWorld
&& !(progress.is_ready() && progress.presentable())
{
screen.raise("teleport, destination not presentable", false, None, now);
screen.tip_edge = Some(TipEdge::Clear);
screen.raise("teleport, destination not presentable", false, map_id, now);
}
// --- Clear: the destination snap has landed, the scene is presentable (tiles + focus
// placements + colliders — [`WorldLoadProgress::presentable`]), and the physics hold is done,
// sustained a few frames. ---
if screen.active {
if screen.active && !screen.held {
if progress.is_ready()
&& progress.presentable()
&& !screen.awaiting_snap
@@ -679,7 +777,7 @@ fn drive_loading_screen(
{
screen.ready_frames += 1;
if screen.ready_frames >= CLEAR_AFTER_READY_FRAMES {
screen.active = false;
screen.dismiss();
info!(
"loading screen: cleared ({}/{} resident, {:.1}s)",
progress.ready,
@@ -741,28 +839,49 @@ fn drive_loading_screen(
return;
}
// --- Backdrop art (resolve via the FK chain; cached by path). Before the snap lands the
// destination is `pending_map` (the roster/transfer told us); after it, `CurrentMap`. ---
let art_map = screen.pending_map.or(map_id);
if let (Some(map_id), Some(maps), Some(screens), Some(assets)) =
(art_map, maps.as_ref(), screens.as_ref(), assets.as_mut())
{
if let Some(path) = maps
.0
.loading_screen_id(map_id)
.and_then(|id| screens.0.path(id))
// --- Backdrop art — `0x406cf0`'s gate in shape (decision 2087): the map id is read ONCE per
// screen, to resolve the texture; from then on the resolved texture is what draws, and only
// the dismiss clears it. A raise landing on a live screen re-points [`LoadingScreen::map`] and
// repaints nothing, which is why "the screen you were given is the screen you keep" survives
// even a transfer arriving mid-load. Resolution walks the FK chain and caches by path. ---
if !screen.art_resolved {
// Until this screen's own art lands, the backdrop is the root's black — never the LAST
// screen's texture, which is what a stale tint would leave on the glass (the reference
// holds a null handle here and does not draw the quad at all).
if let Ok((mut img, _)) = backdrop.single_mut() {
if img.color != Color::BLACK {
img.color = Color::BLACK;
}
}
if let (Some(map_id), Some(maps), Some(screens), Some(assets)) =
(screen.map, maps.as_ref(), screens.as_ref(), assets.as_mut())
{
let path = path.to_string();
let handle = match screen.art_cache.get(&path) {
let path = maps
.0
.loading_screen_id(map_id)
.and_then(|id| screens.0.path(id))
.map(str::to_string);
let handle = path.and_then(|path| match screen.art_cache.get(&path) {
Some(h) => Some(h.clone()),
None => assets.sprite_texture(&path, &mut images).inspect(|h| {
screen.art_cache.insert(path.clone(), h.clone());
}),
};
if let (Some(handle), Ok((mut img, _))) = (handle, backdrop.single_mut()) {
if img.image != handle {
});
match (handle, backdrop.single_mut()) {
(Some(handle), Ok((mut img, _))) => {
img.image = handle;
img.color = Color::WHITE; // reveal the art (was tinted black)
screen.art_resolved = true;
// Once per screen, and the only place the picture is decided — so "which
// backdrop did that load actually show" is a log line rather than a capture.
info!("loading screen: backdrop for map {map_id} resolved");
}
// The resolve failed — no `LoadingScreens` row, or the BLP is missing. This screen
// shows no backdrop for the rest of its life and does not retry: `0x406d0e` writes
// `-1` into the map id for exactly this, and `0x406cf0` early-returns on it after.
_ => {
warn!("loading screen: no backdrop for map {map_id} — plain black this load");
screen.map = None;
}
}
}
@@ -824,4 +943,104 @@ mod tests {
cover.tick(true);
assert!(!cover.presented(), "the next raise starts its own count");
}
/// **The director's report, as an assertion.** Log out inside a dungeon, come back hours
/// later, and the instance is gone: vmangos relocates the character to the dungeon's go-back
/// trigger *inside* `Player::LoadFromDB`, before the world is entered — so no
/// `SMSG_TRANSFER_PENDING` and no `SMSG_NEW_WORLD` ever announce it, and the first the client
/// hears is `SMSG_LOGIN_VERIFY_WORLD` naming a map the roster row did not. The screen keeps
/// the art it was raised with, for its whole life. The reference could not do otherwise:
/// `[0x82f00c]` has no writer on either world-load handler's path (`0x401b00`/`0x401de0`), and
/// the raise `0x406800` is reachable from nothing outside the `LoadingScreen.cpp` TU.
#[test]
fn a_relocating_snap_cannot_move_the_art_the_screen_was_raised_with() {
let mut screen = LoadingScreen::default();
// The pick edge: art from the roster row, Shadowfang Keep, snap still to come.
screen.raise("world entry", true, Some(33), 0.0);
screen.tip_edge = Some(TipEdge::Pick);
screen.displayed = 0.4;
// `SMSG_LOGIN_VERIFY_WORLD`: the server seats us on map 0, at the instance's exit.
assert!(
!screen.far_snap(0),
"a screen is already up — its own snap must not raise a second one"
);
assert_eq!(
screen.map,
Some(33),
"the backdrop is the raise's and holds the whole way — the report"
);
assert!(
!screen.awaiting_snap,
"the snap the raise was waiting for has landed"
);
assert_eq!(
screen.tip_edge,
Some(TipEdge::Pick),
"and the tip 2077 just picked survives it — a raise is the only thing that could \
have cleared it, and no raise happened"
);
assert!(
(screen.displayed - 0.4).abs() < f32::EPSILON,
"nor does the bar restart mid-load"
);
assert!(screen.active, "the screen stays up across its own snap");
}
/// The other half of the same rule: a cross-map port that arrived with **no** announcing edge
/// still gets a cover. This is ours, not the reference's — its own world load blocks, so it
/// needs no backstop.
#[test]
fn a_snap_with_nothing_covering_it_asks_for_a_raise() {
let mut screen = LoadingScreen::default();
assert!(screen.far_snap(1), "nothing is covering this load");
assert!(
!screen.active,
"far_snap answers the question; the caller does the raising, with its own reason"
);
}
/// **The tip comes down with the screen, never with a raise** — `[0x882e10]`'s only writers
/// image-wide are `EnterWorld`'s setter `0x406630` and `0x407f2b`, inside the dismiss
/// `0x407e80`. Ours used to clear it on every non-entry raise, which meant the login snap wiped
/// the tip a server round-trip into *every* world entry.
#[test]
fn only_the_dismiss_clears_the_tip() {
let mut screen = LoadingScreen::default();
screen.raise("world entry", true, Some(0), 0.0);
screen.tip_edge = Some(TipEdge::Pick);
screen.raise("transfer pending", true, Some(389), 0.0);
assert_eq!(
screen.tip_edge,
Some(TipEdge::Pick),
"no raise clears a tip"
);
screen.dismiss();
assert_eq!(screen.tip_edge, Some(TipEdge::Clear), "the dismiss does");
assert!(!screen.active);
}
/// **What a live screen SHOWS is latched by the resolved texture, not by the map id**
/// (decision 2087, correcting 2081's reading). A raise onto a screen that is already up
/// re-points `[0x82f00c]` unconditionally — the reference's `0x406640`/`0x4072c0` have no
/// guard on that store — and repaints nothing, because `0x406cf0` skips the resolver whenever
/// `[0x882e04]` is non-null. Only the dismiss `0x407ed7` clears it.
#[test]
fn a_raise_onto_a_live_screen_repoints_the_map_but_never_the_picture() {
let mut screen = LoadingScreen::default();
screen.raise("world entry", true, Some(33), 0.0);
screen.art_resolved = true; // the first plain-draw frame resolved Shadowfang Keep's art
screen.raise("transfer pending", true, Some(389), 0.0);
assert_eq!(screen.map, Some(389), "the map id is re-pointed, unguarded");
assert!(
screen.art_resolved,
"…and the picture is not: the texture is still the one this screen resolved"
);
// The dismiss is the only thing that lets the next screen resolve its own.
screen.dismiss();
assert!(!screen.art_resolved);
}
}
+170 -362
View File
@@ -20,7 +20,7 @@
//! [`screen`] (the authored layout, transcribed from `AccountLogin.xml`), [`smoke`] (the
//! `WOW_LOGIN_SMOKE` headless prover).
mod queue;
pub(crate) mod queue;
mod screen;
mod smoke;
@@ -36,6 +36,7 @@ use bevy::prelude::*;
use benilla_protocol::{DialFailure, LoginRefusal, LoginStage};
use crate::char_select::ClientState;
use crate::glue::dialog::{DialogKind, GlueDialog};
use crate::glue_strings::GlueStrings;
use crate::net::{
CharListMessage, DisconnectedMessage, LoginAbandon, LoginFailedMessage, LoginQueuedMessage,
@@ -61,7 +62,6 @@ impl Plugin for LoginPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LoginIntent>()
.init_resource::<LoginForm>()
.init_resource::<LoginDialog>()
.add_systems(OnEnter(ClientState::Login), enter_login)
.add_systems(OnExit(ClientState::Login), screen::exit_login)
.add_systems(
@@ -76,26 +76,22 @@ impl Plugin for LoginPlugin {
tick_login_caret,
screen::refresh_boxes,
screen::refresh_checkbox,
// **The dialog is the GLUE's, not this screen's.** The reference's
// `GlueDialog` is a `GlueParent` child shown over whichever glue screen is
// up, and benilla needed that the moment a *character* login could be
// refused: `SMSG_CHARACTER_LOGIN_FAILED` has to be said on the select
// screen, where the pick was made. So [`drive_dialog`] is `pub(crate)` and
// the select screen's chain runs it too — one widget, two screens, rather
// than a second copy of it over there. The kinds this screen owns
// (`Status`, `Queued`, `Realmlist`) can only be *opened* from here, so
// only `Error` is ever reachable on the other one.
//
// It stays inside each screen's own chain rather than being hoisted out
// with a `before`/`after` pair: the shared painters below sit in this
// chain, and ordering a system both after `login_input` and before
// `art_swaps` from outside makes `art_swaps` transitively ordered against
// its own `SystemTypeSet`, which Bevy rejects at schedule build — a panic
// no unit test sees and only a real run finds.
drive_dialog,
// Both after `drive_dialog`: it is what spawns the dialog's edit box, and
// what a realmlist Okay changes the address in.
(screen::refresh_dialog_box, screen::refresh_realmlist),
// The shared glue dialog (2084) — the widget is `crate::glue::dialog`'s
// and the select screen runs the same system over the same resource; what
// a press *means* here is [`answer_dialog`]'s, right behind it. It sits
// inside this chain rather than being hoisted out with a `before`/`after`
// pair, because the shared painters below are in this chain too and
// ordering across it makes a painter transitively ordered against its own
// `SystemTypeSet` — rejected at schedule build, so it panics at boot and
// no unit test sees it (2071).
crate::glue::dialog::drive_glue_dialog,
answer_dialog,
// Both after the driver: it is what spawns the dialog's edit box, and a
// realmlist Okay is what changes the address this repaints.
(
crate::glue::dialog::refresh_dialog_box,
screen::refresh_realmlist,
),
)
.chain()
.before(crate::glue::GlueVisuals)
@@ -375,7 +371,7 @@ fn without_dead_url(text: &str) -> std::borrow::Cow<'_, str> {
/// table `0x836b78` — the long `LOGIN_*` family; the world server's resolve against `0x85cae8` —
/// the short `AUTH_*` family ([`world_refusal_text`]). The chain is grunt opcode table `0x85e278`
/// → `Logon::OnAuthResult 0x5b2c90` (byte-index table `0x5b2ea4` + jump table `0x5b2e78`) →
/// `CGlueMgr::OnLoginState 0x46b0f0` → `CGlueMgr::UpdateLoginDialog 0x46b140`.
/// `CGlueMgr::OnLoginState 0x46b0f0` → `CGlueMgr::UpdateGlueDialog 0x46b140`.
///
/// Two consequences worth stating outright:
///
@@ -455,7 +451,7 @@ fn logon_refusal_text(strings: &GlueStrings, code: Option<u8>) -> &str {
fn drive_policy(
mut attempt: Attempt,
realm_list_up: Res<crate::realm_select::Realms>,
mut dialog: ResMut<LoginDialog>,
mut dialog: ResMut<GlueDialog>,
strings: Option<Res<GlueStrings>>,
time: Res<Time>,
mut stages: MessageReader<LoginStageMessage>,
@@ -525,7 +521,7 @@ fn drive_policy(
}
// The realm list answers the same question the status dialog is asking ("what are we
// connecting to?"), and the reference does not stack them: reaching the realm list means the
// login state moved past `LOGIN_STATE_CONNECTING`, and `CGlueMgr::UpdateLoginDialog` clears
// login state moved past `LOGIN_STATE_CONNECTING`, and `CGlueMgr::UpdateGlueDialog` clears
// the dialog when it does. Ours would otherwise sit behind the list saying "Connecting".
if realm_list_up.shown && matches!(dialog.kind, Some(DialogKind::Status)) {
dialog.close();
@@ -662,7 +658,7 @@ fn drive_policy(
fn to_select_on_roster(
mut msgs: MessageReader<CharListMessage>,
mut intent: ResMut<LoginIntent>,
mut dialog: ResMut<LoginDialog>,
mut dialog: ResMut<GlueDialog>,
state: Res<State<ClientState>>,
mut next: ResMut<NextState<ClientState>>,
) {
@@ -808,7 +804,7 @@ fn login_input(
raw_handle: Query<&bevy::window::RawHandleWrapper, With<bevy::window::PrimaryWindow>>,
mut form: ResMut<LoginForm>,
mut attempt: Attempt,
mut dialog: ResMut<LoginDialog>,
mut dialog: ResMut<GlueDialog>,
strings: Option<Res<GlueStrings>>,
mut sounds: MessageWriter<GlueSound>,
mut quit: Local<bool>,
@@ -898,10 +894,8 @@ fn login_input(
if !form.save {
save_account("");
}
}
// The dialog's own buttons are [`drive_dialog`]'s, and this loop is skipped
// entirely while one is open.
LoginAction::Dialog | LoginAction::Dialog2 => {}
} // The dialog's own buttons are `crate::glue::dialog`'s, and this loop is
// skipped entirely while one is open.
}
}
@@ -910,7 +904,8 @@ fn login_input(
for ev in keyboard.read() {
// A dialog with an edit box owns the keyboard while it is up — the ref's `GlueDialog` is
// `toplevel` with `enableKeyboard="true"`, so the boxes behind it hear nothing. ENTER and
// ESCAPE still come back unclaimed; [`drive_dialog`] reads them as its two buttons.
// ESCAPE still come back unclaimed; [`crate::glue::dialog::drive_glue_dialog`] reads
// them as its two buttons.
if dialog_open {
if dialog.kind.is_some_and(DialogKind::has_edit_box) {
textinput::feed_key(
@@ -1016,7 +1011,7 @@ fn login_input(
/// **A dialog with an edit box takes the focus with the keys** (1667): its box blinks and the
/// form's stops, so the screen never shows two live carets at once. Every other dialog leaves the
/// form's caret running — a dialog eats the keys, not the clock.
fn tick_login_caret(mut form: ResMut<LoginForm>, mut dialog: ResMut<LoginDialog>, time: Res<Time>) {
fn tick_login_caret(mut form: ResMut<LoginForm>, mut dialog: ResMut<GlueDialog>, time: Res<Time>) {
let dt = time.delta_secs();
let in_dialog = dialog.kind.is_some_and(DialogKind::has_edit_box);
textinput::tick_caret(form.focused(), !in_dialog, dt);
@@ -1048,279 +1043,54 @@ fn drive_quit(arm: Option<Res<QuitArm>>, time: Res<Time>, mut exit: MessageWrite
// ── The GlueDialog (connecting / error) ──────────────────────────────────────────────────────────
/// Which dialog is up: the connecting status (Cancel button, text driven by the stages), an
/// error (Okay button), or the realmlist editor (Okay + Cancel over an edit box).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DialogKind {
Status,
Error,
/// **Queued for a full realm** (decision 1681). The reference has no queue dialog *type*: it
/// opens the ordinary `CANCEL` status dialog and re-texts it every frame, relabelling the one
/// button to `CHANGE_REALM`. This is that, as a kind — the relabel is the only thing that
/// distinguishes it from [`Self::Status`], and a kind is how this screen spells "different
/// button caption".
Queued,
/// The realmlist editor (decision 1667) — the reference's `GlueDialog` `hasEditBox` shape,
/// which is how the shipped dialog asks for a typed value (`GlueDialog.lua`: it shows
/// `GlueDialogEditBox` and re-heights the box to
/// `16 + text + 8 + editbox + 8 + button + 16`). The reference never opens this particular
/// dialog — it has no realmlist UI at all — but the widget is its own.
Realmlist,
}
impl DialogKind {
/// Whether this dialog carries the ref's `GlueDialogEditBox` (its `hasEditBox` flag).
pub(super) fn has_edit_box(self) -> bool {
matches!(self, DialogKind::Realmlist)
}
/// `(button1, button2)` captions — `GlueDialogTypes`' own two fields. A `None` second button
/// is the ref's centred single-button layout; `Some` is its BOTTOMRIGHT/LEFT pair.
pub(super) fn buttons(self, strings: &GlueStrings) -> (&str, Option<&str>) {
match self {
DialogKind::Status => (strings.text("CANCEL", "Cancel"), None),
// The reference's own relabel: leaving a queue is "Change Realm", not "Cancel".
DialogKind::Queued => (strings.text("CHANGE_REALM", "Change Realm"), None),
DialogKind::Error => (strings.text("OKAY", "Okay"), None),
DialogKind::Realmlist => (
strings.text("OKAY", "Okay"),
Some(strings.text("CANCEL", "Cancel")),
),
}
}
}
/// What the realmlist dialog says when the box holds something that is not an address. It replaces
/// the prompt in place and **leaves the typed text alone**, so the fix is an edit rather than a
/// retype — the reason a bad value does not close the dialog or become an error dialog of its own.
const REALMLIST_BAD: &str =
"That is not a server address.\nTry logon.example.org or 127.0.0.1:3724";
/// Which of the dialog's two buttons a key press answers, as `(button1, button2)` — the keyboard
/// half of [`drive_dialog`]'s buttons; the mouse half is OR-ed in beside it. Button 1 is the
/// affirmative one on every kind (Cancel on the status dialog, Okay on the others); button 2
/// exists only where the kind declares it. ENTER confirms, ESCAPE dismisses; on a one-button
/// dialog they are the same button.
///
/// **`on_screen` is the whole of the second bug fixed on 2026-08-29.** Pressing ENTER on an empty
/// login form played the sound and showed nothing, while *clicking* Login showed the dialog
/// (director's report). The two systems poll the same [`ButtonInput`]: [`login_input`] opened the
/// error dialog on the ENTER edge, and [`drive_dialog`] — later in the same chained frame, with
/// `keys` untouched — read *the same* `just_pressed(Enter)` as that dialog's own Okay and closed
/// it before it had ever been drawn. Both sounds played, which is exactly what was heard.
///
/// The reference cannot have this bug because it is event-driven: the ENTER that fires
/// `AccountLogin_Login` is *dispatched* to the focused edit box (`OnEnterPressed`), and a dialog
/// that does not exist yet is not in the dispatch. Polling is our seam, so the gate has to be
/// ours too, and it says the same thing the dispatch does — **a dialog answers only keys pressed
/// while it was already on screen**. Not "not this frame": on screen. It is passed the same
/// `fresh` flag that drives the (re)spawn, so the two cannot drift apart.
fn dialog_keys(kind: DialogKind, on_screen: bool, enter: bool, escape: bool) -> (bool, bool) {
if !on_screen {
return (false, false);
}
let button1 = match kind {
// The status dialog's one button IS Cancel, so ESCAPE is it. The queue's button is the
// same act under another name, so it answers to ESCAPE the same way.
DialogKind::Status | DialogKind::Queued => escape,
DialogKind::Error => escape || enter,
DialogKind::Realmlist => enter,
};
(button1, kind == DialogKind::Realmlist && escape)
}
/// The glue layer's one dialog (the ref's shared `GlueDialog`): kind + text; the driver spawns/
/// despawns the tree (respawning on a kind change — the button caption differs) and updates the
/// text in place.
#[derive(Resource, Default)]
pub(crate) struct LoginDialog {
pub(super) kind: Option<DialogKind>,
pub(super) text: String,
pub(super) dirty: bool,
pub(super) root: Option<Entity>,
/// The ref's `GlueDialogEditBox` — a real [`EditBoxState`] like the two on the screen behind
/// it, so the realmlist box gets the same caret, selection, Ctrl+A and clipboard law
/// (decision 0704). Only meaningful while a [`DialogKind::has_edit_box`] dialog is up; it is
/// rebuilt from the current value on every open, so a cancelled edit leaves nothing behind.
pub(super) edit: EditBoxState,
/// The queue's sample ring and the realm it is for — live only while [`DialogKind::Queued`]
/// is up, and rebuilt from empty on every fresh queue so a second login never inherits the
/// first one's estimate.
pub(super) queue: queue::QueueEstimate,
queue_realm: Option<String>,
/// The kind the spawned tree was built for.
spawned: Option<DialogKind>,
/// The glue scale the spawned tree was built at — a resize rebuilds it.
spawned_s: f32,
}
impl LoginDialog {
/// Raise the one-button error dialog over whichever glue screen is up.
///
/// `pub(crate)` for the character-select screen: a refused *character* login
/// (`SMSG_CHARACTER_LOGIN_FAILED`) is said there, not here, and it is the same dialog — the
/// reference has exactly one, under `GlueParent`.
pub(crate) fn open_error(&mut self, text: &str) {
self.kind = Some(DialogKind::Error);
self.set_text(text);
}
/// Is a dialog up? The glue screens' modal test — while one is, the screen behind it does not
/// answer clicks or keys (the select screen's own delete confirm and AddOns panel are read the
/// same way).
pub(crate) fn is_open(&self) -> bool {
self.kind.is_some()
}
fn open_status(&mut self, text: &str) {
self.kind = Some(DialogKind::Status);
self.set_text(text);
}
/// Enter the queue: a fresh ring (a second login must not inherit the first one's estimate)
/// and the realm's name for the `_NAME` text variants.
fn open_queued(&mut self, realm: Option<String>) {
self.kind = Some(DialogKind::Queued);
self.queue = queue::QueueEstimate::default();
self.queue_realm = realm;
self.set_text("");
}
/// Open the realmlist editor over `current`, with the caret at the end of it and the whole
/// value selected — the reference's `hasEditBox` dialogs open ready to be typed over, and a
/// player changing servers is replacing the address far more often than editing it.
fn open_realmlist(&mut self, prompt: &str, current: &str) {
self.kind = Some(DialogKind::Realmlist);
self.edit = textinput::field(crate::realmlist::MAX_LETTERS, false);
self.edit.set_text(current);
// `HighlightText(0, -1)` — the client's own select-all (`0x77cca0`), which resets the
// blink on its way so the box opens on a solid caret.
self.edit.highlight_text(0, -1);
self.set_text(prompt);
}
fn set_text(&mut self, text: &str) {
if self.text != text {
self.text = text.to_string();
self.dirty = true;
}
}
pub(crate) fn close(&mut self) {
self.kind = None;
self.text.clear();
self.dirty = false;
}
}
/// Spawn/despawn the dialog tree with the resource, update its text, and run its one button:
/// Status's Cancel bumps the abandon generation (the in-flight attempt discards at its next
/// stage boundary) and forgets the intent; Error's Okay just closes. Esc = the button; Enter
/// confirms an error.
#[allow(clippy::too_many_arguments)]
pub(crate) fn drive_dialog(
/// **What a glue-dialog press means, on this screen.** The widget itself
/// ([`crate::glue::dialog::drive_glue_dialog`]) owns the tree, the keys and the click sound, and
/// ends an `Error` on its own; everything below is behaviour only the login screen can define, and
/// none of it is reachable from any other screen — `Status`, `Queued` and `Realmlist` can only be
/// *opened* from here.
fn answer_dialog(
mut commands: Commands,
mut dialog: ResMut<LoginDialog>,
mut answers: MessageReader<crate::glue::dialog::GlueDialogAnswer>,
mut dialog: ResMut<GlueDialog>,
mut intent: ResMut<LoginIntent>,
mut realmlist: ResMut<crate::realmlist::Realmlist>,
mut script: Option<NonSendMut<benilla_ui::script::UiScript>>,
abandon: Res<LoginAbandon>,
art: Res<crate::glue::art::GlueArt>,
assets: Res<AssetServer>,
strings: Option<Res<GlueStrings>>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Query<(Entity, &LoginAction)>,
clicks: Res<crate::glue::GlueClicks>,
mut texts: Query<&mut Text, With<screen::DialogText>>,
mut sounds: MessageWriter<GlueSound>,
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
let Some(kind) = dialog.kind else {
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
}
dialog.spawned = None;
return;
};
// (Re)spawn on the open edge, a kind change (the button caption differs), or a window resize
// (the tree bakes the glue scale); a text-only change updates the message line in place.
//
// `fresh` is *this dialog appearing*, as opposed to a resize rebuilding a dialog already up —
// and it is the same flag [`dialog_keys`] takes as `!on_screen`, so the frame the dialog is
// drawn on and the frame it starts answering keys on are one decision, not two.
let s = crate::glue::screen_scale(window.single().ok());
let fresh = dialog.root.is_none() || dialog.spawned != Some(kind);
if fresh || dialog.spawned_s != s {
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
}
dialog.root = Some(screen::spawn_dialog(
&mut commands,
&art,
&assets,
strings,
kind,
&dialog.text,
s,
));
dialog.edit.reset_blink();
dialog.spawned = Some(kind);
dialog.spawned_s = s;
dialog.dirty = false;
} else if dialog.dirty {
for mut t in &mut texts {
if t.0 != dialog.text {
t.0 = dialog.text.clone();
for answer in answers.read() {
match answer.kind {
// The widget's own — it has already dismissed it.
DialogKind::Error => {}
DialogKind::Realmlist => {
// Okay: take the typed address, or say why it cannot be taken and stay open with
// the text as typed — the fix is then an edit, not a retype. **The only press
// that does not end its dialog**, which is exactly why the widget cannot own
// this arm. Cancel (button 2, or ESCAPE, which this kind routes to it) closes.
if answer.button1 {
let typed = dialog.edit.text.clone();
if accept_realmlist(&typed, &mut realmlist, script.as_deref_mut()) {
dialog.dismiss(&mut commands);
} else {
dialog.set_text(REALMLIST_BAD);
}
} else if answer.button2 {
dialog.dismiss(&mut commands);
}
}
}
dialog.dirty = false;
}
// The buttons, from the mouse or the keys. A click needs no `fresh` guard of its own: the
// button it would hit did not exist to be clicked on the frame the tree spawned.
let hit = |want: LoginAction| buttons.iter().any(|(e, a)| *a == want && clicks.hit(e));
let (key1, key2) = dialog_keys(
kind,
!fresh,
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::NumpadEnter),
keys.just_pressed(KeyCode::Escape),
);
let button1 = hit(LoginAction::Dialog) || key1;
let button2 = hit(LoginAction::Dialog2) || key2;
// **Every glue dialog button clicks** — `GlueDialog_OnClick` ends with
// `PlaySound("gsTitleOptionOK")` for button 1 and button 2 alike, whatever the dialog's type.
// Ours played nothing at all. Emitted here, once, before the kinds diverge, so no arm can
// forget it (and so the realmlist editor's Okay-that-refuses still clicks: the reference plays
// the sound on the press, not on the outcome).
if button1 || button2 {
sounds.write(GlueSound("gsTitleOptionOK"));
}
if kind == DialogKind::Realmlist && button1 {
// Okay: take the typed address, or say why it cannot be taken and stay open with the text
// as typed — the fix is then an edit, not a retype.
let typed = dialog.edit.text.clone();
if accept_realmlist(&typed, &mut realmlist, script.as_deref_mut()) {
dialog.close();
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
DialogKind::Status | DialogKind::Queued => {
// Cancel: the next stage boundary discards the attempt; a canceled manual attempt
// must not silently resubmit later.
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
dialog.dismiss(&mut commands);
}
} else {
dialog.set_text(REALMLIST_BAD);
}
return;
}
if button1 || button2 {
if matches!(kind, DialogKind::Status | DialogKind::Queued) {
// Cancel: the next stage boundary discards the attempt; a canceled manual attempt
// must not silently resubmit later.
abandon.0.fetch_add(1, Ordering::SeqCst);
intent.in_flight = false;
intent.clear();
}
dialog.close();
if let Some(root) = dialog.root.take() {
commands.entity(root).despawn();
}
}
}
@@ -1418,7 +1188,9 @@ mod tests {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<LoginIntent>()
.init_resource::<LoginDialog>()
// The dialog is `GluePlugin`'s resource now (2084), so a harness that stands this
// screen's systems up without that plugin has to seat it.
.init_resource::<GlueDialog>()
// The policy asks whether the realm list is standing over this screen; in a harness
// that never raises one the answer is a default `Realms` — always "no".
.init_resource::<crate::realm_select::Realms>()
@@ -1470,7 +1242,7 @@ mod tests {
account back off the client that displaced us",
);
assert!(intent.retry_at.is_none(), "and nothing is scheduled");
let dialog = app.world().resource::<LoginDialog>();
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Error));
// The fallback literal: this App has no GlueStrings, and the table's own row is
// `DISCONNECTED = "Disconnected from server";` (GlueStrings.lua) — the same words.
@@ -1502,7 +1274,7 @@ mod tests {
straight back",
);
assert!(
app.world().resource::<LoginDialog>().kind.is_none(),
app.world().resource::<GlueDialog>().kind.is_none(),
"with no dialog: nothing went wrong",
);
}
@@ -1521,33 +1293,33 @@ mod tests {
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::CharSelect)
.init_resource::<LoginIntent>()
.init_resource::<LoginDialog>()
.init_resource::<GlueDialog>()
.add_message::<CharListMessage>()
.add_systems(Update, to_select_on_roster);
// The refusal's dialog survives its own relist.
app.world_mut()
.resource_mut::<LoginDialog>()
.resource_mut::<GlueDialog>()
.open_error("World server is down");
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
});
app.update();
let dialog = app.world().resource::<LoginDialog>();
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Error));
assert_eq!(dialog.text, "World server is down");
// …and the connecting dialog the roster IS the answer to still closes.
app.world_mut()
.resource_mut::<LoginDialog>()
.resource_mut::<GlueDialog>()
.open_status("Connecting");
app.world_mut().write_message(CharListMessage {
characters: Vec::new(),
realm: None,
});
app.update();
assert!(app.world().resource::<LoginDialog>().kind.is_none());
assert!(app.world().resource::<GlueDialog>().kind.is_none());
}
/// An **unattended** run keeps 0065's paced reconnect on a lost session — the verdict rides
@@ -1571,7 +1343,7 @@ mod tests {
Some(RETRY_DELAY_SECS),
"paced by the flat 3 s off a zeroed clock, not fired on the spot",
);
assert!(app.world().resource::<LoginDialog>().kind.is_none());
assert!(app.world().resource::<GlueDialog>().kind.is_none());
}
/// **The submitted attempt dials the configured realmlist** (decision 1667) — the whole
@@ -1646,74 +1418,110 @@ mod tests {
}
}
/// The dialog kinds' own shape: only the realmlist editor carries the ref's `hasEditBox`, and
/// only it declares a second button. The caret clock and the keyboard routing both branch on
/// the first of those, and `spawn_dialog` lays out from the second.
/// **The seam the move opened** (2084): the widget publishes a press, this screen answers it.
///
/// `accept_realmlist` and the cancel bookkeeping are each covered on their own above, and the
/// widget's keys and kinds are covered in `crate::glue::dialog` — but nothing covered the
/// *wiring* between them, which is the one thing the move could break silently. A dialog whose
/// press reaches nobody looks exactly like a dialog that works until you click it.
#[test]
fn only_the_realmlist_dialog_has_a_box_and_two_buttons() {
let strings = GlueStrings::default();
assert!(!DialogKind::Status.has_edit_box());
assert!(!DialogKind::Error.has_edit_box());
assert!(DialogKind::Realmlist.has_edit_box());
assert_eq!(DialogKind::Status.buttons(&strings), ("Cancel", None));
assert_eq!(DialogKind::Error.buttons(&strings), ("Okay", None));
assert_eq!(
DialogKind::Realmlist.buttons(&strings),
("Okay", Some("Cancel")),
);
}
fn a_published_press_reaches_the_login_screens_answer() {
use crate::glue::dialog::GlueDialogAnswer;
/// **A dialog never answers the key press that opened it.** ENTER on an empty login form
/// opened the error dialog in `login_input` and then, later in the *same* frame with the same
/// `ButtonInput`, `drive_dialog` read that identical `just_pressed(Enter)` as the dialog's own
/// Okay — so the popup appeared and vanished inside one frame and only the two sounds were
/// heard (director's report, 2026-08-29). The gate is being on screen, not a frame count.
#[test]
fn a_dialog_does_not_answer_the_key_that_opened_it() {
// The frame it appears on: the key that opened it is not its answer.
assert_eq!(
dialog_keys(DialogKind::Error, false, true, false),
(false, false),
);
assert_eq!(
dialog_keys(DialogKind::Error, false, false, true),
(false, false),
);
// Once it is up, the same press is.
assert_eq!(
dialog_keys(DialogKind::Error, true, true, false),
(true, false),
);
}
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.init_resource::<LoginIntent>()
.init_resource::<GlueDialog>()
.insert_resource(crate::realmlist::Realmlist::unpinned("localhost"))
.insert_resource(LoginAbandon(std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
)))
.add_message::<GlueDialogAnswer>()
.add_systems(Update, answer_dialog);
/// Which key answers which button, per kind: ENTER and ESCAPE are the same (single) button on
/// an error dialog; ESCAPE alone works the Cancel-shaped ones; the two-button editor splits
/// them, ENTER to Okay and ESCAPE to Cancel.
#[test]
fn each_dialog_kind_maps_its_own_keys() {
for (kind, enter, escape, want) in [
(DialogKind::Error, true, false, (true, false)),
(DialogKind::Error, false, true, (true, false)),
(DialogKind::Status, true, false, (false, false)),
(DialogKind::Status, false, true, (true, false)),
(DialogKind::Queued, false, true, (true, false)),
(DialogKind::Queued, true, false, (false, false)),
(DialogKind::Realmlist, true, false, (true, false)),
(DialogKind::Realmlist, false, true, (false, true)),
] {
assert_eq!(
dialog_keys(kind, true, enter, escape),
want,
"{kind:?} enter={enter} escape={escape}",
);
}
// The status dialog's Cancel: the attempt is abandoned and the credentials forgotten, so
// nothing resubmits behind the player's back.
app.world_mut().resource_mut::<LoginIntent>().creds = Some(("one".into(), "pone".into()));
app.world_mut().resource_mut::<LoginIntent>().in_flight = true;
app.world_mut()
.resource_mut::<GlueDialog>()
.open_status("Connecting");
app.world_mut().write_message(GlueDialogAnswer {
kind: DialogKind::Status,
button1: true,
button2: false,
});
app.update();
let intent = app.world().resource::<LoginIntent>();
assert!(
!intent.in_flight,
"the cancelled attempt is no longer in flight"
);
assert!(intent.creds.is_none(), "and it does not silently resubmit");
assert_eq!(
app.world()
.resource::<LoginAbandon>()
.0
.load(std::sync::atomic::Ordering::SeqCst),
1,
"the abandon generation moved, so the attempt still in flight is discarded",
);
assert!(app.world().resource::<GlueDialog>().kind.is_none());
// The realmlist editor's Okay: the typed address becomes the session's.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_realmlist("Address of realm list server", "localhost");
app.world_mut()
.resource_mut::<GlueDialog>()
.edit
.set_text("logon.example.org");
app.world_mut().write_message(GlueDialogAnswer {
kind: DialogKind::Realmlist,
button1: true,
button2: false,
});
app.update();
assert_eq!(
app.world()
.resource::<crate::realmlist::Realmlist>()
.address(),
"logon.example.org",
);
assert!(app.world().resource::<GlueDialog>().kind.is_none());
// …and its Okay over a bad address keeps the dialog up, with the text replaced — the one
// press in the whole widget that does not end its own dialog.
app.world_mut()
.resource_mut::<GlueDialog>()
.open_realmlist("Address of realm list server", "logon.example.org");
app.world_mut()
.resource_mut::<GlueDialog>()
.edit
.set_text("not an address at all");
app.world_mut().write_message(GlueDialogAnswer {
kind: DialogKind::Realmlist,
button1: true,
button2: false,
});
app.update();
let dialog = app.world().resource::<GlueDialog>();
assert_eq!(dialog.kind, Some(DialogKind::Realmlist), "it stays open");
assert_eq!(dialog.text, REALMLIST_BAD, "saying why");
assert_eq!(
app.world()
.resource::<crate::realmlist::Realmlist>()
.address(),
"logon.example.org",
"and the address it refused is unchanged",
);
}
/// Opening the editor seats the current address in the box, selected whole — so typing a new
/// server replaces the old one instead of appending to it.
#[test]
fn opening_the_editor_preselects_the_current_address() {
let mut dialog = LoginDialog::default();
let mut dialog = GlueDialog::default();
dialog.open_realmlist("Address of realm list server", "logon.example.org");
assert_eq!(dialog.kind, Some(DialogKind::Realmlist));
assert_eq!(dialog.edit.text, "logon.example.org");
@@ -1747,7 +1555,7 @@ mod tests {
});
app.update();
assert_eq!(
app.world().resource::<LoginDialog>().kind,
app.world().resource::<GlueDialog>().kind,
Some(DialogKind::Error),
"every refusal shows the dialog, exit or not",
);
@@ -2033,10 +1841,10 @@ mod tests {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<LoginForm>()
.init_resource::<GlueDialog>()
// The clock now asks which box owns the focus (1667): a dialog with an edit box takes
// it. No dialog is open here, so the form's box keeps it — which is the case this
// asserts.
.init_resource::<LoginDialog>()
.add_systems(Update, tick_login_caret);
let past_the_period = |app: &mut App| {
+4 -179
View File
@@ -20,16 +20,15 @@ use bevy::window::PrimaryWindow;
use crate::char_select::wow_font;
use crate::glue::art::{GlueArt, BACKDROP, GOLD};
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
use crate::glue::widgets::{
abs, glue_button, glue_edit_box, outlined_text, outlined_text_centered, overlay,
paint_glue_field, ArtSwap, GlueBtnKind, GlueFieldPart, GlueText, Hilight,
abs, glue_button, glue_edit_box, outlined_text, overlay, paint_glue_field, ArtSwap,
GlueBtnKind, GlueFieldPart, GlueText, Hilight,
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::{ClientState, DialogKind, Field, LoginForm};
use super::{ClientState, Field, LoginForm};
const SCREEN_Z: i32 = 1100;
/// `GlueFontDisableSmall`'s color (GlueFonts.xml) — the grey the reference's own
@@ -50,12 +49,6 @@ pub(crate) enum LoginAction {
/// Open the realmlist editor (decision 1667) — on the button and on the address readout under
/// it, so clicking the address you want to change does what it looks like it does.
Realmlist,
/// The dialog's first button — Cancel on the status dialog, Okay on the other two
/// ([`super::drive_dialog`]'s; the ref's `GlueDialogButton1`).
Dialog,
/// The dialog's second button, on the kinds that declare one (`GlueDialogButton2` — Cancel on
/// the realmlist editor).
Dialog2,
}
/// Root of the login screen (despawned whole on exit); `with_art` mirrors the select screen's
@@ -80,13 +73,6 @@ pub(super) struct CheckMark;
/// `GlueBtn`, so the shared button pass doesn't cover it).
#[derive(Component)]
pub(super) struct CheckHilight;
/// The dialog's message text (updated in place on stage changes).
#[derive(Component)]
pub(crate) struct DialogText;
/// The dialog's edit box row items — the ref's `GlueDialogEditBox`, painted from
/// [`super::LoginDialog::edit`] by [`refresh_dialog_box`].
#[derive(Component, Clone)]
pub(super) struct DialogEditText;
/// The realmlist readout under the button — the ref's own `AccountLoginRealmName` slot, rewritten
/// in place by [`refresh_realmlist`] when the address changes.
#[derive(Component)]
@@ -626,23 +612,6 @@ pub(super) fn refresh_realmlist(
}
}
/// Paint the dialog's edit box from [`super::LoginDialog::edit`], through the same
/// [`paint_glue_field`] the two screen boxes use (decision 0704) — so the realmlist box gets the
/// identical caret, selection highlight and scrolling.
///
/// Runs **after** [`super::drive_dialog`], which is what spawns the box: on the frame a dialog
/// opens the entities do not exist until that system has run, and painting before it would show
/// one frame of empty box.
#[allow(clippy::type_complexity)]
pub(super) fn refresh_dialog_box(
dialog: Res<super::LoginDialog>,
mut boxes: Query<(&GlueFieldPart, Option<&mut Text>, &mut Visibility), With<DialogEditText>>,
) {
if dialog.kind.is_some_and(DialogKind::has_edit_box) {
paint_glue_field(&dialog.edit, true, boxes.iter_mut());
}
}
/// The checkbox's visuals: the checked overlay tracks the form's save flag; the ADD hover ring
/// tracks the button's interaction (the checkbox isn't a `GlueBtn`, so the shared pass skips it).
#[allow(clippy::type_complexity)]
@@ -678,156 +647,12 @@ pub(super) fn refresh_checkbox(
}
}
/// Build the dialog tree (the ref's shared `GlueDialog`): the 512-wide `UI-DialogBox` backdrop
/// centered on the screen, the message (`GlueFontNormalLarge`, wrapping at 440), an optional edit
/// box, and one or two `GlueDialogButtonTemplate` 200×40 buttons — Cancel for the connecting
/// status, Okay for an error, Okay + Cancel over the box for the realmlist editor.
/// Content-sized vertically (the ref's own `GlueDialog_OnShow` resize, by layout).
///
/// The edit box and the second button are both the reference's own (`GlueDialog.lua`'s
/// `hasEditBox` and `button2`); benilla simply had no dialog that used either until 1667. The two
/// authored geometries they bring with them: the button pair is Button1 BOTTOMRIGHT ← the
/// backdrop's BOTTOM at (−6, 16) with Button2 LEFT ← Button1's RIGHT at (13, 0) — a centred pair
/// with a 13 gap — and the box re-heights the backdrop to
/// `16 + text + 8 + editbox + 8 + button + 16`, which the column below is already shaped as.
///
/// **One stated divergence:** the ref's `GlueDialogEditBox` is 130×32, sized for the short values
/// its own dialogs ask for. A realmlist is a hostname, so ours is 300 wide inside the same 512
/// backdrop; the height, insets and border treatment are the login screen's boxes unchanged.
pub(super) fn spawn_dialog(
commands: &mut Commands,
art: &GlueArt,
assets: &AssetServer,
strings: &GlueStrings,
kind: DialogKind,
text: &str,
s: f32,
) -> Entity {
let px = |v: f32| Val::Px(v * s);
let font = wow_font(assets);
let edit_font: Handle<Font> = assets.load("mpq://Fonts/ARIALN.ttf");
let (caption, caption2) = kind.buttons(strings);
commands
.spawn((
GlobalZIndex(1200), // over the screen's 1100
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
))
.with_children(|overlay_ui| {
let mut boxed = overlay_ui.spawn(Node {
width: px(512.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(0.0), px(16.0)),
row_gap: px(13.0),
..default()
});
boxed.with_children(|b| {
// Backdrop: bg tiled at 32 inside (11,12,12,11), the 32-edge border over it.
if let (Some(bg), Some(border)) = (&art.dialog_bg, &art.dialog_border) {
b.spawn((
tiled_bg_node(bg.clone(), 32.0, s, Color::WHITE),
Node {
position_type: PositionType::Absolute,
left: px(11.0),
right: px(12.0),
top: px(12.0),
bottom: px(11.0),
..default()
},
));
backdrop_border(b, border, 32.0, Color::WHITE);
} else {
b.spawn((
BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.95)),
overlay(),
));
}
// The message (GlueFontNormalLarge 18 at TOP (0,−16), width 440, wrapping) —
// **centred**, which `GlueDialogText` gets by omitting `justifyH` (a FontString's
// default is CENTER; every other wrapped glue string in the shipped XML asks for
// LEFT explicitly). It read left-aligned until the director's eye caught it.
outlined_text_centered(
b,
Node {
width: px(440.0),
justify_content: JustifyContent::Center,
..default()
},
(),
DialogText,
GlueText {
text,
size: 18.0,
color: GOLD,
wrap: true,
},
&font,
s,
);
// The edit box, on the kinds that declare one (`hasEditBox`).
if kind.has_edit_box() {
glue_edit_box(
b,
art,
&edit_font,
(),
DialogEditText,
(300.0, 32.0),
(BOX_BORDER, BOX_FILL),
(15.0, 0.0, 0.0, 5.0), // the login boxes' TextInsets
s,
);
}
// The buttons (GlueDialogButtonTemplate 200×40) — one centred, or the authored
// pair with its 13 gap.
b.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: px(13.0),
..default()
})
.with_children(|row| {
glue_button(
row,
art,
&font,
LoginAction::Dialog,
caption,
200.0,
40.0,
GlueBtnKind::Dialog,
s,
);
if let Some(caption2) = caption2 {
glue_button(
row,
art,
&font,
LoginAction::Dialog2,
caption2,
200.0,
40.0,
GlueBtnKind::Dialog,
s,
);
}
});
});
})
.id()
}
/// Leaving the login screen: drop the tree, the scene, and any open dialog.
pub(super) fn exit_login(
mut commands: Commands,
roots: Query<Entity, With<LoginUi>>,
mut preview: ResMut<crate::portrait::GluePreview>,
mut dialog: ResMut<super::LoginDialog>,
mut dialog: ResMut<crate::glue::dialog::GlueDialog>,
) {
for e in &roots {
commands.entity(e).despawn();
+34 -17
View File
@@ -52,14 +52,23 @@
//! the whole ambient contribution comes from the M2 lighting collector's own enabled lights —
//! the same lights our lit emitters take their `ambient` from.
//!
//! So the trail **darkens with the scene** rather than burning at its authored colour. Two named
//! approximations, both the same class as [`crate::fishing_line`]'s:
//! So the trail **darkens with the scene** rather than burning at its authored colour — and the
//! scene it darkens with is its **wearer's**, not the world's. The draw runs inside the weapon
//! model's own per-frame callback during the wearer's model draw, so the enabled lights it
//! inherits are the ones the M2 collector committed for that unit; the held weapon's light node is
//! the wearer's own by aliasing (`[item+0x3b8] = [wearer+0x3b8]`, `0x718960`). Both halves are
//! four-way byte-derived (wow-re `format7-lighting-term.md`, `part-lit-normal-space.md` §6):
//!
//! - we sample the scene ambient ([`benilla_world::lighting::WowLighting`]) rather than summing a
//! per-model light collector, so an interior trail takes the exterior ambient;
//! - the **emissive** term is not modelled. It is inherited material state whose value that round
//! did not pin, and `EMISSIVEMATERIALSOURCE` is set nowhere, so it is a constant we would be
//! inventing rather than reproducing.
//! - **outdoors** a unit's committed ambient IS the day/night ambient, so
//! [`benilla_world::lighting::WowLighting`] is exact, not an approximation;
//! - **indoors** it is the light node's own ramped word, chasing `cap96(MOCV)` — the room's light,
//! never the sky's ([`benilla_world::interior::NodeAmbient`], the ambient half of the same
//! committed words [`benilla_world::interior::ParticleLight`] folds whole). Its absence is the
//! exterior lane, which is why the fallback above is the right one and not a guess.
//!
//! One named approximation is left: the **emissive** term is not modelled. It is inherited
//! material state whose value that round did not pin, and `EMISSIVEMATERIALSOURCE` is set nowhere,
//! so it is a constant we would be inventing rather than reproducing.
//!
//! The fold happens on the CPU, into the vertex colours, and the draw declares
//! [`benilla_world::particles::buffer::EffectLighting::None`] — the ambient is a per-draw constant,
@@ -401,27 +410,35 @@ fn draw_weapon_trails(
white: Res<TrailWhite>,
lighting: Option<Res<benilla_world::lighting::WowLighting>>,
world_cam: Query<Entity, With<WorldCamera>>,
// The wearer's committed ambient word, present only while its light node is on the interior
// bake lane — the trail takes its WEARER's light, not the scene's (see the module doc).
indoors: Query<&benilla_world::interior::NodeAmbient>,
mut trails: Query<(
Entity,
&mut WeaponTrail,
&GlobalTransform,
&InheritedVisibility,
&benilla_world::model_fade::ParentModel,
)>,
) {
let Ok(cam) = world_cam.single() else {
return;
};
// The ambient half of the fixed-function term, folded per draw (see the module doc). Absent
// before the first lighting resolve — burn at the authored colour rather than at black.
let ambient = lighting.as_deref().map_or([1.0; 3], |l| {
[
l.ambient[0].clamp(0.0, 1.0),
l.ambient[1].clamp(0.0, 1.0),
l.ambient[2].clamp(0.0, 1.0),
]
});
// The EXTERIOR ambient — the fallback, and the right answer outdoors: a unit's own committed
// ambient there is the day/night one (`0x69e4ad`'s exterior intensity leg). Absent before the
// first lighting resolve — burn at the authored colour rather than at black.
let scene = lighting.as_deref().map_or([1.0; 3], |l| l.ambient);
let now_ms = time.elapsed().as_millis() as u32;
for (entity, mut trail, prop, vis) in &mut trails {
for (entity, mut trail, prop, vis, wearer) in &mut trails {
// Indoors the wearer's node carries a committed ambient word of its own — the ramped chase
// toward `cap96(MOCV)`, the room's own light rather than the sky's. Its ABSENCE is the
// exterior lane. A Goldshire-inn character's word is ≈ (0.30, 0.22, 0.14) warm against a
// night sky's ≈ (0.22, 0.26, 0.29) cool: similar in magnitude, opposite in hue, which is
// why taking the scene's outdoors-and-in tinted every indoor trail with the sky.
let ambient = indoors
.get(wearer.0)
.map_or(scene, |a| a.0)
.map(|c| c.clamp(0.0, 1.0));
let bottom = prop.transform_point(trail.bottom);
let top = prop.transform_point(trail.top);
let Some(swing) = trail.swing.as_mut() else {
@@ -403,6 +403,7 @@ fn an_armed_trail_commits_a_strip_to_the_effect_stream() {
.add_systems(Update, (begin_effect_frame, draw_weapon_trails).chain());
let cam = app.world_mut().spawn(WorldCamera).id();
let _ = cam;
let wearer = app.world_mut().spawn_empty().id();
let mut trail = WeaponTrail::new(Vec3::Y, Vec3::ZERO);
trail.arm(kit_324(), 0);
let hand = app
@@ -412,6 +413,7 @@ fn an_armed_trail_commits_a_strip_to_the_effect_stream() {
Transform::default(),
GlobalTransform::default(),
InheritedVisibility::VISIBLE,
benilla_world::model_fade::ParentModel(wearer),
))
.id();
// Frame 1 seeds one pair; a strip needs two, so the first commit is empty by construction.
@@ -482,6 +484,7 @@ fn a_hidden_weapon_draws_nothing() {
.add_systems(Startup, init_trail_white)
.add_systems(Update, (begin_effect_frame, draw_weapon_trails).chain());
app.world_mut().spawn(WorldCamera);
let wearer = app.world_mut().spawn_empty().id();
let mut trail = WeaponTrail::new(Vec3::Y, Vec3::ZERO);
trail.arm(kit_324(), 0);
app.world_mut().spawn((
@@ -489,6 +492,7 @@ fn a_hidden_weapon_draws_nothing() {
Transform::default(),
GlobalTransform::default(),
InheritedVisibility::HIDDEN,
benilla_world::model_fade::ParentModel(wearer),
));
for _ in 0..6 {
app.world_mut()
@@ -498,3 +502,83 @@ fn a_hidden_weapon_draws_nothing() {
}
assert!(app.world().resource::<EffectQuads>().draws.is_empty());
}
/// **The trail takes its WEARER's light, not the scene's** (decision 2086).
///
/// The draw runs inside the weapon model's own per-frame callback, during the wearer's model draw,
/// so the enabled lights it inherits are the ones the M2 collector committed for that unit —
/// four-way byte-derived through `0x70d982 → 0x70ca50 → 0x70baf0` plus the held-weapon `[+0x3b8]`
/// alias. Outdoors that is the day/night ambient, which is the fallback. Indoors it is the light
/// node's own committed word, and taking the sky's instead tinted every indoor trail cool.
#[test]
fn an_indoor_wearers_committed_ambient_wins_over_the_scenes() {
fn strip_rgb(app: &App) -> [f32; 3] {
let quads = app.world().resource::<EffectQuads>();
let draw = quads.draws.first().expect("one draw");
let v = quads.verts[draw.range.start as usize];
[v.color[0], v.color[1], v.color[2]]
}
// A Goldshire-inn character's committed ambient word against a night sky's.
const ROOM: [f32; 3] = [0.298, 0.216, 0.141];
const SKY: [f32; 3] = [0.224, 0.259, 0.290];
let mut lit = [[0.0; 3]; 2];
for (i, indoors) in [false, true].into_iter().enumerate() {
let mut app = App::new();
app.init_resource::<Time>()
.init_resource::<Assets<Image>>()
.init_resource::<EffectQuads>()
.insert_resource({
let mut l = benilla_world::lighting::WowLighting::default();
l.ambient = SKY;
l
})
.add_systems(Startup, init_trail_white)
.add_systems(Update, (begin_effect_frame, draw_weapon_trails).chain());
app.world_mut().spawn(WorldCamera);
let mut wearer = app.world_mut().spawn_empty();
if indoors {
wearer.insert(benilla_world::interior::NodeAmbient(ROOM));
}
let wearer = wearer.id();
let mut trail = WeaponTrail::new(Vec3::Y, Vec3::ZERO);
trail.arm(kit_324(), 0);
let hand = app
.world_mut()
.spawn((
trail,
Transform::default(),
GlobalTransform::default(),
InheritedVisibility::VISIBLE,
benilla_world::model_fade::ParentModel(wearer),
))
.id();
for f in 1..=4u32 {
app.world_mut()
.get_mut::<GlobalTransform>(hand)
.expect("the hand exists")
.clone_from(&GlobalTransform::from_translation(Vec3::X * f as f32));
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(16));
app.update();
}
lit[i] = strip_rgb(&app);
}
// Kit 324's #f82929 through each ambient. The two are close in magnitude and opposite in hue,
// so a channel RATIO is the assertion that would actually catch the wrong one being used.
let [out_r, out_g, _] = lit[0];
let [in_r, in_g, _] = lit[1];
assert!(
(out_r / (248.0 / 255.0) - SKY[0]).abs() < 1e-5,
"outdoors takes the scene ambient: {lit:?}"
);
assert!(
(in_r / (248.0 / 255.0) - ROOM[0]).abs() < 1e-5,
"indoors takes the wearer's committed word: {lit:?}"
);
assert!(
in_r > out_r && in_g < out_g,
"the room is warmer AND redder than the sky — the hue flip is the visible half: {lit:?}"
);
}
+11 -1
View File
@@ -390,7 +390,17 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// "no part is fading" as opaque, which is false of a *pending* unit, and put a full-strength
/// shadow on the ground under an invisible creature for the length of a load. A caller asking
/// one question cannot make that mistake; a caller handed the inputs can.
const CEILING: usize = 178;
/// And 178 → 179: `interior::NodeAmbient`, a PUBLISH — one light node's committed **ambient word
/// alone**, the ramped chase toward `cap96(MOCV)`. `ParticleLight` beside it is the same words
/// folded into the whole fixed-function term (`ambient + 0.9·diffuse + Σ lamps`), which is what a
/// lit particle quad receives; this is the read side for a draw whose vertex format carries **no
/// normal**, so the normal array is disabled outright and its term is the ambient product and
/// nothing else (the weapon swing trail, decisions 2079/2086). Published rather than reconstructed
/// for the reason the entry above gives: the caller cannot rebuild it from `ParticleLight` — the
/// diffuse lobe and the MOLT points are already summed in and cannot be subtracted back out — and
/// a caller that reached for the *scene* ambient instead, which is what this replaces, tinted
/// every indoor trail with the sky.
const CEILING: usize = 179;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
+29 -1
View File
@@ -235,6 +235,23 @@ pub struct LitEmitters(Vec<Entity>);
#[derive(Component, Clone, Copy, PartialEq, Debug)]
pub struct ParticleLight(pub [f32; 3]);
/// The **ambient half alone** of this anchor's committed words — `[`GroundShade::ambient`], the
/// ramped chase toward `cap96(MOCV)`, with no diffuse lobe and no MOLT points.
///
/// [`ParticleLight`] is the whole fixed-function term (`ambient + 0.9·diffuse + Σ lamps`) because
/// that is what a lit particle quad receives. One consumer needs strictly less: a **vertex format
/// with no normal** disables the normal array outright, so its draw evaluates only the ambient
/// product — `out = (Σ enabled lights' Ambient) × authoredColour` (wow-re
/// `gx/scratch/format7-lighting-term.md`). The weapon swing trail is that draw, and it inherits
/// the *wearer's* committed light, four-way byte-derived through
/// `0x70d982 → 0x70ca50 → 0x70baf0` plus the held-weapon `[+0x3b8]` alias.
///
/// Same lifetime as [`ParticleLight`], for the same reason: present only on [`AppliedLaw::Bake`],
/// and its ABSENCE is the exterior lane — outdoors a unit's ambient **is** the day/night ambient
/// (`0x69e4ad`'s exterior intensity leg), so a consumer falls back to the scene's own.
#[derive(Component, Clone, Copy, PartialEq, Debug)]
pub struct NodeAmbient(pub [f32; 3]);
/// The anchor's classification record (0734) — the law its parts render under, plus the
/// movement/residency gate that used to live per part. Inserted by the classifier on the first
/// resolve; a settled anchor is one distance compare per frame, whatever its part count.
@@ -479,6 +496,7 @@ pub fn classify_entity_interior(
Option<&BodyBakeCenter>,
Option<&LitEmitters>,
Option<&mut ParticleLight>,
Option<&mut NodeAmbient>,
),
Or<(With<LitParts>, With<LitEmitters>)>,
>,
@@ -511,6 +529,7 @@ pub fn classify_entity_interior(
bake_center,
lit_emitters,
particle_light,
node_ambient,
) in &mut anchors
{
n_anchors += 1;
@@ -546,6 +565,11 @@ pub fn classify_entity_interior(
&bake.lobes,
)));
}
// …and the ambient word on its own, for the normal-less draws whose
// term is that product and nothing else ([`NodeAmbient`]).
if let Some(mut amb) = node_ambient {
amb.set_if_neq(NodeAmbient(words.0));
}
}
}
}
@@ -1022,6 +1046,9 @@ fn resolve_anchor_law(
// curve, because a particle draw takes the fixed-function lane
// where a mesh batch takes the SH one ([`interior_light_up`]).
ParticleLight(emitter_light),
// …and the ambient half alone, which is the whole term for a
// draw whose vertex format carries no normal ([`NodeAmbient`]).
NodeAmbient(words.0),
));
AppliedLaw::Bake(slot)
}
@@ -1060,7 +1087,8 @@ fn resolve_anchor_law(
.entity(anchor)
.try_remove::<PropProbeSlot>()
.try_remove::<BakeState>()
.try_remove::<ParticleLight>();
.try_remove::<ParticleLight>()
.try_remove::<NodeAmbient>();
}
_ => {}
}