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