From c6767d6a798266ff212ff49b78fef8c4a523b829 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:09:44 +0700 Subject: [PATCH] SendChatMessage resolves its language name against Languages.dbc, raising "Unknown language" on a miss, and the chat send carries that id, so a racial language picked in the chat menu is spoken (#164) (#498) `SendChatMessage`'s language argument is spoken, as in 1.12.1: a racial language picked from the chat menu (`ChatFrame.lua:2404`, passed on by `ChatEdit_SendText` at `:1950-1954`) or named by an addon goes out in `CMSG_MESSAGECHAT`, where before every line went in the faction tongue. - `0x49f1e0` starts from the speaker's default language (`0x5ec890`); when argument 3 passes `lua_isstring` (a string or a number) it is matched by name against every `Languages.dbc` row, case-insensitively (`0x49f8a0`, `SStrCmpI`), and a miss raises "Unknown language" (`0x49f2e1`). nil, an absent argument, a table or a boolean keep the default. A language the character has not learned is sent and left to the server, which drops it (vmangos `ChatHandler.cpp:175`). - The packet builder writes the type, the language and a target for a whisper or a channel only, then the text (`0x49f6c3`-`0x49f72a`); fourteen per-type writers become one, with byte-exact goldens. - Each new UI state is seeded with the language table. Checked live against the local vmangos: a say, a yell, a whisper to self and a GM command round-trip through the new builder; a channel line reaches the server's channel lookup; an unknown language raises in Lua. Fixes #164 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../benilla-app/src/capture/probe_auction.rs | 2 + crates/benilla-app/src/capture/probe_bank.rs | 4 + crates/benilla-app/src/capture/probe_bg.rs | 1 + .../benilla-app/src/capture/probe_bg_queue.rs | 3 + .../benilla-app/src/capture/probe_binder.rs | 1 + crates/benilla-app/src/capture/probe_book.rs | 1 + .../benilla-app/src/capture/probe_charter.rs | 2 + crates/benilla-app/src/capture/probe_chest.rs | 1 + crates/benilla-app/src/capture/probe_clam.rs | 2 + .../benilla-app/src/capture/probe_crossing.rs | 2 + .../benilla-app/src/capture/probe_goquest.rs | 2 + .../src/capture/probe_guard_poi.rs | 1 + crates/benilla-app/src/capture/probe_mail.rs | 1 + .../benilla-app/src/capture/probe_service.rs | 3 + crates/benilla-app/src/capture/probe_stone.rs | 3 + crates/benilla-app/src/capture/probe_taxi.rs | 1 + .../src/capture/probe_vendor_swap.rs | 1 + crates/benilla-app/src/net.rs | 41 ++++ crates/benilla-app/src/net/io.rs | 29 +-- crates/benilla-app/src/player/land.rs | 1 + crates/benilla-app/src/ui_chat/away.rs | 1 + crates/benilla-app/src/ui_chat/idle.rs | 3 +- crates/benilla-app/src/ui_chat/input/mod.rs | 8 + crates/benilla-app/src/ui_chat/tests.rs | 54 ++++- .../benilla-app/src/ui_script/chat_tests.rs | 31 +++ crates/benilla-app/src/ui_script/lifecycle.rs | 6 +- crates/benilla-app/src/ui_unit.rs | 51 +++++ crates/benilla-app/src/ui_world_map.rs | 1 + .../benilla-protocol/src/world/writer/chat.rs | 194 +++++++----------- .../benilla-protocol/src/world/writer/mod.rs | 5 +- crates/benilla-ui/src/script/chat_send.rs | 108 +++++++++- crates/benilla-ui/src/script/model.rs | 3 + 32 files changed, 408 insertions(+), 159 deletions(-) diff --git a/crates/benilla-app/src/capture/probe_auction.rs b/crates/benilla-app/src/capture/probe_auction.rs index 2a936cd5..67304337 100644 --- a/crates/benilla-app/src/capture/probe_auction.rs +++ b/crates/benilla-app/src/capture/probe_auction.rs @@ -431,6 +431,7 @@ fn auction_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} 0"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } @@ -781,6 +782,7 @@ fn auction_probe( kind: ChatKind::Say, target: None, text: format!(".additem {ITEM_ENTRY} {ITEM_COUNT}"), + language: None, }); probe.phase = Phase::EnsureItem { since: now, diff --git a/crates/benilla-app/src/capture/probe_bank.rs b/crates/benilla-app/src/capture/probe_bank.rs index a1f46213..f7791cde 100644 --- a/crates/benilla-app/src/capture/probe_bank.rs +++ b/crates/benilla-app/src/capture/probe_bank.rs @@ -202,6 +202,7 @@ fn bank_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} 0"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } @@ -276,6 +277,7 @@ fn bank_probe( kind: ChatKind::Say, target: None, text: format!(".additem {ITEM_ENTRY} 1"), + language: None, }); probe.phase = Phase::EnsureItem { since: now, @@ -395,6 +397,7 @@ fn bank_probe( let _ = net.0.send(ClientCommand::Chat { kind: ChatKind::Say, text: format!(".modify money {grant}"), + language: None, target: None, }); probe.phase = Phase::BuySlot { @@ -482,6 +485,7 @@ fn bank_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {by} {bz} 0"), + language: None, }); probe.phase = Phase::Refusal { since: now, diff --git a/crates/benilla-app/src/capture/probe_bg.rs b/crates/benilla-app/src/capture/probe_bg.rs index 4273e4e3..b7c7e7eb 100644 --- a/crates/benilla-app/src/capture/probe_bg.rs +++ b/crates/benilla-app/src/capture/probe_bg.rs @@ -291,6 +291,7 @@ fn gm(net: &NetCommands, text: impl Into) { kind: ChatKind::Say, target: None, text: text.into(), + language: None, }); } diff --git a/crates/benilla-app/src/capture/probe_bg_queue.rs b/crates/benilla-app/src/capture/probe_bg_queue.rs index 037fec12..ecfe7f65 100644 --- a/crates/benilla-app/src/capture/probe_bg_queue.rs +++ b/crates/benilla-app/src/capture/probe_bg_queue.rs @@ -87,6 +87,7 @@ fn bg_queue_probe( kind: crate::net::ChatKind::Say, target: None, text: ".revive".to_string(), + language: None, }); // A body under the bracket floor is refused at the hello, with no list to join from. let level = store.0.unit_level().unwrap_or(0); @@ -96,6 +97,7 @@ fn bg_queue_probe( kind: crate::net::ChatKind::Say, target: None, text: format!(".levelup {}", QUEUE_LEVEL - level), + language: None, }); } let [x, y, z] = BATTLEMASTER_AT; @@ -104,6 +106,7 @@ fn bg_queue_probe( kind: crate::net::ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} 0"), + language: None, }); probe.phase = Phase::Hopped { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_binder.rs b/crates/benilla-app/src/capture/probe_binder.rs index 26da0df7..c017c9fc 100644 --- a/crates/benilla-app/src/capture/probe_binder.rs +++ b/crates/benilla-app/src/capture/probe_binder.rs @@ -235,6 +235,7 @@ fn binder_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {INNKEEPER_MAP}"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_book.rs b/crates/benilla-app/src/capture/probe_book.rs index 1add8a07..d48f429c 100644 --- a/crates/benilla-app/src/capture/probe_book.rs +++ b/crates/benilla-app/src/capture/probe_book.rs @@ -239,6 +239,7 @@ fn book_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} 0"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_charter.rs b/crates/benilla-app/src/capture/probe_charter.rs index c8746af6..5346991b 100644 --- a/crates/benilla-app/src/capture/probe_charter.rs +++ b/crates/benilla-app/src/capture/probe_charter.rs @@ -371,6 +371,7 @@ fn charter_probe( kind: ChatKind::Say, target: None, text: format!(".modify money {FUND_COPPER}"), + language: None, }); info!( "PROBE_CHARTER: 0 (precheck) — funding the body with {FUND_COPPER} copper so the \ @@ -1046,6 +1047,7 @@ fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) { kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {REGISTRAR_MAP}"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_chest.rs b/crates/benilla-app/src/capture/probe_chest.rs index 212a03e4..523968b5 100644 --- a/crates/benilla-app/src/capture/probe_chest.rs +++ b/crates/benilla-app/src/capture/probe_chest.rs @@ -200,6 +200,7 @@ fn chest_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {map}"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_clam.rs b/crates/benilla-app/src/capture/probe_clam.rs index 7a5504eb..e7639a55 100644 --- a/crates/benilla-app/src/capture/probe_clam.rs +++ b/crates/benilla-app/src/capture/probe_clam.rs @@ -126,6 +126,7 @@ fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase { kind: ChatKind::Say, target: None, text: format!(".additem {entry} -1"), + language: None, }); Phase::Cleanup { since: now } } @@ -197,6 +198,7 @@ fn clam_probe( kind: ChatKind::Say, target: None, text: format!(".additem {entry}"), + language: None, }); probe.phase = Phase::Stocking { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_crossing.rs b/crates/benilla-app/src/capture/probe_crossing.rs index b3012818..031646d8 100644 --- a/crates/benilla-app/src/capture/probe_crossing.rs +++ b/crates/benilla-app/src/capture/probe_crossing.rs @@ -134,6 +134,7 @@ fn crossing_probe( kind: crate::net::ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {}", m as i32), + language: None, }); return; } @@ -167,6 +168,7 @@ fn crossing_probe( kind: crate::net::ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {} ", z + DROP_HEIGHT), + language: None, }); probe.phase = Phase::Boarding { boat: guid.0, diff --git a/crates/benilla-app/src/capture/probe_goquest.rs b/crates/benilla-app/src/capture/probe_goquest.rs index 36868f36..a3a4f85d 100644 --- a/crates/benilla-app/src/capture/probe_goquest.rs +++ b/crates/benilla-app/src/capture/probe_goquest.rs @@ -147,6 +147,7 @@ fn level_to(net: &NetCommands, from: u32, to: u32) { kind: ChatKind::Say, target: None, text: format!(".levelup {delta}"), + language: None, }); } @@ -209,6 +210,7 @@ fn goquest_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {map}"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_guard_poi.rs b/crates/benilla-app/src/capture/probe_guard_poi.rs index c47611de..e4e707c2 100644 --- a/crates/benilla-app/src/capture/probe_guard_poi.rs +++ b/crates/benilla-app/src/capture/probe_guard_poi.rs @@ -83,6 +83,7 @@ fn guard_poi_probe( kind: crate::net::ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} 0"), + language: None, }); probe.phase = Phase::Hopped { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_mail.rs b/crates/benilla-app/src/capture/probe_mail.rs index bc823497..fb3c6b27 100644 --- a/crates/benilla-app/src/capture/probe_mail.rs +++ b/crates/benilla-app/src/capture/probe_mail.rs @@ -232,6 +232,7 @@ fn mail_probe( kind: ChatKind::Say, target: None, text, + language: None, }); } probe.phase = Phase::Settling { sent_at: now }; diff --git a/crates/benilla-app/src/capture/probe_service.rs b/crates/benilla-app/src/capture/probe_service.rs index 8535374f..e82b6a18 100644 --- a/crates/benilla-app/src/capture/probe_service.rs +++ b/crates/benilla-app/src/capture/probe_service.rs @@ -641,6 +641,7 @@ fn service_probe( kind: ChatKind::Say, target: None, text: format!(".additem {SELL_ITEM_ENTRY} 1"), + language: None, }); probe.phase = Phase::VendorArm { since: now, guid }; } else if now - since > WINDOW_TIMEOUT_SECS { @@ -813,6 +814,7 @@ fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64 kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {}", leg.map), + language: None, }); probe.polls = 0; probe.phase = Phase::Hop { @@ -837,6 +839,7 @@ fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) { kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {VENDOR_MAP}"), + language: None, }); probe.phase = Phase::VendorHop { sent_at: now }; } diff --git a/crates/benilla-app/src/capture/probe_stone.rs b/crates/benilla-app/src/capture/probe_stone.rs index d07f9751..50573cc8 100644 --- a/crates/benilla-app/src/capture/probe_stone.rs +++ b/crates/benilla-app/src/capture/probe_stone.rs @@ -192,6 +192,7 @@ fn stone_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {map}"), + language: None, }); probe.phase = Phase::Settling { sent_at: now }; } @@ -251,6 +252,7 @@ fn stone_probe( kind: ChatKind::Say, target: None, text: format!(".character level {LEVEL_BELOW}"), + language: None, }); probe.phase = Phase::WaitLevel { want: LEVEL_BELOW, @@ -302,6 +304,7 @@ fn stone_probe( kind: ChatKind::Say, target: None, text: format!(".character level {want}"), + language: None, }); probe.phase = Phase::WaitLevel { want, diff --git a/crates/benilla-app/src/capture/probe_taxi.rs b/crates/benilla-app/src/capture/probe_taxi.rs index e56ae22c..7319e495 100644 --- a/crates/benilla-app/src/capture/probe_taxi.rs +++ b/crates/benilla-app/src/capture/probe_taxi.rs @@ -170,6 +170,7 @@ fn taxi_probe( kind: crate::net::ChatKind::Say, target: None, text, + language: None, }); } probe.phase = Phase::Hopped { sent_at: now }; diff --git a/crates/benilla-app/src/capture/probe_vendor_swap.rs b/crates/benilla-app/src/capture/probe_vendor_swap.rs index 2e5e2a56..a7b16552 100644 --- a/crates/benilla-app/src/capture/probe_vendor_swap.rs +++ b/crates/benilla-app/src/capture/probe_vendor_swap.rs @@ -173,6 +173,7 @@ fn vendor_swap_probe( kind: ChatKind::Say, target: None, text: format!(".go xyz {x} {y} {z} {MAP}"), + language: None, }); probe.phase = Phase::Settle { sent_at: now }; } diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index f669ffe1..ff29a309 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -678,23 +678,62 @@ pub(crate) fn addon_wire_chat_type(distribution: benilla_ui::script::AddonDistri /// `HandleChatMessageOpcode`). `Whisper` and `Channel` name their target in `target`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum ChatKind { + /// `/say`; GM dot-commands go out this way, parsed after the language gate. Say, + /// `/yell` (`CHAT_MSG_YELL`, `SharedDefines.h:1199`). Yell, + /// A custom `/emote`, shown verbatim as `"PlayerName "`. Emote, + /// `/whisper`: the target's name precedes the text (`Chat.cpp:3-12`). Whisper, + /// `/p`, dropped silently when ungrouped (`ChatHandler.cpp:472-493`). Party, + /// `/ra`; needs a raid (`ChatHandler.cpp:514-536`). Raid, + /// `/rl`, leader only (`ChatHandler.cpp:538-559`). RaidLeader, + /// `/rw`, leader or assistant only (`ChatHandler.cpp:561-576`). RaidWarning, + /// `/g`; needs a guild (`ChatHandler.cpp:494-503`). Guild, + /// `/o`; needs a guild (`ChatHandler.cpp:504-513`). Officer, + /// Needs a battleground group (`ChatHandler.cpp:579-593`). Battleground, + /// Battleground leader only (`ChatHandler.cpp:595-609`). BattlegroundLeader, + /// Toggles AFK, the text the auto-reply if any; it clears DND (`ChatHandler.cpp:611-630`). Afk, + /// Toggles DND, which clears AFK (`ChatHandler.cpp:632-648`). Dnd, + /// A channel line by name, dropped unless we are on it (`ChatHandler.cpp:255-327`). Channel, } +impl ChatKind { + /// The `CMSG_MESSAGECHAT` `type` field. + pub(crate) fn chat_type(self) -> u32 { + use benilla_protocol::messages as m; + match self { + Self::Say => m::CHAT_TYPE_SAY, + Self::Yell => m::CHAT_TYPE_YELL, + Self::Emote => m::CHAT_TYPE_EMOTE, + Self::Whisper => m::CHAT_TYPE_WHISPER, + Self::Party => m::CHAT_TYPE_PARTY, + Self::Raid => m::CHAT_TYPE_RAID, + Self::RaidLeader => m::CHAT_TYPE_RAID_LEADER, + Self::RaidWarning => m::CHAT_TYPE_RAID_WARNING, + Self::Guild => m::CHAT_TYPE_GUILD, + Self::Officer => m::CHAT_TYPE_OFFICER, + Self::Battleground => m::CHAT_TYPE_BATTLEGROUND, + Self::BattlegroundLeader => m::CHAT_TYPE_BATTLEGROUND_LEADER, + Self::Afk => m::CHAT_TYPE_AFK, + Self::Dnd => m::CHAT_TYPE_DND, + Self::Channel => m::CHAT_TYPE_CHANNEL, + } + } +} + /// `WOW_CAST_TRACE=1`: log our own cast packets and every outbound movement packet. vmangos /// interrupts a cast on a movement report whose position differs at all (`Player::SetPosition`). pub(crate) static CAST_TRACE: std::sync::LazyLock = @@ -780,6 +819,8 @@ pub(crate) enum ClientCommand { kind: ChatKind, target: Option, text: String, + /// The `Languages.dbc` id `SendChatMessage` named; `None` speaks the character's own. + language: Option, }, /// `SendAddonMessage`: a `CMSG_MESSAGECHAT` in `LANG_ADDON` (1.12 has no addon opcode and no /// whispered addon message). `text` is `prefix` TAB `message`. diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 01290709..32cfd1b2 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -25,7 +25,7 @@ use benilla_protocol::{ }; use crossbeam_channel::{Receiver, Sender}; -use super::{CharRequest, ChatKind, ClientCommand, RealmRequest}; +use super::{CharRequest, ClientCommand, RealmRequest}; /// The inbound census: every packet off the world socket, and the unix ms of the latest, which /// tell a silent server from a dead socket for the runaway watch ([`crate::net::motion`]). @@ -920,27 +920,12 @@ fn writer_loop( ClientCommand::CancelAutoRepeat => w.cancel_auto_repeat(), ClientCommand::CancelCast { spell_id } => w.cancel_cast(spell_id), ClientCommand::CancelChannelling { spell_id } => w.cancel_channelling(spell_id), - ClientCommand::Chat { kind, target, text } => match kind { - ChatKind::Say => w.send_chat(&text), - ChatKind::Yell => w.send_yell(&text), - ChatKind::Emote => w.send_emote_chat(&text), - ChatKind::Whisper => { - w.send_whisper(target.as_deref().unwrap_or_default(), &text) - } - ChatKind::Party => w.send_party(&text), - ChatKind::Raid => w.send_raid(&text), - ChatKind::RaidLeader => w.send_raid_leader(&text), - ChatKind::RaidWarning => w.send_raid_warning(&text), - ChatKind::Guild => w.send_guild(&text), - ChatKind::Officer => w.send_officer(&text), - ChatKind::Battleground => w.send_battleground(&text), - ChatKind::BattlegroundLeader => w.send_battleground_leader(&text), - ChatKind::Afk => w.send_afk(&text), - ChatKind::Dnd => w.send_dnd(&text), - ChatKind::Channel => { - w.send_channel(target.as_deref().unwrap_or_default(), &text) - } - }, + ClientCommand::Chat { + kind, + target, + text, + language, + } => w.send_message_chat(kind.chat_type(), language, target.as_deref(), &text), // The distribution is an enum, so its map to a chat type is total. ClientCommand::AddonMessage { distribution, text } => { w.send_addon_message(super::addon_wire_chat_type(distribution), &text) diff --git a/crates/benilla-app/src/player/land.rs b/crates/benilla-app/src/player/land.rs index fa5ce9d0..59ca609a 100644 --- a/crates/benilla-app/src/player/land.rs +++ b/crates/benilla-app/src/player/land.rs @@ -83,6 +83,7 @@ pub(crate) fn land_here( kind: ChatKind::Say, target: None, text, + language: None, }); *pending = Some(time.elapsed_secs() + LAND_TIMEOUT); } diff --git a/crates/benilla-app/src/ui_chat/away.rs b/crates/benilla-app/src/ui_chat/away.rs index bcc9f15f..9366ffd3 100644 --- a/crates/benilla-app/src/ui_chat/away.rs +++ b/crates/benilla-app/src/ui_chat/away.rs @@ -215,6 +215,7 @@ pub(crate) fn movement_clears_afk( kind: crate::net::ChatKind::Afk, target: None, text: String::new(), + language: None, }); } diff --git a/crates/benilla-app/src/ui_chat/idle.rs b/crates/benilla-app/src/ui_chat/idle.rs index 51b4d4f5..49ce968c 100644 --- a/crates/benilla-app/src/ui_chat/idle.rs +++ b/crates/benilla-app/src/ui_chat/idle.rs @@ -182,6 +182,7 @@ pub(crate) fn idle_handler( kind: crate::net::ChatKind::Afk, target: None, text: out.body, + language: None, }); } // `0x482eee call 0x5ab0d0; 0x482ef5 jne`: while a logout is pending the whole leg is skipped, @@ -542,7 +543,7 @@ mod tests { assert!( matches!( sent.as_slice(), - [ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, text }] + [ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, text, .. }] if text == "Away from Keyboard" ), "{sent:?}" diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 12736c23..5bf8df12 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -183,6 +183,7 @@ pub(super) fn drain_chat_input( kind: super::edit::SendType::Say.wire(), target: None, text: msg.to_string(), + language: None, }; if commands.0.send(cmd).is_err() { warn!("chat: not connected; line dropped"); @@ -1414,6 +1415,7 @@ pub(super) fn drain_addon_chat_sends( kind: crate::net::ChatKind::Afk, target: None, text: String::new(), + language: None, }); } } @@ -1442,6 +1444,12 @@ pub(super) fn drain_addon_chat_sends( kind: wire, target: send.target, text, + // Every type but AFK carries the language the binding resolved (`0x49f6f9`); the AFK + // arm's own builder, `SetAFK` (`0x5eb740`), never reads the argument. + language: match wire { + crate::net::ChatKind::Afk => None, + _ => send.language, + }, }; if commands.0.send(cmd).is_err() { warn!("chat: not connected; addon line dropped"); diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 52748d2c..95aea3f5 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2456,6 +2456,56 @@ fn afk_then_dnd_clears_the_afk_first() { assert!(!world.resource::().is_afk()); } +/// `SendChatMessage`'s language through the real drain: the id the binding resolved rides every +/// type's packet (`0x49f6f9`) but AFK's, whose `SetAFK` (`0x5eb740`) never reads it. +#[test] +fn a_named_language_rides_the_chat_command() { + use crate::net::{ChatKind, ClientCommand, NetCommands}; + use bevy::ecs::system::RunSystemOnce; + + let mut world = bevy::prelude::World::new(); + let mut script = benilla_ui::script::UiScript::new().expect("VM"); + script.set_language_table(vec![(2, "Darnassian".into()), (7, "Common".into())]); + world.insert_non_send_resource(script); + let (tx, rx) = crossbeam_channel::unbounded(); + world.insert_resource(NetCommands(tx)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + .non_send_resource::() + .run( + r#" + SendChatMessage("ishnu", "SAY", "darnassian") + SendChatMessage("hi", "WHISPER", "Darnassian", "Bob") + SendChatMessage("hi") + DEFAULT_AFK_MESSAGE = "Away from Keyboard" + SendChatMessage("brb", "AFK", "Darnassian") + "#, + ) + .expect("lua"); + world + .run_system_once(super::input::drain_addon_chat_sends) + .expect("drain"); + let sent: Vec<(ChatKind, Option)> = rx + .try_iter() + .map(|c| match c { + ClientCommand::Chat { kind, language, .. } => (kind, language), + other => panic!("unexpected command {other:?}"), + }) + .collect(); + assert_eq!( + sent, + vec![ + (ChatKind::Say, Some(2)), + (ChatKind::Whisper, Some(2)), + (ChatKind::Say, None), + (ChatKind::Afk, None), + ] + ); +} + /// `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. @@ -2508,7 +2558,9 @@ fn a_channel_send_carries_the_numbered_slots_name() { let sent: Vec<(ChatKind, Option, String)> = rx .try_iter() .map(|c| match c { - ClientCommand::Chat { kind, target, text } => (kind, target, text), + ClientCommand::Chat { + kind, target, text, .. + } => (kind, target, text), other => panic!("unexpected command {other:?}"), }) .collect(); diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 556f462b..f56972bd 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -166,6 +166,37 @@ fn input_editbox_enter_drains_the_typed_line() { assert!(s.take_chat_sends().is_empty(), "drained"); } +/// The chat menu's Language list sets `editBox.language` (`ChatFrame.lua:2403-2405`) and +/// `ChatEdit_SendText` passes it on every send (`:1950-1954`), so `/s` and `/y` speak the pick. +#[test] +fn the_edit_box_speaks_the_language_picked_in_the_chat_menu() { + benilla_formats::wow_data_or_skip!(); + let mut s = chat_frame(); + s.set_language_table(vec![(2, "Darnassian".into()), (7, "Common".into())]); + s.set_known_languages(vec!["Darnassian".into(), "Common".into()]); + for line in ["/s before", "/s ishnu", "/y alah"] { + if line == "/s ishnu" { + s.run("ChatFrameEditBox.language = GetLanguageByIndex(1)") + .unwrap(); + } + assert!(s.focus_editbox("ChatFrameEditBox")); + s.char_input(line); + assert!(s.key_input("ENTER")); + } + let sends = s.take_chat_sends(); + assert_eq!( + sends + .iter() + .map(|c| (c.text.as_str(), c.chat_type.as_str(), c.language)) + .collect::>(), + vec![ + ("before", "SAY", None), + ("ishnu", "SAY", Some(2)), + ("alah", "YELL", Some(2)), + ] + ); +} + #[test] fn input_escape_closes_without_submitting() { benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 66363ebc..fe453d88 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -329,11 +329,13 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) { .is_none_or(crate::cvars::Cvars::addon_version_check); // Per-VM `Update` claims the load itself reads, seeded now: the zone-channel catalog (empty, // `General` would be filed and joined as a custom channel), the binding table (stock - // `ActionButton_OnLoad` paints the hotkeys) and the default language (`ChatFrame_OnEvent`'s - // `PLAYER_ENTERING_WORLD` arm stores it, `ChatFrame.lua:1275-1276`). + // `ActionButton_OnLoad` paints the hotkeys), the default language (`ChatFrame_OnEvent`'s + // `PLAYER_ENTERING_WORLD` arm stores it, `ChatFrame.lua:1275-1276`) and the language table a + // file-scope `SendChatMessage` names its tongue from. crate::ui_chat::seed_zone_channel_catalog(world, &mut script); crate::bindings::seed_bindings_for_vm(world, &mut script); crate::ui_unit::seed_default_language(world, &mut script); + crate::ui_unit::seed_language_table(world, &mut script); // The world map's continent and zone lists: static DBC data in the reference, read at file // scope (Astrolabe builds its whole zone table from `GetMapContinents`/`GetMapZones` there). crate::ui_world_map::seed_world_map_catalog(world, &mut script); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 2b5cca5c..81f9fb1b 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -310,6 +310,21 @@ pub(crate) fn seed_default_language(world: &mut World, script: &mut UiScript) { script.set_default_language(langs.0.name(u32::from(row.race), 0).map(str::to_string)); } +/// Seed a new VM with `Languages.dbc`, the static table `SendChatMessage` resolves its language +/// name against (`0x49f8a0`); locale column 0, as [`feed_default_language`] reads. +pub(crate) fn seed_language_table(world: &World, script: &mut UiScript) { + let Some(langs) = world.get_resource::() else { + return; + }; + script.set_language_table( + langs + .0 + .names(0) + .map(|(id, name)| (id, name.to_string())) + .collect(), + ); +} + /// Push `GetDefaultLanguage()`'s string on a change of race; `None` is the reference's zero value. /// Locale column 0 (the client's slot is `[0xc0e080]`): only enUS is populated in the 1.12 data. fn feed_default_language( @@ -1657,6 +1672,42 @@ mod tests { ); } + /// A new VM is seeded with the shipped `Languages.dbc`, so each race's own tongue resolves by + /// name to the id vmangos keys `KnowsLanguage` on (`SharedDefines.h:253-269`). + #[test] + fn a_new_vm_resolves_every_racial_language_by_name() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let mut world = World::new(); + world.insert_resource(LanguagesRes( + benilla_formats::load_languages(&mut chain).expect("Languages.dbc"), + )); + let mut script = UiScript::new().expect("VM"); + seed_language_table(&world, &mut script); + let want = [ + ("Orcish", 1), + ("Darnassian", 2), + ("Taurahe", 3), + ("Dwarvish", 6), + ("Common", 7), + ("Gnomish", 13), + ("Troll", 14), + ("Gutterspeak", 33), + ]; + for (name, _) in want { + script + .run(&format!(r#"SendChatMessage("hi", "SAY", "{name}")"#)) + .unwrap_or_else(|e| panic!("{name}: {e}")); + } + let got: Vec> = script + .take_chat_sends() + .iter() + .map(|c| c.language) + .collect(); + let ids: Vec> = want.iter().map(|&(_, id)| Some(id)).collect(); + assert_eq!(got, ids); + } + /// [`race_pvp_team`] against the shipped DBCs, walked as `0x5efe00` walks them. #[test] fn race_pvp_team_matches_the_shipped_tables() { diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index 5271b324..1176d59f 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -778,6 +778,7 @@ fn dev_map_jump( kind: crate::net::ChatKind::Say, target: None, text, + language: None, }); } diff --git a/crates/benilla-protocol/src/world/writer/chat.rs b/crates/benilla-protocol/src/world/writer/chat.rs index d059e147..ac101d82 100644 --- a/crates/benilla-protocol/src/world/writer/chat.rs +++ b/crates/benilla-protocol/src/world/writer/chat.rs @@ -1,5 +1,6 @@ -//! The chat sends. Chat speaks [`WorldWriter::chat_language`]: vmangos drops `Universal` from -//! clients and any tongue the character does not know. +//! The chat sends. A line speaks the language `SendChatMessage` named, else +//! [`WorldWriter::chat_language`]: vmangos drops `Universal` outside AFK and DND +//! (`ChatHandler.cpp:105`) and any tongue the character does not know (`:175`). use anyhow::Result; @@ -16,120 +17,18 @@ impl WorldWriter { ) } - /// Send a `/yell` line (`CHAT_MSG_YELL`, `SharedDefines.h:1199`). - pub fn send_yell(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_YELL, self.chat_language, message), - ) - } - - /// Send a custom `/emote` line (`CHAT_MSG_EMOTE`), shown verbatim as `"PlayerName "`. - pub fn send_emote_chat(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_EMOTE, self.chat_language, message), - ) - } - - /// Send a `/whisper` line; the body carries the name before the message (`Chat.cpp:3-12`). - pub fn send_whisper(&mut self, target: &str, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat_whisper(self.chat_language, target, message), - ) - } - - /// Send a `/p` party line, dropped silently when ungrouped (`ChatHandler.cpp:472-493`). - pub fn send_party(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_PARTY, self.chat_language, message), - ) - } - - /// Send a `/ra` raid line (`CHAT_MSG_RAID`); needs a raid (`ChatHandler.cpp:514-536`). - pub fn send_raid(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_RAID, self.chat_language, message), - ) - } - - /// Send a `/g` guild line (`CHAT_MSG_GUILD`); needs a guild (`ChatHandler.cpp:494-503`). - pub fn send_guild(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_GUILD, self.chat_language, message), - ) - } - - /// Send a `/o` officer line (`CHAT_MSG_OFFICER`); needs a guild (`ChatHandler.cpp:504-513`). - pub fn send_officer(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_OFFICER, self.chat_language, message), - ) - } - - /// Send a `/rl` raid-leader line, leader only (`ChatHandler.cpp:538-559`). - pub fn send_raid_leader(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_RAID_LEADER, self.chat_language, message), - ) - } - - /// Send a `/rw` raid warning, leader or assistant only (`ChatHandler.cpp:561-576`). - pub fn send_raid_warning(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat( - messages::CHAT_TYPE_RAID_WARNING, - self.chat_language, - message, - ), - ) - } - - /// Send a battleground line; needs a battleground group (`ChatHandler.cpp:579-593`). - pub fn send_battleground(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat( - messages::CHAT_TYPE_BATTLEGROUND, - self.chat_language, - message, - ), - ) - } - - /// Send a battleground-leader line, leader only (`ChatHandler.cpp:595-609`). - pub fn send_battleground_leader(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat( - messages::CHAT_TYPE_BATTLEGROUND_LEADER, - self.chat_language, - message, - ), - ) - } - - /// Toggle AFK, `message` the auto-reply if any; it clears DND (`ChatHandler.cpp:611-630`). - pub fn send_afk(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_AFK, self.chat_language, message), - ) - } - - /// Toggle DND (`CHAT_MSG_DND`), which clears AFK (`ChatHandler.cpp:632-648`). - pub fn send_dnd(&mut self, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat(messages::CHAT_TYPE_DND, self.chat_language, message), - ) + /// Send a chat line of any `ChatMsg` type, as the reference's generic builder writes it + /// (`0x49f6c3`-`0x49f72a`). `language` is the `Languages.dbc` id `SendChatMessage` resolved; + /// `None` is the character's own tongue, [`WorldWriter::chat_language`]. + pub fn send_message_chat( + &mut self, + chat_type: u32, + language: Option, + target: Option<&str>, + message: &str, + ) -> Result<()> { + let body = message_chat_body(self.chat_language, chat_type, language, target, message); + self.send(opcode::CMSG_MESSAGECHAT, &body) } /// Send an addon message (`SendAddonMessage`): `text` is the caller's composed `prefix` TAB @@ -143,14 +42,6 @@ impl WorldWriter { ) } - /// Send a channel line by name, dropped unless we are on it (`ChatHandler.cpp:255-327`). - pub fn send_channel(&mut self, channel: &str, message: &str) -> Result<()> { - self.send( - opcode::CMSG_MESSAGECHAT, - &messages::messagechat_channel(self.chat_language, channel, message), - ) - } - /// Tell the server we ignore `guid`; that player gets a `CHAT_MSG_IGNORED` notice. pub fn chat_ignored(&mut self, guid: u64) -> Result<()> { self.send(opcode::CMSG_CHAT_IGNORED, &messages::full_guid(guid)) @@ -174,3 +65,58 @@ impl WorldWriter { ) } } + +/// The `CMSG_MESSAGECHAT` body: type, language (`0x49f6f9`), the target cstring only for a whisper +/// or a channel (`0x49f701`-`0x49f712`, types `6` and `0xe`), then the text. `speaker` is the +/// language a send that names none carries. +fn message_chat_body( + speaker: u32, + chat_type: u32, + language: Option, + target: Option<&str>, + message: &str, +) -> Vec { + let target = matches!( + chat_type, + messages::CHAT_TYPE_WHISPER | messages::CHAT_TYPE_CHANNEL + ) + .then(|| target.unwrap_or_default()); + messages::messagechat_kind(chat_type, language.unwrap_or(speaker), target, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_named_language_lands_in_the_body() { + // A Night Elf's `SendChatMessage("hi", "SAY", "Darnassian")`: type 0, language 2. + assert_eq!( + message_chat_body(7, messages::CHAT_TYPE_SAY, Some(2), None, "hi"), + [0, 0, 0, 0, 2, 0, 0, 0, b'h', b'i', 0] + ); + // No language named: the speaker's Common (7). + assert_eq!( + message_chat_body(7, messages::CHAT_TYPE_YELL, None, None, "hi"), + [5, 0, 0, 0, 7, 0, 0, 0, b'h', b'i', 0] + ); + // A Tauren's Taur-ahe (3) whisper: the name before the text. + assert_eq!( + message_chat_body(1, messages::CHAT_TYPE_WHISPER, Some(3), Some("Bo"), "yo"), + [6, 0, 0, 0, 3, 0, 0, 0, b'B', b'o', 0, b'y', b'o', 0] + ); + } + + #[test] + fn only_a_whisper_or_a_channel_carries_a_target() { + assert_eq!( + message_chat_body(1, messages::CHAT_TYPE_PARTY, None, Some("Bo"), "x"), + [1, 0, 0, 0, 1, 0, 0, 0, b'x', 0] + ); + assert_eq!( + message_chat_body(1, messages::CHAT_TYPE_CHANNEL, Some(14), None, "x"), + [0xe, 0, 0, 0, 14, 0, 0, 0, 0, b'x', 0], + "a channel with no name still writes the empty cstring" + ); + } +} diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index de0fa1d1..7b86b8d7 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -63,8 +63,9 @@ pub struct WorldWriter { /// Every packet that reached the socket since the last drain, as `(opcode, body length)`; /// `None` until [`Self::watch_sends`]. Pushed only after a successful write. pub(super) sent: Option>, - /// The character's faction language, set at login from its race; every chat send carries it. - /// vmangos drops the whole message, dot-commands included, when the character does not know it. + /// The character's faction language, set at login from its race, which a chat send naming no + /// language carries. vmangos drops the whole message, dot-commands included, when the + /// character does not know the language. pub(super) chat_language: u32, } diff --git a/crates/benilla-ui/src/script/chat_send.rs b/crates/benilla-ui/src/script/chat_send.rs index 9e1316b8..d2a3c08e 100644 --- a/crates/benilla-ui/src/script/chat_send.rs +++ b/crates/benilla-ui/src/script/chat_send.rs @@ -6,8 +6,11 @@ //! grammar: an addon's `SendChatMessage("/dance")` says the characters. The reference splits the //! same way, `ChatEdit_SendText` parsing and `SendChatMessage` not. //! -//! `language` is accepted and dropped, so every line goes in the speaker's default tongue: the -//! app's chat command has no language field. The reference sends the named language. +//! `language` starts as the speaker's race base language (`0x5ec890`, at `0x49f2aa`). A string or +//! number third argument is matched by name, case-insensitively, against every `Languages.dbc` +//! row (`0x49f8a0`), known to the character or not, and a miss raises `Unknown language` +//! (`0x844afc`, at `0x49f2e1`): a number is its text, so `7` raises. Refusing a tongue the +//! character never learned is the server's (vmangos `ChatHandler.cpp:175`). //! //! Deviation: no truncation at 255 characters (`0x49f604`), because vmangos refuses a longer line //! itself (`Chat.cpp:2177`): it is dropped where the reference sends its first 255. The server @@ -37,6 +40,25 @@ pub struct ChatSend { /// 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, + /// The `Languages.dbc` id the third argument named; `None` is the speaker's default. + pub language: Option, +} + +impl super::UiScript { + /// Push `Languages.dbc` as `(ID, Name_lang)` rows in file order, the table `SendChatMessage` + /// matches its language name against (`[0xc0db40]`, count `[0xc0db44]`). + pub fn set_language_table(&mut self, rows: Vec<(u32, String)>) { + self.model_mut().language_table = rows; + } +} + +/// `0x49f8a0`: the first row, in file order, whose name equals `name` ignoring case (`SStrCmpI` +/// `0x64a4c0`, unbounded length), checked against every row, not only the known languages. +fn language_id(table: &[(u32, String)], name: &str) -> Option { + table + .iter() + .find(|(_, row)| row.eq_ignore_ascii_case(name)) + .map(|&(id, _)| id) } impl super::UiScript { @@ -105,10 +127,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { lua.globals().set( "SendChatMessage", lua.create_function( - |lua, (text, chat_type, _language, target): (String, Option, Value, Value)| { + |lua, (text, chat_type, language, target): (String, Option, Value, Value)| { let chat_type = chat_type .unwrap_or_else(|| "SAY".into()) .to_ascii_uppercase(); + // The language is read only past the empty-line gate (`0x49f28d`-`0x49f2a1`), + // which lets an empty line through for AFK and DND alone. + let reads_language = + !text.is_empty() || matches!(chat_type.as_str(), "AFK" | "DND"); + let language = match super::binding_abi::optional_string(lua, &language) { + Some(name) if reads_language => { + let model = lua.app_data_ref::().expect("model app_data"); + let id = language_id(&model.language_table, &name); + Some( + id.ok_or_else(|| mlua::Error::RuntimeError("Unknown language".into()))?, + ) + } + _ => 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); @@ -119,6 +155,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { text, chat_type, target, + language, }); Ok(()) }, @@ -151,11 +188,13 @@ mod tests { text: "hello".into(), chat_type: "SAY".into(), target: None, + language: None, }, ChatSend { text: "/dance".into(), chat_type: "SAY".into(), target: None, + language: None, }, ] ); @@ -188,12 +227,67 @@ mod tests { assert_eq!(sent[1].chat_type, "CHANNEL"); } + /// Three `Languages.dbc` rows, in the shipped file's order. + fn with_languages(s: &mut UiScript) { + s.set_language_table(vec![ + (1, "Orcish".into()), + (2, "Darnassian".into()), + (7, "Common".into()), + ]); + } + #[test] - fn the_language_argument_is_accepted_and_ignored() { + fn a_language_name_sends_its_languages_dbc_id() { let mut s = UiScript::new().unwrap(); - s.run(r#"SendChatMessage("hi", "SAY", 7)"#).unwrap(); - assert_eq!(s.take_chat_sends().len(), 1); - assert!(s.errors().is_empty(), "{:?}", s.errors()); + with_languages(&mut s); + s.run(r#"SendChatMessage("hi", "SAY", "Darnassian")"#) + .unwrap(); + s.run(r#"SendChatMessage("hi", "YELL", "dARNASSIAN")"#) + .unwrap(); + s.run(r#"SendChatMessage("hi", "WHISPER", "orcish", "Bob")"#) + .unwrap(); + let sent = s.take_chat_sends(); + assert_eq!(sent[0].language, Some(2), "Darnassian's row ID"); + assert_eq!(sent[1].language, Some(2), "SStrCmpI ignores case"); + assert_eq!( + sent[2].language, + Some(1), + "a language the character may not know is sent: the server refuses it" + ); + assert_eq!(sent[2].target.as_deref(), Some("Bob")); + } + + #[test] + fn no_language_argument_speaks_the_default() { + let mut s = UiScript::new().unwrap(); + with_languages(&mut s); + s.run(r#"SendChatMessage("hi")"#).unwrap(); + s.run(r#"SendChatMessage("hi", "SAY", nil)"#).unwrap(); + s.run(r#"SendChatMessage("hi", "SAY", {})"#).unwrap(); + s.run(r#"SendChatMessage("hi", "SAY", true)"#).unwrap(); + let sent = s.take_chat_sends(); + assert_eq!(sent.len(), 4); + assert!( + sent.iter().all(|c| c.language.is_none()), + "not a string, so `lua_isstring` keeps the default: {sent:?}" + ); + } + + #[test] + fn an_unknown_language_raises_and_sends_nothing() { + let mut s = UiScript::new().unwrap(); + with_languages(&mut s); + for arg in [r#""Klingon""#, r#""""#, "7", r#""Darnassian ""#] { + let err = s + .run(&format!(r#"SendChatMessage("hi", "SAY", {arg})"#)) + .expect_err(&format!("{arg} names no row")); + assert!(err.to_string().contains("Unknown language"), "{arg}: {err}"); + } + assert!(s.take_chat_sends().is_empty()); + // AFK and DND pass the empty-line gate, so their language is read. + assert!(s.run(r#"SendChatMessage("", "AFK", "Klingon")"#).is_err()); + // An empty line of any other type ends before the language is read. + s.run(r#"SendChatMessage("", "SAY", "Klingon")"#).unwrap(); } /// The count matters: an addon passes the result straight to `SendChatMessage` as `language`. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index adbd9c75..98ea7a4c 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -295,6 +295,8 @@ pub(crate) struct Model { pub(crate) chat_colors_changed: bool, /// The languages this character knows, in `Languages.dbc` row order. pub(crate) known_languages: Vec, + /// `Languages.dbc`'s `(ID, Name_lang)` rows in file order, whatever the character knows. + pub(crate) language_table: Vec<(u32, String)>, pub(crate) zone_channel_catalog: Vec, pub(crate) channel_commands: Vec, /// The recruitment auto-join latch `[0x843608]`: 0 `STANDARD`, 1 `AUTO` (`0x49ea70` maps any @@ -1078,6 +1080,7 @@ impl Model { chat_colors: super::chat_types::seed(), chat_colors_changed: false, known_languages: Vec::new(), + language_table: Vec::new(), zone_channel_catalog: Vec::new(), channel_commands: Vec::new(), guild_recruitment_mode: 1,