SendChatMessage sends a numbered channel's name, not its number (#377)
Typing into a numbered channel (`/1 hello`, or a line under a "[2. Trade]" header) sent nothing: the channel number went to the wire as the channel's name, so the server found no channel called "2". Fixes #222. - `SendChatMessage` reads a CHANNEL target the way 1.12.1 does (`0x49f4d9`-`0x49f4ea`): `SStrToInt` on the text, then the by-number slot getter (`0x49be50`), and the packet carries that slot's name (`0x49f70b`). A number that names no joined slot sends nothing, before the AFK clear and the tutorial acknowledge. There is no by-name leg, so an addon that passes a channel name sends nothing, as in 1.12.1. - The binding keeps the target as Lua's `tostring` gives it, so 2.7 reads 2 instead of rounding to 3. `LeaveChannelByName` shares the parse and the getter. - The send drains run before the zone-channel walk, so a `/N` resolves against the roster the script read the same frame. Test: `a_channel_send_carries_the_numbered_slots_name` runs the real drain over a slot roster. It failed before (the wire target was "2") and passes now; holes, suspended slots, names and missing targets send nothing. To see it: two characters of one faction in the same zone, both in General. On the first, `/1 hello`: the second now sees it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
de0fa568d7
commit
73ec78c480
@@ -662,7 +662,7 @@ pub(crate) mod schedule_tests {
|
||||
/// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the
|
||||
/// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource
|
||||
/// that commutes by construction belongs in [`Classes`].
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_554;
|
||||
const UPDATE_ACTIONABLE_CEILING: usize = 5_551;
|
||||
const UPDATE_ACTIONABLE_SLACK: usize = 40;
|
||||
|
||||
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
|
||||
|
||||
@@ -158,6 +158,9 @@ impl Plugin for UiChatPlugin {
|
||||
)
|
||||
.chain()
|
||||
.after(UiInput)
|
||||
// Before the zone-channel walk renumbers or renames a slot, so a `/N` resolves
|
||||
// against the roster the script's `GetChannelName` read this frame.
|
||||
.before(channels::end_session_channels_on_disconnect)
|
||||
.in_set(crate::char_select::InWorldGated),
|
||||
)
|
||||
// The zone-channel auto-join, which vmangos leaves to the client. The disconnect clear
|
||||
|
||||
@@ -1010,6 +1010,13 @@ mod tests {
|
||||
"a composed name passes through"
|
||||
);
|
||||
assert_eq!(c.leave_target("mychan").as_deref(), Some("mychan"));
|
||||
assert_eq!(c.leave_target("+1").as_deref(), Some("+1"), "no `+`");
|
||||
assert_eq!(c.leave_target(" 1").as_deref(), Some(" 1"), "no space skip");
|
||||
assert_eq!(
|
||||
c.leave_target("4294967297").as_deref(),
|
||||
Some("General - Elwynn Forest"),
|
||||
"`SStrToInt` wraps: 2^32 + 1 reads 1"
|
||||
);
|
||||
|
||||
// The mask clear needs a slot carrying the wire name (`0x49f0f4`).
|
||||
c.note_zone_channel_left("General - Nowhere");
|
||||
|
||||
@@ -142,6 +142,26 @@ impl ChannelSlot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Storm's `SStrToInt` (`0x64ac60`): an optional `-`, then decimal digits up to the first other
|
||||
/// byte, with no whitespace skip, no `+` and no overflow check (`10·n + d` wraps); no digit is 0.
|
||||
fn sstr_to_int(s: &str) -> i32 {
|
||||
let (neg, digits) = match s.strip_prefix('-') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, s),
|
||||
};
|
||||
let n = digits
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_digit)
|
||||
.fold(0i32, |n, d| {
|
||||
n.wrapping_mul(10).wrapping_add(i32::from(d - b'0'))
|
||||
});
|
||||
if neg {
|
||||
n.wrapping_neg()
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// `id`'s `ZONECHANNELS` bit, `1 << (ChannelID - 1)`; nothing outside `1..=32` has one.
|
||||
pub(crate) fn zone_bit(id: u32) -> u32 {
|
||||
if id == 0 || id > 32 {
|
||||
@@ -200,26 +220,31 @@ impl ChannelState {
|
||||
/// (`0x49be50`), and a hole, an out-of-range number or a suspended slot make the whole call a
|
||||
/// no-op (`None`). Anything else is already the wire name.
|
||||
pub(crate) fn leave_target(&self, arg: &str) -> Option<String> {
|
||||
let digits: String = arg
|
||||
.strip_prefix('-')
|
||||
.unwrap_or(arg)
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if digits.is_empty() || digits.chars().all(|c| c == '0') {
|
||||
return Some(arg.to_string());
|
||||
match sstr_to_int(arg) {
|
||||
0 => Some(arg.to_string()),
|
||||
n => self.confirmed_slot_name(n).map(str::to_string),
|
||||
}
|
||||
if arg.starts_with('-') {
|
||||
return None; // a negative never names a slot
|
||||
}
|
||||
digits
|
||||
.parse::<usize>()
|
||||
}
|
||||
|
||||
/// `SendChatMessage`'s channel target (`0x49f4d9`-`0x49f4ea`): `SStrToInt` straight into
|
||||
/// `0x49be50`, with no name leg, so only a number naming a confirmed slot sends, and the
|
||||
/// packet carries that slot's name. Anything else, a name included, sends nothing.
|
||||
pub(crate) fn send_target(&self, arg: &str) -> Option<String> {
|
||||
self.confirmed_slot_name(sstr_to_int(arg))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// The by-number getter `0x49be50`: slot `n`'s name when it is in range, not a hole, and in
|
||||
/// state 0 (here [`SlotState::Joined`]); below 1, past the end, a hole or another state is
|
||||
/// `None`.
|
||||
fn confirmed_slot_name(&self, n: i32) -> Option<&str> {
|
||||
usize::try_from(n)
|
||||
.ok()
|
||||
.filter(|n| *n > 0)
|
||||
.and_then(|n| self.joined.get(n - 1))
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.and_then(|i| self.joined.get(i))
|
||||
.and_then(|slot| slot.as_ref())
|
||||
.filter(|slot| slot.state == SlotState::Joined)
|
||||
.map(|slot| slot.name.clone())
|
||||
.map(|slot| slot.name.as_str())
|
||||
}
|
||||
|
||||
/// Rename a slot in place at send time, the reference's `0x49bc50` from the walk (`0x49a3dc`):
|
||||
|
||||
@@ -1353,11 +1353,13 @@ pub(super) fn drain_addon_chat_sends(
|
||||
cvars: Res<crate::cvars::Cvars>,
|
||||
// Our descriptor, for the DND arm's live `PLAYER_FLAGS & 0x4`.
|
||||
self_q: Query<&crate::net::ObjectStore, With<crate::net::SelfPlayer>>,
|
||||
// The joined slots a `CHANNEL` target's number names.
|
||||
channels: Res<super::edit::ChannelState>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
for send in script.take_chat_sends() {
|
||||
for mut send in script.take_chat_sends() {
|
||||
let Some(kind) = super::edit::SendType::from_token(&send.chat_type) else {
|
||||
warn!(
|
||||
"chat: SendChatMessage with unknown type {:?}",
|
||||
@@ -1369,6 +1371,19 @@ pub(super) fn drain_addon_chat_sends(
|
||||
));
|
||||
continue;
|
||||
};
|
||||
// A channel target is a slot number, sent as that slot's name; with no such slot the call
|
||||
// ends here (`0x49f4ea`), ahead of the AFK clear and the tutorial acknowledge.
|
||||
if kind == super::edit::SendType::Channel {
|
||||
let name = send.target.as_deref().and_then(|t| channels.send_target(t));
|
||||
if name.is_none() {
|
||||
debug!(
|
||||
"chat: SendChatMessage names no joined channel {:?}; not sent",
|
||||
send.target
|
||||
);
|
||||
continue;
|
||||
}
|
||||
send.target = name;
|
||||
}
|
||||
// `SendChatMessage`'s tutorial acknowledge (`0x49f5fc`) covers the twelve social types,
|
||||
// not say, yell or emote.
|
||||
if !matches!(
|
||||
|
||||
@@ -2391,3 +2391,77 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// `SendChatMessage`'s `CHANNEL` target through the real drain: `SStrToInt` into `0x49be50`
|
||||
/// (`0x49f4d9`-`0x49f4ea`), so the packet carries the numbered slot's name, and a number naming
|
||||
/// no confirmed slot, or a name, sends nothing at all.
|
||||
#[test]
|
||||
fn a_channel_send_carries_the_numbered_slots_name() {
|
||||
use super::edit::{ChannelSlot, ChannelState, SlotState};
|
||||
use crate::net::{ChatKind, ClientCommand, NetCommands};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = bevy::prelude::World::new();
|
||||
world.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM"));
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
world.insert_resource(NetCommands(tx));
|
||||
world.init_resource::<super::feed::ChatLog>();
|
||||
world.init_resource::<super::away::AfkMirror>();
|
||||
world.init_resource::<crate::cvars::Cvars>();
|
||||
world.insert_resource(ChannelState {
|
||||
joined: vec![
|
||||
Some(ChannelSlot::joined("General - Elwynn Forest")),
|
||||
Some(ChannelSlot::joined("Trade - City")),
|
||||
None,
|
||||
Some(ChannelSlot {
|
||||
name: "LocalDefense - Elwynn Forest".into(),
|
||||
state: SlotState::Suspended,
|
||||
}),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
world
|
||||
.non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.run(
|
||||
r#"
|
||||
SendChatMessage("lf1m", "CHANNEL", nil, 2)
|
||||
SendChatMessage("hello", "CHANNEL", nil, "1")
|
||||
SendChatMessage("float", "CHANNEL", nil, 2.7)
|
||||
SendChatMessage("hole", "CHANNEL", nil, 3)
|
||||
SendChatMessage("suspended", "CHANNEL", nil, 4)
|
||||
SendChatMessage("past the end", "CHANNEL", nil, 9)
|
||||
SendChatMessage("by name", "CHANNEL", nil, "General - Elwynn Forest")
|
||||
SendChatMessage("zero", "CHANNEL", nil, "0")
|
||||
SendChatMessage("none", "CHANNEL")
|
||||
SendChatMessage("tell", "WHISPER", nil, "2")
|
||||
"#,
|
||||
)
|
||||
.expect("lua");
|
||||
world
|
||||
.run_system_once(super::input::drain_addon_chat_sends)
|
||||
.expect("drain");
|
||||
|
||||
let sent: Vec<(ChatKind, Option<String>, String)> = rx
|
||||
.try_iter()
|
||||
.map(|c| match c {
|
||||
ClientCommand::Chat { kind, target, text } => (kind, target, text),
|
||||
other => panic!("unexpected command {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
let chan = |target: &str, text: &str| {
|
||||
(
|
||||
ChatKind::Channel,
|
||||
Some(target.to_string()),
|
||||
text.to_string(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
sent,
|
||||
vec![
|
||||
chan("Trade - City", "lf1m"),
|
||||
chan("General - Elwynn Forest", "hello"),
|
||||
chan("Trade - City", "float"),
|
||||
(ChatKind::Whisper, Some("2".into()), "tell".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `SendChatMessage(text [, chatType [, language [, target]]])` (`0x49f1e0`): the engine verb an
|
||||
//! addon speaks through, with no FrameXML between it and the wire. `chatType` defaults to
|
||||
//! `"SAY"`; the fourth argument is the whisper target or the channel name or number.
|
||||
//! `"SAY"`; the fourth argument is the whisper target or the channel's number.
|
||||
//!
|
||||
//! It queues a [`ChatSend`] rather than going through the edit box, whose drain runs the slash
|
||||
//! grammar: an addon's `SendChatMessage("/dance")` says the characters. The reference splits the
|
||||
@@ -34,7 +34,8 @@ pub struct ChatSend {
|
||||
/// The chat type token (`"SAY"`, `"WHISPER"`, `"CHANNEL"`, …), uppercased, since the
|
||||
/// reference's compare ignores case.
|
||||
pub chat_type: String,
|
||||
/// The whisper target or the channel name or number, as text; the app resolves either.
|
||||
/// The fourth argument as `lua_tostring` gives it (`0x49f306`): the whisper target, or the
|
||||
/// channel number the app resolves to a joined channel's name.
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
@@ -108,13 +109,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let chat_type = chat_type
|
||||
.unwrap_or_else(|| "SAY".into())
|
||||
.to_ascii_uppercase();
|
||||
// A channel arrives as a name or a number; both become text.
|
||||
let target = match target {
|
||||
Value::String(s) => Some(s.to_string_lossy()),
|
||||
Value::Integer(n) => Some(n.to_string()),
|
||||
Value::Number(n) => Some(format!("{n:.0}")),
|
||||
_ => None,
|
||||
};
|
||||
// `lua_isstring` then `lua_tostring` (`0x49f2f6`, `0x49f306`): a number is its
|
||||
// text, so `2.7` reaches the channel's `SStrToInt` as "2.7", which reads 2.
|
||||
let target = super::binding_abi::optional_string(lua, &target);
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.chat_sends
|
||||
@@ -166,7 +163,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_target_argument_takes_a_name_or_a_channel_number() {
|
||||
fn the_target_argument_is_its_lua_tostring_text() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(r#"SendChatMessage("hi", "WHISPER", nil, "Bob")"#)
|
||||
.unwrap();
|
||||
@@ -174,10 +171,20 @@ mod tests {
|
||||
.unwrap();
|
||||
s.run(r#"SendChatMessage("lf1m", "CHANNEL", nil, "General")"#)
|
||||
.unwrap();
|
||||
s.run(r#"SendChatMessage("lf1m", "CHANNEL", nil, 2.7)"#)
|
||||
.unwrap();
|
||||
s.run(r#"SendChatMessage("lf1m", "CHANNEL", nil, {})"#)
|
||||
.unwrap();
|
||||
let sent = s.take_chat_sends();
|
||||
assert_eq!(sent[0].target.as_deref(), Some("Bob"));
|
||||
assert_eq!(sent[1].target.as_deref(), Some("1"));
|
||||
assert_eq!(sent[2].target.as_deref(), Some("General"));
|
||||
assert_eq!(
|
||||
sent[3].target.as_deref(),
|
||||
Some("2.7"),
|
||||
"not rounded: the app's `SStrToInt` reads 2"
|
||||
);
|
||||
assert_eq!(sent[4].target, None, "a table is no string");
|
||||
assert_eq!(sent[1].chat_type, "CHANNEL");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user