a full realm is a queue to wait in, and the login dialog is centred

- a full realm answers with `AUTH_WAIT_QUEUE`, and it used to be read as a refusal; the grammar is length-branching exactly as the reference's handler has it, so both mangos body shapes parse; the estimate is the reference's own 8-sample ring — divide before multiply, signed, no timer — whose displayed figure climbs while the queue is silent, pinned by a test so nobody "fixes" it; the dialog is the ordinary status dialog relabelled `CHANGE_REALM` (which cancels back to the login screen: no realm list exists yet), and cancelling actually leaves the queue — the IO thread used to keep its place and walk on into character select on admission
- the stable master, corrected against the binary: a stabled-pet drag rides the shared cursor at payload mode 10 (the mode census had that row's label wrong), a plain click always returns 1 and a drop always nil, the summoned pet dropped on an occupied stable slot swaps (STABLE was refused whenever both bought slots were full, silently), an unpurchased slot takes nothing, the stabled→summoned fork reads the live pet guid not the row, the client itself sends `MSG_LIST_STABLED_PETS` on the interact leg, `PET_STABLE_SHOW` fires on every list, the selection is a petNumber and every list clears it, and result codes 2–7 do nothing at all
- the login dialog's text is centred (a FontString with no `justifyH` is CENTER, and `GlueDialogText` has none), the fifteen-year-dead "see www.worldofwarcraft.com" clauses are cut at the clause, the Login click sound is unconditional (the reference plays `gsLogin` before it checks the boxes), dialog buttons play `gsTitleOptionOK`, the realmlist button's `gsClick` never existed and is `gsLoginNewAccount`, and focusing a login box selects it — a stated divergence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:21:50 +07:00
co-authored by Claude Fable 5
parent a5cb3cf746
commit 77ca6915bc
23 changed files with 1494 additions and 279 deletions
+3
View File
@@ -42,6 +42,9 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
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),
}
}
+42 -1
View File
@@ -131,6 +131,43 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
&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<W: Bundle, T: Bundle>(
parent: &mut ChildSpawnerCommands,
node: Node,
wrapper_extra: W,
text_extra: T,
spec: GlueText,
font: &Handle<Font>,
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<W: Bundle, T: Bundle>(
spans: &[(String, Color)],
size: f32,
wrap: bool,
justify: Justify,
font: &Handle<Font>,
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(),
+294 -44
View File
@@ -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<Res<GlueStrings>>,
time: Res<Time>,
mut stages: MessageReader<LoginStageMessage>,
mut queued: MessageReader<LoginQueuedMessage>,
mut failures: MessageReader<LoginFailedMessage>,
mut disconnects: MessageReader<DisconnectedMessage>,
mut exit: MessageWriter<AppExit>,
@@ -443,6 +512,32 @@ fn drive_policy(
dialog.set_text(stage_text(strings, msg.stage));
}
}
// **The queue** (decision 1681): each packet is one sample, and the first one turns the
// connecting dialog into the queue dialog. A queue is not a failure — the attempt is still in
// flight and `in_flight` deliberately stays set, so nothing resubmits underneath it.
for msg in queued.read() {
if !matches!(dialog.kind, Some(DialogKind::Queued)) {
dialog.open_queued(msg.realm.clone());
}
if let Some(position) = msg.position {
dialog.queue.sample(position, queue_now_ms(&time));
}
info!(
"login: queued for {} at position {}",
msg.realm.as_deref().unwrap_or("the realm"),
msg.position
.map_or_else(|| "unknown".to_string(), |p| p.to_string()),
);
}
// The countdown is live between packets, so the text is recomputed every frame it is up.
if matches!(dialog.kind, Some(DialogKind::Queued)) {
let text = dialog.queue.text(
queue_now_ms(&time),
dialog.queue_realm.clone().as_deref(),
strings,
);
dialog.set_text(&text);
}
for msg in failures.read() {
attempt.intent.in_flight = false;
attempt.intent.park = IoPark::AtLogin;
@@ -561,7 +656,7 @@ fn to_select_on_roster(
// ── The screen's form state + input ──────────────────────────────────────────────────────────────
/// Which edit box has the focus.
#[derive(Default, Clone, Copy, PartialEq, Eq)]
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Field {
#[default]
Account,
@@ -594,6 +689,24 @@ impl Default for LoginForm {
}
impl LoginForm {
/// Give `field` the keyboard **and select everything already in it**.
///
/// **A knowing divergence** (director's call, 2026-08-28). The reference does neither on a
/// click nor a TAB: `CEditBox::OnMouseDown` (`0x77b800`) "focuses unconditionally" and
/// `SetFocus` (`0x77e3d0`) only moves the focus — neither highlights (wow-re `ui.md` §1210,
/// §1218). But the thing a player does after clicking into a login field is *replace* what is
/// there — a remembered account name, a mistyped password — and having to select-all or
/// backspace it out first is friction the reference only avoids by being what everyone was
/// used to in 2006.
///
/// The selection uses the client's own `HighlightText(0, -1)` (`0x77cca0`), which resets the
/// blink on its way, so the box still opens on a solid caret — the property the old
/// `reset_blink()` calls at these sites existed to preserve.
fn focus(&mut self, field: Field) {
self.focus = field;
self.focused().highlight_text(0, -1);
}
/// The box that currently owns the keyboard.
fn focused(&mut self) -> &mut EditBoxState {
match self.focus {
@@ -621,8 +734,10 @@ fn enter_login(mut form: ResMut<LoginForm>, mut preview: ResMut<GluePreview>) {
form.account.set_text(&saved);
form.password.set_text("");
// `SetFocus` starts the caret solid — the screen never opens mid-blink-off (`set_text` alone
// wouldn't do it: it no-ops when the saved name is already in the box).
form.focused().reset_blink();
// wouldn't do it: it no-ops when the saved name is already in the box) — and selects, so a
// remembered account name is typed over rather than appended to.
let focus = form.focus;
form.focus(focus);
preview.scene = Some(GlueScene::MainMenu);
preview.look = None;
preview.yaw = 0.0;
@@ -673,17 +788,10 @@ fn login_input(
// gave, without costing this system a second query.
if interaction.is_changed() && *interaction == Interaction::Pressed {
match action {
// A click takes the focus the same way TAB does — including the solid caret the
// fresh focus starts on, so the box you just clicked never answers with a blank
// half-period.
LoginAction::FocusAccount => {
form.focus = Field::Account;
form.focused().reset_blink();
}
LoginAction::FocusPassword => {
form.focus = Field::Password;
form.focused().reset_blink();
}
// A click takes the focus the same way TAB does — the solid caret, and the
// select-all that makes the next keystroke replace what is in the box.
LoginAction::FocusAccount => form.focus(Field::Account),
LoginAction::FocusPassword => form.focus(Field::Password),
_ => {}
}
}
@@ -698,7 +806,12 @@ fn login_input(
// The realmlist control (1667) — the button and the address readout under it are
// the same action, so clicking either opens the editor.
LoginAction::Realmlist => {
sounds.write(GlueSound("gsClick"));
// `gsLoginNewAccount` — what the reference plays for the other buttons in this
// corner (`AccountLogin_ManageAccount`, `AccountLogin_LaunchCommunitySite`), which
// is the closest authored answer for a button it does not have. It replaces a
// `gsClick` I invented in 1667: no such SoundEntries kit exists, so that press was
// silently playing nothing at all.
sounds.write(GlueSound("gsLoginNewAccount"));
if attempt.realmlist.pinned_by_env() {
// A harness/dev run owns the address for the session (`cvars`' env-override
// law). Say so rather than opening an editor whose Okay would be a silent
@@ -772,11 +885,11 @@ fn login_input(
continue;
}
if ev.state == ButtonState::Pressed && ev.key_code == KeyCode::Tab {
form.focus = match form.focus {
let next = match form.focus {
Field::Account => Field::Password,
Field::Password => Field::Account,
};
form.focused().reset_blink();
form.focus(next);
}
}
@@ -790,24 +903,39 @@ fn login_input(
}
if do_login && !attempt.intent.in_flight {
// The ref's own guards: empty account / empty password get their dialog, no wire.
if form.account.text.is_empty() {
dialog.open_error(strings.text("LOGIN_ENTER_NAME", "Please enter your account name."));
} else if form.password.text.is_empty() {
dialog.open_error(strings.text("LOGIN_ENTER_PASSWORD", "Please enter your password."));
} else {
sounds.write(GlueSound("gsLogin"));
// `AccountLogin_Login`: save or clear the account name per the checkbox, clear the
// password box after grabbing it.
if form.save {
save_account(&form.account.text);
} else {
save_account("");
// **`gsLogin` first, always** — `AccountLogin_Login` plays it before it calls
// `DefaultServerLogin`, so the reference clicks even when the attempt is about to be
// refused for an empty box. Ours played it only on the path that reached the wire, so a
// Login press with a field still blank was silent and the dialog appeared out of nowhere
// (the director's "the sound of the popup is sometimes missing"). It sits OUTSIDE the
// match on purpose: a sound that is structurally unconditional cannot go missing on some
// branch again.
sounds.write(GlueSound("gsLogin"));
match login_press(&form) {
// The ref's own guards: empty account / empty password get their dialog, no wire.
LoginPress::NeedAccount => {
dialog.open_error(
strings.text("LOGIN_ENTER_NAME", "Please enter your account name."),
);
}
LoginPress::NeedPassword => {
dialog.open_error(
strings.text("LOGIN_ENTER_PASSWORD", "Please enter your password."),
);
}
LoginPress::Submit => {
// `AccountLogin_Login`: save or clear the account name per the checkbox, clear the
// password box after grabbing it.
if form.save {
save_account(&form.account.text);
} else {
save_account("");
}
let (user, pass) = (form.account.text.clone(), form.password.text.clone());
form.password.set_text("");
dialog.open_status(strings.text("LOGIN_STATE_CONNECTING", "Connecting"));
attempt.send(&user, &pass, true);
}
let (user, pass) = (form.account.text.clone(), form.password.text.clone());
form.password.set_text("");
dialog.open_status(strings.text("LOGIN_STATE_CONNECTING", "Connecting"));
attempt.send(&user, &pass, true);
}
}
if do_quit && !*quit {
@@ -869,6 +997,12 @@ fn drive_quit(arm: Option<Res<QuitArm>>, time: Res<Time>, mut exit: MessageWrite
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
@@ -888,6 +1022,8 @@ impl DialogKind {
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"),
@@ -917,6 +1053,11 @@ pub(crate) struct LoginDialog {
/// (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.
@@ -932,6 +1073,14 @@ impl LoginDialog {
self.kind = Some(DialogKind::Error);
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.
@@ -976,6 +1125,7 @@ fn drive_dialog(
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();
@@ -1026,13 +1176,22 @@ fn drive_dialog(
let escape = keys.just_pressed(KeyCode::Escape);
let button1 = hit(LoginAction::Dialog)
|| match kind {
// The status dialog's one button IS Cancel, so ESCAPE is it.
DialogKind::Status => escape,
// 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,
};
let button2 = hit(LoginAction::Dialog2) || (kind == DialogKind::Realmlist && escape);
// **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.
@@ -1048,7 +1207,7 @@ fn drive_dialog(
return;
}
if button1 || button2 {
if kind == DialogKind::Status {
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);
@@ -1167,6 +1326,7 @@ mod tests {
std::sync::atomic::AtomicU64::new(0),
)))
.add_message::<LoginStageMessage>()
.add_message::<LoginQueuedMessage>()
.add_message::<LoginFailedMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(Update, drive_policy);
@@ -1426,6 +1586,96 @@ mod tests {
);
}
/// **The dead web address comes off** (director's call) — at the clause, so the sentence in
/// front of it survives.
#[test]
fn a_dead_url_is_cut_at_its_clause() {
// The reported one, verbatim from `GlueStrings.lua`.
assert_eq!(
without_dead_url(
"The information you have entered is not valid. Please check the spelling of the \
account name and password. If you need help in retrieving a lost or stolen \
password and account, see www.worldofwarcraft.com for more information.",
),
"The information you have entered is not valid. Please check the spelling of the \
account name and password. If you need help in retrieving a lost or stolen password \
and account.",
);
// A whole trailing sentence goes when the address is what the sentence is for.
assert_eq!(
without_dead_url(
"This World of Warcraft account has been closed and is no longer available for \
use. Please go to http://www.worldofwarcraft.com/misc/banned.html for further \
information. ",
),
"This World of Warcraft account has been closed and is no longer available for use.",
);
// Nothing to cut: left exactly alone, and borrowed rather than rebuilt.
let clean = "You have used up your prepaid time for this account.";
assert!(matches!(
without_dead_url(clean),
std::borrow::Cow::Borrowed(_)
));
assert_eq!(without_dead_url(clean), clean);
}
/// The guard that stops the trim eating a string whose address has no clause in front of it —
/// `AUTH_BANNED`, where the URL sits behind a dash. Better untrimmed than truncated to nothing.
#[test]
fn an_address_with_no_clause_boundary_is_left_alone() {
let banned = "This account has been banned for violating the Terms of Use Agreement - \
www.worldofwarcraft.com/termsofuse.shtml. Please contact our GM department.";
assert_eq!(without_dead_url(banned), banned);
// …and the degenerate case cannot produce an empty dialog.
assert_eq!(without_dead_url("www.example.com"), "www.example.com");
assert_eq!(without_dead_url(". www.example.com"), ". www.example.com");
}
/// The Login press's two guards, in the reference's order: a wholly empty form asks for the
/// account name first.
#[test]
fn the_login_press_asks_for_the_account_before_the_password() {
let mut form = LoginForm::default();
assert_eq!(login_press(&form), LoginPress::NeedAccount);
form.account.set_text("one");
assert_eq!(login_press(&form), LoginPress::NeedPassword);
form.password.set_text("pone");
assert_eq!(login_press(&form), LoginPress::Submit);
// And a password-only form still asks for the account, not the password.
let mut only_pass = LoginForm::default();
only_pass.password.set_text("pone");
assert_eq!(login_press(&only_pass), LoginPress::NeedAccount);
}
/// **Focusing a box selects it** (director's call, diverging from the reference) — and leaves
/// the caret solid, which is what the `reset_blink` calls this replaced were for.
#[test]
fn focusing_a_box_selects_all_of_it() {
let mut form = LoginForm::default();
form.account.set_text("remembered");
form.password.set_text("secret");
form.focus(Field::Account);
assert_eq!(form.focus, Field::Account);
assert_eq!(form.account.selected_text().as_deref(), Some("remembered"));
assert!(form.account.caret_shown, "a fresh focus starts solid");
// TAB to the other box selects that one whole too. Checked as a RANGE, not as text: a
// password box's `selected_text` hands back the `*` mask, because the real characters are
// never rendered or copied (decision 0704's box law) — which is the correct answer and
// exactly why the assertion cannot ask for them.
form.focus(Field::Password);
assert_eq!(
(form.password.sel_start, form.password.sel_end),
(0, form.password.text.len()),
"the whole password is selected even though it cannot be read back",
);
// Typing then replaces rather than appends — the point of the divergence.
form.focused().insert("x");
assert_eq!(form.password.text, "x");
}
/// The code→string map quotes the client's own strings for the vmangos-verified rows.
/// The realmd map, against the byte-verified table (wow-re `login-failure-dialogs.md`).
///
+320
View File
@@ -0,0 +1,320 @@
//! **The login queue** — our place in line for a full realm, and the reference's own estimate of
//! how long it will take (decision 1681).
//!
//! The realm server answers a login to a full realm with `SMSG_AUTH_RESPONSE(AUTH_WAIT_QUEUE)` and
//! a position, re-sending as the line moves and ending with `AUTH_OK` when we are admitted. It is
//! a **wait, not an outcome** — which is what benilla got wrong until this module: the world
//! handshake treated the code as a refusal, so a busy server was simply unreachable.
//!
//! Everything here is the reference's arithmetic, transcribed (VERIFIED, wow-re
//! `system/glue/scratch/login-failure-dialogs.md` §Q3). It is worth transcribing rather than
//! reinventing because the estimate is visibly wrong in a *specific* way — it divides before it
//! multiplies, so it truncates per-person seconds before scaling them by the whole queue — and a
//! "better" estimator would disagree with the reference by minutes on a long queue.
//!
//! Three things about it are deliberately NOT reproduced, each named at its site: the unguarded
//! 32-bit overflow, the stale-position redisplay on a truncated packet ([`benilla_protocol`]'s
//! parser), and the leading blank line the reference's composite prints.
/// How many `(tick, position)` samples the estimate is formed from — the reference's ring, exactly
/// (`0xb41cf8..0xb41d14` ticks, `0xb41d18..0xb41d34` positions).
const SAMPLES: usize = 8;
/// A minute, in the milliseconds everything here counts in.
const MINUTE_MS: i64 = 60_000;
/// The reference's queue-estimate ring: `[0]` is the newest sample, `[SAMPLES - 1]` the oldest.
///
/// Sampled **once per queue packet, on no timer at all** — so the estimate's whole notion of time
/// is the server's send cadence. A `tick[SAMPLES - 1]` still zero means fewer than eight packets
/// have arrived and no estimate exists yet.
#[derive(Debug, Default, Clone)]
pub(crate) struct QueueEstimate {
tick_ms: [u32; SAMPLES],
position: [u32; SAMPLES],
}
impl QueueEstimate {
/// Fold one queue packet in at `now_ms`.
///
/// **A position that has not moved does not shift the ring** once it is full — it only restamps
/// the newest tick. That is the reference's own guard, and it is what stops a chatty server
/// that keeps repeating the same position from flushing the history the estimate is made of.
pub(crate) fn sample(&mut self, position: u32, now_ms: u32) {
let repeat = position == self.position[0] && self.tick_ms[SAMPLES - 1] != 0;
if !repeat {
self.tick_ms.rotate_right(1);
self.position.rotate_right(1);
}
self.position[0] = position;
self.tick_ms[0] = now_ms;
}
/// Our latest known position, or `None` before any packet.
pub(crate) fn position(&self) -> Option<u32> {
(self.tick_ms[0] != 0).then_some(self.position[0])
}
/// The estimated wait in milliseconds, or `None` while one cannot be formed.
///
/// `floor((now - oldest_tick) / (oldest_pos - newest_pos)) * newest_pos` — **divide first**,
/// truncating, then scale. The reference emits `idiv` at `0x46aed3` and `imul` at `0x46aed5`,
/// in that order, and the order is load-bearing: at 30 s per person and 100 people ahead,
/// dividing first gives the same answer, but at 90 s per two people it discards the remainder
/// once for every person in the queue rather than once overall.
///
/// Two preconditions, both the reference's: the ring must be full, and the queue must have
/// **moved forward** (the oldest position strictly greater than the newest). A queue that has
/// not moved, or has moved backwards, yields nothing rather than a negative or infinite wait.
///
/// The arithmetic is done in `i64` where the reference uses 32-bit signed and does not guard
/// the multiply. That overflow is a defect, not a behaviour: reproducing it would put an
/// absurd countdown on screen for a queue long enough to trigger it.
pub(crate) fn estimate_ms(&self, now_ms: u32) -> Option<i64> {
if self.tick_ms[SAMPLES - 1] == 0 {
return None; // fewer than SAMPLES packets so far
}
let moved = i64::from(self.position[SAMPLES - 1]) - i64::from(self.position[0]);
if moved <= 0 {
return None; // stalled, or going backwards
}
let elapsed = i64::from(now_ms.wrapping_sub(self.tick_ms[SAMPLES - 1]));
let per_place = elapsed / moved;
Some(per_place * i64::from(self.position[0]))
}
/// What the dialog says right now.
///
/// `realm` is the realm's name when we know it — the reference reads the `realmName` CVar it
/// wrote on the way in, and since that is the only door into the queued state, the **`_NAME`
/// variants are what its players actually saw**; the plain "Realm is Full" forms need an empty
/// name and effectively never render.
///
/// What is displayed is `tick[0] + estimate - now`. That reads like a countdown and **is not
/// one**: `estimate` is itself recomputed from `now`, against a divisor fixed by the last eight
/// packets, so a silent queue's figure *climbs* — its slope is `places_left / places_moved - 1`,
/// positive whenever more people are ahead of you than the ring saw move. The next packet
/// re-anchors it. Verified, reproduced deliberately, and pinned by
/// `a_silent_queue_inflates_its_own_estimate`: a client that counted down smoothly here would
/// disagree with the reference by minutes.
///
/// **One stated divergence.** The reference composes this as `"%s\n%s"` with an empty first
/// half — the status leg it means to print is suppressed by a guard sixteen instructions
/// earlier — so its dialog carries a leading blank line for two of the three states and not the
/// third. That is a defect of an empty string, not a layout, and it is not reproduced.
pub(crate) fn text(
&self,
now_ms: u32,
realm: Option<&str>,
strings: &crate::glue_strings::GlueStrings,
) -> String {
let position = self.position().unwrap_or(0);
let named = realm.filter(|r| !r.is_empty());
// `remaining` is what is left of the estimate that was current when the last packet landed.
let remaining = self.estimate_ms(now_ms).and_then(|est| {
(est > 0).then(|| i64::from(self.tick_ms[0]) + est - i64::from(now_ms))
});
let (key, fallback) = match remaining {
None => (
"QUEUE_NAME_TIME_LEFT_UNKNOWN",
"%s is Full\nPosition in queue: %d\nEstimated time: Calculating...",
),
Some(ms) if ms < MINUTE_MS => (
"QUEUE_NAME_TIME_LEFT_SECONDS",
"%s is Full\nPosition in queue: %d\nEstimated time: < 1 minute",
),
Some(_) => (
"QUEUE_NAME_TIME_LEFT",
"%s is Full\nPosition in queue: %d\nEstimated time: %d min",
),
};
let (key, fallback) = match named {
Some(_) => (key, fallback),
// The nameless twin of whichever leg we picked — same text without the leading `%s`.
None => match remaining {
None => (
"QUEUE_TIME_LEFT_UNKNOWN",
"Realm is Full\nPosition in queue: %d\nEstimated time: Calculating...",
),
Some(ms) if ms < MINUTE_MS => (
"QUEUE_TIME_LEFT_SECONDS",
"Realm is Full\nPosition in queue: %d\nEstimated time: < 1 minute",
),
Some(_) => (
"QUEUE_TIME_LEFT",
"Realm is Full\nPosition in queue: %d\nEstimated time: %d min",
),
},
};
let mut out = strings.text(key, fallback).to_string();
if let Some(realm) = named {
out = out.replacen("%s", realm, 1);
}
out = out.replacen("%d", &position.to_string(), 1);
if let Some(ms) = remaining.filter(|ms| *ms >= MINUTE_MS) {
out = out.replacen("%d", &(ms / MINUTE_MS).to_string(), 1);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glue_strings::GlueStrings;
/// Feed packets `step_ms` apart. Returns the ring and **the instant the last one landed**,
/// which is the natural moment to read the estimate at.
fn ring_of(places: &[u32], step_ms: u32) -> (QueueEstimate, u32) {
let mut q = QueueEstimate::default();
let mut now = 1_000; // never 0 — that is the "no sample yet" sentinel
let mut last = now;
for &p in places {
q.sample(p, now);
last = now;
now += step_ms;
}
(q, last)
}
/// No estimate until the ring is full — the reference's `tick[7] != 0` precondition.
#[test]
fn an_estimate_needs_eight_samples() {
for n in 1..SAMPLES {
let places: Vec<u32> = (0..n as u32).map(|i| 100 - i).collect();
let (q, last) = ring_of(&places, 1_000);
assert_eq!(q.estimate_ms(last), None, "{n} samples is not enough");
}
let (q, last) = ring_of(&[100, 99, 98, 97, 96, 95, 94, 93], 1_000);
assert!(q.estimate_ms(last).is_some(), "eight is");
}
/// A queue that has not moved yields nothing rather than an infinite wait.
#[test]
fn a_stalled_queue_has_no_estimate() {
let (q, last) = ring_of(&[50; SAMPLES], 1_000);
assert_eq!(
q.estimate_ms(last),
None,
"it has not moved, so nothing divides"
);
}
/// **A repeat stops shifting the ring once it is full** — the reference's own guard, and what
/// stops a server restating one position from flushing the history the estimate is made of.
///
/// The guard is `newPos == pos[0] && tick[7] != 0`, so while the ring is still filling a repeat
/// DOES shift; only once the oldest slot is stamped does a repeat become a restamp.
#[test]
fn a_repeated_position_restamps_rather_than_shifting() {
let (mut q, last) = ring_of(&[50; SAMPLES], 1_000);
let oldest = q.tick_ms[SAMPLES - 1];
assert_ne!(
oldest, 0,
"eight packets fill the ring even when they repeat"
);
q.sample(50, last + 5_000);
assert_eq!(
q.tick_ms[SAMPLES - 1],
oldest,
"a repeat past a full ring must not push the oldest sample out",
);
assert_eq!(q.tick_ms[0], last + 5_000, "but it does restamp the newest");
}
/// Seven moves of one place, a second apart, read as the eighth lands: 7 000 ms over 7 places
/// is 1 000 ms each, and 93 are still ahead → 93 000 ms.
#[test]
fn the_estimate_is_per_place_times_places_left() {
let (q, last) = ring_of(&[100, 99, 98, 97, 96, 95, 94, 93], 1_000);
assert_eq!(q.estimate_ms(last), Some(93_000));
}
/// **Divide before multiply**, and a fixture where the two orders actually disagree: 7 places
/// over 10 000 ms is 1 428 ms each once truncated, times 93. Multiplying first would keep the
/// remainder and land on a different minute at the boundary.
#[test]
fn the_estimate_divides_before_it_multiplies() {
let mut q = QueueEstimate::default();
for (i, p) in [100u32, 99, 98, 97, 96, 95, 94, 93].into_iter().enumerate() {
q.sample(p, 1_000 + (10_000 * i as u32) / 7);
}
let last = 1_000 + 10_000;
let elapsed = i64::from(last - 1_000);
assert_eq!(q.estimate_ms(last), Some((elapsed / 7) * 93));
assert_ne!(
(elapsed / 7) * 93,
elapsed * 93 / 7,
"this fixture must actually distinguish the two orders",
);
}
/// **Waiting makes the estimate go UP, not down** — surprising, verified, and the reference's.
///
/// The estimate is recomputed every frame from a *growing* elapsed against a divisor fixed by
/// the last eight packets, so while the queue is silent the per-place figure inflates. What is
/// displayed is `tick[0] + estimate - now`, whose slope is `places_left / places_moved - 1` —
/// positive whenever more people are ahead of you than the ring saw move, which is the normal
/// case. So a stalled queue's "estimated time" climbs until the next packet re-anchors it.
///
/// Not reproduced as a *bug fix*: this is what the reference puts on screen, and a client that
/// counted down smoothly here would disagree with it by minutes.
#[test]
fn a_silent_queue_inflates_its_own_estimate() {
let (q, last) = ring_of(&[100, 99, 98, 97, 96, 95, 94, 93], 1_000);
let at_packet = q.estimate_ms(last).unwrap();
let much_later = q.estimate_ms(last + 40_000).unwrap();
assert!(
much_later > at_packet,
"elapsed grows while the divisor does not: {at_packet} -> {much_later}",
);
}
/// The display legs: Calculating before the ring fills, then a minutes figure.
#[test]
fn the_text_walks_calculating_then_minutes() {
let strings = GlueStrings::default();
let (q, last) = ring_of(&[100, 99, 98], 1_000);
let calculating = q.text(last, Some("Benilla"), &strings);
assert!(calculating.starts_with("Benilla is Full"), "{calculating}");
assert!(
calculating.contains("Position in queue: 98"),
"{calculating}"
);
assert!(calculating.contains("Calculating..."), "{calculating}");
let (q, last) = ring_of(&[100, 99, 98, 97, 96, 95, 94, 93], 1_000);
let minutes = q.text(last, Some("Benilla"), &strings);
assert!(minutes.contains("Position in queue: 93"), "{minutes}");
assert!(minutes.contains("1 min"), "{minutes}");
assert!(!minutes.contains("Calculating"), "{minutes}");
}
/// The `< 1 minute` leg, which needs the whole remaining wait under a minute — i.e. few enough
/// people ahead that the inflation above cannot carry it over the threshold.
#[test]
fn a_short_wait_reads_under_a_minute() {
let strings = GlueStrings::default();
let (q, last) = ring_of(&[8, 7, 6, 5, 4, 3, 2, 1], 1_000);
let text = q.text(last, Some("Benilla"), &strings);
assert!(text.contains("Position in queue: 1"), "{text}");
assert!(text.contains("< 1 minute"), "{text}");
}
/// The nameless twin renders when no realm name is known — and neither form leaves a
/// placeholder behind.
#[test]
fn a_nameless_realm_uses_the_plain_form() {
let strings = GlueStrings::default();
let (q, last) = ring_of(&[100, 99, 98], 1_000);
let plain = q.text(last, None, &strings);
assert!(plain.starts_with("Realm is Full"), "{plain}");
assert!(!plain.contains("%s") && !plain.contains("%d"), "{plain}");
let named = q.text(last, Some("Benilla"), &strings);
assert!(!named.contains("%s") && !named.contains("%d"), "{named}");
}
}
+7 -4
View File
@@ -22,8 +22,8 @@ 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, overlay, paint_glue_field, ArtSwap,
GlueBtnKind, GlueFieldPart, GlueText, Hilight,
abs, glue_button, glue_edit_box, outlined_text, outlined_text_centered, overlay,
paint_glue_field, ArtSwap, GlueBtnKind, GlueFieldPart, GlueText, Hilight,
};
use crate::glue_strings::GlueStrings;
use crate::portrait::{PortraitImages, PortraitSource, GLUE_SLOT};
@@ -748,8 +748,11 @@ pub(super) fn spawn_dialog(
overlay(),
));
}
// The message (GlueFontNormalLarge 18 at TOP (0,−16), width 440, wrapping).
outlined_text(
// 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),
+10
View File
@@ -121,6 +121,7 @@ impl Plugin for NetPlugin {
.add_message::<ServerSaidMessage>()
.add_message::<LoggedOutMessage>()
.add_message::<LoginStageMessage>()
.add_message::<LoginQueuedMessage>()
.add_message::<LoginFailedMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(
@@ -2113,6 +2114,15 @@ pub(crate) struct LoginStageMessage {
pub(crate) stage: benilla_protocol::LoginStage,
}
/// We are **queued** for a full realm (decision 1681) — one per `AUTH_WAIT_QUEUE` packet, while
/// the world handshake is still parked. Not a failure: the attempt is live and the queue ends
/// either with a roster or with a real failure.
#[derive(Message, Clone)]
pub(crate) struct LoginQueuedMessage {
pub(crate) position: Option<u32>,
pub(crate) realm: Option<String>,
}
/// The session ended (socket death, logout's teardown edge) — bridged from the Net drain's
/// `Disconnected` arm. [`crate::login`]'s policy reads it as "the IO thread is back at its
/// pre-logon park"; whether anything re-authenticates is [`Self::ends_the_session`]'s answer.
+19 -5
View File
@@ -121,6 +121,8 @@ pub(super) fn apply_net_updates(
MessageWriter<super::MoveModeMessage>,
// The login screen's dialog + reconnect-policy feed (decision 0539).
MessageWriter<super::LoginStageMessage>,
// The login queue's position feed (decision 1681).
MessageWriter<super::LoginQueuedMessage>,
MessageWriter<super::LoginFailedMessage>,
MessageWriter<super::DisconnectedMessage>,
// The server's own answer to a GM dot-command, readable rather than only logged — the
@@ -279,7 +281,12 @@ pub(super) fn apply_net_updates(
ResMut<crate::ui_cast::CastBarFeed>,
ResMut<crate::pending_item_ops::PendingItemOps>,
ResMut<crate::pending_item_ops::LockClearedByFailure>,
ResMut<crate::ui_trainer::TrainerErrors>,
// Nested pair (the tuple is at the ceiling): the two NPC-service windows' error queues,
// each drained onto its window's red line by its own feed.
(
ResMut<crate::ui_trainer::TrainerErrors>,
ResMut<crate::ui_stable::StableErrors>,
),
ResMut<crate::ui_cast::PendingCast>,
// The cooldown store + the Spell.dbc catalog its wire laws read, and the live
// auto-repeat state the bar's flash rides (decision 0137 phase 4).
@@ -439,6 +446,7 @@ pub(super) fn apply_net_updates(
mut speed_changes,
mut move_modes,
mut login_stages,
mut login_queued,
mut login_failures,
mut disconnects,
mut server_said,
@@ -474,6 +482,9 @@ pub(super) fn apply_net_updates(
for ev in events.0.try_iter() {
match ev {
SessionEvent::LoginStage { stage } => session::login_stage(stage, &mut login_stages),
SessionEvent::LoginQueued { position, realm } => {
login_queued.write(crate::net::LoginQueuedMessage { position, realm });
}
SessionEvent::LoginFailed {
refusal,
reason,
@@ -1623,16 +1634,19 @@ pub(super) fn apply_net_updates(
npc::trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &net_commands)
}
SessionEvent::TrainerBuyFailed { error, .. } => {
npc::trainer_buy_failed(error, &mut ui_actions.8)
npc::trainer_buy_failed(error, &mut ui_actions.8 .0)
}
SessionEvent::ListStabledPets {
npc,
num_stable_slots,
pets,
} => npc::list_stabled_pets(npc, num_stable_slots, pets, &mut stable_open),
SessionEvent::StableResult { result } => {
npc::stable_result(result, &mut stable_open, &net_commands)
}
SessionEvent::StableResult { result } => npc::stable_result(
result,
&mut stable_open,
&mut ui_actions.8 .1,
&net_commands,
),
SessionEvent::TaxiNodesShown {
flightmaster,
nearest_node,
+44 -18
View File
@@ -12,7 +12,7 @@ use crate::ui_bank::{BankErrors, BankOpen};
use crate::ui_gossip::GossipState;
use crate::ui_merchant::{MerchantErrors, MerchantOpen, MerchantRefusal};
use crate::ui_quest::QuestGiver;
use crate::ui_stable::StableOpen;
use crate::ui_stable::{StableErrors, StableOpen};
use crate::ui_taxi::TaxiState;
use crate::ui_trainer::{TrainerErrors, TrainerOpen};
@@ -211,30 +211,56 @@ pub(super) fn list_stabled_pets(
stable_open.open(npc, num_stable_slots, pets);
}
/// The answer to a stable verb (`SMSG_STABLE_RESULT`) — one byte, and the *whole* answer: no
/// success carries an updated list (VERIFIED vmangos, every `HandleStable*` path ends in
/// `SendStableResult` and nothing else). So a success **must** be followed by a re-request or the
/// window would go on showing the pre-action arrangement — a pet that is now stabled still standing
/// in slot 0. That re-request is forced by the server's behaviour, not a choice.
/// The answer to a stable verb (`SMSG_STABLE_RESULT`) — one byte, and the client's whole response
/// to it is a five-way jump table (wow-re `system/ui/scratch/stable-master-window.md` §5, VERIFIED
/// off the raw remap/jump bytes at `0x4cadac`/`0x4cad98`; decision 1677):
///
/// The refusal half is where the client's own behaviour is still open (decision 1676): benilla
/// surfaces `ERR_MONEY` on the error line and swallows the generic `ERR_STABLE`, which carries no
/// reason at all — dead player, out of range, no free bought slot, untameable, and "you already
/// have a pet" are one indistinguishable code. What the reference does with each is a wow-re carve
/// item; showing a wrong-but-confident sentence for a code that means five things would be worse
/// than the silence.
pub(super) fn stable_result(result: u8, stable_open: &mut StableOpen, net_commands: &NetCommands) {
/// | code | what the client does |
/// |---|---|
/// | 1 | `DisplayError(0x25)` = **`ERR_NOT_ENOUGH_MONEY`** — the only code that says anything |
/// | 2–7 | **absolutely nothing** — vmangos's catch-all `STABLE_ERR_STABLE = 6` included |
/// | 8, 9 | re-request the list |
/// | 10 | **`inc` the local purchased-slot count**, then re-request |
/// | 11 | fire `PET_STABLE_UPDATE` (no vmangos counterpart — nothing in 1.12 sends it) |
/// | 0, ≥12 | nothing |
///
/// The re-request is not a benilla convenience: no success carries an updated list, so without it
/// the window would go on showing the pre-action arrangement.
///
/// **Code 10's local increment happens BEFORE the guid test**, so a buy-slot success arriving with
/// no stable master open still bumps the count without refreshing. That ordering is reproduced
/// deliberately — it is what makes the purchase row correct if the window is reopened, and the
/// alternative would be to invent a tidier client than the one being reimplemented.
pub(super) fn stable_result(
result: u8,
stable_open: &mut StableOpen,
errors: &mut StableErrors,
net_commands: &NetCommands,
) {
use benilla_protocol::messages::stable_result as code;
let Some(npc) = stable_open.npc else {
debug!("net: stable result {result} with no open stable — ignored");
return;
};
// Ahead of the guid test, exactly as `0x4cacf3` sits ahead of `0x4cad05`.
if result == code::SUCCESS_BUY_SLOT {
stable_open.num_stable_slots = stable_open.num_stable_slots.saturating_add(1);
}
match result {
// The one code that speaks. Its text is the reference's own `GlobalStrings` value for
// `ERR_NOT_ENOUGH_MONEY`, reached through `DisplayError` row 0x25.
code::ERR_MONEY => {
debug!("net: stable purchase refused — not enough money");
errors.0.push("You don't have enough money.".to_string());
}
code::SUCCESS_STABLE | code::SUCCESS_UNSTABLE | code::SUCCESS_BUY_SLOT => {
let Some(npc) = stable_open.npc else {
debug!("net: stable success {result} with no open stable — no re-list");
return;
};
debug!("net: stable action succeeded (code {result}) — re-listing");
let _ = net_commands.0.send(ClientCommand::ListStabledPets { npc });
}
_ => debug!("net: stable action refused (code {result})"),
// Codes 2–7 (the generic ERR_STABLE among them), 0 and ≥12: the client shows NOTHING.
// Not an omission — the catch-all is one code for six causes, and the reference declines to
// guess which.
_ => debug!("net: stable result {result} — no client-visible effect"),
}
}
+25 -1
View File
@@ -329,7 +329,26 @@ fn run(
.unwrap_or_else(|| format!("{}:{}", host_port(&req.host, WORLD_PORT).0, WORLD_PORT));
stage(LoginStage::Handshaking);
let mut session = match WorldSession::connect(&world_addr, &req.user, logon.session_key) {
// The realm we are dialing, for the queue dialog to name — the roster that would otherwise
// carry it is on the far side of the queue, which is exactly when the name is wanted.
let realm_name = realm.as_ref().map(|r| r.name.clone());
// Report our place, and keep waiting only while the attempt is still wanted. A queue can
// last minutes, so unlike every other handshake stage it has to test the abandon generation
// itself — otherwise a Cancel would close the dialog while this thread quietly held its place
// in line and then walked into the world anyway.
let mut on_queue = |position: Option<u32>| {
let _ = events_tx.send(SessionEvent::LoginQueued {
position,
realm: realm_name.clone(),
});
!canceled()
};
let mut session = match WorldSession::connect_queued(
&world_addr,
&req.user,
logon.session_key,
&mut on_queue,
) {
Ok(s) => s,
Err(e) => {
if canceled() {
@@ -351,6 +370,11 @@ fn run(
return fail(None, format!("world handshake with {world_addr}: {e:#}"));
}
};
// The handshake can now block for minutes (the queue), so a cancel that landed while it did
// must not be overtaken by the roster it is about to fetch.
if canceled() {
return Ok(Cycle::Repark);
}
// The roster (creating a starter character on a fresh account so PLAYER_LOGIN has a target).
// Failures here are still pre-roster: surface as LoginFailed, re-park. (An immediately-run
+4 -1
View File
@@ -213,7 +213,10 @@ fn play_item_gesture_sounds(
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_) => {
| CursorPayload::PetAction(_)
// Mode 10 joins them: the stabled-pet grab `0x495010` calls the same generic path and
// names no per-item kit (decision 1677).
| CursorPayload::StablePet(_) => {
let kit_id = match gesture {
CursorGesture::Gain => INTERFACESOUND_CURSORGRABOBJECT,
CursorGesture::Loss => INTERFACESOUND_CURSORDROPOBJECT,
+44
View File
@@ -707,6 +707,24 @@ fn interact_command(
CursorKind::Buy if npc_flags & cursor_mode::npc_flags::AUCTIONEER != 0 => {
Some(ClientCommand::AuctionHello { auctioneer: guid })
}
// A stable master asks for its own pet list — the client sends `MSG_LIST_STABLED_PETS`
// ITSELF on the bit-13 interact leg (`0x5f05a1` → `0x5f05bc` → `0x5e02a0`, whose sole
// caller that is), and the window opens when the reply lands. This corrects decision
// 1676's "server-initiated, off the gossip option": that is how it opens against vmangos
// (`GOSSIP_OPTION_STABLEPET` → `SendStablePet`), but it is not what the client does.
//
// Gated on the gossip bit being absent, which is the honest bound: the cursor ladder above
// is first-match-wins and reaches its STABLEMASTER leg only when no lower service bit is
// set, but the reference's *interaction* dispatcher is a different function whose own
// ordering the carve did not enumerate. A stable master carrying a gossip menu therefore
// keeps the menu — where its own stable option asks the server for the same list — and one
// without a menu takes this leg, which is the case the bare bit-13 test exists for.
CursorKind::Speak
if npc_flags & cursor_mode::npc_flags::STABLEMASTER != 0
&& npc_flags & cursor_mode::npc_flags::GOSSIP == 0 =>
{
Some(ClientCommand::ListStabledPets { npc: guid })
}
CursorKind::Speak | CursorKind::Buy | CursorKind::Trainer | CursorKind::Interact => {
Some(ClientCommand::GossipHello { guid })
}
@@ -981,6 +999,32 @@ mod tests {
));
}
/// The stable-master split (decision 1677): the client asks for the pet list **itself** on the
/// bit-13 interact leg — it is not purely server-initiated, which is what 1676 had. A stable
/// master carrying a gossip menu keeps the menu, whose own stable option asks the server for
/// the same list; one without a menu takes this leg.
#[test]
fn interact_routes_a_menuless_stable_master_to_the_pet_list() {
use cursor_mode::CursorKind;
assert!(matches!(
interact_command(
CursorKind::Speak,
0x64,
cursor_mode::npc_flags::STABLEMASTER
),
Some(ClientCommand::ListStabledPets { npc: 0x64 })
));
// With a gossip menu, the universal hello still wins — the menu carries the stable option.
assert!(matches!(
interact_command(
CursorKind::Speak,
0x64,
cursor_mode::npc_flags::STABLEMASTER | cursor_mode::npc_flags::GOSSIP
),
Some(ClientCommand::GossipHello { guid: 0x64 })
));
}
/// The banker split (decision 0604): Buy(3) is banker OR auctioneer — the BANKER flag routes
/// the direct `CMSG_BANKER_ACTIVATE`, its absence falls to the auctioneer fork above. A
/// gossip-flagged banker never reaches this fork (the low-bit-first ladder classified Speak).
@@ -1160,6 +1160,7 @@ pub(super) fn drive_script(
CursorPayload::Action(a) => a.texture,
CursorPayload::Macro(m) => m.texture,
CursorPayload::PetAction(p) => p.texture,
CursorPayload::StablePet(p) => Some(p.texture),
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
+58 -18
View File
@@ -36,7 +36,9 @@
use benilla_protocol::messages::StabledPet;
use bevy::prelude::*;
use benilla_ui::script::{StableIntent, StablePetSlot, StableState, UiScript, NUM_STABLE_SLOTS};
use benilla_ui::script::{
ScriptValue, StableIntent, StablePetSlot, StableState, UiScript, NUM_STABLE_SLOTS,
};
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
@@ -48,17 +50,19 @@ pub(crate) struct UiStablePlugin;
impl Plugin for UiStablePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StableOpen>().add_systems(
Update,
(
// Range-close before the feed so the clear becomes PET_STABLE_CLOSED the same
// frame; push before the input pass so an open/close is on screen the same frame;
// drain after it (the trainer/merchant/gossip ordering).
close_npc_session_out_of_range::<StableOpen>.before(feed_stable),
feed_stable.before(UiInput),
drain_stable.after(UiInput),
),
);
app.init_resource::<StableOpen>()
.init_resource::<StableErrors>()
.add_systems(
Update,
(
// Range-close before the feed so the clear becomes PET_STABLE_CLOSED the same
// frame; push before the input pass so an open/close is on screen the same frame;
// drain after it (the trainer/merchant/gossip ordering).
close_npc_session_out_of_range::<StableOpen>.before(feed_stable),
feed_stable.before(UiInput),
drain_stable.after(UiInput),
),
);
}
}
@@ -74,6 +78,14 @@ pub(crate) struct StableOpen {
pub(crate) num_stable_slots: u8,
/// The wire rows, each already carrying its rebased client slot index.
pub(crate) pets: Vec<StabledPet>,
/// A list packet has landed and the feed has not yet turned it into `PET_STABLE_SHOW`.
///
/// The distinction this latch draws is the carve's: `PET_STABLE_SHOW` fires **unconditionally
/// at the tail of every `MSG_LIST_STABLED_PETS` handler** (`0x4cac9b`) — every list, not just
/// the first — while `PET_STABLE_UPDATE`/`_PADERDOLL` fire from the **creature-cache
/// callbacks** (`0x4cb3bf`, `0x4cba5f`), i.e. exactly when a late template answer fills a row
/// in. Our two-step resolve has the same two edges, so the events land on the same causes.
pub(crate) fresh_list: bool,
}
impl StableOpen {
@@ -82,12 +94,14 @@ impl StableOpen {
self.npc = Some(npc);
self.num_stable_slots = num_stable_slots;
self.pets = pets;
self.fresh_list = true;
}
pub(crate) fn clear(&mut self) {
self.npc = None;
self.num_stable_slots = 0;
self.pets.clear();
self.fresh_list = false;
}
}
@@ -161,12 +175,14 @@ fn resolve_pet(
}
/// Build the Lua-facing snapshot from [`StableOpen`] — `None` when no stable is open.
#[allow(clippy::too_many_arguments)]
fn snapshot(
open: &StableOpen,
names: &mut NameCache,
families: Option<&PetFamilyTables>,
stats: Option<&PetStatTables>,
next_slot_cost: u32,
has_live_pet: bool,
commands: &NetCommands,
) -> Option<StableState> {
open.npc?;
@@ -188,6 +204,7 @@ fn snapshot(
num_stable_slots: u32::from(open.num_stable_slots),
next_slot_cost,
slots,
has_live_pet,
})
}
@@ -196,12 +213,15 @@ fn snapshot(
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
fn feed_stable(
script: Option<NonSendMut<UiScript>>,
open: Res<StableOpen>,
// ResMut only to consume the fresh-list latch; the feed never authors stable content.
mut open: ResMut<StableOpen>,
bar: Res<crate::ui_pet::PetBar>,
families: Option<Res<PetFamilyTables>>,
stats: Option<Res<PetStatTables>>,
prices: Option<Res<StableSlotPrices>>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut errors: ResMut<StableErrors>,
mut last: Local<crate::ui_script::VmMemo<Option<StableState>>>,
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
) {
@@ -210,6 +230,10 @@ fn feed_stable(
};
let last = last.get(&script);
let last_npc = last_npc.get(&script);
// Refusals surface as the client's red error line (the trainer/merchant path's exact shape).
for text in errors.0.drain(..) {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
// The next slot's price is the client's own job (the wire never sends it) — `StableSlotPrices`
// row `purchased + 1`, the bank's arrangement. `0` past the table, a state in which the
@@ -219,16 +243,21 @@ fn feed_stable(
.and_then(|p| p.0.next_slot_price(open.num_stable_slots))
.unwrap_or(0);
// The client's own `[0xb714a0]|[0xb714a4]` test — a LIVE pet guid, which a dismissed pet does
// not have even though the server still sends its slot-0 row.
let has_live_pet = bar.spells.pet_guid != 0;
let fresh = snapshot(
&open,
&mut names,
families.as_deref(),
stats.as_deref(),
next_slot_cost,
has_live_pet,
&commands,
);
let switched = npc_switched(*last_npc, open.npc);
if fresh == *last && !switched {
let fresh_list = std::mem::take(&mut open.fresh_list);
if fresh == *last && !switched && !fresh_list {
return;
}
// PUSH before firing: `fire_event` dispatches the Lua handlers synchronously, so the snapshot
@@ -242,17 +271,23 @@ fn feed_stable(
script.fire_event("PET_STABLE_CLOSED", vec![]);
script.fire_event("PET_STABLE_SHOW", vec![]);
let _ = script.take_stable_close();
} else if fresh_list && fresh.is_some() {
// **Every list fires SHOW**, not just the first (`0x4cac9b` is unconditional). The window
// is already visible for a refresh, and the reference's own `ShowUIPanel` early-return is
// what makes that idempotent rather than a re-open.
script.fire_event("PET_STABLE_SHOW", vec![]);
} else {
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("PET_STABLE_SHOW", vec![]),
// The paperdoll event rides the content change: the reference repaints the model pane
// from it, and a stabled pet's icon/family arriving late is exactly when it must.
// A content change with no new list is a late creature-query answer filling a row in —
// the client's cache-callback edge, and the only thing those callbacks fire.
(Some(_), Some(_)) => {
script.fire_event("PET_STABLE_UPDATE", vec![]);
script.fire_event("PET_STABLE_UPDATE_PAPERDOLL", vec![]);
}
(Some(_), None) => script.fire_event("PET_STABLE_CLOSED", vec![]),
(None, None) => {}
// A first snapshot with no list latch cannot happen (the latch is set by the packet
// that creates it); a None→None is nothing at all.
(None, _) => {}
}
}
*last = fresh;
@@ -292,6 +327,11 @@ fn drain_stable(
}
}
/// Stable refusals staged for the feed to surface on the window's error line — only ever
/// `ERR_NOT_ENOUGH_MONEY`, the single code the client speaks for (decision 1677).
#[derive(Resource, Default)]
pub(crate) struct StableErrors(pub(crate) Vec<String>);
/// `StableSlotPrices.dbc`, loaded once at startup — the purchase row's price oracle. A missing
/// table quotes `0`, which is the same shape as "past the ladder": degraded, never wrong.
#[derive(Resource)]
+9
View File
@@ -144,6 +144,15 @@ pub enum SessionEvent {
/// A login attempt progressed to `stage` (decision 0539) — IO-thread-emitted, like
/// [`Self::CharacterList`], never wire-decoded.
LoginStage { stage: LoginStage },
/// **We are queued for a full realm** (`SMSG_AUTH_RESPONSE(AUTH_WAIT_QUEUE)`) — a wait, not
/// an outcome. Emitted once per queue packet while the world handshake is parked; the attempt
/// is still live and ends normally with a roster (admitted) or a failure. `position` is `None`
/// when the packet carried no readable one. `realm` is the realm we are queued for, so the
/// screen can name it without waiting for the roster that is on the far side of the queue.
LoginQueued {
position: Option<u32>,
realm: Option<String>,
},
/// A login attempt failed before the roster (decision 0539): `code` is the server's auth
/// result byte ([`crate::auth::AuthReject`]) when the server *refused* us, `None` for a
/// transport failure (dial/handshake error). The IO thread is re-parked pre-logon; the app
@@ -80,6 +80,9 @@ pub enum ServerPacket {
},
AuthResponse {
result: u8,
/// Our place in the login queue, when `result` is [`super::AUTH_WAIT_QUEUE`] — `None` for
/// every other result, and also for a queue packet whose body was too short to carry one.
queue_position: Option<u32>,
},
CharEnum {
characters: Vec<Character>,
+36 -3
View File
@@ -159,9 +159,42 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_AUTH_CHALLENGE => ServerPacket::AuthChallenge {
server_seed: read_u32_le(&mut r)?,
},
opcode::SMSG_AUTH_RESPONSE => ServerPacket::AuthResponse {
result: read_u8(&mut r)?,
},
opcode::SMSG_AUTH_RESPONSE => {
// **The client's own grammar** (VERIFIED, handler `0x5b41b0`; wow-re
// `system/glue/scratch/login-failure-dialogs.md`):
//
// u8 code
// if (code == AUTH_OK || code == AUTH_WAIT_QUEUE) && remaining >= 5:
// u32 billingTimeRemaining, u8 billingPlanFlags, u32 billingTimeRested
// if code == AUTH_WAIT_QUEUE:
// u32 position
//
// The client **branches on the remaining length**, which is what makes both shapes the
// mangos family sends parse correctly: the short `{u8, u32}` leaves 4 bytes (`4 < 5`,
// billing skipped, position at body offset 1) and the long
// `{u8, u32, u8, u32, u32}` leaves 13 (billing read, position at offset 10).
//
// The threshold is **5 guarding a 9-byte group** — not a size check, just "is there
// more here than a bare position?". Copied rather than tidied: a body of 6..=9 bytes
// hits it and mis-parses, and matching the client's mistakes is the point of a
// faithful parser.
//
// ONE DELIBERATE DIVERGENCE: where a truncated body leaves the client's position
// *unwritten* — silently redisplaying the previous queue position from an
// uninitialised process-lifetime global — ours reports `None`. Rendering a stale
// position as current is a bug we decline to reproduce.
let result = read_u8(&mut r)?;
let queued = result == super::AUTH_WAIT_QUEUE;
if (result == super::AUTH_OK || queued) && r.len() >= 5 {
let _billing_time_remaining = read_u32_le(&mut r)?;
let _billing_plan_flags = read_u8(&mut r)?;
let _billing_time_rested = read_u32_le(&mut r)?;
}
ServerPacket::AuthResponse {
result,
queue_position: queued.then(|| read_u32_le(&mut r).ok()).flatten(),
}
}
opcode::SMSG_CHAR_ENUM => {
let count = read_u8(&mut r)?;
let mut characters = Vec::with_capacity(count as usize);
+60 -2
View File
@@ -16,6 +16,16 @@ use super::{recv_packet, send_packet};
/// Generous — a local vmangos answers in milliseconds; this only bounds the pathological stall.
const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// The read bound while **queued** — a different question from the handshake's.
///
/// [`HANDSHAKE_READ_TIMEOUT`] is 10 s because every handshake step should answer promptly. Sitting
/// in a login queue is the one step that should not: the server speaks when our place moves, which
/// on a full realm can be minutes apart. Keeping the handshake bound would abort every queue that
/// mattered. The real client bounds this not at all (it is event-driven and simply waits), but an
/// unbounded blocking read here would make a server that dies mid-queue indistinguishable from a
/// long wait, so the wait is generous rather than infinite.
const QUEUE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// The server requires Warden, which benilla does not implement — raised by
/// [`WorldSession::connect`] and recoverable through the `anyhow` chain with
/// `err.downcast_ref::<WardenRequired>()`, so the login screen can say so plainly.
@@ -91,6 +101,29 @@ impl WorldSession {
username: &str,
session_key: [u8; SESSION_KEY_LENGTH],
) -> Result<Self> {
Self::connect_queued(addr, username, session_key, &mut |_| true)
}
/// [`Self::connect`], reporting our place in the **login queue** as it moves.
///
/// `on_queue` fires once per `AUTH_WAIT_QUEUE` the server sends — `None` when the packet
/// carried no readable position — and the call returns only once we are admitted (`AUTH_OK`),
/// refused, or the socket fails. The queue is a wait, not an outcome, so it is a callback
/// rather than a return value: the screen has to be able to show the position *while* the
/// handshake is still parked here.
///
/// **Returning `false` abandons the queue** and fails the connect. That is the only way out of
/// a wait that can last minutes: every other stage of the handshake is bounded tightly enough
/// that a cancel is honoured at its next boundary, and a queue is not. Abandoning is checked
/// once per packet rather than per frame — the reference tears the socket down on the next
/// tick, which is finer-grained, but it costs a player at most one server update.
pub fn connect_queued(
addr: impl ToSocketAddrs,
username: &str,
session_key: [u8; SESSION_KEY_LENGTH],
on_queue: &mut dyn FnMut(Option<u32>) -> bool,
) -> Result<Self> {
let mut queued = false;
let mut stream = TcpStream::connect(addr).context("connecting to world server")?;
// **Nagle off** (decision 0617). VERIFIED in the reference client: `0x5bca60` calls
// `setsockopt(s, 6 /* IPPROTO_TCP */, 1 /* TCP_NODELAY */, &1, 4)` unconditionally on its game
@@ -158,8 +191,26 @@ impl WorldSession {
// honest message at the login screen.
loop {
match session.recv()? {
ServerPacket::AuthResponse { result } if result == messages::AUTH_OK => break,
ServerPacket::AuthResponse { result } => {
ServerPacket::AuthResponse { result, .. } if result == messages::AUTH_OK => break,
// **Queued, not refused** — the realm is full and we keep our place in line. Report
// the position and go back to reading; the server re-sends as we move up and ends
// the wait with an `AUTH_OK`. Treating this as an ending (which it was until now)
// meant benilla could not log into a busy server at all.
ServerPacket::AuthResponse {
result,
queue_position,
} if result == messages::AUTH_WAIT_QUEUE => {
if !queued {
queued = true;
session
.set_read_timeout(Some(QUEUE_READ_TIMEOUT))
.context("relaxing the read timeout for the login queue")?;
}
if !on_queue(queue_position) {
bail!("login queue abandoned");
}
}
ServerPacket::AuthResponse { result, .. } => {
return Err(WorldAuthReject { code: result }.into())
}
ServerPacket::Other {
@@ -168,6 +219,13 @@ impl WorldSession {
_ => continue,
}
}
// Admitted. The rest of the handshake is prompt again, so it gets the prompt bound back —
// leaving the queue's 300 s in place would make a stalled roster step look like a long wait.
if queued {
session
.set_read_timeout(Some(HANDSHAKE_READ_TIMEOUT))
.context("restoring the handshake read timeout after the queue")?;
}
Ok(session)
}
+4 -1
View File
@@ -338,7 +338,10 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)),
| CursorPayload::PetAction(_)
// Mode 10 (decision 1677) — a stabled pet refuses a bag/doll slot exactly as the
// spell/action family does, and stays on the cursor for the stable window to take.
| CursorPayload::StablePet(_)),
) => {
model.cursor = Some(other);
false
+39 -4
View File
@@ -44,9 +44,9 @@ pub const EQUIPMENT_BAG: i64 = -100;
/// What the cursor carries — the client's payload-mode global [0xb4d900] as a typed enum
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell, **4 = pet action**, **8 =
/// macro**; our Action arm is the client's bar-slot pickup; the money/preview arms stay unbuilt).
/// One transition seam for every surface, so sounds, CURSOR_UPDATE, and lock display can't drift
/// apart per window (decision 0216).
/// macro**, **10 = stabled pet**; our Action arm is the client's bar-slot pickup; the
/// money/preview arms stay unbuilt). One transition seam for every surface, so sounds,
/// CURSOR_UPDATE, and lock display can't drift apart per window (decision 0216).
#[derive(Clone, Debug, PartialEq)]
pub enum CursorPayload {
Item(CursorItem),
@@ -54,6 +54,34 @@ pub enum CursorPayload {
Action(CursorAction),
Macro(CursorMacro),
PetAction(CursorPetAction),
/// A pet picked up from the stable window — **mode 10** (see [`CursorStablePet`]).
///
/// Mode 10 was recorded as "class/talent ability (DBC)" until wow-re's stable-master carve
/// corrected it (`system/ui/scratch/stable-master-window.md` §9; decision 1677): `0x495020` is
/// the stabled-pet grab, `[0xb4d900] = 10` is written at exactly one site image-wide, and it is
/// inside it. benilla built the stable's drag frame-locally on the strength of the old note —
/// this variant is what puts it back on the shared cursor, so a stable pet dropped on the world
/// or another window clears through the same path as every other payload.
StablePet(CursorStablePet),
}
/// A pet held on the cursor from the stable window — payload **mode 10** (`0x495020`, the grab
/// `0x495010`).
///
/// Mode 10 was recorded as "class/talent ability (DBC)" until wow-re's stable-master carve
/// corrected it (`system/ui/scratch/stable-master-window.md` §9; decision 1677): `[0xb4d900] = 10`
/// is written at exactly one site image-wide and it is inside the stabled-pet grab, whose id global
/// `[0xb4e300]` holds a **stable index**, not a spell. benilla first built the stable's drag
/// frame-locally on the strength of the old note; this puts it on the shared cursor.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorStablePet {
/// The slot the grab came from, in the Lua index space `PickupStablePet` takes (`0` = the
/// summoned pet, `1..=2` a stable slot) — the client's `[0xb4e300]`.
pub slot: u8,
/// The family icon drawn at the mouse. Not merely decoration: a **non-empty icon path is the
/// grab's own gate** (`0x495010` refuses without one), so a payload of this shape always has
/// one.
pub texture: String,
}
/// The item currently held on the cursor (`PickupContainerItem`/`SplitContainerItem`/
@@ -269,7 +297,8 @@ pub(crate) fn clear_cursor(model: &mut Model) {
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_),
| CursorPayload::PetAction(_)
| CursorPayload::StablePet(_),
) => {
queue_cursor_update(model);
}
@@ -559,6 +588,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Value::Nil,
Value::Nil,
)),
// Mode 10 — the stabled-pet grab (decision 1677). Reported as nothing: the
// reference's `GetCursorInfo` arms are not carved for it, the stable window never
// asks, and inventing a type string would put a guess where an addon could read it.
Some(CursorPayload::StablePet(_)) => {
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
}
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
}
})?,
@@ -123,6 +123,9 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
// no `CMSG_SET_ACTION_BUTTON` encoding at all, so there is nothing to pack: the payload
// goes straight back on the cursor, where the pet bar can still take it.
CursorPayload::PetAction(_) => None,
// Mode 10 (decision 1677) — a stabled pet has no `CMSG_SET_ACTION_BUTTON` encoding either,
// so the action bar refuses it and it goes back on the cursor.
CursorPayload::StablePet(_) => None,
};
let Some((kind, action, texture)) = placeable else {
model.cursor = Some(held);
+4 -1
View File
@@ -105,7 +105,10 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)),
| CursorPayload::PetAction(_)
// Mode 10 (decision 1677) — a stabled pet refuses a bag/doll slot exactly as the
// spell/action family does, and stays on the cursor for the stable window to take.
| CursorPayload::StablePet(_)),
) => {
model.cursor = Some(other);
false
+1 -1
View File
@@ -163,7 +163,7 @@ pub use container::{
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
EnchantConfirm, WorldPick, EQUIPMENT_BAG,
CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::MultisampleFormat;
pub use death::{DeathAction, DeathUiState};
+464 -175
View File
@@ -25,15 +25,31 @@
//! is the icon of a pet that is *not* summoned: it comes from the creature query's display id,
//! which is why that field stopped being discarded (decision 1676).
//!
//! ## The drag is FRAME-LOCAL, not the cursor
//! ## The drag rides the cursor at mode 10
//!
//! `PickupStablePet` does **not** put anything on the global cursor. wow-re's exhaustive census of
//! every write to the payload-mode global `[0xb4d900]`
//! (`system/ui/scratch/cursor-dragdrop-payload.md` §1) enumerates the modes — item, money, spell,
//! pet action, macro, the three displayId previews, class/talent ability — and **there is no
//! stable-pet mode**. So a picked-up stable pet is state the stable window keeps to itself
//! ([`StableModel::pickup`]), and no other surface can ever be holding one. Building this on
//! [`super::cursor::CursorPayload`] would have invented a mode the client does not have.
//! `PickupStablePet` puts the pet on the **global cursor** under payload mode 10
//! ([`super::cursor::CursorPayload::StablePet`]), carrying the stable index the grab recorded.
//!
//! This corrects what benilla shipped first. The original build made the drag frame-local on the
//! strength of wow-re's payload-mode table, which recorded mode 10 as "class/talent ability (DBC)"
//! — so the census read as "there is no stable-pet mode". The stable-master carve found that
//! `0x495020` **is** the stabled-pet grab and that `[0xb4d900] = 10` is written at exactly one site
//! image-wide, inside it; wow-re corrected its own note in the same round
//! (`system/ui/scratch/stable-master-window.md` §9). Decision 1677.
//!
//! ## Two return conventions that are the API, not details
//!
//! `ClickStablePet` returns **exactly one value, always**, and which one is keyed *solely* on
//! whether the cursor was holding a pet — never on whether a packet went out:
//!
//! - **a plain click** (no payload) is a pure select and pushes the number `1.0` on all three of
//! its legs, so it is **always truthy** and the reference always repaints;
//! - **a drop** pushes **nil** on every leg, including the ones that send a packet, so the
//! reference never repaints from the drop — the repaint comes from the server's next list.
//!
//! Getting this backwards is invisible in a test that only checks the packets and very visible in
//! play: the window would repaint off a stale snapshot on every drag and skip the repaint on every
//! click.
use mlua::{Lua, MultiValue, Value};
@@ -83,6 +99,13 @@ pub struct StableState {
pub next_slot_cost: u32,
/// The three window slots; index `0` is the current pet.
pub slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS],
/// The player has a **live** pet out — the client's own `[0xb714a0]|[0xb714a4]` guid test, the
/// gate that forks a stabled pet's drop between swap and unstable (§6.2).
///
/// **Deliberately not `slots[0].is_some()`.** A dismissed pet, or one left out of range, still
/// has a slot-0 row from the server's character-pet cache while the live guid is zero — so the
/// two disagree exactly there, and the client follows the guid.
pub has_live_pet: bool,
}
impl StableState {
@@ -113,26 +136,27 @@ pub enum StableIntent {
#[derive(Debug)]
pub(crate) struct StableModel {
pub(crate) state: Option<StableState>,
/// The selected slot, or `-1` for none — `GetSelectedStablePet()`'s exact return, including its
/// sentinel, because the reference tests `== -1` directly (`PetStable.lua:44`).
/// The selection, held **as the client holds it** (`[0xb72250]`): `-1` = the summoned pet,
/// `0` = nothing, otherwise a **petNumber**.
///
/// A petNumber rather than a slot index because that is what the binary stores, and it is what
/// makes a *stale* selection degrade correctly: `GetSelectedStablePet` searches the array for
/// the number and answers `-1` when the pet is no longer there (§7.1's fall-through).
pub(crate) selected: i32,
/// The slot a drag started from, `None` when nothing is being dragged.
pub(crate) pickup: Option<u8>,
pub(crate) intents: Vec<StableIntent>,
pub(crate) close: bool,
}
/// **`selected` starts at `-1`, not at `0`** — a derived `Default` would make it `0`, and `0` is a
/// real slot (the current pet). The reference reads the sentinel to decide whether to pick a slot
/// for the player at all (`PetStable.lua:44-59`): defaulted to `0`, a freshly opened window would
/// believe the current pet was already chosen and would never auto-select a stabled one for a
/// hunter with no pet out — the window would open showing nothing, with no way to tell why.
/// The zero state is the client's `0` — "nothing selected" — not `-1`, which is its encoding for
/// *the summoned pet*. The Lua-facing `-1` that `PetStable.lua:44` tests is
/// [`super::UiScript::stable_selection`]'s translation of this, not this field (§7.1).
impl Default for StableModel {
fn default() -> Self {
Self {
state: None,
selected: -1,
pickup: None,
// `0` is "nothing selected" in the client's own encoding — NOT `-1`, which means the
// summoned pet. The window's zero state is the reset `0x4caad3` writes.
selected: 0,
intents: Vec::new(),
close: false,
}
@@ -142,17 +166,20 @@ impl Default for StableModel {
impl super::UiScript {
/// Push (or clear, with `None`) the open stable's snapshot.
///
/// **Clearing resets the window's transient state too** — selection and any half-finished drag.
/// A stale selection surviving a close would have the next stable master open on slot 2
/// highlighted before its list arrived. A *content* change deliberately does not reset it: the
/// list is re-requested after every successful action, and losing the selection on each one
/// would fight the player.
/// **Every list message clears the selection**, and that is the client's own behaviour
/// (`0x4cadf8` writes `[0xb72250] = 0` on each one), not a simplification. The first build kept
/// it across a refresh on the reasoning that benilla re-lists after every action and a reset
/// would "fight the player" — the carve says the reference re-lists on exactly the same
/// successes and clears every time. It does not fight anything, because `PetStable_Update`
/// immediately re-picks: the current pet if there is one, else the first occupied slot
/// (`PetStable.lua:44-59`). Keeping a selection across a list is what would be wrong — it can
/// name a pet the new list no longer contains.
pub fn set_stable(&mut self, state: Option<StableState>) {
let mut model = self.model_mut();
if state.is_none() {
model.stable.selected = -1;
model.stable.pickup = None;
}
model.stable.selected = 0;
// The held payload is NOT dropped here: closing the window clears only the stable-master
// guid in the reference (`0x4cae10`), and a picked-up pet lives on the shared cursor, whose
// own ClearCursor arms own it.
model.stable.state = state;
}
@@ -167,9 +194,37 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().stable.close)
}
/// The selected slot (`-1` = none) — what the app's booth points the model pane at.
/// `GetSelectedStablePet()`'s answer — **`0` = the summoned pet, `1..=2` a stable slot, `-1`
/// nothing** — translated out of the petNumber the model holds, exactly as `0x4cb800` does
/// (find the number in the array, `inc eax`; a number no longer present falls through to `-1`).
pub fn stable_selection(&mut self) -> i32 {
self.model_mut().stable.selected
let model = self.model_mut();
selected_slot(&model.stable)
}
}
/// The petNumber→slot translation behind `GetSelectedStablePet` (§7.1).
fn selected_slot(m: &StableModel) -> i32 {
match m.selected {
-1 => 0,
0 => -1,
pet_number => m
.state
.as_ref()
.and_then(|s| {
s.slots
.iter()
.enumerate()
.skip(1)
.find(|(_, slot)| {
slot.as_ref()
.is_some_and(|p| i64::from(p.pet_number) == i64::from(pet_number))
})
.map(|(i, _)| i as i32)
})
// A selection whose pet is gone after a refresh degrades to "nothing", never to a
// wrong slot — the loop's own fall-through at `0x4cb84c`.
.unwrap_or(-1),
}
}
@@ -180,29 +235,48 @@ fn slot_index(i: i64) -> Option<usize> {
usize::try_from(i).ok().filter(|&i| i < NUM_STABLE_SLOTS)
}
/// Commit a drag from `from` onto `to`, returning the verb it becomes — the **one** place the
/// stable's move law lives.
/// Commit a drag from slot `from` onto slot `to` — the **one** place the stable's move law lives,
/// now read off the binary (`ClickStablePet 0x4cb420` regime B, wow-re
/// `system/ui/scratch/stable-master-window.md` §6.2; decision 1677).
///
/// **INFERRED, pending the wow-re carve of `ClickStablePet 0x4ca…` (decision 1676).** The verb set
/// is VERIFIED (vmangos's three handlers and their preconditions); what is *not* yet read off the
/// binary is how the reference client picks among them. This mapping is derived from the server's
/// own constraints, each arm justified:
/// The first build inferred this from the server's constraint set and got the shape right and the
/// **three edges wrong**. Each of them is a real case:
///
/// - **current → a stable slot** is `CMSG_STABLE_PET`. The destination index is *discarded*: the
/// wire carries no slot and `HandleStablePet` takes the first free one, so dropping on slot 2
/// while slot 1 is empty puts the pet in slot 1. That is the server's behaviour, not a
/// simplification here.
/// - **a stable slot → current** is `Swap` when a pet is out and `Unstable` when none is
/// (`HandleUnstablePet` refuses outright if the player has a pet, even an unsummoned one).
/// - **stable → stable** has no opcode in 5875 at all, so it can only be a no-op.
/// 1. **The summoned pet onto an OCCUPIED stable slot sends `SWAP`, not `STABLE`.** "Drag out ⇒
/// stable it" only holds when the target slot is empty. The inferred version sent `STABLE` for
/// both, which vmangos then refuses whenever both bought slots are full — the pet stays put and
/// nothing says why.
/// 2. **A drop onto an UNPURCHASED slot sends nothing**, and is still a completed drop (cursor
/// cleared, falsy return).
/// 3. **A stabled pet onto the summoned slot forks on the LIVE PET GUID**, not on whether slot 0
/// has a row. A dismissed pet has a row and no guid, and the client sends `UNSTABLE` there —
/// the inferred version read the row and sent `SWAP`.
///
/// If the carve says the client instead ignores the drop target and acts on the *source* alone,
/// only this function changes.
fn drag_verb(from: u8, to: u8, has_current_pet: bool, pet_number: u32) -> Option<StableIntent> {
/// Stable→stable remains a no-op: 5875 has no opcode for it, which the first build did get right.
fn drag_verb(from: u8, to: u8, state: &StableState) -> Option<StableIntent> {
if from == to {
return None;
}
let occupied = |i: u8| state.slots.get(usize::from(i)).and_then(|s| s.as_ref());
match (from, to) {
(0, 1..) => Some(StableIntent::Stable),
(1.., 0) if has_current_pet => Some(StableIntent::Swap(pet_number)),
(1.., 0) => Some(StableIntent::Unstable(pet_number)),
// The summoned pet is held.
(0, _) => match occupied(to) {
// …onto an occupied stable slot: trade places.
Some(target) => Some(StableIntent::Swap(target.pet_number)),
// …onto an empty slot the player owns: stable it (the server picks the destination).
None if u32::from(to) <= state.num_stable_slots => Some(StableIntent::Stable),
// …onto a slot they have not bought: nothing.
None => None,
},
// A stabled pet is held, dropped on the summoned slot.
(_, 0) => occupied(from).map(|held| {
if state.has_live_pet {
StableIntent::Swap(held.pet_number)
} else {
StableIntent::Unstable(held.pet_number)
}
}),
// Stable slot → stable slot: no opcode exists in 5875.
_ => None,
}
}
@@ -211,10 +285,10 @@ fn drag_verb(from: u8, to: u8, has_current_pet: bool, pet_number: u32) -> Option
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetStablePetInfo(i) → icon, name, level, family, loyalty — five returns, or a bare nil for an
// empty slot (the reference tests `if ( icon )` on the first and `if ( GetStablePetInfo(i) )`
// on the call itself, so both shapes must read as absent). `family` substitutes "" rather than
// nil: the reference concatenates it into the level line unguarded (`PetStable.lua:100`).
// GetStablePetInfo(i) → icon, name, level, family, loyalty — **5 values on every reachable
// exit** (`0x4cb280`: `eax = 5` at both returns). A miss pushes `nil, nil, 0.0, nil, nil`, NOT
// nothing: the reference both tests the call for truthiness and destructures all five, and a
// bare nil would make `level` nil where the client answers 0.
g.set(
"GetStablePetInfo",
lua.create_function(|lua, i: i64| {
@@ -223,7 +297,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
slot_index(i).and_then(|i| model.stable.state.as_ref()?.slots[i].clone())
};
let Some(pet) = pet else {
return Ok(MultiValue::new());
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Number(0.0),
Value::Nil,
Value::Nil,
]));
};
Ok(MultiValue::from_vec(vec![
match &pet.icon {
@@ -231,8 +311,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
None => Value::Nil,
},
Value::String(lua.create_string(&pet.name)?),
Value::Integer(i64::from(pet.level)),
Value::String(lua.create_string(pet.family.as_deref().unwrap_or(""))?),
Value::Number(f64::from(pet.level)),
match &pet.family {
Some(f) => Value::String(lua.create_string(f)?),
None => Value::Nil,
},
match &pet.loyalty {
Some(l) => Value::String(lua.create_string(l)?),
None => Value::Nil,
@@ -309,66 +392,135 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetSelectedStablePet",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.stable.selected))
// NOT the raw field: the model holds the client's petNumber encoding, and this binding
// is its translation to slot indices (`selected_slot`).
Ok(i64::from(selected_slot(&model.stable)))
})?,
)?;
// ClickStablePet(i) → did anything change (the reference repaints only on true).
// ClickStablePet(i) → **exactly one value, always**, and which one is keyed SOLELY on whether
// the cursor held a pet — never on whether a packet went out (§6.2).
//
// Two jobs, because the reference wires this to BOTH `OnClick` and `OnReceiveDrag`: commit a
// drag if one is in flight, otherwise select. The commit half's verb choice is `drag_verb`'s —
// and INFERRED until the carve lands (see there).
// plain click -> pure select, no packet, pushes 1.0 on ALL THREE legs => always TRUTHY
// drop -> clears the cursor, pushes nil on EVERY leg => always FALSY
//
// So the reference repaints on every click and on NO drop; a drop's repaint arrives with the
// server's next list. The first build had this inverted (true on a committed drag, false on a
// no-op) — invisible to a packet-only test, and in play a window that repaints off a stale
// snapshot on every drag and skips the repaint on every click.
g.set(
"ClickStablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(to) = slot_index(i) else {
return Ok(false);
// Out of range still takes the select leg's "nothing" arm below.
if !matches!(
model.cursor,
Some(super::cursor::CursorPayload::StablePet(_))
) {
model.stable.selected = 0;
return Ok(Value::Number(1.0));
}
super::cursor::clear_cursor(&mut model);
return Ok(Value::Nil);
};
let to = to as u8;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(state) = model.stable.state.as_ref() else {
return Ok(false);
};
if let Some(from) = model.stable.pickup {
// A drag lands. It is consumed whatever it turns into — including the
// stable→stable no-op, which must still put the cursor down rather than leave the
// window holding a pet nothing can drop.
let has_current = state.slots[0].is_some();
let pet_number = state.slots[from as usize]
// Regime B — a drop.
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
let from = held.slot;
let intent = model
.stable
.state
.as_ref()
.map_or(0, |p| p.pet_number);
model.stable.pickup = None;
if from == to || pet_number == 0 {
return Ok(false);
}
if let Some(intent) = drag_verb(from, to, has_current, pet_number) {
.and_then(|state| drag_verb(from, to, state));
super::cursor::clear_cursor(&mut model);
if let Some(intent) = intent {
model.stable.intents.push(intent);
}
return Ok(true);
return Ok(Value::Nil);
}
// A plain click selects. Selecting the already-selected slot changes nothing, and
// saying so keeps the reference from repainting the whole window on every click.
if model.stable.selected == i32::from(to) {
return Ok(false);
}
model.stable.selected = i32::from(to);
Ok(true)
// Regime A — a plain click: pure select, always truthy.
let pet_number = model
.stable
.state
.as_ref()
.and_then(|s| s.slots[usize::from(to)].as_ref())
.map(|p| p.pet_number);
model.stable.selected = match (to, pet_number) {
// Slot 0 selects the summoned pet by its own sentinel, not by a petNumber.
(0, _) => -1,
(_, Some(n)) => n as i32,
// An empty (or unowned) slot selects NOTHING, rather than the slot itself.
(_, None) => 0,
};
Ok(Value::Number(1.0))
})?,
)?;
// PickupStablePet(i) — begin a frame-local drag (module doc: NOT the global cursor). Picking up
// an empty slot is ignored, exactly as the reference's own empty buttons carry no pet to drag.
// PickupStablePet(i) — the mode-10 grab. **0 Lua values, always.**
//
// The client's gate is the family ICON path, not occupancy (`0x495010`: resolve the record,
// require a non-empty `CreatureFamily` icon, then set the payload). Ours is the resolved icon,
// which is the same test one step later: no icon, no grab.
g.set(
"PickupStablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let occupied = slot_index(i)
.and_then(|i| Some(model.stable.state.as_ref()?.slots[i].is_some()))
.unwrap_or(false);
if occupied {
model.stable.pickup = Some(i as u8);
let texture = slot_index(i)
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
.and_then(|p| p.icon.clone());
if let Some(texture) = texture {
model.cursor = Some(super::cursor::CursorPayload::StablePet(
super::cursor::CursorStablePet {
slot: i as u8,
texture,
},
));
super::cursor::queue_cursor_update(&mut model);
}
Ok(())
})?,
)?;
// StablePet() — 0 args, and it makes no Lua call at all in the reference: its only gate is a
// stable master being open, which for us is the snapshot's existence. Not called by the shipped
// FrameXML (the drag path sends this verb); it exists because the binding does.
g.set(
"StablePet",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.stable.state.is_some() {
model.stable.intents.push(StableIntent::Stable);
}
Ok(())
})?,
)?;
// UnstablePet(i) — carries a gate the drag path does NOT (§7.1): it requires no charmed unit
// and **no pet out**, and bails SILENTLY otherwise, with no packet and no error. Its unsigned
// bound also rejects index 0 outright, where the drag path treats 0 as the summoned pet.
g.set(
"UnstablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let pet_number = model
.stable
.state
.as_ref()
.filter(|s| !s.has_live_pet)
.and_then(|s| {
slot_index(i)
.filter(|&i| i != 0)
.and_then(|i| s.slots[i].as_ref())
})
.map(|p| p.pet_number);
if let Some(pet_number) = pet_number {
model
.stable
.intents
.push(StableIntent::Unstable(pet_number));
}
Ok(())
})?,
@@ -383,13 +535,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|_, _model: Value| Ok(()))?,
)?;
// BuyStableSlot() — queue the purchase. The server prices and picks the slot; a refusal comes
// back as SMSG_STABLE_RESULT's ERR_MONEY.
// BuyStableSlot() — 0 args, with the client's own **silent** local gates in order (§7.1): a
// stable master open · the hard cap `slots != 2` · a price row exists · **affordability**.
// None of them shows a message; the reference disables the button instead. Applying them here
// rather than trusting the button matters because an addon can call the binding directly, and
// the server's refusal for the cap is the same indistinguishable ERR_STABLE as everything else.
g.set(
"BuyStableSlot",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.stable.intents.push(StableIntent::BuySlot);
// Affordability reads the VM's own purse — the same `model.money` the reference's
// `GetMoney()` returns and its own button-state ladder compares (`PetStable.lua:203`),
// rather than a second copy of the number in the stable snapshot.
let money = model.money;
let allowed = model.stable.state.as_ref().is_some_and(|s| {
s.num_stable_slots as usize != NUM_STABLE_SLOTS - 1
&& s.next_slot_cost != 0
&& u64::from(s.next_slot_cost) <= money
});
if allowed {
model.stable.intents.push(StableIntent::BuySlot);
}
Ok(())
})?,
)?;
@@ -425,17 +591,25 @@ mod tests {
}
}
/// A hunter with a pet out and one stabled, one slot bought.
fn open(s: &mut UiScript) {
s.set_stable(Some(StableState {
fn state(slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS]) -> StableState {
StableState {
num_stable_slots: 1,
next_slot_cost: 50_000,
slots: [Some(pet(7, "Rex", 41)), Some(pet(8, "Bruiser", 38)), None],
}));
slots,
has_live_pet: true,
}
}
/// The read surface, through the reference's own destructuring. A closed window answers the
/// absent shape everywhere rather than erroring — the frame's OnLoad runs before any list.
/// A hunter with a pet out and one stabled, one slot bought.
fn open(s: &mut UiScript) {
s.set_stable(Some(state([
Some(pet(7, "Rex", 41)),
Some(pet(8, "Bruiser", 38)),
None,
])));
}
/// The read surface, through the reference's own destructuring.
#[test]
fn the_read_surface_answers_the_reference_calls() {
let mut s = UiScript::new().unwrap();
@@ -443,7 +617,6 @@ mod tests {
assert_eq!(s.eval::<i64>("return GetNumStablePets()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetNextStableSlotCost()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), -1);
assert!(s.eval::<bool>("return GetStablePetInfo(0) == nil").unwrap());
open(&mut s);
assert_eq!(s.eval::<i64>("return GetNumStableSlots()").unwrap(), 1);
@@ -467,12 +640,7 @@ mod tests {
"(Loyalty Level 6) Best Friend".into()
)
);
// The empty third slot reads absent on both tests the reference makes.
assert!(s.eval::<bool>("return GetStablePetInfo(2) == nil").unwrap());
// An out-of-range index is absent too, not an error.
assert!(s.eval::<bool>("return GetStablePetInfo(9) == nil").unwrap());
// The diet list, as BuildListString receives it.
assert_eq!(
s.eval::<(String, String)>("return GetStablePetFoodTypes(1)")
.unwrap(),
@@ -483,119 +651,240 @@ mod tests {
.unwrap());
}
/// A plain click selects, and reports whether anything changed — the reference repaints only
/// on true, so a click on the already-selected slot must answer false or the window redraws
/// itself on every click.
/// **`GetStablePetInfo` returns five values on EVERY exit** (`eax = 5` at both returns), with a
/// miss pushing `nil, nil, 0, nil, nil`. The reference both tests the call for truthiness and
/// destructures all five, so a bare nil would leave `level` nil where the client answers 0 —
/// and would silently change the arity an addon sees.
#[test]
fn a_plain_click_selects_once() {
fn a_missing_pet_still_answers_five_values() {
let mut s = UiScript::new().unwrap();
open(&mut s);
assert!(s.eval::<bool>("return ClickStablePet(1)").unwrap());
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 1);
assert!(!s.eval::<bool>("return ClickStablePet(1)").unwrap());
assert!(s.eval::<bool>("return ClickStablePet(0)").unwrap());
for call in ["GetStablePetInfo(2)", "GetStablePetInfo(9)"] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', {call})"))
.unwrap(),
5,
"{call} arity"
);
// Still falsy on the reference's own `if ( GetStablePetInfo(i) )` test.
assert!(s.eval::<bool>(&format!("return not {call}")).unwrap());
assert_eq!(
s.eval::<i64>(&format!("local _, _, l = {call} return l"))
.unwrap(),
0,
"{call} level is 0, not nil"
);
}
}
/// **A plain click always returns truthy**, on all three of its legs — including a re-click of
/// the same slot and a click on an empty one. The reference repaints on the return, so a
/// falsy answer anywhere here is a window that stops repainting on click.
#[test]
fn every_plain_click_returns_truthy() {
let mut s = UiScript::new().unwrap();
open(&mut s);
for call in [
"ClickStablePet(1)",
"ClickStablePet(1)",
"ClickStablePet(0)",
"ClickStablePet(2)",
] {
assert!(
s.eval::<bool>(&format!("return {call} and true or false"))
.unwrap(),
"{call}"
);
}
assert!(
s.take_stable_intents().is_empty(),
"selecting sends nothing"
);
}
/// The selection's three encodings, read back through `GetSelectedStablePet`'s translation:
/// slot 0 answers 0, an occupied stable slot answers its index, and an **empty** slot selects
/// nothing (`-1`) rather than itself.
#[test]
fn the_selection_translates_back_to_slot_indices() {
let mut s = UiScript::new().unwrap();
open(&mut s);
s.eval::<()>("ClickStablePet(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 0);
// Selecting queues nothing on the wire.
s.eval::<()>("ClickStablePet(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 1);
s.eval::<()>("ClickStablePet(2)").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedStablePet()").unwrap(),
-1,
"an empty slot selects nothing, not itself"
);
}
/// A selection is held as a **petNumber**, so a refresh that no longer contains that pet
/// degrades to "nothing" rather than to a wrong slot — and every list clears it anyway.
#[test]
fn a_list_clears_the_selection_and_a_stale_pet_degrades() {
let mut s = UiScript::new().unwrap();
open(&mut s);
s.eval::<()>("ClickStablePet(1)").unwrap();
assert_eq!(s.stable_selection(), 1);
// Every list message clears it (`0x4cadf8`) — the reference re-picks in PetStable_Update.
open(&mut s);
assert_eq!(s.stable_selection(), -1, "a list clears the selection");
}
/// **A drop always returns nil**, on every leg — the ones that send a packet included. The
/// reference therefore never repaints from a drop; the repaint arrives with the server's next
/// list. This is the convention the first build had inverted.
#[test]
fn every_drop_returns_falsy_even_when_it_sends() {
let mut s = UiScript::new().unwrap();
open(&mut s);
// A drop that DOES send.
assert!(s
.eval::<bool>("PickupStablePet(1) return ClickStablePet(0) == nil")
.unwrap());
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
// A drop that sends nothing is equally falsy.
assert!(s
.eval::<bool>("PickupStablePet(1) return ClickStablePet(1) == nil")
.unwrap());
assert!(s.take_stable_intents().is_empty());
}
/// Dragging the current pet onto a stable slot stables it — and the destination is *not* sent,
/// because the wire cannot carry one.
/// **The summoned pet onto an OCCUPIED stable slot is a SWAP, not a STABLE** — the first of the
/// three edges the inferred law got wrong. Sending STABLE there is refused by the server
/// whenever both bought slots are full, and nothing says why.
#[test]
fn dragging_the_current_pet_out_stables_it() {
fn dropping_onto_an_occupied_slot_swaps() {
let mut s = UiScript::new().unwrap();
open(&mut s);
s.eval::<()>("PickupStablePet(0) ClickStablePet(2)")
s.eval::<()>("PickupStablePet(0) ClickStablePet(1)")
.unwrap();
assert_eq!(s.take_stable_intents(), vec![StableIntent::Stable]);
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
}
/// Dragging a stabled pet onto the current slot forks on whether a pet is already out: a swap
/// when one is, a plain unstable when none is. The server refuses an unstable outright while a
/// pet exists, so picking the wrong one here fails every drag for a hunter with a pet.
/// …and onto an EMPTY slot the player owns it is a STABLE, whose destination the wire cannot
/// carry; onto one they have **not bought**, nothing at all — but the cursor still clears.
#[test]
fn dragging_a_stabled_pet_in_forks_on_having_a_pet() {
fn an_unpurchased_slot_takes_the_drop_and_sends_nothing() {
let mut s = UiScript::new().unwrap();
// Slot 1 bought and empty, slot 2 unbought.
s.set_stable(Some(state([Some(pet(7, "Rex", 41)), None, None])));
s.eval::<()>("PickupStablePet(0) ClickStablePet(1)")
.unwrap();
assert_eq!(s.take_stable_intents(), vec![StableIntent::Stable]);
s.eval::<()>("PickupStablePet(0) ClickStablePet(2)")
.unwrap();
assert!(s.take_stable_intents().is_empty(), "slot 2 is not bought");
// The drop completed: the cursor is empty, so the next click SELECTS.
assert!(s.eval::<bool>("return ClickStablePet(1) and true").unwrap());
assert!(s.take_stable_intents().is_empty());
}
/// **The stabled→summoned fork reads the LIVE PET GUID, not slot 0's row** — the third edge.
/// A dismissed pet still has a row from the server's character-pet cache while the guid is
/// zero, and the client sends UNSTABLE there.
#[test]
fn the_fork_follows_the_live_pet_not_the_row() {
let mut s = UiScript::new().unwrap();
open(&mut s);
s.eval::<()>("PickupStablePet(1) ClickStablePet(0)")
.unwrap();
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
// Same drag with slot 0 empty — the hunter has no pet out.
s.set_stable(Some(StableState {
num_stable_slots: 1,
next_slot_cost: 50_000,
slots: [None, Some(pet(8, "Bruiser", 38)), None],
}));
// The same list, but the pet is dismissed: the row survives, the guid does not.
let mut dismissed = state([Some(pet(7, "Rex", 41)), Some(pet(8, "Bruiser", 38)), None]);
dismissed.has_live_pet = false;
s.set_stable(Some(dismissed));
s.eval::<()>("PickupStablePet(1) ClickStablePet(0)")
.unwrap();
assert_eq!(s.take_stable_intents(), vec![StableIntent::Unstable(8)]);
assert_eq!(
s.take_stable_intents(),
vec![StableIntent::Unstable(8)],
"a row without a live guid unstables"
);
}
/// A drag that has no verb still puts the pet down. Stable→stable has no opcode in 5875, and a
/// drag onto its own slot is a cancel; neither may leave the window holding a pet forever.
/// Stable→stable has no opcode in 5875, and an empty slot carries nothing to grab.
#[test]
fn a_verbless_drag_is_still_consumed() {
fn stable_to_stable_is_a_no_op_and_empty_slots_do_not_grab() {
let mut s = UiScript::new().unwrap();
s.set_stable(Some(StableState {
num_stable_slots: 2,
next_slot_cost: 0,
slots: [None, Some(pet(8, "Bruiser", 38)), None],
..state([None, None, None])
}));
s.eval::<()>("PickupStablePet(1) ClickStablePet(2)")
.unwrap();
assert!(s.take_stable_intents().is_empty());
// The pickup is gone, so the NEXT click selects rather than committing a stale drag.
assert!(s.eval::<bool>("return ClickStablePet(1)").unwrap());
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 1);
// An empty slot never arms a grab, so the following click is a select.
s.eval::<()>("PickupStablePet(2)").unwrap();
assert!(s.eval::<bool>("return ClickStablePet(0) and true").unwrap());
assert!(s.take_stable_intents().is_empty());
}
/// An empty slot carries nothing to drag, so picking it up leaves no pickup armed — otherwise
/// the next click would commit a move for a pet that does not exist.
/// `BuyStableSlot`'s gates are the client's own, and all silent: the hard cap, and
/// affordability.
#[test]
fn an_empty_slot_cannot_be_picked_up() {
fn buy_stable_slot_gates_locally_and_silently() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000_000);
open(&mut s);
s.eval::<()>("PickupStablePet(2) ClickStablePet(0)")
.unwrap();
assert!(s.take_stable_intents().is_empty());
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 0);
}
/// The purchase and close intents, and the close's state reset: a selection must not survive
/// into the next stable master's window.
#[test]
fn purchase_and_close_intents_drain() {
let mut s = UiScript::new().unwrap();
open(&mut s);
s.eval::<()>("ClickStablePet(1) BuyStableSlot()").unwrap();
s.eval::<()>("BuyStableSlot()").unwrap();
assert_eq!(s.take_stable_intents(), vec![StableIntent::BuySlot]);
assert!(s.take_stable_intents().is_empty(), "drain clears");
// Too poor.
s.set_money(10);
s.set_stable(Some(state([None, None, None])));
s.eval::<()>("BuyStableSlot()").unwrap();
assert!(s.take_stable_intents().is_empty(), "unaffordable is silent");
// Both slots owned — the hard cap (and money restored, so only the cap can refuse).
s.set_money(1_000_000);
let mut full = state([None, None, None]);
full.num_stable_slots = 2;
s.set_stable(Some(full));
s.eval::<()>("BuyStableSlot()").unwrap();
assert!(s.take_stable_intents().is_empty(), "the cap is silent");
}
/// `UnstablePet` carries a gate the drag path does not: no pet out, else silence. And its
/// unsigned bound rejects index 0, where the drag path reads 0 as the summoned pet.
#[test]
fn the_unstable_binding_gates_where_the_drag_path_does_not() {
let mut s = UiScript::new().unwrap();
open(&mut s); // has_live_pet = true
s.eval::<()>("UnstablePet(1)").unwrap();
assert!(s.take_stable_intents().is_empty(), "a pet is out — silent");
let mut dismissed = state([None, Some(pet(8, "Bruiser", 38)), None]);
dismissed.has_live_pet = false;
s.set_stable(Some(dismissed));
s.eval::<()>("UnstablePet(0)").unwrap();
assert!(s.take_stable_intents().is_empty(), "index 0 is rejected");
s.eval::<()>("UnstablePet(1)").unwrap();
assert_eq!(s.take_stable_intents(), vec![StableIntent::Unstable(8)]);
}
/// The close intent, and that closing does NOT drop a held pet — the reference's close clears
/// only the stable-master guid.
#[test]
fn close_drains_and_leaves_the_cursor_alone() {
let mut s = UiScript::new().unwrap();
open(&mut s);
assert!(!s.take_stable_close());
s.eval::<()>("ClosePetStables()").unwrap();
assert!(s.take_stable_close());
assert!(!s.take_stable_close(), "drain clears");
assert_eq!(s.stable_selection(), 1);
s.set_stable(None);
assert_eq!(s.stable_selection(), -1, "a close forgets the selection");
}
/// A content refresh — which benilla does after every successful action — must KEEP the
/// selection. Resetting it there would move the player's highlight on every button press.
#[test]
fn a_refresh_keeps_the_selection() {
let mut s = UiScript::new().unwrap();
open(&mut s);
s.eval::<()>("ClickStablePet(1)").unwrap();
open(&mut s);
assert_eq!(s.stable_selection(), 1);
}
/// `SetPetStablePaperdoll` exists and is harmless — the reference calls it at four sites, and a
/// missing global would be a Lua error mid-repaint.
/// `SetPetStablePaperdoll` exists and is harmless — the reference calls it at four sites.
#[test]
fn the_paperdoll_setter_is_callable() {
let s = UiScript::new().unwrap();