diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index 70a7d346..191db3bc 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -42,6 +42,9 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option { CursorPayload::Action(a) => a.texture, CursorPayload::Macro(m) => m.texture, CursorPayload::PetAction(p) => p.texture, + // Mode 10 — the stabled pet's family icon (decision 1677). Always present: a non-empty + // icon path is the grab's own gate. + CursorPayload::StablePet(p) => Some(p.texture), } } diff --git a/crates/benilla-app/src/glue/widgets.rs b/crates/benilla-app/src/glue/widgets.rs index 2eaec2d2..bcae8988 100644 --- a/crates/benilla-app/src/glue/widgets.rs +++ b/crates/benilla-app/src/glue/widgets.rs @@ -131,6 +131,43 @@ pub(crate) fn outlined_text( &markup_spans(spec.text, spec.color, spec.wrap), spec.size, spec.wrap, + Justify::Left, + font, + s, + ) +} + +/// [`outlined_text`], **centred** — for a wrapped paragraph the reference centres. +/// +/// A separate door rather than a field on [`GlueText`] because centring is the exception here, and +/// naming it at the one call site that wants it is cheaper and clearer than a fifth field on +/// thirty-three literals. The justification has to reach all nine strings (the eight outline copies +/// share the layout), so it cannot be inserted onto the real text afterwards. +/// +/// **Which way round is faithful is not obvious**, so: a `FontString` with no `justifyH` defaults +/// to **CENTER**, and `GlueDialogText` (`GlueDialog.xml:56`) omits it — so the dialog is centred. +/// Every *other* wrapped glue string in the shipped XML sets `justifyH="LEFT"` explicitly +/// (`CharacterCreate.xml`'s race/class/faction bodies, `AddonList.xml`'s title/notes/deps), which +/// is why [`outlined_text`] stays left and this is the exception rather than the default. +#[allow(clippy::too_many_arguments)] +pub(crate) fn outlined_text_centered( + parent: &mut ChildSpawnerCommands, + node: Node, + wrapper_extra: W, + text_extra: T, + spec: GlueText, + font: &Handle, + s: f32, +) -> Entity { + outlined_spans( + parent, + node, + wrapper_extra, + text_extra, + &markup_spans(spec.text, spec.color, spec.wrap), + spec.size, + spec.wrap, + Justify::Center, font, s, ) @@ -216,16 +253,20 @@ fn outlined_spans( spans: &[(String, Color)], size: f32, wrap: bool, + justify: Justify, font: &Handle, s: f32, ) -> Entity { + // Both fields set, so no `..default()` — `TextLayout` has exactly these two, and clippy's + // `needless_update` is right that spelling a rest-pattern here only hides the next field + // Bevy adds. let layout = TextLayout { linebreak: if wrap { LineBreak::WordBoundary } else { LineBreak::NoWrap }, - ..default() + justify, }; let tf = TextFont { font: font.clone(), diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index a3993523..04822db7 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -20,6 +20,7 @@ //! [`screen`] (the authored layout, transcribed from `AccountLogin.xml`), [`smoke`] (the //! `WOW_LOGIN_SMOKE` headless prover). +mod queue; mod screen; mod smoke; @@ -37,8 +38,8 @@ use benilla_protocol::{DialFailure, LoginRefusal, LoginStage}; use crate::char_select::ClientState; use crate::glue_strings::GlueStrings; use crate::net::{ - CharListMessage, DisconnectedMessage, LoginAbandon, LoginFailedMessage, LoginRequest, - LoginStageMessage, LoginSubmit, + CharListMessage, DisconnectedMessage, LoginAbandon, LoginFailedMessage, LoginQueuedMessage, + LoginRequest, LoginStageMessage, LoginSubmit, }; use crate::portrait::{GluePreview, GlueScene}; use crate::sound::GlueSound; @@ -178,6 +179,36 @@ impl Attempt<'_> { } } +/// The millisecond clock the queue ring is stamped with — the app's own elapsed time, which is +/// what the reference's `GetTickCount` is here. Only differences matter to the estimate, so an +/// arbitrary epoch is fine; `u32` keeps the wrapping arithmetic the same width the reference used. +fn queue_now_ms(time: &Time) -> u32 { + time.elapsed().as_millis() as u32 +} + +/// What a Login press should do — the reference's two guards, as a verdict. +/// +/// Extracted from [`login_input`] so the validation can be tested without standing a screen up, +/// and so the click sound has somewhere to live that is not inside one of its branches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoginPress { + NeedAccount, + NeedPassword, + Submit, +} + +/// The account box is checked first, so a wholly empty form asks for the account name — which is +/// the order the reference's dialogs come in. +fn login_press(form: &LoginForm) -> LoginPress { + if form.account.text.is_empty() { + LoginPress::NeedAccount + } else if form.password.text.is_empty() { + LoginPress::NeedPassword + } else { + LoginPress::Submit + } +} + /// The `LOGIN_STATE_*` glue string for a stage (the connecting dialog's text). fn stage_text(strings: &GlueStrings, stage: LoginStage) -> &str { match stage { @@ -211,11 +242,12 @@ fn fail_text( }; return format!("{headline}\n{}", dial.address); } - match refusal { - Some(LoginRefusal::World(code)) => world_refusal_text(strings, code).to_string(), - Some(LoginRefusal::Logon(code)) => logon_refusal_text(strings, Some(code)).to_string(), - None => logon_refusal_text(strings, None).to_string(), - } + let authored = match refusal { + Some(LoginRefusal::World(code)) => world_refusal_text(strings, code), + Some(LoginRefusal::Logon(code)) => logon_refusal_text(strings, Some(code)), + None => logon_refusal_text(strings, None), + }; + without_dead_url(authored).into_owned() } /// **The world server's** `SMSG_AUTH_RESPONSE` refusal, in the client's own words. @@ -278,6 +310,42 @@ fn world_refusal_text(strings: &GlueStrings, code: u8) -> &str { strings.text(key, fallback) } +/// Cut the **dead web address** off the tail of an authored failure string (director's call, +/// 2026-08-28). +/// +/// Every long `LOGIN_*`/`AUTH_*` string ends by pointing the player at a Blizzard page — +/// `www.worldofwarcraft.com`, `worldofwarcraft.com/misc/banned.html`. None has served anything in +/// fifteen years, and none was ever about *this* server: benilla's player is on somebody's private +/// realm, where "see www.worldofwarcraft.com for more information" is not merely stale but +/// misdirection. The rest of the sentence is still worth reading, so the address is cut at its +/// **clause**, not its sentence — `"…a lost or stolen password and account, see +/// www.worldofwarcraft.com for more information."` becomes `"…a lost or stolen password and +/// account."` +/// +/// Find the first web address, walk back to the clause boundary before it (a comma or a full +/// stop), end there. **With no boundary to walk back to the string is left exactly as it is** — +/// that guard is what stops `AUTH_BANNED` ("…violating the Terms of Use Agreement - +/// www.worldofwarcraft.com/termsofuse.shtml…"), whose address sits behind a dash with no clause +/// break before it, from being trimmed away to nothing. +fn without_dead_url(text: &str) -> std::borrow::Cow<'_, str> { + let Some(url) = ["www.", "http://", "https://"] + .iter() + .filter_map(|p| text.find(p)) + .min() + else { + return std::borrow::Cow::Borrowed(text); + }; + let head = &text[..url]; + let Some(cut) = head.rfind([',', '.']) else { + return std::borrow::Cow::Borrowed(text); + }; + let kept = head[..cut].trim_end(); + if kept.is_empty() { + return std::borrow::Cow::Borrowed(text); + } + std::borrow::Cow::Owned(format!("{kept}.")) +} + /// The **realmd** logon-proof refusal, in the client's own words — the LONG `LOGIN_*` family. /// /// **This table was wrong in every row until decision 1679**, and wrong in an instructive way: the @@ -376,6 +444,7 @@ fn drive_policy( strings: Option>, time: Res