From 663851b17df06469eee30342e00a24ffad9b6e30 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 8 Sep 2026 16:19:46 +0700 Subject: [PATCH] every line is a key, four find their sound, and the ding prints once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the last app-side lines become GlobalStrings keys — tooltip cells, party, petition, duel, quest, PvP and rest lines read the install's own wording, so a non-enUS chain shows its own text (2045, 2080) - a locked chest says Requires Pick Lock, ammo and thrown weapons drop their invented slot word, a met set bonus reads Set: without the count, and a 100 s cooldown reads 1.67 min as the template prints it - the level-up block and the free-professions line printed twice in chat and print once; a declined invite, a friend coming online, a duel request and a failed quest play the sound 1.12 gives them - Duel cancelled. is the yellow line at the top of the screen, and a trainer refusal or a failed quest with no cached template shows nothing, as on 1.12 Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/death.rs | 50 +- crates/benilla-app/src/net/apply.rs | 37 +- crates/benilla-app/src/net/apply/chat.rs | 28 +- .../benilla-app/src/net/apply/combat_log.rs | 14 +- crates/benilla-app/src/net/apply/group.rs | 71 +-- crates/benilla-app/src/net/apply/quests.rs | 34 +- crates/benilla-app/src/ui_binder.rs | 119 +++-- crates/benilla-app/src/ui_char.rs | 53 +- crates/benilla-app/src/ui_chat/broadcast.rs | 2 +- crates/benilla-app/src/ui_chat/feed.rs | 365 +++++++------ crates/benilla-app/src/ui_chat/frames.rs | 194 +++---- crates/benilla-app/src/ui_chat/input/mod.rs | 70 ++- crates/benilla-app/src/ui_chat/tests.rs | 349 ++++++++---- crates/benilla-app/src/ui_craft.rs | 2 + crates/benilla-app/src/ui_duel.rs | 227 +++++--- crates/benilla-app/src/ui_instance.rs | 62 +-- crates/benilla-app/src/ui_items/feed.rs | 112 ++-- crates/benilla-app/src/ui_party/feed.rs | 4 +- crates/benilla-app/src/ui_party/mod.rs | 188 ++++--- crates/benilla-app/src/ui_petition/feed.rs | 25 +- crates/benilla-app/src/ui_petition/lines.rs | 298 ++++------- crates/benilla-app/src/ui_quest.rs | 10 +- crates/benilla-app/src/ui_quest_log.rs | 277 +++++++--- crates/benilla-app/src/ui_script/mod.rs | 20 + crates/benilla-app/src/ui_social/feed.rs | 238 +++++---- crates/benilla-app/src/ui_social/mod.rs | 200 ++++--- crates/benilla-app/src/ui_talent.rs | 101 ++-- crates/benilla-app/src/ui_tooltip/mod.rs | 241 ++++++--- crates/benilla-app/src/ui_tooltip/tests.rs | 27 + crates/benilla-app/src/ui_trainer/law.rs | 20 +- crates/benilla-app/src/ui_trainer/mod.rs | 70 ++- crates/benilla-app/src/ui_trainer/tests.rs | 50 +- crates/benilla-app/src/ui_unit.rs | 152 ++++-- crates/benilla-app/tests/reference_strings.rs | 191 ++++--- crates/benilla-formats/src/spells/tokens.rs | 138 ++++- crates/benilla-ui/src/script/talent.rs | 14 +- crates/benilla-ui/src/script/tests/talent.rs | 35 +- .../src/script/tests/tooltip_item/bindings.rs | 10 +- .../src/script/tests/tooltip_item/law.rs | 149 +++--- .../script/tests/tooltip_item/mechanics.rs | 39 +- .../src/script/tests/tooltip_item/mod.rs | 113 +++- .../src/script/tests/tooltip_item/set.rs | 44 +- .../src/script/tests/tooltip_unit.rs | 55 +- crates/benilla-ui/src/script/tooltip/mod.rs | 49 +- .../benilla-ui/src/script/tooltip_item/mod.rs | 22 +- .../src/script/tooltip_item/names.rs | 128 +++-- .../src/script/tooltip_item/render.rs | 497 +++++++++++------- crates/benilla-ui/src/script/tooltip_spell.rs | 34 +- crates/benilla-ui/src/script/tooltip_unit.rs | 77 ++- .../benilla-ui/src/script/tradeskill/api.rs | 11 +- .../benilla-ui/src/script/tradeskill/tests.rs | 36 +- .../benilla-ui/src/script/tradeskill/view.rs | 68 ++- crates/benilla-ui/src/script/unit/mod.rs | 20 +- crates/benilla-ui/src/strings.rs | 392 ++++++++++++-- 54 files changed, 3874 insertions(+), 1958 deletions(-) diff --git a/crates/benilla-app/src/death.rs b/crates/benilla-app/src/death.rs index 83b60329..14f4e7d3 100644 --- a/crates/benilla-app/src/death.rs +++ b/crates/benilla-app/src/death.rs @@ -240,13 +240,23 @@ fn feed_death( t.translation.distance_squared(self_t.translation) <= SPIRIT_HEALER_RANGE_SQ }) }); + // Resolved before the push, because the lookup borrows the VM and `set_death` needs it + // mutably. + let sickness = sickness_duration(store.0.unit_level().unwrap_or(0), &|key: &str| { + script + .lua() + .globals() + .get::(key) + .ok() + .filter(|t| !t.is_empty()) + }); script.set_death(DeathUiState { release_remaining, recovery_delay, resurrect_sickness: death_net.resurrect.as_ref().is_some_and(|o| o.sickness), resurrect_has_timer: death_net.resurrect.as_ref().is_some_and(|o| o.has_timer), spirit_healer_in_range, - sickness_duration: sickness_duration(store.0.unit_level().unwrap_or(0)), + sickness_duration: sickness, // `HasSoulstone()` — see [`resolve_self_res`] for the three gates and their order. Not a // per-frame inventory walk in general: the dead gate is first, so while alive this is one // health read, and while dead-unreleased the walk only runs on a zero field. @@ -480,19 +490,31 @@ fn resolve_self_res( /// The sickness-duration string a spirit-healer res would apply at `level` — the verified server /// table (vmangos `Player::ResurrectPlayer` + `Death.SicknessLevel` 11, 0308 §6): nil below 11, /// `(level − 10)` minutes through 19, the aura's full 10 minutes from 20. -fn sickness_duration(level: u32) -> Option { - match level { - 0..=10 => None, - 11..=19 => { - let m = level - 10; - Some(if m == 1 { - "1 minute".into() - } else { - format!("{m} minutes") - }) - } - _ => Some("10 minutes".into()), - } +/// +/// **The wording is the `GENERIC_MIN`/`GENERIC_MIN_P1` pair, read off the player's own +/// `GlobalStrings.lua`** (decision 2045). That is the reference's own key: `GetResSicknessDuration` +/// (`0x51a3a0`) computes a millisecond duration off `Spell.dbc` 15007 × `SpellDuration.dbc` and +/// hands it to the shared formatter `0x52fa50(ms, "GENERIC", 0, 1)`, whose minute arm is +/// `GENERIC_MIN[_P1]` with `ms / 60000` (wow-re `ui/scratch/death-ui.md` §9). Every value this +/// table can produce — 1 to 10 minutes — lands in that arm, so the day/hour/second arms have no +/// case here and the ladder is not reproduced. +/// +/// **What benilla still models differently, deliberately:** the *number* comes from vmangos's +/// server-side table rather than from `Spell.dbc`'s `Base + PerLevel·level`, because the aura the +/// server actually applies is what the dialog must warn about. The `≤ 999 ms → nil` gate the +/// reference gets from that data is our `0..=10 => None` row. +fn sickness_duration(level: u32, get: &dyn Fn(&str) -> Option) -> Option { + let minutes = match level { + 0..=10 => return None, + 11..=19 => level - 10, + _ => 10, + }; + // `GetText(token, nil, ordinal)`'s plural pick, through the shared primitive. + let template = benilla_ui::strings::plural("GENERIC_MIN", Some(minutes), get)?; + Some(benilla_ui::strings::fill( + &template, + &[benilla_ui::strings::Arg::D(i64::from(minutes))], + )) } /// Drive the ghost-world look (decision 0308 §7, all byte-VERIFIED): the FFXDeath screen pass diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 0f4a2063..f7f5f947 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -1028,7 +1028,7 @@ pub(crate) fn apply_net_updates( crate::ui_binder::apply::bound( area, &mut binder, - &mut chat_log, + &mut ui_error_keys, area_table.as_deref(), &mut audio.0, ) @@ -1182,9 +1182,9 @@ pub(crate) fn apply_net_updates( channel, members, .. } => chat::channel_list(channel, &members, &mut chat_log), SessionEvent::ChatPlayerNotFound { name } => { - chat::chat_player_not_found(&name, &mut chat_log) + chat::chat_player_not_found(&name, &mut ui_error_keys) } - SessionEvent::ChatWrongFaction => chat::chat_wrong_faction(&mut chat_log), + SessionEvent::ChatWrongFaction => chat::chat_wrong_faction(&mut ui_error_keys), // The four world broadcasts — parked for `ui_chat::broadcast`'s resolve pass, which // owns the AreaTable/ServerMessages lookups and the joined-defense-channel walk. SessionEvent::ZoneUnderAttack { area_id } => chat::broadcast( @@ -1222,21 +1222,21 @@ pub(crate) fn apply_net_updates( // ── The group/party family (decision 0434 §D2, superseded by 0440) — arm bodies in // `group` ── SessionEvent::GroupInvite { inviter } => { - group::invited(&mut group, &mut chat_log, &inviter) + group::invited(&mut group, &mut ui_error_keys, &inviter) } SessionEvent::GroupDecline { name } => { - group::declined(&mut group, &mut chat_log, &name) + group::declined(&mut group, &mut ui_error_keys, &name) } - SessionEvent::GroupUninvited => group::uninvited(&mut group, &mut chat_log), + SessionEvent::GroupUninvited => group::uninvited(&mut group, &mut ui_error_keys), SessionEvent::GroupLeaderChanged { name } => group::leader_changed( &mut group, - &mut chat_log, + &mut ui_error_keys, &name, &self_guid, &mut names, &net_commands, ), - SessionEvent::GroupDestroyed => group::destroyed(&mut group, &mut chat_log), + SessionEvent::GroupDestroyed => group::destroyed(&mut group, &mut ui_error_keys), SessionEvent::GroupList { group_type, own_flags, @@ -1245,7 +1245,7 @@ pub(crate) fn apply_net_updates( loot, } => group::list( &mut group, - &mut chat_log, + &mut ui_error_keys, &mut quest, group_type, own_flags, @@ -1271,7 +1271,7 @@ pub(crate) fn apply_net_updates( // alone — is logged for the engine's flags, which the timeout tick sums up after 30 s // (1.12 has no per-member answer surface, only the AFK summary line). SessionEvent::ReadyCheckRequest => { - group::ready_check_request(&mut group, &mut chat_log, &self_guid) + group::ready_check_request(&mut group, &mut ui_error_keys, &self_guid) } SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries), // A group member pinged (decision 1596). The wire carries raw world floats and the @@ -1293,7 +1293,7 @@ pub(crate) fn apply_net_updates( challenger, } => crate::ui_duel::apply::requested( &mut duel, - &mut chat_log, + &mut ui_error_keys, &net_commands, arbiter, challenger, @@ -1325,13 +1325,13 @@ pub(crate) fn apply_net_updates( SessionEvent::DuelOutOfBounds => crate::ui_duel::apply::bounds(&mut duel, true), SessionEvent::DuelInBounds => crate::ui_duel::apply::bounds(&mut duel, false), SessionEvent::DuelComplete { started } => { - crate::ui_duel::apply::complete(&mut duel, &mut chat_log, started); + crate::ui_duel::apply::complete(&mut duel, &mut ui_error_keys, started); } SessionEvent::DuelWinner { fled, winner, loser, - } => crate::ui_duel::apply::winner(&mut chat_log, fled, &winner, &loser), + } => crate::ui_duel::apply::winner(&mut duel, fled, &winner, &loser), SessionEvent::DuelCountdown { seconds } => { crate::ui_duel::apply::countdown(&mut duel, seconds); } @@ -1947,14 +1947,9 @@ pub(crate) fn apply_net_updates( SessionEvent::QuestObjectivesComplete { quest_id } => { quest_objectives_complete(quest_id, &mut quest) } - SessionEvent::QuestFailed { quest_id, timed } => quest_failed( - quest_id, - timed, - &mut quest_log, - &net_commands, - &mut chat_log, - &mut quest, - ), + SessionEvent::QuestFailed { quest_id, timed } => { + quest_failed(quest_id, timed, &mut quest_log, &net_commands, &mut quest) + } SessionEvent::QuestLogFull => quest_log_full(&mut quest), // The party quest-share (decision 1733): one member's verdict on a quest we pushed, // and the escort-quest confirm. Both park in `QuestShare` for `crate::ui_quest_share` diff --git a/crates/benilla-app/src/net/apply/chat.rs b/crates/benilla-app/src/net/apply/chat.rs index 4d589326..9c9d20db 100644 --- a/crates/benilla-app/src/net/apply/chat.rs +++ b/crates/benilla-app/src/net/apply/chat.rs @@ -161,20 +161,26 @@ pub(super) fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mu chat_log.push_event(ev); } -/// A whisper target wasn't online — ERR_CHAT_PLAYER_NOT_FOUND_S (GlobalStrings:1534). -pub(super) fn chat_player_not_found(name: &str, chat_log: &mut ChatLog) { - chat_log.push_event(ChatEvent::text_only( - ChatEventKind::System, - format!("No player named '{name}' is currently playing."), +/// A whisper target wasn't online — `ERR_CHAT_PLAYER_NOT_FOUND_S`, catalog row 241, whose `%s` +/// the server's own name fills. +/// +/// The KEY travels rather than a composed sentence (decision 2045): this is the net-apply pass and +/// there is no VM here, so `ui_action`'s drain is what resolves it against the player's own +/// `GlobalStrings.lua` — and the row, not this call site, is what says the line goes to chat +/// (`kind 0`) and makes no sound. +pub(super) fn chat_player_not_found(name: &str, errors: &mut crate::ui_action::UiErrorKeys) { + errors.0.push(crate::ui_action::UiError::s( + "ERR_CHAT_PLAYER_NOT_FOUND_S", + name, )); } -/// A cross-faction whisper was refused — ERR_CHAT_WRONG_FACTION (GlobalStrings:1537). -pub(super) fn chat_wrong_faction(chat_log: &mut ChatLog) { - chat_log.push_event(ChatEvent::text_only( - ChatEventKind::System, - "You can only whisper to members of your alliance.".to_string(), - )); +/// A cross-faction whisper was refused — `ERR_CHAT_WRONG_FACTION`, catalog row 240. Same route and +/// the same reason as [`chat_player_not_found`], with no argument to fill. +pub(super) fn chat_wrong_faction(errors: &mut crate::ui_action::UiErrorKeys) { + errors + .0 + .push(crate::ui_action::UiError::key("ERR_CHAT_WRONG_FACTION")); } /// A server notice (`SMSG_NOTIFICATION`) — the **red UIErrorsFrame line**, never a chat line. diff --git a/crates/benilla-app/src/net/apply/combat_log.rs b/crates/benilla-app/src/net/apply/combat_log.rs index 1ec69e53..cdbd5a58 100644 --- a/crates/benilla-app/src/net/apply/combat_log.rs +++ b/crates/benilla-app/src/net/apply/combat_log.rs @@ -649,9 +649,15 @@ pub(super) fn exploration_xp( /// this packet. pub(super) fn level_up(l: LevelUpInfo, chat_log: &mut ChatLog) { let talent_points = u32::from(l.level >= 10); - chat_log.push_level_up(&l, talent_points); - // ...and park the raw tuple for `ui_unit`'s `PLAYER_LEVEL_UP`, whose nine args the reference's - // own ChatFrame reads (1884). The Rust lines above stay until that window migrates and can - // take over printing them — retiring them first would simply lose the ding. + // Park the raw tuple for `ui_unit`'s `PLAYER_LEVEL_UP`, whose nine args the reference's own + // `ChatFrame_OnEvent` reads (1884). **That is the whole of the ding's chat output.** + // + // This used to compose and print the five-line block in Rust as well, under a comment saying + // the Rust lines would stay "until that window migrates and can take over printing them". + // The window migrated — `benilla.toc` sources `Interface\FrameXML\ChatFrame.xml` and our own + // transcription is gone (1948) — and nobody came back to the condition, so the block was on + // screen **twice** (`ui_chat::tests::the_ding_block_is_printed_once` reproduces it: four lines + // from each side). A deliberate temporary that outlives its trigger is indistinguishable from + // a permanent one; this is what one looks like when it is found. chat_log.push_level_up_gains(&l, talent_points); } diff --git a/crates/benilla-app/src/net/apply/group.rs b/crates/benilla-app/src/net/apply/group.rs index 5b614b54..ca39da12 100644 --- a/crates/benilla-app/src/net/apply/group.rs +++ b/crates/benilla-app/src/net/apply/group.rs @@ -1,9 +1,14 @@ //! The group/party arm bodies (decision 0434 §D2, superseded by 0440) for -//! [`super::apply_net_updates`]'s dispatch match. [`GroupState`] mirrors the wire and *composes* -//! the lines; these are the drain-side shims that push what it composed onto the chat log — the -//! way the reference's engine-side errorId→GlobalStrings display does (mapping byte-verified, -//! decision 0440's §5 fold-back). Each `pub(super)` fn here is exactly one arm's body; the match at -//! the call site stays the dispatcher, one call per arm. +//! [`super::apply_net_updates`]'s dispatch match. [`GroupState`] mirrors the wire and names the +//! **messages** its roster diff implies; these are the drain-side shims that put them on the +//! shared by-key queue. Each `pub(super)` fn here is exactly one arm's body; the match at the call +//! site stays the dispatcher, one call per arm. +//! +//! **They are message ids, not sentences** (decisions 2045/2054). The reference reaches all twelve +//! through one `CGGameUI::DisplayError(msgId)`, and the catalog row that id names answers three +//! questions at once — the text, the surface, and the sound. Composing the English here answered +//! only the first, and got the third wrong for free: `ERR_DECLINE_GROUP_S` carries the +//! `igPlayerInviteDecline` cue, which a straight chat push had no way to play. use benilla_protocol::messages::{ member_status, GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo, @@ -11,19 +16,17 @@ use benilla_protocol::messages::{ use bevy::prelude::*; use crate::names::NameCache; -use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; +use crate::ui_action::{UiError, UiErrorKeys}; use crate::ui_party::GroupState; use crate::ui_quest::QuestGiver; use super::super::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfGuid}; -/// Push the lines a `GroupState::apply_*` composed as CHAT_MSG_SYSTEM — the same seam the other -/// client-composed feeds use (the reference's engine formats these via its errorId→GlobalStrings -/// display and fires them as system chat; benilla's composer hands us the finished strings). -fn push_group_lines(chat_log: &mut ChatLog, lines: Vec) { - for line in lines { - chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, line)); - } +/// Queue the messages a `GroupState::apply_*` named. `ui_action::feed_actions` resolves each key +/// against the VM's own `GlobalStrings.lua` and puts the line on the surface its catalog row +/// names — `ui_guild`'s `push_lines` (decision 2054), one window over. +fn push_group_lines(errors: &mut UiErrorKeys, lines: Vec) { + errors.0.extend(lines); } /// `MSG_RAID_READY_CHECK`, the open form (decision 1989): our own echo as leader takes the @@ -31,38 +34,38 @@ fn push_group_lines(chat_log: &mut ChatLog, lines: Vec) { /// the popup ticket. The leader test is the reference's guid compare (`0x4ba3a0`). pub(super) fn ready_check_request( group: &mut GroupState, - chat_log: &mut ChatLog, + errors: &mut UiErrorKeys, self_guid: &SelfGuid, ) { let we_lead = self_guid.0 == Some(group.leader); - push_group_lines(chat_log, group.apply_ready_check_request(we_lead)); + push_group_lines(errors, group.apply_ready_check_request(we_lead)); } /// `SMSG_GROUP_INVITE` — someone asked us into their group. -pub(super) fn invited(group: &mut GroupState, chat_log: &mut ChatLog, inviter: &str) { - push_group_lines(chat_log, group.apply_invited(inviter)); +pub(super) fn invited(group: &mut GroupState, errors: &mut UiErrorKeys, inviter: &str) { + push_group_lines(errors, group.apply_invited(inviter)); } /// `SMSG_GROUP_DECLINE` — our invitee said no (sent to the inviter only). -pub(super) fn declined(group: &mut GroupState, chat_log: &mut ChatLog, name: &str) { - push_group_lines(chat_log, group.apply_declined(name)); +pub(super) fn declined(group: &mut GroupState, errors: &mut UiErrorKeys, name: &str) { + push_group_lines(errors, group.apply_declined(name)); } /// `SMSG_GROUP_UNINVITE` — we were kicked. -pub(super) fn uninvited(group: &mut GroupState, chat_log: &mut ChatLog) { - push_group_lines(chat_log, group.apply_uninvited()); +pub(super) fn uninvited(group: &mut GroupState, errors: &mut UiErrorKeys) { + push_group_lines(errors, group.apply_uninvited()); } /// `SMSG_GROUP_DESTROYED` — the group is gone outright. -pub(super) fn destroyed(group: &mut GroupState, chat_log: &mut ChatLog) { - push_group_lines(chat_log, group.apply_destroyed()); +pub(super) fn destroyed(group: &mut GroupState, errors: &mut UiErrorKeys) { + push_group_lines(errors, group.apply_destroyed()); } /// `SMSG_GROUP_SET_LEADER` — the line reads differently when the new leader is us, so the composer /// needs our own name. It is cache-seeded at login (`session::connected`), so this never asks. pub(super) fn leader_changed( group: &mut GroupState, - chat_log: &mut ChatLog, + errors: &mut UiErrorKeys, name: &str, self_guid: &SelfGuid, names: &mut NameCache, @@ -71,7 +74,7 @@ pub(super) fn leader_changed( let own = self_guid .0 .and_then(|g| names.resolve(g, net_commands).map(str::to_string)); - push_group_lines(chat_log, group.apply_leader_changed(name, own.as_deref())); + push_group_lines(errors, group.apply_leader_changed(name, own.as_deref())); } /// `SMSG_GROUP_LIST` — the roster echo (and the join/leave diff's line source). Roster changes move @@ -88,7 +91,7 @@ pub(super) fn leader_changed( #[allow(clippy::too_many_arguments)] pub(super) fn list( group: &mut GroupState, - chat_log: &mut ChatLog, + errors: &mut UiErrorKeys, quest: &mut QuestGiver, group_type: u8, own_flags: u8, @@ -109,7 +112,7 @@ pub(super) fn list( .map(|m| (m.guid, m.status & member_status::ONLINE != 0)) .collect(); let lines = group.apply_list(group_type, own_flags, members, leader, loot); - push_group_lines(chat_log, lines); + push_group_lines(errors, lines); seat_new_records(group, &seats, index, net_commands); quest.bump_reask(); } @@ -254,9 +257,9 @@ mod tests { fn the_roster_warms_every_member_into_the_name_cache_once() { let (tx, rx) = crossbeam_channel::unbounded(); let net = NetCommands(tx); - let (mut group, mut chat_log, mut quest) = ( + let (mut group, mut errors, mut quest) = ( GroupState::default(), - ChatLog::default(), + UiErrorKeys::default(), QuestGiver::default(), ); let mut names = NameCache::default(); @@ -266,7 +269,7 @@ mod tests { list( &mut group, - &mut chat_log, + &mut errors, &mut quest, 0, 0, @@ -295,7 +298,7 @@ mod tests { names.insert_player(leader, "Frostshake".into(), Some((1, 4, 1))); list( &mut group, - &mut chat_log, + &mut errors, &mut quest, 0, 0, @@ -419,9 +422,9 @@ mod tests { fn a_roster_new_member_is_seated_at_one_one_and_asked_for_only_when_unseen() { let (tx, rx) = crossbeam_channel::unbounded(); let net = NetCommands(tx); - let (mut group, mut chat_log, mut quest) = ( + let (mut group, mut errors, mut quest) = ( GroupState::default(), - ChatLog::default(), + UiErrorKeys::default(), QuestGiver::default(), ); let mut names = NameCache::default(); @@ -436,7 +439,7 @@ mod tests { let mut send = |group: &mut GroupState, members: Vec| { list( group, - &mut chat_log, + &mut errors, &mut quest, 0, 0, diff --git a/crates/benilla-app/src/net/apply/quests.rs b/crates/benilla-app/src/net/apply/quests.rs index f5e091a4..e65d5e57 100644 --- a/crates/benilla-app/src/net/apply/quests.rs +++ b/crates/benilla-app/src/net/apply/quests.rs @@ -10,7 +10,6 @@ use benilla_protocol::messages::{ use bevy::prelude::*; use crate::ui_action::UiError; -use crate::ui_chat::ChatLog; use crate::ui_quest::QuestGiver; use crate::ui_quest_log::QuestLog; use crate::ui_quest_share::QuestShare; @@ -163,26 +162,35 @@ pub(super) fn quest_objectives_complete(quest_id: u32, quest: &mut QuestGiver) { quest.bump_reask(); } -/// The quest failed (`SMSG_QUESTUPDATE_FAILED` / `_FAILEDTIMER` — `timed` picks which): the one -/// quest-update with a CHAT surface (verified kind-0, key `ERR_QUEST_FAILED_S "%s failed."`) — -/// named with the quest title when its template is cached. Follow-up: the verified handler also -/// plays `igQuestFailed`; the apply loop has no sound seam here yet. +/// The quest failed (`SMSG_QUESTUPDATE_FAILED` `0x196` / `_FAILEDTIMER` `0x197` — `timed` picks +/// which): the one quest-update with a CHAT surface — msgId `0x8b` = `ERR_QUEST_FAILED_S`, catalog +/// row 139, `kind 0`, named with the quest's title (the handler pushes `template+0x9c`). +/// +/// **An uncached template is SILENT, and that is the handler's own gate rather than a fallback.** +/// `0x5e5ad0`'s FAILED arm reads the questId, then *bails* if the template is not in the cache (or +/// if the per-slot flag `byte[slot+7]&2` is set) — it never reaches `0x496720` (wow-re +/// `object-layer/scratch/quest-update-ui-feedback-law.md` §"Per-opcode behaviour"). This used to +/// compose `"Quest failed."` there, a sentence 1.12 has no string for and never says (decision +/// 2045 named it as one of two remaining inventions). +/// +/// The line rides `QuestGiver`'s by-key queue — the route `quest_log_full` and +/// `quest_giver_invalid` already take — so `ui_quest::feed_quest` resolves it against the player's +/// own `GlobalStrings.lua` and `show_messages` reads the row. **That also lands the sound the old +/// path was dropping**: row 139 names the cue `igQuestFailed`, which the reference plays here and +/// benilla had noted as an unbuilt follow-up. pub(super) fn quest_failed( quest_id: u32, timed: bool, quest_log: &mut QuestLog, net_commands: &NetCommands, - chat_log: &mut ChatLog, quest: &mut QuestGiver, ) { debug!("net: quest {quest_id} failed (timed: {timed})"); - let line = quest_log - .template(quest_id, net_commands) - .map_or("Quest failed.".into(), |t| format!("{} failed.", t.title)); - chat_log.push_event(crate::ui_chat::ChatEvent::text_only( - crate::ui_chat::ChatEventKind::System, - line, - )); + if let Some(t) = quest_log.template(quest_id, net_commands) { + quest.push_message(UiError::s("ERR_QUEST_FAILED_S", t.title.clone())); + } else { + debug!("net: quest {quest_id} has no cached template — the reference shows nothing"); + } // A failure moves what the givers offer — the reference sweeps from these `SMSG_QUESTUPDATE_*` // handlers too (0654). quest.bump_reask(); diff --git a/crates/benilla-app/src/ui_binder.rs b/crates/benilla-app/src/ui_binder.rs index 55b30930..0604f976 100644 --- a/crates/benilla-app/src/ui_binder.rs +++ b/crates/benilla-app/src/ui_binder.rs @@ -48,11 +48,6 @@ use crate::net::{ClientCommand, NetCommands}; use crate::ui_script::UiInput; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; -/// `HOME_INN` (GlobalStrings.lua:2278, verbatim) — the tail of the reference's `arg1` chain, used -/// when neither the sub-area nor the zone resolves an `AreaTable` name. Its own `GetBindLocation` -/// uses the identical fallback, which is how wow-re cross-checked the order. -const HOME_INN: &str = "your inn"; - /// The innkeeper's pending bind question. Written by the net drain's `BinderConfirm` arm, read by /// [`feed_binder`] (which fires `CONFIRM_BINDER` and publishes `CheckBinderDist`'s answer) and by /// [`drain_binder`] (which turns the dialog's Accept into the activate). @@ -124,7 +119,14 @@ fn feed_binder( return; } binder.ask = false; - let name = area_name(&world, areas.as_deref()); + let name = area_name(&world, areas.as_deref(), &|key: &str| { + script + .lua() + .globals() + .get::(key) + .ok() + .filter(|t| !t.is_empty()) + }); script.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str(name)]); } @@ -138,13 +140,23 @@ fn feed_binder( fn area_name( world: &benilla_world::world_point::WorldPoint, areas: Option<&AreaTableRes>, + get: &dyn Fn(&str) -> Option, ) -> String { - area_name_of(world.area(), areas) + area_name_of(world.area(), areas, get) } /// [`area_name`]'s chain over a bare leaf id — split out so the three legs are testable against the /// real `AreaTable` without standing up a world. -fn area_name_of(leaf: Option, areas: Option<&AreaTableRes>) -> String { +/// +/// The tail is the `HOME_INN` GlobalString (`GetBindLocation` uses the identical fallback, which is +/// how wow-re cross-checked the order), read off the player's own table rather than re-typed +/// (decision 2045); an install that does not carry it yields the empty string, which is the +/// reference's data-suppression face and still fires the question. +fn area_name_of( + leaf: Option, + areas: Option<&AreaTableRes>, + get: &dyn Fn(&str) -> Option, +) -> String { let named = |id: u32| { areas .and_then(|a| a.0.get(id)) @@ -159,12 +171,8 @@ fn area_name_of(leaf: Option, areas: Option<&AreaTableRes>) -> String { .filter(|z| *z != 0); leaf.and_then(named) .or_else(|| zone.and_then(named)) - .unwrap_or_else(|| HOME_INN.to_string()) -} - -/// The `SMSG_PLAYERBOUND` line, composed — `ERR_DEATHBIND_SUCCESS_S` with the packet's area name. -fn bound_line(area_name: &str) -> String { - ERR_DEATHBIND_SUCCESS_S.replace("%s", area_name) + .or_else(|| get("HOME_INN")) + .unwrap_or_default() } /// Turn the dialog's Accept into `CMSG_BINDER_ACTIVATE`. @@ -194,10 +202,6 @@ fn drain_binder( } } -/// `ERR_DEATHBIND_SUCCESS_S` (GlobalStrings.lua:1543, verbatim) — the line the reference prints -/// when the bind lands, substituted with the **packet's** area name. -const ERR_DEATHBIND_SUCCESS_S: &str = "%s is now your home."; - /// `SoundEntries.dbc` id 1141 — played on `SMSG_PLAYERBOUND` **unconditionally**, before the area /// is resolved (`0x5e3d6e`, ahead of the bounds test). So an area id the catalog cannot name is /// silent but still audible, which is the reference's own ordering rather than an accident of ours. @@ -211,11 +215,17 @@ pub(crate) mod apply { use bevy::ecs::message::MessageWriter; use crate::net::{ServerSoundKind, ServerSoundMessage}; - use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; - /// `SMSG_PLAYERBOUND` — the bind took. Retract the question, play the sound, and print - /// " is now your home." as CHAT_MSG_SYSTEM (`0x5e3d3f`: the sound first, then - /// `DisplayError(0x138)` with the packet's own area id resolved through `AreaTable`). + /// `SMSG_PLAYERBOUND` — the bind took. Retract the question, play the sound, and queue + /// `DisplayError(0x138)` = `ERR_DEATHBIND_SUCCESS_S` (catalog row 312, `kind 0` — a system + /// chat line) with the packet's own area id resolved through `AreaTable` — the handler's own + /// order at `0x5e3d3f`: the sound first, then the message. + /// + /// The line rides the by-key queue rather than being composed here (decision 2045): this is + /// the net-apply pass and there is no VM in hand, so the KEY travels to `ui_action`'s drain, + /// which resolves it against the player's own `GlobalStrings.lua` and puts it on the surface + /// the catalog names. The `SoundEntries` cue below is the *handler's* own, separate from the + /// row's `+0x08` (which is `None` for this row). /// /// This is the **feedback half of B249** — "accepting appears to change nothing" was partly /// that nothing ever said it had. The hearthstone itself moves on `SMSG_BINDPOINTUPDATE`, the @@ -224,7 +234,7 @@ pub(crate) mod apply { pub(crate) fn bound( area: u32, binder: &mut BinderState, - chat_log: &mut ChatLog, + errors: &mut crate::ui_action::UiErrorKeys, areas: Option<&AreaTableRes>, sounds: &mut MessageWriter, ) { @@ -238,9 +248,9 @@ pub(crate) mod apply { debug!("ui_binder: bound to area {area}, which AreaTable does not name — no line"); return; }; - chat_log.push_event(ChatEvent::text_only( - ChatEventKind::System, - super::bound_line(name), + errors.0.push(crate::ui_action::UiError::s( + "ERR_DEATHBIND_SUCCESS_S", + name, )); } } @@ -284,10 +294,13 @@ mod tests { assert!(binder.ask, "the same innkeeper asking again owes a dialog"); } - /// The `arg1` chain, against the REAL AreaTable (`0x5dfe5e`): sub-area, else parent zone, else - /// the `HOME_INN` GlobalString — and never nothing, because the reference never withholds the - /// question for want of a name. 186 is Dolanaar, the leaf the bug's own innkeeper stands in. - /// Skips without client data. + /// The `arg1` chain, against the REAL AreaTable and the REAL `GlobalStrings.lua` (`0x5dfe5e`): + /// sub-area, else parent zone, else the `HOME_INN` GlobalString — and never nothing, because + /// the reference never withholds the question for want of a name. 186 is Dolanaar, the leaf + /// the bug's own innkeeper stands in. + /// + /// The tail resolves off the player's own table rather than a stub, so a `HOME_INN` that the + /// install words differently is what this reads (decision 2045). Skips without client data. #[test] fn the_dialogs_name_falls_back_sub_area_then_zone_then_home_inn() { let data = benilla_formats::wow_data_or_skip!(); @@ -295,19 +308,53 @@ mod tests { let areas = AreaTableRes( benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc"), ); + let src = chain + .read_file("Interface\\FrameXML\\GlobalStrings.lua") + .expect("GlobalStrings.lua in the chain"); + let vm = UiScript::new().expect("VM"); + vm.run(&String::from_utf8_lossy(&src)).expect("runs clean"); + let get = |key: &str| { + vm.lua() + .globals() + .get::(key) + .ok() + .filter(|t| !t.is_empty()) + }; + let inn = get("HOME_INN").expect("HOME_INN ships"); - assert_eq!(area_name_of(Some(186), Some(&areas)), "Dolanaar"); + assert_eq!(area_name_of(Some(186), Some(&areas), &get), "Dolanaar"); // No row, no catalog, no id at all — all three reach the GlobalString rather than "". - assert_eq!(area_name_of(Some(0xffff), Some(&areas)), HOME_INN); - assert_eq!(area_name_of(Some(186), None), HOME_INN); - assert_eq!(area_name_of(None, Some(&areas)), HOME_INN); + assert_eq!(area_name_of(Some(0xffff), Some(&areas), &get), inn); + assert_eq!(area_name_of(Some(186), None, &get), inn); + assert_eq!(area_name_of(None, Some(&areas), &get), inn); + // …and an install with no such key withholds the *name*, never the question. + assert_eq!(area_name_of(None, None, &|_| None), ""); } /// The line the bind lands with — the feedback half of B249, since "accepting appears to change /// nothing" was partly that nothing ever said it had. + /// + /// The assertion is the KEY and its one argument, not the sentence (2045): the wording is the + /// install's, and `ui_action::ui_error_text` is what fills it. Resolved here against the real + /// table so a typo'd key — which would degrade the line to silence, not to a wrong sentence — + /// still fails. Skips without client data. #[test] - fn the_bind_prints_the_area_is_now_your_home() { - assert_eq!(bound_line("Dolanaar"), "Dolanaar is now your home."); + fn the_bind_queues_the_deathbind_success_line_named_by_the_area() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let src = chain + .read_file("Interface\\FrameXML\\GlobalStrings.lua") + .expect("GlobalStrings.lua in the chain"); + let vm = UiScript::new().expect("VM"); + vm.run(&String::from_utf8_lossy(&src)).expect("runs clean"); + let g = |key: &str| vm.lua().globals().get::(key).ok(); + + let msg = crate::ui_action::UiError::s("ERR_DEATHBIND_SUCCESS_S", "Dolanaar"); + assert_eq!(msg.key, "ERR_DEATHBIND_SUCCESS_S"); + assert_eq!( + crate::ui_action::ui_error_text(&msg, &g).as_deref(), + Some("Dolanaar is now your home.") + ); } /// Closing (the range guard, or the bind landing) retracts both the guid and any unfired diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 7260ad7c..fe7871a5 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -47,6 +47,7 @@ use benilla_ui::script::{ SkillsState, UiScript, UnitCombatStats, BANK_BAG_SLOT_COUNT, EQUIPMENT_BAG, SKILL_DEFENSE, SKILL_UNARMED, }; +use benilla_ui::strings::Arg; use crate::entities::ItemDisplays; use crate::items::Items; @@ -157,6 +158,35 @@ type SkillBlock = Option<(u64, std::collections::HashMap)>; /// login fill (empty → populated) and a character switch (self guid change) re-seed silently — /// the event fires, the lines don't. Still open: the exact chat channel of the real emitter /// (TU-E left `0x496720`'s routing untraced; Skill is the `ChatTypeInfo` family's own key). +/// One skill-watcher line: resolve the key off the player's own `GlobalStrings.lua`, fill it, and +/// push it as `CHAT_MSG_SKILL`. +/// +/// **Deliberately not `ui_action::show_messages`, and the reason is a real gap rather than a +/// preference.** `ERR_SKILL_UP_SI`/`ERR_SKILL_GAINED_S` (rows 54/53) are the two of the three +/// catalog rows whose `chat_type` is **23 = `CHAT_MSG_SKILL`**, not the `10 = CHAT_MSG_SYSTEM` +/// every other `kind 0` row carries — and that field is exactly what the shared sink does not +/// read yet (its own doc says so: "benilla raises none of them yet; when it does, this is the +/// line that has to read the field"). Routing these through it today would silently demote them +/// to SYSTEM and out of the Skill filter. So the key, the fill and the data-suppression rule are +/// the shared ones; only the surface is carried here, until the sink learns `chat_type`. +/// +/// An absent or empty key shows nothing — the reference's own null/empty guard. +fn skill_line(script: &UiScript, chat: &mut crate::ui_chat::ChatLog, key: &str, args: &[Arg<'_>]) { + let Some(template) = script + .lua() + .globals() + .get::(key) + .ok() + .filter(|t| !t.is_empty()) + else { + return; + }; + chat.push_event(crate::ui_chat::ChatEvent::text_only( + crate::ui_chat::ChatEventKind::Skill, + benilla_ui::strings::fill(&template, args), + )); +} + fn watch_skill_ups( script: Option>, self_guid: Res, @@ -224,17 +254,20 @@ fn watch_skill_ups( .is_some_and(|s| s.catalog.announces_skill_ups(line_id, race, class)) }; match prev_map.get(&id) { - // A rank-up: the ERR_SKILL_UP_SI line (GlobalStrings.lua:1838). + // A rank-up: `ERR_SKILL_UP_SI`, catalog row 54 — **not** `SKILL_RANK_UP`, + // which is the same enUS sentence and no catalog row at all (decision 2045). Some(&old) if value > old => { if let Some(name) = name() { // Both verdicts are logged — the retest's instrument: a moved line // either announces or names the gate that held it. if announces() { debug!("chat: skill-up announced ({name} {old}→{value})"); - chat.push_event(crate::ui_chat::ChatEvent::text_only( - crate::ui_chat::ChatEventKind::Skill, - format!("Your skill in {name} has increased to {value}."), - )); + skill_line( + &script, + &mut chat, + "ERR_SKILL_UP_SI", + &[Arg::S(&name), Arg::D(i64::from(value))], + ); } else { debug!("chat: skill-up silenced ({name} {old}→{value}, the 0x402 gate)"); } @@ -247,10 +280,12 @@ fn watch_skill_ups( if let Some(name) = name() { if announces() { debug!("chat: skill-gain announced ({name} at {value})"); - chat.push_event(crate::ui_chat::ChatEvent::text_only( - crate::ui_chat::ChatEventKind::Skill, - format!("You have gained the {name} skill."), - )); + skill_line( + &script, + &mut chat, + "ERR_SKILL_GAINED_S", + &[Arg::S(&name)], + ); } else { debug!( "chat: skill-gain silenced ({name} at {value}, the 0x402 gate)" diff --git a/crates/benilla-app/src/ui_chat/broadcast.rs b/crates/benilla-app/src/ui_chat/broadcast.rs index 9511d327..bd3ca60b 100644 --- a/crates/benilla-app/src/ui_chat/broadcast.rs +++ b/crates/benilla-app/src/ui_chat/broadcast.rs @@ -433,7 +433,7 @@ mod tests { ..Default::default() }; assert_eq!( - super::super::frames::compose(&event, ChatEventKind::Channel, "Common").unwrap(), + crate::ui_chat::tests::compose(&event, ChatEventKind::Channel, "Common").unwrap(), "[3. LocalDefense] : |cffffff00Sentinel Hill is under attack!|r" ); } diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index cb668e6f..c2dccf5a 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -354,38 +354,34 @@ impl ChatLog { /// 0304). A named kill waits on the victim's name; everything else composes immediately /// (COMBATLOG_XPGAIN_FIRSTPERSON_UNNAMED "You gain %d experience."). pub(crate) fn push_xp_gain(&mut self, x: &XpGain) { - if x.kill && x.victim != 0 { - self.pending.push(Pending::XpGain { - victim: x.victim, - total: x.total, - bonus: x.total.saturating_sub(x.base), - tries: 0, - }); - } else { - self.pending.push(Pending::Event(ChatEvent::text_only( - ChatEventKind::CombatXpGain, - xp_gain_line(None, x.total, 0), - ))); - } + // Both forks park. The unnamed one has no name to wait for — `victim: 0` is what says so + // at the drain — but it still has a template to resolve, and the string table is the VM's + // (decision 2045). One node, one composer, one place the wording can be wrong. + let named = x.kill && x.victim != 0; + self.pending.push(Pending::XpGain { + victim: if named { x.victim } else { 0 }, + total: x.total, + bonus: if named { + x.total.saturating_sub(x.base) + } else { + 0 + }, + tries: 0, + }); } /// Queue an honor award's chat line (`SMSG_PVP_CREDIT` → CHAT_MSG_COMBAT_HONOR_GAIN; /// decision 1512). A credit with **no victim guid** is the bonus/objective form and needs no /// resolve at all, so it composes here and now — the same fork [`Self::push_xp_gain`] takes. pub(crate) fn push_pvp_credit(&mut self, honor: i32, victim: u64, rank: u8) { - if victim != 0 { - self.pending.push(Pending::HonorGain { - victim, - honor, - rank, - tries: 0, - }); - } else { - self.pending.push(Pending::Event(ChatEvent::text_only( - ChatEventKind::CombatHonorGain, - honor_gain_line(None, None, honor), - ))); - } + // Both forks park, for [`Self::push_xp_gain`]'s reason: `victim: 0` is the award form and + // waits for nothing, but its template still has to be resolved where the VM is. + self.pending.push(Pending::HonorGain { + victim, + honor, + rank, + tries: 0, + }); } /// Queue a text emote's chat line (`SMSG_TEXT_EMOTE` → CHAT_MSG_TEXT_EMOTE; decision 1274) for @@ -411,18 +407,6 @@ impl ChatLog { }); } - /// Queue our ding's chat lines (`SMSG_LEVELUP_INFO` → the reference PLAYER_LEVEL_UP handler; - /// decision 0304). `talent_points` is the handler's arg4, client-derived (the packet - /// carries none). - pub(crate) fn push_level_up(&mut self, l: &LevelUpInfo, talent_points: u32) { - for text in level_up_lines(l, talent_points) { - self.pending.push(Pending::Event(ChatEvent::text_only( - ChatEventKind::System, - text, - ))); - } - } - /// Disconnect: drop every pending item (mirrors the merchant/gossip/loot session clears). pub(crate) fn clear_session(&mut self) { self.pending.clear(); @@ -461,56 +445,69 @@ impl ChatLog { } } -/// The ding's chat lines — the reference PLAYER_LEVEL_UP handler transcribed -/// (ChatFrame.lua:1283-1324, GlobalStrings LEVEL_UP / LEVEL_UP_HEALTH[_MANA] / -/// LEVEL_UP_CHAR_POINTS[_P1] / LEVEL_UP_STAT × SPELL_STAT0..4), in its exact order. Decision 0304. -pub(super) fn level_up_lines(l: &LevelUpInfo, talent_points: u32) -> Vec { - let mut lines = vec![format!( - "Congratulations, you have reached level {}!", - l.level - )]; - // LEVEL_UP_HEALTH_MANA when mana gained, else LEVEL_UP_HEALTH — unconditional. - let mana = l.powers[0]; - if mana > 0 { - lines.push(format!( - "You have gained {} hit points and {} mana.", - l.health, mana - )); - } else { - lines.push(format!("You have gained {} hit points.", l.health)); - } - // LEVEL_UP_CHAR_POINTS[_P1] — the GetText singular/plural pick. - if talent_points == 1 { - lines.push("You have gained 1 talent point.".to_string()); - } else if talent_points > 1 { - lines.push(format!("You have gained {talent_points} talent points.")); - } - // LEVEL_UP_STAT × each positive gain, SPELL_STAT0..4 order. - for (name, gain) in ["Strength", "Agility", "Stamina", "Intellect", "Spirit"] - .into_iter() - .zip(l.stats) - { - if gain > 0 { - lines.push(format!("Your {name} increases by {gain}.")); - } - } - lines +/// The VM's own string table as a lookup — `getglobal(key)`, which is where every sentence this +/// feed shows comes from (decision 2045). Taken for exactly as long as one composition needs it, +/// so the `route` that follows can take the VM mutably. +fn globals(script: &benilla_ui::script::UiScript) -> impl Fn(&str) -> Option + '_ { + |key: &str| script.lua().globals().get::(key).ok() } -/// The XP award's chat line: COMBATLOG_XPGAIN_FIRSTPERSON ("%s dies, you gain %d experience."), -/// its EXHAUSTION1 rested form, or the UNNAMED form (no victim). INTERIM: the rested state word -/// is "Rested" — the only state the live server produces (the beta tired/exhausted penalties are -/// dead data); the client's state-word table is the in-flight 0304 §5's to pin. -pub(super) fn xp_gain_line(victim: Option<&str>, total: u32, bonus: u32) -> String { +/// Fill a key's template, or nothing at all if the chain has no string for it — the reference's +/// data-suppression face, and the reason nothing here carries a fallback sentence. +fn keyed( + get: &dyn Fn(&str) -> Option, + key: &str, + args: &[benilla_ui::strings::Arg<'_>], +) -> Option { + let text = benilla_ui::strings::fill(&get(key)?, args); + (!text.is_empty()).then_some(text) +} + +/// The XP award's chat line: `COMBATLOG_XPGAIN_FIRSTPERSON`, its `_EXHAUSTION1` rested form, or +/// the `_UNNAMED` form (no victim). +/// +/// **The rested template takes four arguments, not two** — +/// `"%s dies, you gain %d experience. (%s exp %s bonus)"` — and the last two are the bonus +/// *rendered with its sign* and the state WORD. That word is the one thing on this line the +/// reference does not keep in GlobalStrings: 1.12 ships no `EXHAUSTION_STATE*` key, so it comes +/// out of the client's own table and stays a literal here, with the same INTERIM it always had. +/// "Rested" is the only state the live server produces (the beta tired/exhausted penalties are +/// dead data); the client's table is the in-flight 0304 §5's to pin. +pub(super) fn xp_gain_line( + victim: Option<&str>, + total: u32, + bonus: u32, + get: &dyn Fn(&str) -> Option, +) -> Option { + use benilla_ui::strings::Arg; match victim { - Some(name) if bonus > 0 => { - format!("{name} dies, you gain {total} experience. (+{bonus} exp Rested bonus)") - } - Some(name) => format!("{name} dies, you gain {total} experience."), - None => format!("You gain {total} experience."), + Some(name) if bonus > 0 => keyed( + get, + "COMBATLOG_XPGAIN_EXHAUSTION1", + &[ + Arg::S(name), + Arg::D(i64::from(total)), + Arg::S(&format!("+{bonus}")), + Arg::S(RESTED_STATE), + ], + ), + Some(name) => keyed( + get, + "COMBATLOG_XPGAIN_FIRSTPERSON", + &[Arg::S(name), Arg::D(i64::from(total))], + ), + None => keyed( + get, + "COMBATLOG_XPGAIN_FIRSTPERSON_UNNAMED", + &[Arg::D(i64::from(total))], + ), } } +/// The rest-state word the `_EXHAUSTION1` template's fourth argument takes. Not a GlobalString in +/// 1.12 — see [`xp_gain_line`]. +const RESTED_STATE: &str = "Rested"; + /// The honor line an `SMSG_PVP_CREDIT` becomes — the three GlobalStrings forms (COMBATLOG_HONORAWARD /// :786, COMBATLOG_HONORGAIN :787, COMBATLOG_DISHONORGAIN :785), decision 1512. /// @@ -519,7 +516,7 @@ pub(super) fn xp_gain_line(victim: Option<&str>, total: u32, bonus: u32) -> Stri /// GAIN; victim and **`honor <= 0`** → DISHONOR. The boundary is `<=`, not `<` — a zero-honor kill /// takes the dishonorable arm, which the pre-verdict reading had on the honorable side. /// -/// An absent `rank_title` formats an EMPTY rank slot rather than dropping the clause. That is the +/// An absent `rank_title` fills an EMPTY rank slot rather than dropping the clause. That is the /// reference's own shape — it is precisely the emptiness vmangos floors a rankless victim's rank at /// 5 to avoid showing (1512), and compensating for it a second time on our side would hide what the /// server is actually sending. @@ -527,35 +524,58 @@ pub(super) fn honor_gain_line( victim: Option<&str>, rank_title: Option<&str>, honor: i32, -) -> String { + get: &dyn Fn(&str) -> Option, +) -> Option { + use benilla_ui::strings::Arg; match victim { - None => format!("You have been awarded {honor} honor points."), - Some(name) if honor <= 0 => format!("{name} dies, dishonorable kill."), - Some(name) => format!( - "{name} dies, honorable kill Rank: {} (Estimated Honor Points: {honor})", - rank_title.unwrap_or_default() + None => keyed(get, "COMBATLOG_HONORAWARD", &[Arg::D(i64::from(honor))]), + Some(name) if honor <= 0 => keyed(get, "COMBATLOG_DISHONORGAIN", &[Arg::S(name)]), + Some(name) => keyed( + get, + "COMBATLOG_HONORGAIN", + &[ + Arg::S(name), + Arg::S(rank_title.unwrap_or_default()), + Arg::D(i64::from(honor)), + ], ), } } -/// The discovery toast — GlobalStrings ERR_ZONE_EXPLORED ("Discovered: %s"), fired on every -/// exploration packet to the UIErrorsFrame (byte-verified: error-table route 1 → -/// `AddErrorMessage 0x4945b0` → UI_INFO_MESSAGE; decisions 0828/0829). -pub(super) fn exploration_toast(area_name: &str) -> String { - format!("Discovered: {area_name}") +/// The discovery toast — `ERR_ZONE_EXPLORED` ("Discovered: %s"), fired on every exploration packet +/// to the UIErrorsFrame (byte-verified: error-table route 1 → `AddErrorMessage 0x4945b0` → +/// UI_INFO_MESSAGE; decisions 0828/0829). +pub(super) fn exploration_toast( + area_name: &str, + get: &dyn Fn(&str) -> Option, +) -> Option { + keyed( + get, + "ERR_ZONE_EXPLORED", + &[benilla_ui::strings::Arg::S(area_name)], + ) } -/// The discovery chat line — GlobalStrings ERR_ZONE_EXPLORED_XP ("Discovered %s: %d experience -/// gained"), fired **in addition to** the toast iff the packet carried XP (byte-verified: the -/// signed `jle` skip at `0x5e422f`; route 0 → CHAT_MSG_SYSTEM; decisions 0828/0829). -pub(super) fn exploration_line(area_name: &str, xp: u32) -> String { - format!("Discovered {area_name}: {xp} experience gained") +/// The discovery chat line — `ERR_ZONE_EXPLORED_XP` ("Discovered %s: %d experience gained"), fired +/// **in addition to** the toast iff the packet carried XP (byte-verified: the signed `jle` skip at +/// `0x5e422f`; route 0 → CHAT_MSG_SYSTEM; decisions 0828/0829). +pub(super) fn exploration_line( + area_name: &str, + xp: u32, + get: &dyn Fn(&str) -> Option, +) -> Option { + use benilla_ui::strings::Arg; + keyed( + get, + "ERR_ZONE_EXPLORED_XP", + &[Arg::S(area_name), Arg::D(i64::from(xp))], + ) } /// Build the event a channel notice becomes: JOINED/LEFT → the member-line kinds; the rest → /// CHANNEL_NOTICE (composed by [`super::frames::compose_notice`], the notice byte riding the /// event's `notice` field). -fn notice_event( +pub(super) fn notice_event( notice_byte: u8, channel: String, a: Option, @@ -1017,15 +1037,27 @@ pub(super) fn feed_chat( continue; } // RANDOM_ROLL_RESULT = "%s rolls %d (%d-%d)" (GlobalStrings:3290). - let line = format!( - "{} rolls {roll} ({min}-{max})", - name.unwrap_or_else(|| "Unknown".into()) - ); - route( - &mut script, - &mut windows, - &ChatEvent::text_only(ChatEventKind::System, line), - ); + let name = name.unwrap_or_else(|| "Unknown".into()); + let line = { + use benilla_ui::strings::Arg; + keyed( + &globals(&script), + "RANDOM_ROLL_RESULT", + &[ + Arg::S(&name), + Arg::D(i64::from(roll)), + Arg::D(i64::from(min)), + Arg::D(i64::from(max)), + ], + ) + }; + if let Some(text) = line { + route( + &mut script, + &mut windows, + &ChatEvent::text_only(ChatEventKind::System, text), + ); + } } Pending::XpGain { victim, @@ -1033,25 +1065,30 @@ pub(super) fn feed_chat( bonus, tries, } => { - let name = names.resolve(victim, &commands).map(str::to_string); - if name.is_none() && tries < NAME_MAX_TRIES { - still.push(Pending::XpGain { - victim, - total, - bonus, - tries: tries + 1, - }); - continue; + // `victim == 0` is the UNNAMED form — nothing to resolve, straight to the fill. + let name = if victim == 0 { + None + } else { + let resolved = names.resolve(victim, &commands).map(str::to_string); + if resolved.is_none() && tries < NAME_MAX_TRIES { + still.push(Pending::XpGain { + victim, + total, + bonus, + tries: tries + 1, + }); + continue; + } + Some(resolved.unwrap_or_else(|| "Unknown".into())) + }; + let line = xp_gain_line(name.as_deref(), total, bonus, &globals(&script)); + if let Some(text) = line { + route( + &mut script, + &mut windows, + &ChatEvent::text_only(ChatEventKind::CombatXpGain, text), + ); } - let name = name.unwrap_or_else(|| "Unknown".into()); - route( - &mut script, - &mut windows, - &ChatEvent::text_only( - ChatEventKind::CombatXpGain, - xp_gain_line(Some(&name), total, bonus), - ), - ); } Pending::HonorGain { victim, @@ -1059,16 +1096,22 @@ pub(super) fn feed_chat( rank, tries, } => { - let name = names.resolve(victim, &commands).map(str::to_string); - if name.is_none() && tries < NAME_MAX_TRIES { - still.push(Pending::HonorGain { - victim, - honor, - rank, - tries: tries + 1, - }); - continue; - } + // `victim == 0` is the AWARD form — no name, no rank title, nothing to wait for. + let name = if victim == 0 { + None + } else { + let resolved = names.resolve(victim, &commands).map(str::to_string); + if resolved.is_none() && tries < NAME_MAX_TRIES { + still.push(Pending::HonorGain { + victim, + honor, + rank, + tries: tries + 1, + }); + continue; + } + resolved + }; // **The side is the VICTIM's and the gender is OURS**, and that asymmetry is the // reference's own (`0x625270`): the team digit is computed inline over the victim's // faction template, while the gendered GlobalString resolve runs against the local @@ -1087,16 +1130,21 @@ pub(super) fn feed_chat( .0 .and_then(|g| names.player_traits(g)) .is_some_and(|(_, _, gender)| gender == 1); - let title = script.pvp_rank_title(rank, team, female); - let name = name.unwrap_or_else(|| "Unknown".into()); - route( - &mut script, - &mut windows, - &ChatEvent::text_only( - ChatEventKind::CombatHonorGain, - honor_gain_line(Some(&name), title.as_deref(), honor), - ), + let title = (victim != 0).then(|| script.pvp_rank_title(rank, team, female)); + let name = (victim != 0).then(|| name.unwrap_or_else(|| "Unknown".into())); + let line = honor_gain_line( + name.as_deref(), + title.flatten().as_deref(), + honor, + &globals(&script), ); + if let Some(text) = line { + route( + &mut script, + &mut windows, + &ChatEvent::text_only(ChatEventKind::CombatHonorGain, text), + ); + } } Pending::TextEmote { performer, @@ -1207,17 +1255,26 @@ pub(super) fn feed_chat( } Pending::Discovery { area, xp } => { // The toast fires every time; the chat line only rides XP (decisions 0828/0829). - script.fire_event( - "UI_INFO_MESSAGE", - vec![benilla_ui::script::ScriptValue::Str(exploration_toast( - &area, - ))], - ); - if xp > 0 { + let (toast, line) = { + let get = globals(&script); + ( + exploration_toast(&area, &get), + (xp > 0) + .then(|| exploration_line(&area, xp, &get)) + .flatten(), + ) + }; + if let Some(toast) = toast { + script.fire_event( + "UI_INFO_MESSAGE", + vec![benilla_ui::script::ScriptValue::Str(toast)], + ); + } + if let Some(text) = line { route( &mut script, &mut windows, - &ChatEvent::text_only(ChatEventKind::System, exploration_line(&area, xp)), + &ChatEvent::text_only(ChatEventKind::System, text), ); } } diff --git a/crates/benilla-app/src/ui_chat/frames.rs b/crates/benilla-app/src/ui_chat/frames.rs index ba24e1a7..ee7bcbb1 100644 --- a/crates/benilla-app/src/ui_chat/frames.rs +++ b/crates/benilla-app/src/ui_chat/frames.rs @@ -5,15 +5,18 @@ //! patterns, the `//` flag prefix, the `|Hplayer:…|h[Name]|h` link (never on EMOTE //! or monster lines), the `[Language]` header, the `[N. Name]` channel prefix with its " - Zone" //! tail stripped (the SPEECH branch only — a notice prints arg4 whole, 1275), and the -//! `CHAT__NOTICE` channel-notice strings. Formats are QUOTED from the extracted GlobalStrings -//! (0288's pin, §2/§4); colors come from the stock window's `ChatTypeInfo` table (1948), whose -//! shipped defaults are transcribed in [`super::event::default_color`]. +//! `CHAT__NOTICE` channel-notice strings. **Every one of those formats is a KEY, resolved from +//! the player's own `GlobalStrings.lua` at render time** (decision 2045) — and resolved the way +//! `ChatFrame_OnEvent` itself does it, by splicing the type into `CHAT__GET` and the notice +//! token into `CHAT__NOTICE` rather than by carrying a table of English. Colors come from +//! the stock window's `ChatTypeInfo` table (1948), whose shipped defaults are transcribed in +//! [`super::event::default_color`]. use bevy::prelude::*; -use benilla_protocol::messages::channel_notice as notice; +use benilla_ui::strings::{fill, Arg}; -use super::event::{event_name, ChatEvent, ChatEventKind}; +use super::event::{event_name, notice_token, ChatEvent, ChatEventKind}; /// What the app keeps beside the reference's chat frames: the default language its composer /// needs for the log-file line, and the log files themselves. The per-window registration that @@ -58,7 +61,12 @@ pub(crate) fn route( // tab flash. The app's transcription of that composition survives only for the log files, // which want the rendered line the window will show. let default_language = windows.default_language.clone(); - if let Some(line) = compose(event, kind, &default_language) { + // The composer's strings are the VM's own — `getglobal` against the `GlobalStrings.lua` the + // reference's Lua reads, so the log line and the window line cannot say different things + // (decision 2045). + if let Some(line) = compose(event, kind, &default_language, &|key| { + script.lua().globals().get::(key).ok() + }) { windows.logs.record(kind.is_combat_log(), &line); } script.fire_event(event_name(kind), event.script_args()); @@ -70,6 +78,7 @@ pub(crate) fn compose( event: &ChatEvent, kind: ChatEventKind, default_language: &str, + get: &dyn Fn(&str) -> Option, ) -> Option { use ChatEventKind as K; Some(match kind { @@ -91,21 +100,37 @@ pub(crate) fn compose( // sentence was already built by the time it became an event — that is what // [`super::combat`] is — so there is nothing left for the composer to do. k if k.is_combat_log() => event.text.clone(), - // "%s is ignoring you." (CHAT_IGNORED, arg2). - K::Ignored => format!("{} is ignoring you.", event.sender), - // "[%s] " .. the member list (CHAT_CHANNEL_LIST_GET, l.1409) — arg4 WHOLE, see - // [`strip_zone`]: only the speech branch runs the gsub. - K::ChannelList => format!("[{}] {}", event.channel, event.text), + // `format(TEXT(CHAT_IGNORED), arg2)` (l.1404). **`CHAT_IGNORED`, not + // `ERR_IGNORING_YOU_S`** — both read "%s is ignoring you." in enUS and only the key says + // which one the client actually shows here (2045's "assert the identifier, not the + // sentence"); a locale that words them apart is where the difference surfaces. + K::Ignored => fill( + &get("CHAT_IGNORED").unwrap_or_default(), + &[Arg::S(&event.sender)], + ), + // `format(CHAT_CHANNEL_LIST_GET .. arg1, arg4)` (l.1409) — the member list IS the message + // and the channel fills the hole, with arg4 WHOLE: see [`strip_zone`] for why only the + // speech branch runs the gsub. The reference does not escape arg1 in this arm. + K::ChannelList => fill( + &format!( + "{}{}", + get("CHAT_CHANNEL_LIST_GET").unwrap_or_default(), + event.text + ), + &[Arg::S(&event.channel)], + ), K::ChannelNotice | K::ChannelNoticeUser => { - return compose_notice(event); + return compose_notice(event, kind, get); } // Everything else is the player/monster-line branch (l.1425-1467). _ => { - let pflag = match event.flag.as_str() { - "AFK" => "", - "DND" => "", - "GM" => "", - _ => "", + // `pflag = TEXT(getglobal("CHAT_FLAG_"..arg6))` (l.1430) — `CHAT_FLAG_AFK`/`_DND`/ + // `_GM`, spliced from the flag the wire sent rather than matched against three + // hardcoded angle-bracket tokens. An empty flag looks up nothing, as it does there. + let pflag = if event.flag.is_empty() { + String::new() + } else { + get(&format!("CHAT_FLAG_{}", event.flag)).unwrap_or_default() }; let monster = matches!( kind, @@ -146,14 +171,22 @@ pub(crate) fn compose( } else { String::new() }; - // MONSTER_EMOTE / RAID_BOSS_EMOTE embed their `%s` in the text itself - // (CHAT_MONSTER_EMOTE_GET = "" — l.1437 keeps the name bare for the substitution). - let body = if matches!(kind, K::MonsterEmote | K::RaidBossEmote) { - format!("{header}{}", event.text.replace("%s", &named)) + // `arg1 = gsub(arg1, "%%", "%%%%")` (l.1436), then + // `format(CHAT__GET .. languageHeader .. arg1, name)` — ONE substitution over + // the pattern and the body concatenated, which is why the body has to be escaped + // first: a `%s` a player typed must not eat the name. The monster family and + // RAID_BOSS_EMOTE are the reference's deliberate exception (l.1434-1437): their + // `%s` IS the name's hole (`CHAT_MONSTER_EMOTE_GET = ""`), so the escape is skipped + // and the fill reaches into the text. + let text = if monster { + event.text.clone() } else { - let get = get_pattern(kind); - format!("{}{header}{}", get.replace("%s", &named), event.text) + event.text.replace('%', "%%") }; + let body = fill( + &format!("{}{header}{text}", get_pattern(kind, get)), + &[Arg::S(&named)], + ); // The channel prefix (l.1462-1466): arg4 with its " - Zone" tail stripped, // bracketed. arg4 arrives already numbered ("2. Trade - City") once the channel // wiring (P6) assigns numbers. @@ -166,34 +199,19 @@ pub(crate) fn compose( }) } -/// The `CHAT__GET` prefix patterns (GlobalStrings, quoted — `\32` spaces verbatim). -fn get_pattern(kind: ChatEventKind) -> &'static str { - use ChatEventKind as K; - match kind { - K::Say => "%s says: ", - K::Yell => "%s yells: ", - K::Whisper => "%s whispers: ", - K::WhisperInform => "To %s: ", - K::Emote => "%s ", - K::Afk => "%s is Away From Keyboard: ", - K::Dnd => "%s does not wish to be disturbed: ", - K::Party => "[Party] %s: ", - K::Guild => "[Guild] %s: ", - K::Officer => "[Officer] %s: ", - K::Raid => "[Raid] %s: ", - K::RaidLeader => "[Raid Leader] %s: ", - K::RaidWarning => "[Raid Warning] %s: ", - K::Battleground => "[Battleground] %s: ", - K::BattlegroundLeader => "[Battleground Leader] %s: ", - K::Channel => "%s: ", - K::ChannelJoin => "%s joined channel.", - K::ChannelLeave => "%s left channel.", - K::MonsterSay => "%s says: ", - K::MonsterYell => "%s yells: ", - K::MonsterWhisper => "%s whispers: ", - // Handled before get_pattern is consulted. - _ => "%s", - } +/// The `CHAT__GET` prefix pattern for a kind — `getglobal("CHAT_"..type.."_GET")`, the +/// reference's own splice (l.1445-1453), where `type` is the event name minus its `CHAT_MSG_` +/// prefix. So this is a *derivation*, not a table: every kind the client fires has such a key by +/// construction, and the ones with nothing to prefix ship as `""` (the whole combat/spell family, +/// `CHAT_MONSTER_EMOTE_GET`, `CHAT_RAID_BOSS_EMOTE_GET`) rather than being absent. +/// +/// A key the chain has no string for resolves to `""` — the same nothing an unprefixed line gets, +/// and the reference's own data-suppression posture. +fn get_pattern(kind: ChatEventKind, get: &dyn Fn(&str) -> Option) -> String { + let ty = event_name(kind) + .strip_prefix("CHAT_MSG_") + .unwrap_or_default(); + get(&format!("CHAT_{ty}_GET")).unwrap_or_default() } /// Strip the zone tail from a channel display name (`gsub(arg4, "%s%-%s.*", "")` — @@ -212,52 +230,36 @@ fn strip_zone(channel: &str) -> &str { } } -/// The `SMSG_CHANNEL_NOTIFY` → chat line law: the notice byte selects the quoted -/// `CHAT__NOTICE` string (GlobalStrings 493-745); `channel` fills `%s` first, the tail names -/// (already guid-resolved by the bridge) fill the rest. `None` = the 1.12 UI shows nothing for -/// this notice (MODE_CHANGE has no NOTICE string — flag-change chatter is silent). +/// The `SMSG_CHANNEL_NOTIFY` → chat line law: the notice byte becomes a **token** +/// ([`notice_token`], the client's own `0x49c60c` jump table) and the token names the string — +/// `getglobal("CHAT_"..arg1.."_NOTICE")`, l.1416/1424. `None` = the 1.12 UI shows nothing for this +/// notice: MODE_CHANGE has no token and no NOTICE string (flag-change chatter is silent), and +/// neither does a byte past the table. +/// +/// **The argument list is the reference's, and its order never varies with the notice.** +/// CHANNEL_NOTICE passes arg4 alone; CHANNEL_NOTICE_USER passes arg4 then arg2, plus arg5 when the +/// packet carried a second name ("X kicked by Y"). One line reads the other way round — +/// `CHAT_INVITE_NOTICE = "%2$s has invited you to join the channel '%1$s'."` — and it gets there +/// with **positional specifiers in the string**, not with a special case at the call site. That is +/// the whole argument for resolving these by key: hand-typing the English silently hardcodes one +/// locale's word order (decision 2045). /// /// `chan` is arg4 **whole**, zone tail and all — see [`strip_zone`] for why the notice arms are /// not the gsub's callers. -pub(crate) fn compose_notice(event: &ChatEvent) -> Option { - let chan = &event.channel; - let a = &event.sender; // the notice's first name (actor / affected) - let b = &event.target; // the second name (kicked-by style) - let n: u8 = event.notice_byte().unwrap_or(0xFF); - Some(match n { - notice::YOU_JOINED => format!("Joined Channel: [{chan}]"), - notice::YOU_LEFT => format!("Left Channel: [{chan}]"), - notice::WRONG_PASSWORD => format!("Wrong password for {chan}."), - notice::NOT_MEMBER => format!("Not on channel {chan}."), - notice::NOT_MODERATOR => format!("Not a moderator of {chan}."), - notice::PASSWORD_CHANGED => format!("[{chan}] Password changed by {a}."), - notice::OWNER_CHANGED => format!("[{chan}] Owner changed to {a}."), - notice::PLAYER_NOT_FOUND => format!("[{chan}] Player {a} is not on channel."), - notice::NOT_OWNER => format!("[{chan}] You are not the channel owner."), - notice::CHANNEL_OWNER => format!("[{chan}] Channel owner is {a}."), - notice::MODE_CHANGE => return None, // no NOTICE string in 1.12 — silent - notice::ANNOUNCEMENTS_ON => format!("[{chan}] Channel announcements enabled by {a}."), - notice::ANNOUNCEMENTS_OFF => format!("[{chan}] Channel announcements disabled by {a}."), - notice::MODERATION_ON => format!("[{chan}] Channel moderation enabled by {a}."), - notice::MODERATION_OFF => format!("[{chan}] Channel moderation disabled by {a}."), - notice::MUTED => format!("[{chan}] You do not have permission to speak."), - notice::PLAYER_KICKED => format!("[{chan}] Player {a} kicked by {b}."), - notice::BANNED => format!("[{chan}] You are banned from that channel."), - notice::PLAYER_BANNED => format!("[{chan}] Player {a} banned by {b}."), - notice::PLAYER_UNBANNED => format!("[{chan}] Player {a} unbanned by {b}."), - notice::PLAYER_NOT_BANNED => format!("[{chan}] Player {a} is not banned."), - notice::PLAYER_ALREADY_MEMBER => format!("[{chan}] Player {a} is already on the channel."), - notice::INVITE => format!("{a} has invited you to join the channel '{chan}'."), - notice::INVITE_WRONG_FACTION => format!("Target is in the wrong alliance for {chan}."), - notice::WRONG_FACTION => format!("Wrong alliance for {chan}."), - notice::INVALID_NAME => "Invalid channel name".to_string(), - notice::NOT_MODERATED => format!("{chan} is not moderated"), - notice::PLAYER_INVITED => format!("[{chan}] You invited {a} to join the channel"), - notice::PLAYER_INVITE_BANNED => format!("[{chan}] {a} has been banned."), - notice::THROTTLED => format!( - "[{chan}] The number of messages that can be sent to this channel is limited, \ - please wait to send another message." - ), - _ => return None, - }) +pub(crate) fn compose_notice( + event: &ChatEvent, + kind: ChatEventKind, + get: &dyn Fn(&str) -> Option, +) -> Option { + let token = notice_token(event.notice_byte()?)?; + let template = get(&format!("CHAT_{token}_NOTICE"))?; + let mut args = vec![Arg::S(event.channel.as_str())]; + if kind == ChatEventKind::ChannelNoticeUser { + args.push(Arg::S(&event.sender)); + if !event.target.is_empty() { + args.push(Arg::S(&event.target)); + } + } + let line = fill(&template, &args); + (!line.is_empty()).then_some(line) } diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index 23cc7488..20a6c857 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -553,13 +553,15 @@ pub(super) fn drain_chat_input( "off" => group.clear_session(), // The raid grid's instrument (decision 1549) — 25 synthetic rows, us leading. "raid" => { - for line in crate::ui_party::synthetic_raid(&mut group, &mut names, self_guid.0) - { - chat_log.push_event(super::event::ChatEvent::text_only( - super::event::ChatEventKind::System, - line, - )); - } + // Onto the same by-key queue the wire arm uses (2045/2054), so the instrument + // shows the lines a real roster would — resolved from GlobalStrings, on the + // surface each catalog row names, with its sound. An instrument that composed + // its own text would be eyeballing something the game never prints. + chat_out.ui_errors.0.extend(crate::ui_party::synthetic_raid( + &mut group, + &mut names, + self_guid.0, + )); } "invite" => group.pending_invite = Some("Partner".to_string()), // A group member's ping, without the group member (decision 1596). 35 yd @@ -598,12 +600,10 @@ pub(super) fn drain_chat_input( let w = benilla_assets::coords::bevy_to_wow(tf.translation()); (w[0], w[1]) }); - for line in crate::ui_party::synthetic_roster(&mut group, player_xy) { - chat_log.push_event(super::event::ChatEvent::text_only( - super::event::ChatEventKind::System, - line, - )); - } + chat_out + .ui_errors + .0 + .extend(crate::ui_party::synthetic_roster(&mut group, player_xy)); if arg == "lead" { // The leader-view variant: an unmatched leader guid resolves to // leader_index 0 in the feed — "we lead" — so the leader-only popup @@ -898,7 +898,14 @@ pub(super) fn drain_chat_input( None => warn!("castvis: no selection and no self avatar — dropped"), } } - ParsedChat::ChatTest => chattest_battery(&mut chat_log), + ParsedChat::ChatTest => chattest_battery(&mut chat_log, &|key: &str| { + script + .lua() + .globals() + .get::(key) + .ok() + .filter(|t| !t.is_empty()) + }), // `/logout` (and `/camp`) is the reference's own `SlashCmdList["LOGOUT"]` → `Logout()`, // so it takes the SAME route the game menu's Logout button does (decision 0674): the // request queues on the script seam, `crate::ui_logout` sends it and narrates the @@ -1025,11 +1032,22 @@ pub(super) fn drain_chat_input( continue; } } - // HELP_TEXT_SIMPLE (the ref's unknown-command reply, ChatEdit_ParseText l.2203). - chat_log.push_event(super::event::ChatEvent::text_only( - super::event::ChatEventKind::System, - "Type '/help' for a listing of a few commands.".to_string(), - )); + // HELP_TEXT_SIMPLE (the ref's unknown-command reply, ChatEdit_ParseText l.2203), + // read off the player's own table rather than re-typed here (decision 2045). It is + // no message-catalog row — the reference emits it from Lua straight into chat — so + // there is no surface or sound to look up, only the wording. + if let Some(text) = script + .lua() + .globals() + .get::("HELP_TEXT_SIMPLE") + .ok() + .filter(|t| !t.is_empty()) + { + chat_log.push_event(super::event::ChatEvent::text_only( + super::event::ChatEventKind::System, + text, + )); + } } } } @@ -1038,7 +1056,7 @@ pub(super) fn drain_chat_input( /// `/chattest` (the 0288 instrument): one synthetic line of every renderable form through the /// real event pipeline — kinds, flags, the language header, channel prefixes, notices, and both /// link forms (item + player), so formats/colors/links verify in one screen. -fn chattest_battery(log: &mut super::feed::ChatLog) { +fn chattest_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Option) { use super::event::{ChatEvent, ChatEventKind as K}; let player = |kind: K, text: &str, sender: &str| { let mut e = ChatEvent::text_only(kind, text.into()); @@ -1094,7 +1112,7 @@ fn chattest_battery(log: &mut super::feed::ChatLog) { for e in battery { log.push_event(e); } - combat_log_battery(log); + combat_log_battery(log, get); info!("chattest: battery queued"); } @@ -1110,7 +1128,7 @@ fn chattest_battery(log: &mut super::feed::ChatLog) { /// /// The chat TYPES are picked to show the block's spread rather than one row: your own melee and /// spells, your pet, a hostile player, and a creature hitting you (which is the one that is red). -fn combat_log_battery(log: &mut super::feed::ChatLog) { +fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Option) { use super::combat::{self, Family, Fills, PendingCombat, Variant}; use super::event::ChatEventKind as K; @@ -1372,12 +1390,18 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) { ..fills(250, None, None) }, ), + // The failure reason is the ONE fill in this battery that is a reference string rather + // than a synthetic name: production puts a resolved GlobalString in this slot + // (`ui_action::feed`'s `CAST_FAIL_KEYS` lookup), so a battery that typed English here + // would read as the only untranslated line on a localized install. `ERR_OUT_OF_MANA` and + // not `OUT_OF_MANA` — the same enUS sentence, but the byte-verified NO_POWER pick table + // `0x8118dc` names the former (`ui_action::cast_fail`). line( K::SpellFailedLocalPlayer, combat::SPELLFAILCAST, Variant::SelfOther, Fills { - named: "Not enough mana".into(), + named: get("ERR_OUT_OF_MANA").unwrap_or_default(), ..fills(0, None, None) }, ), diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index a06f6652..75a61d8d 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -1,9 +1,44 @@ use crate::net::ChatKind; use super::event::{default_color, ChatEvent, ChatEventKind as K}; -use super::frames::compose; use super::input::{emote_send_eligible, emote_target, EmoteGate, ParsedChat}; +thread_local! { + /// The **shipped** string table, in a VM, once per test thread — `GlobalStrings.lua` run as + /// the chunk it is, so `\32` and every other escape is Lua's own doing rather than a parser + /// of ours. + /// + /// The composer resolves `CHAT__GET` / `CHAT__NOTICE` off the player's chain now + /// (decision 2045), so an assertion below is only worth making against the real table: a test + /// that graded a rendered line against a stub would pass on sentences the running client + /// never shows, which is the trap decision 2052 named when it moved the glue tests onto the + /// loader's own assembly. + /// + /// Built lazily, so a caller's `wow_data_or_skip!()` runs first. + static GLOBAL_STRINGS: benilla_ui::script::UiScript = { + let s = benilla_ui::script::UiScript::new().expect("VM"); + crate::ui_script::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua"); + s + }; +} + +/// Run something that needs the shipped string table — the feed's line producers resolve their +/// keys through one of these now (decision 2045), and every assertion below wants the REAL table +/// under it rather than a stub with our own idea of the wording in it. +fn with_strings(f: impl FnOnce(&dyn Fn(&str) -> Option) -> T) -> T { + GLOBAL_STRINGS.with(|s| f(&|key: &str| s.lua().globals().get::(key).ok())) +} + +/// [`super::frames::compose`] against the shipped table — the shim that keeps every assertion in +/// this file (and [`super::broadcast`]'s own) reading as a rendered line rather than as a lookup. +pub(super) fn compose(event: &ChatEvent, kind: K, default_language: &str) -> Option { + GLOBAL_STRINGS.with(|s| { + super::frames::compose(event, kind, default_language, &|key| { + s.lua().globals().get::(key).ok() + }) + }) +} + /// A player-line event (the wire bridge's output shape) — sender resolved, optional flag. fn ev(kind: K, text: &str, sender: &str) -> ChatEvent { ChatEvent { @@ -230,53 +265,6 @@ fn a_received_text_emote_composes_its_sentence_and_names_the_performer() { assert!(line(SIT, them("-")).is_none(), "/sit prints nothing"); } -#[test] -fn level_up_lines_follow_the_reference_order_and_forms() { - let _data = benilla_formats::wow_data_or_skip!(); - use benilla_protocol::messages::LevelUpInfo; - - // A caster ding with talent point + three stat gains: the PLAYER_LEVEL_UP handler's exact - // line order (ChatFrame.lua:1283-1324) — LEVEL_UP, HEALTH_MANA, CHAR_POINTS, STAT × positive. - let l = LevelUpInfo { - level: 10, - health: 22, - powers: [15, 0, 0, 0, 0], - stats: [0, 1, 2, 3, 0], - }; - assert_eq!( - super::feed::level_up_lines(&l, 1), - vec![ - "Congratulations, you have reached level 10!", - "You have gained 22 hit points and 15 mana.", - "You have gained 1 talent point.", - "Your Agility increases by 1.", - "Your Stamina increases by 2.", - "Your Intellect increases by 3.", - ] - ); - // A manaless early ding: LEVEL_UP_HEALTH form, no talent line (arg4 == 0 skips), the plural - // form when more than one point. - let l = LevelUpInfo { - level: 2, - health: 12, - powers: [0; 5], - stats: [1, 0, 1, 0, 0], - }; - assert_eq!( - super::feed::level_up_lines(&l, 0), - vec![ - "Congratulations, you have reached level 2!", - "You have gained 12 hit points.", - "Your Strength increases by 1.", - "Your Stamina increases by 1.", - ] - ); - assert_eq!( - super::feed::level_up_lines(&l, 2)[2], - "You have gained 2 talent points." - ); -} - /// The three honor forms (COMBATLOG_HONORAWARD / COMBATLOG_HONORGAIN / COMBATLOG_DISHONORGAIN, /// GlobalStrings :786/:787/:785) and the fork between them, decision 1512. /// @@ -287,30 +275,33 @@ fn level_up_lines_follow_the_reference_order_and_forms() { fn honor_gain_lines_pick_the_reference_form() { let _data = benilla_formats::wow_data_or_skip!(); assert_eq!( - super::feed::honor_gain_line(None, None, 42), - "You have been awarded 42 honor points." + with_strings(|g| super::feed::honor_gain_line(None, None, 42, g)).as_deref(), + Some("You have been awarded 42 honor points.") ); assert_eq!( - super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 137), - "Grimtusk dies, honorable kill Rank: Sergeant (Estimated Honor Points: 137)" + with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 137, g)) + .as_deref(), + Some("Grimtusk dies, honorable kill Rank: Sergeant (Estimated Honor Points: 137)") ); // A dishonorable kill: vmangos sends the same packet with a negative honor // (`HonorMgr.cpp:807`), and the client's fork is on the sign. assert_eq!( - super::feed::honor_gain_line(Some("Innkeeper Renee"), None, -37), - "Innkeeper Renee dies, dishonorable kill." + with_strings(|g| super::feed::honor_gain_line(Some("Innkeeper Renee"), None, -37, g)) + .as_deref(), + Some("Innkeeper Renee dies, dishonorable kill.") ); // The BOUNDARY, byte-verified at `0x625270`: the test is `honor <= 0`, so a zero-honor kill // takes the dishonorable arm. The pre-verdict reading had `< 0` and put this one on the // honorable side, where it would have printed "Rank: (Estimated Honor Points: 0)". assert_eq!( - super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 0), - "Grimtusk dies, dishonorable kill." + with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 0, g)) + .as_deref(), + Some("Grimtusk dies, dishonorable kill.") ); // No rank title: the clause stays, empty — the reference's own shape. assert_eq!( - super::feed::honor_gain_line(Some("Grimtusk"), None, 5), - "Grimtusk dies, honorable kill Rank: (Estimated Honor Points: 5)" + with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), None, 5, g)).as_deref(), + Some("Grimtusk dies, honorable kill Rank: (Estimated Honor Points: 5)") ); } @@ -320,16 +311,16 @@ fn xp_gain_lines_pick_the_reference_form() { // COMBATLOG_XPGAIN_FIRSTPERSON / its EXHAUSTION1 rested form / _UNNAMED (GlobalStrings // :801/:789/:804). assert_eq!( - super::feed::xp_gain_line(Some("Kobold Vermin"), 35, 0), - "Kobold Vermin dies, you gain 35 experience." + with_strings(|g| super::feed::xp_gain_line(Some("Kobold Vermin"), 35, 0, g)).as_deref(), + Some("Kobold Vermin dies, you gain 35 experience.") ); assert_eq!( - super::feed::xp_gain_line(Some("Kobold Vermin"), 52, 17), - "Kobold Vermin dies, you gain 52 experience. (+17 exp Rested bonus)" + with_strings(|g| super::feed::xp_gain_line(Some("Kobold Vermin"), 52, 17, g)).as_deref(), + Some("Kobold Vermin dies, you gain 52 experience. (+17 exp Rested bonus)") ); assert_eq!( - super::feed::xp_gain_line(None, 120, 0), - "You gain 120 experience." + with_strings(|g| super::feed::xp_gain_line(None, 120, 0, g)).as_deref(), + Some("You gain 120 experience.") ); // The XP kind wears the shipped lavender (chat-cache row 46, 0x6F6FFF). assert_eq!(default_color(K::CombatXpGain), [111, 111, 255]); @@ -342,12 +333,12 @@ fn exploration_lines_pick_the_reference_form() { // (UIErrorsFrame); ERR_ZONE_EXPLORED_XP (:1926) — the chat system line that rides // additionally iff xp > 0 (byte-verified branch `0x5e422f`; decisions 0828/0829). assert_eq!( - super::feed::exploration_toast("Westfall"), - "Discovered: Westfall" + with_strings(|g| super::feed::exploration_toast("Westfall", g)).as_deref(), + Some("Discovered: Westfall") ); assert_eq!( - super::feed::exploration_line("Westfall", 85), - "Discovered Westfall: 85 experience gained" + with_strings(|g| super::feed::exploration_line("Westfall", 85, g)).as_deref(), + Some("Discovered Westfall: 85 experience gained") ); } @@ -392,23 +383,33 @@ fn channel_line_prefixes_the_stripped_channel() { /// same channel, the same arg4, and the reference renders them differently. `gsub(arg4, /// "%s%-%s.*", "")` lives at l.1463, inside the speech `else` arm, *after* every notice arm has /// returned — so speech says "[General]" and the join notice says "[General - Elwynn Forest]". +/// +/// **The fixtures are built by the bridge**, not hand-stamped with a kind. CHANNEL_NOTICE and +/// CHANNEL_NOTICE_USER are two different arms of `ChatFrame_OnEvent` — one passes arg4 alone, the +/// other arg4/arg2/arg5 (l.1416/1424) — and which one a notice byte takes is +/// [`super::feed::notice_event`]'s call, not the test author's. Stamping it by hand is how this +/// test came to describe PLAYER_KICKED as a plain CHANNEL_NOTICE, which the bridge has never +/// produced; nothing could see it while the composer ignored the distinction. #[test] fn channel_notices_compose_by_the_notice_law() { let _data = benilla_formats::wow_data_or_skip!(); - let mut e = ChatEvent::text_only(K::ChannelNotice, String::new()); - e.channel = "General - Elwynn Forest".into(); - e.notice = "2".into(); // YOU_JOINED + let notice = |byte: u8, channel: &str, a: Option<&str>, b: Option<&str>| { + let e = super::feed::notice_event( + byte, + channel.to_string(), + a.map(str::to_string), + b.map(str::to_string), + ) + .expect("the bridge builds an event for this notice"); + let kind = e.kind.expect("a built notice always carries its kind"); + compose(&e, kind, "Common") + }; assert_eq!( - compose(&e, K::ChannelNotice, "Common").unwrap(), + notice(0x02, "General - Elwynn Forest", None, None).unwrap(), // YOU_JOINED "Joined Channel: [General - Elwynn Forest]" ); - let mut kick = ChatEvent::text_only(K::ChannelNotice, String::new()); - kick.channel = "World".into(); - kick.sender = "Ann".into(); - kick.target = "Mod".into(); - kick.notice = "18".into(); // PLAYER_KICKED 0x12 assert_eq!( - compose(&kick, K::ChannelNotice, "Common").unwrap(), + notice(0x12, "World", Some("Ann"), Some("Mod")).unwrap(), // PLAYER_KICKED "[World] Player Ann kicked by Mod." ); // A member join line is a CHANNEL_JOIN event, hyperlinked like any player line. @@ -806,7 +807,7 @@ fn a_leave_notice_keeps_its_number_because_the_record_dies_after_the_line() { "arg8 — what the color resolves through" ); assert_eq!( - super::frames::compose(&e, K::ChannelNotice, "Common").unwrap(), + compose(&e, K::ChannelNotice, "Common").unwrap(), "Left Channel: [2. General - Elwynn Forest]" ); assert_eq!( @@ -900,25 +901,65 @@ fn a_session_end_empties_the_window_and_the_boxs_memory() { ); } -/// Every notice byte the composer renders has a token to fire, and vice versa — the two tables are -/// the same set by assertion rather than by good intentions (they are read off the same -/// `CHAT__NOTICE` GlobalStrings keys). +/// **Every notice token names a string the shipped client actually has.** The composer splices +/// the token into `CHAT__NOTICE` the way `ChatFrame_OnEvent` does (l.1416/1424), so the token +/// table IS the render table — there is nothing left to cross-check between two lists of ours. +/// What can still be wrong is a token that resolves to nothing, which would silently print no +/// line at all; that is what this asserts, against the player's own `GlobalStrings.lua`. +/// +/// The bytes with no token render nothing, and must keep rendering nothing: MODE_CHANGE (`0x0C`) +/// fires no chat event in the reference at all, and JOINED/LEFT (`0x00`/`0x01`) are the member +/// lines rather than notices. #[test] -fn every_rendered_notice_has_a_token() { +fn every_notice_token_resolves_and_the_tokenless_bytes_stay_silent() { let _data = benilla_formats::wow_data_or_skip!(); - for byte in 0x00u8..=0x1F { + for byte in 0x00u8..=0x21 { let mut e = ChatEvent::text_only(K::ChannelNotice, String::new()); e.channel = "World".into(); e.notice = byte.to_string(); - let rendered = super::frames::compose_notice(&e).is_some(); - let token = super::event::notice_token(byte).is_some(); - assert_eq!( - rendered, token, - "notice {byte:#04x}: renders={rendered} but token={token}" - ); + let rendered = GLOBAL_STRINGS.with(|s| { + super::frames::compose_notice(&e, K::ChannelNotice, &|key| { + s.lua().globals().get::(key).ok() + }) + }); + match super::event::notice_token(byte) { + Some(token) => assert!( + rendered.is_some(), + "notice {byte:#04x} has token {token} but CHAT_{token}_NOTICE resolves to nothing" + ), + None => assert_eq!( + rendered, None, + "notice {byte:#04x} has no token and must render nothing" + ), + } } } +/// The one notice whose two names are **not** in the order it prints them: +/// `CHAT_INVITE_NOTICE = "%2$s has invited you to join the channel '%1$s'."`, filled from the +/// reference's own fixed `(arg4, arg2)` list (l.1418). This is decision 2045's whole argument in +/// one assertion — hand-typing the English bakes in one locale's word order, and only a +/// positional fill off the shipped string can put the inviter first while the channel is +/// argument one. +#[test] +fn the_invite_notice_reorders_its_two_names() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut e = ChatEvent::text_only(K::ChannelNoticeUser, String::new()); + e.channel = "2. Trade - City".into(); + e.sender = "Ann".into(); + e.notice = benilla_protocol::messages::channel_notice::INVITE.to_string(); + let line = GLOBAL_STRINGS.with(|s| { + super::frames::compose_notice(&e, K::ChannelNoticeUser, &|key| { + s.lua().globals().get::(key).ok() + }) + }); + assert_eq!( + line.as_deref(), + Some("Ann has invited you to join the channel '2. Trade - City'."), + "the inviter fills %2$s and the channel — arg4, zone tail and all — fills %1$s" + ); +} + /// The `ALL` sweep list really is every variant. A new kind fails [`super::event::event_name`]'s /// exhaustive match at compile time; this is what makes you add it to `ALL` as well. #[test] @@ -1875,7 +1916,7 @@ fn a_combat_log_line_renders_verbatim() { e.flag = "AFK".into(); e.language = "Orcish".into(); assert_eq!( - super::frames::compose(&e, kind, &default_language).as_deref(), + compose(&e, kind, &default_language).as_deref(), Some("You hit Kobold Vermin for 5."), "{} must render verbatim", super::event::event_name(kind) @@ -2229,3 +2270,129 @@ fn console_detail_doodad_alpha_parses() { } ); } + +/// **The ding block is printed once, by the reference's own window.** +/// +/// `benilla.toc` sources `Interface\FrameXML\ChatFrame.xml` off the player's chain, and stock +/// `ChatFrame_OnEvent` composes the whole five-line level-up block itself from `PLAYER_LEVEL_UP` +/// (`ChatFrame.lua` l.1283-1324) — `LEVEL_UP`, the health/mana pair, `LEVEL_UP_CHAR_POINTS`, and a +/// `LEVEL_UP_STAT` per positive gain. benilla fires that event with the reference's nine arguments +/// (decision 1884), and for a while it *also* routed its own Rust copy of the same five lines, +/// under a comment saying they would stay "until that window migrates". The window migrated; the +/// copy did not go. Every ding printed twice, and nothing could see it: both halves were correct +/// on their own, and the composer's own tests only ever checked the text it produced. +/// +/// So this counts what lands in the real window. The event is the whole of the ding now — which +/// also means the count below is the reference's own composition, not ours. +#[test] +fn the_ding_block_is_printed_once() { + let _data = benilla_formats::wow_data_or_skip!(); + use benilla_protocol::messages::LevelUpInfo; + let mut s = chat_vm(); + let mut log = super::ChatLog::default(); + + let info = LevelUpInfo { + level: 10, + health: 22, + powers: [15, 0, 0, 0, 0], + stats: [1, 0, 0, 0, 0], + }; + // The whole of what the packet's apply does now: park the gains, print nothing. + log.push_level_up_gains(&info, 1); + let before = lines_in_window(&s); + assert_eq!( + lines_in_window(&s) - before, + 0, + "the app composes no ding line of its own" + ); + + // Tap the window's own `AddMessage` so the assertion can be about the BLOCK and not just its + // length — this is where the deleted Rust composer's test went. The subject moved to the + // reference's Lua; the knowledge did not. + s.run( + r#" + DingLines = {} + local add = ChatFrame1.AddMessage + ChatFrame1.AddMessage = function(self, text, ...) + table.insert(DingLines, text) + return add(self, text, unpack(arg)) + end + "#, + ) + .unwrap(); + + // The event `ui_unit` fires, with the reference's nine arguments. + let args: Vec = [10i64, 22, 15, 1, 1, 0, 0, 0, 0] + .into_iter() + .map(benilla_ui::script::ScriptValue::Int) + .collect(); + s.fire_event("PLAYER_LEVEL_UP", args); + assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); + assert_eq!( + lines_in_window(&s) - before, + 4, + "LEVEL_UP, the health/mana pair, CHAR_POINTS, and one STAT — once each" + ); + + // `ChatFrame.lua` l.1283-1324's exact order and forms, off the shipped GlobalStrings: the + // singular `LEVEL_UP_CHAR_POINTS` at one point (`GetText`'s plural pick), and one + // `LEVEL_UP_STAT` for the single positive gain, named through `SPELL_STAT0_NAME`. + let lines: Vec = (1..=4) + .map(|i| s.eval::(&format!("return DingLines[{i}]")).unwrap()) + .collect(); + assert_eq!( + lines, + [ + "Congratulations, you have reached level 10!", + "You have gained 22 hit points and 15 mana.", + "You have gained 1 talent point.", + "Your Strength increases by 1.", + ] + ); +} + +/// **The free-professions line, same question as the ding.** Stock `ChatFrame_OnEvent` handles +/// `CHARACTER_POINTS_CHANGED` too (`ChatFrame.lua` l.1324-1334): on `arg2 > 0` it reads +/// `UnitCharacterPoints("player")` and prints `GetText("LEVEL_UP_SKILL_POINTS", nil, cp2)`. +/// `ui_talent` fires that event *and* composes the same line. This asks the window which of them +/// lands. +#[test] +fn the_free_professions_line_is_printed_once() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = chat_vm(); + s.set_talents(benilla_ui::script::TalentUiState { + points: (0, 2), + ..Default::default() + }); + s.run( + r#" + SkillLines = {} + local add = ChatFrame1.AddMessage + ChatFrame1.AddMessage = function(self, text, ...) + table.insert(SkillLines, text) + return add(self, text, unpack(arg)) + end + "#, + ) + .unwrap(); + let before = lines_in_window(&s); + // `ui_talent`'s fire: arg1 = the talent delta, arg2 = the profession delta. + s.fire_event( + "CHARACTER_POINTS_CHANGED", + vec![ + benilla_ui::script::ScriptValue::Int(0), + benilla_ui::script::ScriptValue::Int(1), + ], + ); + assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); + assert_eq!( + lines_in_window(&s) - before, + 1, + "the stock frame prints the professions line from the event" + ); + assert_eq!( + s.eval::("return SkillLines[1]").unwrap(), + "You now have 2 free professions.", + "LEVEL_UP_SKILL_POINTS_P1, plural-picked by GetText on cp2" + ); +} diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index c6955640..993890df 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -162,11 +162,13 @@ fn feed_craft( .line(line) .map(|l| l.name.clone()) .unwrap_or_else(|| format!("Skill {line}")); + let text = crate::ui_script::token_text(&script); let ctx = benilla_formats::TokenContext { durations: &spells.durations, radii: &spells.radii, lookup: &|id| spells.catalog.get(id), home_area: None, + text: &text, }; // The **admission law** — `0x5e9c20`, byte-verified (decision 1124): the player knows the // spell, it is not hidden (`Attributes & 0x20`), and its `castUI` **equals this window's diff --git a/crates/benilla-app/src/ui_duel.rs b/crates/benilla-app/src/ui_duel.rs index 9a7ced93..0daad436 100644 --- a/crates/benilla-app/src/ui_duel.rs +++ b/crates/benilla-app/src/ui_duel.rs @@ -49,7 +49,6 @@ use crate::names::NameCache; use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, SelfPlayer}; use crate::target::Selection; use crate::ui_action::Spells; -use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; use crate::ui_script::UiInput; /// `SPELL_EFFECT_DUEL` — the `Effect[0]` value that identifies the duel spell in the player's own @@ -60,21 +59,40 @@ use crate::ui_script::UiInput; /// mechanism, so a data change moves with the data. const SPELL_EFFECT_DUEL: u32 = 83; -// The GlobalStrings templates, quoted verbatim from the reference client's own patch chain -// (decision 0246 extraction; `GlobalStrings.lua` line numbers cited per constant). The two `ERR_*` -// lines are what `CGGameUI::DisplayError 0x496720` resolves for error ids `0x135`/`0x136` — the -// table at `0xb4b498` holds their keys at record 309/310 (20-byte records, key at offset 0). -const DUEL_COUNTDOWN: &str = "Duel starting: %d"; // GlobalStrings:955 -const DUEL_WINNER_KNOCKOUT: &str = "%1$s has defeated %2$s in a duel"; // GlobalStrings:958 -const DUEL_WINNER_RETREAT: &str = "%2$s has fled from %1$s in a duel"; // GlobalStrings:959 -const ERR_DUEL_REQUESTED: &str = "You have requested a duel."; // GlobalStrings:1553 -const ERR_DUEL_CANCELLED: &str = "Duel cancelled."; // GlobalStrings:1552 +/// A line this window owes but cannot yet write: the VM that holds the string table is +/// [`feed_duel`]'s, not the net drain's (decision 2045 — the text is the player's `GlobalStrings`, +/// so it is resolved where the VM is, and named by key everywhere else). +/// +/// **These three keys are not message-catalog rows** — `DUEL_COUNTDOWN` and the two +/// `DUEL_WINNER_*` appear nowhere in the reference's FrameXML either, which is what marks them +/// engine-composed. So they cannot ride `UiErrorKeys`: an unknown key takes `Shown::keyed`'s +/// fallback and turns the line red. They go out as `Shown::unkeyed` on the system chat channel +/// they have always used (2054's `/ginfo` shape). The two `ERR_DUEL_*` lines beside them ARE rows +/// and take the other road entirely. +enum OwedLine { + /// `DUEL_COUNTDOWN` ("Duel starting: %d") with the seconds left. + Countdown(u32), + /// `DUEL_WINNER_RETREAT` when the loser fled, else `DUEL_WINNER_KNOCKOUT`. + /// + /// Both templates are **positional** — `"%1$s has defeated %2$s in a duel"` against + /// `"%2$s has fled from %1$s in a duel"` — so the same (winner, loser) pair fills them in + /// opposite orders. It is the canonical case for why an ordered fill is not enough and why a + /// re-typed English sentence cannot carry the rule at all. + Winner { + fled: bool, + winner: String, + loser: String, + }, +} /// The duel session mirror. Written by the net drain's duel arms, read by [`feed_duel`] (which /// fires the Era events on its edges) and [`tick_countdown`]. Cleared on disconnect beside the /// other per-login resources. #[derive(Resource, Default)] pub(crate) struct DuelState { + /// Lines composed here but resolvable only where the VM is — [`feed_duel`] drains them + /// (see [`OwedLine`]). The `ui_guild`/`ui_petition` shape (decisions 2054/2045). + owed: Vec, /// The duel-flag GameObject guid identifying the pending or running duel; `0` = none. This is /// the client's `[0xb73240]`: set by the request, echoed on accept/cancel, cleared only by /// completion. Its non-zero→zero edge is what fires `DUEL_FINISHED`. @@ -142,22 +160,28 @@ impl DuelState { } } -/// Compose `SMSG_DUEL_WINNER`'s system line. The templates use positional `%1$s`/`%2$s` because -/// the retreat wording swaps the two names — quoted from GlobalStrings verbatim, so the -/// substitution is positional here too rather than `format!`'s. -fn winner_line(fled: bool, winner: &str, loser: &str) -> String { - let template = if fled { - DUEL_WINNER_RETREAT - } else { - DUEL_WINNER_KNOCKOUT +/// Resolve one owed line against the VM's own string table. `None` = no string, no line — the +/// reference's data-suppression face. +fn owed_text(line: &OwedLine, get: &dyn Fn(&str) -> Option) -> Option { + use benilla_ui::strings::{fill, Arg}; + let text = match line { + OwedLine::Countdown(n) => fill(&get("DUEL_COUNTDOWN")?, &[Arg::D(i64::from(*n))]), + OwedLine::Winner { + fled, + winner, + loser, + } => { + let key = if *fled { + "DUEL_WINNER_RETREAT" + } else { + "DUEL_WINNER_KNOCKOUT" + }; + // Winner first, loser second — always. Which one the sentence *names* first is the + // template's business, through its `%1$s`/`%2$s`. + fill(&get(key)?, &[Arg::S(winner), Arg::S(loser)]) + } }; - benilla_ui::strings::fill( - template, - &[ - benilla_ui::strings::Arg::S(winner), - benilla_ui::strings::Arg::S(loser), - ], - ) + (!text.is_empty()).then_some(text) } /// The net drain's `SessionEvent::Duel*` arms, factored here so the wire laws live beside the @@ -173,7 +197,7 @@ pub(crate) mod apply { /// 0633 recorded as absent for want of an ignore list; decision 0668 supplies one. pub(crate) fn requested( duel: &mut DuelState, - chat_log: &mut ChatLog, + errors: &mut crate::ui_action::UiErrorKeys, commands: &NetCommands, arbiter: u64, challenger: u64, @@ -185,22 +209,34 @@ pub(crate) mod apply { return; } if duel.apply_requested(arbiter, challenger, own) { - error_line(chat_log, ERR_DUEL_REQUESTED); + errors + .0 + .push(crate::ui_action::UiError::key("ERR_DUEL_REQUESTED")); let _ = commands.0.send(ClientCommand::DuelAccepted { arbiter }); } } - /// `SMSG_DUEL_COMPLETE` — clear the session, cancel the countdown, and show "Duel cancelled." - /// when the duel never started. - pub(crate) fn complete(duel: &mut DuelState, chat_log: &mut ChatLog, started: bool) { + /// `SMSG_DUEL_COMPLETE` — clear the session, cancel the countdown, and raise + /// `ERR_DUEL_CANCELLED` when the duel never started. + pub(crate) fn complete( + duel: &mut DuelState, + errors: &mut crate::ui_action::UiErrorKeys, + started: bool, + ) { if duel.apply_complete(started) { - error_line(chat_log, ERR_DUEL_CANCELLED); + errors + .0 + .push(crate::ui_action::UiError::key("ERR_DUEL_CANCELLED")); } } - /// `SMSG_DUEL_WINNER` — the outcome line, as CHAT_MSG_SYSTEM. - pub(crate) fn winner(chat_log: &mut ChatLog, fled: bool, winner: &str, loser: &str) { - system_line(chat_log, winner_line(fled, winner, loser)); + /// `SMSG_DUEL_WINNER` — the outcome line, owed to the feed. + pub(crate) fn winner(duel: &mut DuelState, fled: bool, winner: &str, loser: &str) { + duel.owed.push(OwedLine::Winner { + fled, + winner: winner.to_string(), + loser: loser.to_string(), + }); } /// `SMSG_DUEL_COUNTDOWN` — arm the client-side tick. @@ -214,19 +250,6 @@ pub(crate) mod apply { } } -/// A `DisplayError`-shaped line. benilla models `CGGameUI::DisplayError 0x496720` as the red -/// `UI_ERROR_MESSAGE` toast (decision 0427, the cast-failure pipeline) — but that fires through -/// the VM, which the net drain has no handle on. Both duel error ids are pure text with no -/// arguments, so they ride the system chat channel the countdown and outcome lines already use; -/// the UIErrorsFrame routing is the open refinement, noted in decision 0633. -fn error_line(chat_log: &mut ChatLog, text: &str) { - system_line(chat_log, text.to_string()); -} - -fn system_line(chat_log: &mut ChatLog, text: String) { - chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text)); -} - /// Fire the four duel events on their state edges — the [`crate::ui_party::feed`] pattern: the /// drain mutates [`DuelState`], the feed diffs it against what it last announced. fn feed_duel( @@ -234,6 +257,7 @@ fn feed_duel( mut duel: ResMut, mut names: ResMut, commands: Res, + mut sink: crate::ui_action::MessageSink, mut fed: Local>, ) { let Some(mut script) = script else { @@ -241,6 +265,22 @@ fn feed_duel( }; let fed = fed.get(&script); + // The owed countdown/outcome lines, resolved against the VM's own string table. They land + // ahead of the event edges below for the same reason the social feed's do: the line describes + // the transition the event announces. + let owed = std::mem::take(&mut duel.owed); + let lines: Vec = { + let get = |key: &str| script.lua().globals().get::(key).ok(); + owed.iter() + .filter_map(|line| { + owed_text(line, &get).map(|text| { + crate::ui_action::Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, text) + }) + }) + .collect() + }; + crate::ui_action::show_messages(&mut script, &mut sink, "ui_duel", lines); + // DUEL_REQUESTED(challengerName) — held until the name resolves (module doc, deviation 2). if let Some(guid) = duel.challenger { if let Some(name) = names.resolve(guid, &commands).map(str::to_string) { @@ -278,12 +318,7 @@ struct FedDuel { /// `ProcessCountdown` (`0x4d4930`): print "Duel starting: N" now, then once a second while the /// count is non-zero. The first line lands the frame the packet arrives — the reference calls the /// tick body directly before arming the timer — so the timer here starts already elapsed. -fn tick_countdown( - time: Res