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:
Sam Sung
2026-09-26 02:46:13 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent de0fa568d7
commit 73ec78c480
7 changed files with 159 additions and 28 deletions
+1 -1
View File
@@ -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) {
+3
View File
@@ -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");
+41 -16
View File
@@ -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`):
+16 -1
View File
@@ -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!(
+74
View File
@@ -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()),
]
);
}
+17 -10
View File
@@ -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");
}