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:
@@ -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 {
|
||||
|
||||
@@ -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)>>,
|
||||
) {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user