diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 4b7bb866..d71380c4 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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) { diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index f4a1cfae..4d292c10 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -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 diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs index 2e4f631c..82175b24 100644 --- a/crates/benilla-app/src/ui_chat/channels.rs +++ b/crates/benilla-app/src/ui_chat/channels.rs @@ -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"); diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index d51fad31..b65e81ac 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -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 { - 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::() + } + + /// `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 { + 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`): diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index e719ed90..98045303 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -1353,11 +1353,13 @@ pub(super) fn drain_addon_chat_sends( cvars: Res, // Our descriptor, for the DND arm's live `PLAYER_FLAGS & 0x4`. self_q: Query<&crate::net::ObjectStore, With>, + // The joined slots a `CHANNEL` target's number names. + channels: Res, ) { 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!( diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 95d2a426..16331236 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -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::(); + world.init_resource::(); + world.init_resource::(); + 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::() + .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)> = 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()), + ] + ); +} diff --git a/crates/benilla-ui/src/script/chat_send.rs b/crates/benilla-ui/src/script/chat_send.rs index 8942e15b..9e1316b8 100644 --- a/crates/benilla-ui/src/script/chat_send.rs +++ b/crates/benilla-ui/src/script/chat_send.rs @@ -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, } @@ -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::() .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"); }