trades answer the reference's way, and the message catalog is a table

- an incoming trade request is answered by the reference's eight-leg ladder, not by reflex — before, `CMSG_BEGIN_TRADE` went back the same frame it decoded, so any player in range could put a window on your screen mid-cinematic, mid-loading, while mind-controlled, at an auctioneer, or from your own ignore list; three legs answer with something other than "busy" (the ignore list sends `CMSG_IGNORE_TRADE`, two legs send no packet at all), Block Trades ships as the CVar and options row it is in 1.12 with its refusal on the chat line the catalog says, and the consent popup that was built came back out — 1.12 signals the trade dialog from nowhere, so asking first would be a divergence every player and addon feels, and the reference's answer to an unwanted trade is already in the ladder
- the client's message catalog ships as a generated table instead of being read by eye: every refusal, notice and confirmation goes through one dispatcher taking only a message id into a 465-record registry, benilla names 163 of the keys, and the surface does not follow the key names — the byte-exact sweep settles 170 chat / 28 yellow / 267 red, none on the console arm, with the skill-up trio speaking CHAT_MSG_SKILL rather than system; a message's surface now comes from its record, not its call site, which found `ERR_SPELL_FAILED_NOTUNSHEATHED` painted red where the client paints it yellow (seventeen of our eighteen cast-fail overrides are red; that one is not, and nothing downstream could have known which id the dispatch chose) — and a new test walks the workspace's own source asserting every `ERR_` literal is a real catalog row, because an unresolvable key shows NOTHING and nobody files a bug about a toast they never saw
- starting an attack stands you up — the stand sits after the reference's attackability cluster and before any range logic, so an attackable target out of reach stands you, a friendly NPC does not, and re-issuing an attack you are already making stands you too; the rest of the intended build is proven unnecessary rather than deferred: bare ground cannot stand you on any client (its two requirements are mutually exclusive in both CVar configurations), gossip is proven not to stand, and the loot leg is unproven and left alone
- a kick returns you to the login screen again instead of the client reconnecting indefinitely and taking the account back off whoever kicked it: "nobody is here" is DECLARED now, never inferred — the login environment variables answer "log in without waiting for someone to type", which cannot answer "is anybody here?", so a run acts unattended only on `$WOW_UNATTENDED` (implied by a capture or rig run), with the doubt deliberately falling toward a loud probe timeout over a silent account fight; and the player-tests gate finally resolves the install that sourcing FrameXML off the chain has required, instead of failing red for its own reasons on any shell without `$WOW_DATA` exported

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:20:07 +07:00
co-authored by Claude Fable 5
parent 023c490974
commit 3d0bc38978
37 changed files with 2223 additions and 444 deletions
+19 -3
View File
@@ -635,8 +635,8 @@
OPTIONS_PAGE_ROWS = {
Controls = {
"RowStickyTarget", "RowAutoLoot", "RowInvertMouse", "RowMouseSpeed",
"RowCameraFollowStyle", "RowMaxCameraDistance",
"RowStickyTarget", "RowBlockTrades", "RowAutoLoot", "RowInvertMouse",
"RowMouseSpeed", "RowCameraFollowStyle", "RowMaxCameraDistance",
},
Audio = {
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
@@ -2102,11 +2102,27 @@
self.invert = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAutoLoot" inherits="OptionsCheckboxRowTemplate">
<!-- Block Trades (decision 1764). Seated here because
this page is where 1.12's Basic Options GENERAL box
lands, and Sticky Targeting above is the other row we
ship from it — the reference's own box order runs 45
(Sticky Targeting) down the left column and 14 (this)
down the right, so the two belong adjacent when the
box is flattened to one column. Its tooltip key IS the
reference's, unlike the row above. -->
<Frame name="$parentRowBlockTrades" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "BlockTrades", "Block Trades",
"OPTION_TOOLTIP_BLOCK_TRADES")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAutoLoot" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowBlockTrades" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowBlockTrades" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<!-- No tip: 1.12 has no Auto Loot row, so no
OPTION_TOOLTIP_* for it either (verified — the
5875 GlobalStrings has no AUTO_LOOT key at all,
+12 -10
View File
@@ -425,10 +425,11 @@ fn apply_roster_policy(
}
None
}
// `WOW_CHAR`, or the login smoke's optional third field (decision 1262): the smoke's
// seat exists because `WOW_CHAR` is also an *unattended* marker
// ([`crate::run_mode::unattended_login`]), so using it to reach the world would switch
// the very branch a session test is trying to exercise. Same fast path, no marker.
// `WOW_CHAR`, or the login smoke's optional third field (decision 1262): the seat
// exists because `WOW_CHAR` *was* also an unattended marker, so using it to reach the
// world switched the very branch a session test was trying to exercise. 1769 severed
// that — `WOW_CHAR` now only means "skip the click" — and the seat stays as the
// smoke's own way in. Same fast path either way.
None => std::env::var("WOW_CHAR").ok().or_else(|| {
std::env::var("WOW_LOGIN_SMOKE")
.ok()
@@ -509,13 +510,14 @@ fn apply_roster_policy(
}
None => {
warn!("char select: WOW_CHAR={name} not on this account — showing roster");
// A harness run (env creds, no smoke) can never pick a different row itself —
// parked here it burns its whole wall-clock. Same marker the login arm uses,
// same greppable verdict for leg.sh.
if crate::run_mode::unattended_login()
&& std::env::var_os("WOW_LOGIN_SMOKE").is_none()
// A driverless run can never pick a different row itself — parked here it
// burns its whole wall-clock. Same home as the login arm's verdict, so the
// marker leg.sh greps for is written in exactly one place (1371, 1769).
if std::env::var_os("WOW_LOGIN_SMOKE").is_none()
&& crate::run_mode::fatal_when_driverless(&format!(
"WOW_CHAR={name} is not on this account"
))
{
error!("login: FATAL — WOW_CHAR={name} is not on this account; exiting");
exit.write(AppExit::error());
}
}
+73
View File
@@ -302,6 +302,20 @@ pub(crate) fn start_attack_local(
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
// **Starting an attack stands you up** — `0x5ecc9b call 0x5ed430(0)`, inside this function and
// ahead of everything else it does (decision 1768).
//
// Its position is the content. It sits **after** the attackability cluster
// (`0x5ecc2e`/`0x5ecc35`, whose not-attackable leg diverts to `0x5ecc37` and never reaches the
// stand) — which is why a right-click on a friendly NPC does not stand you — and **before any
// range logic**, `CMSG_ATTACKSWING` being all the way down at `0x5eccfd`. So an attackable
// target out of reach *does* stand you, and so does re-issuing an attack you are already
// making: that skip lands at `0x5ecd78`, 73 bytes past the stand. Hence unconditional here,
// above the swing's own `engaged` fork, rather than inside it.
//
// benilla's callers own the attackability walk (see this function's header), so by the time we
// are called the diverting leg has already been taken elsewhere.
commands.write_message(crate::player::StandStateRequest { state: 0 });
if !engaged || stop_in_flight {
let _ = net
.0
@@ -1122,6 +1136,65 @@ fn find_resolved<'a>(
anims.find(resolved_id(anims, id, catalog))
}
#[cfg(test)]
mod attack_stand_tests {
use super::*;
/// **`StartAttack` stands a seated player** — `0x5ecc9b`, and unconditionally within this
/// function (decision 1768).
///
/// `engaged = true` is the case worth pinning. It takes `0x5ecb70`'s skip, which jumps to the
/// tail at `0x5ecd78` — **73 bytes past the stand** — so re-issuing an attack you are already
/// making still stands you, and no `CMSG_ATTACKSWING` goes out. A stand written only on the
/// swing's own leg would miss exactly this case.
///
/// This is the behaviour that replaces the turn-driven stand 1766 deleted: what stands a
/// seated player on a right-click is the *action* the click dispatches, not the gesture.
#[test]
fn starting_an_attack_stands_you_even_when_the_swing_is_skipped() {
let mut app = App::new();
app.add_message::<SheathRequest>()
.add_message::<crate::player::StandStateRequest>();
// A dead-letter net channel: the send is `let _ = …` and no packet is under test here.
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.insert_resource(crate::ui_action::AutoRepeatActive(None));
let me = app.world_mut().spawn_empty().id();
app.add_systems(
Update,
move |mut commands: Commands,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut sheath: MessageWriter<SheathRequest>,
net: Res<crate::net::NetCommands>| {
start_attack_local(
me,
0xdead_beef,
true, // already engaged — the skip leg
false,
&mut auto_repeat,
&mut sheath,
&mut commands,
&net,
);
},
);
app.update();
let stands: Vec<u8> = app
.world_mut()
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
.drain()
.map(|r| r.state)
.collect();
assert_eq!(
stands,
vec![0],
"StartAttack asks for stand state 0 exactly once, even on the leg that sends no swing"
);
}
}
#[cfg(test)]
mod nock_latch_tests {
use super::*;
+27 -1
View File
@@ -103,6 +103,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// pitch invert). `autoLootDefault` is era's — no 1.12 CVar exists, vanilla only had the
// shift gesture — default off, like era's engine registrar.
("deselectOnClick", "1"),
// *Block Trades* (decision 1764) — 1.12's own `BlockTrades` (`0x842fbc`), the General-box
// checkbox at index 14 whose tooltip is "Block all incoming trade requests.". Registered
// **"0"**: the reference's own `0x4bf7bc` leg only refuses when the CVar is set, so an
// unset/absent value has to mean "trades allowed" — and a client that shipped with trades
// blocked would refuse every trade until the player found the box. The knob is
// [`crate::ui_trade::BlockTrades`], read by the incoming-request answerer.
("BlockTrades", "0"),
// 1.12's own `autoSelfCast` (`0x870dc0`) — a friendly cast that binds nothing falls back to
// the caster. The behaviour has been here since the cast arm landed, welded to a Resource
// default; it is a CVar now because 1.12's `TOGGLEAUTOSELFCAST` binding is `GetCVar`/`SetCVar`
@@ -567,6 +574,7 @@ pub(crate) struct KnobParams<'w> {
tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>,
pane_rate: ResMut<'w, PaneRate>,
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
block_trades: ResMut<'w, crate::ui_trade::BlockTrades>,
auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>,
realmlist: ResMut<'w, crate::realmlist::Realmlist>,
}
@@ -601,6 +609,7 @@ impl KnobParams<'_> {
tex_filter: &mut self.tex_filter,
pane_rate: &mut self.pane_rate,
guild_notify: &mut self.guild_notify,
block_trades: &mut self.block_trades,
auto_self_cast: &mut self.auto_self_cast,
realmlist: &mut self.realmlist,
}
@@ -631,6 +640,7 @@ struct Knobs<'a> {
tex_filter: &'a mut benilla_assets::TexFilterSetting,
pane_rate: &'a mut PaneRate,
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
block_trades: &'a mut crate::ui_trade::BlockTrades,
auto_self_cast: &'a mut crate::ui_action::AutoSelfCast,
realmlist: &'a mut crate::realmlist::Realmlist,
}
@@ -721,6 +731,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"showlootspam" => knobs.loot.show_loot_spam = v != 0.0,
// Guild Member Alert (1589) — conjunct 2 of the sign-on/sign-off line's condition.
"guildmembernotify" => knobs.guild_notify.0 = v != 0.0,
"blocktrades" => knobs.block_trades.0 = v != 0.0,
// The panel's 0/1/2 lands as the density multiplier ×1/×2/×3; the clamp is the 1.12
// slider's own range (an off-grid hand-edit rides between stops, like every slider).
"worlddetail" => knobs.clutter.density = v.clamp(0.0, 2.0) + 1.0,
@@ -1004,6 +1015,7 @@ fn sync_cvars(
render_scale,
pane_rate,
guild_notify,
block_trades,
auto_self_cast,
msaa,
msaa_formats,
@@ -1041,7 +1053,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 41] = [
let session: [(&str, String); 42] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1067,6 +1079,7 @@ fn sync_cvars(
("autoLootDefault", flag(loot.auto_loot)),
("showLootSpam", flag(loot.show_loot_spam)),
("guildMemberNotify", flag(guild_notify.0)),
("BlockTrades", flag(block_trades.0)),
("UnitNamePlayer", flag(names.player)),
("UnitNameNPC", flag(names.npc)),
("UnitNameOwn", flag(names.own)),
@@ -1332,6 +1345,13 @@ mod tests {
crate::ui_guild::GuildMemberNotify::default().0
);
assert_eq!(d["guildMemberNotify"], 0.0, "the binary registers \"0\"");
// Block Trades (1764) welds the same way, and its "0" is behaviour: the refusal leg only
// fires when the CVar is SET, so an unset value must read as "trades allowed".
assert_eq!(
d["BlockTrades"] != 0.0,
crate::ui_trade::BlockTrades::default().0
);
assert_eq!(d["BlockTrades"], 0.0, "an unset BlockTrades allows trades");
// The name trio (0992) welds to NameConfig's defaults the same way.
let names = NameConfig::default();
assert_eq!(d["UnitNamePlayer"] != 0.0, names.player);
@@ -1394,6 +1414,7 @@ mod tests {
let mut video = VideoConfig::default();
let mut pane_rate = PaneRate::default();
let mut guild_notify = crate::ui_guild::GuildMemberNotify::default();
let mut block_trades = crate::ui_trade::BlockTrades::default();
// Literal, not Default: MsaaSetting::default() reads $WOW_MSAA.
let mut msaa = MsaaSetting { samples: 1 };
// What an Apple GPU answers for the trio we render into (Rgba16Float / Depth32Float /
@@ -1433,6 +1454,7 @@ mod tests {
render_scale: &mut render_scale,
pane_rate: &mut pane_rate,
guild_notify: &mut guild_notify,
block_trades: &mut block_trades,
msaa: &mut msaa,
tex_filter: &mut tex_filter,
msaa_formats: &msaa_formats,
@@ -1552,6 +1574,9 @@ mod tests {
// Guild Member Alert (1589) — the row that ships OFF, so its ON is the interesting write.
assert!(apply_to_knobs("guildMemberNotify", "1", &mut knobs));
assert!(knobs.guild_notify.0);
// Block Trades (1764) — the other row that ships OFF; its ON is what refuses a trade.
assert!(apply_to_knobs("BlockTrades", "1", &mut knobs));
assert!(knobs.block_trades.0);
// The name trio lands on its gates (0992).
assert!(apply_to_knobs("UnitNameNPC", "0", &mut knobs));
assert!(!knobs.names.npc);
@@ -1710,6 +1735,7 @@ mod tests {
))
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.init_resource::<crate::ui_trade::BlockTrades>()
.init_resource::<crate::ui_action::AutoSelfCast>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
+56 -38
View File
@@ -12,8 +12,8 @@
//!
//! **A session that is lost is over** (decision 1262): the reference's `GlueParent.lua` answers
//! `DISCONNECTED_FROM_SERVER` with `SetGlueScreen("login")` + `GlueDialog_Show("DISCONNECTED")`,
//! and so does this. 0065's seamless reconnect survives only where nobody is here to type — an
//! unattended run ([`crate::run_mode::unattended_login`]) — because a client that
//! and so does this. 0065's seamless reconnect survives only where the run has *declared* that
//! nobody is here ([`crate::run_mode::unattended`], decision 1769) — because a client that
//! re-authenticates on its own takes the account back off whoever just displaced it.
//!
//! Module split: this file (state, policy, input, dialogs, the saved-account persistence),
@@ -456,16 +456,10 @@ fn drive_policy(
// Those failures exit non-zero instead, on one greppable marker — "login: FATAL" — that
// leg.sh keys on (decision 1371).
//
// **"May" is the whole correction.** This flag is derived from the environment
// ([`crate::run_mode::unattended_login`] — any of `$WOW_USER`/`$WOW_PASS`/`$WOW_CHAR`), and
// the environment cannot see who is in the room. The director keeps `$WOW_CHAR` set to skip
// character select, which made every one of their sessions "a harness" — so a **typed**
// password with a typo killed the process instead of showing the dialog the reference shows.
// The env fact is the right answer to "should the client log in without waiting for someone
// to type?" and the wrong answer to "is there anybody here?"; it was answering both. Whether
// an *attempt* was typed is not an inference at all — see `announced` at the use sites.
let harness_env =
crate::run_mode::unattended_login() && std::env::var_os("WOW_LOGIN_SMOKE").is_none();
// **Nobody is here only if the run says so** (decision 1769). Whether the client may end the
// run itself is [`crate::run_mode::fatal_when_driverless`]'s to answer; the two facts this
// scope adds are local ones. The smoke owns its own verdict, so it is never ours to end.
let smoke = std::env::var_os("WOW_LOGIN_SMOKE").is_some();
let empty = GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
@@ -474,10 +468,9 @@ fn drive_policy(
// The login smoke drives its own credentials instead.
if !attempt.intent.env_read {
attempt.intent.env_read = true;
// The same fact a lost session asks about (decision 1262) — read from the one place that
// owns it, so "the harness logs in for us" and "the harness logs back in for us" can never
// be two different answers.
if crate::run_mode::unattended_login() && std::env::var_os("WOW_LOGIN_SMOKE").is_none() {
// Purely "are the credentials in the environment?" (decision 1769) — whether anybody is
// here to *react* is a different fact with a different home, `run_mode::unattended`.
if crate::run_mode::env_login() && std::env::var_os("WOW_LOGIN_SMOKE").is_none() {
let user = std::env::var("WOW_USER").unwrap_or_else(|_| "one".into());
let pass = std::env::var("WOW_PASS").unwrap_or_else(|_| "pone".into());
// The account guard (decision 0649): a vmangos login KICKS whoever holds the account,
@@ -500,8 +493,11 @@ fn drive_policy(
dialog.open_error(&why);
// The refusal is deterministic — the slot is baked into the binary — so the
// run can never get past this screen (the 1371 legs burned 3 × timeout on it).
error!("login: FATAL — account guard refused the only credentials this run has; exiting");
exit.write(AppExit::error());
if crate::run_mode::fatal_when_driverless(
"the account guard refused the only credentials this run has",
) {
exit.write(AppExit::error());
}
}
}
}
@@ -550,13 +546,17 @@ fn drive_policy(
// by definition, and an attended failure gets the reference's answer: the dialog, and
// another go. Only an attempt nobody typed may kill the process.
let typed = attempt.intent.announced;
let fatal = harness_env && !typed;
let may_end_the_run = !typed && !smoke;
if msg.terminal {
warn!("login: {}", msg.reason);
attempt.intent.clear();
dialog.open_error(&msg.reason);
if fatal {
error!("login: FATAL — terminal login failure with nobody at the keyboard ({}); exiting", msg.reason);
if may_end_the_run
&& crate::run_mode::fatal_when_driverless(&format!(
"terminal login failure with nobody at the keyboard ({})",
msg.reason
))
{
exit.write(AppExit::error());
}
continue;
@@ -569,8 +569,11 @@ fn drive_policy(
warn!("login: refused ({refusal:?}, {byte:#04x}) — {}", msg.reason);
attempt.intent.clear();
dialog.open_error(&fail_text(strings, Some(refusal), None));
if fatal {
error!("login: FATAL — refused ({refusal:?}, {byte:#04x}) and no resubmit can change it; exiting");
if may_end_the_run
&& crate::run_mode::fatal_when_driverless(&format!(
"refused ({refusal:?}, {byte:#04x}) and no resubmit can change it"
))
{
exit.write(AppExit::error());
}
}
@@ -1655,31 +1658,46 @@ mod tests {
exited(&mut app, &mut cursor)
}
/// **A typo must not kill the client.**
/// **A typo must not kill the client**, and **env credentials alone must not either.**
///
/// The reported crash: `login: FATAL — refused (code 0x04) … exiting`, on a password typed at
/// the screen. `$WOW_CHAR` was set — the director keeps it to skip character select — which
/// makes [`crate::run_mode::unattended_login`] true and therefore made *every* session "a
/// harness", so a refusal took `AppExit::error()` instead of the reference's dialog. The
/// environment cannot see who is in the room; `announced` can, because only the screen's own
/// submit sets it.
/// the screen, because `$WOW_CHAR` was set and the old predicate read any env credential as
/// "a harness". Two answers, both here, because they close the hole from both ends: whether
/// an *attempt* was typed is direct evidence (`announced`, set by the screen's own submit and
/// by nothing else), and whether the *run* is driverless is a declaration the run makes
/// (`WOW_UNATTENDED`, decision 1769) — never an inference off credentials the director's own
/// launch line carries.
#[test]
fn a_typed_password_refusal_never_exits() {
let _lock = crate::local_state::test_env::ENV_LOCK.lock();
// The harness environment the director actually runs with.
let _char = crate::local_state::test_env::EnvGuard::set("WOW_CHAR", "Somebody");
let _smoke = crate::local_state::test_env::EnvGuard::unset("WOW_LOGIN_SMOKE");
// 1 · The director's actual launch line, verbatim from `.cargo/config.toml`'s example.
// Nothing here says anybody is absent, so nothing may end the run — typed or not.
let _user = crate::local_state::test_env::EnvGuard::set("WOW_USER", "one");
let _pass = crate::local_state::test_env::EnvGuard::set("WOW_PASS", "pone");
let _char = crate::local_state::test_env::EnvGuard::set("WOW_CHAR", "One");
let _decl = crate::local_state::test_env::EnvGuard::unset("WOW_UNATTENDED");
let _cap = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _rig = crate::local_state::test_env::EnvGuard::unset("WOW_RIG");
assert!(
crate::run_mode::unattended_login(),
"the fixture must reproduce the env that made this fatal",
crate::run_mode::env_login() && !crate::run_mode::unattended(),
"the fixture must be the env the director plays in: credentials, nobody declared away",
);
assert!(
!refusal_exits(false),
"an env-credentialled run with a person in it shows the dialog, it does not exit",
);
// 2 · A run that declares itself driverless keeps decision 1371's leg guarantee.
let _decl = crate::local_state::test_env::EnvGuard::set("WOW_UNATTENDED", "1");
assert!(
refusal_exits(false),
"an attempt nobody typed, in a run nobody is in, still exits non-zero",
);
assert!(
!refusal_exits(true),
"a password typed at the screen is attended by definition — dialog, then another go",
);
assert!(
refusal_exits(false),
"an attempt nobody typed still exits non-zero: decision 1371's leg guarantee",
"a password typed at the screen is attended by direct evidence — dialog, another go",
);
}
+7 -6
View File
@@ -31,12 +31,13 @@ pub(crate) fn smoke_character(spec: &str) -> Option<String> {
/// log + exit failure on a refusal — the wrong-password path is provable headlessly.
///
/// **Naming a character keeps the run going into the world instead of exiting** (decision 1262).
/// This is the only headless way to reach the world down the *player's* path: `WOW_CHAR` also
/// enters the world, but setting it makes the run **unattended**
/// ([`crate::run_mode::unattended_login`]), and an unattended run takes the other branch at every
/// session decision — so the attended half was untestable without a person at the keyboard. The
/// pick itself stays `char_select`'s (`apply_roster_policy` reads the third field the same way it
/// reads `WOW_CHAR`); this only declines to exit. Pair with `WOW_PROBE_EXIT_AT` to bound the run.
/// It was the only headless way to reach the world down the *player's* path, back when setting
/// `WOW_CHAR` also made the run unattended and so switched the very branch a session test wanted
/// to exercise; decision 1769 severed that, and `WOW_CHAR` now says nothing about who is in the
/// room. The seat stays because it is the smoke's own way in, and because a run that declares
/// `WOW_UNATTENDED=1` still needs a way to name a character without taking it. The pick itself
/// stays `char_select`'s (`apply_roster_policy` reads the third field the same way it reads
/// `WOW_CHAR`); this only declines to exit. Pair with `WOW_PROBE_EXIT_AT` to bound the run.
#[allow(clippy::too_many_arguments)]
pub(super) fn debug_login_smoke(
state: Res<State<ClientState>>,
+84 -6
View File
@@ -1733,10 +1733,22 @@ pub(crate) enum ClientCommand {
InitiateTrade {
target: u64,
},
/// The target's auto-reply to `BEGIN_TRADE` (`CMSG_BEGIN_TRADE`, empty) — makes the server emit
/// `OPEN_WINDOW` to both sides. Sent by the net bridge the frame it decodes a `BEGIN_TRADE`
/// status (decision 0592 P1; the reference client auto-answers, `TradeHandler.cpp`).
/// **Accept** an incoming trade request (`CMSG_BEGIN_TRADE`, empty) — makes the server emit
/// `OPEN_WINDOW` to both sides. Sent by [`crate::ui_trade::answer_trade_request`], never by the
/// net drain: *whether* to accept is a policy with five inputs and (decision 1764) the player's
/// own answer, so the arm that decodes `BEGIN_TRADE` only records the request.
BeginTrade,
/// Decline an incoming trade request as **ignored** (`CMSG_IGNORE_TRADE`, `0x119`, empty) —
/// leg 2 of the reference's ladder (`0x4bf759` → `0x5d41c0`, its one call site image-wide),
/// and the only leg that answers on a different opcode. vmangos turns it into
/// `TRADE_STATUS_IGNORE_YOU`, so the initiator reads "… is ignoring you" rather than "busy".
IgnoreTrade,
/// **Decline** an incoming trade request as busy (`CMSG_BUSY_TRADE`, `0x118`, empty) — the
/// reference's own refusal sender (`0x5d4150`), which vmangos turns into `TRADE_STATUS_BUSY`
/// to *both* sides. Five of the ladder's eight refusal legs go out on this opcode (decision
/// 1764); the ignore leg uses [`Self::IgnoreTrade`], two legs send nothing at all, and the
/// **player's** own No is [`Self::CancelTrade`], not this.
BusyTrade,
/// Press the Trade button (`CMSG_ACCEPT_TRADE`) — the accept half of the two-sided confirm.
AcceptTrade,
/// Drop your own accept but stay in the trade (`CMSG_UNACCEPT_TRADE`).
@@ -2294,11 +2306,19 @@ impl DisconnectedMessage {
///
/// It is `false` twice over. A [`SessionEnd::LoggedOut`](benilla_protocol::SessionEnd::LoggedOut)
/// teardown is the relist *inside* one session — the roster that follows IS the character
/// select the player asked for. And an unattended run ([`crate::run_mode::unattended_login`])
/// keeps 0065's seamless reconnect: a probe has nobody to press Okay.
/// select the player asked for. And a run that has **declared itself unattended**
/// ([`crate::run_mode::unattended`]) keeps 0065's seamless reconnect: a probe has nobody to
/// press Okay.
///
/// That second clause used to read the *environment* instead
/// ([`crate::run_mode::env_login`] — "any of `$WOW_USER`/`$WOW_PASS`/`$WOW_CHAR`"), which is
/// true of the director's own launch line, so 1262's fix was inert in every session a person
/// was actually in: the kick came, and the client reconnected and took the account back off
/// whoever had displaced it. Decision 1769 — the doubt now falls the other way, and only a run
/// that says it is driverless gets to act instead of a person.
pub(crate) fn new(reason: String, end: benilla_protocol::SessionEnd) -> Self {
let session_over =
end == benilla_protocol::SessionEnd::Lost && !crate::run_mode::unattended_login();
end == benilla_protocol::SessionEnd::Lost && !crate::run_mode::unattended();
Self {
reason,
end,
@@ -2498,6 +2518,64 @@ pub(crate) struct AiReactionMessage {
mod tests {
use super::*;
/// **The kick sends the player back to the login screen** — decision 1262's whole promise,
/// pinned at the one place that decides it, in the environment the director actually plays in.
///
/// 1262 was built and verified in August and then quietly stopped working, with none of its
/// code touched: the verdict asked the *environment* whether anybody was here, and on
/// 2026-08-29 the director's launch line became `WOW_USER=one WOW_PASS=pone cargo play` — the
/// example line in our own `.cargo/config.toml`. From that moment every session they played
/// was "a probe", so a kick meant reconnect, and the account ping-ponged exactly as B252
/// reported. Nothing about the fix was wrong; the fact under it was. 1769 made the claim a
/// declaration, and this test is the tripwire: a run may only act instead of a person if it
/// said so.
#[test]
fn a_kick_ends_the_session_unless_the_run_declared_itself_driverless() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lost = || {
DisconnectedMessage::new("kicked".into(), benilla_protocol::SessionEnd::Lost)
.session_over
};
// The director's launch line, verbatim. Credentials in the environment, nobody declared
// absent — this is a person playing, and a lost session is over.
let _user = EnvGuard::set("WOW_USER", "one");
let _pass = EnvGuard::set("WOW_PASS", "pone");
let _char = EnvGuard::set("WOW_CHAR", "One");
let _decl = EnvGuard::unset("WOW_UNATTENDED");
let _cap = EnvGuard::unset("WOW_CAPTURE");
let _rig = EnvGuard::unset("WOW_RIG");
assert!(lost(), "the reported regression: this must be `true`");
// A clean `/logout` is never "over" — the roster that follows IS the screen they asked for.
assert!(
!DisconnectedMessage::new("bye".into(), benilla_protocol::SessionEnd::LoggedOut)
.session_over
);
// 0065's seamless reconnect survives exactly where the run says nobody is there to press
// Okay — a declaration, or the two runs that cannot be a person.
{
let _d = EnvGuard::set("WOW_UNATTENDED", "1");
assert!(!lost(), "a declared probe still reconnects");
}
{
let _r = EnvGuard::set("WOW_RIG", "at:229,0,0,0");
assert!(!lost(), "a rig drives the body; it cannot be a person");
}
{
let _c = EnvGuard::set("WOW_CAPTURE", "some-scenario");
assert!(
!lost(),
"a capture authors the camera; it cannot be a person"
);
}
assert!(lost(), "and the guards put every one of those back");
}
/// The vanilla player set, and the one every number below is read against:
/// walk 2.5 · run 7.0 · runBack 4.5 · swim 4.722 · swimBack 2.5 (vmangos `baseMoveSpeed`).
fn vanilla() -> MoveSpeeds {
+1 -3
View File
@@ -1956,9 +1956,7 @@ pub(super) fn apply_net_updates(
// The player-trade arc (decision 0592 P1): the status packet drives the open/accept/close
// state machine, the extended snapshot replaces one side's item/gold — both into the
// `TradeSession` the trade feed (`crate::ui_trade`) reads.
SessionEvent::TradeStatus { status } => {
trade::trade_status(status, &mut trade_session, &net_commands)
}
SessionEvent::TradeStatus { status } => trade::trade_status(status, &mut trade_session),
SessionEvent::TradeStatusExtended { state } => {
trade::trade_status_extended(&state, &mut trade_session)
}
+11 -11
View File
@@ -208,13 +208,13 @@ pub(super) fn auction_removed_notification(item_entry: u32, auction: &mut Auctio
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_action::MsgSurface;
use crate::ui_action::MsgKind;
fn keys(auction: &AuctionOpen) -> Vec<(&'static str, MsgSurface, Option<u32>)> {
fn keys(auction: &AuctionOpen) -> Vec<(&'static str, MsgKind, Option<u32>)> {
auction
.messages
.iter()
.map(|m| (m.key, m.surface, m.item))
.map(|m| (m.key, benilla_ui::messages::kind_of(m.key), m.item))
.collect()
}
@@ -259,7 +259,7 @@ mod tests {
auction_owner_notification(&owner(0, 10_000), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_SOLD_S", MsgSurface::Chat, Some(2589))]
vec![("ERR_AUCTION_SOLD_S", MsgKind::Chat, Some(2589))]
);
// Expired: guid zeroed, and nobody paid anything.
@@ -267,7 +267,7 @@ mod tests {
auction_owner_notification(&owner(0, 0), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_EXPIRED_S", MsgSurface::Chat, Some(2589))],
vec![("ERR_AUCTION_EXPIRED_S", MsgKind::Chat, Some(2589))],
"a zero bid on a closed auction is an expiry, not a sale"
);
}
@@ -279,14 +279,14 @@ mod tests {
auction_bidder_notification(&bidder(0), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_WON_S", MsgSurface::Chat, Some(2589))]
vec![("ERR_AUCTION_WON_S", MsgKind::Chat, Some(2589))]
);
let mut a = AuctionOpen::default();
auction_bidder_notification(&bidder(9_000), &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_OUTBID_S", MsgSurface::Chat, Some(2589))]
vec![("ERR_AUCTION_OUTBID_S", MsgKind::Chat, Some(2589))]
);
}
@@ -305,7 +305,7 @@ mod tests {
auction_command_result(7, action, auction_error::OK, &tail, &mut a);
assert_eq!(
keys(&a),
vec![(key, MsgSurface::Chat, None)],
vec![(key, MsgKind::Chat, None)],
"a successful {action} says so in chat, with no item fill"
);
}
@@ -314,7 +314,7 @@ mod tests {
auction_removed_notification(2589, &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_REMOVED_S", MsgSurface::Chat, Some(2589))]
vec![("ERR_AUCTION_REMOVED_S", MsgKind::Chat, Some(2589))]
);
// A refusal, on the red frame.
@@ -322,7 +322,7 @@ mod tests {
auction_command_result(0, auction_action::BID_PLACED, 10, &tail, &mut a);
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_BID_OWN", MsgSurface::Error, None)]
vec![("ERR_AUCTION_BID_OWN", MsgKind::Error, None)]
);
// HIGHER_BID is the live outbid UPDATE path, not a message — the line the player sees is
@@ -345,7 +345,7 @@ mod tests {
a.clear();
assert_eq!(
keys(&a),
vec![("ERR_AUCTION_SOLD_S", MsgSurface::Chat, Some(2589))]
vec![("ERR_AUCTION_SOLD_S", MsgKind::Chat, Some(2589))]
);
}
}
+8 -2
View File
@@ -402,7 +402,10 @@ mod tests {
notification("GM mode is ON".to_string(), &mut errors);
assert_eq!(
errors.0,
[("GM mode is ON".to_string(), false)],
[(
"GM mode is ON".to_string(),
crate::ui_action::MsgKind::Error
)],
"the SendNotification half is the RED toast (0x4945b0's flag 1), never a second line"
);
}
@@ -420,7 +423,10 @@ mod tests {
);
assert_eq!(
errors.0,
[("You must be at least level 58 to enter.".to_string(), true)]
[(
"You must be at least level 58 to enter.".to_string(),
crate::ui_action::MsgKind::Info
)]
);
}
}
+3 -3
View File
@@ -103,7 +103,7 @@ pub(super) fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
"ERR_FISH_NOT_HOOKED"
};
debug!("net: fishing verdict {key}");
errors.0.push(UiError::info_key(key));
errors.0.push(UiError::key(key));
}
/// The server refused to open the loot window (`SMSG_LOOT_RESPONSE`'s error shape — didn't kill
@@ -295,8 +295,8 @@ mod tests {
assert_eq!(
errors.0,
vec![
UiError::info_key("ERR_FISH_NOT_HOOKED"),
UiError::info_key("ERR_FISH_ESCAPED"),
UiError::key("ERR_FISH_NOT_HOOKED"),
UiError::key("ERR_FISH_ESCAPED"),
]
);
+21 -27
View File
@@ -9,7 +9,7 @@ use benilla_protocol::messages::{
};
use bevy::prelude::*;
use crate::ui_action::{MsgSurface, UiError};
use crate::ui_action::UiError;
use crate::ui_chat::ChatLog;
use crate::ui_quest::QuestGiver;
use crate::ui_quest_log::QuestLog;
@@ -167,7 +167,7 @@ pub(super) fn quest_failed(
/// English chat push here).
pub(super) fn quest_log_full(quest: &mut QuestGiver) {
debug!("net: quest log full");
quest.push_message(MsgSurface::Error, UiError::key("ERR_QUEST_LOG_FULL"));
quest.push_message(UiError::key("ERR_QUEST_LOG_FULL"));
}
/// The giver won't offer the quest (`SMSG_QUESTGIVER_QUEST_INVALID`, ref handler `0x5dbca0`): one
@@ -181,10 +181,9 @@ pub(super) fn quest_log_full(quest: &mut QuestGiver) {
/// this the panel sat open on a refused accept (decision 0669).
pub(super) fn quest_giver_invalid(reason: u32, quest: &mut QuestGiver) {
debug!("net: questgiver refused to offer the quest (reason {reason})");
quest.push_message(
MsgSurface::Chat,
UiError::key(crate::ui_quest::questgiver_invalid_key(reason)),
);
quest.push_message(UiError::key(crate::ui_quest::questgiver_invalid_key(
reason,
)));
quest.clear();
}
@@ -210,17 +209,13 @@ pub(super) fn quest_giver_failed(
.map(|t| t.title.clone())
})
.unwrap_or_default();
quest.push_message(
MsgSurface::Chat,
UiError {
key: crate::ui_quest::questgiver_failed_key(reason),
fill_s: Some(title),
fill_d: None,
info: false,
},
);
quest.push_message(UiError {
key: crate::ui_quest::questgiver_failed_key(reason),
fill_s: Some(title),
fill_d: None,
});
if matches!(reason, 4 | 50) {
quest.push_message(MsgSurface::Error, UiError::key("ERR_INV_FULL"));
quest.push_message(UiError::key("ERR_INV_FULL"));
}
quest.clear();
}
@@ -283,8 +278,8 @@ mod tests {
quest_giver_invalid(13, &mut giver);
let msgs = giver.take_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].0, MsgSurface::Chat);
assert_eq!(msgs[0].1, UiError::key("ERR_QUEST_ALREADY_ON"));
assert_eq!(msgs[0].kind(), crate::ui_action::MsgKind::Chat);
assert_eq!(msgs[0], UiError::key("ERR_QUEST_ALREADY_ON"));
assert!(!giver.is_open(), "the ref closes the window on a refusal");
}
@@ -299,18 +294,17 @@ mod tests {
quest_giver_failed(373, 4, &mut giver, &mut log, &commands);
let msgs = giver.take_messages();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].0, MsgSurface::Chat);
assert_eq!(msgs[0].kind(), crate::ui_action::MsgKind::Chat);
assert_eq!(
msgs[0].1,
msgs[0],
UiError {
key: "ERR_QUEST_FAILED_BAG_FULL_S",
fill_s: Some("A Threat Within".into()),
fill_d: None,
info: false,
}
);
assert_eq!(msgs[1].0, MsgSurface::Error);
assert_eq!(msgs[1].1, UiError::key("ERR_INV_FULL"));
assert_eq!(msgs[1].kind(), crate::ui_action::MsgKind::Error);
assert_eq!(msgs[1], UiError::key("ERR_INV_FULL"));
assert!(!giver.is_open());
}
@@ -322,8 +316,8 @@ mod tests {
quest_log_full(&mut giver);
let msgs = giver.take_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].0, MsgSurface::Error);
assert_eq!(msgs[0].1, UiError::key("ERR_QUEST_LOG_FULL"));
assert_eq!(msgs[0].kind(), crate::ui_action::MsgKind::Error);
assert_eq!(msgs[0], UiError::key("ERR_QUEST_LOG_FULL"));
assert!(
giver.is_open(),
"the ref's 0x195 arm does not close the panel"
@@ -340,8 +334,8 @@ mod tests {
let commands = NetCommands(tx);
quest_giver_failed(999, 17, &mut giver, &mut log, &commands);
let msgs = giver.take_messages();
assert_eq!(msgs[0].1.key, "ERR_QUEST_FAILED_MAX_COUNT_S");
assert_eq!(msgs[0].1.fill_s.as_deref(), Some(""));
assert_eq!(msgs[0].key, "ERR_QUEST_FAILED_MAX_COUNT_S");
assert_eq!(msgs[0].fill_s.as_deref(), Some(""));
}
// ── The share's BUSY refusal (decision 1738) ─────────────────────────────────────────────────
@@ -1055,6 +1055,7 @@ mod tests {
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::player::StandStateRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
@@ -1222,6 +1223,7 @@ mod tests {
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::player::StandStateRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
@@ -1354,6 +1356,7 @@ mod tests {
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::player::StandStateRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
@@ -1661,6 +1664,7 @@ mod tests {
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::player::StandStateRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
+16 -11
View File
@@ -7,26 +7,31 @@
use benilla_protocol::messages::{TradeStatus, TradeStatusExtended};
use crate::net::{ClientCommand, NetCommands};
use crate::ui_trade::TradeSession;
/// `SessionEvent::TradeStatus` (`SMSG_TRADE_STATUS`) — the trade state machine (decision 0592 P1).
/// `BEGIN_TRADE` records the partner and auto-answers `CMSG_BEGIN_TRADE` (the reference client's
/// auto-reply, vmangos `TradeHandler.cpp` — it makes the server emit `OPEN_WINDOW` to both sides);
/// `OPEN_WINDOW` opens the window; `TRADE_ACCEPT`/`BACK_TO_TRADE` drive the accept glow; the terminal
/// and refusal codes close it. The refusal statuses' red error text is decision 0592 P3;
/// `ONLY_CONJURED` (a rejected item placement, not a window close) and any out-of-range code are inert.
pub(super) fn trade_status(status: TradeStatus, trade: &mut TradeSession, commands: &NetCommands) {
/// `BEGIN_TRADE` records the incoming request **without answering it** (decision 1764 — the reply
/// is a policy with five gates and the player's own consent, so
/// [`crate::ui_trade::answer_trade_request`] owns it); `OPEN_WINDOW` opens the window;
/// `TRADE_ACCEPT`/`BACK_TO_TRADE` drive the accept glow; the terminal and refusal codes close it.
/// The refusal statuses' red error text is decision 0592 P3; `ONLY_CONJURED` (a rejected item
/// placement, not a window close) and any out-of-range code are inert.
pub(super) fn trade_status(status: TradeStatus, trade: &mut TradeSession) {
bevy::log::info!(target: "trade", "SMSG_TRADE_STATUS {status:?}");
match status {
TradeStatus::BeginTrade { partner } => {
trade.begin(partner);
let _ = commands.0.send(ClientCommand::BeginTrade);
}
TradeStatus::BeginTrade { partner } => trade.request(partner),
TradeStatus::OpenWindow => trade.open_window(),
TradeStatus::Accept => trade.partner_accepted(),
TradeStatus::BackToTrade => trade.back_to_trade(),
// Terminal (complete/cancel/close) + every initiate/in-trade refusal → close the window.
//
// **Two statuses sit outside the reference's own clear set, and neither is reachable on
// vmangos** (decision 1764, noticed rather than chased). The reference clears its trade
// cells in the status handler's common tail on 0, 3, 5, 6, 8, 10, 11, 12, 13 and 14–21;
// this list is that set minus `Rejected` (9), which we close on and it does not, plus
// `Unknown13` (13), which it clears on and we treat as inert below. vmangos sends neither,
// so there is no observable to settle it against — which is exactly why it is written down
// here instead of being guessed at in either direction.
TradeStatus::Canceled
| TradeStatus::Complete
| TradeStatus::CloseWindow { .. }
+2
View File
@@ -1028,6 +1028,8 @@ fn writer_loop(
// The player-trade arc (decision 0592) — the CMSG verbs onto the P0 writers.
ClientCommand::InitiateTrade { target } => w.initiate_trade(target),
ClientCommand::BeginTrade => w.begin_trade(),
ClientCommand::BusyTrade => w.busy_trade(),
ClientCommand::IgnoreTrade => w.ignore_trade(),
ClientCommand::AcceptTrade => w.accept_trade(),
ClientCommand::UnacceptTrade => w.unaccept_trade(),
ClientCommand::CancelTrade => w.cancel_trade(),
+107 -16
View File
@@ -51,30 +51,84 @@ pub(crate) fn scenario_active() -> bool {
std::env::var("WOW_CAPTURE").is_ok()
}
/// **Is anything but a person answering this session's login?** (`$WOW_USER` / `$WOW_PASS` /
/// `$WOW_CHAR` — the login screen's env fast path.)
/// **Do the credentials come from the environment?** (`$WOW_USER` / `$WOW_PASS` / `$WOW_CHAR` —
/// the login screen's env fast path.)
///
/// The player answer is `false`, and it is the default: with none of these set the client opens at
/// the login screen and waits for somebody to type. Setting any of them *is* a harness saying "log
/// in without me" — a probe, a smoke, a rig — so it is also the honest answer to the question a
/// dead session asks: **is there anybody here to log back in?**
/// the login screen and waits for somebody to type. Setting any of them says *"log in without
/// waiting for me to type"* — and **that is the only thing it says.** It is not a claim about who
/// is in the room; [`unattended`] is the fact for that, and keeping the two apart is decision 1769.
///
/// That question decides what a lost session does (decision 1262). A person gets the reference's
/// answer — the world torn down, the account screen, "Disconnected from server", and nothing
/// retried until they say so. An unattended run keeps 0065's seamless reconnect, because the
/// alternative is a probe parked on a login dialog for the rest of its wall-clock. One environment
/// fact, several readers, no shared symbol — the module doc's pattern, same as [`scenario_active`],
/// and the reason the login policy's own fast path asks here instead of re-reading the three names.
///
/// The session-loss readers never call this directly: [`crate::net::DisconnectedMessage::new`]
/// asks once, at the wire edge, and every reader acts on the verdict it carries — so the teardown,
/// the screen flip and the credential policy cannot answer it three different ways.
pub(crate) fn unattended_login() -> bool {
/// One environment fact, several readers, no shared symbol — the module doc's pattern, same as
/// [`scenario_active`], and the reason the roster's `WOW_CHAR` fast path asks here instead of
/// re-reading the three names.
pub(crate) fn env_login() -> bool {
["WOW_USER", "WOW_PASS", "WOW_CHAR"]
.iter()
.any(|k| std::env::var_os(k).is_some())
}
/// **Is there nobody here?** (`$WOW_UNATTENDED`, or the two runs that are automated by
/// construction: `$WOW_CAPTURE` and `$WOW_RIG`.)
///
/// The player answer is `false` and it is the **default**, like every other answer in this module:
/// a client with nobody at the keyboard is the exception, and an exception declares itself. The
/// direction is the whole point — guessing "attended" when a probe is running costs that probe a
/// timeout, loudly; guessing "unattended" when a person is playing ships them a client that fights
/// another client for their account, silently. Those two are not worth the same, so the doubt goes
/// one way.
///
/// **This is the fact [`env_login`] was mistaken for, twice.** Env credentials mean "log in without
/// typing"; they say nothing about the room — and the director plays with
/// `WOW_USER=one WOW_PASS=pone cargo play`, which is the example line in `.cargo/config.toml`. So
/// every session they played read as "a harness": a typed password's refusal killed the process
/// (fixed on 2026-08-28 by asking `announced` — direct evidence — instead of the environment) and,
/// the half that fix left standing, a kick sent the client racing to take the account back instead
/// of to the login screen. That is decision 1262's reported bug, reappearing three weeks later
/// with not one line of its code changed, because the fact underneath it was never true. 1769.
///
/// `WOW_CAPTURE` and `WOW_RIG` are folded in because they *cannot* be a person: a capture authors
/// the camera and a rig drives the body, so a run that sets either has already said what it is and
/// cannot forget to. Everything else declares — `scripts/leg.sh`, `smoke.sh`, `cine.sh`,
/// `summon-live.sh` and the ad-hoc probe recipe in `method.md` all pass `WOW_UNATTENDED=1`.
///
/// Read by whatever may act *instead of* a person: the lost-session verdict (decision 1262 — the
/// session-loss readers never call this directly, [`crate::net::DisconnectedMessage::new`] asks
/// once at the wire edge and every reader acts on the verdict it carries) and the two `FATAL`
/// exits that keep a driverless run from burning its wall-clock on a dialog (decision 1371).
pub(crate) fn unattended() -> bool {
["WOW_UNATTENDED", "WOW_CAPTURE", "WOW_RIG"]
.iter()
.any(|k| std::env::var_os(k).is_some())
}
/// **This run cannot get past the screen it is on — may the client end it instead of a person?**
/// (decision 1371's `FATAL` marker, resting on 1769's fact.)
///
/// `true` only for a run that has declared itself [`unattended`]: it exits non-zero on the one
/// greppable marker `scripts/leg.sh` keys on, rather than parking a driverless run on a dialog for
/// its whole wall-clock. Otherwise the dialog stays up for whoever is there.
///
/// The `false` arm still speaks, when the credentials came from the environment: a probe that
/// quietly parks for 300 s instead of failing in 5 is precisely what 1769's default trades away,
/// so the log names the declaration that buys the old behaviour back. Both arms live here because
/// three call sites — two in [`crate::login`], one in [`crate::char_select`] — must not drift
/// apart on either the verdict or the marker `leg.sh` greps for.
pub(crate) fn fatal_when_driverless(why: &str) -> bool {
if unattended() {
error!("login: FATAL — {why}; exiting");
return true;
}
if env_login() {
warn!(
"login: {why} — leaving the dialog up, because nothing declared this run driverless. \
Set WOW_UNATTENDED=1 if nobody is here and it should exit non-zero instead \
(decision 1769)."
);
}
false
}
/// **Which screen the client starts on.** A player starts at the login screen, always; a capture
/// boots straight into the world (no net, no picker), and a *glue* capture boots onto the very
/// screen it photographs.
@@ -369,6 +423,43 @@ mod tests {
);
}
/// **Two questions, two facts** (decision 1769) — and the fixture is the line the director
/// actually types, so a future merge of the two answers fails here rather than in their game.
#[test]
fn env_credentials_are_not_a_claim_about_who_is_in_the_room() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _decl = EnvGuard::unset("WOW_UNATTENDED");
let _cap = EnvGuard::unset("WOW_CAPTURE");
let _rig = EnvGuard::unset("WOW_RIG");
// A player build's defaults: nothing set, nothing assumed.
{
let _u = EnvGuard::unset("WOW_USER");
let _p = EnvGuard::unset("WOW_PASS");
let _c = EnvGuard::unset("WOW_CHAR");
assert!(!env_login() && !unattended());
}
// `WOW_USER=one WOW_PASS=pone cargo play` — `.cargo/config.toml`'s own example, and the
// director's launch line since 2026-08-29. Log them in without typing, yes. Decide things
// on their behalf, no.
let _u = EnvGuard::set("WOW_USER", "one");
let _p = EnvGuard::set("WOW_PASS", "pone");
assert!(env_login(), "the fast path still submits for them");
assert!(
!unattended(),
"credentials in the environment are not an empty chair",
);
// The declaration, and the two runs that are automated by construction.
for key in ["WOW_UNATTENDED", "WOW_CAPTURE", "WOW_RIG"] {
let _d = EnvGuard::set(key, "1");
assert!(unattended(), "{key} declares the run driverless");
}
assert!(!unattended(), "and each guard puts it back");
}
#[test]
fn the_guard_only_polices_accounts_that_belong_to_someone() {
// Our own slot's probe: the whole point of the identity.
-3
View File
@@ -738,7 +738,6 @@ fn route_lock_refusal(
key: "ERR_USE_LOCKED_WITH_ITEM_S",
fill_s: Some(name),
fill_d: None,
info: false,
}),
},
benilla_formats::LOCK_KEY_SKILL => {
@@ -749,14 +748,12 @@ fn route_lock_refusal(
key: "ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI",
fill_s: Some(name),
fill_d: Some(required),
info: false,
})
} else {
Some(UiError {
key: "ERR_USE_LOCKED_WITH_SPELL_S",
fill_s: Some(name),
fill_d: None,
info: false,
})
}
}
+1
View File
@@ -1173,6 +1173,7 @@ mod tests {
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
world.init_resource::<Selection>();
world.spawn(SelfPlayer);
+155 -34
View File
@@ -260,7 +260,47 @@ impl FailArgs<'_> {
}
}
/// The displayed text for a failed cast — `None` = the reference shows nothing. `get` is the
/// The errorId every reason that overrides nothing falls through to — `0x2c`, whose text is the
/// bare `"%s"`, so what the player reads is the first layer's `SPELL_FAILED_*` string. benilla
/// short-circuits the identity substitution and displays that string directly; the id still
/// matters, because it is the id whose **record** decides the surface.
pub(super) const PASSTHROUGH: &str = "ERR_SPELL_FAILED_S";
/// One resolved cast-failure line: the text to show, and the **message id's key** that decides
/// where to show it.
///
/// The key rides along because the reference's second layer hands its errorId to
/// `CGGameUI::DisplayError`, and that function reads the record's kind — so the surface is a
/// property of the id this dispatch picked, not of "cast failures" as a category. Seventeen of
/// the overrides are red and `ERR_SPELL_FAILED_NOTUNSHEATHED` is **yellow**; before the catalog
/// (decision 1770) benilla painted all eighteen red, because nothing here knew which id it had
/// chosen by the time the line reached the frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CastFailLine {
pub key: &'static str,
pub text: String,
}
impl CastFailLine {
/// A line the second layer left on its default errorId — the drain builds these for the three
/// argument arms it owns (`0x78` TOTEMS, `0x5c` REAGENTS, `0x19`-`0x1b` EQUIPPED_ITEM_CLASS*),
/// whose fills need the item caches.
pub(super) fn passthrough(text: String) -> Self {
Self {
key: PASSTHROUGH,
text,
}
}
fn fill(self, name: &str) -> Self {
Self {
text: self.text.replace("%s", name),
..self
}
}
}
/// The displayed line for a failed cast — `None` = the reference shows nothing. `get` is the
/// VM's GlobalStrings lookup (an absent or empty key resolves to `None`, the data-suppression
/// face). Reasons beyond the table print their code — our debug affordance, not a ref string.
pub(super) fn cast_fail_text(
@@ -268,16 +308,17 @@ pub(super) fn cast_fail_text(
spell: Option<&SpellDisplay>,
args: FailArgs<'_>,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
let get_display = |key: &str| get(key).filter(|s| !s.is_empty());
) -> Option<CastFailLine> {
let get_text = |key: &str| get(key).filter(|s| !s.is_empty());
let get_display = |key: &'static str| get_text(key).map(|text| CastFailLine { key, text });
// The errorId overrides (`0x6e1aab–0x6e1c5f`): the replaced-message reasons.
match reason {
0x01 => return get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_HEALTH"),
0x02 => {
let t = get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S")?;
let power = spell.map_or(0, |d| d.power_type);
let name = get_display(power_keys(power).1).unwrap_or_default();
return Some(t.replace("%s", &name));
let name = get_text(power_keys(power).1).unwrap_or_default();
return Some(t.fill(&name));
}
0x09 => return get_display("ERR_GENERIC_NO_TARGET"),
0x17 => return None, // DONT_REPORT: control-flow hidden (jumps past DisplayError)
@@ -312,12 +353,19 @@ pub(super) fn cast_fail_text(
}
// The passthrough layer: errorId 0x2c ("%s") displays GetText(SPELL_FAILED_<name>) as-is.
let Some(key) = CAST_FAIL_KEYS.get(usize::from(reason)) else {
return Some(format!("Spell failed ({reason:#04x})"));
return Some(CastFailLine {
key: PASSTHROUGH,
text: format!("Spell failed ({reason:#04x})"),
});
};
let text = get_text(key)?;
let line = |text: String| CastFailLine {
key: PASSTHROUGH,
text,
};
let text = get_display(key)?;
// The argument arms (`0x6e1d8e`): fill the template's `%s` from the reason's own DBC.
if let Some(name) = args.fill(reason, spell) {
return Some(text.replace("%s", &name));
return Some(line(text.replace("%s", &name)));
}
// An arm we don't model (or one whose lookup missed) — strip the tokens so the stem reads
// clean ("Missing reagent: %s" → "Missing reagent"), never a raw % on screen.
@@ -328,13 +376,13 @@ pub(super) fn cast_fail_text(
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
return Some(
return Some(line(
stripped
.trim_end_matches([' ', ':', '.', '(', ')'])
.to_string(),
);
));
}
Some(text)
Some(line(text))
}
#[cfg(test)]
@@ -357,9 +405,45 @@ mod tests {
("ERR_POTION_COOLDOWN", "Item is not ready yet."),
("ERR_OUT_OF_MANA", "Not enough mana"),
("ERR_OUT_OF_RAGE", "Not enough rage"),
(
"ERR_SPELL_FAILED_NOTUNSHEATHED",
"You have nothing to attack with.",
),
])
}
/// **The one cast failure that is not a red line.** Reason `0x41` overrides to
/// `ERR_SPELL_FAILED_NOTUNSHEATHED` (id 320), and that record's kind is `1` — the yellow
/// `UI_INFO_MESSAGE`, not the red `UI_ERROR_MESSAGE` its seventeen override neighbours take.
///
/// benilla painted it red until decision 1770, and could not have done otherwise: the drain
/// fired one event for every resolved cast-failure line, because by the time a line got there
/// nothing knew which errorId this dispatch had chosen. Carrying the key out is what makes the
/// surface answerable, and this test is the answer.
///
/// Its two yellow siblings, `ERR_FISH_NOT_HOOKED` (318) and `ERR_FISH_ESCAPED` (319), sit one
/// and two ids below it and were hand-traced correctly by an earlier round — which is exactly
/// how a hand-swept surface goes wrong: two of the three neighbours seen, the third not.
#[test]
fn the_unsheathed_refusal_is_yellow_and_its_neighbours_are_red() {
use benilla_ui::messages::{kind_of, MsgKind};
let m = gs();
let g = getter(&m);
let line = cast_fail_text(0x41, None, FailArgs::default(), &g).expect("a line");
assert_eq!(line.key, "ERR_SPELL_FAILED_NOTUNSHEATHED");
assert_eq!(kind_of(line.key), MsgKind::Info);
// An override that IS red, and a passthrough that falls to errorId 0x2c.
let red = cast_fail_text(0x59, None, FailArgs::default(), &g).expect("a line");
assert_eq!(red.key, "ERR_SPELL_OUT_OF_RANGE");
assert_eq!(kind_of(red.key), MsgKind::Error);
let through = cast_fail_text(0x43, None, FailArgs::default(), &g).expect("a line");
assert_eq!(through.key, PASSTHROUGH);
assert_eq!(kind_of(through.key), MsgKind::Error);
}
fn getter<'a>(
map: &'a HashMap<&'static str, &'static str>,
) -> impl Fn(&str) -> Option<String> + 'a {
@@ -392,35 +476,49 @@ mod tests {
let m = gs();
let g = getter(&m);
assert_eq!(
cast_fail_text(0x43, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x43, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of ammo"
);
assert_eq!(
cast_fail_text(0x59, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x59, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of range."
);
assert_eq!(
cast_fail_text(0x76, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x76, None, FailArgs::default(), &g)
.unwrap()
.text,
"Target too close"
);
assert_eq!(
cast_fail_text(0x09, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x09, None, FailArgs::default(), &g)
.unwrap()
.text,
"You have no target."
);
// 0x3c: plain spell → spell cooldown; Attr&0x10 → ability; potion category → potion.
let plain = spell(0, 0, 0);
assert_eq!(
cast_fail_text(0x3C, Some(&plain), FailArgs::default(), &g).unwrap(),
cast_fail_text(0x3C, Some(&plain), FailArgs::default(), &g)
.unwrap()
.text,
"Spell is not ready yet."
);
let ability = spell(1, 0, 0x10);
assert_eq!(
cast_fail_text(0x3C, Some(&ability), FailArgs::default(), &g).unwrap(),
cast_fail_text(0x3C, Some(&ability), FailArgs::default(), &g)
.unwrap()
.text,
"Ability is not ready yet."
);
let potion = spell(0, 4, 0);
assert_eq!(
cast_fail_text(0x3C, Some(&potion), FailArgs::default(), &g).unwrap(),
cast_fail_text(0x3C, Some(&potion), FailArgs::default(), &g)
.unwrap()
.text,
"Item is not ready yet."
);
}
@@ -433,12 +531,16 @@ mod tests {
let g = getter(&m);
let rage = spell(1, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g).unwrap(),
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough rage"
);
let mana = spell(0, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&mana), FailArgs::default(), &g).unwrap(),
cast_fail_text(0x4D, Some(&mana), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough mana"
);
}
@@ -453,11 +555,15 @@ mod tests {
assert_eq!(cast_fail_text(0x17, None, FailArgs::default(), &g), None);
assert_eq!(cast_fail_text(0x08, None, FailArgs::default(), &g), None);
assert_eq!(
cast_fail_text(0x92, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x92, None, FailArgs::default(), &g)
.unwrap()
.text,
"Spell failed (0x92)"
);
assert_eq!(
cast_fail_text(0x5C, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x5C, None, FailArgs::default(), &g)
.unwrap()
.text,
"Missing reagent"
);
}
@@ -479,31 +585,43 @@ mod tests {
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
assert_eq!(
cast_fail_text(0x43, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x43, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of ammo"
);
assert_eq!(
cast_fail_text(0x59, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x59, None, FailArgs::default(), &g)
.unwrap()
.text,
"Out of range."
);
assert_eq!(
cast_fail_text(0x3C, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x3C, None, FailArgs::default(), &g)
.unwrap()
.text,
"Spell is not ready yet."
);
let rage = spell(1, 0, 0);
assert_eq!(
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g).unwrap(),
cast_fail_text(0x4D, Some(&rage), FailArgs::default(), &g)
.unwrap()
.text,
"Not enough rage"
);
// The environment gate's pair (decision 1056) — both are plain passthroughs, so what the
// player reads IS the GlobalStrings value. A typo'd key here would degrade a real refusal
// to a dead-looking button, which is what this test exists to catch.
assert_eq!(
cast_fail_text(0x50, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x50, None, FailArgs::default(), &g)
.unwrap()
.text,
"Cannot use while swimming"
);
assert_eq!(
cast_fail_text(0x58, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x58, None, FailArgs::default(), &g)
.unwrap()
.text,
"Can only use while swimming"
);
// The data-suppression face on the real file: the absent keys show nothing.
@@ -524,26 +642,28 @@ mod tests {
// 0x5e REQUIRES_SPELL_FOCUS: the Crown of the Earth phials' own refusal. Focus 12 is the
// Starbreeze Village moonwell — using the Jade Phial at any *other* pool is the report.
assert_eq!(
cast_fail_text(0x5E, None, args(12), &g).unwrap(),
cast_fail_text(0x5E, None, args(12), &g).unwrap().text,
"Requires Starbreeze Village Moonwell"
);
assert_eq!(
cast_fail_text(0x5E, None, args(1), &g).unwrap(),
cast_fail_text(0x5E, None, args(1), &g).unwrap().text,
"Requires Anvil"
);
// 0x5d REQUIRES_AREA: area 1657 is Darnassus.
assert_eq!(
cast_fail_text(0x5D, None, args(1657), &g).unwrap(),
cast_fail_text(0x5D, None, args(1657), &g).unwrap().text,
"You need to be in Darnassus"
);
// The fallbacks. An id the DBC doesn't name, and a wire word the server never sent, both
// decline the arm and fall through to the strip — never a raw `%s` on screen.
assert_eq!(
cast_fail_text(0x5E, None, args(999_999), &g).unwrap(),
cast_fail_text(0x5E, None, args(999_999), &g).unwrap().text,
"Requires"
);
assert_eq!(
cast_fail_text(0x5D, None, FailArgs::default(), &g).unwrap(),
cast_fail_text(0x5D, None, FailArgs::default(), &g)
.unwrap()
.text,
"You need to be in"
);
// 0x5e alone has a client-side stand-in: the failing spell's own `RequiresSpellFocus`
@@ -563,7 +683,8 @@ mod tests {
},
&g
)
.unwrap(),
.unwrap()
.text,
"Requires Shadowglen Moonwell"
);
}
@@ -764,6 +764,7 @@ mod tests {
// The sheath request the commit tail writes for a ranged spell — a bare World has no
// message storage until it is asked for.
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Messages<crate::player::StandStateRequest>>();
(world, rx)
}
+49 -56
View File
@@ -15,9 +15,10 @@
//! `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`, firing `UI_ERROR_MESSAGE` (or, for the info arm,
//! `UI_INFO_MESSAGE`) per resolved line;
//! every string comes from the VM's own loaded `GlobalStrings.lua`, never hardcoded, so an
//! 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 an
//! absent key shows nothing (the reference's data-suppression face) and localization rides
//! for free.
@@ -74,24 +75,15 @@ impl CastErrors {
#[derive(Resource, Default)]
pub(crate) struct MountErrors(pub Vec<(bool, u32)>);
/// Where a client message is SHOWN — the `kind` field (`+0x04`) of the reference's message record
/// (`0xb4b498 + 20*msgId`), which `CGGameUI::DisplayError` (`0x496720`) dispatches on through the
/// four-way jump at `0x496888`: **0 → the chat window** (`0x49a870`), 1 → `AddErrorMessage(text, 0)`
/// (the yellow info line), **2 → `AddErrorMessage(text, 1)`** (the red error line), 3 → the console.
/// Only the two our messages use are modeled (decision 0669); the info line has its own established
/// route (`UI_INFO_MESSAGE`, decision 0340) and rides [`UiError::info`] instead.
/// 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.
///
/// This lives beside [`UiError`] rather than in any one window because the surface is a property of
/// the MESSAGE, not of the window that raised it — and it has more than one tenant: the quest
/// refusals (0669) and, since 1523, the auction house, where the twenty `ERR_AUCTION_*` ids split
/// clean down the middle of this enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MsgSurface {
/// kind 0 — a chat-window system line.
Chat,
/// kind 2 — the red `UIErrorsFrame` line.
Error,
}
/// 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 `DisplayError` message: a GlobalStrings key plus the argText fills. The 1.12 error
/// formats use at most one `%s` and one `%d` ("Requires %s", "Requires %s %d" — wow-re
@@ -104,13 +96,6 @@ pub(crate) struct UiError {
pub key: &'static str,
pub fill_s: Option<String>,
pub fill_d: Option<u32>,
/// The message record's **type arm** (wow-re `fish-msg-handlers.md`, byte-proven — the
/// registry entry's `+4` type, last written `mov edx,1` at `0x486235`): the reference's
/// `DisplayError` pipeline is one, and the type selects the UIErrorsFrame event — type 1
/// fires the **yellow** `UI_INFO_MESSAGE` (0xe1), type 2 the **red** `UI_ERROR_MESSAGE`
/// (0xe0). `true` = the yellow info arm (the fishing verdicts are the first tenants);
/// `false` = the red error arm, the default.
pub info: bool,
}
impl UiError {
@@ -121,16 +106,13 @@ impl UiError {
key,
fill_s: None,
fill_d: None,
info: false,
}
}
/// A fill-less **type-1** message — the yellow `UI_INFO_MESSAGE` arm (see [`UiError::info`]).
pub(crate) fn info_key(key: &'static str) -> Self {
Self {
info: true,
..Self::key(key)
}
/// Where this message is shown, straight off the catalog row its key names — the reference's
/// `DisplayError` reading `[record+0x04]`, which is the only place that answer has ever lived.
pub(crate) fn kind(&self) -> MsgKind {
benilla_ui::messages::kind_of(self.key)
}
}
@@ -142,27 +124,30 @@ impl UiError {
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 two
/// events, same drain.
/// key to look up, because the server (or a fixed literal) already wrote it. Same frame, same
/// sink, same drain.
///
/// The wire tenants are the reference's own: `SMSG_NOTIFICATION` (`0x1cb`, handler `0x401800` —
/// 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). So the flag IS [`UiError::info`] under another name.
/// (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, bool)>);
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, false));
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, true));
self.0.push((text, MsgKind::Info));
}
}
@@ -181,28 +166,37 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -
(!text.is_empty()).then_some(text)
}
/// Show already-resolved [`UiError`] lines on the surface each message record names — the second
/// half of every [`MsgSurface`] tenant's drain, factored out once it had three (the questgiver
/// refusals 0669, the auction house 1523, the party quest-share 1733). The tenants differ only in
/// how they *build* their lines (a window's queue, a deferred item template, a guid awaiting a
/// name); what they do with a resolved one is this, identically, down to 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).
/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names.
///
/// `who` is the caller's module tag for that debug line.
/// 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.
///
/// `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,
chat: &mut ChatLog,
who: &str,
lines: impl IntoIterator<Item = (MsgSurface, String)>,
lines: impl IntoIterator<Item = (MsgKind, String)>,
) {
for (surface, text) in lines {
debug!("{who}: message ({surface:?}) {text:?}");
match surface {
MsgSurface::Chat => {
for (kind, text) in lines {
debug!("{who}: message ({kind:?}) {text:?}");
match kind {
MsgKind::Chat => {
chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
MsgSurface::Error => {
MsgKind::Info => {
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(text)]);
}
MsgKind::Error => {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
}
@@ -428,7 +422,6 @@ mod ui_error_tests {
key,
fill_s: s.map(String::from),
fill_d: d,
info: false,
}
}
+43 -45
View File
@@ -39,8 +39,8 @@ use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use super::errors::{first_missing_totem, first_short_reagent, mount_result_key};
use super::weapon_icon::{auto_attack_icon, substitutes_weapon_icon};
use super::{
cast_fail, ui_error_text, CastErrors, MountErrors, PlayerActions, Spells, UiError, UiErrorKeys,
UiErrorTexts,
cast_fail, show_messages, ui_error_text, CastErrors, MountErrors, MsgKind, PlayerActions,
Spells, UiError, UiErrorKeys, UiErrorTexts,
};
/// What an ITEM action shows when its icon cannot be resolved — the reference's own hardcoded
@@ -127,7 +127,7 @@ pub(super) fn feed_actions(
focus: spell_focus.as_deref().map(|f| &f.catalog),
areas: areas.as_deref().map(|a| &a.0),
};
let texts: Vec<String> = cast_errors
let texts: Vec<cast_fail::CastFailLine> = cast_errors
.0
.drain(..)
.filter_map(|fail| {
@@ -156,7 +156,7 @@ pub(super) fn feed_actions(
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
return get(key)
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
}
}
if reason == 0x78 || reason == 0x5c {
@@ -191,7 +191,7 @@ pub(super) fn feed_actions(
};
return get(key)
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
.map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name)));
}
// The combat-log twin. **Only the `…SELF` half is reachable here**: `SMSG_CAST_FAILED`
// is addressed to the caster alone, so benilla never learns that somebody *else's*
@@ -257,56 +257,56 @@ pub(super) fn feed_actions(
for line in fail_lines {
chat_log.push_combat(line);
}
for text in texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
show_messages(
&mut script,
&mut chat_log,
"ui_action",
texts
.into_iter()
.map(|l| (benilla_ui::messages::kind_of(l.key), l.text)),
);
// (Dis)mount refusals ride the same red line, keyed straight into GlobalStrings
// (Dis)mount refusals ride the same route, keyed straight into GlobalStrings
// ([`mount_result_key`] — no format arguments in any of these strings).
let mount_texts: Vec<String> = mount_errors
let mount_texts: Vec<(&'static str, String)> = mount_errors
.0
.drain(..)
.filter_map(|(mount, code)| {
let key = mount_result_key(mount, code)?;
script.lua().globals().get::<String>(key).ok()
Some((key, script.lua().globals().get::<String>(key).ok()?))
})
.collect();
for text in mount_texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
show_messages(
&mut script,
&mut chat_log,
"ui_action",
mount_texts
.into_iter()
.map(|(key, text)| (benilla_ui::messages::kind_of(key), text)),
);
// Client-local by-key refusals (the `DisplayError` route — [`UiErrorKeys`]); the key IS the
// GlobalStrings lookup, no code table between. The entry's type arm picks the line: type 1
// (`UiError::info`) is the yellow `UI_INFO_MESSAGE`, type 2 the red `UI_ERROR_MESSAGE` —
// the reference's one pipeline forking on the registry type (wow-re `fish-msg-handlers.md`;
// the fishing verdicts are the yellow tenants).
let key_texts: Vec<(String, bool)> = ui_error_keys
// GlobalStrings lookup, no code table between, and the key is also what names the surface:
// [`UiError::kind`] reads the message record straight out of the catalog instead of the queue
// carrying a hand-set flag alongside every push (decision 1770).
let key_lines: Vec<(MsgKind, String)> = ui_error_keys
.0
.drain(..)
.filter_map(|e| {
ui_error_text(&e, &|key| script.lua().globals().get::<String>(key).ok())
.map(|t| (t, e.info))
.map(|t| (e.kind(), t))
})
.collect();
for (text, info) in key_texts {
let event = if info {
"UI_INFO_MESSAGE"
} else {
"UI_ERROR_MESSAGE"
};
script.fire_event(event, vec![ScriptValue::Str(text)]);
}
show_messages(&mut script, &mut chat_log, "ui_action", key_lines);
// Already-resolved lines ([`UiErrorTexts`]) — the wire's own text, no key to look up. Same
// frame, same two events; the queued bool IS the reference's `0x4945b0` flag.
for (text, info) in ui_error_texts.0.drain(..) {
let event = if info {
"UI_INFO_MESSAGE"
} else {
"UI_ERROR_MESSAGE"
};
script.fire_event(event, vec![ScriptValue::Str(text)]);
}
// Already-resolved lines ([`UiErrorTexts`]) — the wire's own text, no key to look up and no
// record behind it; the queued kind IS the reference's `0x4945b0` flag.
let resolved: Vec<(MsgKind, String)> = ui_error_texts
.0
.drain(..)
.map(|(text, kind)| (kind, text))
.collect();
show_messages(&mut script, &mut chat_log, "ui_action", resolved);
// The ENGINE's own by-key refusals ride the very same line. `benilla_ui` is engine-free and
// cannot reach [`UiErrorKeys`], so a refusal raised inside the script crate (today: dropping a
@@ -315,17 +315,15 @@ pub(super) fn feed_actions(
// frame late by construction (the refusal happens during the input pass this feed precedes),
// which is invisible on a toast.
let engine_keys = script.take_ui_errors();
let engine_texts: Vec<String> = engine_keys
let engine_lines: Vec<(MsgKind, String)> = engine_keys
.into_iter()
.filter_map(|key| {
ui_error_text(&UiError::key(key), &|k| {
script.lua().globals().get::<String>(k).ok()
})
let e = UiError::key(key);
ui_error_text(&e, &|k| script.lua().globals().get::<String>(k).ok())
.map(|t| (e.kind(), t))
})
.collect();
for text in engine_texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
show_messages(&mut script, &mut chat_log, "ui_action", engine_lines);
let store = self_q.iter().next();
+1 -1
View File
@@ -67,7 +67,7 @@ pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors,
CastFail, MountErrors, MsgSurface, UiError, UiErrorKeys, UiErrorTexts,
CastFail, MountErrors, MsgKind, UiError, UiErrorKeys, UiErrorTexts,
};
// `pub(crate)`: the target chain registers the cursor pre-empt + the click commit, and the
// spellbook/stance/craft drains thread the mode through the one cast-send path (decision 0792).
+7 -18
View File
@@ -48,7 +48,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError};
use crate::ui_action::{show_messages, ui_error_text, UiError};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
@@ -255,7 +255,6 @@ pub(crate) struct AuctionOpen {
/// That split is the whole reason this is a queue of records rather than a queue of red strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AuctionMessage {
pub(crate) surface: MsgSurface,
pub(crate) key: &'static str,
/// The item entry whose name fills the `%s`; `None` for the six fill-less lines.
///
@@ -267,31 +266,22 @@ pub(crate) struct AuctionMessage {
}
impl AuctionMessage {
/// A chat line with no fill (`ERR_AUCTION_STARTED` and friends).
/// A line with no fill (`ERR_AUCTION_STARTED` and friends).
pub(crate) fn chat(key: &'static str) -> Self {
Self {
surface: MsgSurface::Chat,
key,
item: None,
}
Self { key, item: None }
}
/// A chat line about one item — the five `_S` outcomes.
/// A line about one item — the five `_S` outcomes.
pub(crate) fn chat_item(key: &'static str, item: u32) -> Self {
Self {
surface: MsgSurface::Chat,
key,
item: Some(item),
}
}
/// A red line — the twelve refusals, none of which take a fill.
/// A refusal — the twelve precondition failures, none of which take a fill.
pub(crate) fn error(key: &'static str) -> Self {
Self {
surface: MsgSurface::Error,
key,
item: None,
}
Self { key, item: None }
}
}
@@ -622,10 +612,9 @@ fn feed_auction(
key: msg.key,
fill_s: fill,
fill_d: None,
info: false,
};
if let Some(text) = ui_error_text(&err, &get) {
lines.push((msg.surface, text));
lines.push((benilla_ui::messages::kind_of(msg.key), text));
}
}
auction.messages = deferred;
+19 -1
View File
@@ -62,7 +62,7 @@
//! Its arm reads nothing off the wire — `0x5e4a09` is `push 0x1c3; call 0x496720`, three
//! instructions — so the whole packet is "show message 451". benilla already models
//! `CGGameUI::DisplayError 0x496720` and its message registry
//! ([`crate::ui_action::MsgSurface`]); what this packet needed was the two facts that registry
//! ([`crate::ui_action::MsgKind`]); what this packet needed was the two facts that registry
//! entry carries, and both are VERIFIED here:
//!
//! - **451 is `ERR_CHAT_RESTRICTED`.** The string `"ERR_CHAT_RESTRICTED"` (`0x83f6d4`) has exactly
@@ -239,6 +239,14 @@ pub(super) fn feed_broadcasts(
push_channel_lines(&mut log, &channels, zone, player_zone, &text);
}
Broadcast::ChatRestricted => {
// The system line is written out here rather than routed through
// `ui_action::show_messages`, and that is not an oversight: of this feed's four
// arms only this one is a `DisplayError` tenant at all — the two defence
// broadcasts are engine-composed CHANNEL lines and `SMSG_SERVER_MESSAGE` composes
// from `ServerMessages.dbc`, so three of the four have no message record to read a
// surface from. The claim that this one's record says "chat" is checked instead,
// in `chat_restricted_is_a_chat_row` below.
//
// An absent key shows nothing — this crate's uniform GlobalStrings stance, and a
// deliberate half-step short of the reference: `0x496720` suppresses on the message
// record's own key field being NULL or empty (`0x4967c5`), but `FrameScript_GetText`
@@ -289,6 +297,16 @@ fn push_channel_lines(
#[cfg(test)]
mod tests {
/// `ERR_CHAT_RESTRICTED` (id 451) carries kind 0, so the arm above is right to write a chat
/// system line and not a red toast — the one hand-set surface left in this feed, checked
/// rather than trusted (decision 1770).
#[test]
fn chat_restricted_is_a_chat_row() {
use benilla_ui::messages::{by_key, kind_of, MsgKind};
assert_eq!(by_key("ERR_CHAT_RESTRICTED").expect("a real row").id, 451);
assert_eq!(kind_of("ERR_CHAT_RESTRICTED"), MsgKind::Chat);
}
use super::*;
use benilla_formats::{ChatChannelRow, ChatChannelsCatalog};
+15
View File
@@ -425,6 +425,21 @@ impl ChatLog {
.collect()
}
/// The text of every parked line headed for a chat window, in order — test-only, the
/// text-shaped twin of [`Self::pending_len`]. A count proves a line was queued; only the text
/// proves it is the *right* line, which is the whole question for a composed one (decision
/// 1764's `ERR_TRADE_BLOCKED_S`, whose `%s` has to name the right player).
#[cfg(test)]
pub(crate) fn pending_texts(&self) -> Vec<String> {
self.pending
.iter()
.filter_map(|p| match p {
Pending::Event(e) => Some(e.text.clone()),
_ => None,
})
.collect()
}
/// How many parked items are headed for a CHAT WINDOW.
///
/// Addon lines are excluded on purpose. They park in the same queue for the same ask-once name
+6 -14
View File
@@ -29,7 +29,7 @@
//! composed at apply time and fired here.
use benilla_ui::script::{
PetitionRecordView, PetitionRequest, PetitionState as VmPetition, ScriptValue, UiScript,
PetitionRecordView, PetitionRequest, PetitionState as VmPetition, UiScript,
PETITION_TYPE_CHARTER, PETITION_TYPE_PETITION,
};
use bevy::prelude::*;
@@ -37,7 +37,7 @@ use bevy::prelude::*;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_chat::ChatLog;
use crate::ui_items::{find_item, ItemSearch};
use crate::ui_session::{npc_switched, NpcSession};
@@ -76,18 +76,10 @@ pub(super) fn feed_petition(
};
let fed = fed.get(&script);
// The composed lines, onto their two channels. Drained before the snapshot so a refusal shows
// in the same frame as the state change that caused it.
for line in std::mem::take(&mut petition.lines) {
match line {
lines::Line::Chat(text) => {
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
lines::Line::Error(text) => {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
}
}
// The composed lines, each onto the surface its message record names. Drained before the
// snapshot so a refusal shows in the same frame as the state change that caused it.
let composed = std::mem::take(&mut petition.lines);
crate::ui_action::show_messages(&mut script, &mut chat_log, "ui_petition", composed);
// **Rebuilt every frame, deliberately.** Half of what this window shows arrives from a CACHE,
// not a packet, and reading that cache is what ISSUES its query (decision 0660's law). So a
+144 -91
View File
@@ -14,6 +14,10 @@
//! - the **five** refusals `0x145`-`0x149`, and `ERR_NO_GUILD_CHARTER` (`0x7c`), carry kind `2` →
//! `UI_ERROR_MESSAGE` (`0xe0`), the **red UIErrorsFrame line**.
//!
//! That sweep is no longer this module's to carry: since decision 1770 the whole registry ships as
//! [`benilla_ui::messages`], and each line below asks it by key. The paragraphs above stay because
//! they record *why* the split is not guessable — the reason a lookup was worth building.
//!
//! Two more facts from the same sweep that shape the tables below:
//!
//! - **`PETITION_SIGN_NEED_MORE` (4) has no user-visible message on the SIGN path at all.** It
@@ -32,45 +36,93 @@
use benilla_protocol::messages::petition_result;
use crate::ui_action::MsgKind;
/// One message this module composes: the **catalog key**, which names both the row and — through
/// [`benilla_ui::messages`] — the surface it goes to, and the 1.12 text.
///
/// The key rides beside the text rather than the surface doing so, because the surface is not this
/// module's to know: it is the message record's `+0x04`, and reading it here from the catalog is
/// what retired the hand-swept table this file used to carry (decision 1770). The five
/// `ERR_GUILD_NAME_*` rows below were recorded as **INFERRED** for exactly that reason; the sweep
/// confirms all five are kind 2, so the inference is now a lookup.
#[derive(Debug, Clone, Copy)]
struct Msg {
key: &'static str,
text: &'static str,
}
const fn msg(key: &'static str, text: &'static str) -> Msg {
Msg { key, text }
}
// ── The templates, quoted verbatim from the reference's own patch chain; the `GlobalStrings.lua`
// line is on each ──────────────────────────────────────────────────────────────────────────
const ERR_PETITION_ALREADY_SIGNED: &str = "You have already signed that guild charter."; // :1762
const ERR_PETITION_CREATOR: &str = "You can't sign your own guild charter."; // :1763
const ERR_PETITION_DECLINED_S: &str = "%s has declined your guild invitation."; // :1764
const ERR_PETITION_IN_GUILD: &str = "You are already in a guild."; // :1765
const ERR_PETITION_NOT_ENOUGH_SIGNATURES: &str = "You need more signatures."; // :1766
const ERR_PETITION_NOT_SAME_SERVER: &str = "That player is not from your server"; // :1767
const ERR_PETITION_OFFERED_S: &str = "You have requested %s's signature."; // :1768
const ERR_PETITION_SIGNED: &str = "Guild charter signed."; // :1769
const ERR_PETITION_SIGNED_S: &str = "%s has signed your guild charter."; // :1770
const ERR_NO_GUILD_CHARTER: &str = "You don't have a guild charter."; // :1745
const ERR_PETITION_ALREADY_SIGNED: Msg = msg(
"ERR_PETITION_ALREADY_SIGNED",
"You have already signed that guild charter.",
); // :1762
const ERR_PETITION_CREATOR: Msg = msg(
"ERR_PETITION_CREATOR",
"You can't sign your own guild charter.",
); // :1763
const ERR_PETITION_DECLINED_S: Msg = msg(
"ERR_PETITION_DECLINED_S",
"%s has declined your guild invitation.",
); // :1764
const ERR_PETITION_IN_GUILD: Msg = msg("ERR_PETITION_IN_GUILD", "You are already in a guild."); // :1765
const ERR_PETITION_NOT_ENOUGH_SIGNATURES: Msg = msg(
"ERR_PETITION_NOT_ENOUGH_SIGNATURES",
"You need more signatures.",
); // :1766
const ERR_PETITION_NOT_SAME_SERVER: Msg = msg(
"ERR_PETITION_NOT_SAME_SERVER",
"That player is not from your server",
); // :1767
const ERR_PETITION_OFFERED_S: Msg = msg(
"ERR_PETITION_OFFERED_S",
"You have requested %s's signature.",
); // :1768
const ERR_PETITION_SIGNED: Msg = msg("ERR_PETITION_SIGNED", "Guild charter signed."); // :1769
const ERR_PETITION_SIGNED_S: Msg =
msg("ERR_PETITION_SIGNED_S", "%s has signed your guild charter."); // :1770
const ERR_NO_GUILD_CHARTER: Msg = msg("ERR_NO_GUILD_CHARTER", "You don't have a guild charter."); // :1745
// The guild-name validator's messages (`0x4f5160`'s seven arms). Only the three
// `benilla_ui::script::validate_guild_name` implements can be produced; the rest are here so the
// key→text map is complete and a later carve of `0x6c9b70` only has to add the branch.
const ERR_GUILD_ENTER_NAME: &str = "Enter a name for your guild."; // :1603
const ERR_GUILD_NAME_INVALID: &str = "That name contains invalid characters, Enter a new name."; // :1616
const ERR_GUILD_NAME_INVALID_SPACE: &str =
"Guild names cannot start or end with a space. Enter a new name."; // :1617
const ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES: &str =
"Consecutive spaces are not allowed. Enter a new name."; // :1619
const ERR_GUILD_NAME_TOO_SHORT: &str = "That name is too short. Enter a new name."; // :1622
const ERR_GUILD_ENTER_NAME: Msg = msg("ERR_GUILD_ENTER_NAME", "Enter a name for your guild."); // :1603
const ERR_GUILD_NAME_INVALID: Msg = msg(
"ERR_GUILD_NAME_INVALID",
"That name contains invalid characters, Enter a new name.",
); // :1616
const ERR_GUILD_NAME_INVALID_SPACE: Msg = msg(
"ERR_GUILD_NAME_INVALID_SPACE",
"Guild names cannot start or end with a space. Enter a new name.",
); // :1617
const ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES: Msg = msg(
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES",
"Consecutive spaces are not allowed. Enter a new name.",
); // :1619
const ERR_GUILD_NAME_TOO_SHORT: Msg = msg(
"ERR_GUILD_NAME_TOO_SHORT",
"That name is too short. Enter a new name.",
); // :1622
/// Which of the client's two channels a line goes to — the `+4 kind` field, as a type.
///
/// Modelled rather than left implicit because the split does not follow the key names, and a
/// `String` return would let a future edit put a refusal in the chat log without anything noticing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Line {
/// Kind `0` → the `CHAT_MSG_SYSTEM` chat line.
Chat(String),
/// Kind `2` → the red `UI_ERROR_MESSAGE` frame.
Error(String),
/// One composed line: the surface its message record names, and the text.
pub(super) type Line = (MsgKind, String);
/// A line with nothing to fill.
fn line(m: Msg) -> Line {
(benilla_ui::messages::kind_of(m.key), m.text.to_string())
}
/// One `%s` substitution.
fn fill(template: &str, arg: &str) -> String {
template.replacen("%s", arg, 1)
/// A line with one `%s` substitution.
fn fill(m: Msg, arg: &str) -> Line {
(
benilla_ui::messages::kind_of(m.key),
m.text.replacen("%s", arg, 1),
)
}
/// The line one `SMSG_PETITION_SIGN_RESULTS` prints **when the signer is us** — the switch at
@@ -82,11 +134,11 @@ fn fill(template: &str, arg: &str) -> String {
/// `NEED_MORE` and anything `>= 6` return `None` — the default arm reaches only the debug console.
pub(super) fn my_sign_line(result: u32) -> Option<Line> {
Some(match result {
petition_result::OK => Line::Chat(ERR_PETITION_SIGNED.to_string()),
petition_result::ALREADY_SIGNED => Line::Error(ERR_PETITION_ALREADY_SIGNED.to_string()),
petition_result::ALREADY_IN_GUILD => Line::Error(ERR_PETITION_IN_GUILD.to_string()),
petition_result::CANT_SIGN_OWN => Line::Error(ERR_PETITION_CREATOR.to_string()),
petition_result::NOT_SERVER => Line::Error(ERR_PETITION_NOT_SAME_SERVER.to_string()),
petition_result::OK => line(ERR_PETITION_SIGNED),
petition_result::ALREADY_SIGNED => line(ERR_PETITION_ALREADY_SIGNED),
petition_result::ALREADY_IN_GUILD => line(ERR_PETITION_IN_GUILD),
petition_result::CANT_SIGN_OWN => line(ERR_PETITION_CREATOR),
petition_result::NOT_SERVER => line(ERR_PETITION_NOT_SAME_SERVER),
// `NEED_MORE` (4) and >= 6: the default arm, debug console only.
_ => return None,
})
@@ -96,15 +148,15 @@ pub(super) fn my_sign_line(result: u32) -> Option<Line> {
/// emitted **only when the signer's name is already cached** (`0x4f42f6`); on a cache miss the
/// client increments its pending-name counter instead and says nothing until the name lands.
pub(super) fn signed_by_other(name: &str) -> Line {
Line::Chat(fill(ERR_PETITION_SIGNED_S, name))
fill(ERR_PETITION_SIGNED_S, name)
}
/// The line one `SMSG_TURN_IN_PETITION_RESULTS` prints. **Success prints nothing** — see the module
/// doc; it is not an omission here.
pub(super) fn turn_in_line(result: u32) -> Option<Line> {
Some(match result {
petition_result::ALREADY_IN_GUILD => Line::Error(ERR_PETITION_IN_GUILD.to_string()),
petition_result::NEED_MORE => Line::Error(ERR_PETITION_NOT_ENOUGH_SIGNATURES.to_string()),
petition_result::ALREADY_IN_GUILD => line(ERR_PETITION_IN_GUILD),
petition_result::NEED_MORE => line(ERR_PETITION_NOT_ENOUGH_SIGNATURES),
_ => return None,
})
}
@@ -113,47 +165,45 @@ pub(super) fn turn_in_line(result: u32) -> Option<Line> {
/// **only if the declining player's name is already cached**. There is no query and no retry: an
/// uncached name means the owner is told nothing at all (`0x5ef12a`/`0x5ef139`).
pub(super) fn declined_line(name: &str) -> Line {
Line::Chat(fill(ERR_PETITION_DECLINED_S, name))
fill(ERR_PETITION_DECLINED_S, name)
}
/// The local echo for a charter we just offered — emitted **optimistically on the send**, with no
/// server confirmation (`0x4f48fa`). Nothing comes back on success; the *target* is the one the
/// server answers.
pub(super) fn offered_line(name: &str) -> Line {
Line::Chat(fill(ERR_PETITION_OFFERED_S, name))
fill(ERR_PETITION_OFFERED_S, name)
}
/// Offering a charter to yourself — guard 6 of `OfferPetition`'s eight (`0x4f4839`), which emits
/// `ERR_PETITION_CREATOR`, the same red line signing your own charter gets.
pub(super) fn self_offer_line() -> Line {
Line::Error(ERR_PETITION_CREATOR.to_string())
line(ERR_PETITION_CREATOR)
}
/// The local refusal when `TurnInGuildCharter()`'s bag scan finds no charter — a **red** line
/// (`0x5ef49a` emits id `0x7c`, kind 2), and no packet is built.
pub(super) fn no_charter_line() -> Line {
Line::Error(ERR_NO_GUILD_CHARTER.to_string())
line(ERR_NO_GUILD_CHARTER)
}
/// The line for a name the client's own validator refused
/// ([`benilla_ui::script::validate_guild_name`]) — a red line, by the class every other
/// `ERR_GUILD_NAME_*` row in the catalog belongs to.
/// ([`benilla_ui::script::validate_guild_name`]) — a red line.
///
/// **The kind of these five rows is INFERRED**, unlike the nine petition rows above: that round
/// swept the petition ids and `0x7c`, not the `0x75`-`0x7b` band. They are refusals, and every
/// refusal in the swept set is kind 2. An unknown key falls back to the generic invalid-name text
/// rather than showing a raw key.
/// **These five rows were recorded here as INFERRED** — the sweep that settled the nine petition
/// ids and `0x7c` never covered the `0x75`-`0x7b` band, so the kind was reasoned from the fact
/// that every refusal in the swept set is kind 2. The full catalog (decision 1770) confirms all
/// five: the inference was right, and it is a lookup now rather than a reason.
///
/// An unknown key falls back to the generic invalid-name text rather than showing a raw key.
pub(super) fn name_refused_line(key: &str) -> Line {
Line::Error(
match key {
"ERR_GUILD_ENTER_NAME" => ERR_GUILD_ENTER_NAME,
"ERR_GUILD_NAME_INVALID_SPACE" => ERR_GUILD_NAME_INVALID_SPACE,
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES" => ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
"ERR_GUILD_NAME_TOO_SHORT" => ERR_GUILD_NAME_TOO_SHORT,
_ => ERR_GUILD_NAME_INVALID,
}
.to_string(),
)
line(match key {
"ERR_GUILD_ENTER_NAME" => ERR_GUILD_ENTER_NAME,
"ERR_GUILD_NAME_INVALID_SPACE" => ERR_GUILD_NAME_INVALID_SPACE,
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES" => ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
"ERR_GUILD_NAME_TOO_SHORT" => ERR_GUILD_NAME_TOO_SHORT,
_ => ERR_GUILD_NAME_INVALID,
})
}
#[cfg(test)]
@@ -167,19 +217,22 @@ mod tests {
fn successes_go_to_chat_and_refusals_go_to_the_red_line() {
assert_eq!(
my_sign_line(petition_result::OK),
Some(Line::Chat("Guild charter signed.".into()))
Some((MsgKind::Chat, "Guild charter signed.".into()))
);
assert_eq!(
signed_by_other("Bob"),
Line::Chat("Bob has signed your guild charter.".into())
(MsgKind::Chat, "Bob has signed your guild charter.".into())
);
assert_eq!(
declined_line("Bob"),
Line::Chat("Bob has declined your guild invitation.".into())
(
MsgKind::Chat,
"Bob has declined your guild invitation.".into()
)
);
assert_eq!(
offered_line("Bob"),
Line::Chat("You have requested Bob's signature.".into())
(MsgKind::Chat, "You have requested Bob's signature.".into())
);
for (code, text) in [
@@ -202,13 +255,13 @@ mod tests {
] {
assert_eq!(
my_sign_line(code),
Some(Line::Error(text.into())),
Some((MsgKind::Error, text.into())),
"code {code} is a RED line"
);
}
assert_eq!(
no_charter_line(),
Line::Error("You don't have a guild charter.".into())
(MsgKind::Error, "You don't have a guild charter.".into())
);
}
@@ -224,7 +277,7 @@ mod tests {
);
assert_eq!(
turn_in_line(petition_result::NEED_MORE),
Some(Line::Error("You need more signatures.".into()))
Some((MsgKind::Error, "You need more signatures.".into()))
);
}
@@ -262,30 +315,24 @@ mod tests {
.unwrap_or_else(|e| panic!("{key} missing from GlobalStrings: {e}"))
};
for (key, ours) in [
("ERR_PETITION_ALREADY_SIGNED", ERR_PETITION_ALREADY_SIGNED),
("ERR_PETITION_CREATOR", ERR_PETITION_CREATOR),
("ERR_PETITION_DECLINED_S", ERR_PETITION_DECLINED_S),
("ERR_PETITION_IN_GUILD", ERR_PETITION_IN_GUILD),
(
"ERR_PETITION_NOT_ENOUGH_SIGNATURES",
ERR_PETITION_NOT_ENOUGH_SIGNATURES,
),
("ERR_PETITION_NOT_SAME_SERVER", ERR_PETITION_NOT_SAME_SERVER),
("ERR_PETITION_OFFERED_S", ERR_PETITION_OFFERED_S),
("ERR_PETITION_SIGNED", ERR_PETITION_SIGNED),
("ERR_PETITION_SIGNED_S", ERR_PETITION_SIGNED_S),
("ERR_NO_GUILD_CHARTER", ERR_NO_GUILD_CHARTER),
("ERR_GUILD_ENTER_NAME", ERR_GUILD_ENTER_NAME),
("ERR_GUILD_NAME_INVALID", ERR_GUILD_NAME_INVALID),
("ERR_GUILD_NAME_INVALID_SPACE", ERR_GUILD_NAME_INVALID_SPACE),
(
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES",
ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
),
("ERR_GUILD_NAME_TOO_SHORT", ERR_GUILD_NAME_TOO_SHORT),
for m in [
ERR_PETITION_ALREADY_SIGNED,
ERR_PETITION_CREATOR,
ERR_PETITION_DECLINED_S,
ERR_PETITION_IN_GUILD,
ERR_PETITION_NOT_ENOUGH_SIGNATURES,
ERR_PETITION_NOT_SAME_SERVER,
ERR_PETITION_OFFERED_S,
ERR_PETITION_SIGNED,
ERR_PETITION_SIGNED_S,
ERR_NO_GUILD_CHARTER,
ERR_GUILD_ENTER_NAME,
ERR_GUILD_NAME_INVALID,
ERR_GUILD_NAME_INVALID_SPACE,
ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
ERR_GUILD_NAME_TOO_SHORT,
] {
assert_eq!(g(key), ours, "{key} is not what the client says");
assert_eq!(g(m.key), m.text, "{} is not what the client says", m.key);
}
}
@@ -295,15 +342,21 @@ mod tests {
fn refused_names_resolve_their_key_and_degrade_safely() {
assert_eq!(
name_refused_line("ERR_GUILD_ENTER_NAME"),
Line::Error("Enter a name for your guild.".into())
(MsgKind::Error, "Enter a name for your guild.".into())
);
assert_eq!(
name_refused_line("ERR_GUILD_NAME_INVALID_SPACE"),
Line::Error("Guild names cannot start or end with a space. Enter a new name.".into())
(
MsgKind::Error,
"Guild names cannot start or end with a space. Enter a new name.".into()
)
);
assert_eq!(
name_refused_line("ERR_SOMETHING_UNCARVED"),
Line::Error("That name contains invalid characters, Enter a new name.".into()),
(
MsgKind::Error,
"That name contains invalid characters, Enter a new name.".into()
),
"never a raw key on screen"
);
}
+10 -10
View File
@@ -29,7 +29,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError};
use crate::ui_action::{show_messages, ui_error_text, MsgKind, UiError};
use crate::ui_chat::ChatLog;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -69,7 +69,7 @@ pub(crate) struct QuestGiver {
/// reference's `DisplayError(msgId)` split into (surface, GlobalStrings key + fills), so the
/// line comes from the VM's own strings and never from a hardcoded English literal
/// (decision 0669).
messages: Vec<(MsgSurface, UiError)>,
messages: Vec<UiError>,
/// The re-ask epoch — see [`Self::bump_reask`].
reask: u32,
}
@@ -163,13 +163,14 @@ impl QuestGiver {
}
/// Queue one client message for [`feed_quest`] to resolve and show — the net apply's half of
/// the reference's `DisplayError(msgId)` (decision 0669).
pub(crate) fn push_message(&mut self, surface: MsgSurface, msg: UiError) {
self.messages.push((surface, msg));
/// the reference's `DisplayError(msgId)` (decision 0669). The message carries its own surface
/// in its key; the caller does not choose one (decision 1770).
pub(crate) fn push_message(&mut self, msg: UiError) {
self.messages.push(msg);
}
/// Take the queued client messages (drained by [`feed_quest`], which owns the VM).
pub(crate) fn take_messages(&mut self) -> Vec<(MsgSurface, UiError)> {
pub(crate) fn take_messages(&mut self) -> Vec<UiError> {
std::mem::take(&mut self.messages)
}
@@ -465,12 +466,12 @@ fn feed_quest(
// VM's own GlobalStrings first (immutable script), then show each on the surface its message
// record names — decision 0669's `DisplayError` kind. Drained BEFORE the snapshot's early-out
// so a refusal that also closes the panel still gets its line out this frame.
let lines: Vec<(MsgSurface, String)> = giver
let lines: Vec<(MsgKind, String)> = giver
.take_messages()
.into_iter()
.filter_map(|(surface, msg)| {
.filter_map(|msg| {
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
ui_error_text(&msg, &get).map(|text| (surface, text))
ui_error_text(&msg, &get).map(|text| (msg.kind(), text))
})
.collect();
show_messages(&mut script, &mut chat, "ui_quest", lines);
@@ -978,7 +979,6 @@ mod tests {
key: questgiver_failed_key(4),
fill_s: Some("A Threat Within".into()),
fill_d: None,
info: false,
},
&g
)
+9 -11
View File
@@ -47,7 +47,7 @@ use bevy::prelude::*;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::{show_messages, ui_error_text, MsgSurface, UiError};
use crate::ui_action::{show_messages, ui_error_text, MsgKind, UiError};
use crate::ui_chat::ChatLog;
use crate::ui_script::UiInput;
@@ -116,7 +116,7 @@ impl QuestShare {
/// `push 0x0` at `0x487dc9`-`0x487e69`), which `CGGameUI::DisplayError` dispatches to the chat
/// window as `CHAT_MSG_SYSTEM`. Not one of them reaches `UIErrorsFrame`, which is what 1733
/// guessed and is why the guess is recorded as having been a guess.
fn verdict_message(msg: QuestShareMsg) -> Option<(MsgSurface, &'static str)> {
fn verdict_message(msg: QuestShareMsg) -> Option<&'static str> {
let key = match msg {
QuestShareMsg::SHARING_QUEST => "ERR_QUEST_PUSH_SUCCESS_S",
QuestShareMsg::CANT_TAKE_QUEST => "ERR_QUEST_PUSH_INVALID_S",
@@ -129,7 +129,7 @@ fn verdict_message(msg: QuestShareMsg) -> Option<(MsgSurface, &'static str)> {
QuestShareMsg::FINISH_QUEST => "ERR_QUEST_PUSH_ALREADY_DONE_S",
_ => return None,
};
Some((MsgSurface::Chat, key))
Some(key)
}
/// Owns [`QuestShare`] and its two systems — a plugin of its own rather than a lodger in
@@ -166,10 +166,10 @@ fn feed_quest_share(
};
// The verdicts: one line per resolved name, the rest kept for the next frame.
let mut lines: Vec<(MsgSurface, String)> = Vec::new();
let mut lines: Vec<(MsgKind, String)> = Vec::new();
let mut waiting = Vec::new();
for mut v in std::mem::take(&mut share.verdicts) {
let Some((surface, key)) = verdict_message(v.msg) else {
let Some(key) = verdict_message(v.msg) else {
debug!(
"ui_quest_share: unmapped push verdict {} — no line",
v.msg.0
@@ -182,11 +182,10 @@ fn feed_quest_share(
key,
fill_s: Some(name),
fill_d: None,
info: false,
};
let get = |k: &str| script.lua().globals().get::<String>(k).ok();
if let Some(text) = ui_error_text(&err, &get) {
lines.push((surface, text));
lines.push((err.kind(), text));
}
}
None => {
@@ -259,7 +258,7 @@ mod tests {
#[test]
fn verdict_keys_are_distinct() {
let mut keys: Vec<&str> = (0u8..=8)
.filter_map(|v| verdict_message(QuestShareMsg(v)).map(|(_, k)| k))
.filter_map(|v| verdict_message(QuestShareMsg(v)))
.collect();
keys.sort_unstable();
let n = keys.len();
@@ -284,7 +283,7 @@ mod tests {
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
for raw in 0u8..=8 {
let (_, key) = verdict_message(QuestShareMsg(raw)).expect("mapped");
let key = verdict_message(QuestShareMsg(raw)).expect("mapped");
let text = g(key).unwrap_or_default();
assert!(!text.is_empty(), "{key} (verdict {raw}) missing");
assert!(text.contains("%s"), "{key} names no member: {text:?}");
@@ -293,13 +292,12 @@ mod tests {
// The two ends of the flow, filled: the opener the sharer sees the instant they click, and
// the answer a decline produces.
let line = |raw: u8| {
let (_, key) = verdict_message(QuestShareMsg(raw)).unwrap();
let key = verdict_message(QuestShareMsg(raw)).unwrap();
ui_error_text(
&UiError {
key,
fill_s: Some("Mate".into()),
fill_d: None,
info: false,
},
&g,
)
@@ -2151,9 +2151,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
// 53 of the 55 are 1.12's own; the other two are Render Scale (1639) and Display Mode
// 54 of the 56 are 1.12's own; the other two are Render Scale (1639) and Display Mode
// (1650), whose descriptions are benilla's and whose carve-out is above.
assert_eq!(checked, 55, "every tipped row carries a live key");
// The 28th CVar row is Block Trades (1764), on the Controls page — its key
// OPTION_TOOLTIP_BLOCK_TRADES is the reference's, so it is counted here like the rest.
assert_eq!(checked, 56, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2260,7 +2262,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
// …plus the Graphics page's Render Scale (1639) and Display Mode (1650), the two rows whose
// descriptions are benilla's own rather than 1.12 GlobalStrings — see the guard above.
assert_eq!(raised, 55, "every row but Auto Loot raises a description");
// …and Block Trades (1764), the Controls page's 28th CVar row.
assert_eq!(raised, 56, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
+7
View File
@@ -140,6 +140,13 @@ impl SocialState {
self.friends_dirty = true;
}
/// Seat the ignore list directly — test-only, for the neighbours that consult it rather than
/// build it (decision 1764's trade-request ladder, whose leg 2 answers on its own opcode).
#[cfg(test)]
pub(crate) fn set_ignores_for_test(&mut self, guids: Vec<u64>) {
self.apply_ignore_list(guids);
}
/// `SMSG_IGNORE_LIST` — likewise.
fn apply_ignore_list(&mut self, guids: Vec<u64>) {
self.ignores = guids;
+567 -20
View File
@@ -14,6 +14,24 @@
//! intents (`InitiateTrade`/`AcceptTrade`/`CancelTradeAccept`/`CloseTrade`) back out into the trade
//! `CMSG`s.
//!
//! **An incoming request is answered here, not on the wire** (decision 1764).
//! `SMSG_TRADE_STATUS(BEGIN_TRADE)` only *records* the request; [`answer_trade_request`] walks the
//! reference's own eight-leg ladder (`0x4bf736`) and either refuses it — on one of three different
//! opcodes, or with no packet at all — or accepts it, which is what the client did unconditionally
//! before. The split is what makes the ladder possible: it reads the ignore list, the cinematic
//! state, player control, the auction house, the initiator's descriptor, an outbound initiate of
//! ours and the [`BlockTrades`] CVar, none of which can reach `apply_net_updates` at Bevy's
//! 16-`SystemParam` ceiling — and its answer may span frames, which a wire decoder cannot do at
//! all. Decision 1725 left the cinematic leg unbuilt for exactly that reason and said so; this is
//! the seam that makes it, and the seven beside it, ordinary.
//!
//! **There is no consent prompt, and that is faithful rather than missing.** 1.12.1 registers a
//! `TRADE_REQUEST` event and signals it from nowhere, so the `TRADE` StaticPopup that would have
//! asked is dead code in the real client (§5 cross-checked — wow-re
//! `ui/scratch/incoming-trade-request-law.md` §3). benilla wired that dialog up and took it back
//! out (decision 1764): the reference's answer to an unwanted trade is the ignore list and the
//! *Block Trades* checkbox, both of which are legs of the ladder below.
//!
//! **The partner's portrait** rides the shared `"npc"` booth token: [`TradeSession`] implements
//! [`NpcSession`] so [`crate::ui_session::feed_interact_npc`] points the `"npc"` portrait at the
//! partner's live entity while the window is open — the partner is a nearby player (within
@@ -36,7 +54,7 @@ use benilla_ui::script::{ScriptValue, TradeSideState, TradeSlotItem, TradeState,
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::target::Selection;
use crate::ui_party::GroupState;
use crate::ui_script::UiInput;
@@ -59,12 +77,35 @@ struct TradeOffer {
///
/// `partner` is set the moment the trade begins (the initiator records its target at
/// `CMSG_INITIATE_TRADE` time, since the later `OPEN_WINDOW` carries no guid; the target records it
/// from `BEGIN_TRADE`); `open` flips on `OPEN_WINDOW`. `npc()` is gated on `open` so the `"npc"`
/// portrait bakes only while the window is up.
/// when the ladder *accepts* the request — see `request`); `open` flips on `OPEN_WINDOW`. `npc()`
/// is gated on `open` so the `"npc"` portrait bakes only while the window is up.
#[derive(Resource, Default)]
pub(crate) struct TradeSession {
/// The trade partner's guid; `Some` from the initiate/begin onward, `None` = no trade.
/// The trade partner's guid; `Some` from the initiate/accept onward, `None` = no trade.
partner: Option<u64>,
/// An incoming `BEGIN_TRADE` **this client has not answered yet** (decision 1764) — the
/// initiator's guid. Set by [`Self::request`] and cleared the moment
/// [`answer_trade_request`] resolves it, either by promoting it to `partner`
/// ([`Self::begin`]) or by refusing ([`Self::refuse_request`]). Mutually exclusive with
/// `partner`: a request that has not been answered is not yet a trade.
request: Option<u64>,
/// **An outbound `CMSG_INITIATE_TRADE` of ours is live** — the reference's `[0xc4bec8]`, and
/// leg 1 of [`answer_trade_request`]'s ladder, where it drops an incoming request without a
/// reply of any kind.
///
/// Its own field rather than `partner.is_some()` because the two are different sets, and the
/// binary is explicit about which — a complete 5-site census of `[0xc4bec8]`: set at
/// `0x5d4021` as `CMSG_INITIATE_TRADE` goes out, zeroed at module init (`0x5d478e`) and in the
/// status handler's common tail (`0x5d4931`), read twice. So a trade we did not start has it
/// clear for its whole life, while `partner` is set the moment we accept one.
///
/// **It survives `OPEN_WINDOW`**, which is the half that is easy to get wrong and was worth
/// asking about: the tail only clears on the statuses that set `esi`, and 1, 2 (`OPEN_WINDOW`),
/// 4, 7, 9 and 22 do not — so the latch stands for the whole life of a trade *we* opened, and
/// leg 1 drops an incoming request throughout it. Modelling it as "we asked and no window has
/// opened yet" would have been strictly narrower, and wrong in exactly the window where it
/// matters. Ours is cleared by the session reset, which is the same moment.
initiated: bool,
/// The window is up (`OPEN_WINDOW` arrived). `partner` can be `Some` before this (an initiate in
/// flight the server hasn't opened yet).
open: bool,
@@ -86,12 +127,40 @@ impl TradeSession {
pub(crate) fn initiate(&mut self, target: u64) {
*self = TradeSession {
partner: Some(target),
initiated: true,
..Default::default()
};
}
/// `BEGIN_TRADE(initiator guid)` — the target learns the partner and (via the drain-adjacent
/// bridge) auto-answers `CMSG_BEGIN_TRADE`. Resets any stale prior offer.
/// `BEGIN_TRADE(initiator guid)` — record the incoming request, **unanswered**. Nothing goes
/// on the wire here (decision 1764): [`answer_trade_request`] owns the reply.
///
/// Records it *beside* whatever session already exists rather than resetting — a request is
/// not a trade, and a request arriving over a live one must not destroy it. That reset is
/// [`Self::begin`]'s, at the moment the request is accepted and there really is a fresh trade.
/// Leaving it here would also have made the "already trading" refusal leg dead code, since
/// the ladder reads the very fields the reset had just cleared.
pub(crate) fn request(&mut self, initiator: u64) {
self.request = Some(initiator);
}
/// The incoming request awaiting an answer, if any.
pub(crate) fn pending_request(&self) -> Option<u64> {
self.request
}
/// Spend the pending request **without touching the rest of the session** — the refusal path's
/// counterpart to [`Self::begin`]. It clears only the request for the same reason
/// [`Self::request`] sets only the request: refusing an offer is not cancelling the trade you
/// are in, and on a fresh session the two are indistinguishable anyway (there is nothing else
/// to clear). The server's own `TRADE_STATUS_BUSY` reply closes what needs closing.
pub(crate) fn refuse_request(&mut self) {
self.request = None;
}
/// Accept the pending request: the initiator becomes the partner and the request is spent. The
/// caller sends `CMSG_BEGIN_TRADE` in the same breath; the server answers both sides
/// `OPEN_WINDOW`.
pub(crate) fn begin(&mut self, partner: u64) {
*self = TradeSession {
partner: Some(partner),
@@ -215,21 +284,213 @@ pub(crate) struct UiTradePlugin;
impl Plugin for UiTradePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TradeSession>().add_systems(
Update,
(
// Feed before the input pass so an open/close/mirror is on screen the same frame;
// drain after it so a click's intent (initiate, accept, close) goes out the same
// frame (the ui_mail ordering exactly). After the UnitFeed set so the resolved
// item-template store is landed. No range-guard registration — a trade's cancel is
// server-driven (the module doc).
feed_trade.after(crate::ui_unit::UnitFeed).before(UiInput),
drain_trade.after(UiInput),
),
);
app.init_resource::<TradeSession>()
.init_resource::<BlockTrades>()
.add_systems(
Update,
(
// Feed before the input pass so an open/close/mirror is on screen the same
// frame; drain after it so a click's intent (initiate, accept, close) goes out
// the same frame (the ui_mail ordering exactly). After the UnitFeed set so the
// resolved item-template store is landed. No range-guard registration — a
// trade's cancel is server-driven (the module doc).
feed_trade.after(crate::ui_unit::UnitFeed).before(UiInput),
// The incoming request's answer needs no VM at all — it is wire policy over
// engine state — so it sits ahead of the feed, and an accepted request's
// `partner` is on screen the same frame the window opens.
answer_trade_request.before(feed_trade),
drain_trade.after(UiInput),
),
);
}
}
/// **Block Trades** — 1.12's own `BlockTrades` CVar (`0x842fbc`), the *Block Trades* checkbox in
/// Basic Options' General box (`UIOptionsFrame.lua` l.11: `UIOptionsFrameCheckButtons`
/// `["BLOCK_TRADES"] = { index = 14, cvar = "BlockTrades" }`, tooltip
/// `OPTION_TOOLTIP_BLOCK_TRADES` = "Block all incoming trade requests.").
///
/// A knob resource rather than a field on [`TradeSession`], for the same reason
/// [`crate::ui_guild::GuildMemberNotify`] is one: it is a *setting*, and `TradeSession` is cleared
/// on every disconnect.
///
/// Its one reader is [`answer_trade_request`]'s last refusal leg — the only leg of that ladder
/// that speaks, which is why the silent ones are ordered ahead of it.
#[derive(Resource, Default)]
pub(crate) struct BlockTrades(pub(crate) bool);
/// `ERR_TRADE_BLOCKED_S` (`GlobalStrings.lua` l.1889) — the line the *Block Trades* refusal prints,
/// naming the would-be partner. Two spaces after the full stop, as shipped.
const ERR_TRADE_BLOCKED_S: &str = "%s has requested to trade. You have refused.";
/// **Answer an incoming trade request** — the arm the reference keeps inside `CGTradeInfo`'s
/// dispatcher (`0x4bf736`, case 1 of the 23-case `0x4bf720`), lifted out to where its inputs live.
///
/// The net drain only *records* the request ([`TradeSession::request`]); this system decides. The
/// split is the point: the decision reads the ignore list, the cinematic state, player control, the
/// auction house, the initiator's own descriptor, an outbound initiate of ours and a CVar, and none
/// of those can reach `apply_net_updates`, whose signature is at Bevy's 16-`SystemParam` ceiling.
/// That ceiling is exactly why decision 1725 left the cinematic leg unbuilt and said so; it is not
/// a ceiling this side of the seam has. The answer can also **span frames**, which a wire decoder
/// cannot do at all: the `BlockTrades` line names the initiator, and that name may need a
/// `CMSG_NAME_QUERY` first.
///
/// **The ladder is the reference's, in its order** — eight legs, walked contiguously over
/// `[0x4bf736, 0x4bf7f8)` and cross-checked by four independent derivations (wow-re
/// `ui/scratch/incoming-trade-request-law.md` §2). Three answer with something other than "busy",
/// which is why this is a ladder and not a boolean:
///
/// | # | condition | benilla reads | answer |
/// |---|---|---|---|
/// | 1 | our own `CMSG_INITIATE_TRADE` is in flight (`[0xc4bec8]`) | [`TradeSession::initiated`] | **nothing at all** |
/// | 2 | the initiator is on our **ignore list** (`0x5ae5a0`, by guid, cap 25) | [`crate::ui_social::SocialState::is_ignored`] | **`CMSG_IGNORE_TRADE`**, silent |
/// | 3 | the initiator resolves to no streamed **player** object (`0x468460(TYPEMASK_PLAYER)`) | [`GuidIndex`] + [`ObjectStore`] | **nothing at all** |
/// | 4 | the initiator is dead or a ghost (`0x605f30`) | raw health ≤ 0, or `PLAYER_FLAGS` ghost | `CMSG_BUSY_TRADE`, silent |
/// | 5 | a cinematic is playing (`[0xb4e310]`) | [`crate::cinematic::Cinematic`] | `CMSG_BUSY_TRADE`, silent |
/// | 6 | we have lost **player control** (`[0xb4b3e4]`) | `Player::control_lost` | `CMSG_BUSY_TRADE`, silent |
/// | 7 | an **auction house** window is open (`[0xb725f8]`/`[0xb725fc]`) | [`crate::ui_auction::AuctionOpen`] | `CMSG_BUSY_TRADE`, silent |
/// | 8 | the `BlockTrades` CVar is set (`0x842fbc`) | [`BlockTrades`] | `CMSG_BUSY_TRADE` **+ the one chat line** |
/// | — | nothing refuses it | — | **`CMSG_BEGIN_TRADE`**, and the server opens both windows |
///
/// Only leg 8 speaks, so the **order is behaviour**, not taste: a request during a cinematic is
/// refused silently because that leg returns before the message.
///
/// **There is no consent step, and that is the reference's answer, not an omission.** The 5875
/// client registers a `TRADE_REQUEST` event (id `0x11d`) and signals it from nowhere — a
/// whole-image census — so `StaticPopupDialogs["TRADE"]`, its `TRADE_WITH_QUESTION` text and its
/// `BeginTrade`/`CancelTrade` verbs are dead code there, and an incoming trade that survives the
/// ladder opens the window unasked. benilla briefly wired that dialog up and then took it back out
/// (decision 1764): a popup the real client never shows is a divergence every addon and every
/// player would feel, and the reference's own answer to an unwanted trade is leg 2 and leg 8 —
/// ignore them, or tick *Block Trades*.
///
/// Three legs were wrong in wow-re's earlier five-condition gloss and were corrected by the round
/// that produced the note above — `[0xb4b3e4]` is player *control*, not "in world"; `[0xb725f8]` is
/// the auction house, not a pending trade; and the ignore leg was missing entirely. This is why the
/// gloss was not built from.
#[allow(clippy::too_many_arguments)]
fn answer_trade_request(
mut trade: ResMut<TradeSession>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
social: Res<crate::ui_social::SocialState>,
cinematic: Res<crate::cinematic::Cinematic>,
player: Res<crate::player::Player>,
auction: Res<crate::ui_auction::AuctionOpen>,
block_trades: Res<BlockTrades>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
) {
let Some(initiator) = trade.pending_request() else {
return;
};
// ── Legs 1 and 3: the two that send NOTHING ─────────────────────────────────────────────────
//
// Leg 1 — our own `CMSG_INITIATE_TRADE` is in flight. See [`TradeSession::initiated`]: the
// latch is "we asked somebody", it survives `OPEN_WINDOW`, and it is never set by a trade we
// did not start.
//
// Leg 3 — the initiator does not resolve to a streamed **player**. The reference asks
// `0x468460(TYPEMASK_PLAYER)` and returns on NULL; ours is the guid index plus the store, and
// it fails in exactly the case the reference's does (nothing of that guid in the world).
let initiator_store = index
.0
.get(&initiator)
.and_then(|e| stores.get(*e).ok())
.filter(|_| benilla_protocol::guid::is_player(initiator));
if trade.initiated || initiator_store.is_none() {
info!(
target: "trade",
"BEGIN_TRADE from {initiator:#x} dropped without a reply (own initiate in flight: {}, \
initiator resolved: {})",
trade.initiated,
initiator_store.is_some(),
);
trade.refuse_request();
return;
}
// ── Leg 2: the ignore list, and the ONLY leg that answers on a different opcode ─────────────
//
// The server has no ignore check for trade at all (vmangos `HandleInitiateTradeOpcode` reads
// no social list), so this refusal is entirely the client's — the same shape as the duel
// challenge's (decision 0668). `CMSG_IGNORE_TRADE` rather than busy is what makes the
// initiator read "… is ignoring you" instead of "… is busy", and it is a single call site
// image-wide (`0x4bf759` → `0x5d41c0`). Both sides key on the guid, and both cap at 25.
if social.is_ignored(initiator) {
info!(target: "trade", "BEGIN_TRADE from an ignored {initiator:#x}; sending CMSG_IGNORE_TRADE");
let _ = commands.0.send(ClientCommand::IgnoreTrade);
trade.refuse_request();
return;
}
// ── Legs 4-7: the silent "busy" refusals. None needs a name, so none waits for one ──────────
//
// Leg 4 — the initiator is dead or a ghost. `0x605f30` is **raw** health ≤ 0 or the
// `PLAYER_FLAGS` ghost bit; deliberately NOT `unit_reads_dead()`, which also takes the
// dead-looking dynflag and would refuse a feigning hunter the reference trades with.
// Leg 5 — a cinematic (`[0xb4e310]`): decision 1725 named this exact hole and left it for want
// of a param slot here.
// Leg 6 — player control lost (`[0xb4b3e4]`): NOT an in-world flag. The cell is written by
// `0x4958e0`, which signals `PLAYER_CONTROL_GAINED`/`LOST` — the same wire fact
// `SMSG_CLIENT_CONTROL_UPDATE` gives `Player::control_lost`.
// Leg 7 — an auction house is open (`[0xb725f8]`/`[0xb725fc]`, one auctioneer guid split
// across two cells, set and cleared with the AH window).
let dead_or_ghost = initiator_store
.is_some_and(|s| s.0.unit_health().is_some_and(|hp| hp == 0) || s.0.player_is_ghost());
if dead_or_ghost
|| cinematic.is_playing()
|| player.control_lost
|| auction.auctioneer.is_some()
{
info!(target: "trade", "BEGIN_TRADE from {initiator:#x} refused silently; sending CMSG_BUSY_TRADE");
let _ = commands.0.send(ClientCommand::BusyTrade);
trade.refuse_request();
return;
}
// ── Leg 8 is the only one that names the initiator, so only it waits for the name cache ─────
//
// The reference reads it straight off the initiator's live `CGUnit` (`0x609210`) and does not
// have to wait; benilla may need a `CMSG_NAME_QUERY` round trip first. Leg 3 above has already
// established the initiator is streamed, which is exactly the case the reference has the name
// for free, so in practice there is nothing to wait for.
//
// **The wait needs no timer, because the server already bounds it.** The only way a name never
// arrives is a player the server can no longer find — and that is precisely the case where
// vmangos's `CleanupsBeforeDelete` sends US `TRADE_STATUS_TRADE_CANCELED` regardless of the
// leaver's own `sendback`, which clears the request through `close`. `NameCache` asks once per
// guid per connection, so the frames spent here cost one query, not one per frame.
//
// It is also the one leg that could not live in the net drain even with a param to spare: the
// answer may land a frame or two after the packet did.
if block_trades.0 {
let Some(name) = names.resolve(initiator, &commands).map(str::to_string) else {
return;
};
info!(target: "trade", "BEGIN_TRADE from {name} refused by BlockTrades; sending CMSG_BUSY_TRADE");
let _ = commands.0.send(ClientCommand::BusyTrade);
trade.refuse_request();
// `0x496720(0xbb, 0x609210(initiator))` — and catalog row `0xbb` carries `kind = 0`, chat
// type `0xa`, no sound cue. So this is a **system chat line**, not the red
// `UI_ERROR_MESSAGE` (`kind = 2`). All three trade rows are chat rows; `crate::ui_duel`'s
// note that benilla models `DisplayError` as the red toast is true of its own two ids and
// not of these.
chat_log.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
ERR_TRADE_BLOCKED_S.replacen("%s", &name, 1),
));
return;
}
// Nothing refuses it: accept, exactly as the reference does at the bottom of the same ladder.
info!(target: "trade", "BEGIN_TRADE from {initiator:#x} accepted; sending CMSG_BEGIN_TRADE");
trade.begin(initiator);
let _ = commands.0.send(ClientCommand::BeginTrade);
}
/// Resolve one wire [`TradeItem`] into the Lua-facing [`TradeSlotItem`]: name/quality through the
/// ask-once item-template cache, icon through the wire `display_id` + `ItemDisplayInfo.dbc`. `None`s
/// stay `None` while a template query is in flight — the slot shows the icon (from the wire) and fills
@@ -557,12 +818,61 @@ mod tests {
assert_eq!(s.npc(), None);
}
/// An incoming `BEGIN_TRADE` is a *request*, not a trade: nothing is a partner until it is
/// answered, so the "npc" portrait, the snapshot and the already-in-a-trade refusal leg all
/// read the same "no trade here" they read before it arrived (decision 1764).
#[test]
fn begin_records_partner_for_the_target_side() {
fn an_incoming_request_is_not_yet_a_trade() {
let mut s = TradeSession::default();
s.request(0xABCD);
assert_eq!(s.pending_request(), Some(0xABCD));
assert_eq!(s.partner, None, "an unanswered request has no partner");
assert!(!s.is_open(), "BEGIN_TRADE does not itself open the window");
assert_eq!(s.npc(), None);
}
/// Accepting the request promotes the initiator to partner and spends the request — the exact
/// state the initiator's own side reaches through `initiate`.
#[test]
fn accepting_a_request_promotes_the_initiator_to_partner() {
let mut s = TradeSession::default();
s.request(0xABCD);
s.begin(0xABCD);
assert_eq!(s.partner, Some(0xABCD));
assert!(!s.is_open(), "BEGIN_TRADE does not itself open the window");
assert_eq!(s.pending_request(), None, "the request is spent");
assert!(!s.is_open(), "the window waits for OPEN_WINDOW");
}
/// Refusing (or any close) clears the request with everything else, so a refusal in flight can
/// never be answered twice.
#[test]
fn closing_clears_a_pending_request() {
let mut s = TradeSession::default();
s.request(0xABCD);
s.close();
assert_eq!(s.pending_request(), None);
}
/// A `BEGIN_TRADE` arriving over a **live** trade leaves that trade completely alone — and so
/// does refusing it. This is what makes the "already trading" leg of the ladder readable at
/// all: it reads the fields a reset would have cleared a line earlier.
#[test]
fn a_request_leaves_a_live_trade_alone() {
let mut s = TradeSession::default();
s.initiate(0x1);
s.open_window();
s.partner_accepted();
s.request(0x2);
assert_eq!(s.pending_request(), Some(0x2));
assert_eq!(s.partner, Some(0x1), "the live trade is untouched");
assert!(s.is_open());
assert!(s.their_accept);
s.refuse_request();
assert_eq!(s.pending_request(), None);
assert_eq!(s.partner, Some(0x1), "refusing an offer is not cancelling");
assert!(s.is_open());
}
#[test]
@@ -703,6 +1013,243 @@ mod tests {
);
}
/// A request that survives every leg is **accepted unasked** — the bottom of the reference's
/// own ladder, and the behaviour benilla briefly replaced with a consent dialog before taking
/// it back out (decision 1764). The 5875 client never asks: it registers `TRADE_REQUEST` and
/// signals it from nowhere.
#[test]
fn a_request_that_survives_the_ladder_is_accepted() {
let (mut app, rx) = request_app(false, false, true);
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::BeginTrade)),
"nothing refused it, so the client answers — the server opens both windows"
);
let trade = app.world().resource::<TradeSession>();
assert_eq!(trade.partner, Some(0x7));
assert_eq!(trade.pending_request(), None, "the request is spent");
assert!(
!trade.initiated,
"a trade we did not start never sets the initiate latch"
);
assert!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts()
.is_empty(),
"and an accepted request says nothing — only leg 8 speaks"
);
}
/// **Block Trades** refuses without asking, and it is the one refusal that speaks: the
/// `ERR_TRADE_BLOCKED_S` line naming the initiator (`0x496720(0xbb, …)`). Catalog row `0xbb`
/// is `kind = 0` — the **chat frame**, chat type `0xa` — so this is a system chat line and
/// **not** the red `UI_ERROR_MESSAGE`, which is `kind = 2`. That correction is the RE's.
#[test]
fn block_trades_refuses_and_says_so() {
let (mut app, rx) = request_app(true, false, true);
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(matches!(rx.try_recv(), Ok(ClientCommand::BusyTrade)));
assert_eq!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts(),
vec!["Grubbis has requested to trade. You have refused.".to_string()],
);
}
/// Legs 5 and 6 — a cinematic (decision 1725's named hole) and lost player control — refuse
/// with **no** line. Only leg 8 reaches the message, which is why the ladder's order is
/// behaviour and not taste.
#[test]
fn the_silent_legs_refuse_without_a_word() {
for (cinematic, controlled) in [(true, true), (false, false)] {
let (mut app, rx) = request_app(false, cinematic, controlled);
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::BusyTrade)),
"cinematic={cinematic} controlled={controlled}: refused as busy"
);
assert!(
app.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts()
.is_empty(),
"cinematic={cinematic} controlled={controlled}: a silent leg says nothing"
);
}
}
/// Leg 7 — an open auction house refuses the request. `[0xb725f8]`/`[0xb725fc]` is the open
/// auctioneer's guid, which the old five-condition gloss had mislabelled as "a trade is
/// already pending". benilla was one commit from building that wrong leg.
#[test]
fn an_open_auction_house_refuses_the_request() {
let (mut app, rx) = request_app(false, false, true);
app.world_mut()
.resource_mut::<crate::ui_auction::AuctionOpen>()
.auctioneer = Some(0x1234);
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(matches!(rx.try_recv(), Ok(ClientCommand::BusyTrade)));
}
/// Leg 2 — an ignored initiator is refused on a **different opcode**, silently. The server has
/// no ignore check for trade at all (vmangos's `HandleInitiateTradeOpcode` reads no social
/// list), so this refusal is entirely the client's; `IGNORE_TRADE` rather than `BUSY_TRADE` is
/// what makes the initiator read "is ignoring you" instead of "is busy".
#[test]
fn an_ignored_initiator_is_refused_as_ignored() {
let (mut app, rx) = request_app(false, false, true);
app.world_mut()
.resource_mut::<crate::ui_social::SocialState>()
.set_ignores_for_test(vec![0x7]);
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::IgnoreTrade)),
"the ignore leg does NOT answer busy"
);
assert!(app
.world()
.resource::<crate::ui_chat::ChatLog>()
.pending_texts()
.is_empty());
}
/// Leg 4 — a dead or ghost initiator is refused as busy. The predicate is the reference's
/// `0x605f30`: **raw** health ≤ 0, or the `PLAYER_FLAGS` ghost bit. Deliberately not
/// `unit_reads_dead()` (`UnitIsDead 0x517ac0`), which also takes the dead-looking dynflag and
/// would refuse a feigning hunter the reference trades with quite happily.
#[test]
fn a_dead_or_ghost_initiator_is_refused() {
// `UNIT_FIELD_HEALTH` and `PLAYER_FLAGS`, spelled here the way `ui_unit`'s tests spell
// their field ids — the protocol crate keeps them private.
const HEALTH: u16 = 22;
const PLAYER_FLAGS: u16 = 190;
const GHOST: u32 = 0x10;
for (label, fields) in [
("dead", vec![(HEALTH, 0u32)]),
("ghost", vec![(HEALTH, 1u32), (PLAYER_FLAGS, GHOST)]),
] {
let (mut app, rx) = request_app(false, false, true);
let entity = app.world().resource::<GuidIndex>().0[&0x7];
app.world_mut().entity_mut(entity).insert(ObjectStore(
benilla_protocol::messages::ObjectFields::from_pairs(&fields),
));
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::BusyTrade)),
"{label}: refused as busy"
);
}
}
/// Legs 1 and 3 — the two that answer with **nothing at all**: our own initiate in flight
/// (`[0xc4bec8]`), and an initiator that resolves to no streamed player object. Both drop the
/// request without a packet, which is the reference's behaviour and not an oversight — it has
/// nothing to say to a trade it cannot see. Leg 1 is also what replaced the "already trading"
/// leg the old gloss claimed: the latch is set by the initiate sender and never by
/// `OPEN_WINDOW`, so it is "we asked somebody", not "a window is up".
#[test]
fn two_legs_answer_with_no_packet_at_all() {
let answered = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter().any(|c| {
matches!(
c,
ClientCommand::BusyTrade
| ClientCommand::IgnoreTrade
| ClientCommand::BeginTrade
| ClientCommand::CancelTrade
)
})
};
// Leg 1: we asked somebody else first, and the request arrives over it.
let (mut app, rx) = request_app(false, false, true);
{
let mut trade = app.world_mut().resource_mut::<TradeSession>();
trade.initiate(0x9);
trade.request(0x7);
}
app.update();
assert!(
!answered(&rx),
"an initiate of our own in flight drops the request in silence"
);
let trade = app.world().resource::<TradeSession>();
assert_eq!(
trade.pending_request(),
None,
"…and spends it, so it is not retried every frame"
);
assert_eq!(trade.partner, Some(0x9), "…and our own trade survives it");
// Leg 3: nothing of that guid is streamed.
let (mut app, rx) = request_app(false, false, true);
app.world_mut().resource_mut::<GuidIndex>().0.remove(&0x7);
app.world_mut().resource_mut::<TradeSession>().request(0x7);
app.update();
assert!(
!answered(&rx),
"an unresolvable initiator drops the request in silence"
);
}
/// The harness for [`answer_trade_request`]: an app carrying the eight pieces of state the
/// ladder reads, and two streamed player objects to be asked by. **No VM** — the system needs
/// none, which is the shape it settled into once the consent dialog came back out (decision
/// 1764): the answer is wire policy over engine state, and the only reason it is not in the
/// net drain is the parameter ceiling there.
fn request_app(
block_trades: bool,
cinematic: bool,
controlled: bool,
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
let mut names = NameCache::default();
// Two names, because several of these tests need a SECOND asker — and an unresolved name
// parks leg 8 before it reaches the line, which would let a test pass for the wrong
// reason (it did, once).
names.insert_player(0x7, "Grubbis".to_string(), None);
names.insert_player(0x8, "Skarrid".to_string(), None);
app.init_resource::<TradeSession>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::ui_social::SocialState>()
.init_resource::<crate::ui_auction::AuctionOpen>()
.init_resource::<GuidIndex>()
.init_resource::<crate::player::Player>()
.insert_resource(names)
.insert_resource(NetCommands(tx))
.insert_resource(BlockTrades(block_trades))
.insert_resource(if cinematic {
crate::cinematic::Cinematic::playing_for_test()
} else {
crate::cinematic::Cinematic::default()
});
app.world_mut()
.resource_mut::<crate::player::Player>()
.control_lost = !controlled;
// The two askers are streamed player objects — leg 3 drops a request from a guid that
// resolves to nothing, so every test that expects to reach the ladder needs them present.
for guid in [0x7u64, 0x8] {
let e = app.world_mut().spawn(ObjectStore::default()).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(guid, e);
}
app.add_systems(Update, answer_trade_request);
(app, rx)
}
/// Draining a `SetTradeMoney` offer ships `CMSG_SET_TRADE_GOLD` — but only while the window is
/// open, so a stray value with no trade is dropped (decision 0592 P2).
#[test]
+82
View File
@@ -0,0 +1,82 @@
//! Every `ERR_*` key this workspace names must be a real row of the client's message catalog.
//!
//! benilla writes these keys as string literals at ~160 call sites, and each one used to arrive by
//! hand-tracing a `push <id>; call CGGameUI::DisplayError` in the binary. A typo, a key from a
//! later expansion, or a plausible-looking invention would all have been invisible: the string
//! simply would not resolve in `GlobalStrings.lua`, and the reference's own data-suppression rule
//! means benilla shows *nothing* for an unresolvable key. Silence is the failure mode, which is the
//! worst kind — nobody files a bug about a toast they never saw.
//!
//! Since decision 1770 the surface those keys are shown on is read from
//! [`benilla_ui::messages`] rather than carried beside them, so a key that is not in the catalog
//! also silently takes the fallback. This walk is what keeps that fallback unreachable: it is the
//! tripwire that turns "we believe these are message ids" into a checked claim.
//!
//! It reads source text rather than any registry the code keeps, deliberately — a registry could
//! only contain what somebody remembered to register.
use std::collections::BTreeSet;
use std::path::Path;
/// Not message ids, and not meant to be — both are fixtures for the unknown-key *fallback*, which
/// needs a key the client does not have in order to be tested at all: `ui_petition::lines`'
/// degrade-to-generic arm, and `benilla_ui::messages`' own proof that `kind_of` answers for a key
/// it has never heard of. Anything else appearing here is a defect, not an entry to add.
const NOT_A_MESSAGE: &[&str] = &["ERR_SOMETHING_UNCARVED", "ERR_NOT_A_REAL_MESSAGE"];
/// The generated table itself, which is the answer and not a question — walking it would make
/// every key trivially present and the check vacuous.
const GENERATED: &str = "catalog.rs";
fn walk(dir: &Path, into: &mut Vec<String>) {
for entry in std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) {
let path = entry.expect("dir entry").path();
if path.is_dir() {
walk(&path, into);
} else if path.extension().is_some_and(|e| e == "rs")
&& path.file_name().is_some_and(|n| n != GENERATED)
{
into.push(std::fs::read_to_string(&path).expect("read source"));
}
}
}
/// Pull every `"ERR_…"` string literal out of a source file, the way the call sites write them.
fn err_keys(src: &str) -> impl Iterator<Item = String> + '_ {
src.match_indices("\"ERR_").filter_map(|(i, _)| {
let rest = &src[i + 1..];
let end = rest.find('"')?;
let key = &rest[..end];
key.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
.then(|| key.to_string())
})
}
#[test]
fn every_error_key_in_the_source_is_a_catalog_row() {
let mut sources = Vec::new();
walk(Path::new("src"), &mut sources);
walk(Path::new("../benilla-ui/src"), &mut sources);
assert!(sources.len() > 100, "the walk found almost nothing to read");
let keys: BTreeSet<String> = sources.iter().flat_map(|s| err_keys(s)).collect();
// 163 at the time of writing — the point is only that the walk found the vocabulary and not
// a handful of files, so this is a floor and not a ledger to keep updated.
assert!(
keys.len() > 150,
"expected the hand-written `ERR_*` vocabulary, found {}",
keys.len()
);
let strays: Vec<&String> = keys
.iter()
.filter(|k| !NOT_A_MESSAGE.contains(&k.as_str()))
.filter(|k| benilla_ui::messages::by_key(k).is_none())
.collect();
assert!(
strays.is_empty(),
"these keys are not rows of the 5875 message catalog, so the client would show nothing \
for them: {strays:?}"
);
}
+1
View File
@@ -28,6 +28,7 @@ pub mod justify;
pub mod layout;
pub mod loader;
pub mod markup;
pub mod messages;
pub mod order;
pub mod script;
pub mod source;
+167
View File
@@ -0,0 +1,167 @@
//! The client's **message catalog** — the one table that decides where a UI message is shown.
//!
//! Every user-visible refusal, notice and confirmation the engine raises goes through a single
//! function, `CGGameUI::DisplayError` (`0x496720`), which takes a **message id** and nothing else.
//! The id indexes the registry at `0xb4b498`, 465 records of 20 bytes:
//!
//! | offset | field | what it is |
//! |---|---|---|
//! | `+0x00` | [`MessageRecord::key`] | the `GlobalStrings.lua` key, resolved by `FrameScript_GetText` |
//! | `+0x04` | [`MessageRecord::kind`] | **where it goes** — the four-way jump at `0x496888` ([`MsgKind`]) |
//! | `+0x08` | [`MessageRecord::sound`] | a sound cue name, or nothing |
//! | `+0x0c` | [`MessageRecord::type_tag`] | `0x44` = play the cue; anything else is an error-speech id |
//! | `+0x10` | [`MessageRecord::chat_type`] | the chat type handed to `0x49a870` — read on [`MsgKind::Chat`] only |
//!
//! **Why this table is in the tree at all.** benilla names ~160 of these keys across a dozen
//! modules, and until now every one of them arrived by hand-tracing a `push <id>; call 0x496720`
//! in the binary and reading the record by eye — the key *and* the surface, carried separately to
//! the call site, with nothing checking they agreed. That is how [`super::script`]'s petition lines
//! got their split wrong on the first try (`ui_petition::lines`' own module doc records it), and
//! it is a tax on every future message. The registry is a **fact about the client**, of exactly the
//! kind this project already carries as code — opcode numbers, struct offsets, DBC schemas — so it
//! belongs here as a table, once, instead of being re-derived per call site. Nothing localized
//! ships with it: the *keys* are `GlobalStrings.lua` identifiers (interface code, decision 1602)
//! and the text itself is still read at runtime out of the player's own install.
//!
//! It is **generated**, never hand-edited — `scripts/gen-message-catalog.py` from wow-5875-re's
//! `re/ui/message-catalog.tsv`, whose extractor decodes the CRT static initializer
//! `[0x484ca0, 0x488408)` that fills the table.
//!
//! **What the sweep settles that guessing could not:** the split does not follow the key names.
//! `ERR_PETITION_SIGNED_S` is a chat line and `ERR_PETITION_ALREADY_SIGNED` is a red toast;
//! `ERR_FISH_ESCAPED` is yellow and `ERR_INV_FULL` is red. 170 rows are [`MsgKind::Chat`], 28 are
//! [`MsgKind::Info`], 267 are [`MsgKind::Error`] — and **none is the console arm**, which the
//! dispatcher has and this build's table never reaches (`0x63cb50`'s console lines are called
//! directly, not through a record), so [`MsgKind`] models three.
mod catalog;
pub use catalog::CATALOG;
/// Where a message is shown — the record's `+0x04`, as the four-way jump at `0x496888` reads it.
///
/// The reference's fourth arm (`3` → the debug console `0x63cd00`) has **no tenant in 5875's
/// table**: no row carries it, so modelling it would be modelling something that cannot happen.
/// The generator refuses a kind it does not know, so a build whose table did use it would fail
/// loudly here rather than silently pick a surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsgKind {
/// `0` → the chat window, through the shared composer `0x49a870` with the record's own
/// [`MessageRecord::chat_type`].
Chat,
/// `1` → `AddErrorMessage(text, 0)`, which fires `UI_INFO_MESSAGE` (`0xe1`) — the **yellow**
/// `UIErrorsFrame` line.
Info,
/// `2` → `AddErrorMessage(text, 1)`, which fires `UI_ERROR_MESSAGE` (`0xe0`) — the **red**
/// `UIErrorsFrame` line. The commonest by a wide margin, which is exactly why guessing it
/// usually works and is not good enough.
Error,
}
/// One row of `0xb4b498`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MessageRecord {
/// The message id — the argument `DisplayError` is called with, and this row's index.
pub id: u16,
/// The `GlobalStrings.lua` key whose text is displayed. An absent or empty string shows
/// nothing at all: the reference's own data-suppression face (`0x4967bd`/`0x4967c5`).
pub key: &'static str,
/// Where it is shown.
pub kind: MsgKind,
/// The sound cue played with it, or `None` for the 435 silent rows. Carried as data;
/// benilla does not wire message cues yet.
pub sound: Option<&'static str>,
/// `+0x0c`. `0x44` means "play [`Self::sound`]"; the other 56 rows carry an error-speech id
/// instead. Unmodelled, recorded.
pub type_tag: u8,
/// The chat type handed to the composer, meaningful for [`MsgKind::Chat`] rows only. `10`
/// (`CHAT_MSG_SYSTEM`) for all but three — the skill-up trio `ERR_PROFICIENCY_GAINED_S`,
/// `ERR_SKILL_GAINED_S` and `ERR_SKILL_UP_SI` speak on `23` (`CHAT_MSG_SKILL`).
pub chat_type: u8,
}
/// The record for a message id, or `None` past the table's end — the reference's own bound, the
/// `cmp id,0x1d1; jge` at `0x49672c`.
pub fn by_id(id: u16) -> Option<&'static MessageRecord> {
CATALOG.get(usize::from(id))
}
/// The record for a `GlobalStrings` key. Keys are unique across the table (the generator refuses a
/// duplicate), so this is well defined.
///
/// A linear scan, deliberately: this is reached once per *displayed message* — a toast, a system
/// line — which is a handful per second at the very worst, and a sorted side-table would be one
/// more generated thing to keep true for no measurable gain.
pub fn by_key(key: &str) -> Option<&'static MessageRecord> {
CATALOG.iter().find(|r| r.key == key)
}
/// Where a message goes, by key — the one question nearly every caller has.
///
/// A key with no row falls back to [`MsgKind::Error`]. That case 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; the red line is both the commonest kind and the most
/// visible place for such a mistake to show itself. `benilla-app`'s
/// `every_error_key_in_the_source_is_a_catalog_row` test is what keeps it unreachable.
pub fn kind_of(key: &str) -> MsgKind {
by_key(key).map_or(MsgKind::Error, |r| r.kind)
}
#[cfg(test)]
mod tests {
use super::*;
/// The table is complete and dense, so `CATALOG[id]` is the reference's own addressing.
#[test]
fn ids_are_dense_and_the_table_is_whole() {
assert_eq!(CATALOG.len(), 465);
for (i, r) in CATALOG.iter().enumerate() {
assert_eq!(usize::from(r.id), i, "row {i} carries id {}", r.id);
}
assert!(by_id(465).is_none(), "the reference bounds at 0x1d1");
}
/// Keys are unique — [`by_key`] would otherwise be answering an ambiguous question.
#[test]
fn keys_are_unique() {
let mut keys: Vec<&str> = CATALOG.iter().map(|r| r.key).collect();
keys.sort_unstable();
let n = keys.len();
keys.dedup();
assert_eq!(keys.len(), n);
}
/// **The split does not follow the key names.** Four rows that a re-implementation reading
/// English would sort differently than the binary does — the petition pair that was actually
/// got wrong once, and the fishing verdict that sits one id away from two red neighbours.
#[test]
fn the_surface_is_not_guessable_from_the_key() {
assert_eq!(kind_of("ERR_PETITION_SIGNED_S"), MsgKind::Chat);
assert_eq!(kind_of("ERR_PETITION_ALREADY_SIGNED"), MsgKind::Error);
assert_eq!(kind_of("ERR_FISH_ESCAPED"), MsgKind::Info);
assert_eq!(kind_of("ERR_INV_FULL"), MsgKind::Error);
}
/// The three rows that speak on a chat type other than `CHAT_MSG_SYSTEM`.
#[test]
fn the_skill_up_rows_speak_on_chat_msg_skill() {
for key in [
"ERR_PROFICIENCY_GAINED_S",
"ERR_SKILL_GAINED_S",
"ERR_SKILL_UP_SI",
] {
let r = by_key(key).expect(key);
assert_eq!(r.kind, MsgKind::Chat);
assert_eq!(r.chat_type, 23, "{key}");
}
assert_eq!(by_key("ERR_QUEST_ALREADY_ON").unwrap().chat_type, 10);
}
/// A key the client does not have is not expressible as a message id; the fallback exists so
/// that a benilla typo is loud rather than silent.
#[test]
fn an_unknown_key_falls_back_to_the_red_line() {
assert!(by_key("ERR_NOT_A_REAL_MESSAGE").is_none());
assert_eq!(kind_of("ERR_NOT_A_REAL_MESSAGE"), MsgKind::Error);
}
}
+482
View File
@@ -0,0 +1,482 @@
//! The 5875 message catalog, row for row.
//!
//! @generated by `scripts/gen-message-catalog.py` from wow-5875-re `re/ui/message-catalog.tsv`
//! (itself generated by that repo's `re/ui/extract-message-catalog.py` out of `WoW.exe`).
//! Do not edit by hand — regenerate.
use super::{MessageRecord, MsgKind};
/// Every row of `0xb4b498`, indexed by message id (the ids are dense `0..465`, so
/// `CATALOG[id]` *is* the reference's `0xb4b498 + 20*id`).
///
/// `rustfmt` is held off so that a regeneration is a no-op diff: one row per line is the shape
/// this script emits, and a formatter that rewrapped it would make the file drift from its own
/// generator.
#[rustfmt::skip]
pub static CATALOG: [MessageRecord; 465] = [
MessageRecord { id: 0, key: "ERR_INV_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x00, chat_type: 10 },
MessageRecord { id: 1, key: "ERR_BANK_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 2, key: "ERR_CANT_EQUIP_LEVEL_I", kind: MsgKind::Error, sound: None, type_tag: 0x02, chat_type: 10 },
MessageRecord { id: 3, key: "ERR_CANT_EQUIP_SKILL", kind: MsgKind::Error, sound: None, type_tag: 0x29, chat_type: 10 },
MessageRecord { id: 4, key: "ERR_CANT_EQUIP_EVER", kind: MsgKind::Error, sound: None, type_tag: 0x03, chat_type: 10 },
MessageRecord { id: 5, key: "ERR_CANT_EQUIP_RANK", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 6, key: "ERR_CANT_EQUIP_REPUTATION", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 7, key: "ERR_PROFICIENCY_NEEDED", kind: MsgKind::Error, sound: None, type_tag: 0x30, chat_type: 10 },
MessageRecord { id: 8, key: "ERR_WRONG_SLOT", kind: MsgKind::Error, sound: None, type_tag: 0x1b, chat_type: 10 },
MessageRecord { id: 9, key: "ERR_BAG_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x1d, chat_type: 10 },
MessageRecord { id: 10, key: "ERR_INTERNAL_BAG_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 11, key: "ERR_DESTROY_NONEMPTY_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 12, key: "ERR_BAG_IN_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x1a, chat_type: 10 },
MessageRecord { id: 13, key: "ERR_TOO_MANY_SPECIAL_BAGS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 14, key: "ERR_TRADE_EQUIPPED_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 15, key: "ERR_AMMO_ONLY", kind: MsgKind::Error, sound: None, type_tag: 0x1c, chat_type: 10 },
MessageRecord { id: 16, key: "ERR_NO_SLOT_AVAILABLE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 17, key: "ERR_WRONG_BAG_TYPE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 18, key: "ERR_ITEM_MAX_COUNT", kind: MsgKind::Error, sound: None, type_tag: 0x1e, chat_type: 10 },
MessageRecord { id: 19, key: "ERR_NOT_EQUIPPABLE", kind: MsgKind::Error, sound: None, type_tag: 0x2c, chat_type: 10 },
MessageRecord { id: 20, key: "ERR_CANT_STACK", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 21, key: "ERR_CANT_SWAP", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 22, key: "ERR_SLOT_EMPTY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 23, key: "ERR_ITEM_NOT_FOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 24, key: "ERR_TOO_FEW_TO_SPLIT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 25, key: "ERR_SPLIT_FAILED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 26, key: "ERR_NOT_A_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x19, chat_type: 10 },
MessageRecord { id: 27, key: "ERR_NOT_OWNER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 28, key: "ERR_ONLY_ONE_QUIVER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 29, key: "ERR_NO_BANK_SLOT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 30, key: "ERR_NO_BANK_HERE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 31, key: "ERR_ITEM_LOCKED", kind: MsgKind::Error, sound: None, type_tag: 0x3d, chat_type: 10 },
MessageRecord { id: 32, key: "ERR_2HANDED_EQUIPPED", kind: MsgKind::Error, sound: None, type_tag: 0x2a, chat_type: 10 },
MessageRecord { id: 33, key: "ERR_VENDOR_NOT_INTERESTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 34, key: "ERR_VENDOR_HATES_YOU", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 35, key: "ERR_VENDOR_SOLD_OUT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 36, key: "ERR_VENDOR_TOO_FAR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 37, key: "ERR_NOT_ENOUGH_MONEY", kind: MsgKind::Error, sound: None, type_tag: 0x28, chat_type: 10 },
MessageRecord { id: 38, key: "ERR_RECEIVE_ITEM_S", kind: MsgKind::Chat, sound: Some("ITEMGENERICSOUND"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 39, key: "ERR_DROP_BOUND_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x04, chat_type: 10 },
MessageRecord { id: 40, key: "ERR_TRADE_BOUND_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x3b, chat_type: 10 },
MessageRecord { id: 41, key: "ERR_TRADE_QUEST_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x3b, chat_type: 10 },
MessageRecord { id: 42, key: "ERR_TRADE_GROUND_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 43, key: "ERR_TRADE_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 44, key: "ERR_SPELL_FAILED_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 45, key: "ERR_ITEM_COOLDOWN", kind: MsgKind::Error, sound: None, type_tag: 0x05, chat_type: 10 },
MessageRecord { id: 46, key: "ERR_POTION_COOLDOWN", kind: MsgKind::Error, sound: None, type_tag: 0x2f, chat_type: 10 },
MessageRecord { id: 47, key: "ERR_FOOD_COOLDOWN", kind: MsgKind::Error, sound: None, type_tag: 0x07, chat_type: 10 },
MessageRecord { id: 48, key: "ERR_SPELL_COOLDOWN", kind: MsgKind::Error, sound: None, type_tag: 0x0c, chat_type: 10 },
MessageRecord { id: 49, key: "ERR_ABILITY_COOLDOWN", kind: MsgKind::Error, sound: None, type_tag: 0x32, chat_type: 10 },
MessageRecord { id: 50, key: "ERR_SPELL_ALREADY_KNOWN_S", kind: MsgKind::Error, sound: None, type_tag: 0x0d, chat_type: 10 },
MessageRecord { id: 51, key: "ERR_PET_SPELL_ALREADY_KNOWN_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 52, key: "ERR_PROFICIENCY_GAINED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 23 },
MessageRecord { id: 53, key: "ERR_SKILL_GAINED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 23 },
MessageRecord { id: 54, key: "ERR_SKILL_UP_SI", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 23 },
MessageRecord { id: 55, key: "ERR_LEARN_SPELL_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 56, key: "ERR_LEARN_ABILITY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 57, key: "ERR_LEARN_RECIPE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 58, key: "ERR_INVITE_PLAYER_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 59, key: "ERR_INVITE_SELF", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 60, key: "ERR_INVITED_TO_GROUP_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 61, key: "ERR_ALREADY_IN_GROUP_S", kind: MsgKind::Chat, sound: None, type_tag: 0x14, chat_type: 10 },
MessageRecord { id: 62, key: "ERR_PLAYER_BUSY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 63, key: "ERR_NEW_LEADER_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 64, key: "ERR_NEW_LEADER_YOU", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 65, key: "ERR_LEFT_GROUP_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 66, key: "ERR_LEFT_GROUP_YOU", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 67, key: "ERR_GROUP_DISBANDED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 68, key: "ERR_DECLINE_GROUP_S", kind: MsgKind::Chat, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 69, key: "ERR_JOINED_GROUP_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 70, key: "ERR_UNINVITE_YOU", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 71, key: "ERR_BAD_PLAYER_NAME_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 72, key: "ERR_NOT_IN_GROUP", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 73, key: "ERR_TARGET_NOT_IN_GROUP_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 74, key: "ERR_GROUP_FULL", kind: MsgKind::Chat, sound: None, type_tag: 0x08, chat_type: 10 },
MessageRecord { id: 75, key: "ERR_NOT_LEADER", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 76, key: "ERR_PLAYER_DIED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 77, key: "ERR_GUILD_CREATE_S", kind: MsgKind::Chat, sound: Some("LEVELUP"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 78, key: "ERR_GUILD_INVITE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 79, key: "ERR_INVITED_TO_GUILD_SS", kind: MsgKind::Chat, sound: Some("LEVELUP"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 80, key: "ERR_ALREADY_IN_GUILD_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 81, key: "ERR_ALREADY_INVITED_TO_GUILD_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 82, key: "ERR_INVITED_TO_GUILD", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 83, key: "ERR_ALREADY_IN_GUILD", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 84, key: "ERR_GUILD_ACCEPT", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 85, key: "ERR_GUILD_DECLINE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 86, key: "ERR_GUILD_PERMISSIONS", kind: MsgKind::Chat, sound: None, type_tag: 0x3e, chat_type: 10 },
MessageRecord { id: 87, key: "ERR_GUILD_JOIN_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 88, key: "ERR_GUILD_FOUNDER_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 89, key: "ERR_GUILD_PROMOTE_SSS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 90, key: "ERR_GUILD_DEMOTE_SSS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 91, key: "ERR_GUILD_QUIT_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 92, key: "ERR_GUILD_LEAVE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 93, key: "ERR_GUILD_REMOVE_SS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 94, key: "ERR_GUILD_REMOVE_SELF", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 95, key: "ERR_GUILD_DISBAND_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 96, key: "ERR_GUILD_DISBAND_SELF", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 97, key: "ERR_GUILD_LEADER_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 98, key: "ERR_GUILD_LEADER_SELF", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 99, key: "ERR_GUILD_PLAYER_NOT_FOUND_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 100, key: "ERR_GUILD_PLAYER_NOT_IN_GUILD_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 101, key: "ERR_GUILD_PLAYER_NOT_IN_GUILD", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 102, key: "ERR_GUILD_CANT_PROMOTE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 103, key: "ERR_GUILD_CANT_DEMOTE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 104, key: "ERR_GUILD_NOT_IN_A_GUILD", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 105, key: "ERR_GUILD_INTERNAL", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 106, key: "ERR_GUILD_LEADER_IS_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 107, key: "ERR_GUILD_LEADER_CHANGED_SS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 108, key: "ERR_GUILD_DISBANDED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 109, key: "ERR_GUILD_NOT_ALLIED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 110, key: "ERR_GUILD_LEADER_LEAVE", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 111, key: "ERR_GUILD_RANKS_LOCKED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 112, key: "ERR_GUILD_RANK_IN_USE", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 113, key: "ERR_GUILD_RANK_TOO_HIGH_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 114, key: "ERR_GUILD_RANK_TOO_LOW_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 115, key: "ERR_GUILD_NAME_INVALID", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 116, key: "ERR_GUILD_NAME_EXISTS_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 117, key: "ERR_GUILD_ENTER_NAME", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 118, key: "ERR_GUILD_NAME_TOO_SHORT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 119, key: "ERR_GUILD_NAME_MIXED_LANGUAGES", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 120, key: "ERR_GUILD_NAME_PROFANE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 121, key: "ERR_GUILD_NAME_RESERVED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 122, key: "ERR_GUILD_NAME_INVALID_SPACE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 123, key: "ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 124, key: "ERR_NO_GUILD_CHARTER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 125, key: "ERR_OUT_OF_RANGE", kind: MsgKind::Error, sound: None, type_tag: 0x0a, chat_type: 10 },
MessageRecord { id: 126, key: "ERR_PLAYER_DEAD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 127, key: "ERR_CLIENT_LOCKED_OUT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 128, key: "ERR_KILLED_BY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 129, key: "ERR_LOOT_LOCKED", kind: MsgKind::Error, sound: None, type_tag: 0x21, chat_type: 10 },
MessageRecord { id: 130, key: "ERR_LOOT_TOO_FAR", kind: MsgKind::Error, sound: None, type_tag: 0x23, chat_type: 10 },
MessageRecord { id: 131, key: "ERR_LOOT_DIDNT_KILL", kind: MsgKind::Error, sound: None, type_tag: 0x1f, chat_type: 10 },
MessageRecord { id: 132, key: "ERR_LOOT_BAD_FACING", kind: MsgKind::Error, sound: None, type_tag: 0x20, chat_type: 10 },
MessageRecord { id: 133, key: "ERR_LOOT_NOTSTANDING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 134, key: "ERR_LOOT_STUNNED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 135, key: "ERR_LOOT_NO_UI", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 136, key: "ERR_LOOT_WHILE_INVULNERABLE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 137, key: "ERR_QUEST_ACCEPTED_S", kind: MsgKind::Chat, sound: Some("QUESTADDED"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 138, key: "ERR_QUEST_COMPLETE_S", kind: MsgKind::Chat, sound: Some("igQuestListComplete"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 139, key: "ERR_QUEST_FAILED_S", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 140, key: "ERR_QUEST_FAILED_BAG_FULL_S", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 141, key: "ERR_QUEST_FAILED_MAX_COUNT_S", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 142, key: "ERR_QUEST_FAILED_LOW_LEVEL", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 143, key: "ERR_QUEST_FAILED_MISSING_ITEMS", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 144, key: "ERR_QUEST_FAILED_WRONG_RACE", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 145, key: "ERR_QUEST_FAILED_NOT_ENOUGH_MONEY", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 146, key: "ERR_QUEST_ONLY_ONE_TIMED", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 147, key: "ERR_QUEST_NEED_PREREQS", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 148, key: "ERR_QUEST_ALREADY_ON", kind: MsgKind::Chat, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 149, key: "ERR_QUEST_REWARD_EXP_I", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 150, key: "ERR_QUEST_REWARD_ITEM_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 151, key: "ERR_QUEST_REWARD_MONEY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 152, key: "ERR_QUEST_MUST_CHOOSE", kind: MsgKind::Error, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 153, key: "ERR_QUEST_LOG_FULL", kind: MsgKind::Error, sound: Some("igQuestFailed"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 154, key: "ERR_COMBAT_DAMAGE_SSI", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 155, key: "ERR_INSPECT_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 156, key: "ERR_CANT_USE_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x33, chat_type: 10 },
MessageRecord { id: 157, key: "ERR_MUST_EQUIP_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x31, chat_type: 10 },
MessageRecord { id: 158, key: "ERR_PASSIVE_ABILITY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 159, key: "ERR_2HSKILLNOTFOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 160, key: "ERR_NO_ATTACK_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x26, chat_type: 10 },
MessageRecord { id: 161, key: "ERR_INVALID_ATTACK_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x0b, chat_type: 10 },
MessageRecord { id: 162, key: "ERR_ATTACK_STUNNED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 163, key: "ERR_ATTACK_PACIFIED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 164, key: "ERR_ATTACK_MOUNTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 165, key: "ERR_ATTACK_FLEEING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 166, key: "ERR_ATTACK_CONFUSED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 167, key: "ERR_ATTACK_CHARMED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 168, key: "ERR_ATTACK_DEAD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 169, key: "ERR_ATTACK_PREVENTED_BY_MECHANIC_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 170, key: "ERR_TAXISAMENODE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 171, key: "ERR_TAXINOSUCHPATH", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 172, key: "ERR_TAXIUNSPECIFIEDSERVERERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 173, key: "ERR_TAXINOTENOUGHMONEY", kind: MsgKind::Info, sound: None, type_tag: 0x36, chat_type: 10 },
MessageRecord { id: 174, key: "ERR_TAXITOOFARAWAY", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 175, key: "ERR_TAXINOVENDORNEARBY", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 176, key: "ERR_TAXINOTVISITED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 177, key: "ERR_TAXIPLAYERBUSY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 178, key: "ERR_TAXIPLAYERALREADYMOUNTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 179, key: "ERR_TAXIPLAYERSHAPESHIFTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 180, key: "ERR_TAXIPLAYERMOVING", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 181, key: "ERR_TAXINOPATHS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 182, key: "ERR_TAXINOTSTANDING", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 183, key: "ERR_NO_REPLY_TARGET", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 184, key: "ERR_GENERIC_NO_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x2d, chat_type: 10 },
MessageRecord { id: 185, key: "ERR_INITIATE_TRADE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 186, key: "ERR_TRADE_REQUEST_S", kind: MsgKind::Chat, sound: Some("LEVELUP"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 187, key: "ERR_TRADE_BLOCKED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 188, key: "ERR_TRADE_TARGET_DEAD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 189, key: "ERR_TRADE_TOO_FAR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 190, key: "ERR_TRADE_CANCELLED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 191, key: "ERR_TRADE_COMPLETE", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 192, key: "ERR_TRADE_BAG_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 193, key: "ERR_TRADE_TARGET_BAG_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 194, key: "ERR_TRADE_MAX_COUNT_EXCEEDED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 195, key: "ERR_TRADE_TARGET_MAX_COUNT_EXCEEDED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 196, key: "ERR_ALREADY_TRADING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 197, key: "ERR_MOUNT_INVALIDMOUNTEE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 198, key: "ERR_MOUNT_TOOFARAWAY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 199, key: "ERR_MOUNT_ALREADYMOUNTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 200, key: "ERR_MOUNT_NOTMOUNTABLE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 201, key: "ERR_MOUNT_NOTYOURPET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 202, key: "ERR_MOUNT_OTHER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 203, key: "ERR_MOUNT_LOOTING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 204, key: "ERR_MOUNT_RACECANTMOUNT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 205, key: "ERR_MOUNT_SHAPESHIFTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 206, key: "ERR_DISMOUNT_NOPET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 207, key: "ERR_DISMOUNT_NOTMOUNTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 208, key: "ERR_DISMOUNT_NOTYOURPET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 209, key: "ERR_SPELL_FAILED_TOTEMS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 210, key: "ERR_SPELL_FAILED_REAGENTS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 211, key: "ERR_SPELL_FAILED_REAGENTS_GENERIC", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 212, key: "ERR_SPELL_FAILED_EQUIPPED_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 213, key: "ERR_SPELL_FAILED_EQUIPPED_ITEM_CLASS_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 214, key: "ERR_SPELL_FAILED_SHAPESHIFT_FORM_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 215, key: "ERR_BADATTACKFACING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 216, key: "ERR_BADATTACKPOS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 217, key: "ERR_CHEST_IN_USE", kind: MsgKind::Error, sound: None, type_tag: 0x34, chat_type: 10 },
MessageRecord { id: 218, key: "ERR_USE_CANT_OPEN", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 219, key: "ERR_USE_LOCKED", kind: MsgKind::Error, sound: None, type_tag: 0x3d, chat_type: 10 },
MessageRecord { id: 220, key: "ERR_DOOR_LOCKED", kind: MsgKind::Error, sound: None, type_tag: 0x3d, chat_type: 10 },
MessageRecord { id: 221, key: "ERR_BUTTON_LOCKED", kind: MsgKind::Error, sound: None, type_tag: 0x3d, chat_type: 10 },
MessageRecord { id: 222, key: "ERR_USE_LOCKED_WITH_ITEM_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 223, key: "ERR_USE_LOCKED_WITH_SPELL_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 224, key: "ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 225, key: "ERR_USE_TOO_FAR", kind: MsgKind::Error, sound: None, type_tag: 0x39, chat_type: 10 },
MessageRecord { id: 226, key: "ERR_USE_BAD_ANGLE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 227, key: "ERR_USE_OBJECT_MOVING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 228, key: "ERR_USE_SPELL_FOCUS", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 229, key: "ERR_USE_DESTROYED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 230, key: "ERR_SET_LOOT_FREEFORALL", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 231, key: "ERR_SET_LOOT_ROUNDROBIN", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 232, key: "ERR_SET_LOOT_MASTER", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 233, key: "ERR_SET_LOOT_GROUP", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 234, key: "ERR_SET_LOOT_NBG", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 235, key: "ERR_SET_LOOT_THRESHOLD_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 236, key: "ERR_NEW_LOOT_MASTER_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 237, key: "ERR_SPECIFY_MASTER_LOOTER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 238, key: "ERR_TAME_FAILED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 239, key: "ERR_CHAT_WHILE_DEAD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 240, key: "ERR_CHAT_WRONG_FACTION", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 241, key: "ERR_CHAT_PLAYER_NOT_FOUND_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 242, key: "ERR_NEWTAXIPATH", kind: MsgKind::Info, sound: Some("TaxiNodeDiscovered"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 243, key: "ERR_NO_PET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 244, key: "ERR_NOTYOURPET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 245, key: "ERR_PET_NOT_RENAMEABLE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 246, key: "ERR_NULL_PETNAME", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 247, key: "ERR_INVALID_PETNAME", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 248, key: "ERR_QUEST_OBJECTIVE_COMPLETE_S", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 249, key: "ERR_QUEST_UNKNOWN_COMPLETE", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 250, key: "ERR_QUEST_ADD_KILL_SII", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 251, key: "ERR_QUEST_ADD_FOUND_SII", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 252, key: "ERR_QUEST_ADD_ITEM_SII", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 253, key: "ERR_CANNOTCREATEDIRECTORY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 254, key: "ERR_CANNOTCREATEFILE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 255, key: "ERR_PLAYER_WRONG_FACTION", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 256, key: "ERR_BANKSLOT_FAILED_TOO_MANY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 257, key: "ERR_BANKSLOT_INSUFFICIENT_FUNDS", kind: MsgKind::Error, sound: None, type_tag: 0x16, chat_type: 10 },
MessageRecord { id: 258, key: "ERR_BANKSLOT_NOTBANKER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 259, key: "ERR_FRIEND_DB_ERROR", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 260, key: "ERR_FRIEND_LIST_FULL", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 261, key: "ERR_FRIEND_ADDED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 262, key: "ERR_FRIEND_ONLINE_SS", kind: MsgKind::Chat, sound: Some("FRIENDJOINGAME"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 263, key: "ERR_FRIEND_OFFLINE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 264, key: "ERR_FRIEND_NOT_FOUND", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 265, key: "ERR_FRIEND_WRONG_FACTION", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 266, key: "ERR_FRIEND_REMOVED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 267, key: "ERR_FRIEND_ERROR", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 268, key: "ERR_FRIEND_ALREADY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 269, key: "ERR_FRIEND_SELF", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 270, key: "ERR_IGNORE_FULL", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 271, key: "ERR_IGNORE_SELF", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 272, key: "ERR_IGNORE_NOT_FOUND", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 273, key: "ERR_IGNORE_ALREADY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 274, key: "ERR_IGNORE_ADDED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 275, key: "ERR_IGNORE_REMOVED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 276, key: "ERR_IGNORE_AMBIGUOUS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 277, key: "ERR_ONLY_ONE_BOLT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 278, key: "ERR_ONLY_ONE_AMMO", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 279, key: "ERR_SPELL_FAILED_EQUIPPED_SPECIFIC_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 280, key: "ERR_WRONG_BAG_TYPE_SUBCLASS", kind: MsgKind::Error, sound: None, type_tag: 0x1c, chat_type: 10 },
MessageRecord { id: 281, key: "ERR_CANT_WRAP_STACKABLE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 282, key: "ERR_CANT_WRAP_EQUIPPED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 283, key: "ERR_CANT_WRAP_WRAPPED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 284, key: "ERR_CANT_WRAP_BOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 285, key: "ERR_CANT_WRAP_UNIQUE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 286, key: "ERR_CANT_WRAP_BAGS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 287, key: "ERR_OUT_OF_MANA", kind: MsgKind::Error, sound: None, type_tag: 0x0f, chat_type: 10 },
MessageRecord { id: 288, key: "ERR_OUT_OF_RAGE", kind: MsgKind::Error, sound: None, type_tag: 0x3f, chat_type: 10 },
MessageRecord { id: 289, key: "ERR_OUT_OF_FOCUS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 290, key: "ERR_OUT_OF_ENERGY", kind: MsgKind::Error, sound: None, type_tag: 0x40, chat_type: 10 },
MessageRecord { id: 291, key: "ERR_OUT_OF_HEALTH", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 292, key: "ERR_LOOT_GONE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 293, key: "ERR_MOUNT_FORCEDDISMOUNT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 294, key: "ERR_AUTOFOLLOW_TOO_FAR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 295, key: "ERR_UNIT_NOT_FOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 296, key: "ERR_INVALID_FOLLOW_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 297, key: "ERR_INVALID_INSPECT_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 298, key: "ERR_GUILDEMBLEM_SUCCESS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 299, key: "ERR_GUILDEMBLEM_INVALID_TABARD_COLORS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 300, key: "ERR_GUILDEMBLEM_NOGUILD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 301, key: "ERR_GUILDEMBLEM_NOTGUILDMASTER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 302, key: "ERR_GUILDEMBLEM_NOTENOUGHMONEY", kind: MsgKind::Error, sound: None, type_tag: 0x28, chat_type: 10 },
MessageRecord { id: 303, key: "ERR_GUILDEMBLEM_INVALIDVENDOR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 304, key: "ERR_EMBLEMERROR_NOTABARDGEOSET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 305, key: "ERR_SPELL_OUT_OF_RANGE", kind: MsgKind::Error, sound: None, type_tag: 0x2e, chat_type: 10 },
MessageRecord { id: 306, key: "ERR_COMMAND_NEEDS_TARGET", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 307, key: "ERR_NOAMMO_S", kind: MsgKind::Error, sound: None, type_tag: 0x01, chat_type: 10 },
MessageRecord { id: 308, key: "ERR_TOOBUSYTOFOLLOW", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 309, key: "ERR_DUEL_REQUESTED", kind: MsgKind::Chat, sound: Some("LEVELUP"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 310, key: "ERR_DUEL_CANCELLED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 311, key: "ERR_DEATHBINDALREADYBOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 312, key: "ERR_DEATHBIND_SUCCESS_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 313, key: "ERR_NOEMOTEWHILERUNNING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 314, key: "ERR_ZONE_EXPLORED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 315, key: "ERR_ZONE_EXPLORED_XP", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 316, key: "ERR_INVALID_ITEM_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x33, chat_type: 10 },
MessageRecord { id: 317, key: "ERR_IGNORING_YOU_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 318, key: "ERR_FISH_NOT_HOOKED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 319, key: "ERR_FISH_ESCAPED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 320, key: "ERR_SPELL_FAILED_NOTUNSHEATHED", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 321, key: "ERR_PETITION_OFFERED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 322, key: "ERR_PETITION_SIGNED", kind: MsgKind::Chat, sound: Some("igPlayerInviteAccept"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 323, key: "ERR_PETITION_SIGNED_S", kind: MsgKind::Chat, sound: Some("igPlayerInviteAccept"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 324, key: "ERR_PETITION_DECLINED_S", kind: MsgKind::Chat, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 325, key: "ERR_PETITION_ALREADY_SIGNED", kind: MsgKind::Error, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 326, key: "ERR_PETITION_IN_GUILD", kind: MsgKind::Error, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 327, key: "ERR_PETITION_CREATOR", kind: MsgKind::Error, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 328, key: "ERR_PETITION_NOT_ENOUGH_SIGNATURES", kind: MsgKind::Error, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 329, key: "ERR_PETITION_NOT_SAME_SERVER", kind: MsgKind::Error, sound: Some("igPlayerInviteDecline"), type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 330, key: "ERR_SPELL_UNLEARNED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 331, key: "ERR_PET_SPELL_ROOTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 332, key: "ERR_PET_SPELL_AFFECTING_COMBAT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 333, key: "ERR_PET_SPELL_OUT_OF_RANGE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 334, key: "ERR_PET_SPELL_NOT_BEHIND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 335, key: "ERR_PET_SPELL_TARGETS_DEAD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 336, key: "ERR_PET_SPELL_DEAD", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 337, key: "ERR_PET_SPELL_NOPATH", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 338, key: "ERR_ITEM_CANT_BE_DESTROYED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 339, key: "ERR_TICKET_ALREADY_EXISTS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 340, key: "ERR_TICKET_CREATE_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 341, key: "ERR_TICKET_UPDATE_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 342, key: "ERR_TICKET_DB_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 343, key: "ERR_TICKET_NO_TEXT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 344, key: "ERR_OBJECT_IS_BUSY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 345, key: "ERR_EXHAUSTION_WELLRESTED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 346, key: "ERR_EXHAUSTION_RESTED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 347, key: "ERR_EXHAUSTION_NORMAL", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 348, key: "ERR_EXHAUSTION_TIRED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 349, key: "ERR_EXHAUSTION_EXHAUSTED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 350, key: "ERR_NO_ITEMS_WHILE_SHAPESHIFTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 351, key: "ERR_CANT_INTERACT_SHAPESHIFTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 352, key: "ERR_MAIL_QUEST_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x3b, chat_type: 10 },
MessageRecord { id: 353, key: "ERR_MAIL_BOUND_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x3b, chat_type: 10 },
MessageRecord { id: 354, key: "ERR_MAIL_CONJURED_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 355, key: "ERR_MAIL_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 356, key: "ERR_MAIL_TO_SELF", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 357, key: "ERR_MAIL_TARGET_NOT_FOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 358, key: "ERR_MAIL_DATABASE_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 359, key: "ERR_MAIL_SENT", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 360, key: "ERR_NOT_HAPPY_ENOUGH", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 361, key: "ERR_USE_CANT_IMMUNE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 362, key: "ERR_CANT_BE_DISENCHANTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 363, key: "ERR_CANT_USE_DISARMED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 364, key: "ERR_AUCTION_QUEST_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x3b, chat_type: 10 },
MessageRecord { id: 365, key: "ERR_AUCTION_BOUND_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x3b, chat_type: 10 },
MessageRecord { id: 366, key: "ERR_AUCTION_CONJURED_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 367, key: "ERR_AUCTION_WRAPPED_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 368, key: "ERR_AUCTION_LOOT_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 369, key: "ERR_AUCTION_BAG", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 370, key: "ERR_AUCTION_DATABASE_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 371, key: "ERR_AUCTION_BID_OWN", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 372, key: "ERR_AUCTION_BID_INCREMENT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 373, key: "ERR_AUCTION_HIGHER_BID", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 374, key: "ERR_AUCTION_MIN_BID", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 375, key: "ERR_AUCTION_REPAIR_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 376, key: "ERR_AUCTION_STARTED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 377, key: "ERR_AUCTION_REMOVED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 378, key: "ERR_AUCTION_OUTBID_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 379, key: "ERR_AUCTION_WON_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 380, key: "ERR_AUCTION_SOLD_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 381, key: "ERR_AUCTION_EXPIRED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 382, key: "ERR_AUCTION_REMOVED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 383, key: "ERR_AUCTION_BID_PLACED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 384, key: "ERR_LOGOUT_FAILED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 385, key: "ERR_QUEST_PUSH_SUCCESS_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 386, key: "ERR_QUEST_PUSH_INVALID_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 387, key: "ERR_QUEST_PUSH_ACCEPTED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 388, key: "ERR_QUEST_PUSH_DECLINED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 389, key: "ERR_QUEST_PUSH_TOO_FAR_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 390, key: "ERR_QUEST_PUSH_BUSY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 391, key: "ERR_QUEST_PUSH_LOG_FULL_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 392, key: "ERR_QUEST_PUSH_ONQUEST_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 393, key: "ERR_QUEST_PUSH_ALREADY_DONE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 394, key: "ERR_RAID_GROUP_ONLY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 395, key: "ERR_RAID_GROUP_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 396, key: "ERR_PVP_KILL_HONORABLE", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 397, key: "ERR_PVP_KILL_DISHONORABLE", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 398, key: "ERR_SPELL_FAILED_ALREADY_AT_FULL_HEALTH", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 399, key: "ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 400, key: "ERR_AUTOLOOT_MONEY_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 401, key: "ERR_GENERIC_STUNNED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 402, key: "ERR_TARGET_STUNNED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 403, key: "ERR_MUST_REPAIR_DURABILITY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 404, key: "ERR_RAID_YOU_JOINED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 405, key: "ERR_RAID_YOU_LEFT", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 406, key: "ERR_RAID_MEMBER_ADDED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 407, key: "ERR_RAID_MEMBER_REMOVED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 408, key: "ERR_CLICK_ON_ITEM_TO_FEED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 409, key: "ERR_TOO_MANY_CHAT_CHANNELS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 410, key: "ERR_LOOT_ROLL_PENDING", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 411, key: "ERR_LOOT_PLAYER_NOT_FOUND", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 412, key: "ERR_NOT_IN_RAID", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 413, key: "ERR_LOGGING_OUT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 414, key: "ERR_TARGET_LOGGING_OUT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 415, key: "ERR_NOT_WHILE_MOUNTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 416, key: "ERR_NOT_WHILE_SHAPESHIFTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 417, key: "ERR_NOT_IN_COMBAT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 418, key: "ERR_NOT_WHILE_DISARMED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 419, key: "ERR_PET_BROKEN", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 420, key: "ERR_TALENT_WIPE_ERROR", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 421, key: "ERR_FEIGN_DEATH_RESISTED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 422, key: "ERR_MEETING_STONE_IN_QUEUE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 423, key: "ERR_MEETING_STONE_LEFT_QUEUE_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 424, key: "ERR_MEETING_STONE_OTHER_MEMBER_LEFT", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 425, key: "ERR_MEETING_STONE_PARTY_KICKED_FROM_QUEUE", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 426, key: "ERR_MEETING_STONE_MEMBER_STILL_IN_QUEUE", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 427, key: "ERR_MEETING_STONE_SUCCESS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 428, key: "ERR_MEETING_STONE_IN_PROGRESS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 429, key: "ERR_MEETING_STONE_MEMBER_ADDED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 430, key: "ERR_MEETING_STONE_GROUP_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 431, key: "ERR_MEETING_STONE_NOT_LEADER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 432, key: "ERR_MEETING_STONE_INVALID_LEVEL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 433, key: "ERR_MEETING_STONE_MUST_BE_LEADER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 434, key: "ERR_MEETING_STONE_NO_RAID_GROUP", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 435, key: "ERR_GUILDEMBLEM_SAME", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 436, key: "ERR_EQUIP_TRADE_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 437, key: "ERR_PVP_TOGGLE_ON", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 438, key: "ERR_PVP_TOGGLE_OFF", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 439, key: "ERR_GROUP_JOIN_BATTLEGROUND_DESERTERS", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 440, key: "ERR_GROUP_JOIN_BATTLEGROUND_S", kind: MsgKind::Info, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 441, key: "ERR_GROUP_JOIN_BATTLEGROUND_FAIL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 442, key: "ERR_GROUP_JOIN_BATTLEGROUND_TOO_MANY", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 443, key: "ERR_INVALID_PROMOTION_CODE", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 444, key: "ERR_BG_PLAYER_JOINED_SS", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 445, key: "ERR_BG_PLAYER_LEFT_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 446, key: "ERR_RESTRICTED_ACCOUNT", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 447, key: "ERR_PLAY_TIME_EXCEEDED", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 448, key: "ERR_APPROACHING_PARTIAL_PLAY_TIME", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 449, key: "ERR_APPROACHING_NO_PLAY_TIME", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 450, key: "ERR_UNHEALTHY_TIME", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 451, key: "ERR_CHAT_RESTRICTED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 452, key: "ERR_MAIL_REACHED_CAP", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 453, key: "ERR_INVALID_RAID_TARGET", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 454, key: "ERR_RAID_LEADER_READY_CHECK_START_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 455, key: "ERR_READY_CHECK_IN_PROGRESS", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 456, key: "ERR_DUNGEON_DIFFICULTY_FAILED", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 457, key: "ERR_DUNGEON_DIFFICULTY_CHANGED_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 458, key: "ERR_TRADE_WRONG_REALM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 459, key: "ERR_CHAT_PLAYER_AMBIGUOUS_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 460, key: "ERR_LOOT_CANT_LOOT_THAT_NOW", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 461, key: "ERR_LOOT_MASTER_INV_FULL", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 462, key: "ERR_LOOT_MASTER_UNIQUE_ITEM", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 463, key: "ERR_LOOT_MASTER_OTHER", kind: MsgKind::Error, sound: None, type_tag: 0x44, chat_type: 10 },
MessageRecord { id: 464, key: "ERR_FILTERING_YOU_S", kind: MsgKind::Chat, sound: None, type_tag: 0x44, chat_type: 10 },
];