Files
benilla-octo/crates/benilla-app/src/ui_action/errors.rs
T
SamandClaude Fable 5.1 f2377ec846 every objective line shows, the detail follows the row, and 1.98 lints
- a quest objective that carries both a creature and an item is two lines in the quest log, as the reference's four passes enumerate it: Graverobbers shows its Embalming Ichor (2249)
- the quest log's detail follows the live selection inside a frame, so a quest addon's log walk reads the right quest; Questie no longer files a chain quest under its shortest sibling (2247)
- a UI tint change is not a material change: a run's colour rides the batch's instance tag, so the PlayerFrame glow no longer re-prepares a material every frame (2245)
- the workspace lints clean on rustc 1.98: constant-size chunks become as_chunks, one slice fill, two unused globs
- WOW_HOVER_PROBE aims the pick in a headless run, and the inspect card names the tooltip's ladder stage (2248, 2250)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 17:01:27 +07:00

1083 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The red error line's queues and resolvers — every route into `UIErrorsFrame`'s top line
//! except the cast-fail two-layer display (its own module, [`super::cast_fail`]):
//!
//! - [`CastErrors`] — the wire [`CastFail`]s (`SMSG_CAST_RESULT` + local cast refusals), resolved
//! by [`super::cast_fail`] at the drain.
//! - [`MountErrors`] — the `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` code pairs, resolved by
//! [`mount_result_key`] (decision 0441 P2).
//! - [`UiErrorKeys`] — client-LOCAL refusals straight by GlobalStrings key, the
//! `CGGameUI::DisplayError` route for errors with no wire code and no spell record:
//! `ERR_ATTACK_MOUNTED` (decision 0481) and the GameObject lock-refusal toasts
//! ("Requires Herbalism", decision 0545) and the guild lines (decision 2054) — the latter
//! carry [`UiError`]'s ordered argText list, resolved by [`ui_error_text`].
//!
//! - [`UiErrorTexts`] — the same route for lines that arrive already resolved (the server's own
//! `SMSG_NOTIFICATION` / `SMSG_AREA_TRIGGER_MESSAGE` text, the death durability notice), so
//! there is no key to look up — only the arm to pick.
//!
//! All four drain in `super::feed_actions` into the one sink [`show_messages`], which puts each
//! resolved line on the surface its message record names — read from the catalog
//! ([`benilla_ui::messages`], decision 1770) rather than carried to the call site by hand.
//! Every string comes from the VM's own loaded `GlobalStrings.lua`, never hardcoded, so
//! localization rides for free. An absent key shows **nothing**, which is a deliberate
//! divergence and not the reference's behaviour — see [`ui_error_text`].
use benilla_ui::messages::MessageRecord;
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use crate::net::ObjectStore;
use crate::sound::MessageSounds;
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_items::{count_of, InventoryScope};
/// Cast failures queued for the UI error line — the wire triple from `SMSG_CAST_RESULT` and the
/// local refusals alike. The spell id rides along because the display layer keys several messages
/// on the failing spell's record ([`super::cast_fail`]: NO_POWER's power family, the 0x28/0x3c
/// cooldown families).
#[derive(Resource, Default)]
pub(crate) struct CastErrors(pub Vec<CastFail>);
/// One queued cast failure: the failing spell, the wire reason, and the reason-specific argument
/// word that fills the message's `%s` (the arm table at `0x6e1d8e` — a `SpellFocusObject.dbc` id
/// for 0x5e, an `AreaTable.dbc` id for 0x5d).
///
/// `arg` is `None` for **almost** every client-local refusal, and that is faithful rather than a
/// gap: the local refusals the reference raises itself go through `DisplayError` with no argText,
/// and the two client-generated argument messages it *does* build (the lock toasts, MIN_SKILL) are
/// [`UiError`]'s tenants, not this queue's.
///
/// The **one** exception is the crowd-control ladder's `0x8d` (decision 1948). That refusal is
/// local — `0x6094f0` bails before any packet — yet it carries an argument all the same, because
/// its own exemption scan produced one: the blocking aura's `SpellMechanic.dbc` id. It is the
/// only place a locally-generated word reaches this queue, which is why `push_local` still cannot
/// set one and [`CastErrors::push_local_arg`] exists beside it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CastFail {
pub spell_id: u32,
pub reason: u8,
pub arg: Option<u32>,
/// **Whose** refusal this is — which decides which of the reference's two message tables
/// resolves it. See [`Caster`].
pub caster: Caster,
}
/// Who failed to cast — the one input that picks between the reference's **two** cast-failure
/// message tables (decision 2033).
///
/// It is not a flag on one handler: `SMSG_CAST_FAILED` and `SMSG_PET_CAST_FAILED` land in two
/// separate functions, `0x6e1a00` and `0x6e8eb0`, each with its own reason -> errorId map. Ten of
/// the pet's reasons resolve to something the player's never says, six of them to the
/// `ERR_PET_SPELL_*` catalog rows that exist for no other purpose. Everything else about the two
/// — the first-layer `SPELL_FAILED_*` vocabulary, the argument arms, the strip fallback — is
/// literally the same code, which is why this rides the shared queue instead of forking it.
///
/// [`super::cast_fail::cast_fail_text`] is the reader; nothing downstream of it asks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Caster {
/// Ours — `SMSG_CAST_FAILED` and every client-local refusal we raise for ourselves.
#[default]
Player,
/// The pet's or the charm's — `SMSG_PET_CAST_FAILED` only.
Pet,
}
impl CastFail {
/// A **client-local** refusal — no wire argument (see [`Self::arg`]).
///
/// Always the player's: every local refusal benilla raises is raised on its own cast, and the
/// reference has no client-side path that refuses on the pet's behalf — a pet's refusals are
/// all decided server-side and arrive as `SMSG_PET_CAST_FAILED`.
pub(crate) const fn local(spell_id: u32, reason: u8) -> Self {
Self {
spell_id,
reason,
arg: None,
caster: Caster::Player,
}
}
}
impl CastErrors {
/// Queue a [`CastFail::local`] refusal.
pub(crate) fn push_local(&mut self, spell_id: u32, reason: u8) {
self.0.push(CastFail::local(spell_id, reason));
}
/// Queue a client-local refusal that carries its own argument word — see [`CastFail::arg`]
/// for why there is exactly one such tenant.
pub(crate) fn push_local_arg(&mut self, spell_id: u32, reason: u8, arg: u32) {
self.0.push(CastFail {
spell_id,
reason,
arg: Some(arg),
caster: Caster::Player,
});
}
/// Queue the **pet's** wire refusal (`SMSG_PET_CAST_FAILED`). Same queue, same drain, same
/// sink — [`Caster::Pet`] is the whole difference, and it is read once, at the table pick.
///
/// No argument word: `PetCastFailed::AppendBodyTo` writes none, so the reference's own
/// argument arms on this path have nothing to read.
pub(crate) fn push_pet(&mut self, spell_id: u32, reason: u8) {
self.0.push(CastFail {
spell_id,
reason,
arg: None,
caster: Caster::Pet,
});
}
}
/// (Dis)mount refusals queued for the UI error line, as the wire pair `(mount, code)` from
/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` (decision 0441 P2) — resolved to text through the
/// VM's own GlobalStrings by key ([`mount_result_key`]), the [`CastErrors`] shape exactly.
#[derive(Resource, Default)]
pub(crate) struct MountErrors(pub Vec<(bool, u32)>);
/// Taming refusals queued for the UI error line, as the raw `SMSG_PET_TAME_FAILURE` reason byte
/// (decision 2039) — [`MountErrors`]' shape, and here for the same reason: it is a **wire code
/// family**, resolved by a code table, not a client-local key.
///
/// It cannot ride [`UiErrorKeys`] because its message is a *nested* lookup, which is exactly what
/// the reference does: `0x6e6a20` resolves the reason's `PETTAME_*` key through the script VM
/// (`0x703bf0`) and then passes that **string** as `DisplayError(0xee)`'s argText, filling
/// `ERR_TAME_FAILED`'s lone `%s`. [`UiError`]'s arguments are text the raise site already has;
/// here the raise site has only a byte, and the VM is only reachable at the drain.
#[derive(Resource, Default)]
pub(crate) struct PetTameFailures(pub Vec<u8>);
/// Where a client message is SHOWN is **not a decision this crate makes** — it is the `kind` field
/// (`+0x04`) of the reference's message record, and it now arrives from the catalog
/// ([`benilla_ui::messages`], decision 1770) instead of being hand-carried to every call site
/// beside the key. [`MsgKind`] is that field; [`UiError::kind`] is the whole lookup.
///
/// It reads as a property of the MESSAGE rather than of the window that raised it because that is
/// exactly what it is: the quest refusals (0669), the auction house (1523) and the party
/// quest-share (1733) all ask the same table the same question.
pub(crate) use benilla_ui::messages::MsgKind;
/// One argument in a message's **argText list**.
///
/// Heterogeneous and ordered because the template's specifiers are: the lock-refusal toast
/// `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` is String-then-Integer (wow-re cursor-system.md §8.8,
/// decision 0545), and a list of strings beside a separate number cannot express that.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum FillArg {
S(String),
D(i64),
}
/// One `DisplayError` message: a GlobalStrings key plus the **ordered argText list** the
/// reference pushes with it. Not red-line-only: this is the payload of the reference's ONE
/// `CGGameUI::DisplayError` (`0x496720`) whatever surface the message's record names — the quest
/// refusals carry their chat lines in it too (decision 0669).
///
/// **`0x496720` is variadic** — cdecl, the catalog id first and the argText after it, `add esp,4`
/// at a bare call site and `add esp,8` at a one-string one (wow-re
/// `system/ui/scratch/staticpopup-dialog-bindings.md`; `guild-api-carve.md` §5's "22 of 22" arity
/// control). Three strings is the most any call site pushes: `SMSG_GUILD_EVENT`'s shared emitter
/// tail `0x5e745f` passes **1, 2 or 3** of them off the packet's `strCount`, feeding
/// `ERR_GUILD_PROMOTE_SSS`. This field was a single `fill_s` until decision 2054, which is why
/// the guild lines could not use this route at all and composed their own English instead.
///
/// A key whose string carries fewer specifiers than there are arguments ignores the rest; one
/// that carries more shows the starved specifier verbatim, which is `SStrPrintf`'s own behaviour
/// and the reason a short `strCount` is passed short rather than padded with empties.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct UiError {
pub key: &'static str,
pub args: Vec<FillArg>,
}
impl UiError {
/// A fill-less message — the plain-key tenants (`ERR_ATTACK_MOUNTED`, the flag-locked
/// strategy defaults). The reference's `push <id>; call 0x496720; add esp,4`.
pub(crate) fn key(key: &'static str) -> Self {
Self {
key,
args: Vec::new(),
}
}
/// One string — the `_S` family, and the reference's `add esp,8`.
pub(crate) fn s(key: &'static str, s: impl Into<String>) -> Self {
Self {
key,
args: vec![FillArg::S(s.into())],
}
}
/// Several strings, in the order they are pushed — the `_SS`/`_SSS` family.
pub(crate) fn strings(key: &'static str, args: &[&str]) -> Self {
Self {
key,
args: args.iter().map(|s| FillArg::S((*s).to_string())).collect(),
}
}
/// A mixed list, for a template whose specifiers are not all `%s`.
pub(crate) fn args(key: &'static str, args: Vec<FillArg>) -> Self {
Self { key, args }
}
/// The first string argument — what a test asking "was the name filled in?" means.
#[cfg(test)]
pub(crate) fn arg_s(&self) -> Option<&str> {
self.args.iter().find_map(|a| match a {
FillArg::S(s) => Some(s.as_str()),
FillArg::D(_) => None,
})
}
/// The first integer argument.
#[cfg(test)]
pub(crate) fn arg_d(&self) -> Option<i64> {
self.args.iter().find_map(|a| match a {
FillArg::D(d) => Some(*d),
FillArg::S(_) => None,
})
}
}
/// Client-LOCAL refusals queued for the UI error line straight by GlobalStrings key — the
/// `CGGameUI::DisplayError` route for errors that carry no wire code and no spell record
/// (`ERR_ATTACK_MOUNTED` was the first tenant; the GameObject lock-refusal toasts of
/// decision 0545 are the formatted ones). The [`MountErrors`] shape without the code table.
#[derive(Resource, Default)]
pub(crate) struct UiErrorKeys(pub Vec<UiError>);
/// [`UiErrorKeys`]' twin for lines that arrive **already resolved** — text with no GlobalStrings
/// key to look up, because the server (or a fixed literal) already wrote it. Same frame, same
/// sink, same drain.
///
/// This is the one route that does **not** consult the catalog, and faithfully so: its tenants do
/// not go through `DisplayError` at all, so there is no record and no id to read a kind from. The
/// wire tenants are the reference's own: `SMSG_NOTIFICATION` (`0x1cb`, handler `0x401800` —
/// `mov edx,1; call 0x4945b0` → `UI_ERROR_MESSAGE`) and `SMSG_AREA_TRIGGER_MESSAGE` (`0x2b8`, the
/// shared handler `0x48f690`'s arm at `0x48f8ff` — `xor edx,edx; call 0x4945b0` →
/// `UI_INFO_MESSAGE`). `0x4945b0(text, flag)` is the whole sink: null/empty guard, then
/// `neg edx; sbb edx,edx; add edx,0xe1` = event `0xe1` when the flag is 0 and `0xe0` when it is 1
/// (wow-re `system/ui/ui.md` l.2459). The handler's own choice of flag is what the [`MsgKind`]
/// here carries — the same two surfaces the catalog names, reached by a different road.
#[derive(Resource, Default)]
pub(crate) struct UiErrorTexts(pub Vec<(String, MsgKind)>);
impl UiErrorTexts {
/// The red `UI_ERROR_MESSAGE` arm (`0x4945b0(text, 1)`).
pub(crate) fn error(&mut self, text: String) {
self.0.push((text, MsgKind::Error));
}
/// The yellow `UI_INFO_MESSAGE` arm (`0x4945b0(text, 0)`).
pub(crate) fn info(&mut self, text: String) {
self.0.push((text, MsgKind::Info));
}
}
/// Resolve one [`UiError`] to its displayed text — `GetText(key)` + the `%s`/`%d` argText
/// substitution ("Requires %s" + "Herbalism" → "Requires Herbalism", cursor-system.md §8.8).
/// `None` (the key resolves to nothing, or the filled text is empty) = show nothing — a
/// **NAMED DIVERGENCE**, corrected from a false citation this doc carried until 2246.
///
/// The claim was that this is the reference's own guard at `0x4967bd`/`0x4967c5`. It is not.
/// Those two test the **catalog row's key field** — `0x4967b6 mov ecx,[edx*4 + 0xb4b498]`, the
/// row's `+0x00`, null then first-byte-empty — i.e. whether the *record names a key at all*. In
/// benilla that field is a `&'static str` literal in a generated table, so the guard they model
/// can never fire, and the guard actually implemented here is one the reference does not have:
/// between the resolve (`0x4967d7 call 0x703bf0`) and the dispatch (`0x496842`) there is **no
/// test of the resolved string**. A key the player's `GlobalStrings.lua` does not define resolves
/// to the empty string and the reference emits an **empty** line — `0x49a870`'s own entry test
/// (`0x49a881 test esi,esi`) is on the buffer *pointer*, not its contents.
///
/// Kept because it only bites on a chain missing a stock key, where silence beats a blank line in
/// the log — but kept **named**, because the shape of the old comment (bytes cited for behaviour
/// those bytes do not justify) is the same shape as the `removed_spell` error 2246 corrects.
/// [`keyed_line`] and [`keyed_line_s`] carry the same divergence.
pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
// Through the one shared filler (2045). This used `str::replace`, which fills EVERY `%s`
// with the same argument — latent only because no message on this queue carried two yet.
// The list's order is the reference's own push order, and a specifier with no argument left
// is copied through rather than blanked, which is `SStrPrintf`'s behaviour and the whole
// reason a short guild `strCount` is passed short (2054).
let args: Vec<benilla_ui::strings::Arg<'_>> = e
.args
.iter()
.map(|a| match a {
FillArg::S(s) => benilla_ui::strings::Arg::S(s),
FillArg::D(d) => benilla_ui::strings::Arg::D(*d),
})
.collect();
let text = benilla_ui::strings::fill(&get(e.key)?, &args);
(!text.is_empty()).then_some(text)
}
/// One resolved line on its way to the screen — the text, where it goes, and the **catalog row it
/// came from** when it has one.
///
/// The record rides along because a message's *sound* is one of its fields (`+0x08`/`+0x0c`) just
/// as its surface is (`+0x04`), and the reference reads all three in the same breath inside
/// `CGGameUI::DisplayError`. Carrying only the [`MsgKind`], as this did before 1815, threw the
/// other two away at the call site and left the catalog's sound columns unreadable by anything.
pub(crate) struct Shown {
record: Option<&'static MessageRecord>,
kind: MsgKind,
text: String,
}
impl Shown {
/// The resolved text — what the surface will show.
#[cfg(test)]
pub(crate) fn text(&self) -> &str {
&self.text
}
/// A **catalog** message, named by its GlobalStrings key — the reference's `DisplayError(id)`,
/// which is nearly every line benilla shows. One lookup answers both "where does it go" and
/// "what does it sound like".
///
/// A key with no row falls back to [`MsgKind::Error`] and no sound. That cannot arise in the
/// reference (a message is an *index*, so an unknown key is not expressible), so it only ever
/// means benilla named a key the client does not have.
///
/// `every_error_key_in_the_source_is_a_catalog_row` was supposed to keep that unreachable and
/// **could not**: all 465 catalog keys begin `ERR_`, so that walk collects `"ERR_…"` literals
/// — which makes it blind to exactly the mistake it is guarding against, a raise site that
/// named something else. `PET_SPELL_NOPATH` and `SPELL_FAILED_OUT_OF_RANGE` sat on the pet's
/// feedback line for months, took this fallback every time, and no gate could see them
/// (decision 2033). Widening the walk is not possible from the text — `SPELL_FAILED_*` keys
/// are legitimate GlobalStrings lookups elsewhere in this very module — so the tripwire moves
/// to the funnel: this is where a non-row key becomes observable, so it says so.
pub(crate) fn keyed(key: &str, text: String) -> Self {
let record = benilla_ui::messages::by_key(key);
if record.is_none() {
warn!("message {key:?} is not a catalog row — surface and sound are a guess");
}
Self {
record,
kind: record.map_or(MsgKind::Error, |r| r.kind),
text,
}
}
/// A line with **no catalog row** — the wire's own already-resolved text ([`UiErrorTexts`]),
/// which reaches the reference's sink `0x4945b0` directly and never passes a record. Silent,
/// faithfully: there is no `+0x08`/`+0x0c` to read.
pub(crate) fn unkeyed(kind: MsgKind, text: String) -> Self {
Self {
record: None,
kind,
text,
}
}
}
/// Resolve a message key against the VM's own `GlobalStrings.lua` into a [`Shown`] — or `None`
/// when the key has no string there. Carrying a *key* rather than text at every raise site is the
/// reference's shape; suppressing on an **absent** one is [`ui_error_text`]'s named divergence,
/// not the `0x4967bd`/`0x4967c5` guard this line used to cite.
///
/// Lives here rather than in each window because every keyed raise site needs exactly this and
/// four of them had grown their own copy (decision 1821).
pub(crate) fn keyed_line(script: &UiScript, key: &'static str) -> Option<Shown> {
let text = script.lua().globals().get::<String>(key).ok()?;
(!text.is_empty()).then(|| Shown::keyed(key, text))
}
/// [`keyed_line`] for a `%s`-shaped string: each argument fills the next `%s`, in order — the
/// reference's `format`-with-pushed-arguments face of the same catalog rows (the battleground
/// join verdict's map name, the joined-or-left player's name, 1974).
pub(crate) fn keyed_line_s(script: &UiScript, key: &'static str, args: &[&str]) -> Option<Shown> {
let template = script.lua().globals().get::<String>(key).ok()?;
let args: Vec<benilla_ui::strings::Arg<'_>> = args
.iter()
.copied()
.map(benilla_ui::strings::Arg::S)
.collect();
let text = benilla_ui::strings::fill(&template, &args);
(!text.is_empty()).then(|| Shown::keyed(key, text))
}
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and
/// queue the sound its catalog row names.
///
/// Every route into the client's message display ends here — the window queues that resolve a
/// [`UiError`] (the questgiver refusals 0669, the auction house 1523, the party quest-share 1733),
/// the client-local [`UiErrorKeys`], and the already-resolved [`UiErrorTexts`] off the wire. They
/// differ only in how they *build* a line and where the kind comes from; what happens to a built
/// one is this, once, so the three cannot drift apart.
///
/// The sound is **queued, not played** — this runs deep inside the UI feeds, which have no audio
/// resources and should not grow them; `crate::sound::message` drains the queue after the input
/// pass, on the same frame (decision 1815).
///
/// `who` is the caller's module tag for the debug line — which exists because the chat path keeps
/// no log of its own, so without it a live probe can count lines but never read one (0669's
/// in-app leg).
///
/// [`MsgKind::Chat`] lines go out as `CHAT_MSG_SYSTEM`. Three catalog rows ask for
/// `CHAT_MSG_SKILL` instead (the skill-up trio, [`benilla_ui::messages::MessageRecord::chat_type`])
/// and benilla raises none of them yet; when it does, this is the line that has to read the field.
pub(crate) fn show_messages(
script: &mut UiScript,
sink: &mut MessageSink,
who: &str,
lines: impl IntoIterator<Item = Shown>,
) {
for Shown { record, kind, text } in lines {
debug!("{who}: message ({kind:?}) {text:?}");
// The reference sounds a message on the way past its text, not instead of it — so this
// sits with the display and inherits its every guard (an empty line never gets here).
if let Some(record) = record {
sink.sounds.push(record);
}
match kind {
MsgKind::Chat => {
sink.chat
.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
MsgKind::Info => {
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(text)]);
}
MsgKind::Error => {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
}
}
}
/// Everywhere a displayed message lands, besides the script VM: the chat window and the sound
/// queue. One [`SystemParam`] rather than two, because [`show_messages`] writes both on every
/// line and no caller wants one without the other — and because the feeds that raise messages are
/// already at Bevy's parameter ceiling.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct MessageSink<'w> {
pub(crate) chat: ResMut<'w, ChatLog>,
pub(crate) sounds: ResMut<'w, MessageSounds>,
}
/// `UNIT_FIELD_FLAGS` bits the attack-start validator refuses on, paired with the message each
/// raises — read at `0x612eec`+ in the binary's own test order (wow-re
/// `object-layer/scratch/pet-command-validators.md` §2). All four are crowd control: the actor is
/// not refusing, it is unable.
///
/// Each has a second face in the reference — errorId `0xa9`
/// `ERR_ATTACK_PREVENTED_BY_MECHANIC_S`, which substitutes a resolved mechanic name — chosen by
/// five `0x6e9…` resolvers whose bodies wow-re records as **not carved**. We raise the plain form
/// only; the fill is a strictly better message for the same refusal, never a different one.
const ATTACK_FLAG_REFUSALS: [(u32, &str); 4] = [
(0x0004_0000, "ERR_ATTACK_STUNNED"),
(0x0002_0000, "ERR_ATTACK_PACIFIED"),
(0x0080_0000, "ERR_ATTACK_FLEEING"),
(0x0040_0000, "ERR_ATTACK_CONFUSED"),
];
/// Phase A of the shared attack-start validator `0x612df0` — **the actor's own eligibility**
/// (wow-re `object-layer/scratch/pet-command-validators.md` §2, carved 2026-08-05; it supersedes
/// the mounted-only fragment decision 0481 built from the one gate that was known then).
///
/// `0x612df0(ecx = actor, &outGuid)` is ONE function with three call sites, and **the actor is
/// whoever the caller passes in `ecx`** — the player for the melee attack-start router `0x6131aa`,
/// and **the pet** for the pet bar's ATTACK arm (`0x4bd40d` passes `edi`, the pet object). Every
/// gate below reads the actor's own descriptor, so one function answers both; that is why this
/// takes an actor rather than reaching for the self store the way the fragment did.
///
/// A veto is total: `EAX = 0`, one of the ten consecutive `ERR_ATTACK_*` registry ids `0xa0`–`0xa9`
/// through `CGGameUI::DisplayError`, and **no packet at all** — on the pet arm the refusal's
/// `0x4bd414 je 0x4bd4c6` lands on the function epilogue, not on the shared send.
///
/// Order is the binary's, and it is observable: a dead **and** mounted actor says "Can't attack
/// while dead."
///
/// `dead` is `0x605f30(actor)`, which for any non-player actor — a pet never carries the player
/// typemask bit — degenerates to exactly `health <= 0`. Its further leg for a *player* actor
/// (`[[obj+0xe68]+8]` bit 4, reached only when health is positive, so plainly the ghost state) is
/// byte-read but unnamed in wow-re's note, so it is left to the caller: pass `dead` yourself when
/// you know more than the health field does.
pub(crate) fn attack_actor_refusal(
actor: Option<&ObjectStore>,
self_guid: Option<u64>,
errors: &mut UiErrorKeys,
) -> bool {
let Some(key) = attack_actor_blocked(actor, self_guid) else {
return false;
};
debug!("attack refused locally by the actor's own state — {key}");
errors.0.push(UiError::key(key));
true
}
/// The same ladder **without the message** — which condition blocks the swing, or `None`.
///
/// It exists because `0x612df0` is not on every attack-start path, and 1851's §5 pinned which:
/// its three callers image-wide are the pet-attack command (`0x4bd40d`), the Attack
/// action/keybind (`0x6131aa`) and TryCast (`0x6e4efb`) — and **not** the world right-click.
/// That click runs `0x60bea0` → `0x60c247 call 0x5ecb70`, whose whole extent
/// `[0x5ecb70, 0x5ecda3)` contains no `call 0x496720` and no `push` of `0xa0`–`0xa9`: it has its
/// own short, silent conjunct set (`0x5ecc06` is its mounted bail) and every failure lands on
/// `0x5ecc37`, which bails or stops attacking. **A right-click on a hostile shows no error text
/// under any condition** — not mounted, dead, charmed, stunned, pacified, fleeing or confused.
///
/// So the click asks this, and the bar asks [`attack_actor_refusal`]. The *predicate* is still
/// `0x612df0`'s rather than `0x5ecb70`'s own — the two sets overlap but are not identical, and
/// transcribing `0x5ecb70`'s is its own slice — but the **silence** is now the verified law, and
/// the swing is still suppressed, which is the half that keeps us off the wire.
pub(crate) fn attack_actor_blocked(
actor: Option<&ObjectStore>,
self_guid: Option<u64>,
) -> Option<&'static str> {
// No descriptor is no refusal. The reference resolves the actor object first and skips the
// whole chain when it cannot (`0x4bd403`: no pet ⇒ send unmodified) — an un-streamed unit is
// not an ineligible one.
let fields = &actor?.0;
let key = if fields.unit_health().is_some_and(|h| h == 0) {
"ERR_ATTACK_DEAD"
} else if fields
.unit_charmed_by()
.is_some_and(|g| Some(g) != self_guid)
{
// Charmed by somebody who is not us. Charmed BY US is not a refusal — a mind-controlled
// unit is one you are allowed to swing with.
"ERR_ATTACK_CHARMED"
} else if let Some((_, key)) = ATTACK_FLAG_REFUSALS
.iter()
.find(|(bit, _)| fields.unit_flags() & bit != 0)
{
key
} else if fields.unit_mount_display_id() > 0 {
"ERR_ATTACK_MOUNTED"
} else {
return None;
};
Some(key)
}
/// The ref's pre-send totem/reagent possession check — `CheckReagentsAndTotems 0x6e4000`,
/// byte-verified (decision 0552; wow-re `cast-fail-strings.md` "Loose end 2"): TryCast runs it
/// for EVERY cast path (action bar, Lua, the GameObject-use opener) **before any packet is
/// built**. Totems first (2 slots, a bag **presence** test — the Mining Pick / Skinning Knife /
/// Thieves' Tools tools), then reagents (8 slots, a bag **count** test). The first failing slot
/// refuses the cast LOCALLY — reason `0x78`/`0x5c` into [`CastErrors`] (whose drain fills
/// "Requires <item>" / "Missing reagent: <item>") and **no send** — which is the only way
/// "Requires Mining Pick" can ever appear: vmangos answers a sent pickless cast with
/// `ITEM_GONE` ("Item is gone"), and its own source marks the totem reason "client-side only".
/// A missing self store skips the check, like the ref's `IsActivePlayer` gate (the client can
/// only see its own bags). Returns `true` when the cast must be refused.
pub(crate) fn reagent_totem_refusal(
spell_id: u32,
def: Option<&benilla_formats::SpellDisplay>,
self_store: Option<&ObjectStore>,
items: &crate::items::Items,
errors: &mut CastErrors,
) -> bool {
let (Some(d), Some(store)) = (def, self_store) else {
return false;
};
// Totems before reagents — the ref's in-function loop order.
let reason = if first_missing_totem(d, store, items).is_some() {
0x78
} else if first_short_reagent(d, store, items).is_some() {
0x5c
} else {
return false;
};
debug!("cast {spell_id} refused locally — missing totem/reagent ({reason:#04x})");
errors.push_local(spell_id, reason);
true
}
/// The first totem (tool) slot whose item is absent from our bags — the `0x6e4000` totem loop's
/// failing slot, re-derived (a presence test: any owned count satisfies).
pub(super) fn first_missing_totem(
d: &benilla_formats::SpellDisplay,
store: &ObjectStore,
items: &crate::items::Items,
) -> Option<u32> {
d.totems
.iter()
.copied()
.filter(|&t| t != 0)
.find(|&t| count_of(&store.0, items, t, InventoryScope::CARRIED) == 0)
}
/// The first reagent slot whose owned count falls short — the `0x6e4000` reagent loop's failing
/// slot, re-derived.
pub(super) fn first_short_reagent(
d: &benilla_formats::SpellDisplay,
store: &ObjectStore,
items: &crate::items::Items,
) -> Option<u32> {
d.reagents
.iter()
.copied()
.filter(|&(id, _)| id != 0)
.find(|&(id, n)| count_of(&store.0, items, id, InventoryScope::CARRIED) < n)
.map(|(id, _)| id)
}
/// The (dis)mount result code → its `ERR_MOUNT_*`/`ERR_DISMOUNT_*` GlobalStrings key. The code
/// tables are vmangos `UnitDefines.h` (`UnitMountResult`/`UnitDismountResult`); every key was
/// verified present in the shipped 1.12 `GlobalStrings.lua` (patch-2.MPQ, extracted 2026-07-17)
/// — including the deliberately-shipped `ERR_MOUNT_OTHER` = "UNKNOWN MOUNT ERROR" and the
/// INTERNAL-ERROR dismount strings. The success codes (10 mounting / 3 dismounting) are silent.
pub(super) fn mount_result_key(mount: bool, code: u32) -> Option<&'static str> {
if mount {
match code {
0 => Some("ERR_MOUNT_INVALIDMOUNTEE"),
1 => Some("ERR_MOUNT_TOOFARAWAY"),
2 => Some("ERR_MOUNT_ALREADYMOUNTED"),
3 => Some("ERR_MOUNT_NOTMOUNTABLE"),
4 => Some("ERR_MOUNT_NOTYOURPET"),
5 => Some("ERR_MOUNT_OTHER"),
6 => Some("ERR_MOUNT_LOOTING"),
7 => Some("ERR_MOUNT_RACECANTMOUNT"),
8 => Some("ERR_MOUNT_SHAPESHIFTED"),
9 => Some("ERR_MOUNT_FORCEDDISMOUNT"),
_ => None, // 10 = OK; anything past the table stays the debug log's business
}
} else {
match code {
0 => Some("ERR_DISMOUNT_NOPET"),
1 => Some("ERR_DISMOUNT_NOTMOUNTED"),
2 => Some("ERR_DISMOUNT_NOTYOURPET"),
_ => None, // 3 = OK
}
}
}
#[cfg(test)]
mod shown_tests {
use super::{MessageSink, MessageSounds, MsgKind, Shown};
use crate::ui_chat::ChatLog;
use benilla_ui::script::UiScript;
use bevy::prelude::*;
/// The producer half of the message-sound path (decision 1815), driven through the real
/// [`super::show_messages`] rather than around it: a **keyed** line queues its catalog row for
/// `crate::sound::message` to sound, an **unkeyed** one queues nothing (the wire's own text has
/// no record), and a key the client does not have queues nothing either — the same fallback
/// that makes it a red line.
#[test]
fn a_keyed_line_queues_its_row_and_an_unkeyed_one_queues_nothing() {
let mut world = World::new();
world.init_resource::<ChatLog>();
world.init_resource::<MessageSounds>();
let mut state = bevy::ecs::system::SystemState::<MessageSink>::new(&mut world);
let mut script = UiScript::new().expect("VM");
{
let mut sink = state.get_mut(&mut world);
super::show_messages(
&mut script,
&mut sink,
"test",
[
// A voiced refusal, a cue row, a chat row that still speaks, and one with
// neither — every arm of the record's two sound columns.
Shown::keyed("ERR_OUT_OF_MANA", "Not enough mana".into()),
Shown::keyed("ERR_NEWTAXIPATH", "New flight path discovered!".into()),
Shown::keyed("ERR_ALREADY_IN_GROUP_S", "Bob is already in a group".into()),
Shown::keyed("ERR_CANT_STACK", "That item cannot stack".into()),
Shown::unkeyed(MsgKind::Error, "the server said so".into()),
Shown::keyed("ERR_NOT_A_REAL_MESSAGE", "invented".into()),
],
);
}
state.apply(&mut world);
let queued = world.resource::<MessageSounds>().queued().to_vec();
let keys: Vec<&str> = queued.iter().map(|r| r.key).collect();
assert_eq!(
keys,
[
"ERR_OUT_OF_MANA",
"ERR_NEWTAXIPATH",
"ERR_ALREADY_IN_GROUP_S",
"ERR_CANT_STACK",
],
"the unkeyed line and the unknown key have no row to sound"
);
// …and what each of them asks the drain for.
assert_eq!(queued[0].type_tag, 0x0f, "the voice line");
assert_eq!(queued[1].sound, Some("TaxiNodeDiscovered"), "the named cue");
assert_eq!(queued[2].kind, MsgKind::Chat, "a chat row sounds too");
assert_eq!(
(queued[3].type_tag, queued[3].sound),
(0x44, None),
"queued and silent — the drain's own no-op"
);
}
}
#[cfg(test)]
mod mount_error_tests {
use super::mount_result_key;
#[test]
fn success_codes_are_silent_and_failures_map() {
assert_eq!(mount_result_key(true, 10), None); // MOUNTRESULT_OK
assert_eq!(mount_result_key(false, 3), None); // DISMOUNTRESULT_OK
assert_eq!(mount_result_key(true, 2), Some("ERR_MOUNT_ALREADYMOUNTED"));
assert_eq!(mount_result_key(true, 8), Some("ERR_MOUNT_SHAPESHIFTED"));
assert_eq!(mount_result_key(false, 1), Some("ERR_DISMOUNT_NOTMOUNTED"));
// Off-table codes stay the debug log's business — no red line.
assert_eq!(mount_result_key(true, 11), None);
assert_eq!(mount_result_key(false, 4), None);
}
/// The RUNTIME leg on the real data (the `cast_fail` pattern): every key this table can
/// emit resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` — the guard
/// against a typo'd key silently swallowing the red line. Skips without client data.
#[test]
fn every_mount_key_resolves_in_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
for (mount, codes) in [(true, 0..=9), (false, 0..=2)] {
for code in codes {
let key = mount_result_key(mount, code).expect("failure code maps");
let text = g(key).unwrap_or_default();
assert!(!text.is_empty(), "{key} missing from GlobalStrings");
}
}
assert_eq!(
g("ERR_MOUNT_ALREADYMOUNTED").unwrap(),
"You're already mounted!"
);
assert_eq!(g("ERR_DISMOUNT_NOTMOUNTED").unwrap(), "You're not mounted!");
}
}
#[cfg(test)]
mod ui_error_tests {
use super::{ui_error_text, FillArg, UiError};
fn filled(key: &'static str, s: Option<&str>, d: Option<u32>) -> UiError {
let mut args = Vec::new();
args.extend(s.map(|s| FillArg::S(s.to_string())));
args.extend(d.map(|d| FillArg::D(i64::from(d))));
UiError::args(key, args)
}
/// The DisplayError argText substitution against a fake getter: `%s` then `%d`, key-absent
/// and key-empty both silent (the GlobalStrings data-suppression face).
#[test]
fn fills_substitute_and_absent_keys_are_silent() {
let get = |key: &str| match key {
"REQ_S" => Some("Requires %s".to_string()),
"REQ_SI" => Some("Requires %s %d".to_string()),
"PLAIN" => Some("Can't attack while mounted.".to_string()),
"EMPTY" => Some(String::new()),
_ => None,
};
let t = |e: &UiError| ui_error_text(e, &get);
assert_eq!(
t(&filled("REQ_S", Some("Herbalism"), None)).as_deref(),
Some("Requires Herbalism")
);
assert_eq!(
t(&filled("REQ_SI", Some("Mining"), Some(100))).as_deref(),
Some("Requires Mining 100")
);
assert_eq!(
t(&UiError::key("PLAIN")).as_deref(),
Some("Can't attack while mounted.")
);
assert_eq!(t(&UiError::key("EMPTY")), None);
assert_eq!(t(&UiError::key("ABSENT")), None);
}
/// The RUNTIME leg on the real data (the `cast_fail`/mount pattern): every GlobalStrings
/// key the lock-refusal toasts (decision 0545, wow-re cursor-system.md §8.8) and the totem
/// fill can emit resolves in the shipped 1.12 `GlobalStrings.lua`, with the exact ref-quoted
/// formats — the guard against a typo'd key silently swallowing the red line. Skips without
/// client data.
#[test]
fn every_lock_refusal_key_resolves_in_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
assert_eq!(g("ERR_USE_LOCKED_WITH_SPELL_S").unwrap(), "Requires %s");
assert_eq!(
g("ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI").unwrap(),
"Requires %s %d"
);
assert_eq!(g("ERR_USE_LOCKED_WITH_ITEM_S").unwrap(), "Requires %s");
assert_eq!(g("ERR_USE_LOCKED").unwrap(), "Item is locked.");
assert_eq!(g("ERR_DOOR_LOCKED").unwrap(), "The door is locked.");
assert_eq!(
g("ERR_BUTTON_LOCKED").unwrap(),
"That has already been used."
);
assert_eq!(g("ERR_USE_CANT_OPEN").unwrap(), "You can't open that.");
// The ENGINE's own by-key refusal (`benilla_ui`'s `place_action`, errorId `0x9e`). The
// key is the load-bearing half: an unresolvable one makes `ui_error_text` answer None and
// the refusal quietly goes back to being the divergence 0666 named.
assert_eq!(
g("ERR_PASSIVE_ABILITY").unwrap(),
"You can't put a passive ability in the action bar."
);
// The wire-side totem fill's template (feed_actions' 0x78 arm): "Requires Mining Pick".
assert_eq!(g("SPELL_FAILED_TOTEMS").unwrap(), "Requires %s");
// End to end through the formatter — the two gathering lines the director will see.
let herb = filled("ERR_USE_LOCKED_WITH_SPELL_S", Some("Herbalism"), None);
assert_eq!(
ui_error_text(&herb, &g).as_deref(),
Some("Requires Herbalism")
);
let vein = filled(
"ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
Some("Mining"),
Some(155),
);
assert_eq!(
ui_error_text(&vein, &g).as_deref(),
Some("Requires Mining 155")
);
}
}
#[cfg(test)]
mod attack_actor_tests {
use super::*;
use benilla_protocol::ObjectFields;
const HEALTH: u16 = 22;
const FLAGS: u16 = 46;
const CHARMEDBY: u16 = 10;
const MOUNT: u16 = 133;
fn actor(pairs: &[(u16, u32)]) -> ObjectStore {
// A live, unowned, unmounted, unimpaired unit unless a case says otherwise.
let mut all = vec![(HEALTH, 100)];
all.extend_from_slice(pairs);
ObjectStore(ObjectFields::from_pairs(&all))
}
fn refusal(store: Option<&ObjectStore>, self_guid: Option<u64>) -> Option<&'static str> {
let mut errors = UiErrorKeys::default();
let refused = attack_actor_refusal(store, self_guid, &mut errors);
assert_eq!(
refused,
!errors.0.is_empty(),
"a refusal and a message are the same event — the ref raises one per veto"
);
errors.0.first().map(|e| e.key)
}
/// Every gate of `0x612df0`'s Phase A, each on its own, against an otherwise-healthy actor.
#[test]
fn each_actor_gate_raises_its_own_error() {
assert_eq!(
refusal(Some(&actor(&[])), Some(7)),
None,
"a fit actor swings"
);
assert_eq!(
refusal(Some(&actor(&[(HEALTH, 0)])), Some(7)),
Some("ERR_ATTACK_DEAD")
);
assert_eq!(
refusal(Some(&actor(&[(MOUNT, 1147)])), Some(7)),
Some("ERR_ATTACK_MOUNTED")
);
for (bit, key) in ATTACK_FLAG_REFUSALS {
assert_eq!(
refusal(Some(&actor(&[(FLAGS, bit)])), Some(7)),
Some(key),
"unit flag {bit:#x}"
);
}
// An unrelated flag bit is not a refusal — the mask is four specific bits, not "any flag".
assert_eq!(refusal(Some(&actor(&[(FLAGS, 0x1000)])), Some(7)), None);
}
/// `0x612e33`'s charm test compares against the ACTIVE PLAYER's guid, so being charmed **by
/// us** is not a refusal — that is the whole point of mind control, and reading the field as a
/// plain "is charmed" boolean would make every controlled unit unable to swing.
#[test]
fn charmed_by_us_still_swings_and_charmed_away_does_not() {
let mine = actor(&[(CHARMEDBY, 7), (CHARMEDBY + 1, 0)]);
assert_eq!(refusal(Some(&mine), Some(7)), None);
let theirs = actor(&[(CHARMEDBY, 9), (CHARMEDBY + 1, 0)]);
assert_eq!(refusal(Some(&theirs), Some(7)), Some("ERR_ATTACK_CHARMED"));
// Not knowing our own guid yet cannot make somebody else's charm look like ours.
assert_eq!(refusal(Some(&theirs), None), Some("ERR_ATTACK_CHARMED"));
}
/// The order is the binary's, and it is observable: the FIRST gate to fail names the message,
/// so a dead-and-mounted actor says "Can't attack while dead."
#[test]
fn the_first_failing_gate_names_the_message() {
let both = actor(&[(HEALTH, 0), (MOUNT, 1147), (FLAGS, 0x0004_0000)]);
assert_eq!(refusal(Some(&both), Some(7)), Some("ERR_ATTACK_DEAD"));
// …and with the death removed, the flag block still precedes the mount check.
let cc = actor(&[(MOUNT, 1147), (FLAGS, 0x0004_0000)]);
assert_eq!(refusal(Some(&cc), Some(7)), Some("ERR_ATTACK_STUNNED"));
}
/// An un-streamed actor is not an ineligible one: the reference skips the whole chain when it
/// cannot resolve the object (`0x4bd403` — no pet ⇒ send unmodified). A descriptor that simply
/// has not sent a health field yet must not read as a corpse.
#[test]
fn an_unresolved_actor_is_never_a_refusal() {
assert_eq!(refusal(None, Some(7)), None);
assert_eq!(
refusal(Some(&ObjectStore(ObjectFields::default())), Some(7)),
None
);
}
/// The RUNTIME leg (the mount/lock-refusal pattern): all ten of the consecutive `ERR_ATTACK_*`
/// registry ids `0xa0`–`0xa9` resolve to non-empty strings in the shipped 1.12
/// `GlobalStrings.lua`. This is the guard against a typo'd key turning a refusal into a silent
/// one — a veto that shows nothing is indistinguishable from a click that did nothing.
#[test]
fn every_attack_error_key_resolves_in_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
for key in [
"ERR_NO_ATTACK_TARGET", // 0xa0
"ERR_INVALID_ATTACK_TARGET", // 0xa1
"ERR_ATTACK_STUNNED", // 0xa2
"ERR_ATTACK_PACIFIED", // 0xa3
"ERR_ATTACK_MOUNTED", // 0xa4
"ERR_ATTACK_FLEEING", // 0xa5
"ERR_ATTACK_CONFUSED", // 0xa6
"ERR_ATTACK_CHARMED", // 0xa7
"ERR_ATTACK_DEAD", // 0xa8
"ERR_ATTACK_PREVENTED_BY_MECHANIC_S", // 0xa9
] {
assert!(!g(key).unwrap_or_default().is_empty(), "{key} missing");
}
// The one wow-re quotes from the file, as a spot check that the ids line up with the keys.
assert_eq!(g("ERR_ATTACK_DEAD").unwrap(), "Can't attack while dead.");
}
}
#[cfg(test)]
mod totem_reagent_tests {
use super::*;
use benilla_formats::SpellDisplay;
use benilla_protocol::ObjectFields;
fn store() -> ObjectStore {
// Empty bags: no pack fields streamed → every count reads 0, everything is "missing".
ObjectStore(ObjectFields::default())
}
fn spell(totems: [u32; 2], reagents: [(u32, u32); 8]) -> SpellDisplay {
SpellDisplay {
totems,
reagents,
..Default::default()
}
}
/// The pre-send check's routing (`0x6e4000`, decision 0552) against empty bags: a totem
/// spell refuses 0x78, a reagent spell 0x5c, totems win when both lack (the ref's loop
/// order), a materials-free spell passes, and absent def/store skip the check (the ref's
/// `IsActivePlayer` gate) — the cast then goes out for the server to judge.
#[test]
fn missing_materials_refuse_with_the_refs_reasons() {
let items = crate::items::Items::default();
let st = store();
let mining = spell([2901, 0], [(0, 0); 8]);
let mut errors = CastErrors::default();
assert!(reagent_totem_refusal(
2575,
Some(&mining),
Some(&st),
&items,
&mut errors
));
assert_eq!(errors.0.as_slice(), &[CastFail::local(2575, 0x78)]);
let mut reagents = [(0, 0); 8];
reagents[0] = (17056, 1); // Slow Fall's Light Feather
let slow_fall = spell([0, 0], reagents);
let mut errors = CastErrors::default();
assert!(reagent_totem_refusal(
130,
Some(&slow_fall),
Some(&st),
&items,
&mut errors
));
assert_eq!(errors.0.as_slice(), &[CastFail::local(130, 0x5c)]);
let both = spell([2901, 0], reagents);
let mut errors = CastErrors::default();
assert!(reagent_totem_refusal(
1,
Some(&both),
Some(&st),
&items,
&mut errors
));
assert_eq!(errors.0.as_slice(), &[CastFail::local(1, 0x78)]);
let plain = spell([0, 0], [(0, 0); 8]);
let mut errors = CastErrors::default();
assert!(!reagent_totem_refusal(
133,
Some(&plain),
Some(&st),
&items,
&mut errors
));
assert!(!reagent_totem_refusal(
2575,
None,
Some(&st),
&items,
&mut errors
));
assert!(!reagent_totem_refusal(
2575,
Some(&mining),
None,
&items,
&mut errors
));
assert!(errors.0.is_empty());
}
/// The failing-slot selection the fill re-derives: the first MISSING totem / first SHORT
/// reagent (against empty bags, the first nonzero of each).
#[test]
fn first_failing_slot_is_named() {
let items = crate::items::Items::default();
let st = store();
let mut reagents = [(0, 0); 8];
reagents[1] = (17056, 1);
let d = spell([0, 7005], reagents);
assert_eq!(first_missing_totem(&d, &st, &items), Some(7005));
assert_eq!(first_short_reagent(&d, &st, &items), Some(17056));
let none = spell([0, 0], [(0, 0); 8]);
assert_eq!(first_missing_totem(&none, &st, &items), None);
assert_eq!(first_short_reagent(&none, &st, &items), None);
}
}