every line is a key, four find their sound, and the ding prints once
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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::<String>(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<String> {
|
||||
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<String>) -> Option<String> {
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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<UiError>) {
|
||||
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<String>) {
|
||||
/// 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<GroupMemberEntry>| {
|
||||
list(
|
||||
group,
|
||||
&mut chat_log,
|
||||
&mut errors,
|
||||
&mut quest,
|
||||
0,
|
||||
0,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<String>(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>,
|
||||
) -> 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<u32>, 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<u32>,
|
||||
areas: Option<&AreaTableRes>,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> String {
|
||||
let named = |id: u32| {
|
||||
areas
|
||||
.and_then(|a| a.0.get(id))
|
||||
@@ -159,12 +171,8 @@ fn area_name_of(leaf: Option<u32>, 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
|
||||
/// "<area> 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<ServerSoundMessage>,
|
||||
) {
|
||||
@@ -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::<String>(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::<String>(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
|
||||
|
||||
@@ -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<u16, u16>)>;
|
||||
/// 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::<String>(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<NonSendMut<UiScript>>,
|
||||
self_guid: Res<crate::net::SelfGuid>,
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> + '_ {
|
||||
|key: &str| script.lua().globals().get::<String>(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<String>,
|
||||
key: &str,
|
||||
args: &[benilla_ui::strings::Arg<'_>],
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
//! patterns, the `<AFK>/<DND>/<GM>` 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_<X>_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_<X>_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_<TYPE>_GET` and the notice
|
||||
//! token into `CHAT_<TOKEN>_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::<String>(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<String>,
|
||||
) -> Option<String> {
|
||||
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" => "<AFK>",
|
||||
"DND" => "<DND>",
|
||||
"GM" => "<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_<TYPE>_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_<TYPE>_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_<TYPE>_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>) -> 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_<X>_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<String> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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::<String>(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::<String>("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<String>) {
|
||||
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<String>) {
|
||||
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)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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_<TYPE>_GET` / `CHAT_<X>_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<T>(f: impl FnOnce(&dyn Fn(&str) -> Option<String>) -> T) -> T {
|
||||
GLOBAL_STRINGS.with(|s| f(&|key: &str| s.lua().globals().get::<String>(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<String> {
|
||||
GLOBAL_STRINGS.with(|s| {
|
||||
super::frames::compose(event, kind, default_language, &|key| {
|
||||
s.lua().globals().get::<String>(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_<X>_NOTICE` GlobalStrings keys).
|
||||
/// **Every notice token names a string the shipped client actually has.** The composer splices
|
||||
/// the token into `CHAT_<X>_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::<String>(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::<String>(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<benilla_ui::script::ScriptValue> = [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<String> = (1..=4)
|
||||
.map(|i| s.eval::<String>(&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::<String>("return SkillLines[1]").unwrap(),
|
||||
"You now have 2 free professions.",
|
||||
"LEVEL_UP_SKILL_POINTS_P1, plural-picked by GetText on cp2"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<OwedLine>,
|
||||
/// 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<String>) -> Option<String> {
|
||||
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<DuelState>,
|
||||
mut names: ResMut<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedDuel>>,
|
||||
) {
|
||||
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<crate::ui_action::Shown> = {
|
||||
let get = |key: &str| script.lua().globals().get::<String>(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<Time>,
|
||||
mut duel: ResMut<DuelState>,
|
||||
mut chat_log: ResMut<ChatLog>,
|
||||
mut started: Local<bool>,
|
||||
) {
|
||||
fn tick_countdown(time: Res<Time>, mut duel: ResMut<DuelState>, mut started: Local<bool>) {
|
||||
let Some(countdown) = duel.countdown.as_mut() else {
|
||||
*started = false;
|
||||
return;
|
||||
@@ -297,10 +332,11 @@ fn tick_countdown(
|
||||
if !fire {
|
||||
return;
|
||||
}
|
||||
let line = DUEL_COUNTDOWN.replace("%d", &countdown.remaining.to_string());
|
||||
system_line(&mut chat_log, line);
|
||||
let remaining = countdown.remaining;
|
||||
countdown.remaining -= 1;
|
||||
if countdown.remaining == 0 {
|
||||
let done = countdown.remaining == 0;
|
||||
duel.owed.push(OwedLine::Countdown(remaining));
|
||||
if done {
|
||||
duel.countdown = None;
|
||||
*started = false;
|
||||
}
|
||||
@@ -429,7 +465,10 @@ impl Plugin for UiDuelPlugin {
|
||||
app.init_resource::<DuelState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
tick_countdown,
|
||||
// `tick_countdown` only *names* its line now — `feed_duel` resolves it against
|
||||
// the VM — so the tick has to run first or every "Duel starting: N" lands a
|
||||
// frame late.
|
||||
tick_countdown.before(feed_duel),
|
||||
feed_duel.before(UiInput),
|
||||
drain_duel.after(UiInput),
|
||||
),
|
||||
@@ -509,17 +548,79 @@ mod tests {
|
||||
assert_eq!(catalog.get(7266).unwrap().name, "Duel");
|
||||
}
|
||||
|
||||
/// The two outcome templates take (winner, loser) in that order — the retreat wording reads
|
||||
/// them back to front, which is exactly why they are positional.
|
||||
/// **The two outcome templates take (winner, loser) in that order, and one of them prints
|
||||
/// them back to front.** That is the whole reason they are positional, and the reason this is
|
||||
/// resolved from the player's own `GlobalStrings.lua` rather than typed here: the swap lives
|
||||
/// in the *string*, so a locale that orders the clause differently gets it right for free and
|
||||
/// a re-typed English sentence never could (decision 2045).
|
||||
///
|
||||
/// Resolved against the real shipped file, because a stub would be asserting our own idea of
|
||||
/// the templates back at us.
|
||||
#[test]
|
||||
fn the_outcome_line_places_both_names_positionally() {
|
||||
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 s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let get = |key: &str| s.lua().globals().get::<String>(key).ok();
|
||||
|
||||
let outcome = |fled| {
|
||||
owed_text(
|
||||
&OwedLine::Winner {
|
||||
fled,
|
||||
winner: "Onerogue".into(),
|
||||
loser: "Twomage".into(),
|
||||
},
|
||||
&get,
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
winner_line(false, "Onerogue", "Twomage"),
|
||||
"Onerogue has defeated Twomage in a duel"
|
||||
outcome(false).as_deref(),
|
||||
Some("Onerogue has defeated Twomage in a duel")
|
||||
);
|
||||
assert_eq!(
|
||||
winner_line(true, "Onerogue", "Twomage"),
|
||||
"Twomage has fled from Onerogue in a duel"
|
||||
outcome(true).as_deref(),
|
||||
Some("Twomage has fled from Onerogue in a duel")
|
||||
);
|
||||
assert_eq!(
|
||||
owed_text(&OwedLine::Countdown(3), &get).as_deref(),
|
||||
Some("Duel starting: 3")
|
||||
);
|
||||
}
|
||||
|
||||
/// **The two `ERR_DUEL_*` lines are catalog rows, and the catalog disagrees with where this
|
||||
/// window used to put them.** `ERR_DUEL_CANCELLED` is `MsgKind::Info` — the yellow
|
||||
/// `UI_INFO_MESSAGE`, not a chat line — and `ERR_DUEL_REQUESTED` carries the `LEVELUP` cue,
|
||||
/// which a straight `chat_log.push_event` had no row in hand to play. Both were invisible
|
||||
/// while the sentences were composed here; both are read now.
|
||||
///
|
||||
/// The three engine-composed templates are asserted to be the opposite case — **not** rows —
|
||||
/// because that is what forbids them the `Shown::keyed` road, where an unknown key takes the
|
||||
/// red-line fallback.
|
||||
#[test]
|
||||
fn the_error_pair_are_catalog_rows_and_the_templates_are_not() {
|
||||
use benilla_ui::messages::{by_key, MsgKind};
|
||||
let requested = by_key("ERR_DUEL_REQUESTED").expect("a catalog row");
|
||||
assert_eq!(requested.kind, MsgKind::Chat);
|
||||
assert_eq!(requested.sound, Some("LEVELUP"));
|
||||
let cancelled = by_key("ERR_DUEL_CANCELLED").expect("a catalog row");
|
||||
assert_eq!(
|
||||
cancelled.kind,
|
||||
MsgKind::Info,
|
||||
"the yellow info line, not chat"
|
||||
);
|
||||
for key in [
|
||||
"DUEL_COUNTDOWN",
|
||||
"DUEL_WINNER_KNOCKOUT",
|
||||
"DUEL_WINNER_RETREAT",
|
||||
] {
|
||||
assert!(
|
||||
by_key(key).is_none(),
|
||||
"{key} is engine-composed — it must not go out through Shown::keyed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,21 +291,6 @@ impl InstanceState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The GlobalStrings token `GetText(token, nil, ordinal)` actually reads — `LocaleProperties.lua`
|
||||
/// (`GetPluralTag` → `GetPluralIndex`), which the reference calls into through `0x703bf0`.
|
||||
///
|
||||
/// Absent or exactly 1 → the bare token; **anything else, zero included** → `token .. "_P1"`, with
|
||||
/// `GetText`'s own fall-back to the bare token when the twin does not resolve.
|
||||
fn plural_token(token: &str, ordinal: Option<u32>, get: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
if ordinal.is_some_and(|n| n != 1) {
|
||||
let plural = format!("{token}_P1");
|
||||
if get(&plural).is_some_and(|s| !s.is_empty()) {
|
||||
return plural;
|
||||
}
|
||||
}
|
||||
token.to_string()
|
||||
}
|
||||
|
||||
/// Resolve one queued line to its displayed text — `GetText` + the `%s`/`%d` fills, then the
|
||||
/// debug wrapper if the save-created flag asked for it.
|
||||
///
|
||||
@@ -317,8 +302,8 @@ fn lockout_text(
|
||||
map_name: Option<&str>,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
let token = plural_token(line.token, line.ordinal, get);
|
||||
let template = get(&token).filter(|s| !s.is_empty())?;
|
||||
// `GetText(token, nil, ordinal)`'s own plural pick, through the shared primitive.
|
||||
let template = benilla_ui::strings::plural(line.token, line.ordinal, get)?;
|
||||
// Arguments in the template's own order — every lockout template takes its `%s` before its
|
||||
// `%d`s (checked against all fourteen tokens), which is what makes one ordered list exact.
|
||||
let mut args: Vec<benilla_ui::strings::Arg<'_>> = Vec::new();
|
||||
@@ -825,26 +810,37 @@ mod tests {
|
||||
/// `if ( not string )` arm. Modelled with a locale that has the singular only.
|
||||
#[test]
|
||||
fn a_missing_plural_twin_falls_back_to_the_bare_token() {
|
||||
// Marked stand-ins rather than the shipped wording: the primitive returns a *template*,
|
||||
// so the assertion has to be readable as "which key did it reach", which is the thing
|
||||
// worth asserting (decision 2045).
|
||||
let sparse = |key: &str| match key {
|
||||
"RAID_INSTANCE_WARNING_HOURS" => Some("in %d hour.".to_string()),
|
||||
"RAID_INSTANCE_WARNING_HOURS" => Some("<bare>".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let both = |key: &str| match key {
|
||||
"RAID_INSTANCE_WARNING_HOURS" => Some("<bare>".to_string()),
|
||||
"RAID_INSTANCE_WARNING_HOURS_P1" => Some("<twin>".to_string()),
|
||||
"RAID_INSTANCE_WELCOME" => Some("<welcome>".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let plural = benilla_ui::strings::plural;
|
||||
assert_eq!(
|
||||
plural_token("RAID_INSTANCE_WARNING_HOURS", Some(5), &sparse),
|
||||
"RAID_INSTANCE_WARNING_HOURS"
|
||||
plural("RAID_INSTANCE_WARNING_HOURS", Some(5), &sparse).as_deref(),
|
||||
Some("<bare>"),
|
||||
"no twin: GetText's own `if ( not string )` arm"
|
||||
);
|
||||
assert_eq!(
|
||||
plural_token("RAID_INSTANCE_WARNING_HOURS", Some(5), &globals),
|
||||
"RAID_INSTANCE_WARNING_HOURS_P1"
|
||||
plural("RAID_INSTANCE_WARNING_HOURS", Some(5), &both).as_deref(),
|
||||
Some("<twin>")
|
||||
);
|
||||
assert_eq!(
|
||||
plural_token("RAID_INSTANCE_WARNING_HOURS", Some(1), &globals),
|
||||
"RAID_INSTANCE_WARNING_HOURS",
|
||||
plural("RAID_INSTANCE_WARNING_HOURS", Some(1), &both).as_deref(),
|
||||
Some("<bare>"),
|
||||
"exactly 1 is the bare token"
|
||||
);
|
||||
assert_eq!(
|
||||
plural_token("RAID_INSTANCE_WELCOME", None, &globals),
|
||||
"RAID_INSTANCE_WELCOME",
|
||||
plural("RAID_INSTANCE_WELCOME", None, &both).as_deref(),
|
||||
Some("<welcome>"),
|
||||
"no ordinal is the bare token"
|
||||
);
|
||||
}
|
||||
@@ -1092,17 +1088,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The three plural twins exist, so `plural_token` really has something to select.
|
||||
// The three plural twins exist, so the pick really has something to select — asserted by
|
||||
// the twin's own shipped text, which differs from the bare token's.
|
||||
for token in [
|
||||
"RAID_INSTANCE_WARNING_HOURS",
|
||||
"RAID_INSTANCE_WARNING_MIN",
|
||||
"RAID_INSTANCE_WARNING_MIN_SOON",
|
||||
] {
|
||||
let twin = real(&format!("{token}_P1"));
|
||||
assert!(twin.is_some(), "{token} has a shipped plural twin");
|
||||
assert_eq!(
|
||||
plural_token(token, Some(2), &real),
|
||||
format!("{token}_P1"),
|
||||
"{token} has a shipped plural twin"
|
||||
benilla_ui::strings::plural(token, Some(2), &real),
|
||||
twin,
|
||||
"{token} at two takes its twin"
|
||||
);
|
||||
assert_ne!(twin, real(token), "{token}'s two forms differ");
|
||||
}
|
||||
// …and the welcome does not, which is why the reference passes it no ordinal at all.
|
||||
assert!(
|
||||
@@ -1113,7 +1113,7 @@ mod tests {
|
||||
// Every warning template names the instance and takes exactly the fills we hand it.
|
||||
for (ty, fills) in [(1u32, 1usize), (2, 1), (3, 1), (4, 3)] {
|
||||
let line = warning(ty, 409, 3_600).expect("a template");
|
||||
let template = real(&plural_token(line.token, line.ordinal, &real)).unwrap();
|
||||
let template = benilla_ui::strings::plural(line.token, line.ordinal, &real).unwrap();
|
||||
assert!(template.contains("%s"), "{} names the instance", line.token);
|
||||
assert_eq!(
|
||||
template.matches("%d").count(),
|
||||
|
||||
@@ -69,6 +69,9 @@ fn spell_desc_text(
|
||||
spells: Option<&crate::ui_action::Spells>,
|
||||
id: u32,
|
||||
home_area: Option<&str>,
|
||||
// The `$d`/`$s` tokens resolve `INT_SPELL_DURATION_*` / `INT_SPELL_POINTS_SPREAD_TEMPLATE`
|
||||
// out of the VM (decision 2045); `benilla-formats` names the key, this side renders it.
|
||||
text: &dyn Fn(&str, &[i64]) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
let sp = spells?;
|
||||
let d = sp.catalog.get(id)?;
|
||||
@@ -81,6 +84,7 @@ fn spell_desc_text(
|
||||
radii: &sp.radii,
|
||||
lookup: &|i| sp.catalog.get(i),
|
||||
home_area,
|
||||
text,
|
||||
};
|
||||
Some(benilla_formats::substitute(desc, d, &ctx)).filter(|t| !t.is_empty())
|
||||
}
|
||||
@@ -105,19 +109,15 @@ fn charges_count(spells: &[benilla_protocol::messages::ItemSpellEntry]) -> i32 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A reputation rank (0..=7) → its enUS standing label (GlobalStrings
|
||||
/// `FACTION_STANDING_LABEL1..8`) — the "Requires <Faction> - <Standing>" tail.
|
||||
fn standing_label(rank: u32) -> &'static str {
|
||||
[
|
||||
"Hated",
|
||||
"Hostile",
|
||||
"Unfriendly",
|
||||
"Neutral",
|
||||
"Friendly",
|
||||
"Honored",
|
||||
"Revered",
|
||||
"Exalted",
|
||||
][rank.min(7) as usize]
|
||||
/// A reputation rank (0..=7) → its standing label, `FACTION_STANDING_LABEL{rank+1}` off the
|
||||
/// player's own `GlobalStrings.lua` — the tail of the `ITEM_REQ_REPUTATION` line.
|
||||
///
|
||||
/// Keys rather than an eight-string table (decision 2045): the bare tokens, not the `_FEMALE`
|
||||
/// twins, which is the spelling the reference itself uses wherever it builds a standing label
|
||||
/// (`FACTION_STANDING_LABEL%d` `0x84b5dc`, wow-re `ui/scratch/quest-leaderboard-law.md` §5).
|
||||
/// `None` for an install that does not carry the row — the line then names no standing.
|
||||
fn standing_label(rank: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
get(&format!("FACTION_STANDING_LABEL{}", rank.min(7) + 1))
|
||||
}
|
||||
|
||||
/// Build an item template's tooltip view (decision 0274 P1): the full wire fields plus the
|
||||
@@ -136,13 +136,17 @@ fn template_view(
|
||||
sub_classes: Option<&benilla_formats::ItemSubClassCatalog>,
|
||||
classes: Option<&benilla_formats::ItemClassCatalog>,
|
||||
icons: Option<&ItemDisplays>,
|
||||
// The VM's own `GlobalStrings.lua`, for the reputation-requirement line (decision 2045).
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
// The same table, for the `$`-engine's own keyed tokens — see [`spell_desc_text`].
|
||||
text: &dyn Fn(&str, &[i64]) -> Option<String>,
|
||||
) -> benilla_ui::script::ItemTemplateView {
|
||||
let spell_name = |id: u32| -> Option<String> {
|
||||
spells
|
||||
.and_then(|s| s.catalog.get(id))
|
||||
.map(|sd| sd.name.clone())
|
||||
};
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, home_area);
|
||||
let spell_text = |id: u32| spell_desc_text(spells, id, home_area, text);
|
||||
benilla_ui::script::ItemTemplateView {
|
||||
name: t.name.clone(),
|
||||
quality: t.quality,
|
||||
@@ -197,11 +201,21 @@ fn template_view(
|
||||
.flatten(),
|
||||
required_honor_rank: t.required_honor_rank,
|
||||
required_city_rank: t.required_city_rank,
|
||||
// `ITEM_REQ_REPUTATION` ("%s - %s" after its own "Requires ") — `0x854b8c`, the key the
|
||||
// reference's own item tooltip builds this line with (wow-re
|
||||
// `ui/scratch/tooltip-content-law.md` §"required-spell / honor-rank"). Both holes are
|
||||
// filled in the template's order; an install missing either key shows no line.
|
||||
required_rep_line: (t.required_rep_faction != 0)
|
||||
.then(|| {
|
||||
factions
|
||||
.and_then(|c| c.faction_name(t.required_rep_faction))
|
||||
.map(|f| format!("Requires {f} - {}", standing_label(t.required_rep_rank)))
|
||||
let faction = factions.and_then(|c| c.faction_name(t.required_rep_faction))?;
|
||||
let standing = standing_label(t.required_rep_rank, get)?;
|
||||
Some(benilla_ui::strings::fill(
|
||||
&get("ITEM_REQ_REPUTATION")?,
|
||||
&[
|
||||
benilla_ui::strings::Arg::S(faction),
|
||||
benilla_ui::strings::Arg::S(&standing),
|
||||
],
|
||||
))
|
||||
})
|
||||
.flatten(),
|
||||
required_rep_faction: t.required_rep_faction,
|
||||
@@ -257,6 +271,9 @@ pub(super) fn feed_item_sets(
|
||||
let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog);
|
||||
let mut done: Vec<u32> = Vec::new();
|
||||
let mut push: Vec<(u32, benilla_ui::script::ItemSetView)> = Vec::new();
|
||||
// Scoped, so the push below can take the VM mutably: the build reads the string table, the
|
||||
// push writes the store, and the two cannot hold it at once.
|
||||
let token_text = crate::ui_script::token_text(&script);
|
||||
for (&set_id, last) in pending.iter_mut() {
|
||||
let Some(row) = sets.0.set(set_id) else {
|
||||
done.push(set_id); // no such row — drop the ask for good
|
||||
@@ -273,7 +290,7 @@ pub(super) fn feed_item_sets(
|
||||
.bonuses
|
||||
.iter()
|
||||
.filter_map(|&(n, spell)| {
|
||||
spell_desc_text(spell_res, spell, None).map(|text| (n, text))
|
||||
spell_desc_text(spell_res, spell, None, &token_text).map(|desc| (n, desc))
|
||||
})
|
||||
.collect(),
|
||||
required_skill: row.required_skill,
|
||||
@@ -295,6 +312,7 @@ pub(super) fn feed_item_sets(
|
||||
push.push((set_id, view));
|
||||
}
|
||||
}
|
||||
drop(token_text);
|
||||
for (id, view) in push {
|
||||
script.set_item_set(id, view);
|
||||
}
|
||||
@@ -401,21 +419,41 @@ pub(super) fn feed_item_stats(
|
||||
.copied()
|
||||
.filter(|&id| items.template(id, 0, &commands).is_some())
|
||||
.collect();
|
||||
for id in ready {
|
||||
pending.remove(&id);
|
||||
let Some(t) = items.template(id, 0, &commands) else {
|
||||
continue;
|
||||
// Built first, pushed after: the reference-string lookup borrows the VM and `set_item_template`
|
||||
// needs it mutably, so the resolve and the push cannot interleave.
|
||||
let views: Vec<(u32, benilla_ui::script::ItemTemplateView)> = {
|
||||
let get = |key: &str| {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
let view = template_view(
|
||||
&t.clone(),
|
||||
spell_res,
|
||||
skill_catalog,
|
||||
home_area.as_deref(),
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
sub_classes.as_deref().map(|s| &s.0),
|
||||
classes.as_deref().map(|c| &c.0),
|
||||
icons.as_deref(),
|
||||
);
|
||||
ready
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
pending.remove(&id);
|
||||
let t = items.template(id, 0, &commands)?.clone();
|
||||
Some((
|
||||
id,
|
||||
template_view(
|
||||
&t,
|
||||
spell_res,
|
||||
skill_catalog,
|
||||
home_area.as_deref(),
|
||||
factions.as_deref().map(|f| f.catalog()),
|
||||
sub_classes.as_deref().map(|s| &s.0),
|
||||
classes.as_deref().map(|c| &c.0),
|
||||
icons.as_deref(),
|
||||
&get,
|
||||
&crate::ui_script::token_text(&script),
|
||||
),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (id, view) in views {
|
||||
script.set_item_template(id, view);
|
||||
}
|
||||
}
|
||||
@@ -1765,6 +1803,12 @@ mod tests {
|
||||
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
|
||||
};
|
||||
|
||||
// No string table: what this test is about is whether a line is built at all, and
|
||||
// Fireball's description reaches no keyed token — a `$s1` spread renders from the spell's
|
||||
// own columns. A fixture that pretended to hold GlobalStrings would be claiming coverage
|
||||
// this assertion does not have.
|
||||
let no_strings = |_: &str, _: &[i64]| None;
|
||||
|
||||
// Every "Opening"/"Closing" the lock chain can reach — the key spells (3365/3366/6247/6477
|
||||
// are all literally named "Opening") carry no description, so NO Use: line may be built.
|
||||
for id in [3365u32, 3366, 6246, 6247, 6477, 21651] {
|
||||
@@ -1774,7 +1818,7 @@ mod tests {
|
||||
"spell {id} has a name — which is exactly what must NOT leak into the tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
super::spell_desc_text(Some(&spells), id, None),
|
||||
super::spell_desc_text(Some(&spells), id, None, &no_strings),
|
||||
None,
|
||||
"spell {id} ({:?}) has no description, so the reference prints no trigger line",
|
||||
d.name
|
||||
@@ -1783,7 +1827,7 @@ mod tests {
|
||||
|
||||
// The control: a described spell still produces its line, so this is a law about EMPTY
|
||||
// descriptions and not a blanket mute. Fireball (133) is the tooltip suite's own anchor.
|
||||
let fireball = super::spell_desc_text(Some(&spells), 133, None)
|
||||
let fireball = super::spell_desc_text(Some(&spells), 133, None, &no_strings)
|
||||
.expect("a described spell still yields its line");
|
||||
assert!(
|
||||
fireball.contains("damage"),
|
||||
|
||||
@@ -1319,7 +1319,7 @@ fn loot_method_id(method: &str) -> Option<u32> {
|
||||
pub(crate) fn synthetic_roster(
|
||||
group: &mut GroupState,
|
||||
player_xy: Option<(f32, f32)>,
|
||||
) -> Vec<String> {
|
||||
) -> Vec<crate::ui_action::UiError> {
|
||||
let members = vec![
|
||||
GroupMemberEntry {
|
||||
name: "Alice".into(),
|
||||
@@ -1407,7 +1407,7 @@ pub(crate) fn synthetic_raid(
|
||||
group: &mut GroupState,
|
||||
names: &mut NameCache,
|
||||
self_guid: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
) -> Vec<crate::ui_action::UiError> {
|
||||
// (name, class id, race id) — `benilla-formats`' own `ChrClasses`/`ChrRaces` ids, the pair
|
||||
// `NameCache::player_traits` hands `ui_unit::class_names`. Eight classes so every colour in
|
||||
// `RAID_CLASS_COLORS` shows; the races are Alliance-side and cosmetic here.
|
||||
|
||||
@@ -124,27 +124,6 @@ pub struct GroupState {
|
||||
pub ready_check_answers: Vec<(u64, bool)>,
|
||||
}
|
||||
|
||||
// The GlobalStrings templates, quoted verbatim from the reference client's own patch chain
|
||||
// (decision 0246 extraction; `GlobalStrings.lua` line numbers cited per constant).
|
||||
const JOINED_PARTY: &str = "%s joins the party."; // ERR_JOINED_GROUP_S (GlobalStrings:1665)
|
||||
const LEFT_PARTY: &str = "%s leaves the party."; // ERR_LEFT_GROUP_S (GlobalStrings:1670)
|
||||
const GROUP_DISBANDED: &str = "Your group has been disbanded."; // ERR_GROUP_DISBANDED (GlobalStrings:1582)
|
||||
const UNINVITE_YOU: &str = "You have been removed from the group."; // ERR_UNINVITE_YOU (GlobalStrings:1904)
|
||||
const INVITED_TO_GROUP: &str = "%s has invited you to join a group."; // ERR_INVITED_TO_GROUP_S (GlobalStrings:1654)
|
||||
const DECLINE_GROUP: &str = "%s declines your group invitation."; // ERR_DECLINE_GROUP_S (GlobalStrings:1545)
|
||||
const NEW_LEADER: &str = "%s is now the group leader."; // ERR_NEW_LEADER_S (GlobalStrings:1724)
|
||||
const NEW_LEADER_YOU: &str = "You are now the group leader."; // ERR_NEW_LEADER_YOU (GlobalStrings:1725)
|
||||
const RAID_MEMBER_ADDED: &str = "%s has joined the raid group"; // ERR_RAID_MEMBER_ADDED_S (GlobalStrings:1824)
|
||||
const RAID_MEMBER_REMOVED: &str = "%s has left the raid group"; // ERR_RAID_MEMBER_REMOVED_S (GlobalStrings:1825)
|
||||
const RAID_YOU_JOINED: &str = "You have joined a raid group"; // ERR_RAID_YOU_JOINED (GlobalStrings:1826)
|
||||
const READY_CHECK_START_S: &str = "%s has initiated a ready check"; // ERR_RAID_LEADER_READY_CHECK_START_S (GlobalStrings:1823)
|
||||
|
||||
/// `format!`-free "%s" substitution — the templates are quoted verbatim from GlobalStrings, so
|
||||
/// they carry printf placeholders, not Rust ones.
|
||||
fn fmt_s(template: &str, arg: &str) -> String {
|
||||
template.replacen("%s", arg, 1)
|
||||
}
|
||||
|
||||
impl GroupState {
|
||||
/// Apply one `SMSG_GROUP_LIST` and compose the system lines its roster diff implies. The
|
||||
/// diff is **ungated** (the 0440 byte law, `0x5e6c19`/`0x5e6d37`): the cache starts empty,
|
||||
@@ -158,7 +137,7 @@ impl GroupState {
|
||||
members: Vec<GroupMemberEntry>,
|
||||
leader: u64,
|
||||
loot: Option<GroupLootInfo>,
|
||||
) -> Vec<String> {
|
||||
) -> Vec<UiError> {
|
||||
let mut lines = Vec::new();
|
||||
// The all-zero shape is "you are no longer in a group" (vmangos sends a default packet,
|
||||
// `Group.h:257`); `leader == 0` is its reliable discriminator (a live group always names
|
||||
@@ -167,19 +146,19 @@ impl GroupState {
|
||||
// Raid wording keys off whichever side of the transition was a raid. INTERIM (0440):
|
||||
// the §5 pinned the party line's `groupType==0` gate; the raid twin's exact trigger
|
||||
// wasn't walked.
|
||||
let (added_tpl, removed_tpl) = if group_type == 1 || (leaving && self.group_type == 1) {
|
||||
(RAID_MEMBER_ADDED, RAID_MEMBER_REMOVED)
|
||||
let (added, removed) = if group_type == 1 || (leaving && self.group_type == 1) {
|
||||
("ERR_RAID_MEMBER_ADDED_S", "ERR_RAID_MEMBER_REMOVED_S")
|
||||
} else {
|
||||
(JOINED_PARTY, LEFT_PARTY)
|
||||
("ERR_JOINED_GROUP_S", "ERR_LEFT_GROUP_S")
|
||||
};
|
||||
for m in &members {
|
||||
if !self.members.iter().any(|old| old.guid == m.guid) {
|
||||
lines.push(fmt_s(added_tpl, &m.name));
|
||||
lines.push(UiError::s(added, &m.name));
|
||||
}
|
||||
}
|
||||
for old in &self.members {
|
||||
if !members.iter().any(|m| m.guid == old.guid) {
|
||||
lines.push(fmt_s(removed_tpl, &old.name));
|
||||
lines.push(UiError::s(removed, &old.name));
|
||||
}
|
||||
}
|
||||
if leaving {
|
||||
@@ -189,7 +168,7 @@ impl GroupState {
|
||||
// Our own party→raid conversion (or joining straight into a raid). INTERIM like the
|
||||
// wording pick above.
|
||||
if group_type == 1 && self.group_type != 1 {
|
||||
lines.push(RAID_YOU_JOINED.to_string());
|
||||
lines.push(UiError::key("ERR_RAID_YOU_JOINED"));
|
||||
}
|
||||
// The OTHER direction clears the raid-target board. `0x4ba550` — sole caller `0x5e6ebb`,
|
||||
// inside `SMSG_GROUP_LIST 0x7d`'s raid-flag-CLEAR leg — `rep stosd`-zeroes all eight slots
|
||||
@@ -236,28 +215,28 @@ impl GroupState {
|
||||
/// `SMSG_GROUP_INVITE` — someone asked us in. Arms the pending invite (the popup rides it)
|
||||
/// and composes the chat-side notice line (CONFIRMED 0440: the real handler `0x5e6730`
|
||||
/// fires ERR_INVITED_TO_GROUP_S in addition to the popup's event).
|
||||
pub fn apply_invited(&mut self, inviter: &str) -> Vec<String> {
|
||||
pub fn apply_invited(&mut self, inviter: &str) -> Vec<UiError> {
|
||||
self.pending_invite = Some(inviter.to_string());
|
||||
vec![fmt_s(INVITED_TO_GROUP, inviter)]
|
||||
vec![UiError::s("ERR_INVITED_TO_GROUP_S", inviter)]
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_DECLINE` — our invitee said no (sent to the inviter only).
|
||||
pub fn apply_declined(&mut self, name: &str) -> Vec<String> {
|
||||
vec![fmt_s(DECLINE_GROUP, name)]
|
||||
pub fn apply_declined(&mut self, name: &str) -> Vec<UiError> {
|
||||
vec![UiError::s("ERR_DECLINE_GROUP_S", name)]
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_UNINVITE` (empty body) — we were kicked. Prints ERR_UNINVITE_YOU
|
||||
/// **unconditionally**, at the opcode (0440: handler `0x5e6850`); the empty roster echo
|
||||
/// that follows adds its own leave-diff lines, exactly like the reference.
|
||||
pub fn apply_uninvited(&mut self) -> Vec<String> {
|
||||
vec![UNINVITE_YOU.to_string()]
|
||||
pub fn apply_uninvited(&mut self) -> Vec<UiError> {
|
||||
vec![UiError::key("ERR_UNINVITE_YOU")]
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_DESTROYED` — the group is gone outright. Gated on believing we're grouped
|
||||
/// (0440: `0x5e6880` tests `0x4e86d0() != 0`); the echo list's own lines still follow.
|
||||
pub fn apply_destroyed(&mut self) -> Vec<String> {
|
||||
pub fn apply_destroyed(&mut self) -> Vec<UiError> {
|
||||
if self.in_group {
|
||||
vec![GROUP_DISBANDED.to_string()]
|
||||
vec![UiError::key("ERR_GROUP_DISBANDED")]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -265,11 +244,11 @@ impl GroupState {
|
||||
|
||||
/// `SMSG_GROUP_SET_LEADER` — the broadcast leader change, by name (the guid is already in
|
||||
/// our roster; vmangos re-sends the full list right after, `Group::ChangeLeader`).
|
||||
pub fn apply_leader_changed(&mut self, name: &str, own_name: Option<&str>) -> Vec<String> {
|
||||
pub fn apply_leader_changed(&mut self, name: &str, own_name: Option<&str>) -> Vec<UiError> {
|
||||
if own_name == Some(name) {
|
||||
vec![NEW_LEADER_YOU.to_string()]
|
||||
vec![UiError::key("ERR_NEW_LEADER_YOU")]
|
||||
} else {
|
||||
vec![fmt_s(NEW_LEADER, name)]
|
||||
vec![UiError::s("ERR_NEW_LEADER_S", name)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +416,7 @@ impl GroupState {
|
||||
/// nor pops; everyone **else** prints `ERR_RAID_LEADER_READY_CHECK_START_S` with the leader's
|
||||
/// name and gets the popup — the ticket the feed turns into a `READY_CHECK` event edge. Both
|
||||
/// arms bump the request generation and start a fresh answer log.
|
||||
pub fn apply_ready_check_request(&mut self, we_lead: bool) -> Vec<String> {
|
||||
pub fn apply_ready_check_request(&mut self, we_lead: bool) -> Vec<UiError> {
|
||||
self.ready_check_requests = self.ready_check_requests.wrapping_add(1);
|
||||
self.ready_check_answers.clear();
|
||||
if we_lead {
|
||||
@@ -452,7 +431,7 @@ impl GroupState {
|
||||
.find(|m| m.guid == self.leader)
|
||||
.map(|m| m.name.as_str())
|
||||
.unwrap_or("");
|
||||
vec![fmt_s(READY_CHECK_START_S, leader)]
|
||||
vec![UiError::s("ERR_RAID_LEADER_READY_CHECK_START_S", leader)]
|
||||
}
|
||||
|
||||
/// `MSG_RAID_READY_CHECK` (answer form) — one member's answer, which the server forwards to
|
||||
@@ -526,7 +505,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
g.apply_ready_check_request(false),
|
||||
vec!["Alice has initiated a ready check".to_string()]
|
||||
vec![UiError::s("ERR_RAID_LEADER_READY_CHECK_START_S", "Alice")]
|
||||
);
|
||||
assert_eq!((g.ready_check, g.ready_check_requests), (1, 1));
|
||||
assert!(
|
||||
@@ -555,18 +534,18 @@ mod tests {
|
||||
// (the real client's cache starts empty, 0x5e6c19).
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None),
|
||||
vec!["Alice joins the party."]
|
||||
vec![UiError::s("ERR_JOINED_GROUP_S", "Alice")]
|
||||
);
|
||||
assert!(g.in_group);
|
||||
// Carol joins.
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, vec![member("Alice", 1), member("Carol", 3)], 1, None),
|
||||
vec!["Carol joins the party."]
|
||||
vec![UiError::s("ERR_JOINED_GROUP_S", "Carol")]
|
||||
);
|
||||
// Carol leaves (3→2).
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None),
|
||||
vec!["Carol leaves the party."]
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Carol")]
|
||||
);
|
||||
// An unchanged resync (loot/status churn re-sends the list) prints nothing.
|
||||
assert!(g
|
||||
@@ -586,29 +565,29 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, Vec::new(), 0, None),
|
||||
vec!["Alice leaves the party."]
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Alice")]
|
||||
);
|
||||
assert!(!g.in_group);
|
||||
|
||||
// Kicked: SMSG_GROUP_UNINVITE prints unconditionally at the opcode.
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(
|
||||
g.apply_uninvited(),
|
||||
vec!["You have been removed from the group."]
|
||||
);
|
||||
assert_eq!(g.apply_uninvited(), vec![UiError::key("ERR_UNINVITE_YOU")]);
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, Vec::new(), 0, None),
|
||||
vec!["Alice leaves the party."]
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Alice")]
|
||||
);
|
||||
|
||||
// Destroyed: GROUP_DESTROYED prints (grouped), the echo list still runs its diff.
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(g.apply_destroyed(), vec!["Your group has been disbanded."]);
|
||||
assert_eq!(
|
||||
g.apply_destroyed(),
|
||||
vec![UiError::key("ERR_GROUP_DISBANDED")]
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, Vec::new(), 0, None),
|
||||
vec!["Alice leaves the party."]
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Alice")]
|
||||
);
|
||||
// Ungrouped, GROUP_DESTROYED is silent (the 0x4e86d0 gate).
|
||||
assert!(g.apply_destroyed().is_empty());
|
||||
@@ -618,7 +597,7 @@ mod tests {
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, Vec::new(), 0, None),
|
||||
vec!["Alice leaves the party."]
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Alice")]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -630,15 +609,18 @@ mod tests {
|
||||
// Party → raid conversion: same roster, type flips.
|
||||
assert_eq!(
|
||||
g.apply_list(1, 0, vec![member("Alice", 1)], 1, None),
|
||||
vec!["You have joined a raid group"]
|
||||
vec![UiError::key("ERR_RAID_YOU_JOINED")]
|
||||
);
|
||||
// A raid join/leave uses the raid strings.
|
||||
assert_eq!(
|
||||
g.apply_list(1, 0, vec![member("Alice", 1), member("Dave", 4)], 1, None),
|
||||
vec!["Dave has joined the raid group"]
|
||||
vec![UiError::s("ERR_RAID_MEMBER_ADDED_S", "Dave")]
|
||||
);
|
||||
let mut lines = g.apply_list(1, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(lines.pop().as_deref(), Some("Dave has left the raid group"));
|
||||
assert_eq!(
|
||||
lines.pop(),
|
||||
Some(UiError::s("ERR_RAID_MEMBER_REMOVED_S", "Dave"))
|
||||
);
|
||||
// Leaving the raid: the ack prints the LEAVE line; the empty list diffs with raid
|
||||
// wording (keyed off the departed group's type).
|
||||
assert_eq!(
|
||||
@@ -647,7 +629,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, Vec::new(), 0, None),
|
||||
vec!["Alice has left the raid group"]
|
||||
vec![UiError::s("ERR_RAID_MEMBER_REMOVED_S", "Alice")]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,7 +652,7 @@ mod tests {
|
||||
let mut g = GroupState::default();
|
||||
assert_eq!(
|
||||
g.apply_invited("Bob"),
|
||||
vec!["Bob has invited you to join a group."]
|
||||
vec![UiError::s("ERR_INVITED_TO_GROUP_S", "Bob")]
|
||||
);
|
||||
assert_eq!(g.pending_invite.as_deref(), Some("Bob"));
|
||||
assert_eq!(
|
||||
@@ -691,7 +673,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_declined("Carol"),
|
||||
vec!["Carol declines your group invitation."]
|
||||
vec![UiError::s("ERR_DECLINE_GROUP_S", "Carol")]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -701,11 +683,11 @@ mod tests {
|
||||
let mut g = GroupState::default();
|
||||
assert_eq!(
|
||||
g.apply_leader_changed("Alice", Some("Benilla")),
|
||||
vec!["Alice is now the group leader."]
|
||||
vec![UiError::s("ERR_NEW_LEADER_S", "Alice")]
|
||||
);
|
||||
assert_eq!(
|
||||
g.apply_leader_changed("Benilla", Some("Benilla")),
|
||||
vec!["You are now the group leader."]
|
||||
vec![UiError::key("ERR_NEW_LEADER_YOU")]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -955,4 +937,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The twelve roster/invite/leader lines, by key** — the family that used to be twelve
|
||||
/// re-typed sentences beside twelve key names in comments, where nothing joined the two
|
||||
/// (decision 2045). The same three checks the command-result family gets: the key resolves in
|
||||
/// the shipped file, it is a catalog row (so its surface and sound are read rather than
|
||||
/// guessed), and an `_S` row's hole matches the fill that reaches it.
|
||||
///
|
||||
/// **What the old chat-log path could not do**, and this one does: `ERR_DECLINE_GROUP_S`'s row
|
||||
/// names the `igPlayerInviteDecline` cue. Pushing the composed sentence straight into the chat
|
||||
/// log threw the row away and with it the sound — silently, since a missing cue looks exactly
|
||||
/// like a cue that has not been wired.
|
||||
#[test]
|
||||
fn the_roster_lines_resolve_and_carry_their_catalog_row() {
|
||||
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 s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
|
||||
let mut g = GroupState::default();
|
||||
let mut lines = Vec::new();
|
||||
// Every producer in the file, DRIVEN rather than listed — a party join and leave, then a
|
||||
// raid conversion with its own join and leave, then the per-opcode lines. Listing the keys
|
||||
// would let a new line skip this test; driving the producers cannot.
|
||||
lines.extend(g.apply_list(0, 0, vec![member("Alice", 1), member("Bob", 2)], 1, None));
|
||||
lines.extend(g.apply_list(0, 0, vec![member("Alice", 1)], 1, None));
|
||||
lines.extend(g.apply_list(1, 0, vec![member("Alice", 1), member("Dave", 4)], 1, None));
|
||||
lines.extend(g.apply_list(1, 0, vec![member("Alice", 1)], 1, None));
|
||||
lines.extend(g.apply_invited("Bob"));
|
||||
lines.extend(g.apply_declined("Carol"));
|
||||
lines.extend(g.apply_leader_changed("Alice", Some("Us")));
|
||||
lines.extend(g.apply_leader_changed("Us", Some("Us")));
|
||||
lines.extend(g.apply_uninvited());
|
||||
lines.extend(g.apply_destroyed());
|
||||
g.leader = 1;
|
||||
lines.extend(g.apply_ready_check_request(false));
|
||||
|
||||
let mut keys: Vec<&str> = lines.iter().map(|m| m.key).collect();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"ERR_DECLINE_GROUP_S",
|
||||
"ERR_GROUP_DISBANDED",
|
||||
"ERR_INVITED_TO_GROUP_S",
|
||||
"ERR_JOINED_GROUP_S",
|
||||
"ERR_LEFT_GROUP_S",
|
||||
"ERR_NEW_LEADER_S",
|
||||
"ERR_NEW_LEADER_YOU",
|
||||
"ERR_RAID_LEADER_READY_CHECK_START_S",
|
||||
"ERR_RAID_MEMBER_ADDED_S",
|
||||
"ERR_RAID_MEMBER_REMOVED_S",
|
||||
"ERR_RAID_YOU_JOINED",
|
||||
"ERR_UNINVITE_YOU",
|
||||
],
|
||||
"all twelve keys this window can raise are exercised below"
|
||||
);
|
||||
|
||||
for msg in &lines {
|
||||
let text: String = s.lua().globals().get(msg.key).expect(msg.key);
|
||||
assert!(!text.is_empty(), "{} resolves empty", msg.key);
|
||||
assert!(
|
||||
benilla_ui::messages::by_key(msg.key).is_some(),
|
||||
"{} is not a catalog row, so its surface and sound would be a guess",
|
||||
msg.key
|
||||
);
|
||||
assert_eq!(
|
||||
text.contains("%s"),
|
||||
msg.arg_s().is_some(),
|
||||
"{} vs its fill",
|
||||
msg.key
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
benilla_ui::messages::by_key("ERR_DECLINE_GROUP_S").and_then(|r| r.sound),
|
||||
Some("igPlayerInviteDecline"),
|
||||
"the cue the chat-log path was dropping"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,17 +75,22 @@ pub(super) fn feed_petition(
|
||||
};
|
||||
let fed = fed.get(&script);
|
||||
|
||||
// The composed lines, each onto the surface its message record names. Drained before the
|
||||
// snapshot so a refusal shows in the same frame as the state change that caused it.
|
||||
// The composed lines, each resolved against the VM's own `GlobalStrings.lua` and put on the
|
||||
// surface its message record names. Drained before the snapshot so a refusal shows in the
|
||||
// same frame as the state change that caused it. A key the string table has no entry for
|
||||
// shows nothing — the reference's data-suppression face, and the reason every line upstream
|
||||
// carries a key rather than a sentence (decision 2045).
|
||||
let composed = std::mem::take(&mut petition.lines);
|
||||
crate::ui_action::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"ui_petition",
|
||||
composed
|
||||
.into_iter()
|
||||
.map(|(key, text)| crate::ui_action::Shown::keyed(key, text)),
|
||||
);
|
||||
let resolved: Vec<crate::ui_action::Shown> = composed
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
crate::ui_action::ui_error_text(e, &|key| {
|
||||
script.lua().globals().get::<String>(key).ok()
|
||||
})
|
||||
.map(|text| crate::ui_action::Shown::keyed(e.key, text))
|
||||
})
|
||||
.collect();
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_petition", resolved);
|
||||
|
||||
// **Rebuilt every frame, deliberately.** Half of what this window shows arrives from a CACHE,
|
||||
// not a packet, and reading that cache is what ISSUES its query (decision 0660's law). So a
|
||||
|
||||
@@ -36,94 +36,24 @@
|
||||
|
||||
use benilla_protocol::messages::petition_result;
|
||||
|
||||
/// One message this module composes: the **catalog key**, which names both the row and — through
|
||||
/// [`benilla_ui::messages`] — the surface it goes to, and the 1.12 text.
|
||||
use crate::ui_action::UiError;
|
||||
|
||||
/// One composed line: a **catalog key** and the arguments the reference pushes with it, resolved
|
||||
/// at the sink against the player's own `GlobalStrings.lua` (decision 2045).
|
||||
///
|
||||
/// The key rides beside the text rather than the surface doing so, because the surface is not this
|
||||
/// module's to know: it is the message record's `+0x04`, and reading it here from the catalog is
|
||||
/// what retired the hand-swept table this file used to carry (decision 1770). The five
|
||||
/// `ERR_GUILD_NAME_*` rows below were recorded as **INFERRED** for exactly that reason; the sweep
|
||||
/// confirms all five are kind 2, so the inference is now a lookup.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Msg {
|
||||
key: &'static str,
|
||||
text: &'static str,
|
||||
}
|
||||
|
||||
const fn msg(key: &'static str, text: &'static str) -> Msg {
|
||||
Msg { key, text }
|
||||
}
|
||||
|
||||
// ── The templates, quoted verbatim from the reference's own patch chain; the `GlobalStrings.lua`
|
||||
// line is on each ──────────────────────────────────────────────────────────────────────────
|
||||
const ERR_PETITION_ALREADY_SIGNED: Msg = msg(
|
||||
"ERR_PETITION_ALREADY_SIGNED",
|
||||
"You have already signed that guild charter.",
|
||||
); // :1762
|
||||
const ERR_PETITION_CREATOR: Msg = msg(
|
||||
"ERR_PETITION_CREATOR",
|
||||
"You can't sign your own guild charter.",
|
||||
); // :1763
|
||||
const ERR_PETITION_DECLINED_S: Msg = msg(
|
||||
"ERR_PETITION_DECLINED_S",
|
||||
"%s has declined your guild invitation.",
|
||||
); // :1764
|
||||
const ERR_PETITION_IN_GUILD: Msg = msg("ERR_PETITION_IN_GUILD", "You are already in a guild."); // :1765
|
||||
const ERR_PETITION_NOT_ENOUGH_SIGNATURES: Msg = msg(
|
||||
"ERR_PETITION_NOT_ENOUGH_SIGNATURES",
|
||||
"You need more signatures.",
|
||||
); // :1766
|
||||
const ERR_PETITION_NOT_SAME_SERVER: Msg = msg(
|
||||
"ERR_PETITION_NOT_SAME_SERVER",
|
||||
"That player is not from your server",
|
||||
); // :1767
|
||||
const ERR_PETITION_OFFERED_S: Msg = msg(
|
||||
"ERR_PETITION_OFFERED_S",
|
||||
"You have requested %s's signature.",
|
||||
); // :1768
|
||||
const ERR_PETITION_SIGNED: Msg = msg("ERR_PETITION_SIGNED", "Guild charter signed."); // :1769
|
||||
const ERR_PETITION_SIGNED_S: Msg =
|
||||
msg("ERR_PETITION_SIGNED_S", "%s has signed your guild charter."); // :1770
|
||||
const ERR_NO_GUILD_CHARTER: Msg = msg("ERR_NO_GUILD_CHARTER", "You don't have a guild charter."); // :1745
|
||||
|
||||
// The guild-name validator's messages (`0x4f5160`'s seven arms). Only the three
|
||||
// `benilla_ui::script::validate_guild_name` implements can be produced; the rest are here so the
|
||||
// key→text map is complete and a later carve of `0x6c9b70` only has to add the branch.
|
||||
const ERR_GUILD_ENTER_NAME: Msg = msg("ERR_GUILD_ENTER_NAME", "Enter a name for your guild."); // :1603
|
||||
const ERR_GUILD_NAME_INVALID: Msg = msg(
|
||||
"ERR_GUILD_NAME_INVALID",
|
||||
"That name contains invalid characters, Enter a new name.",
|
||||
); // :1616
|
||||
const ERR_GUILD_NAME_INVALID_SPACE: Msg = msg(
|
||||
"ERR_GUILD_NAME_INVALID_SPACE",
|
||||
"Guild names cannot start or end with a space. Enter a new name.",
|
||||
); // :1617
|
||||
const ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES: Msg = msg(
|
||||
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES",
|
||||
"Consecutive spaces are not allowed. Enter a new name.",
|
||||
); // :1619
|
||||
const ERR_GUILD_NAME_TOO_SHORT: Msg = msg(
|
||||
"ERR_GUILD_NAME_TOO_SHORT",
|
||||
"That name is too short. Enter a new name.",
|
||||
); // :1622
|
||||
|
||||
/// One composed line: the **message key** and the text.
|
||||
/// This file used to carry the key *and* a hand-typed copy of each sentence, and the copy was the
|
||||
/// part that could rot: three of the five validator lines shipped as **paraphrases** — "Names may
|
||||
/// not begin or end with a space." where the client says "Guild names cannot start or end with a
|
||||
/// space. Enter a new name." — because they were written from the key name rather than read from
|
||||
/// the file. A key cannot paraphrase. The oracle test that caught those is gone with the text it
|
||||
/// graded; what replaces it asserts that every key here IS a catalog row and that an `_S` key's
|
||||
/// arity matches the fill, which is the thing text comparison could never check.
|
||||
///
|
||||
/// It carries the key rather than the resolved [`MsgKind`] because the record answers more than
|
||||
/// where the line goes — `ERR_PETITION_SIGNED` and its neighbours name a sound cue too
|
||||
/// (`igPlayerInviteAccept`/`Decline`), and `crate::ui_action::Shown` reads all of it from the one
|
||||
/// row at the sink (decision 1815).
|
||||
pub(super) type Line = (&'static str, String);
|
||||
|
||||
/// A line with nothing to fill.
|
||||
fn line(m: Msg) -> Line {
|
||||
(m.key, m.text.to_string())
|
||||
}
|
||||
|
||||
/// A line with one `%s` substitution.
|
||||
fn fill(m: Msg, arg: &str) -> Line {
|
||||
(m.key, m.text.replacen("%s", arg, 1))
|
||||
}
|
||||
/// The key rides rather than a resolved [`benilla_ui::messages::MsgKind`] because the record
|
||||
/// answers more than where the line goes — `ERR_PETITION_SIGNED` and its neighbours name a sound
|
||||
/// cue too (`igPlayerInviteAccept`/`Decline`), and `crate::ui_action::Shown` reads all of it from
|
||||
/// the one row at the sink (decision 1815).
|
||||
pub(super) type Line = UiError;
|
||||
|
||||
/// The line one `SMSG_PETITION_SIGN_RESULTS` prints **when the signer is us** — the switch at
|
||||
/// `0x5eeff5`'s "I signed" leg.
|
||||
@@ -133,58 +63,58 @@ fn fill(m: Msg, arg: &str) -> Line {
|
||||
///
|
||||
/// `NEED_MORE` and anything `>= 6` return `None` — the default arm reaches only the debug console.
|
||||
pub(super) fn my_sign_line(result: u32) -> Option<Line> {
|
||||
Some(match result {
|
||||
petition_result::OK => line(ERR_PETITION_SIGNED),
|
||||
petition_result::ALREADY_SIGNED => line(ERR_PETITION_ALREADY_SIGNED),
|
||||
petition_result::ALREADY_IN_GUILD => line(ERR_PETITION_IN_GUILD),
|
||||
petition_result::CANT_SIGN_OWN => line(ERR_PETITION_CREATOR),
|
||||
petition_result::NOT_SERVER => line(ERR_PETITION_NOT_SAME_SERVER),
|
||||
Some(UiError::key(match result {
|
||||
petition_result::OK => "ERR_PETITION_SIGNED",
|
||||
petition_result::ALREADY_SIGNED => "ERR_PETITION_ALREADY_SIGNED",
|
||||
petition_result::ALREADY_IN_GUILD => "ERR_PETITION_IN_GUILD",
|
||||
petition_result::CANT_SIGN_OWN => "ERR_PETITION_CREATOR",
|
||||
petition_result::NOT_SERVER => "ERR_PETITION_NOT_SAME_SERVER",
|
||||
// `NEED_MORE` (4) and >= 6: the default arm, debug console only.
|
||||
_ => return None,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// The line the **owner** gets when somebody else signs — `ERR_PETITION_SIGNED_S`, a chat line, and
|
||||
/// emitted **only when the signer's name is already cached** (`0x4f42f6`); on a cache miss the
|
||||
/// client increments its pending-name counter instead and says nothing until the name lands.
|
||||
pub(super) fn signed_by_other(name: &str) -> Line {
|
||||
fill(ERR_PETITION_SIGNED_S, name)
|
||||
UiError::s("ERR_PETITION_SIGNED_S", name)
|
||||
}
|
||||
|
||||
/// The line one `SMSG_TURN_IN_PETITION_RESULTS` prints. **Success prints nothing** — see the module
|
||||
/// doc; it is not an omission here.
|
||||
pub(super) fn turn_in_line(result: u32) -> Option<Line> {
|
||||
Some(match result {
|
||||
petition_result::ALREADY_IN_GUILD => line(ERR_PETITION_IN_GUILD),
|
||||
petition_result::NEED_MORE => line(ERR_PETITION_NOT_ENOUGH_SIGNATURES),
|
||||
Some(UiError::key(match result {
|
||||
petition_result::ALREADY_IN_GUILD => "ERR_PETITION_IN_GUILD",
|
||||
petition_result::NEED_MORE => "ERR_PETITION_NOT_ENOUGH_SIGNATURES",
|
||||
_ => return None,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// The line an inbound `MSG_PETITION_DECLINE` prints to the charter's owner — a chat line, and
|
||||
/// **only if the declining player's name is already cached**. There is no query and no retry: an
|
||||
/// uncached name means the owner is told nothing at all (`0x5ef12a`/`0x5ef139`).
|
||||
pub(super) fn declined_line(name: &str) -> Line {
|
||||
fill(ERR_PETITION_DECLINED_S, name)
|
||||
UiError::s("ERR_PETITION_DECLINED_S", name)
|
||||
}
|
||||
|
||||
/// The local echo for a charter we just offered — emitted **optimistically on the send**, with no
|
||||
/// server confirmation (`0x4f48fa`). Nothing comes back on success; the *target* is the one the
|
||||
/// server answers.
|
||||
pub(super) fn offered_line(name: &str) -> Line {
|
||||
fill(ERR_PETITION_OFFERED_S, name)
|
||||
UiError::s("ERR_PETITION_OFFERED_S", name)
|
||||
}
|
||||
|
||||
/// Offering a charter to yourself — guard 6 of `OfferPetition`'s eight (`0x4f4839`), which emits
|
||||
/// `ERR_PETITION_CREATOR`, the same red line signing your own charter gets.
|
||||
pub(super) fn self_offer_line() -> Line {
|
||||
line(ERR_PETITION_CREATOR)
|
||||
UiError::key("ERR_PETITION_CREATOR")
|
||||
}
|
||||
|
||||
/// The local refusal when `TurnInGuildCharter()`'s bag scan finds no charter — a **red** line
|
||||
/// (`0x5ef49a` emits id `0x7c`, kind 2), and no packet is built.
|
||||
pub(super) fn no_charter_line() -> Line {
|
||||
line(ERR_NO_GUILD_CHARTER)
|
||||
UiError::key("ERR_NO_GUILD_CHARTER")
|
||||
}
|
||||
|
||||
/// The line for a name the client's own validator refused
|
||||
@@ -197,12 +127,15 @@ pub(super) fn no_charter_line() -> Line {
|
||||
///
|
||||
/// An unknown key falls back to the generic invalid-name text rather than showing a raw key.
|
||||
pub(super) fn name_refused_line(key: &str) -> Line {
|
||||
line(match key {
|
||||
"ERR_GUILD_ENTER_NAME" => ERR_GUILD_ENTER_NAME,
|
||||
"ERR_GUILD_NAME_INVALID_SPACE" => ERR_GUILD_NAME_INVALID_SPACE,
|
||||
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES" => ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
|
||||
"ERR_GUILD_NAME_TOO_SHORT" => ERR_GUILD_NAME_TOO_SHORT,
|
||||
_ => ERR_GUILD_NAME_INVALID,
|
||||
UiError::key(match key {
|
||||
"ERR_GUILD_ENTER_NAME" => "ERR_GUILD_ENTER_NAME",
|
||||
"ERR_GUILD_NAME_INVALID_SPACE" => "ERR_GUILD_NAME_INVALID_SPACE",
|
||||
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES" => "ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES",
|
||||
"ERR_GUILD_NAME_TOO_SHORT" => "ERR_GUILD_NAME_TOO_SHORT",
|
||||
// The validator can name a key `0x6c9b70` has not been carved for. Re-keying it to the
|
||||
// generic invalid-name row is what keeps a raw key off the screen; the match above is
|
||||
// what makes that a re-key rather than a pass-through.
|
||||
_ => "ERR_GUILD_NAME_INVALID",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,64 +144,58 @@ mod tests {
|
||||
use super::*;
|
||||
use benilla_ui::messages::MsgKind;
|
||||
|
||||
/// A composed line resolved the way the sink resolves it — key → catalog row → surface. The
|
||||
/// assertions below are about *where a line goes*, and this is the join that answers it.
|
||||
fn shown(line: Line) -> (MsgKind, String) {
|
||||
(benilla_ui::messages::kind_of(line.0), line.1)
|
||||
/// A composed line's key and the surface its catalog row names — the join the sink makes.
|
||||
///
|
||||
/// **The key, not the sentence.** `ERR_PETITION_CREATOR` and `ERR_PETITION_ALREADY_SIGNED`
|
||||
/// say different things in English, but plenty of neighbours do not: asserting displayed text
|
||||
/// passes exactly where two rows agree in enUS and diverge in every other locale, which is why
|
||||
/// decision 2045 puts the assertion on the identifier.
|
||||
fn shown(line: Line) -> (&'static str, MsgKind) {
|
||||
(line.key, benilla_ui::messages::kind_of(line.key))
|
||||
}
|
||||
|
||||
/// **The channel split, which does not follow the key names.** Success to chat, refusal to the
|
||||
/// red frame — swept out of the message catalog, not guessed. This test is the one that would
|
||||
/// fail if somebody "tidied" the two into a single `String` return.
|
||||
/// red frame — swept out of the message catalog, not guessed.
|
||||
#[test]
|
||||
fn successes_go_to_chat_and_refusals_go_to_the_red_line() {
|
||||
assert_eq!(
|
||||
my_sign_line(petition_result::OK).map(shown),
|
||||
Some((MsgKind::Chat, "Guild charter signed.".into()))
|
||||
Some(("ERR_PETITION_SIGNED", MsgKind::Chat))
|
||||
);
|
||||
assert_eq!(
|
||||
shown(signed_by_other("Bob")),
|
||||
(MsgKind::Chat, "Bob has signed your guild charter.".into())
|
||||
("ERR_PETITION_SIGNED_S", MsgKind::Chat)
|
||||
);
|
||||
assert_eq!(
|
||||
shown(declined_line("Bob")),
|
||||
(
|
||||
MsgKind::Chat,
|
||||
"Bob has declined your guild invitation.".into()
|
||||
)
|
||||
("ERR_PETITION_DECLINED_S", MsgKind::Chat)
|
||||
);
|
||||
assert_eq!(
|
||||
shown(offered_line("Bob")),
|
||||
(MsgKind::Chat, "You have requested Bob's signature.".into())
|
||||
("ERR_PETITION_OFFERED_S", MsgKind::Chat)
|
||||
);
|
||||
// The name really does reach the fill — the half of a `_S` line that a key assertion
|
||||
// alone would not cover.
|
||||
assert_eq!(offered_line("Bob").arg_s(), Some("Bob"));
|
||||
|
||||
for (code, text) in [
|
||||
for (code, key) in [
|
||||
(
|
||||
petition_result::ALREADY_SIGNED,
|
||||
"You have already signed that guild charter.",
|
||||
),
|
||||
(
|
||||
petition_result::ALREADY_IN_GUILD,
|
||||
"You are already in a guild.",
|
||||
),
|
||||
(
|
||||
petition_result::CANT_SIGN_OWN,
|
||||
"You can't sign your own guild charter.",
|
||||
),
|
||||
(
|
||||
petition_result::NOT_SERVER,
|
||||
"That player is not from your server",
|
||||
"ERR_PETITION_ALREADY_SIGNED",
|
||||
),
|
||||
(petition_result::ALREADY_IN_GUILD, "ERR_PETITION_IN_GUILD"),
|
||||
(petition_result::CANT_SIGN_OWN, "ERR_PETITION_CREATOR"),
|
||||
(petition_result::NOT_SERVER, "ERR_PETITION_NOT_SAME_SERVER"),
|
||||
] {
|
||||
assert_eq!(
|
||||
my_sign_line(code).map(shown),
|
||||
Some((MsgKind::Error, text.into())),
|
||||
Some((key, MsgKind::Error)),
|
||||
"code {code} is a RED line"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
shown(no_charter_line()),
|
||||
(MsgKind::Error, "You don't have a guild charter.".into())
|
||||
("ERR_NO_GUILD_CHARTER", MsgKind::Error)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +211,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
turn_in_line(petition_result::NEED_MORE).map(shown),
|
||||
Some((MsgKind::Error, "You need more signatures.".into()))
|
||||
Some(("ERR_PETITION_NOT_ENOUGH_SIGNATURES", MsgKind::Error))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,16 +225,13 @@ mod tests {
|
||||
assert_eq!(turn_in_line(petition_result::ALREADY_SIGNED), None);
|
||||
}
|
||||
|
||||
/// **Every string in this file, checked against the shipped `GlobalStrings.lua`.**
|
||||
///
|
||||
/// Three of the five validator lines shipped here as *paraphrases* — "Names may not begin or
|
||||
/// end with a space." where the real client says "Guild names cannot start or end with a
|
||||
/// space. Enter a new name." — because they were written from the key name instead of copied.
|
||||
/// Nothing else could catch that: the text is never compared to anything at runtime, so a
|
||||
/// paraphrase renders, reads plausibly, and is simply not what the game says. This test is the
|
||||
/// oracle, and it is `ui_items::equip_error`'s, applied one table over.
|
||||
/// **Every key this file names, checked against the shipped `GlobalStrings.lua` and against
|
||||
/// the catalog.** The oracle it replaces compared our *copy* of each sentence to the file's;
|
||||
/// with the copies gone, the failures still worth catching are a key that resolves to nothing
|
||||
/// (the line would silently not appear) and a key whose arity disagrees with the fill — an
|
||||
/// `_S` row handed no argument, or a plain row handed one. Neither is visible in English.
|
||||
#[test]
|
||||
fn every_line_matches_the_real_global_strings() {
|
||||
fn every_key_resolves_and_its_arity_matches_the_fill() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let src = chain
|
||||
@@ -315,55 +239,63 @@ mod tests {
|
||||
.expect("GlobalStrings.lua in the chain");
|
||||
let s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let g = |key: &str| {
|
||||
s.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.unwrap_or_else(|e| panic!("{key} missing from GlobalStrings: {e}"))
|
||||
};
|
||||
|
||||
for m in [
|
||||
ERR_PETITION_ALREADY_SIGNED,
|
||||
ERR_PETITION_CREATOR,
|
||||
ERR_PETITION_DECLINED_S,
|
||||
ERR_PETITION_IN_GUILD,
|
||||
ERR_PETITION_NOT_ENOUGH_SIGNATURES,
|
||||
ERR_PETITION_NOT_SAME_SERVER,
|
||||
ERR_PETITION_OFFERED_S,
|
||||
ERR_PETITION_SIGNED,
|
||||
ERR_PETITION_SIGNED_S,
|
||||
ERR_NO_GUILD_CHARTER,
|
||||
ERR_GUILD_ENTER_NAME,
|
||||
ERR_GUILD_NAME_INVALID,
|
||||
ERR_GUILD_NAME_INVALID_SPACE,
|
||||
ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
|
||||
ERR_GUILD_NAME_TOO_SHORT,
|
||||
] {
|
||||
assert_eq!(g(m.key), m.text, "{} is not what the client says", m.key);
|
||||
let mut lines: Vec<Line> = vec![
|
||||
signed_by_other("Bob"),
|
||||
declined_line("Bob"),
|
||||
offered_line("Bob"),
|
||||
self_offer_line(),
|
||||
no_charter_line(),
|
||||
];
|
||||
lines.extend((0..8).filter_map(my_sign_line));
|
||||
lines.extend((0..8).filter_map(turn_in_line));
|
||||
lines.extend(
|
||||
[
|
||||
"ERR_GUILD_ENTER_NAME",
|
||||
"ERR_GUILD_NAME_INVALID",
|
||||
"ERR_GUILD_NAME_INVALID_SPACE",
|
||||
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES",
|
||||
"ERR_GUILD_NAME_TOO_SHORT",
|
||||
]
|
||||
.map(name_refused_line),
|
||||
);
|
||||
|
||||
for line in lines {
|
||||
let text: String = s
|
||||
.lua()
|
||||
.globals()
|
||||
.get(line.key)
|
||||
.unwrap_or_else(|e| panic!("{} missing from GlobalStrings: {e}", line.key));
|
||||
assert!(!text.is_empty(), "{} resolves empty", line.key);
|
||||
assert!(
|
||||
benilla_ui::messages::by_key(line.key).is_some(),
|
||||
"{} is not a catalog row, so its surface and sound would be a guess",
|
||||
line.key
|
||||
);
|
||||
assert_eq!(
|
||||
text.contains("%s"),
|
||||
line.arg_s().is_some(),
|
||||
"{} vs its fill",
|
||||
line.key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The validator's keys resolve to their own text, and an unknown key degrades to the generic
|
||||
/// invalid-name line rather than surfacing a raw key to the player.
|
||||
/// The validator's keys pass through, and a key `0x6c9b70`'s uncarved arms could name
|
||||
/// degrades to the generic invalid-name row rather than surfacing a raw key to the player.
|
||||
#[test]
|
||||
fn refused_names_resolve_their_key_and_degrade_safely() {
|
||||
fn refused_names_keep_their_key_and_degrade_safely() {
|
||||
assert_eq!(
|
||||
shown(name_refused_line("ERR_GUILD_ENTER_NAME")),
|
||||
(MsgKind::Error, "Enter a name for your guild.".into())
|
||||
("ERR_GUILD_ENTER_NAME", MsgKind::Error)
|
||||
);
|
||||
assert_eq!(
|
||||
shown(name_refused_line("ERR_GUILD_NAME_INVALID_SPACE")),
|
||||
(
|
||||
MsgKind::Error,
|
||||
"Guild names cannot start or end with a space. Enter a new name.".into()
|
||||
)
|
||||
("ERR_GUILD_NAME_INVALID_SPACE", MsgKind::Error)
|
||||
);
|
||||
assert_eq!(
|
||||
shown(name_refused_line("ERR_SOMETHING_UNCARVED")),
|
||||
(
|
||||
MsgKind::Error,
|
||||
"That name contains invalid characters, Enter a new name.".into()
|
||||
),
|
||||
("ERR_GUILD_NAME_INVALID", MsgKind::Error),
|
||||
"never a raw key on screen"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,11 +240,15 @@ pub(crate) fn questgiver_invalid_key(reason: u32) -> &'static str {
|
||||
/// `4`/`0x32` → BAG_FULL, `0x11` → MAX_COUNT, everything else → the plain FAILED line. All three
|
||||
/// strings carry a `%s` the caller fills with the quest title (the ref pushes `questRecord+0x9c`
|
||||
/// alongside the msgId). Decision 0669.
|
||||
///
|
||||
/// The wording is the player's own and is never restated here (decision 2045) — the msgId is what
|
||||
/// identifies each arm, and `every_quest_refusal_key_resolves_in_the_real_global_strings` is what
|
||||
/// checks the three against the shipped table.
|
||||
pub(crate) fn questgiver_failed_key(reason: u32) -> &'static str {
|
||||
match reason {
|
||||
4 | 50 => "ERR_QUEST_FAILED_BAG_FULL_S", // msgId 140 — "%s failed: Inventory is full."
|
||||
17 => "ERR_QUEST_FAILED_MAX_COUNT_S", // msgId 141 — "%s failed: Duplicate item found."
|
||||
_ => "ERR_QUEST_FAILED_S", // msgId 139 — "%s failed."
|
||||
4 | 50 => "ERR_QUEST_FAILED_BAG_FULL_S", // msgId 140 — the bag-full wording
|
||||
17 => "ERR_QUEST_FAILED_MAX_COUNT_S", // msgId 141 — the duplicate-item wording
|
||||
_ => "ERR_QUEST_FAILED_S", // msgId 139 — the plain form
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ use benilla_ui::script::{
|
||||
QuestItemView, QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState,
|
||||
ScriptValue, UiScript,
|
||||
};
|
||||
use benilla_ui::strings::{fill, Arg};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
@@ -165,9 +166,22 @@ impl Plugin for UiQuestLogPlugin {
|
||||
/// live backpack+bags count, meaningful only for an item objective — [`crate::ui_items::count_of`]).
|
||||
///
|
||||
/// The custom `text` field, when non-empty, REPLACES the auto-generated name in the line but keeps
|
||||
/// the "cur/req" suffix — CONFIRMED since (wow-re `ui/scratch/quest-leaderboard-law.md`: a
|
||||
/// creature objective carrying `ObjectiveText[i]` formats with `QUEST_OBJECTS_FOUND "%s: %d/%d"`,
|
||||
/// not the "slain" key, and that text IS the name — while `type` still answers `"monster"`).
|
||||
/// the "cur/req" suffix — CONFIRMED since (wow-re `ui/scratch/quest-leaderboard-law.md` §5: a
|
||||
/// creature objective carrying `ObjectiveText[i]` formats with `QUEST_OBJECTS_FOUND`, not the
|
||||
/// "slain" key, and that text IS the name — while `type` still answers `"monster"`).
|
||||
///
|
||||
/// **The three format keys are the leaderboard's own, and are NOT the progress toast's** (decision
|
||||
/// 2045's central trap: `QUEST_MONSTERS_KILLED` and `ERR_QUEST_ADD_KILL_SII` are the same enUS
|
||||
/// sentence, and `QUEST_OBJECTS_FOUND`/`QUEST_ITEMS_NEEDED`/`ERR_QUEST_ADD_FOUND_SII`/
|
||||
/// `ERR_QUEST_ADD_ITEM_SII` are the same four). `GetQuestLogLeaderBoard 0x4e0000` resolves
|
||||
/// `QUEST_MONSTERS_KILLED` `0x84b61c` (creature, no override), `QUEST_OBJECTS_FOUND` `0x84b634`
|
||||
/// (the override leg AND both GameObject legs) and `QUEST_ITEMS_NEEDED` `0x84b5f8` (item) — §5's
|
||||
/// table, address by address. Only the toast handler `0x5e5ad0` names the `ERR_*` twins, and it
|
||||
/// names them because they are *message-catalog* rows; these three are not.
|
||||
///
|
||||
/// A key the install does not carry renders the line with **empty text rather than no line**: the
|
||||
/// reference's `FrameScript_GetText` hands back its pre-seeded empty string and the leaderboard
|
||||
/// entry still exists, which is what `GetNumQuestLeaderBoards` counts.
|
||||
///
|
||||
/// **Known-wrong (decision 1156, not yet corrected):** `finished` must be `cur >= req` and NOTHING
|
||||
/// else. The whole-quest COMPLETE bit is read exactly once in the reference binding, by the `event`
|
||||
@@ -181,26 +195,42 @@ fn objective_line(
|
||||
creature_name: Option<&str>,
|
||||
item_name: Option<&str>,
|
||||
bag_count: u32,
|
||||
// The VM's own `GlobalStrings.lua` (decision 2045).
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<QuestLogObjectiveView> {
|
||||
// The reference's cache miss reads `" "` (`0x82ee00`); ours reads this, a longer-standing
|
||||
// divergence that says "still in flight" rather than looking like a nameless objective.
|
||||
const PLACEHOLDER: &str = "...";
|
||||
let line = |key: &str, name: &str, cur: u32, req: u32| {
|
||||
get(key).map_or_else(String::new, |f| {
|
||||
fill(
|
||||
&f,
|
||||
&[Arg::S(name), Arg::D(i64::from(cur)), Arg::D(i64::from(req))],
|
||||
)
|
||||
})
|
||||
};
|
||||
if obj.creature_or_go != 0 && obj.required_count > 0 {
|
||||
let req = obj.required_count;
|
||||
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
|
||||
let cur = u32::from(counter).min(req);
|
||||
let kind = if is_go { "object" } else { "monster" };
|
||||
let text = if !obj.text.is_empty() {
|
||||
format!("{}: {cur}/{req}", obj.text)
|
||||
// §5's fork, in its own order: a non-empty override IS the name and takes
|
||||
// `QUEST_OBJECTS_FOUND` on either kind; a GameObject takes that key on both its legs
|
||||
// (the binding fetches it once, above its cache test, at `0x4e031e`); only a plain
|
||||
// creature reaches `QUEST_MONSTERS_KILLED`. No gameobject-name cache yet — a later
|
||||
// CMSG_GAMEOBJECT_QUERY slice — so a GO with no override wears the placeholder.
|
||||
let (key, name) = if !obj.text.is_empty() {
|
||||
("QUEST_OBJECTS_FOUND", obj.text.as_str())
|
||||
} else if is_go {
|
||||
// No gameobject-name cache yet — a later CMSG_GAMEOBJECT_QUERY slice.
|
||||
format!("Objective: {cur}/{req}")
|
||||
("QUEST_OBJECTS_FOUND", PLACEHOLDER)
|
||||
} else {
|
||||
format!(
|
||||
"{} slain: {cur}/{req}",
|
||||
creature_name.unwrap_or(PLACEHOLDER)
|
||||
(
|
||||
"QUEST_MONSTERS_KILLED",
|
||||
creature_name.unwrap_or(PLACEHOLDER),
|
||||
)
|
||||
};
|
||||
return Some(QuestLogObjectiveView {
|
||||
text,
|
||||
text: line(key, name, cur, req),
|
||||
kind: kind.into(),
|
||||
finished: cur >= req,
|
||||
cur,
|
||||
@@ -210,13 +240,18 @@ fn objective_line(
|
||||
if obj.item_id != 0 && obj.item_count > 0 {
|
||||
let req = obj.item_count;
|
||||
let cur = bag_count.min(req);
|
||||
let text = if !obj.text.is_empty() {
|
||||
format!("{}: {cur}/{req}", obj.text)
|
||||
// Pre-existing divergence, preserved rather than quietly changed here: §5's item row
|
||||
// resolves its name from the **item cache alone** — the per-objective override buffer
|
||||
// (`template+0x14dc + i*0x100`) is read only by the creature and GameObject branches — so
|
||||
// the reference would show the item's name where this shows `ObjectiveText[i]`. The FORMAT
|
||||
// key is `QUEST_ITEMS_NEEDED` either way, which is what this conversion is about.
|
||||
let name = if obj.text.is_empty() {
|
||||
item_name.unwrap_or(PLACEHOLDER)
|
||||
} else {
|
||||
format!("{}: {cur}/{req}", item_name.unwrap_or(PLACEHOLDER))
|
||||
obj.text.as_str()
|
||||
};
|
||||
return Some(QuestLogObjectiveView {
|
||||
text,
|
||||
text: line("QUEST_ITEMS_NEEDED", name, cur, req),
|
||||
kind: "item".into(),
|
||||
finished: cur >= req,
|
||||
cur,
|
||||
@@ -286,6 +321,8 @@ fn build_objectives(
|
||||
items: &mut Items,
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
// The VM's own `GlobalStrings.lua`, for [`objective_line`]'s three format keys.
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<QuestLogObjectiveView> {
|
||||
let mut objectives = Vec::with_capacity(template.objectives.len());
|
||||
for (i, obj) in template.objectives.iter().enumerate() {
|
||||
@@ -315,6 +352,7 @@ fn build_objectives(
|
||||
creature_name.as_deref(),
|
||||
item_name.as_deref(),
|
||||
bag_count,
|
||||
get,
|
||||
) {
|
||||
objectives.push(line);
|
||||
}
|
||||
@@ -490,6 +528,9 @@ fn feed_quest_log(
|
||||
tag_names: Option<Res<QuestTagNamesRes>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
spells: Option<Res<Spells>>,
|
||||
// The completion toasts are message-catalog rows, so they go out through the one sink that
|
||||
// reads the row's surface and sound (`crate::ui_action::show_messages`).
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
mut last: Local<crate::ui_script::VmMemo<QuestLogState>>,
|
||||
mut prior_quest_ids: Local<crate::ui_script::VmMemo<Option<HashSet<u32>>>>,
|
||||
) {
|
||||
@@ -533,6 +574,17 @@ fn feed_quest_log(
|
||||
*prior_quest_ids = Some(current);
|
||||
}
|
||||
|
||||
// The player's own `GlobalStrings.lua`, for the leaderboard's three format keys (2045). Taken
|
||||
// here, above the build, because everything between this and the push below reads the VM only.
|
||||
let get = |key: &str| {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
|
||||
// ── The IN-FLIGHT GATE — all of the log, or none of it ──────────────────────────────────────
|
||||
// The reference's rebuild (`0x4de510`) counts cache misses into `[0xbb7498]`, skips the row
|
||||
// for each miss entirely (`0x4de66b`/`0x4de67e` — no row AND no header for its group), and
|
||||
@@ -629,34 +681,41 @@ fn feed_quest_log(
|
||||
if collapsed {
|
||||
continue; // folded: the quest stays in the log, just not in the visible list
|
||||
}
|
||||
let (title, level, tag, pushable, objectives) = match quest_log
|
||||
.template(r.quest_id, &commands)
|
||||
{
|
||||
Some(t) => (
|
||||
t.title.clone(),
|
||||
t.level,
|
||||
// The `(Elite)`/`(Dungeon)`/`(Raid)`/`(PvP)` suffix: the template's `Type` is
|
||||
// a `QuestInfo.dbc` row id ([`benilla_formats::QuestTagNames`]), and `Type`
|
||||
// 0 — the untagged majority — names no row. Absent table (no client data) =
|
||||
// no tag, never an empty string: the row Lua branches on presence.
|
||||
tag_names
|
||||
.as_ref()
|
||||
.and_then(|tags| tags.0.resolve(t.quest_type))
|
||||
.map(str::to_string),
|
||||
// `QUEST_FLAGS_SHARABLE` (vmangos `QuestDef.h:153`) — the whole of the
|
||||
// shareable test, and it is the CLIENT's alone: vmangos's
|
||||
// `HandlePushQuestToParty` never checks the bit, it only re-tests it on the
|
||||
// receiver's accept (`Player::CanShareQuest`). So an unshareable quest whose
|
||||
// button we wrongly enabled would push, and the party would get a detail panel
|
||||
// for a quest the server then refuses (decision 1733).
|
||||
t.flags & quest_flags::SHARABLE != 0,
|
||||
build_objectives(t, &r.log_slot, &store.0, &mut items, &mut names, &commands),
|
||||
),
|
||||
// Unreachable: the in-flight gate above emptied `rows` unless EVERY template is
|
||||
// cached, which is the reference's own all-or-nothing rebuild. This arm used to
|
||||
// paint a `"..."` placeholder row — the behaviour the §5 refuted.
|
||||
None => unreachable!("the in-flight gate leaves only cached rows"),
|
||||
};
|
||||
let (title, level, tag, pushable, objectives) =
|
||||
match quest_log.template(r.quest_id, &commands) {
|
||||
Some(t) => (
|
||||
t.title.clone(),
|
||||
t.level,
|
||||
// The `(Elite)`/`(Dungeon)`/`(Raid)`/`(PvP)` suffix: the template's `Type` is
|
||||
// a `QuestInfo.dbc` row id ([`benilla_formats::QuestTagNames`]), and `Type`
|
||||
// 0 — the untagged majority — names no row. Absent table (no client data) =
|
||||
// no tag, never an empty string: the row Lua branches on presence.
|
||||
tag_names
|
||||
.as_ref()
|
||||
.and_then(|tags| tags.0.resolve(t.quest_type))
|
||||
.map(str::to_string),
|
||||
// `QUEST_FLAGS_SHARABLE` (vmangos `QuestDef.h:153`) — the whole of the
|
||||
// shareable test, and it is the CLIENT's alone: vmangos's
|
||||
// `HandlePushQuestToParty` never checks the bit, it only re-tests it on the
|
||||
// receiver's accept (`Player::CanShareQuest`). So an unshareable quest whose
|
||||
// button we wrongly enabled would push, and the party would get a detail panel
|
||||
// for a quest the server then refuses (decision 1733).
|
||||
t.flags & quest_flags::SHARABLE != 0,
|
||||
build_objectives(
|
||||
t,
|
||||
&r.log_slot,
|
||||
&store.0,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&commands,
|
||||
&get,
|
||||
),
|
||||
),
|
||||
// Unreachable: the in-flight gate above emptied `rows` unless EVERY template is
|
||||
// cached, which is the reference's own all-or-nothing rebuild. This arm used to
|
||||
// paint a `"..."` placeholder row — the behaviour the §5 refuted.
|
||||
None => unreachable!("the in-flight gate leaves only cached rows"),
|
||||
};
|
||||
let complete = if r.log_slot.state & quest_slot_state::COMPLETE != 0 {
|
||||
1
|
||||
} else if r.log_slot.state & quest_slot_state::FAIL != 0 {
|
||||
@@ -761,12 +820,22 @@ fn feed_quest_log(
|
||||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(line)]);
|
||||
}
|
||||
if quest.completed {
|
||||
let msg = if quest.title.is_empty() {
|
||||
"Objective Complete.".to_string()
|
||||
// The verified 0x198 pair — msgId `0xf8` `ERR_QUEST_OBJECTIVE_COMPLETE_S` when
|
||||
// the handler has the objective text, `0xf9` `ERR_QUEST_UNKNOWN_COMPLETE` when it
|
||||
// does not (wow-re `object-layer/scratch/quest-update-ui-feedback-law.md` §5's
|
||||
// table; never `ERR_QUEST_COMPLETE_S`, which is the turn-in's own call site).
|
||||
// Both are catalog rows, so the surface and the sound come from there rather than
|
||||
// from a hand-picked `fire_event` here (decisions 1770/1815).
|
||||
let line = if quest.title.is_empty() {
|
||||
crate::ui_action::keyed_line(&script, "ERR_QUEST_UNKNOWN_COMPLETE")
|
||||
} else {
|
||||
format!("{} (Complete)", quest.title)
|
||||
crate::ui_action::keyed_line_s(
|
||||
&script,
|
||||
"ERR_QUEST_OBJECTIVE_COMPLETE_S",
|
||||
&[&quest.title],
|
||||
)
|
||||
};
|
||||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(msg)]);
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_quest_log", line);
|
||||
}
|
||||
// QUEST_WATCH_UPDATE's arg1 is the quest's LOG index: stock QuestLog_OnEvent hands it
|
||||
// to AutoQuestWatch_Update(questIndex), whose end is AddQuestWatch(questIndex)
|
||||
@@ -1141,11 +1210,29 @@ mod tests {
|
||||
|
||||
// ── objective_line ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A stand-in string table whose three templates are **deliberately not** the shipped wording.
|
||||
///
|
||||
/// That is the point, and it is decision 2045's: in enUS `QUEST_MONSTERS_KILLED` and
|
||||
/// `ERR_QUEST_ADD_KILL_SII` are the same sentence, and `QUEST_OBJECTS_FOUND`,
|
||||
/// `QUEST_ITEMS_NEEDED` and their two `ERR_QUEST_ADD_*` twins are the same four — so an
|
||||
/// assertion on English cannot tell a right key from a wrong one. These name the key in the
|
||||
/// rendered line, so each case below asserts *which lookup happened*. The real wording is
|
||||
/// checked against the player's own table by
|
||||
/// [`the_leaderboard_keys_resolve_in_the_real_global_strings`].
|
||||
fn probe_strings(key: &str) -> Option<String> {
|
||||
match key {
|
||||
"QUEST_MONSTERS_KILLED" => Some("<KILLED %s %d of %d>".into()),
|
||||
"QUEST_OBJECTS_FOUND" => Some("<FOUND %s %d of %d>".into()),
|
||||
"QUEST_ITEMS_NEEDED" => Some("<NEEDED %s %d of %d>".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creature_objective_with_resolved_name() {
|
||||
let o = obj(100, 10, 0, 0, "");
|
||||
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0).unwrap();
|
||||
assert_eq!(line.text, "Kobold Vermin slain: 3/10");
|
||||
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<KILLED Kobold Vermin 3 of 10>");
|
||||
assert_eq!(line.kind, "monster");
|
||||
assert!(!line.finished);
|
||||
}
|
||||
@@ -1153,16 +1240,20 @@ mod tests {
|
||||
#[test]
|
||||
fn creature_objective_in_flight_shows_a_placeholder() {
|
||||
let o = obj(100, 10, 0, 0, "");
|
||||
let line = objective_line(&o, 0, None, None, 0).unwrap();
|
||||
assert_eq!(line.text, "... slain: 0/10");
|
||||
let line = objective_line(&o, 0, None, None, 0, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<KILLED ... 0 of 10>");
|
||||
}
|
||||
|
||||
/// A GameObject objective with no name takes `QUEST_OBJECTS_FOUND` on the placeholder — the
|
||||
/// binding fetches that key once, ABOVE its cache test (`0x4e031e`), so both its legs share
|
||||
/// it. Before decision 2045 this composed an invented `"Objective: %d/%d"` line, which is a
|
||||
/// sentence 1.12 never says.
|
||||
#[test]
|
||||
fn go_objective_falls_back_without_a_name_cache() {
|
||||
let go_id = ((-57i32) as u32) | 0x8000_0000;
|
||||
let o = obj(go_id, 1, 0, 0, "");
|
||||
let line = objective_line(&o, 1, None, None, 0).unwrap();
|
||||
assert_eq!(line.text, "Objective: 1/1");
|
||||
let line = objective_line(&o, 1, None, None, 0, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<FOUND ... 1 of 1>");
|
||||
assert_eq!(line.kind, "object");
|
||||
assert!(line.finished); // cur (1) >= req (1)
|
||||
}
|
||||
@@ -1171,8 +1262,8 @@ mod tests {
|
||||
fn go_objective_prefers_its_custom_text_over_the_fallback() {
|
||||
let go_id = ((-57i32) as u32) | 0x8000_0000;
|
||||
let o = obj(go_id, 4, 0, 0, "Destroy the barricade");
|
||||
let line = objective_line(&o, 1, None, None, 0).unwrap();
|
||||
assert_eq!(line.text, "Destroy the barricade: 1/4");
|
||||
let line = objective_line(&o, 1, None, None, 0, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<FOUND Destroy the barricade 1 of 4>");
|
||||
assert_eq!(line.kind, "object");
|
||||
}
|
||||
|
||||
@@ -1180,8 +1271,8 @@ mod tests {
|
||||
fn item_objective_counts_bags_and_clamps_to_required() {
|
||||
let o = obj(0, 0, 2000, 5, "");
|
||||
// 8 in the bags, but required is only 5 — the line clamps, doesn't overshoot.
|
||||
let line = objective_line(&o, 0, None, Some("Kobold Ear"), 8).unwrap();
|
||||
assert_eq!(line.text, "Kobold Ear: 5/5");
|
||||
let line = objective_line(&o, 0, None, Some("Kobold Ear"), 8, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<NEEDED Kobold Ear 5 of 5>");
|
||||
assert_eq!(line.kind, "item");
|
||||
assert!(line.finished);
|
||||
}
|
||||
@@ -1189,18 +1280,54 @@ mod tests {
|
||||
#[test]
|
||||
fn item_objective_in_flight_shows_a_placeholder() {
|
||||
let o = obj(0, 0, 2000, 5, "");
|
||||
let line = objective_line(&o, 0, None, None, 2).unwrap();
|
||||
assert_eq!(line.text, "...: 2/5");
|
||||
let line = objective_line(&o, 0, None, None, 2, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<NEEDED ... 2 of 5>");
|
||||
}
|
||||
|
||||
/// **The key fork §5 pins**, and the one an English assertion could never see: a creature
|
||||
/// objective carrying `ObjectiveText[i]` stops being "slain" — the override IS the name and
|
||||
/// the format becomes `QUEST_OBJECTS_FOUND` (`mov ecx,0x84b634` @`0x4e03e1`) — while `type`
|
||||
/// still answers `"monster"`, because that is read from a fresh sign test of
|
||||
/// `ReqCreatureOrGOId[i]`.
|
||||
#[test]
|
||||
fn custom_text_overrides_the_creature_auto_line() {
|
||||
let o = obj(100, 10, 0, 0, "Slay the vermin");
|
||||
let line = objective_line(&o, 4, Some("Kobold Vermin"), None, 0).unwrap();
|
||||
assert_eq!(line.text, "Slay the vermin: 4/10");
|
||||
let line = objective_line(&o, 4, Some("Kobold Vermin"), None, 0, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<FOUND Slay the vermin 4 of 10>");
|
||||
assert_eq!(line.kind, "monster");
|
||||
}
|
||||
|
||||
/// The three keys against the player's REAL `GlobalStrings.lua` — the guard a key assertion
|
||||
/// needs beside it, since a typo'd key degrades a line to empty rather than to a wrong
|
||||
/// sentence. Skips without client data.
|
||||
#[test]
|
||||
fn the_leaderboard_keys_resolve_in_the_real_global_strings() {
|
||||
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 get = |key: &str| {
|
||||
vm.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
for key in [
|
||||
"QUEST_MONSTERS_KILLED",
|
||||
"QUEST_OBJECTS_FOUND",
|
||||
"QUEST_ITEMS_NEEDED",
|
||||
] {
|
||||
assert!(get(key).is_some(), "{key} missing");
|
||||
}
|
||||
let o = obj(100, 10, 0, 0, "");
|
||||
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, &get).unwrap();
|
||||
assert_eq!(line.text, "Kobold Vermin slain: 3/10");
|
||||
}
|
||||
|
||||
/// Decision 1158, correcting 0109: a line's `finished` is `cur >= req` and **nothing else**.
|
||||
/// The reference reads the whole-quest COMPLETE bit exactly once in `GetQuestLogLeaderBoard`
|
||||
/// (`0x4e02a2`), in the `event` branch, where it is that line's only predicate — it is never
|
||||
@@ -1211,15 +1338,15 @@ mod tests {
|
||||
let o = obj(100, 10, 0, 0, "");
|
||||
// The counter (1) is well short of required (10). Whatever the quest's state byte says,
|
||||
// THIS line is not finished.
|
||||
let line = objective_line(&o, 1, Some("Kobold"), None, 0).unwrap();
|
||||
assert_eq!(line.text, "Kobold slain: 1/10");
|
||||
let line = objective_line(&o, 1, Some("Kobold"), None, 0, &probe_strings).unwrap();
|
||||
assert_eq!(line.text, "<KILLED Kobold 1 of 10>");
|
||||
assert!(!line.finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_objective_slot_emits_no_line() {
|
||||
let o = obj(0, 0, 0, 0, "");
|
||||
assert!(objective_line(&o, 0, None, None, 0).is_none());
|
||||
assert!(objective_line(&o, 0, None, None, 0, &probe_strings).is_none());
|
||||
}
|
||||
|
||||
// ── build_objectives: every occupied slot gets its own lines, not just the selection ────────────
|
||||
@@ -1276,17 +1403,23 @@ mod tests {
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
let objectives = build_objectives(
|
||||
&template, &log_slot, &store, &mut items, &mut names, &commands,
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
|
||||
// The two unused quads emit nothing; the two real ones resolve in order — creature/item
|
||||
// names are unresolved (ask-once mid-flight, no answer seeded) so they fall to the same
|
||||
// placeholder path `objective_line`'s own tests already cover directly.
|
||||
assert_eq!(objectives.len(), 2);
|
||||
assert_eq!(objectives[0].text, "... slain: 3/10");
|
||||
assert_eq!(objectives[0].text, "<KILLED ... 3 of 10>");
|
||||
assert_eq!(objectives[0].kind, "monster");
|
||||
assert!(!objectives[0].finished);
|
||||
assert_eq!(objectives[1].text, "...: 0/5");
|
||||
assert_eq!(objectives[1].text, "<NEEDED ... 0 of 5>");
|
||||
assert_eq!(objectives[1].kind, "item");
|
||||
}
|
||||
|
||||
@@ -1314,14 +1447,20 @@ mod tests {
|
||||
let commands = NetCommands(tx);
|
||||
|
||||
let objectives = build_objectives(
|
||||
&template, &log_slot, &store, &mut items, &mut names, &commands,
|
||||
&template,
|
||||
&log_slot,
|
||||
&store,
|
||||
&mut items,
|
||||
&mut names,
|
||||
&commands,
|
||||
&probe_strings,
|
||||
);
|
||||
assert_eq!(objectives.len(), 1);
|
||||
assert!(
|
||||
!objectives[0].finished,
|
||||
"the counter is 1/10 — the quest's COMPLETE bit is not this line's business"
|
||||
);
|
||||
assert_eq!(objectives[0].text, "... slain: 1/10");
|
||||
assert_eq!(objectives[0].text, "<KILLED ... 1 of 10>");
|
||||
}
|
||||
|
||||
// ── money_split ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -804,6 +804,26 @@ pub(crate) fn fire_chat_login(s: &mut benilla_ui::script::UiScript) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `benilla_formats::TokenContext::text` seam (decision 2045): resolve a `GlobalStrings` key
|
||||
/// out of the VM and fill its `%d` holes through the one shared filler.
|
||||
///
|
||||
/// The split it implements is deliberate. `benilla-formats` walks the spell's columns and knows
|
||||
/// which key and which numbers a `$d` or `$s` token wants; it has no business knowing about a
|
||||
/// script VM or carrying a printf-family formatter, and it depends on neither crate that has one.
|
||||
/// So it names the key, and this renders it.
|
||||
pub(crate) fn token_text(
|
||||
script: &benilla_ui::script::UiScript,
|
||||
) -> impl Fn(&str, &[i64]) -> Option<String> + '_ {
|
||||
|key: &str, args: &[i64]| {
|
||||
let template = benilla_ui::strings::global(script.lua(), key)?;
|
||||
let args: Vec<_> = args
|
||||
.iter()
|
||||
.map(|n| benilla_ui::strings::Arg::D(*n))
|
||||
.collect();
|
||||
Some(benilla_ui::strings::fill(&template, &args))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`test_ui::load_ui`] for a test module OUTSIDE `ui_script` — `ui_action::feed_tests` drives the
|
||||
/// real `UIErrorsFrame` end to end and needs the same both-stores reader everything else uses.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,19 +14,26 @@ use bevy::prelude::*;
|
||||
use crate::area::AreaTableRes;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_action::{MessageSink, Shown, UiError};
|
||||
use crate::ui_unit::{class_names, race_names};
|
||||
|
||||
use super::{fill_line, result_template, status_tag, SocialState};
|
||||
use super::{name_pushes, result_key, status_flag_key, SocialState};
|
||||
|
||||
/// `WHO_LIST_FORMAT` / `WHO_LIST_GUILD_FORMAT` / `WHO_NUM_RESULTS(_P1)` — the chat-routed `/who`
|
||||
/// output (`GlobalStrings.lua:5441-5444`). These four keys appear **nowhere** in the reference's
|
||||
/// FrameXML (exhaustively grepped), which is what identifies them as engine-composed: when
|
||||
/// `SetWhoToUI` is off, the engine prints the results as chat lines itself. So do we.
|
||||
const WHO_LIST_FORMAT: &str = "|Hplayer:%s|h[%s]|h: Level %d %s %s - %s";
|
||||
const WHO_LIST_GUILD_FORMAT: &str = "|Hplayer:%s|h[%s]|h: Level %d %s %s <%s> - %s";
|
||||
const WHO_NUM_RESULTS: &str = "%d player total";
|
||||
const WHO_NUM_RESULTS_P1: &str = "%d players total";
|
||||
/// output. These four keys appear **nowhere** in the reference's FrameXML (exhaustively grepped),
|
||||
/// which is what identifies them as engine-composed: when `SetWhoToUI` is off, the engine prints
|
||||
/// the results as chat lines itself. So do we — and, since decision 2045, out of the player's own
|
||||
/// `GlobalStrings.lua` rather than out of a copy typed here.
|
||||
///
|
||||
/// **None of the four is a message-catalog row**, so these lines cannot go out through
|
||||
/// `Shown::keyed`: an unknown key takes that path's fallback and turns the line red. They are
|
||||
/// `Shown::unkeyed(MsgKind::Chat, …)`, the same route `/ginfo`'s two templates take (2054).
|
||||
const WHO_KEYS: [&str; 4] = [
|
||||
"WHO_LIST_FORMAT",
|
||||
"WHO_LIST_GUILD_FORMAT",
|
||||
"WHO_NUM_RESULTS",
|
||||
"WHO_NUM_RESULTS_P1",
|
||||
];
|
||||
|
||||
/// The chat-frame threshold on an answer the Who frame did not claim. `SMSG_WHO`'s parser
|
||||
/// (`0x5adf60`) computes one **print flag** before its record loop (`0x5adf9c`–`0x5adfd5`) and
|
||||
@@ -60,7 +67,7 @@ pub(super) fn feed_social(
|
||||
mut names: ResMut<NameCache>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut chat_log: ResMut<ChatLog>,
|
||||
mut sink: MessageSink,
|
||||
mut fed: Local<crate::ui_script::VmMemo<FedSocial>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -69,11 +76,27 @@ pub(super) fn feed_social(
|
||||
let fed = fed.get(&script);
|
||||
let areas = areas.as_deref().map(|a| &a.0);
|
||||
|
||||
// The owed system lines first: a line about a friend who just went offline should land before
|
||||
// the list update that removes their zone.
|
||||
drain_result_lines(&mut social, &mut names, &commands, &mut chat_log);
|
||||
// Everything that reads the VM's own string table, resolved under one borrow of it.
|
||||
let (owed, friends, display_order) = {
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
// Each owed line carries a catalog key, so the surface AND the sound come from its row —
|
||||
// `ERR_FRIEND_ONLINE_SS`'s is `FRIENDJOINGAME`, which a straight chat push could not play.
|
||||
let owed: Vec<Shown> = drain_result_lines(&mut social, &mut names, &commands)
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
crate::ui_action::ui_error_text(e, &get).map(|text| Shown::keyed(e.key, text))
|
||||
})
|
||||
.collect();
|
||||
// The row's away tag, through `CHAT_FLAG_AFK`/`_DND` — the chat frame's own pair.
|
||||
let away = |status: u8| status_flag_key(status).and_then(&get).unwrap_or_default();
|
||||
let (friends, display_order) = friend_rows(&social, &mut names, &commands, areas, &away);
|
||||
(owed, friends, display_order)
|
||||
};
|
||||
|
||||
// The owed lines first: one about a friend who just went offline should land before the list
|
||||
// update that removes their zone.
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_social", owed);
|
||||
|
||||
let (friends, display_order) = friend_rows(&social, &mut names, &commands, areas);
|
||||
let (ignores, ignore_order) = ignore_rows(&social, &mut names, &commands);
|
||||
let who = who_rows(&social, areas);
|
||||
|
||||
@@ -123,10 +146,13 @@ pub(super) fn feed_social(
|
||||
// order**: the per-record line is composed inside the parse loop (`0x5ae0a1`, one
|
||||
// call per record) and the `qsort` at `0x5ae0e2` sits past the loop's back-edge, so
|
||||
// it reorders only the array `GetWhoInfo` reads, never these lines.
|
||||
let wire_order: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
|
||||
for line in who_lines(&wire_order, social.who_total) {
|
||||
system_line(&mut chat_log, line);
|
||||
}
|
||||
let printed = {
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
let wire_order: Vec<WhoInfo> =
|
||||
social.who.iter().map(|e| who_row(e, areas)).collect();
|
||||
who_lines(&wire_order, social.who_total, &get)
|
||||
};
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_social", printed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,34 +163,42 @@ fn answer_goes_to_the_frame(to_ui: bool, shown: usize) -> bool {
|
||||
to_ui || shown > WHO_CHAT_MAX
|
||||
}
|
||||
|
||||
/// Compose and push every result line whose name has resolved. A line needing a name waits (the
|
||||
/// reference's resolve-then-compose order); one that doesn't goes out immediately.
|
||||
/// Name every result line whose subject has resolved. A line that needs a name waits for the
|
||||
/// query (the reference's resolve-then-compose order); one that doesn't is ready at once.
|
||||
///
|
||||
/// Whether a name is needed comes off the KEY's `_S`/`_SS` suffix ([`name_pushes`]) rather than
|
||||
/// off the resolved text: the text may not be there yet, and "no `%s` in it" and "no string for
|
||||
/// it" must not look the same.
|
||||
fn drain_result_lines(
|
||||
social: &mut SocialState,
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
chat_log: &mut ChatLog,
|
||||
) {
|
||||
) -> Vec<UiError> {
|
||||
let mut still_pending = Vec::new();
|
||||
let mut ready = Vec::new();
|
||||
for update in std::mem::take(&mut social.pending_lines) {
|
||||
let Some(template) = result_template(update.result) else {
|
||||
let Some(key) = result_key(update.result) else {
|
||||
continue; // an unknown code shows nothing
|
||||
};
|
||||
if !template.contains("%s") {
|
||||
system_line(chat_log, template.to_string());
|
||||
let pushes = name_pushes(key);
|
||||
if pushes == 0 {
|
||||
ready.push(UiError::key(key));
|
||||
continue;
|
||||
}
|
||||
// A named line with no subject (the server answers a failed lookup with guid 0) can never
|
||||
// resolve — print nothing rather than "%s added to friends." or hold it forever.
|
||||
// resolve — print nothing rather than a starved template, or hold it forever.
|
||||
if update.guid == 0 {
|
||||
continue;
|
||||
}
|
||||
match names.resolve(update.guid, commands).map(str::to_string) {
|
||||
Some(name) => system_line(chat_log, fill_line(template, &name)),
|
||||
// The same name, pushed as many times as the key's arity says — which is what the
|
||||
// reference does for the `|Hplayer:%s|h[%s]|h` link.
|
||||
Some(name) => ready.push(UiError::strings(key, &vec![name.as_str(); pushes])),
|
||||
None => still_pending.push(update),
|
||||
}
|
||||
}
|
||||
social.pending_lines = still_pending;
|
||||
ready
|
||||
}
|
||||
|
||||
/// The chat-routed `/who` output (module doc's four engine-only templates): one line per row **in
|
||||
@@ -173,52 +207,47 @@ fn drain_result_lines(
|
||||
/// The order is the parser's, not a presentation choice: `0x5ae0a1` composes a record's line
|
||||
/// inside the loop that reads it, and the summary block `0x5ae0f1`–`0x5ae12a` sits after the
|
||||
/// loop's back-edge (wow-re `who-list-sort-law.md` §11.4).
|
||||
fn who_lines(rows: &[WhoInfo], total: u32) -> Vec<String> {
|
||||
let template = if total == 1 {
|
||||
WHO_NUM_RESULTS
|
||||
} else {
|
||||
WHO_NUM_RESULTS_P1
|
||||
fn who_lines(rows: &[WhoInfo], total: u32, get: &dyn Fn(&str) -> Option<String>) -> Vec<Shown> {
|
||||
use benilla_ui::strings::{fill, Arg};
|
||||
|
||||
let chat = |text: String| {
|
||||
(!text.is_empty()).then(|| Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, text))
|
||||
};
|
||||
let mut lines = Vec::with_capacity(rows.len() + 1);
|
||||
for row in rows {
|
||||
let template = if row.guild.is_empty() {
|
||||
WHO_LIST_FORMAT
|
||||
let key = if row.guild.is_empty() {
|
||||
WHO_KEYS[0]
|
||||
} else {
|
||||
WHO_LIST_GUILD_FORMAT
|
||||
WHO_KEYS[1]
|
||||
};
|
||||
// The templates are positional-by-order, not indexed: name, name, level, race, class,
|
||||
// [guild,] zone.
|
||||
let mut line = template.to_string();
|
||||
for fill in [
|
||||
row.name.as_str(),
|
||||
row.name.as_str(),
|
||||
&row.level.to_string(),
|
||||
row.race.as_str(),
|
||||
row.class.as_str(),
|
||||
] {
|
||||
line = replace_first_token(&line, fill);
|
||||
}
|
||||
let Some(template) = get(key) else {
|
||||
continue; // no string, no line
|
||||
};
|
||||
// The templates interleave `%s` and `%d` and are positional-by-order, not indexed: name,
|
||||
// name, level, race, class, [guild,] zone. The shared filler walks both specifiers in one
|
||||
// pass, which is what retired this file's own replace-first token walk (2045).
|
||||
let mut args = vec![
|
||||
Arg::S(&row.name),
|
||||
Arg::S(&row.name),
|
||||
Arg::D(i64::from(row.level)),
|
||||
Arg::S(&row.race),
|
||||
Arg::S(&row.class),
|
||||
];
|
||||
if !row.guild.is_empty() {
|
||||
line = replace_first_token(&line, &row.guild);
|
||||
args.push(Arg::S(&row.guild));
|
||||
}
|
||||
line = replace_first_token(&line, &row.zone);
|
||||
lines.push(line);
|
||||
args.push(Arg::S(&row.zone));
|
||||
lines.extend(chat(fill(&template, &args)));
|
||||
}
|
||||
// The `_P1` plural twin for anything but exactly one — `GetText`'s own rule, so a zero total
|
||||
// reads "0 players total" (see `benilla_ui::script::tooltip::plural_template`).
|
||||
let total_key = if total == 1 { WHO_KEYS[2] } else { WHO_KEYS[3] };
|
||||
if let Some(template) = get(total_key) {
|
||||
lines.extend(chat(fill(&template, &[Arg::D(i64::from(total))])));
|
||||
}
|
||||
lines.push(template.replace("%d", &total.to_string()));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Replace the first `%s` or `%d` in `line` with `fill` — the templates interleave both, so a
|
||||
/// per-token replace-first walk fills them in wire order.
|
||||
fn replace_first_token(line: &str, fill: &str) -> String {
|
||||
match (line.find("%s"), line.find("%d")) {
|
||||
(Some(s), Some(d)) if d < s => line.replacen("%d", fill, 1),
|
||||
(Some(_), _) => line.replacen("%s", fill, 1),
|
||||
(None, Some(_)) => line.replacen("%d", fill, 1),
|
||||
(None, None) => line.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The friend rows in display order (name-sorted), plus the guid order that produced them so the
|
||||
/// drain can map a row index back to a player.
|
||||
fn friend_rows(
|
||||
@@ -226,6 +255,7 @@ fn friend_rows(
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
areas: Option<&AreaTableCatalog>,
|
||||
away: &dyn Fn(u8) -> String,
|
||||
) -> (Vec<FriendInfo>, Vec<u64>) {
|
||||
let mut rows: Vec<(u64, FriendInfo)> = social
|
||||
.friends
|
||||
@@ -254,7 +284,7 @@ fn friend_rows(
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
connected: online,
|
||||
status: status_tag(entry.status).to_string(),
|
||||
status: away(entry.status),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -346,10 +376,6 @@ fn index_of(order: &[u64], guid: u64) -> u32 {
|
||||
.map_or(0, |i| i as u32 + 1)
|
||||
}
|
||||
|
||||
fn system_line(chat_log: &mut ChatLog, text: String) {
|
||||
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
|
||||
}
|
||||
|
||||
/// Turn the Era API's social intents into their sends. Every "by index" intent resolves through
|
||||
/// the display order the feed just published, and every "by name" one through the list's own
|
||||
/// resolved names — because the wire removes by **guid** (see [`super`]'s module doc).
|
||||
@@ -559,7 +585,22 @@ mod tests {
|
||||
);
|
||||
|
||||
let wire: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, None)).collect();
|
||||
let lines = who_lines(&wire, social.who_total);
|
||||
// Against the real shipped templates: these four keys are engine-composed, so there is no
|
||||
// FrameXML call site to read them off and a stub would only assert our own guess back.
|
||||
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 = benilla_ui::script::UiScript::new().expect("VM");
|
||||
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let lines: Vec<String> = who_lines(&wire, social.who_total, &|key| {
|
||||
vm.lua().globals().get::<String>(key).ok()
|
||||
})
|
||||
.iter()
|
||||
.map(|s| s.text().to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(lines.len(), 3, "one per row plus the total: {lines:?}");
|
||||
assert!(
|
||||
lines[0].contains("Zzz"),
|
||||
@@ -619,31 +660,52 @@ mod tests {
|
||||
assert_eq!(index_of(&[11, 22], 0), 0, "nothing selected");
|
||||
}
|
||||
|
||||
/// The who templates interleave `%s` and `%d`, so the fills have to walk them in order.
|
||||
/// **The who templates interleave `%s` and `%d`**, and the guilded one takes a seventh fill
|
||||
/// the other does not — so the row's fields have to reach them in wire order, through one
|
||||
/// walk that speaks both specifiers. That walk is `benilla_ui::strings::fill` now; this file
|
||||
/// used to carry its own `replace_first_token`, one of the eight copies decision 2045 retired.
|
||||
///
|
||||
/// Against the shipped templates, not a stub: these four keys are engine-composed, so there
|
||||
/// is no FrameXML call site to read them off and a stub would assert our own guess back.
|
||||
#[test]
|
||||
fn who_line_tokens_fill_in_wire_order() {
|
||||
let mut line = WHO_LIST_GUILD_FORMAT.to_string();
|
||||
for fill in [
|
||||
"Tigole", "Tigole", "40", "Human", "Rogue", "Legacy", "Westfall",
|
||||
] {
|
||||
line = replace_first_token(&line, fill);
|
||||
}
|
||||
fn who_lines_fill_both_specifiers_in_wire_order() {
|
||||
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 = benilla_ui::script::UiScript::new().expect("VM");
|
||||
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let get = |key: &str| vm.lua().globals().get::<String>(key).ok();
|
||||
|
||||
let row =
|
||||
|name: &str, level: u32, guild: &str, race: &str, class: &str, zone: &str| WhoInfo {
|
||||
name: name.into(),
|
||||
level,
|
||||
guild: guild.into(),
|
||||
race: race.into(),
|
||||
class: class.into(),
|
||||
zone: zone.into(),
|
||||
};
|
||||
let lines: Vec<String> = who_lines(
|
||||
&[
|
||||
row("Tigole", 40, "Legacy", "Human", "Rogue", "Westfall"),
|
||||
row("Solo", 5, "", "Dwarf", "Priest", "Coldridge Valley"),
|
||||
],
|
||||
2,
|
||||
&get,
|
||||
)
|
||||
.iter()
|
||||
.map(|s| s.text().to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
line,
|
||||
lines[0],
|
||||
"|Hplayer:Tigole|h[Tigole]|h: Level 40 Human Rogue <Legacy> - Westfall"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unguilded template is the same walk minus the guild fill.
|
||||
#[test]
|
||||
fn the_unguilded_who_line_skips_the_guild() {
|
||||
let mut line = WHO_LIST_FORMAT.to_string();
|
||||
for fill in ["Solo", "Solo", "5", "Dwarf", "Priest", "Coldridge Valley"] {
|
||||
line = replace_first_token(&line, fill);
|
||||
}
|
||||
assert_eq!(
|
||||
line,
|
||||
"|Hplayer:Solo|h[Solo]|h: Level 5 Dwarf Priest - Coldridge Valley"
|
||||
lines[1], "|Hplayer:Solo|h[Solo]|h: Level 5 Dwarf Priest - Coldridge Valley",
|
||||
"the unguilded template skips the guild fill"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,35 +49,6 @@ mod query;
|
||||
|
||||
pub(crate) use query::parse as who_query;
|
||||
|
||||
/// The GlobalStrings templates for the friend/ignore results, quoted verbatim from the reference
|
||||
/// client's own patch chain (decision 0246 extraction; `GlobalStrings.lua` line cited per
|
||||
/// constant). Composed here rather than in Lua for the same reason the party lines are
|
||||
/// (decision 0434 §D2): the real client composes them engine-side, and the FrameXML never names
|
||||
/// these keys.
|
||||
const ERR_FRIEND_DB_ERROR: &str = "Friend lookup database error."; // GlobalStrings:1571
|
||||
const ERR_FRIEND_LIST_FULL: &str = "You don't have room for any more friends."; // :1573
|
||||
const ERR_FRIEND_ONLINE_SS: &str = "|Hplayer:%s|h[%s]|h has come online."; // :1576
|
||||
const ERR_FRIEND_OFFLINE_S: &str = "%s has gone offline."; // :1575
|
||||
const ERR_FRIEND_NOT_FOUND: &str = "Player not found."; // :1574
|
||||
const ERR_FRIEND_REMOVED_S: &str = "%s removed from friends list."; // :1577
|
||||
const ERR_FRIEND_ADDED_S: &str = "%s added to friends."; // :1569
|
||||
const ERR_FRIEND_ALREADY_S: &str = "%s is already your friend."; // :1570
|
||||
const ERR_FRIEND_SELF: &str = "You can't put yourself on your friend list."; // :1578
|
||||
const ERR_FRIEND_WRONG_FACTION: &str = "Friends must be part of your alliance."; // :1579
|
||||
const ERR_IGNORE_FULL: &str = "You can't ignore any more players."; // :1639
|
||||
const ERR_IGNORE_SELF: &str = "You can't ignore yourself."; // :1642
|
||||
const ERR_IGNORE_NOT_FOUND: &str = "Player not found."; // :1640
|
||||
const ERR_IGNORE_ALREADY_S: &str = "%s is already being ignored."; // :1637
|
||||
const ERR_IGNORE_ADDED_S: &str = "%s is now being ignored."; // :1636
|
||||
const ERR_IGNORE_REMOVED_S: &str = "%s is no longer being ignored."; // :1641
|
||||
const ERR_IGNORE_AMBIGUOUS: &str = "That name is ambiguous, type more of the player's server name"; // :1638
|
||||
const ERR_FRIEND_ERROR: &str = "Unknown friend response from server."; // :1572
|
||||
|
||||
/// The away tags the friends-list template's third `%s` takes — `CHAT_FLAG_AFK` / `CHAT_FLAG_DND`
|
||||
/// (`GlobalStrings.lua:766-767`), the same pair the chat frame prefixes a speaker's name with.
|
||||
const CHAT_FLAG_AFK: &str = "<AFK>";
|
||||
const CHAT_FLAG_DND: &str = "<DND>";
|
||||
|
||||
/// The social session mirror. Filled by the net drain's social arms, read by the feed, cleared on
|
||||
/// disconnect beside the other per-login resources.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -251,50 +222,70 @@ impl SocialState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The line one result prints, and whether it needs the subject's name first.
|
||||
/// The **message key** one friend/ignore result prints — resolved at the feed against the player's
|
||||
/// own `GlobalStrings.lua` (decision 2045), never composed here.
|
||||
///
|
||||
/// `%s` templates wait for the name query; the rest print immediately. This is the reference's
|
||||
/// own order (resolve, then compose — wow-re's text-emote flow, VERIFIED for the sibling path).
|
||||
fn result_template(result: u8) -> Option<&'static str> {
|
||||
/// The keys are what makes this table readable at all: two of its rows,
|
||||
/// `ERR_FRIEND_NOT_FOUND` and `ERR_IGNORE_NOT_FOUND`, are the *same sentence* in enUS ("Player not
|
||||
/// found.") and different strings everywhere else. A table of English cannot tell them apart, and
|
||||
/// nothing that compared displayed text — a test of ours included — ever could.
|
||||
///
|
||||
/// Composed engine-side in the reference too (decision 0434 §D2): the FrameXML never names these
|
||||
/// keys, so there is no Lua path that would resolve them for us.
|
||||
fn result_key(result: u8) -> Option<&'static str> {
|
||||
Some(match result {
|
||||
friend_result::DB_ERROR => ERR_FRIEND_DB_ERROR,
|
||||
friend_result::LIST_FULL => ERR_FRIEND_LIST_FULL,
|
||||
friend_result::ONLINE => ERR_FRIEND_ONLINE_SS,
|
||||
friend_result::OFFLINE => ERR_FRIEND_OFFLINE_S,
|
||||
friend_result::NOT_FOUND => ERR_FRIEND_NOT_FOUND,
|
||||
friend_result::REMOVED => ERR_FRIEND_REMOVED_S,
|
||||
friend_result::ADDED_ONLINE | friend_result::ADDED_OFFLINE => ERR_FRIEND_ADDED_S,
|
||||
friend_result::ALREADY => ERR_FRIEND_ALREADY_S,
|
||||
friend_result::SELF => ERR_FRIEND_SELF,
|
||||
friend_result::ENEMY => ERR_FRIEND_WRONG_FACTION,
|
||||
friend_result::IGNORE_FULL => ERR_IGNORE_FULL,
|
||||
friend_result::IGNORE_SELF => ERR_IGNORE_SELF,
|
||||
friend_result::IGNORE_NOT_FOUND => ERR_IGNORE_NOT_FOUND,
|
||||
friend_result::IGNORE_ALREADY => ERR_IGNORE_ALREADY_S,
|
||||
friend_result::IGNORE_ADDED => ERR_IGNORE_ADDED_S,
|
||||
friend_result::IGNORE_REMOVED => ERR_IGNORE_REMOVED_S,
|
||||
friend_result::IGNORE_AMBIGUOUS => ERR_IGNORE_AMBIGUOUS,
|
||||
friend_result::UNKNOWN => ERR_FRIEND_ERROR,
|
||||
friend_result::DB_ERROR => "ERR_FRIEND_DB_ERROR",
|
||||
friend_result::LIST_FULL => "ERR_FRIEND_LIST_FULL",
|
||||
friend_result::ONLINE => "ERR_FRIEND_ONLINE_SS",
|
||||
friend_result::OFFLINE => "ERR_FRIEND_OFFLINE_S",
|
||||
friend_result::NOT_FOUND => "ERR_FRIEND_NOT_FOUND",
|
||||
friend_result::REMOVED => "ERR_FRIEND_REMOVED_S",
|
||||
friend_result::ADDED_ONLINE | friend_result::ADDED_OFFLINE => "ERR_FRIEND_ADDED_S",
|
||||
friend_result::ALREADY => "ERR_FRIEND_ALREADY_S",
|
||||
friend_result::SELF => "ERR_FRIEND_SELF",
|
||||
friend_result::ENEMY => "ERR_FRIEND_WRONG_FACTION",
|
||||
friend_result::IGNORE_FULL => "ERR_IGNORE_FULL",
|
||||
friend_result::IGNORE_SELF => "ERR_IGNORE_SELF",
|
||||
friend_result::IGNORE_NOT_FOUND => "ERR_IGNORE_NOT_FOUND",
|
||||
friend_result::IGNORE_ALREADY => "ERR_IGNORE_ALREADY_S",
|
||||
friend_result::IGNORE_ADDED => "ERR_IGNORE_ADDED_S",
|
||||
friend_result::IGNORE_REMOVED => "ERR_IGNORE_REMOVED_S",
|
||||
friend_result::IGNORE_AMBIGUOUS => "ERR_IGNORE_AMBIGUOUS",
|
||||
friend_result::UNKNOWN => "ERR_FRIEND_ERROR",
|
||||
// An unknown code shows nothing — GlobalStrings data-suppression, the same face an
|
||||
// absent key wears everywhere else in this client.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fill a result template with the subject's name. `ERR_FRIEND_ONLINE_SS` takes it **twice**
|
||||
/// (the `|Hplayer:%s|h[%s]|h` link the comment in GlobalStrings explicitly warns not to
|
||||
/// localize), so a plain replace-all is the right substitution, not a positional one.
|
||||
fn fill_line(template: &str, name: &str) -> String {
|
||||
template.replace("%s", name)
|
||||
/// How many times the subject's name is pushed with a result's message — **the arity the key's own
|
||||
/// suffix names**, which is the reference's convention rather than ours: a bare key takes none,
|
||||
/// `_S` one, `_SS` two.
|
||||
///
|
||||
/// `ERR_FRIEND_ONLINE_SS`'s two are the `|Hplayer:%s|h[%s]|h` link's, and GlobalStrings' own
|
||||
/// comment beside it warns the link is not to be localized. Reading the arity off the key rather
|
||||
/// than counting `%s` in the resolved text is what keeps a line that has not resolved yet — an
|
||||
/// install without the string, a name still in flight — from being mistaken for one that takes no
|
||||
/// name at all.
|
||||
fn name_pushes(key: &str) -> usize {
|
||||
if key.ends_with("_SS") {
|
||||
2
|
||||
} else if key.ends_with("_S") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<AFK>`/`<DND>` tag a friend row's third `%s` takes.
|
||||
fn status_tag(status: u8) -> &'static str {
|
||||
match status {
|
||||
friend_status::AFK => CHAT_FLAG_AFK,
|
||||
friend_status::DND => CHAT_FLAG_DND,
|
||||
_ => "",
|
||||
}
|
||||
/// The `CHAT_FLAG_AFK`/`CHAT_FLAG_DND` key a friend row's away tag resolves through — the same
|
||||
/// pair the chat frame prefixes a speaker's name with. `None` = present and not flagged, which
|
||||
/// shows nothing.
|
||||
fn status_flag_key(status: u8) -> Option<&'static str> {
|
||||
Some(match status {
|
||||
friend_status::AFK => "CHAT_FLAG_AFK",
|
||||
friend_status::DND => "CHAT_FLAG_DND",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The net drain's `SessionEvent::Friend*`/`Who*` arms, factored here so the wire laws live
|
||||
@@ -452,19 +443,27 @@ mod tests {
|
||||
fn every_result_code_maps_to_a_line() {
|
||||
for result in 0x00..=0x11u8 {
|
||||
assert!(
|
||||
result_template(result).is_some(),
|
||||
result_key(result).is_some(),
|
||||
"result {result:#04x} has no line"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
result_template(friend_result::ADDED_ONLINE),
|
||||
result_template(friend_result::ADDED_OFFLINE),
|
||||
result_key(friend_result::ADDED_ONLINE),
|
||||
result_key(friend_result::ADDED_OFFLINE),
|
||||
);
|
||||
assert_eq!(result_key(friend_result::UNKNOWN), Some("ERR_FRIEND_ERROR"));
|
||||
assert_eq!(result_key(0x77), None, "an unknown code shows nothing");
|
||||
// **The two "Player not found." rows are different keys.** They read identically in enUS
|
||||
// and differently in other locales, so this pair is the reason the table names keys at
|
||||
// all — no assertion on displayed text could tell them apart (decision 2045).
|
||||
assert_eq!(
|
||||
result_key(friend_result::NOT_FOUND),
|
||||
Some("ERR_FRIEND_NOT_FOUND")
|
||||
);
|
||||
assert_eq!(
|
||||
result_template(friend_result::UNKNOWN),
|
||||
Some(ERR_FRIEND_ERROR)
|
||||
result_key(friend_result::IGNORE_NOT_FOUND),
|
||||
Some("ERR_IGNORE_NOT_FOUND")
|
||||
);
|
||||
assert_eq!(result_template(0x77), None, "an unknown code shows nothing");
|
||||
}
|
||||
|
||||
/// A logout drops the lists and the last `/who`, and keeps the **sort chain** — which is
|
||||
@@ -496,17 +495,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The online line takes the name twice (the player link); the rest take it once.
|
||||
/// **The arity comes off the key's suffix**, which is the reference's own marker: the online
|
||||
/// line's `_SS` takes the name twice (the `|Hplayer:%s|h[%s]|h` link), an `_S` once, and a
|
||||
/// bare key none. Read this way rather than by counting `%s` in the resolved sentence, so a
|
||||
/// line whose string has not resolved is never mistaken for one that needs no name.
|
||||
#[test]
|
||||
fn the_online_line_fills_the_name_twice() {
|
||||
assert_eq!(
|
||||
fill_line(ERR_FRIEND_ONLINE_SS, "Bob"),
|
||||
"|Hplayer:Bob|h[Bob]|h has come online."
|
||||
fn the_key_suffix_is_the_name_arity() {
|
||||
assert_eq!(name_pushes("ERR_FRIEND_ONLINE_SS"), 2);
|
||||
assert_eq!(name_pushes("ERR_FRIEND_ADDED_S"), 1);
|
||||
assert_eq!(name_pushes("ERR_FRIEND_SELF"), 0, "not an _S despite the S");
|
||||
}
|
||||
|
||||
/// **Every key this table names resolves in the shipped file, is a catalog row, and its own
|
||||
/// `%s` count matches the arity its suffix promises.** The three checks that replace comparing
|
||||
/// our copy of each sentence to the file's — and the third is the one the copies never made:
|
||||
/// nothing used to join `ERR_FRIEND_ONLINE_SS`'s two holes to the two pushes it gets.
|
||||
#[test]
|
||||
fn every_key_resolves_and_its_arity_matches_the_string() {
|
||||
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 s = benilla_ui::script::UiScript::new().expect("VM");
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
|
||||
let mut keys: Vec<&str> = (0x00..=0x11u8).filter_map(result_key).collect();
|
||||
keys.extend(
|
||||
[
|
||||
status_flag_key(friend_status::AFK),
|
||||
status_flag_key(friend_status::DND),
|
||||
]
|
||||
.map(Option::unwrap),
|
||||
);
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), 19, "17 result rows plus the two away tags");
|
||||
|
||||
for key in keys {
|
||||
let text: String = s.lua().globals().get(key).expect(key);
|
||||
assert!(!text.is_empty(), "{key} resolves empty");
|
||||
// The away tags are `CHAT_FLAG_*`, not `DisplayError` messages: no catalog row, and
|
||||
// none owed — they are a field on a friends-list row, not a line on a surface.
|
||||
if !key.starts_with("CHAT_FLAG_") {
|
||||
assert!(
|
||||
benilla_ui::messages::by_key(key).is_some(),
|
||||
"{key} is not a catalog row, so its surface and sound would be a guess"
|
||||
);
|
||||
assert_eq!(
|
||||
text.matches("%s").count(),
|
||||
name_pushes(key),
|
||||
"{key}: the string's holes vs the arity its suffix promises"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
fill_line(ERR_FRIEND_ADDED_S, "Bob"),
|
||||
"Bob added to friends."
|
||||
benilla_ui::messages::by_key("ERR_FRIEND_ONLINE_SS").and_then(|r| r.sound),
|
||||
Some("FRIENDJOINGAME"),
|
||||
"the cue the chat-log path was dropping"
|
||||
);
|
||||
assert_eq!(fill_line(ERR_FRIEND_SELF, "Bob"), ERR_FRIEND_SELF);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,24 @@
|
||||
//! `talent-api.md`); the frame's gold/green/gray availability compose Lua-side from the
|
||||
//! transcribed reference (tier gate, prereq triplets, meetsPrereq). The learn SEND gates on
|
||||
//! not-at-max only (LearnTalent `0x4f36a0`) — the server enforces the rest.
|
||||
//! - **tooltip req lines** (red, shown while locked): `TOOLTIP_TALENT_TIER_POINTS` ("Requires
|
||||
//! %d points in %s Talents") + `TOOLTIP_TALENT_PREREQ[_P1]` ("Requires %d point(s) in %s",
|
||||
//! the prereq talent's name) — GlobalStrings:4260-4263. A `required_spell` gap has no
|
||||
//! GlobalString and renders no line (the desaturation still communicates it).
|
||||
//! - **tooltip req lines** (red, shown while locked): `TOOLTIP_TALENT_TIER_POINTS` (`0x854a40`)
|
||||
//! for a short tab total, `TOOLTIP_TALENT_PREREQ[_P1]` (`0x854a5c`) per unmet prereq slot, and
|
||||
//! `ITEM_REQ_SKILL` (`0x84e338`, reused from the item tooltip) for an unmet `required_spell` —
|
||||
//! the three keys `0x52b390` emits, in wow-re `talent-api.md`'s own order. **Keys, resolved off
|
||||
//! the player's own `GlobalStrings.lua` at the feed** (decision 2045), never sentences written
|
||||
//! here; a key the install does not carry renders no line, which is the reference's own
|
||||
//! data-suppression face (the desaturation still communicates the lock).
|
||||
//! - The tooltip's spell parts (display + next rank) ride `ui_tooltip`'s spell channel, which
|
||||
//! pre-feeds every rank spell of the class's pages at arrival — a `SetTalent` hover hits on
|
||||
//! the FIRST enter (the reference's all-local instancy); its ask-once miss path stays as the
|
||||
//! odd-case fallback.
|
||||
//!
|
||||
//! The free-professions chat line (`CHARACTER_POINTS_CHANGED` → "You now have %d free
|
||||
//! profession(s)." — LEVEL_UP_SKILL_POINTS[_P1], the reference ChatFrame.lua:1326-1337) is
|
||||
//! composed here on a cp2 rise: this feed owns the points diff, and the chat arc's own kinds
|
||||
//! carry it as a SYSTEM line.
|
||||
//! The free-professions chat line (`CHARACTER_POINTS_CHANGED` → `LEVEL_UP_SKILL_POINTS[_P1]`,
|
||||
//! the reference ChatFrame.lua:1326-1337) is composed here on a cp2 rise: this feed owns the
|
||||
//! points diff, and the chat arc's own kinds carry it as a SYSTEM line. The token is **not** a
|
||||
//! message-catalog row — the reference resolves it in Lua and calls `AddMessage` itself — so it
|
||||
//! rides no keyed queue and carries no surface or sound of its own (decision 2054's `/ginfo`
|
||||
//! shape); only the wording comes off the install.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -36,10 +41,10 @@ use benilla_formats::{Talent, TalentCatalog};
|
||||
use benilla_ui::script::{
|
||||
ScriptValue, TalentPrereqView, TalentTabView, TalentUiState, TalentView, UiScript,
|
||||
};
|
||||
use benilla_ui::strings::{fill, Arg};
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{PlayerActions, Spells};
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_script::{gate, UiInput};
|
||||
use crate::ui_unit::UnitFeed;
|
||||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||||
@@ -100,7 +105,6 @@ fn feed_talents(
|
||||
spells: Option<Res<Spells>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
changed_self: Query<(), (With<SelfPlayer>, Changed<ObjectStore>)>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -142,14 +146,27 @@ fn feed_talents(
|
||||
store.0.player_talent_points().unwrap_or(0),
|
||||
store.0.player_free_professions().unwrap_or(0),
|
||||
);
|
||||
let fresh = build_pages(
|
||||
&talents.catalog,
|
||||
&actions.spells,
|
||||
spells,
|
||||
race,
|
||||
class,
|
||||
points,
|
||||
);
|
||||
// The reference strings the pages carry are read off the player's own `GlobalStrings.lua`
|
||||
// (2045). Scoped, because the lookup borrows the VM and the push below needs it mutably.
|
||||
let fresh = {
|
||||
let get = |key: &str| {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
};
|
||||
build_pages(
|
||||
&talents.catalog,
|
||||
&actions.spells,
|
||||
spells,
|
||||
race,
|
||||
class,
|
||||
points,
|
||||
&get,
|
||||
)
|
||||
};
|
||||
if fresh != memory.pushed {
|
||||
gate.audit("feed_talents", "the talent pages");
|
||||
debug!(
|
||||
@@ -180,18 +197,13 @@ fn feed_talents(
|
||||
],
|
||||
);
|
||||
}
|
||||
// The professions line rides the cp2 RISE only (a spend consumes, a rise frees — the
|
||||
// reference prints on arg2 > 0); the first observation seeds silently.
|
||||
if let Some((_, old_cp2)) = memory.points {
|
||||
if points.1 > old_cp2 {
|
||||
let text = if points.1 == 1 {
|
||||
"You now have 1 free profession.".to_string()
|
||||
} else {
|
||||
format!("You now have {} free professions.", points.1)
|
||||
};
|
||||
chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
|
||||
}
|
||||
}
|
||||
// **The professions line is not composed here, and must not be.** The event fired above IS
|
||||
// the line: stock `ChatFrame_OnEvent`'s `CHARACTER_POINTS_CHANGED` arm (`ChatFrame.lua`
|
||||
// l.1324-1334) reads `UnitCharacterPoints("player")` on `arg2 > 0` and prints
|
||||
// `GetText("LEVEL_UP_SKILL_POINTS", nil, cp2)` itself, and `benilla.toc` sources that file
|
||||
// off the player's own chain (1948). This feed used to print a second copy beside it —
|
||||
// `ui_chat::tests::the_free_professions_line_is_printed_once` is what asks the window rather
|
||||
// than reasoning about it, and it was the ding block's twin (see that test's neighbour).
|
||||
if memory.points != Some(points) {
|
||||
gate.audit("feed_talents", "the points memo");
|
||||
memory.points = Some(points);
|
||||
@@ -210,6 +222,7 @@ fn rank_of(t: &Talent, known: &BTreeSet<u32>) -> u32 {
|
||||
}
|
||||
|
||||
/// Build the pushed snapshot — the app's whole resolve (module doc).
|
||||
#[allow(clippy::too_many_arguments)] // the catalogs, the player's state, and the string table
|
||||
fn build_pages(
|
||||
catalog: &TalentCatalog,
|
||||
known: &BTreeSet<u32>,
|
||||
@@ -217,6 +230,9 @@ fn build_pages(
|
||||
race: u8,
|
||||
class: u8,
|
||||
points: (u32, u32),
|
||||
// The VM's own `GlobalStrings.lua`, for the three red requirement lines — `None` for a key
|
||||
// the install does not carry, which renders no line (decision 2045).
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> TalentUiState {
|
||||
let mut tabs = Vec::new();
|
||||
let mut pages = Vec::new();
|
||||
@@ -241,17 +257,17 @@ fn build_pages(
|
||||
let tier_unlocked = t.row * 5 <= spent;
|
||||
let mut req_lines = Vec::new();
|
||||
if !tier_unlocked {
|
||||
req_lines.push(format!(
|
||||
"Requires {} points in {} Talents",
|
||||
t.row * 5,
|
||||
tab.name
|
||||
));
|
||||
req_lines.extend(
|
||||
get("TOOLTIP_TALENT_TIER_POINTS")
|
||||
.map(|f| fill(&f, &[Arg::D(i64::from(t.row * 5)), Arg::S(&tab.name)])),
|
||||
);
|
||||
}
|
||||
// The requiredSpell line rides ITEM_REQ_SKILL "Requires %s" (byte-verified in the
|
||||
// SetTalent builder, wow-re talent-api.md).
|
||||
// The requiredSpell line rides ITEM_REQ_SKILL (`0x84e338`, the item tooltip's own
|
||||
// key reused) — byte-verified in the SetTalent builder, wow-re talent-api.md. It is
|
||||
// NOT the `LOCKED_WITH_*`/`SPELL_FAILED_*` family, which reads identically in enUS.
|
||||
if !meets_prereq {
|
||||
if let Some(req) = spells.catalog.get(t.required_spell) {
|
||||
req_lines.push(format!("Requires {}", req.name));
|
||||
req_lines.extend(get("ITEM_REQ_SKILL").map(|f| fill(&f, &[Arg::S(&req.name)])));
|
||||
}
|
||||
}
|
||||
let mut prereqs = Vec::new();
|
||||
@@ -270,11 +286,10 @@ fn build_pages(
|
||||
.get(p.ranks[0])
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_default();
|
||||
req_lines.push(if need == 1 {
|
||||
format!("Requires 1 point in {p_name}")
|
||||
} else {
|
||||
format!("Requires {need} points in {p_name}")
|
||||
});
|
||||
req_lines.extend(
|
||||
benilla_ui::strings::plural("TOOLTIP_TALENT_PREREQ", Some(need), get)
|
||||
.map(|f| fill(&f, &[Arg::D(i64::from(need)), Arg::S(&p_name)])),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::{TooltipTint, UiScript, UnitState};
|
||||
use benilla_ui::strings::Arg;
|
||||
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
@@ -52,6 +53,19 @@ struct ViewCtx<'a> {
|
||||
items: &'a mut Items,
|
||||
commands: &'a NetCommands,
|
||||
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
|
||||
/// The VM's own `GlobalStrings.lua` (decision 2045) — every cell this builder composes is a
|
||||
/// key, and this is where they resolve. `text` is the `%d`-filling twin the `$`-engine's
|
||||
/// keyed tokens take (`benilla_formats::TokenContext::text`).
|
||||
get: &'a dyn Fn(&str) -> Option<String>,
|
||||
text: &'a dyn Fn(&str, &[i64]) -> Option<String>,
|
||||
}
|
||||
|
||||
/// Fill a key's template out of the VM's own `GlobalStrings.lua`, or nothing at all when the
|
||||
/// chain has no string for it — the reference's data-suppression face, and the reason no cell
|
||||
/// here carries a fallback sentence (decision 2045).
|
||||
fn keyed(get: &dyn Fn(&str) -> Option<String>, key: &str, args: &[Arg<'_>]) -> Option<String> {
|
||||
let text = benilla_ui::strings::fill(&get(key)?, args);
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Build one spell's tooltip view (decision 0274 P2) — the verified spell line law's inputs,
|
||||
@@ -83,6 +97,7 @@ fn spell_tooltip_view(
|
||||
radii: &spells.radii,
|
||||
lookup: &|id| spells.catalog.get(id),
|
||||
home_area,
|
||||
text: vctx.text,
|
||||
};
|
||||
// The cost cell (law §3.3, the `0x52e8ad` caller): the RESOLVED cost — `GetPowerCost`'s
|
||||
// number, the same `power_cost` the usable walk compares (0948) — through the power-type key
|
||||
@@ -123,14 +138,30 @@ fn spell_tooltip_view(
|
||||
};
|
||||
let range = spells.ranges.get(d.range_index).and_then(|r| {
|
||||
if r.is_melee() {
|
||||
// **INTERIM, and a known divergence — the reference has no melee wording at all.**
|
||||
//
|
||||
// The guess this line used to carry ("the display-name column of `SpellRange.dbc`")
|
||||
// is REFUTED: a melee spell is simply one whose range row sets flags bit 0 — the one
|
||||
// shipped such row is id 2 "Combat Range", 813 spells — and `0x6e3480` computes
|
||||
// `max = casterReach + reach + 1.3333334` floored at `5.0`, which then renders
|
||||
// through the very same `SPELL_RANGE` + `"%d"` path as every other range. On a player
|
||||
// that is normally **"5 yd range"**, not a word.
|
||||
//
|
||||
// Not converted here because the faithful number needs the caster's own
|
||||
// `UNIT_FIELD_COMBATREACH`, which this builder does not read yet — a data change, not
|
||||
// a string one. Verified in wow-re `system/ui/scratch/tooltip-globalstring-key-resolves.md`
|
||||
// §A3; named in decision 2080 rather than guessed at.
|
||||
Some("Melee Range".to_string())
|
||||
} else if r.max > 0.0 {
|
||||
// The law's fork (`0x854fb4`): a nonzero min prints the "%d-%d" pair (Charge: 8-25).
|
||||
Some(if r.min > 0.0 {
|
||||
format!("{}-{} yd range", r.min as i32, r.max as i32)
|
||||
// `SPELL_RANGE = "%s yd range"` — and its hole is a **string**, which is what makes
|
||||
// the law's pair fork (`0x854fb4`'s `"%d-%d"`, Charge: 8-25) expressible at all: the
|
||||
// number cell is composed first and handed over whole.
|
||||
let yards = if r.min > 0.0 {
|
||||
format!("{}-{}", r.min as i32, r.max as i32)
|
||||
} else {
|
||||
format!("{} yd range", r.max as i32)
|
||||
})
|
||||
format!("{}", r.max as i32)
|
||||
};
|
||||
keyed(vctx.get, "SPELL_RANGE", &[Arg::S(&yards)])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -151,37 +182,49 @@ fn spell_tooltip_view(
|
||||
.get(d.casting_time_index)
|
||||
.map(|c| c.base_ms as i32)
|
||||
.unwrap_or(0);
|
||||
Some(if base > 0 {
|
||||
if base >= 60_000 {
|
||||
format!("{} min cast", trim_secs(f64::from(base) / 60_000.0))
|
||||
// Each arm is a key. The two timed ones are `%.3g` templates — significant digits, not
|
||||
// decimals — so the seconds are handed over as a real and the shipped string decides how
|
||||
// it reads (decision 2080); this used to round to one decimal on our side.
|
||||
if base > 0 {
|
||||
let (key, v) = if base >= 60_000 {
|
||||
("SPELL_CAST_TIME_MIN", f64::from(base) / 60_000.0)
|
||||
} else {
|
||||
format!("{} sec cast", trim_secs(f64::from(base) / 1000.0))
|
||||
}
|
||||
} else if base < 0 {
|
||||
"Instant cast".to_string()
|
||||
} else if d.on_next_swing() {
|
||||
"Next melee".to_string()
|
||||
} else if d.tooltip_on_next_ranged() {
|
||||
"Attack speed".to_string()
|
||||
} else if d.tooltip_channeled() {
|
||||
"Channeled".to_string()
|
||||
} else if d.power_type == 0 && resolved_cost > 0 {
|
||||
"Instant cast".to_string()
|
||||
("SPELL_CAST_TIME_SEC", f64::from(base) / 1000.0)
|
||||
};
|
||||
keyed(vctx.get, key, &[Arg::F(v)])
|
||||
} else {
|
||||
"Instant".to_string()
|
||||
})
|
||||
let key = if base < 0 {
|
||||
"SPELL_CAST_TIME_INSTANT"
|
||||
} else if d.on_next_swing() {
|
||||
"SPELL_ON_NEXT_SWING"
|
||||
} else if d.tooltip_on_next_ranged() {
|
||||
"SPELL_ON_NEXT_RANGED"
|
||||
} else if d.tooltip_channeled() {
|
||||
"SPELL_CAST_CHANNELED"
|
||||
} else if d.power_type == 0 && resolved_cost > 0 {
|
||||
"SPELL_CAST_TIME_INSTANT"
|
||||
} else {
|
||||
// "Instant" without the "cast" — its own key, and the reason the mana fork above
|
||||
// exists at all.
|
||||
"SPELL_CAST_TIME_INSTANT_NO_MANA"
|
||||
};
|
||||
keyed(vctx.get, key, &[])
|
||||
}
|
||||
};
|
||||
// The cooldown cell reads BOTH recovery columns (law §3.4: `max([+0x4c],[+0x50])>0` —
|
||||
// Charge's 15 s is CategoryRecoveryTime; its RecoveryTime is 0).
|
||||
let recovery_ms = d.recovery_ms.max(d.category_recovery_ms);
|
||||
let cooldown = (recovery_ms > 0).then(|| {
|
||||
let secs = f64::from(recovery_ms) / 1000.0;
|
||||
if secs >= 60.0 {
|
||||
format!("{} min cooldown", trim_secs(secs / 60.0))
|
||||
} else {
|
||||
format!("{} sec cooldown", trim_secs(secs))
|
||||
}
|
||||
});
|
||||
let cooldown = (recovery_ms > 0)
|
||||
.then(|| {
|
||||
let secs = f64::from(recovery_ms) / 1000.0;
|
||||
let (key, v) = if secs >= 60.0 {
|
||||
("SPELL_RECAST_TIME_MIN", secs / 60.0)
|
||||
} else {
|
||||
("SPELL_RECAST_TIME_SEC", secs)
|
||||
};
|
||||
keyed(vctx.get, key, &[Arg::F(v)])
|
||||
})
|
||||
.flatten();
|
||||
// The required-form line (law §3.6): the Stances mask's form names off
|
||||
// SpellShapeshiftForm.dbc, joined; bit b = form id b+1. Met against the CURRENT form.
|
||||
//
|
||||
@@ -209,7 +252,15 @@ fn spell_tooltip_view(
|
||||
.filter_map(|b| spells.forms.get(&(b + 1)).map(|f| f.name.as_str()))
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
(!names.is_empty()).then(|| format!("Requires {}", names.join(", ")))
|
||||
(!names.is_empty())
|
||||
.then(|| {
|
||||
keyed(
|
||||
vctx.get,
|
||||
"SPELL_REQUIRED_FORM",
|
||||
&[Arg::S(&names.join(", "))],
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.flatten();
|
||||
let form_met = form != 0 && d.stances & (1u32 << (u32::from(form) - 1)) != 0;
|
||||
@@ -225,7 +276,7 @@ fn spell_tooltip_view(
|
||||
.requirement_name(d.equipped_item_class as u32, d.equipped_item_subclass_mask)
|
||||
})
|
||||
.flatten()
|
||||
.map(|name| format!("Requires {name}"));
|
||||
.and_then(|name| keyed(vctx.get, "SPELL_EQUIPPED_ITEM", &[Arg::S(&name)]));
|
||||
// The chance-to-X line (law line 10, §3-CHANCE): `Effect[0]` picks which of the player's four
|
||||
// avoidance/crit percentages to print, and — except for ATTACK, which bypasses the gate — the
|
||||
// spell must be passive. The percentages are already percents on the wire.
|
||||
@@ -322,15 +373,6 @@ fn chance_line(d: &benilla_formats::SpellDisplay, store: Option<&ObjectStore>) -
|
||||
Some(format!("{percentage:.2}% chance to {label}"))
|
||||
}
|
||||
|
||||
/// The `%.3g`-style terse seconds (1.5 → "1.5", 2.0 → "2") — the SPELL_CAST_TIME/RECAST shape.
|
||||
fn trim_secs(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 {
|
||||
format!("{}", v.round() as i64)
|
||||
} else {
|
||||
format!("{v:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The push loop's change detectors — everything a view SNAPSHOTS at build time. A change to any
|
||||
/// of them means every pushed view is stale, so the loop re-pushes the lot (the reference simply
|
||||
/// re-runs its builder on every hover, so a stale snapshot is a benilla-only failure mode).
|
||||
@@ -498,35 +540,48 @@ fn feed_spell_tooltips(
|
||||
memory.reagents = reagent_state;
|
||||
wanted.extend(memory.pushed.drain());
|
||||
}
|
||||
let mut vctx = ViewCtx {
|
||||
home_area: home_area.as_deref(),
|
||||
form,
|
||||
store: self_store,
|
||||
items: &mut items,
|
||||
commands: &commands,
|
||||
sub_classes: sub_classes.as_deref().map(|c| &c.0),
|
||||
};
|
||||
for id in wanted {
|
||||
if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) {
|
||||
// Register this spell's reagents in the watch set, seeded with the state the view was
|
||||
// just built against — so the next recompute re-pushes on a REAL change only.
|
||||
if let Some(d) = spells.catalog.get(id) {
|
||||
for (entry, _) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
|
||||
if let std::collections::btree_map::Entry::Vacant(slot) =
|
||||
memory.reagents.entry(entry)
|
||||
{
|
||||
let named = vctx.items.template(entry, 0, vctx.commands).is_some();
|
||||
let owned = vctx.store.map_or(0, |s| {
|
||||
count_of(&s.0, vctx.items, entry, InventoryScope::CARRIED)
|
||||
});
|
||||
slot.insert((owned, named));
|
||||
// The string table is read while the views are BUILT and the store is written when they are
|
||||
// PUSHED, so the two passes are split — one borrow of the VM cannot be both, and the build's
|
||||
// borrow ends with this block.
|
||||
let mut built: Vec<(u32, benilla_ui::script::SpellTooltipView)> = Vec::new();
|
||||
{
|
||||
let get = |key: &str| benilla_ui::strings::global(script.lua(), key);
|
||||
let text = crate::ui_script::token_text(&script);
|
||||
let mut vctx = ViewCtx {
|
||||
home_area: home_area.as_deref(),
|
||||
form,
|
||||
store: self_store,
|
||||
items: &mut items,
|
||||
commands: &commands,
|
||||
sub_classes: sub_classes.as_deref().map(|c| &c.0),
|
||||
get: &get,
|
||||
text: &text,
|
||||
};
|
||||
for id in wanted {
|
||||
if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) {
|
||||
// Register this spell's reagents in the watch set, seeded with the state the view was
|
||||
// just built against — so the next recompute re-pushes on a REAL change only.
|
||||
if let Some(d) = spells.catalog.get(id) {
|
||||
for (entry, _) in d.reagents.iter().copied().filter(|&(e, _)| e != 0) {
|
||||
if let std::collections::btree_map::Entry::Vacant(slot) =
|
||||
memory.reagents.entry(entry)
|
||||
{
|
||||
let named = vctx.items.template(entry, 0, vctx.commands).is_some();
|
||||
let owned = vctx.store.map_or(0, |s| {
|
||||
count_of(&s.0, vctx.items, entry, InventoryScope::CARRIED)
|
||||
});
|
||||
slot.insert((owned, named));
|
||||
}
|
||||
}
|
||||
}
|
||||
built.push((id, view));
|
||||
memory.pushed.insert(id);
|
||||
}
|
||||
script.set_spell_tooltip(id, view);
|
||||
memory.pushed.insert(id);
|
||||
}
|
||||
}
|
||||
for (id, view) in built {
|
||||
script.set_spell_tooltip(id, view);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the tooltip was last driven for — the change detector (the byte law rebuilds once per
|
||||
@@ -553,18 +608,6 @@ fn lines_view(s: &UnitState) -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LockType` index → the requirement word (the `LOCKED_WITH_SPELL[_KNOWN]` "Requires %s" text
|
||||
/// for skill locks — vanilla's small fixed vocabulary; item-key locks name the item instead).
|
||||
fn lock_type_word(index: u32) -> Option<&'static str> {
|
||||
Some(match index {
|
||||
1 => "Lockpicking",
|
||||
2 => "Herbalism",
|
||||
3 => "Mining",
|
||||
4 => "Disarm Trap",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The **"Locked" line's colour** (`0x52ab03`-`0x52ab43`, decision 0770).
|
||||
///
|
||||
/// The builder seats red `0xc0d3a8` as the default *before* it calls the resolver, then re-colours
|
||||
@@ -718,7 +761,16 @@ fn drive_mouseover_tooltip(
|
||||
let Some(name) = names.resolve(owner, &commands).map(str::to_string) else {
|
||||
return;
|
||||
};
|
||||
script.world_tooltip_gameobject(&format!("Corpse of {name}"), &[], None);
|
||||
// `CORPSE_TOOLTIP = "Corpse of %s"` — the builder's own key (`0x52aef0`). No string, no
|
||||
// plate, which is the reference's data-suppression face.
|
||||
let Some(plate) = keyed(
|
||||
&|key: &str| benilla_ui::strings::global(script.lua(), key),
|
||||
"CORPSE_TOOLTIP",
|
||||
&[Arg::S(&name)],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
script.world_tooltip_gameobject(&plate, &[], None);
|
||||
*last = LastHover::Corpse(guid);
|
||||
return;
|
||||
}
|
||||
@@ -793,6 +845,9 @@ fn drive_mouseover_tooltip(
|
||||
.filter(|_| template.lock_id != 0)
|
||||
.and_then(|l| l.0.slots(template.lock_id));
|
||||
let mut lines: Vec<(String, TooltipTint)> = Vec::new();
|
||||
// The lock lines' string table, taken for the length of the build. Every one of them is a
|
||||
// key whose enUS value is "Requires %s" and whose identity only the binary settles.
|
||||
let go_get = |key: &str| benilla_ui::strings::global(script.lua(), key);
|
||||
if flag_locked {
|
||||
// **The "Locked" line is coloured by whether you can actually open it** (director-
|
||||
// reported: a door you hold the key for read red). The builder sets red `0xc0d3a8` as
|
||||
@@ -820,7 +875,9 @@ fn drive_mouseover_tooltip(
|
||||
&mut matched,
|
||||
)
|
||||
});
|
||||
lines.push(("Locked".to_string(), locked_line_tint(outcome)));
|
||||
if let Some(text) = keyed(&go_get, "LOCKED", &[]) {
|
||||
lines.push((text, locked_line_tint(outcome)));
|
||||
}
|
||||
}
|
||||
if let Some(slot0) = slots
|
||||
.map(|s| s[0])
|
||||
@@ -829,7 +886,12 @@ fn drive_mouseover_tooltip(
|
||||
match slot0.key_type {
|
||||
benilla_formats::LOCK_KEY_ITEM => {
|
||||
if let Some(t) = go_inputs.items.template(slot0.index, 0, &commands) {
|
||||
lines.push((format!("Requires {}", t.name), TooltipTint::White));
|
||||
// `LOCKED_WITH_ITEM`, resolved at `0x52acb8` — one of eleven 1.12 keys
|
||||
// whose enUS value is exactly "Requires %s", and the only one this arm
|
||||
// reaches (wow-re `tooltip-globalstring-key-resolves.md` §B).
|
||||
if let Some(text) = keyed(&go_get, "LOCKED_WITH_ITEM", &[Arg::S(&t.name)]) {
|
||||
lines.push((text, TooltipTint::White));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The opener-*known* arm additionally wants the reference's skill-margin colour
|
||||
@@ -837,8 +899,23 @@ fn drive_mouseover_tooltip(
|
||||
// model is the unknown arm, which is what a hovering player almost always is. A
|
||||
// flagged object stays silent there, exactly as the binary does.
|
||||
benilla_formats::LOCK_KEY_SKILL if !flag_locked => {
|
||||
if let Some(word) = lock_type_word(slot0.index) {
|
||||
lines.push((format!("Requires {word}"), TooltipTint::Red));
|
||||
// **`LOCKED_WITH_SPELL`, not `LOCKED_WITH_SPELL_KNOWN`** — the two are the
|
||||
// same sentence in enUS and the byte test between them is a pure MEMBERSHIP
|
||||
// one: `0x5f83d0` writes a non-zero spell id into its first out-param iff the
|
||||
// player knows *any* spell that opens this LockType, and `0x52abcb` branches
|
||||
// on that. This is the not-known arm (the flag-clear leg at `0x52ac04`);
|
||||
// skill *sufficiency* never changes the key, only the colour.
|
||||
//
|
||||
// The `%s` is `LockType.dbc`'s own localized `Name` — "Pick Lock", not
|
||||
// "Lockpicking", which is what a hand-typed table here used to say.
|
||||
let word = go_inputs
|
||||
.lock_types
|
||||
.as_deref()
|
||||
.and_then(|c| c.0.name(slot0.index));
|
||||
if let Some(text) =
|
||||
word.and_then(|w| keyed(&go_get, "LOCKED_WITH_SPELL", &[Arg::S(w)]))
|
||||
{
|
||||
lines.push((text, TooltipTint::Red));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -10,15 +10,40 @@ struct TestCtx {
|
||||
items: Items,
|
||||
commands: NetCommands,
|
||||
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
|
||||
/// The two lookups the builder resolves through, over the **shipped** `GlobalStrings.lua` in
|
||||
/// a VM this harness owns.
|
||||
///
|
||||
/// Every cell this builder composes is a key resolved at runtime (decision 2045), so a test
|
||||
/// that asserts a rendered cell has to grade it against the player's own table — a stub would
|
||||
/// pass on wording the client never shows, which is the trap 2052 named when it moved the
|
||||
/// glue tests onto the loader's own assembly.
|
||||
get: Box<Getter>,
|
||||
text: Box<Filler>,
|
||||
}
|
||||
|
||||
/// The two lookup shapes, named so the harness's fields read.
|
||||
type Getter = dyn Fn(&str) -> Option<String>;
|
||||
type Filler = dyn Fn(&str, &[i64]) -> Option<String>;
|
||||
|
||||
impl TestCtx {
|
||||
fn new() -> Self {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let vm = std::rc::Rc::new(benilla_ui::script::UiScript::new().expect("VM"));
|
||||
crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
let (for_get, for_text) = (vm.clone(), vm);
|
||||
Self {
|
||||
items: Items::default(),
|
||||
commands: NetCommands(tx),
|
||||
_rx: rx,
|
||||
get: Box::new(move |key| benilla_ui::strings::global(for_get.lua(), key)),
|
||||
text: Box::new(move |key, args: &[i64]| {
|
||||
let template = benilla_ui::strings::global(for_text.lua(), key)?;
|
||||
let args: Vec<_> = args
|
||||
.iter()
|
||||
.map(|n| benilla_ui::strings::Arg::D(*n))
|
||||
.collect();
|
||||
Some(benilla_ui::strings::fill(&template, &args))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +68,8 @@ impl TestCtx {
|
||||
items: &mut self.items,
|
||||
commands: &self.commands,
|
||||
sub_classes,
|
||||
get: self.get.as_ref(),
|
||||
text: self.text.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,13 +163,14 @@ pub(super) fn service_tooltip(wire_spell: u32, spells: &SpellCatalog) -> Trainer
|
||||
/// implementation gets wrong even when its ordering is right: the partition is **total**, so no row
|
||||
/// is ever dropped at a tradeskill trainer; and the header vocabulary is the client's own label table
|
||||
/// (`0x807520 + key * 0x40`), whose two entries are the global strings `TRADESKILL_SERVICE_STEP`
|
||||
/// ("Development Skills") and `TRADESKILL_SERVICE_LEARN` ("Recipes") — 1.12.1 enUS `GlobalStrings.lua`,
|
||||
/// the same inlining [`super::train_error_text`] does for `ERR_NOT_ENOUGH_MONEY`.
|
||||
/// and `TRADESKILL_SERVICE_LEARN` — **keys**, read off the player's own table at the feed rather
|
||||
/// than re-typed here (decision 2045); a header the install cannot name comes back empty, which is
|
||||
/// the reference's own data-suppression face.
|
||||
///
|
||||
/// **Type 1** (mount — the client's "talent") is a *hybrid*, not a third predicate: an
|
||||
/// already-known service (`state == 2`) goes to the signed **`-1`** group (`0x4d77e8`) under the
|
||||
/// `KNOWN_TALENTS_HEADER` global string ("My Talents"); everything else takes the same skill-line
|
||||
/// path as types 0/3.
|
||||
/// `KNOWN_TALENTS_HEADER` global string; everything else takes the same skill-line path as
|
||||
/// types 0/3.
|
||||
///
|
||||
/// **Types 0/3 (and type 1's remainder)** key on the **TAUGHT** spell's `SkillLine`
|
||||
/// (`0x4d7c60`→`0x60c920`, decision 0247 — the one hop in this file that survived 1124's audit); an
|
||||
@@ -181,19 +182,24 @@ pub(super) fn service_group(
|
||||
category: TrainerServiceCategory,
|
||||
spells: &SpellCatalog,
|
||||
skill_lines: Option<&SkillLineCatalog>,
|
||||
// The VM's own `GlobalStrings.lua` — the two label-table entries and the known-talents header.
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> (u32, String) {
|
||||
if trainer_type == TRAINER_TYPE_TRADESKILL {
|
||||
let step = spells
|
||||
.get(wire_spell)
|
||||
.is_some_and(|d| d.effects.contains(&SPELL_EFFECT_SKILL_STEP));
|
||||
return if step {
|
||||
(1, "Development Skills".to_string())
|
||||
(1, get("TRADESKILL_SERVICE_STEP").unwrap_or_default())
|
||||
} else {
|
||||
(2, "Recipes".to_string())
|
||||
(2, get("TRADESKILL_SERVICE_LEARN").unwrap_or_default())
|
||||
};
|
||||
}
|
||||
if trainer_type == TRAINER_TYPE_MOUNT && category == TrainerServiceCategory::Used {
|
||||
return (TRAINER_GROUP_KNOWN, "My Talents".to_string());
|
||||
return (
|
||||
TRAINER_GROUP_KNOWN,
|
||||
get("KNOWN_TALENTS_HEADER").unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
let line = skill_lines
|
||||
.and_then(|c| c.spell_to_line(taught))
|
||||
|
||||
@@ -148,19 +148,34 @@ impl Plugin for UiTrainerPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The red error-line text for a trainer refusal (`SMSG_TRAINER_BUY_FAILED`'s
|
||||
/// [`benilla_protocol::messages::train_fail`] code). The money case is the exact enUS `GlobalString`
|
||||
/// (`ERR_NOT_ENOUGH_MONEY`); the skill/unavailable cases are best-effort English — the real client
|
||||
/// builds these in C++, so their exact wording is a wow-re confirm item (rarely reached: the Train
|
||||
/// button is disabled unless the service is available and affordable).
|
||||
fn train_error_text(code: u32) -> String {
|
||||
/// A trainer refusal (`SMSG_TRAINER_BUY_FAILED`'s
|
||||
/// [`benilla_protocol::messages::train_fail`] code) is **silent on every player-facing surface**,
|
||||
/// and goes to the log instead. That is the reference's own behaviour, byte-carved rather than
|
||||
/// assumed (wow-re dispatch, 2026-09-07; handler `0x5e5f10`, registered at `0x5e3291`, arms at
|
||||
/// `0x5e5f95` / `0x5e5fb8` / `0x5e5fdc`, a code ≥ 3 doing nothing at all):
|
||||
///
|
||||
/// - there is **no GlobalStrings key and no message-catalog id** — the handler passes neither;
|
||||
/// - there is **no `DisplayError 0x496720` and no `0x4945b0`** anywhere in its body, so the line
|
||||
/// never reaches `UIErrorsFrame`, the info line, or chat;
|
||||
/// - all three arms call **`0x63cd00`**, the developer console's printf, with a hardcoded C format
|
||||
/// string whose `%d` is the spell id — the strings below, which are the *client's own* and are
|
||||
/// each referenced exactly once image-wide (`0x8604bc`, `0x860494`, `0x860464`);
|
||||
/// - no Lua event fires either: the reference UI knows only `TRAINER_SHOW`/`TRAINER_CLOSED`/
|
||||
/// `TRAINER_UPDATE`, and `BuyTrainerService 0x4da210` runs no client-side money or skill test
|
||||
/// before sending, so there is no second path that would speak.
|
||||
///
|
||||
/// benilla showed a red `UI_ERROR_MESSAGE` here, one of whose three sentences was a re-typed
|
||||
/// `ERR_NOT_ENOUGH_MONEY` and two of which were invented (decision 2045). Both problems have the
|
||||
/// same answer, and it is the reference's: say it to the log and nothing to the player. Rarely
|
||||
/// reached either way — the Train button is disabled unless the service is available and
|
||||
/// affordable.
|
||||
fn train_error_log_line(code: u32) -> &'static str {
|
||||
use benilla_protocol::messages::train_fail;
|
||||
match code {
|
||||
train_fail::NOT_ENOUGH_MONEY => "You don't have enough money.",
|
||||
train_fail::NOT_ENOUGH_SKILL => "You do not have the required skill.",
|
||||
_ => "That trainer service is not available.",
|
||||
train_fail::NOT_ENOUGH_MONEY => "Not enough money for trainer service",
|
||||
train_fail::NOT_ENOUGH_SKILL => "Not enough skill points for trainer service",
|
||||
_ => "Trainer service unavailable",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Resolve one wire [`TrainerSpell`] into the Lua-facing [`TrainerService`]: name/subtext/icon from
|
||||
@@ -179,6 +194,8 @@ fn resolve_service(
|
||||
icons: Option<&ItemDisplays>,
|
||||
items: &mut Items,
|
||||
commands: &NetCommands,
|
||||
// The VM's own `GlobalStrings.lua`, for [`service_group`]'s three header labels.
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> TrainerService {
|
||||
// The trainer offers a LEARN wrapper (decision 0247); the ability it teaches is the taught
|
||||
// spell, and the tree GROUPS by that hop (`0x4d7c60` → `[skillrec+4]`). It is the only thing
|
||||
@@ -244,8 +261,15 @@ fn resolve_service(
|
||||
// unresolved `0` drops the service from the tree exactly as the client's builder does. Log the
|
||||
// genuine miss (catalog present but no line) so DBC gaps surface rather than silently swallowing
|
||||
// a service the server offered. The tradeskill arm resolves no line at all and cannot miss.
|
||||
let (group_key, group_name) =
|
||||
service_group(wire.spell, taught, trainer_type, cat, spells, skill_lines);
|
||||
let (group_key, group_name) = service_group(
|
||||
wire.spell,
|
||||
taught,
|
||||
trainer_type,
|
||||
cat,
|
||||
spells,
|
||||
skill_lines,
|
||||
get,
|
||||
);
|
||||
if group_key == 0 && skill_lines.is_some() {
|
||||
debug!(
|
||||
"ui_trainer: trainer spell {} (teaches {taught}) has no skill line — dropped from the tree",
|
||||
@@ -282,7 +306,7 @@ fn resolve_service(
|
||||
|
||||
/// Build the Lua-facing snapshot from [`TrainerOpen`] + the spell/skill catalogs — `None` when no
|
||||
/// trainer is open.
|
||||
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
|
||||
#[allow(clippy::too_many_arguments)] // the catalogs, the player's state, and the string table
|
||||
fn snapshot(
|
||||
open: &TrainerOpen,
|
||||
spells: &SpellCatalog,
|
||||
@@ -291,6 +315,7 @@ fn snapshot(
|
||||
icons: Option<&ItemDisplays>,
|
||||
items: &mut Items,
|
||||
commands: &NetCommands,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<TrainerState> {
|
||||
open.trainer?;
|
||||
Some(TrainerState {
|
||||
@@ -309,6 +334,7 @@ fn snapshot(
|
||||
icons,
|
||||
items,
|
||||
commands,
|
||||
get,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
@@ -347,12 +373,12 @@ fn feed_trainer(
|
||||
let last = last.get(&script);
|
||||
let last_trainer = last_trainer.get(&script);
|
||||
let last_name = last_name.get(&script);
|
||||
// Refusals surface as the client's red error line (the merchant/equip/cast path's exact shape).
|
||||
// Refusals go to the log and nowhere else — the reference's own console-only handling
|
||||
// ([`train_error_log_line`]). Logged rather than dropped for the reason 0651 logs the server's
|
||||
// dot-command answers: a refusal nothing said and a packet nothing noticed look identical
|
||||
// afterwards.
|
||||
for code in errors.0.drain(..) {
|
||||
script.fire_event(
|
||||
"UI_ERROR_MESSAGE",
|
||||
vec![ScriptValue::Str(train_error_text(code))],
|
||||
);
|
||||
info!("ui_trainer: {} (code {code})", train_error_log_line(code));
|
||||
}
|
||||
// Nothing to resolve a name/icon from yet — try again once Spell.dbc lands.
|
||||
let Some(spells) = spells.as_deref() else {
|
||||
@@ -380,6 +406,14 @@ fn feed_trainer(
|
||||
icons.as_deref(),
|
||||
&mut items,
|
||||
&commands,
|
||||
&|key: &str| {
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(key)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
},
|
||||
);
|
||||
// The trainer's name resolves through the NameCache (a creature-name query, ask-once — the
|
||||
// real client's `UnitName("npc")`). `None`/empty while in flight; the title shows the static
|
||||
|
||||
@@ -20,6 +20,21 @@ fn empty_catalog() -> SpellCatalog {
|
||||
/// default; the tests that want a landed template seed one.
|
||||
use crate::items::TestDeps as Deps;
|
||||
|
||||
/// The three group-header labels [`super::law::service_group`] reads, spelled **unlike** the
|
||||
/// shipped ones on purpose (decision 2045): `TRADESKILL_SERVICE_STEP`/`_LEARN` and
|
||||
/// `KNOWN_TALENTS_HEADER` are single-value keys, so an assertion on their enUS wording would pass
|
||||
/// for any lookup at all. Naming the key in the value makes each assertion say *which* lookup the
|
||||
/// arm made. The real table is checked by
|
||||
/// [`the_group_header_keys_resolve_in_the_real_global_strings`].
|
||||
fn probe_strings(key: &str) -> Option<String> {
|
||||
match key {
|
||||
"TRADESKILL_SERVICE_STEP" => Some("<STEP>".into()),
|
||||
"TRADESKILL_SERVICE_LEARN" => Some("<LEARN>".into()),
|
||||
"KNOWN_TALENTS_HEADER" => Some("<KNOWN>".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`resolve_service`] with the icon trio defaulted — the shape the gate/state tests want.
|
||||
fn resolve_service(
|
||||
wire: &TrainerSpell,
|
||||
@@ -38,6 +53,7 @@ fn resolve_service(
|
||||
None,
|
||||
&mut deps.items,
|
||||
&deps.commands,
|
||||
&probe_strings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +82,7 @@ fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option<TrainerState> {
|
||||
None,
|
||||
&mut deps.items,
|
||||
&deps.commands,
|
||||
&probe_strings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -351,9 +368,10 @@ fn display_name_is_the_wire_spell_not_the_taught_one() {
|
||||
);
|
||||
|
||||
// And the group law that puts it first: 2020 carries Effect 44 SKILL_STEP where a recipe
|
||||
// wrapper does not. Both read off the real `Spell.dbc` rather than a fixture.
|
||||
// wrapper does not. Both read off the real `Spell.dbc` rather than a fixture; the header
|
||||
// itself is a GlobalStrings key, so the probe table names which one was looked up.
|
||||
assert_eq!(svc.group_key, 1);
|
||||
assert_eq!(svc.group_name, "Development Skills");
|
||||
assert_eq!(svc.group_name, "<STEP>");
|
||||
let recipe = resolve_service(
|
||||
&wire(2743, trainer_spell_state::RED, 50, 0, 164),
|
||||
TRAINER_TYPE_TRADESKILL,
|
||||
@@ -362,7 +380,7 @@ fn display_name_is_the_wire_spell_not_the_taught_one() {
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(recipe.group_key, 2);
|
||||
assert_eq!(recipe.group_name, "Recipes");
|
||||
assert_eq!(recipe.group_name, "<LEARN>");
|
||||
assert_eq!(recipe.name.as_deref(), Some("Copper Chain Pants"));
|
||||
}
|
||||
|
||||
@@ -419,10 +437,10 @@ fn resolve_reads_cost_state_and_gates_with_no_catalog() {
|
||||
assert!(svc.is_trade_skill, "trainer_type 2 → tradeskill");
|
||||
assert!(svc.prof_first_rank == w.is_primary_prof_first_rank);
|
||||
// No spell catalog at a TRADESKILL trainer → the SKILL_STEP predicate reads false and the row
|
||||
// falls to the "Recipes" group. Nothing is ever dropped at type 2 (the partition is total), so
|
||||
// falls to the `TRADESKILL_SERVICE_LEARN` group. Nothing is ever dropped at type 2 (the partition is total), so
|
||||
// the "unresolved → 0" arm belongs to the skill-line types, not this one.
|
||||
assert_eq!(svc.group_key, 2);
|
||||
assert_eq!(svc.group_name, "Recipes");
|
||||
assert_eq!(svc.group_name, "<LEARN>");
|
||||
// No skill gate when req_skill is 0.
|
||||
let plain = resolve_service(
|
||||
&wire(78, trainer_spell_state::GREEN, 50, 5, 0),
|
||||
@@ -729,3 +747,25 @@ fn only_a_packet_that_opens_a_window_resets_the_filter() {
|
||||
"a different trainer is a new window whatever is in flight"
|
||||
);
|
||||
}
|
||||
|
||||
/// The three group-header keys against the player's REAL `GlobalStrings.lua` — the guard the
|
||||
/// synthetic [`probe_strings`] table needs beside it, since a typo'd key yields an empty header
|
||||
/// rather than a wrong one. Skips without client data.
|
||||
#[test]
|
||||
fn the_group_header_keys_resolve_in_the_real_global_strings() {
|
||||
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 = benilla_ui::script::UiScript::new().expect("VM");
|
||||
vm.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
for key in [
|
||||
"TRADESKILL_SERVICE_STEP",
|
||||
"TRADESKILL_SERVICE_LEARN",
|
||||
"KNOWN_TALENTS_HEADER",
|
||||
] {
|
||||
let text = vm.lua().globals().get::<String>(key).unwrap_or_default();
|
||||
assert!(!text.is_empty(), "{key} missing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisp
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer};
|
||||
use crate::target::{ring_reaction, Factions, Selection};
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_script::{gate, UiInput};
|
||||
|
||||
/// The feed pass — runs **after [`benilla_world::schedule::WorldStage::Net`]** (the feeds snapshot state
|
||||
@@ -183,6 +182,12 @@ impl Plugin for UiUnitPlugin {
|
||||
)),
|
||||
)
|
||||
.init_resource::<UnitFeedState>()
|
||||
// [`feed_units`] shows catalog messages (the rest-state pair, the PvP toggle) through
|
||||
// `ui_action::show_messages`, whose sink is the chat log and the message-sound queue.
|
||||
// The queue belongs to the sound stack, which a UI-only harness does not stand up, and a
|
||||
// missing `ResMut` is a system-validation panic — so this plugin declares it. `init` is a
|
||||
// no-op when the sound plugin has already put it there.
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.add_message::<UnitCombatFeedback>()
|
||||
.add_message::<CombatTextEvent>()
|
||||
.add_systems(
|
||||
@@ -1128,28 +1133,27 @@ const PLAYER_FLAGS_RESTING: u32 = 0x20;
|
||||
const PLAYER_FLAGS_PARTIAL_PLAY_TIME: u32 = 0x1000;
|
||||
const PLAYER_FLAGS_NO_PLAY_TIME: u32 = 0x2000;
|
||||
|
||||
/// The PvP-preference announcement rule (decision 0652): `(toast, verbose)` on a real change of the
|
||||
/// bit, `None` otherwise.
|
||||
/// The PvP-preference announcement rule (decision 0652): the `(toast, verbose)` **GlobalStrings
|
||||
/// keys** on a real change of the bit, `None` otherwise.
|
||||
///
|
||||
/// `was: None` is first sight and stays silent — the reference's handler is driven by a
|
||||
/// *changed-bits* mask (`new ^ old`), so the descriptor that first carries the flag at login says
|
||||
/// nothing. The two texts are verbatim 1.12 `GlobalStrings.lua`: the `ERR_PVP_TOGGLE_*` toast
|
||||
/// (l.1788-1789) and the `PVP_TOGGLE_*_VERBOSE` chat sentence (l.3221-3222). Both are
|
||||
/// argument-free, so there is no format step.
|
||||
/// nothing.
|
||||
///
|
||||
/// **Keys, not sentences** (decision 2045), and the pair rides two different routes because the
|
||||
/// reference gives them two different natures. `ERR_PVP_TOGGLE_ON`/`_OFF` are message-catalog rows
|
||||
/// (437/438, `kind 1` — the yellow `UI_INFO_MESSAGE`), so the catalog names their surface;
|
||||
/// `PVP_TOGGLE_ON_VERBOSE`/`_OFF_VERBOSE` are **not** catalog rows at all, so there is no record to
|
||||
/// read a kind or a sound off and the chat surface is the handler's own — the `/ginfo` shape
|
||||
/// (`ui_guild::feed::ginfo_lines`, decision 2054). Both are argument-free, so there is no fill step.
|
||||
fn pvp_announcement(was: Option<bool>, now: bool) -> Option<(&'static str, &'static str)> {
|
||||
if was? == now {
|
||||
return None;
|
||||
}
|
||||
Some(if now {
|
||||
(
|
||||
"PvP combat toggled on",
|
||||
"You are now flagged for PvP combat and will remain so until toggled off.",
|
||||
)
|
||||
("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE")
|
||||
} else {
|
||||
(
|
||||
"PvP combat toggled off",
|
||||
"You will be unflagged for PvP combat after five minutes of non-PvP action in friendly territory.",
|
||||
)
|
||||
("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1158,18 +1162,19 @@ fn pvp_announcement(was: Option<bool>, now: bool) -> Option<(&'static str, &'sta
|
||||
/// dispatcher's `rep cmpsb` mirror diff at `0x4655bb`), through the hard-coded 3×2 pair table
|
||||
/// `0x80af50` — state 1 → `ERR_EXHAUSTION_RESTED`, state 2 → `ERR_EXHAUSTION_NORMAL`, state 0 →
|
||||
/// the table's deliberate no-message sentinel (id 0x1d1), states ≥ 3 gated off before the table
|
||||
/// (`cmp esi,3; jae`), so the beta tiers never speak even though their strings ship. The line is
|
||||
/// a plain yellow SYSTEM chat message (`CHAT_MSG_SYSTEM`) — never UIErrorsFrame, no sound.
|
||||
/// enUS literals like every app-side chat line (`level_up_lines`' shape); the keys above are the
|
||||
/// GlobalStrings homes. Entering rested also arms a one-shot tutorial popup (id 0x19) — the
|
||||
/// tutorial system isn't built, a named cut.
|
||||
/// (`cmp esi,3; jae`), so the beta tiers never speak even though their strings ship.
|
||||
///
|
||||
/// The table holds **message ids**, so the answer here is the row's key and the catalog decides the
|
||||
/// rest (decision 2045): rows 346/347 are `kind 0` — a plain SYSTEM chat line, never UIErrorsFrame
|
||||
/// — with no cue and `type_tag 0x44`, so no voice either. Entering rested also arms a one-shot
|
||||
/// tutorial popup (id 0x19) — the tutorial system isn't built, a named cut.
|
||||
fn rest_state_message(prev: u8, new: u8) -> Option<&'static str> {
|
||||
if prev == new {
|
||||
return None;
|
||||
}
|
||||
match new {
|
||||
1 => Some("You feel rested."),
|
||||
2 => Some("You feel normal."),
|
||||
1 => Some("ERR_EXHAUSTION_RESTED"),
|
||||
2 => Some("ERR_EXHAUSTION_NORMAL"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1396,7 +1401,9 @@ fn feed_units(
|
||||
factions: Option<Res<Factions>>,
|
||||
reputations: Res<Reputations>,
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
mut chat: ResMut<ChatLog>,
|
||||
// The chat window AND the message-sound queue, because this feed shows catalog messages (the
|
||||
// rest-state pair, the PvP toggle) and `show_messages` writes both on every line.
|
||||
mut sink: crate::ui_action::MessageSink,
|
||||
// The guild-identity cache `GetGuildInfo(unit)` reads — `ResMut` because it is a LAZY cache
|
||||
// (decision 1257): a lookup that misses is what sends the `CMSG_GUILD_QUERY`, exactly as a
|
||||
// `NameCache::resolve` miss sends the name query above.
|
||||
@@ -1813,12 +1820,9 @@ fn feed_units(
|
||||
// which the real client's fresh-CREATE path never runs through the notify pass
|
||||
// (byte-verified: login is structurally silent). The pool watcher never messages.
|
||||
if let Some(p) = prev {
|
||||
if let Some(text) = rest_state_message(p.0, rest.0) {
|
||||
chat.push_event(ChatEvent::text_only(
|
||||
ChatEventKind::System,
|
||||
text.to_string(),
|
||||
));
|
||||
}
|
||||
let line = rest_state_message(p.0, rest.0)
|
||||
.and_then(|key| crate::ui_action::keyed_line(&script, key));
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_unit", line);
|
||||
}
|
||||
// The two self-only flag events, each on its own bits' edge (`0x5eead0` and
|
||||
// `0x5eeb65` above). `prev` None is the login descriptor and stays silent for both,
|
||||
@@ -1875,7 +1879,7 @@ fn feed_units(
|
||||
// `SPELL_STAT0..4` order. Absent gains are ZEROS, not a shorter payload — a
|
||||
// demotion really did gain nothing, and every consumer guards with `if ( argN > 0 )`
|
||||
// so zero reads as "no line" while nil raises.
|
||||
let (info, talent_points) = chat.take_level_up_gains(level).unzip();
|
||||
let (info, talent_points) = sink.chat.take_level_up_gains(level).unzip();
|
||||
let gain = |f: fn(&benilla_protocol::messages::LevelUpInfo) -> u32| {
|
||||
ScriptValue::Int(i64::from(info.as_ref().map_or(0, f)))
|
||||
};
|
||||
@@ -2034,11 +2038,27 @@ fn feed_units(
|
||||
let desired = store.0.player_flags() & PLAYER_FLAGS_PVP_DESIRED != 0;
|
||||
if let Some((toast, verbose)) = pvp_announcement(memo.pvp_desired, desired) {
|
||||
gate.audit("feed_units", "the PvP-desired edge");
|
||||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(toast.to_string())]);
|
||||
chat.push_event(ChatEvent::text_only(
|
||||
ChatEventKind::System,
|
||||
verbose.to_string(),
|
||||
));
|
||||
// The toast is a catalog row, so its surface is read there; the verbose sentence is
|
||||
// not one, so it is emitted `unkeyed` on the handler's own chat surface rather than
|
||||
// pushed through `Shown::keyed`, whose unknown-key fallback would turn it RED (2054).
|
||||
let lines = [
|
||||
crate::ui_action::keyed_line(&script, toast),
|
||||
script
|
||||
.lua()
|
||||
.globals()
|
||||
.get::<String>(verbose)
|
||||
.ok()
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| {
|
||||
crate::ui_action::Shown::unkeyed(benilla_ui::messages::MsgKind::Chat, t)
|
||||
}),
|
||||
];
|
||||
crate::ui_action::show_messages(
|
||||
&mut script,
|
||||
&mut sink,
|
||||
"ui_unit",
|
||||
lines.into_iter().flatten(),
|
||||
);
|
||||
}
|
||||
memo.pvp_desired = Some(desired);
|
||||
}
|
||||
@@ -2397,6 +2417,9 @@ mod tests {
|
||||
.init_resource::<Reputations>()
|
||||
.init_resource::<crate::ui_party::GroupState>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
// The feed's `MessageSink` is chat + sounds since the PvP/rest lines became message
|
||||
// KEYS (decision 2080): the row a key names carries the cue, so the sink reads both.
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.init_resource::<crate::ui_guild::GuildState>();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(NetCommands(tx));
|
||||
@@ -2874,8 +2897,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The PvP-preference announcement law (decision 0652), as the reference's changed-bits handler
|
||||
/// runs it: silent on first sight, one pair per real edge, and the OFF text is the one that
|
||||
/// explains the five-minute wait — the whole reason the toggle doesn't read as dead.
|
||||
/// runs it: silent on first sight, one pair of KEYS per real edge.
|
||||
///
|
||||
/// The assertion is on the identifiers, never the sentences (decision 2045) — an English
|
||||
/// comparison passes exactly where two keys agree in enUS and diverge everywhere else. The
|
||||
/// wording lives in the player's own `GlobalStrings.lua` and is checked against it by
|
||||
/// [`the_pvp_and_rest_keys_resolve_in_the_real_global_strings`].
|
||||
#[test]
|
||||
fn pvp_announcement_speaks_only_on_an_edge() {
|
||||
assert_eq!(
|
||||
@@ -2887,19 +2914,45 @@ mod tests {
|
||||
assert_eq!(pvp_announcement(Some(true), true), None, "no change");
|
||||
assert_eq!(pvp_announcement(Some(false), false), None, "no change");
|
||||
|
||||
let (toast, verbose) = pvp_announcement(Some(false), true).expect("turning it on speaks");
|
||||
assert_eq!(toast, "PvP combat toggled on"); // GlobalStrings ERR_PVP_TOGGLE_ON
|
||||
assert_eq!(
|
||||
verbose,
|
||||
"You are now flagged for PvP combat and will remain so until toggled off."
|
||||
pvp_announcement(Some(false), true),
|
||||
Some(("ERR_PVP_TOGGLE_ON", "PVP_TOGGLE_ON_VERBOSE"))
|
||||
);
|
||||
assert_eq!(
|
||||
pvp_announcement(Some(true), false),
|
||||
Some(("ERR_PVP_TOGGLE_OFF", "PVP_TOGGLE_OFF_VERBOSE"))
|
||||
);
|
||||
}
|
||||
|
||||
let (toast, verbose) = pvp_announcement(Some(true), false).expect("turning it off speaks");
|
||||
assert_eq!(toast, "PvP combat toggled off"); // GlobalStrings ERR_PVP_TOGGLE_OFF
|
||||
/// The four keys, against the player's REAL `GlobalStrings.lua` — the guard a key-based
|
||||
/// assertion needs beside it, since a typo'd key degrades a real line to silence rather than
|
||||
/// to a wrong sentence. Also pins the one thing the director would actually notice: the OFF
|
||||
/// verbose sentence is the one that explains the five-minute wait, which is the whole reason
|
||||
/// the toggle doesn't read as dead. Skips without client data.
|
||||
#[test]
|
||||
fn the_pvp_and_rest_keys_resolve_in_the_real_global_strings() {
|
||||
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 s = UiScript::new().expect("VM");
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let g = |key: &str| s.lua().globals().get::<String>(key).unwrap_or_default();
|
||||
|
||||
for (was, now) in [(false, true), (true, false)] {
|
||||
let (toast, verbose) = pvp_announcement(Some(was), now).expect("an edge speaks");
|
||||
assert!(!g(toast).is_empty(), "{toast} missing");
|
||||
assert!(!g(verbose).is_empty(), "{verbose} missing");
|
||||
}
|
||||
assert!(
|
||||
verbose.contains("five minutes"),
|
||||
"the OFF sentence is what tells the player the flag lingers: {verbose}"
|
||||
g("PVP_TOGGLE_OFF_VERBOSE").contains("five minutes"),
|
||||
"the OFF sentence is what tells the player the flag lingers"
|
||||
);
|
||||
for state in [1u8, 2] {
|
||||
let key = rest_state_message(0, state).expect("states 1 and 2 speak");
|
||||
assert!(!g(key).is_empty(), "{key} missing");
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest-state chat law (decision 1098, wow-re §§6-10): a message needs a real byte
|
||||
@@ -2908,11 +2961,11 @@ mod tests {
|
||||
/// swallowed by the dispatcher's mirror diff.
|
||||
#[test]
|
||||
fn rest_state_message_speaks_only_on_a_real_transition() {
|
||||
assert_eq!(rest_state_message(2, 1), Some("You feel rested."));
|
||||
assert_eq!(rest_state_message(1, 2), Some("You feel normal."));
|
||||
assert_eq!(rest_state_message(2, 1), Some("ERR_EXHAUSTION_RESTED"));
|
||||
assert_eq!(rest_state_message(1, 2), Some("ERR_EXHAUSTION_NORMAL"));
|
||||
assert_eq!(
|
||||
rest_state_message(0, 1),
|
||||
Some("You feel rested."),
|
||||
Some("ERR_EXHAUSTION_RESTED"),
|
||||
"0→1 IS a transition"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -2957,7 +3010,10 @@ mod tests {
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<Reputations>()
|
||||
.init_resource::<crate::ui_party::GroupState>()
|
||||
.init_resource::<ChatLog>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
// `feed_units` shows catalog messages through `show_messages`, whose sink is the chat
|
||||
// log AND the message-sound queue.
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.init_resource::<crate::ui_guild::GuildState>()
|
||||
.init_resource::<InteractNpc>();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
|
||||
@@ -31,56 +31,43 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The drift still standing, per file: **208 literals over 31 files**, each a sentence the
|
||||
/// reference ships and we re-typed.
|
||||
/// **Empty, and that is the point.** This was decision 2045's ratchet — a per-file budget of
|
||||
/// re-typed sentences that might only ever go down — and it went down to nothing.
|
||||
///
|
||||
/// **These numbers may only decrease.** They are not a budget to spend; every one is a string that
|
||||
/// will not translate, and some carry a surface and a voice line with them. Lower the count when
|
||||
/// you convert a file, and delete the row at zero — a row left stale fails this test too, so a
|
||||
/// conversion cannot quietly re-permit the drift it retired. `char_create` (2045) and `ui_trade`
|
||||
/// (2042, a neighbouring session) are both converted and have no row: that is what retiring one
|
||||
/// looks like, and the second was caught by this test rather than by remembering.
|
||||
/// It stays as an empty list rather than being deleted with the machinery, because the walk below
|
||||
/// is still the guard: a row appearing here is a file that started re-typing again, and the
|
||||
/// argument for the rule holds whether or not anything is currently breaking it.
|
||||
///
|
||||
/// **Regenerate these against a clean tree, never mid-edit.** The walk is deterministic on the
|
||||
/// sources plus the shipped tables (two consecutive runs agree exactly), but a baseline captured
|
||||
/// while other files are half-converted records counts that no later tree will reproduce — which
|
||||
/// is exactly how the first cut of this list carried a row the gates then failed on.
|
||||
const ALLOWED: &[(&str, usize)] = &[
|
||||
("benilla-app/src/ui_chat/frames.rs", 48),
|
||||
("benilla-app/src/ui_tooltip/mod.rs", 28),
|
||||
("benilla-app/src/ui_party/mod.rs", 12),
|
||||
("benilla-app/src/ui_social/mod.rs", 18),
|
||||
("benilla-app/src/ui_petition/lines.rs", 15),
|
||||
("benilla-app/src/ui_chat/feed.rs", 13),
|
||||
("benilla-ui/src/script/tooltip_item/render.rs", 9),
|
||||
("benilla-app/src/ui_unit.rs", 6),
|
||||
("benilla-formats/src/spells/tokens.rs", 6),
|
||||
("benilla-app/src/ui_duel.rs", 5),
|
||||
("benilla-app/src/ui_social/feed.rs", 4),
|
||||
("benilla-app/src/ui_talent.rs", 4),
|
||||
("benilla-ui/src/script/tooltip_unit.rs", 4),
|
||||
("benilla-app/src/ui_quest.rs", 3),
|
||||
("benilla-app/src/ui_quest_log.rs", 3),
|
||||
("benilla-app/src/ui_script/resolve_bench.rs", 3),
|
||||
("benilla-ui/src/script/tooltip_item/names.rs", 3),
|
||||
("benilla-ui/src/script/tradeskill/view.rs", 3),
|
||||
("benilla-app/src/net/apply/chat.rs", 2),
|
||||
("benilla-app/src/ui_binder.rs", 2),
|
||||
("benilla-app/src/ui_char.rs", 2),
|
||||
("benilla-app/src/ui_trainer/law.rs", 2),
|
||||
("benilla-app/src/ui_trainer/mod.rs", 2),
|
||||
("benilla-ui/src/script/tooltip_item/mod.rs", 2),
|
||||
("benilla-ui/src/script/tooltip_spell.rs", 2),
|
||||
("benilla-ui/src/script/unit/mod.rs", 2),
|
||||
("benilla-app/src/death.rs", 1),
|
||||
("benilla-app/src/net/apply/quests.rs", 1),
|
||||
("benilla-app/src/ui_chat/input/mod.rs", 1),
|
||||
("benilla-app/src/ui_items/feed.rs", 1),
|
||||
("benilla-ui/src/script/talent.rs", 1),
|
||||
];
|
||||
/// **The number went UP once before it went down, and that is the part worth remembering.** Two
|
||||
/// defects in the walk itself — a `RESOLVERS` entry that a `sort_by_key` call satisfied, and a
|
||||
/// `#[cfg(test)]` stripper blind to the out-of-line `mod tests;` — had it reporting 160 where the
|
||||
/// truth was 180, hiding 36 literals inside one function while double-counting a handful
|
||||
/// elsewhere. A ratchet is only ever as honest as the walk under it: a number that may only
|
||||
/// decrease is worthless if the number was never right.
|
||||
///
|
||||
/// **What this walk still cannot do**, unchanged from 2045 and worth keeping in view now that the
|
||||
/// count is zero: it finds *re-typed* strings, because those are byte-identical to the shipped
|
||||
/// ones. It cannot find *invented* ones — text matching nothing has nothing to match against —
|
||||
/// and it cannot see a one-word value, which collides with ordinary program text. Three invented
|
||||
/// slot words and a hand-typed `LockType` name were found in this arc by reading the reference,
|
||||
/// not by this test.
|
||||
///
|
||||
/// **Regenerate against a clean tree, never mid-edit.** The walk is deterministic on the sources
|
||||
/// plus the shipped tables (two consecutive runs agree exactly), but a baseline captured while
|
||||
/// other files are half-converted records counts no later tree will reproduce.
|
||||
const ALLOWED: &[(&str, usize)] = &[];
|
||||
|
||||
/// Paths that are never player-facing: dev instruments, probes, capture harnesses and benches.
|
||||
/// A `.learn <spell>` GM command is not a UI string even when it collides with one.
|
||||
///
|
||||
/// **`resolve_bench` is the one entry that is not a *category* but a hole in the walk**, and it is
|
||||
/// named here rather than papered over. The file is `#[cfg(test)] mod resolve_bench;` — test-only
|
||||
/// code, exactly what [`strip_test_modules`] exists to drop — but that stripper works *within* a
|
||||
/// file, and this module's body lives in another one whose name does not contain "test", so the
|
||||
/// walk visits it as production source. Its three hits are Lua fixtures inside `#[ignore]`d
|
||||
/// release-only benches (`GameTooltip:AddLine("Main Hand", …)` — a line whose only job is to have
|
||||
/// a width). Skipping cfg(test)-only *files* properly would mean parsing the module tree; this is
|
||||
/// the honest one-line stand-in until something needs the general answer.
|
||||
fn is_instrument(rel: &str) -> bool {
|
||||
[
|
||||
"/capture/",
|
||||
@@ -89,6 +76,7 @@ fn is_instrument(rel: &str) -> bool {
|
||||
"debug_panel",
|
||||
"shape_gate",
|
||||
"framexml_diff",
|
||||
"resolve_bench",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| rel.contains(p))
|
||||
@@ -180,6 +168,12 @@ fn walk(dir: &Path, into: &mut Vec<PathBuf>) {
|
||||
}
|
||||
|
||||
/// Does this function resolve a key at all? If so, a literal inside it is a fallback.
|
||||
///
|
||||
/// Matched on an **identifier boundary** for the bare-word entries, which is not fussiness:
|
||||
/// `by_key(` is a substring of `sort_by_key(`, and one `bonuses.sort_by_key(…)` was exempting the
|
||||
/// whole 530-line `render_view` — the largest literal-bearing function in the workspace — from
|
||||
/// this walk. A resolver list that can be satisfied by an unrelated method name is not a filter,
|
||||
/// it is a blindfold. Entries that begin with punctuation (`.text(`) are already delimited.
|
||||
const RESOLVERS: &[&str] = &[
|
||||
"strings.get(",
|
||||
".text(",
|
||||
@@ -191,6 +185,20 @@ const RESOLVERS: &[&str] = &[
|
||||
"glue_strings",
|
||||
];
|
||||
|
||||
/// [`RESOLVERS`], on an identifier boundary for the bare-word entries.
|
||||
fn resolves(body: &str) -> bool {
|
||||
RESOLVERS.iter().any(|r| {
|
||||
let word = r.starts_with(|c: char| c.is_alphanumeric() || c == '_');
|
||||
body.match_indices(r).any(|(i, _)| {
|
||||
!word
|
||||
|| !body[..i]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| c.is_alphanumeric() || c == '_')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
let data = match benilla_formats::wow_data() {
|
||||
@@ -198,7 +206,7 @@ fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
None => return,
|
||||
};
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let mut shipped = HashMap::new();
|
||||
let mut shipped: HashMap<String, Vec<String>> = HashMap::new();
|
||||
// The base tables AND the locale patches laid over them (decision 2052): where `Localize()`
|
||||
// redefines a key, its wording is the one the player actually reads, so a set that stopped at
|
||||
// the base files would be grading against text this install never shows.
|
||||
@@ -217,7 +225,15 @@ fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
// A one-word or punctuation-only value ("Locked", "%s") is too weak a signal: it
|
||||
// collides with ordinary program text. Sentences are what this is after.
|
||||
if n.split(' ').count() >= 2 && n.chars().any(|c| c.is_ascii_lowercase()) {
|
||||
shipped.entry(n).or_insert(k);
|
||||
// **Every** key with this wording, not the first one seen. A value maps to more
|
||||
// than one key far more often than you would guess — `CHAT_IGNORED` and
|
||||
// `ERR_IGNORING_YOU_S` are the same enUS sentence, and so are
|
||||
// `INVTYPE_SHIELD`/`SECONDARYHANDSLOT`, `CHAR_CREATE_NAME_IN_USE`/
|
||||
// `CHAR_NAME_RESERVED` — and naming one of them is how a reader converts a site
|
||||
// to the wrong key. Which one belongs at a given call site is the *reference's
|
||||
// code* to answer, never this table's; the report's job is to say that a choice
|
||||
// exists (decision 2045, "assert the identifier, not the sentence").
|
||||
shipped.entry(n).or_default().push(k);
|
||||
taken += 1;
|
||||
}
|
||||
}
|
||||
@@ -249,7 +265,7 @@ fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
}
|
||||
let src = strip_test_modules(&std::fs::read_to_string(path).expect("read source"));
|
||||
for (fname, body) in split_fns(&src) {
|
||||
if RESOLVERS.iter().any(|r| body.contains(r)) {
|
||||
if resolves(body) {
|
||||
continue; // a literal here is a fallback beside a real lookup
|
||||
}
|
||||
for lit in string_literals(body) {
|
||||
@@ -257,16 +273,35 @@ fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
if n.split(' ').count() < 2 {
|
||||
continue;
|
||||
}
|
||||
if let Some(key) = shipped.get(&n) {
|
||||
if let Some(keys) = shipped.get(&n) {
|
||||
let mut keys = keys.clone();
|
||||
keys.sort_unstable();
|
||||
found
|
||||
.entry(rel.clone())
|
||||
.or_default()
|
||||
.push(format!("{key} — {fname}: {lit:?}"));
|
||||
.push(format!("{} — {fname}: {lit:?}", keys.join(" | ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A ratchet says only whether a file got worse; converting one needs the list. `cargo test -p
|
||||
// benilla-app --test reference_strings -- --nocapture` with `BENILLA_REFSTRINGS_REPORT=1`
|
||||
// prints every hit — key, function, literal — biggest file first, so a conversion starts from
|
||||
// what is actually there rather than from a count.
|
||||
if std::env::var_os("BENILLA_REFSTRINGS_REPORT").is_some() {
|
||||
let mut files: Vec<(&String, &Vec<String>)> = found.iter().collect();
|
||||
files.sort_by_key(|(f, h)| (std::cmp::Reverse(h.len()), (*f).clone()));
|
||||
let total: usize = files.iter().map(|(_, h)| h.len()).sum();
|
||||
println!("\n{total} re-typed literals over {} files", files.len());
|
||||
for (file, hits) in files {
|
||||
println!("\n{file} ({})", hits.len());
|
||||
for h in hits {
|
||||
println!(" {h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut over = Vec::new();
|
||||
for (file, hits) in &found {
|
||||
let budget = allowed.get(file.as_str()).copied().unwrap_or(0);
|
||||
@@ -300,43 +335,61 @@ fn no_user_facing_sentence_is_written_in_rust_when_the_reference_ships_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cut every `#[cfg(test)]` block out of a source file before scanning it.
|
||||
/// Cut every `#[cfg(test)]` item out of a source file before scanning it.
|
||||
///
|
||||
/// Without this the walk reads test fixtures as production text — `ui_action::cast_fail`'s unit
|
||||
/// tests build a *fake* GlobalStrings map whose 48 entries are, by construction, byte-identical to
|
||||
/// the shipped strings. Those are the test doing its job, not drift.
|
||||
///
|
||||
/// **The item's terminator is a brace OR a semicolon, whichever comes first**, and getting that
|
||||
/// wrong was silently corrupting this walk in both directions. `#[cfg(test)] mod tests;` — the
|
||||
/// out-of-line form, 28 files in this workspace — has no brace of its own, so a brace-only scan
|
||||
/// either ran off the end (re-appending the prefix it had already emitted, double-counting every
|
||||
/// literal above it: that is the whole of `unit/mod.rs`'s row of "2") or, when any later item had
|
||||
/// a brace, matched THAT one and deleted every line between — hiding real drift with no trace.
|
||||
fn strip_test_modules(src: &str) -> String {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut rest = src;
|
||||
while let Some(at) = rest.find("#[cfg(test)]") {
|
||||
out.push_str(&rest[..at]);
|
||||
// skip to the opening brace of the item that attribute guards, then brace-match it
|
||||
let after = &rest[at..];
|
||||
let Some(open) = after.find('{') else { break };
|
||||
let mut depth = 0usize;
|
||||
let mut end = None;
|
||||
for (i, c) in after[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(open + i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let end = match (after.find('{'), after.find(';')) {
|
||||
// A braced item: brace-match it away.
|
||||
(Some(open), None) => brace_match(after, open),
|
||||
(Some(open), Some(semi)) if open < semi => brace_match(after, open),
|
||||
// `mod tests;` / `use …;` — the item ends at its semicolon and its body, if it has
|
||||
// one, is another file's problem.
|
||||
(_, Some(semi)) => Some(semi + 1),
|
||||
(None, None) => None,
|
||||
};
|
||||
match end {
|
||||
Some(e) => rest = &after[e..],
|
||||
None => break,
|
||||
// A trailing attribute with nothing after it: there is nothing left to keep.
|
||||
None => return out,
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The index just past the `}` matching the `{` at `open`, or `None` if it never closes.
|
||||
fn brace_match(src: &str, open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (i, c) in src[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(open + i + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Split a source file into `(fn name, body)` pairs — crude but enough to ask "does the enclosing
|
||||
/// function resolve a key?", which is the only question here.
|
||||
fn split_fns(src: &str) -> Vec<(&str, &str)> {
|
||||
|
||||
@@ -35,6 +35,21 @@ pub struct TokenContext<'a> {
|
||||
/// "Returns you to $z."). Fed from `SMSG_BINDPOINTUPDATE`'s areaId through AreaTable.dbc;
|
||||
/// `None` (no bind seen yet) leaves the token raw, like any unresolved token.
|
||||
pub home_area: Option<&'a str>,
|
||||
/// **Resolve a `GlobalStrings` key and fill its `%d` holes** — the caller's job, because both
|
||||
/// halves of it live on the other side of this crate's boundary: the string table is the
|
||||
/// script VM's and the one shared printf-family filler is `benilla_ui::strings::fill`
|
||||
/// (decision 2045). This crate has no business depending on either, so the split is that the
|
||||
/// token engine picks the KEY and the NUMBERS and the caller renders them.
|
||||
///
|
||||
/// Integer holes only, and the signature says so on purpose: every key reached through here
|
||||
/// is one of the `INT_SPELL_*` family, which exists precisely because these values are
|
||||
/// integers. The float twins (`SPELL_DURATION_SEC = "%.2f sec"`,
|
||||
/// `SPELL_POINTS_SPREAD_TEMPLATE = "%.1f to %.1f"`) are a *different* set of keys for a
|
||||
/// different path, and reaching for one of those would print "14.0 to 22.0" where the client
|
||||
/// prints "14 to 22".
|
||||
///
|
||||
/// `None` (key absent, or no table at all) leaves the token raw, like any unresolved token.
|
||||
pub text: &'a dyn Fn(&str, &[i64]) -> Option<String>,
|
||||
}
|
||||
|
||||
/// The byte-verified effect bounds (`0x6e3800`, flat term): `(min, max)`.
|
||||
@@ -51,20 +66,50 @@ fn duration_ms(d: &SpellDisplay, ctx: &TokenContext) -> Option<i64> {
|
||||
Some(i64::from(row.base_ms))
|
||||
}
|
||||
|
||||
/// Whole-unit duration text — INTERIM shape (the `0x52fa50` formatter's exact rendering is
|
||||
/// unpinned): "N sec" / "N min" / "N hours", "until cancelled" for the permanent sentinel.
|
||||
fn duration_text(ms: i64) -> String {
|
||||
/// Whole-unit duration text — the `INT_SPELL_DURATION_*` family, largest unit that fits, with
|
||||
/// `SPELL_DURATION_UNTIL_CANCELLED` for the permanent sentinel.
|
||||
///
|
||||
/// **The words were ours and two of them were wrong.** This read "N hours"; 1.12 says
|
||||
/// `INT_SPELL_DURATION_HOURS_P1 = "%d hrs"`. The sentence "N hours" *does* exist in
|
||||
/// GlobalStrings — as `LASTONLINE_HOURS_P1`, the friends list's last-seen column — which is
|
||||
/// exactly the trap decision 2045 describes: a text search finds a key, and it is the wrong key
|
||||
/// for this call site. It also had no days arm at all, so a two-day aura read "48 hrs".
|
||||
///
|
||||
/// The ladder is the one `0x52fa50` walks (byte-pinned for the aura line as wow-re §3-BUFF, and
|
||||
/// implemented for that surface in `benilla_ui::script::tooltip::duration_text`) and the plural
|
||||
/// pick is `GetText`'s: the bare token at exactly one, the `_P1` twin otherwise. Only HOURS ships
|
||||
/// a twin in this family, so the other three fall back to the bare token — which is the same
|
||||
/// fallback `plural_template` takes, and the reason `INT_SPELL_DURATION_MIN` reads "1 min" and
|
||||
/// "9 min" alike.
|
||||
fn duration_text(ms: i64, ctx: &TokenContext) -> Option<String> {
|
||||
if ms < 0 {
|
||||
return "until cancelled".into();
|
||||
return (ctx.text)("SPELL_DURATION_UNTIL_CANCELLED", &[]);
|
||||
}
|
||||
let secs = ms / 1000;
|
||||
if secs < 60 {
|
||||
format!("{secs} sec")
|
||||
} else if secs < 3600 {
|
||||
format!("{} min", secs / 60)
|
||||
let (unit, n) = if secs < 60 {
|
||||
("SEC", secs)
|
||||
} else if secs < 3_600 {
|
||||
("MIN", secs / 60)
|
||||
} else if secs < 86_400 {
|
||||
("HOURS", secs / 3_600)
|
||||
} else {
|
||||
format!("{} hours", secs / 3600)
|
||||
}
|
||||
("DAYS", secs / 86_400)
|
||||
};
|
||||
let key = format!("INT_SPELL_DURATION_{unit}");
|
||||
(n != 1)
|
||||
.then(|| (ctx.text)(&format!("{key}_P1"), &[n]))
|
||||
.flatten()
|
||||
.or_else(|| (ctx.text)(&key, &[n]))
|
||||
}
|
||||
|
||||
/// The min–max spread of an effect's points — `INT_SPELL_POINTS_SPREAD_TEMPLATE` ("%d to %d").
|
||||
///
|
||||
/// **The `INT_` twin, not `SPELL_POINTS_SPREAD_TEMPLATE`.** Both read the same shape in enUS and
|
||||
/// the float one is spelled `"%.1f to %.1f"`, so reaching for it would print "14.0 to 22.0" where
|
||||
/// Fireball rank 1 says "14 to 22". `effect_bounds` is integral by construction (base + dice ×
|
||||
/// sides, all `i32` columns), which is what makes the integer key the right one here.
|
||||
fn spread_text(min: i64, max: i64, ctx: &TokenContext) -> Option<String> {
|
||||
(ctx.text)("INT_SPELL_POINTS_SPREAD_TEMPLATE", &[min, max])
|
||||
}
|
||||
|
||||
/// Trim a float to the client's terse style (no trailing zeros: 2.5 → "2.5", 3.0 → "3").
|
||||
@@ -98,7 +143,7 @@ fn token_value(
|
||||
Some(if min == max {
|
||||
(min.to_string(), min as f64)
|
||||
} else {
|
||||
(format!("{min} to {max}"), max as f64)
|
||||
(spread_text(min, max, ctx)?, max as f64)
|
||||
})
|
||||
}
|
||||
'm' if letter == 'm' => {
|
||||
@@ -122,13 +167,13 @@ fn token_value(
|
||||
Some(if tmin == tmax {
|
||||
(tmin.to_string(), tmin as f64)
|
||||
} else {
|
||||
(format!("{tmin} to {tmax}"), tmax as f64)
|
||||
(spread_text(tmin, tmax, ctx)?, tmax as f64)
|
||||
})
|
||||
}
|
||||
'd' => {
|
||||
let ms = duration_ms(d, ctx)?;
|
||||
let v = if ms < 0 { 0.0 } else { ms as f64 / 1000.0 };
|
||||
Some((duration_text(ms), v))
|
||||
Some((duration_text(ms, ctx)?, v))
|
||||
}
|
||||
't' => {
|
||||
let period = i64::from(*d.effect_amplitude.get(slot).unwrap_or(&0));
|
||||
@@ -285,6 +330,27 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::spells::SpellDisplay;
|
||||
|
||||
/// The string table these tests resolve against — **deliberately not the shipped wording**.
|
||||
///
|
||||
/// What is under test here is which KEY the engine reaches for and which numbers fill it,
|
||||
/// never what the sentence says (decision 2045). A fixture that echoed the real strings would
|
||||
/// pass on a *wrong* key wherever two of them agree in English, which is exactly how
|
||||
/// `LASTONLINE_HOURS_P1`'s wording came to be spelled into the duration ladder in the first
|
||||
/// place. It is also the idiom `benilla_ui::script::tests::tooltip` uses for the same reason.
|
||||
fn text(key: &str, args: &[i64]) -> Option<String> {
|
||||
let n = |i: usize| args.get(i).copied().unwrap_or_default();
|
||||
Some(match key {
|
||||
"SPELL_DURATION_UNTIL_CANCELLED" => "<forever>".into(),
|
||||
"INT_SPELL_DURATION_SEC" => format!("<{}sec>", n(0)),
|
||||
"INT_SPELL_DURATION_MIN" => format!("<{}min>", n(0)),
|
||||
"INT_SPELL_DURATION_HOURS" => format!("<{}hour>", n(0)),
|
||||
"INT_SPELL_DURATION_HOURS_P1" => format!("<{}hrs>", n(0)),
|
||||
"INT_SPELL_DURATION_DAYS" => format!("<{}days>", n(0)),
|
||||
"INT_SPELL_POINTS_SPREAD_TEMPLATE" => format!("<{}..{}>", n(0), n(1)),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
durations: &'a SpellDurationCatalog,
|
||||
radii: &'a SpellRadiusCatalog,
|
||||
@@ -295,9 +361,49 @@ mod tests {
|
||||
durations,
|
||||
radii,
|
||||
lookup,
|
||||
text: &text,
|
||||
}
|
||||
}
|
||||
|
||||
/// **The duration ladder picks its key by unit and by plural**, and the plural rule is
|
||||
/// `GetText`'s: the bare token at exactly one, the `_P1` twin otherwise. Only HOURS ships a
|
||||
/// twin, so the other three units read the same at one as at nine.
|
||||
///
|
||||
/// The days arm is here because the reference's ladder has one and this file did not — a
|
||||
/// two-day aura used to read as an hour count.
|
||||
#[test]
|
||||
fn the_duration_ladder_picks_unit_then_plural() {
|
||||
let mut durations = SpellDurationCatalog::default();
|
||||
let radii = SpellRadiusCatalog::default();
|
||||
for (idx, ms) in [
|
||||
(1, 30_000),
|
||||
(2, 60_000),
|
||||
(3, 3_600_000),
|
||||
(4, 7_200_000),
|
||||
(5, 172_800_000),
|
||||
(6, -1),
|
||||
] {
|
||||
durations.insert_for_tests(idx, ms);
|
||||
}
|
||||
let c = ctx(&durations, &radii, &none_lookup);
|
||||
let d = |duration_index| {
|
||||
substitute(
|
||||
"$d",
|
||||
&SpellDisplay {
|
||||
duration_index,
|
||||
..Default::default()
|
||||
},
|
||||
&c,
|
||||
)
|
||||
};
|
||||
assert_eq!(d(1), "<30sec>");
|
||||
assert_eq!(d(2), "<1min>", "a single minute takes the bare token");
|
||||
assert_eq!(d(3), "<1hour>", "and so does a single hour");
|
||||
assert_eq!(d(4), "<2hrs>", "but two take the _P1 twin");
|
||||
assert_eq!(d(5), "<2days>", "the days arm the ladder used to lack");
|
||||
assert_eq!(d(6), "<forever>");
|
||||
}
|
||||
|
||||
fn none_lookup<'a>(_: u32) -> Option<&'a SpellDisplay> {
|
||||
None
|
||||
}
|
||||
@@ -317,7 +423,7 @@ mod tests {
|
||||
let c = ctx(&durations, &radii, &none_lookup);
|
||||
assert_eq!(
|
||||
substitute("causes $s1 Fire damage and $s2 more", &d, &c),
|
||||
"causes 14 to 22 Fire damage and 24 more"
|
||||
"causes <14..22> Fire damage and 24 more"
|
||||
);
|
||||
// Negative base points print absolute (the client's "reduces by N" phrasing).
|
||||
d.effect_base_points = [-31, 0, 0];
|
||||
@@ -347,7 +453,7 @@ mod tests {
|
||||
let c = ctx(&durations, &radii, &none_lookup);
|
||||
assert_eq!(
|
||||
substitute("Deals $o1 damage over $d, every $t1 sec.", &d, &c),
|
||||
"Deals 18 damage over 18 sec, every 3 sec."
|
||||
"Deals 18 damage over <18sec>, every 3 sec."
|
||||
);
|
||||
assert_eq!(
|
||||
substitute("Restores $/2;s1 health: $l point:points;.", &d, &c),
|
||||
@@ -374,6 +480,7 @@ mod tests {
|
||||
durations: &durations,
|
||||
radii: &radii,
|
||||
lookup: &lookup,
|
||||
text: &text,
|
||||
};
|
||||
assert_eq!(
|
||||
substitute("as strong as $1234s1 hits", &d, &c),
|
||||
@@ -390,6 +497,7 @@ mod tests {
|
||||
durations: &durations,
|
||||
radii: &radii,
|
||||
lookup: &lookup,
|
||||
text: &text,
|
||||
};
|
||||
assert_eq!(
|
||||
substitute("Returns you to $z.", &d, &unbound),
|
||||
|
||||
@@ -297,10 +297,22 @@ pub(super) fn install_tooltip_method(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
.map(|v| v.description.clone())
|
||||
})
|
||||
.flatten();
|
||||
// `TOOLTIP_TALENT_RANK` = "Rank %d/%d" (`0x854a2c`, pushed at `0x52b213`) —
|
||||
// the key, filled with the two counts, never a sentence of ours (decision 2045).
|
||||
// An install whose string table lacks it shows no rank line at all.
|
||||
let rank_line = crate::strings::global(lua, "TOOLTIP_TALENT_RANK").map(|tmpl| {
|
||||
crate::strings::fill(
|
||||
&tmpl,
|
||||
&[
|
||||
crate::strings::Arg::D(t.rank.into()),
|
||||
crate::strings::Arg::D(t.max_rank.into()),
|
||||
],
|
||||
)
|
||||
});
|
||||
(
|
||||
t.display_spell,
|
||||
TalentLines {
|
||||
rank_line: format!("Rank {}/{}", t.rank, t.max_rank),
|
||||
rank_line,
|
||||
reqs: t.req_lines.clone(),
|
||||
next_spell: t.next_spell,
|
||||
next_desc,
|
||||
|
||||
@@ -106,11 +106,27 @@ fn learn_talent_queues_for_the_app_drain() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// SetTalent = the spell builder + the talent interleave: name, "Rank r/m" white, cost line,
|
||||
/// gold description, "Next rank:" + the next rank's gold description, green "Click to learn".
|
||||
/// A stand-in string table for the talent tail's three keys — **deliberately not the shipped
|
||||
/// wording**, because what is under test is which key each line reaches and what fills it, never
|
||||
/// what the sentence says (decision 2045).
|
||||
fn seed_talent_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
TOOLTIP_TALENT_RANK = "[RANK %d/%d]"
|
||||
TOOLTIP_TALENT_NEXT_RANK = "[NEXT_RANK]"
|
||||
TOOLTIP_TALENT_LEARN = "[LEARN]"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// SetTalent = the spell builder + the talent interleave: name, TOOLTIP_TALENT_RANK white, cost
|
||||
/// line, gold description, TOOLTIP_TALENT_NEXT_RANK + the next rank's gold description, green
|
||||
/// TOOLTIP_TALENT_LEARN.
|
||||
#[test]
|
||||
fn set_talent_renders_the_interleaved_tooltip() {
|
||||
let mut s = script();
|
||||
seed_talent_strings(&mut s);
|
||||
s.set_talents(one_tab_state());
|
||||
s.set_spell_tooltip(
|
||||
11070,
|
||||
@@ -136,13 +152,13 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetTalent(1, 1)
|
||||
assert(tt:IsShown(), "SetTalent shows")
|
||||
-- name, Rank 3/5, Instant, desc, Next rank:, next desc, Click to learn = 7 lines.
|
||||
-- name, rank, Instant, desc, next-rank header, next desc, learn hint = 7 lines.
|
||||
assert(tt:NumLines() == 7, "got " .. tt:NumLines())
|
||||
assert(TTTextLeft1:GetText() == "Improved Fireball")
|
||||
assert(TTTextLeft2:GetText() == "Rank 3/5")
|
||||
assert(TTTextLeft2:GetText() == "[RANK 3/5]", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "Instant")
|
||||
assert(TTTextLeft5:GetText() == "Next rank:")
|
||||
assert(TTTextLeft7:GetText() == "Click to learn")
|
||||
assert(TTTextLeft5:GetText() == "[NEXT_RANK]")
|
||||
assert(TTTextLeft7:GetText() == "[LEARN]")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -151,7 +167,7 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
let quads = s.extract();
|
||||
let green = quads.iter().any(|q| {
|
||||
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
|
||||
if t == "Click to learn" && c[0] < 1e-6 && (c[1] - 1.0).abs() < 1e-6)
|
||||
if t == "[LEARN]" && c[0] < 1e-6 && (c[1] - 1.0).abs() < 1e-6)
|
||||
});
|
||||
assert!(green, "the learn hint is green");
|
||||
assert!(s.take_errors().is_empty());
|
||||
@@ -162,6 +178,7 @@ fn set_talent_renders_the_interleaved_tooltip() {
|
||||
#[test]
|
||||
fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
let mut s = script();
|
||||
seed_talent_strings(&mut s);
|
||||
s.set_talents(one_tab_state());
|
||||
// No spell view pushed for Ignite (11119): the render falls back, the ask is recorded.
|
||||
s.run(
|
||||
@@ -171,7 +188,7 @@ fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetTalent(1, 2)
|
||||
assert(tt:IsShown())
|
||||
assert(TT2TextLeft1:GetText() == "Rank 0/5", "fallback shows the rank head")
|
||||
assert(TT2TextLeft1:GetText() == "[RANK 0/5]", "fallback shows the rank head")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -195,7 +212,7 @@ fn set_talent_locked_reqs_and_the_ask_once_miss() {
|
||||
local tt = getglobal("TT2")
|
||||
tt:SetOwner(getglobal("TB2"), "ANCHOR_RIGHT")
|
||||
tt:SetTalent(1, 2)
|
||||
-- name, Rank 0/5, req(red), desc = 4 lines; rank 0 has no next block, locked has no hint.
|
||||
-- name, rank, req(red), desc = 4 lines; rank 0 has no next block, locked has no hint.
|
||||
assert(tt:NumLines() == 4, "got " .. tt:NumLines())
|
||||
assert(TT2TextLeft3:GetText() == "Requires 5 points in Improved Fireball")
|
||||
"#,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::script::*;
|
||||
/// The shopping-compare pipeline end-to-end (0274 P4): a bag-ring hover on the main GameTooltip
|
||||
/// with shift held fires `SHOW_COMPARE_TOOLTIP` once per finger slot; a ref-shaped listener
|
||||
/// (PaperDollFrame.lua:621-640) seats ShoppingTooltip1/2, whose ARMED `SetInventoryItem` renders
|
||||
/// the byte law's compare shape — gray "Currently Equipped", WHITE name, the compact cut (the
|
||||
/// the byte law's compare shape — the gray CURRENTLY_EQUIPPED header, WHITE name, the compact cut (the
|
||||
/// description never prints). Releasing shift hides the pair; a shift-up hover fires nothing
|
||||
/// until the rising edge.
|
||||
#[test]
|
||||
@@ -135,7 +135,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
assert(table.getn(compare_calls) == 2, "two ring compares, got " .. table.getn(compare_calls))
|
||||
assert(compare_calls[1] == "11:1" and compare_calls[2] == "12:2", "slot:index order")
|
||||
assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown())
|
||||
assert(ShoppingTooltip1TextLeft1:GetText() == "Currently Equipped")
|
||||
assert(ShoppingTooltip1TextLeft1:GetText() == "[CURRENTLY_EQUIPPED]")
|
||||
assert(ShoppingTooltip1TextLeft2:GetText() == "Old Loop")
|
||||
assert(ShoppingTooltip2TextLeft2:GetText() == "Older Loop")
|
||||
-- The compact cut at 0x52e14c: the description never prints on a compare.
|
||||
@@ -167,10 +167,10 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
|
||||
})
|
||||
.unwrap_or([0.0; 4])
|
||||
};
|
||||
let gray = color_of("Currently Equipped");
|
||||
let gray = color_of("[CURRENTLY_EQUIPPED]");
|
||||
assert!(
|
||||
(gray[0] - 128.0 / 255.0).abs() < 0.01 && (gray[1] - 128.0 / 255.0).abs() < 0.01,
|
||||
"Currently Equipped is gray, got {gray:?}"
|
||||
"CURRENTLY_EQUIPPED is gray, got {gray:?}"
|
||||
);
|
||||
let name = color_of("Old Loop");
|
||||
assert_eq!(name, [1.0, 1.0, 1.0, 1.0], "compare name is WHITE");
|
||||
@@ -493,7 +493,7 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
|
||||
s.eval::<bool>(
|
||||
r#"local n = ShoppingTooltip1:NumLines()
|
||||
for i = 1, n do
|
||||
if getglobal("ShoppingTooltip1TextLeft"..i):GetText() == "Currently Equipped" then
|
||||
if getglobal("ShoppingTooltip1TextLeft"..i):GetText() == "[CURRENTLY_EQUIPPED]" then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,20 +37,21 @@ fn item_line_law_and_red_requirements() {
|
||||
texts,
|
||||
vec![
|
||||
"Ravager",
|
||||
"Binds when equipped",
|
||||
"Two-Hand",
|
||||
"[ITEM_BIND_ON_EQUIP]",
|
||||
"[INVTYPE_2HWEAPON]",
|
||||
// School 0 is physical: the reference names no school word for it either.
|
||||
"68 - 103 Damage",
|
||||
"+ 2 - 4 Shadow Damage",
|
||||
"(25.3 damage per second)",
|
||||
"+ 2 - 4 [SCHOOL5] Damage",
|
||||
"[DPS 25.3]",
|
||||
// Display order, NOT wire order: the fixture feeds (Stamina, Strength) but the
|
||||
// 0x808e88 table prints Strength first (STR,AGI,STA,INT,SPI,HP,MANA).
|
||||
"+9 Strength",
|
||||
"+12 Stamina",
|
||||
"+10 Shadow Resistance",
|
||||
"Durability 90 / 90",
|
||||
"Classes: Warrior, Rogue",
|
||||
"Requires Level 37",
|
||||
"Chance on hit: Ravager",
|
||||
"[MOD_STRENGTH +9]",
|
||||
"[MOD_STAMINA +12]",
|
||||
"[RESIST_SINGLE +10 [SCHOOL5]]",
|
||||
"[DURABILITY 90/90]",
|
||||
"[CLASSES Warrior, Rogue]",
|
||||
"[MIN_LEVEL 37]",
|
||||
"[ONPROC] Ravager",
|
||||
"\"A wicked axe of the Scarlet Crusade.\"",
|
||||
],
|
||||
"the verified line order (0276)"
|
||||
@@ -109,13 +110,9 @@ fn red_lines_track_player_state() {
|
||||
.unwrap_or_else(|| panic!("no line starting {needle}"))
|
||||
.1
|
||||
};
|
||||
assert_eq!(find("[MIN_LEVEL"), [1.0, 1.0, 1.0, 1.0], "60 ≥ 37 → white");
|
||||
assert_eq!(
|
||||
find("Requires Level"),
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
"60 ≥ 37 → white"
|
||||
);
|
||||
assert_eq!(
|
||||
find("Classes:"),
|
||||
find("[CLASSES"),
|
||||
[1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0],
|
||||
"mage → red"
|
||||
);
|
||||
@@ -170,26 +167,26 @@ fn verified_families_signable_locked_resists_known() {
|
||||
texts,
|
||||
vec![
|
||||
"Sealed Charter",
|
||||
"<Right Click for Details>",
|
||||
"Unique",
|
||||
"This Item Begins a Quest",
|
||||
"Locked",
|
||||
"+5 to All Resistances",
|
||||
"Already known",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_UNIQUE]",
|
||||
"[ITEM_STARTS_QUEST]",
|
||||
"[LOCKED]",
|
||||
"[RESIST_ALL +5]",
|
||||
"[ITEM_SPELL_KNOWN]",
|
||||
"\"Sign here.\"",
|
||||
],
|
||||
"the verified gated families in the verified order"
|
||||
);
|
||||
let color = |needle: &str| lines.iter().find(|(t, _)| t == needle).unwrap().1;
|
||||
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||||
assert_eq!(color("Locked"), red, "LOCKED is red");
|
||||
assert_eq!(color("[LOCKED]"), red, "LOCKED is red");
|
||||
assert_eq!(
|
||||
color("Already known"),
|
||||
color("[ITEM_SPELL_KNOWN]"),
|
||||
red,
|
||||
"SPELL_KNOWN is unconditional red"
|
||||
);
|
||||
assert_eq!(
|
||||
color("<Right Click for Details>"),
|
||||
color("[ITEM_SIGNABLE]"),
|
||||
[0.0, 1.0, 0.0, 1.0],
|
||||
"SIGNABLE is green"
|
||||
);
|
||||
@@ -263,7 +260,11 @@ fn proficiency_and_reputation_reds() {
|
||||
.1
|
||||
};
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Two-Hand"), white, "proficient slot is white");
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_2HWEAPON]"),
|
||||
white,
|
||||
"proficient slot is white"
|
||||
);
|
||||
assert_eq!(
|
||||
right_color(&mut s, "Axe"),
|
||||
white,
|
||||
@@ -284,7 +285,7 @@ fn proficiency_and_reputation_reds() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(
|
||||
color(&lines, "Two-Hand"),
|
||||
color(&lines, "[INVTYPE_2HWEAPON]"),
|
||||
white,
|
||||
"slot survives a hard miss"
|
||||
);
|
||||
@@ -302,7 +303,11 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(871)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Two-Hand"), red, "alt-usable reds the slot");
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_2HWEAPON]"),
|
||||
red,
|
||||
"alt-usable reds the slot"
|
||||
);
|
||||
assert_eq!(
|
||||
right_color(&mut s, "Axe"),
|
||||
white,
|
||||
@@ -326,8 +331,10 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
// `INVTYPE_WEAPONOFFHAND`, NOT `INVTYPE_SHIELD` — one enUS sentence, two keys, and only the
|
||||
// weapon one belongs on an InventoryType 22.
|
||||
assert_eq!(
|
||||
color(&lines, "Off Hand"),
|
||||
color(&lines, "[INVTYPE_WEAPONOFFHAND]"),
|
||||
red,
|
||||
"no Dual Wield reds the slot"
|
||||
);
|
||||
@@ -341,7 +348,11 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Off Hand"), white, "Dual Wield clears it");
|
||||
assert_eq!(
|
||||
color(&lines, "[INVTYPE_WEAPONOFFHAND]"),
|
||||
white,
|
||||
"Dual Wield clears it"
|
||||
);
|
||||
// An item class with NO proficiency entry never reds (the map only ever holds classes
|
||||
// the server sent masks for).
|
||||
s.set_item_template(
|
||||
@@ -358,7 +369,7 @@ fn proficiency_and_reputation_reds() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(
|
||||
color(&lines, "Chest"),
|
||||
color(&lines, "[INVTYPE_CHEST]"),
|
||||
white,
|
||||
"a class with no mask entry stays white"
|
||||
);
|
||||
@@ -377,7 +388,7 @@ fn proficiency_and_reputation_reds() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(889)
|
||||
assert(TTTextLeft2:GetText() == "Finger")
|
||||
assert(TTTextLeft2:GetText() == "[INVTYPE_FINGER]")
|
||||
assert(TTTextRight2:GetText() == nil or TTTextRight2:GetText() == "",
|
||||
"a ring never prints its Miscellaneous type")
|
||||
"#,
|
||||
@@ -413,7 +424,7 @@ fn required_level_one_is_hidden() {
|
||||
assert(tt:NumLines() == 1, "req 1 hides like req 0, got " .. tt:NumLines())
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(13)
|
||||
assert(tt:NumLines() == 2, "req 2 prints, got " .. tt:NumLines())
|
||||
assert(TTTextLeft2:GetText() == "Requires Level 2")
|
||||
assert(TTTextLeft2:GetText() == "[MIN_LEVEL 2]")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -516,11 +527,11 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
texts,
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Guild Name: BTC",
|
||||
"Guild Master: Twowarrior",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique",
|
||||
"[GUILD_CHARTER_TITLE BTC]",
|
||||
"[GUILD_CHARTER_CREATOR Twowarrior]",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]",
|
||||
],
|
||||
"the two guild lines sit ABOVE the green line, not below it"
|
||||
);
|
||||
@@ -542,10 +553,10 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
hover(&mut s, 2),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Guild Name: BTC",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
"[GUILD_CHARTER_TITLE BTC]",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]"
|
||||
],
|
||||
"an unresolved owner withholds ITS line only — the repaint fills it"
|
||||
);
|
||||
@@ -553,9 +564,9 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
hover(&mut s, 3),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]"
|
||||
],
|
||||
"no record yet: exactly the plate we shipped before, not a blank one"
|
||||
);
|
||||
@@ -563,11 +574,11 @@ fn charter_lines_sit_between_the_name_and_the_signable_line() {
|
||||
hover(&mut s, 4),
|
||||
vec![
|
||||
"Guild Charter",
|
||||
"Petition: Something",
|
||||
"Created by Someone",
|
||||
"<Right Click for Details>",
|
||||
"Soulbound",
|
||||
"Unique"
|
||||
"[PETITION_TITLE Something]",
|
||||
"[PETITION_CREATOR Someone]",
|
||||
"[ITEM_SIGNABLE]",
|
||||
"[ITEM_SOULBOUND]",
|
||||
"[ITEM_UNIQUE]"
|
||||
],
|
||||
"the record's charter bit picks the key family"
|
||||
);
|
||||
@@ -650,7 +661,7 @@ fn instance_tail_creator_and_readable() {
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["Plain Letter", "Written by One", "<Right Click to Read>"],
|
||||
vec!["Plain Letter", "[WRITTEN_BY One]", "[ITEM_READABLE]"],
|
||||
"the letter: writer line + instance-gated READABLE"
|
||||
);
|
||||
assert_eq!(lines[1].1, [1.0, 1.0, 1.0, 1.0], "WRITTEN_BY is white");
|
||||
@@ -659,14 +670,18 @@ fn instance_tail_creator_and_readable() {
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 2)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(texts, vec!["Plain Letter", "<Right Click to Read>"]);
|
||||
assert_eq!(texts, vec!["Plain Letter", "[ITEM_READABLE]"]);
|
||||
// Crafted + locked: the green-escaped Made-by; no open line while LockID gates it.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 3)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["Heavy Chest", "Locked", "|cff00ff00<Made by Geoffrey>|r"],
|
||||
vec![
|
||||
"Heavy Chest",
|
||||
"[LOCKED]",
|
||||
"|cff00ff00[CREATED_BY Geoffrey]|r"
|
||||
],
|
||||
"CREATED_BY carries the string's own green escape; locked chest hides OPENABLE"
|
||||
);
|
||||
// The instance UNLOCKED bit retires the LOCKED line AND satisfies the openable lock sub-gate.
|
||||
@@ -674,14 +689,14 @@ fn instance_tail_creator_and_readable() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(texts, vec!["Heavy Chest", "<Right Click to Open>"]);
|
||||
assert_eq!(texts, vec!["Heavy Chest", "[ITEM_OPENABLE]"]);
|
||||
assert_eq!(lines[1].1, [0.0, 1.0, 0.0, 1.0], "OPENABLE is green");
|
||||
// The director's case: a lockless LOOTABLE template (a clam) is openable outright — name +
|
||||
// the green line, nothing between them.
|
||||
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 5)"#)
|
||||
.unwrap();
|
||||
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(texts, vec!["Small Barnacled Clam", "<Right Click to Open>"]);
|
||||
assert_eq!(texts, vec!["Small Barnacled Clam", "[ITEM_OPENABLE]"]);
|
||||
// The same clam mid-cooldown takes SetBagItem's OTHER leg (p6=1) — the openable tree is
|
||||
// skipped wholesale, so the green line is gone (the reference prints ITEM_COOLDOWN_TIME in
|
||||
// its place; unfed here). `hasCooldown`, the binding's own return, is the same boolean.
|
||||
@@ -765,23 +780,35 @@ fn a_runtime_bound_instance_overrides_the_bind_line_to_soulbound() {
|
||||
let bind_line = |s: &mut UiScript| {
|
||||
lines_of(s)
|
||||
.into_iter()
|
||||
.find(|(t, _)| t == "Soulbound" || t.starts_with("Binds when") || t == "Quest Item")
|
||||
.find(|(t, _)| {
|
||||
matches!(
|
||||
t.as_str(),
|
||||
"[ITEM_SOULBOUND]"
|
||||
| "[ITEM_BIND_ON_PICKUP]"
|
||||
| "[ITEM_BIND_ON_EQUIP]"
|
||||
| "[ITEM_BIND_ON_USE]"
|
||||
| "[ITEM_BIND_QUEST]"
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no bind line at all"))
|
||||
};
|
||||
let (text, color) = bind_line(&mut s);
|
||||
assert_eq!(text, "Soulbound", "a runtime-bound instance overrides §6");
|
||||
assert_eq!(
|
||||
text, "[ITEM_SOULBOUND]",
|
||||
"a runtime-bound instance overrides §6"
|
||||
);
|
||||
assert_eq!(color, [1.0, 1.0, 1.0, 1.0], "§6 is white");
|
||||
|
||||
// Control 1: the SAME item, instance not bound — the template's bonding stands.
|
||||
s.run(r#"TT:SetBagItem(0, 2)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Binds when equipped");
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
|
||||
|
||||
// Control 2: a TEMPLATE hover carries no instance at all, so nothing can override.
|
||||
s.run(r#"TT:SetItemById(871)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Binds when equipped");
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_ON_EQUIP]");
|
||||
|
||||
// Control 3: the override's other arm is ITEM_BIND_QUEST — the same text 4|5 already print.
|
||||
s.run(r#"TT:SetBagItem(0, 3)"#).unwrap();
|
||||
assert_eq!(bind_line(&mut s).0, "Quest Item");
|
||||
assert_eq!(bind_line(&mut s).0, "[ITEM_BIND_QUEST]");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ fn bag_item_money_law_and_fallback() {
|
||||
TT:SetBagItem(0, 2)
|
||||
assert(money_fired == nil, "unsellable fires no money")
|
||||
local last = getglobal("TTTextLeft" .. TT:NumLines())
|
||||
assert(last:GetText() == "No sell price", "ITEM_UNSELLABLE line")
|
||||
assert(last:GetText() == "[ITEM_UNSELLABLE]", "ITEM_UNSELLABLE line")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -160,7 +160,7 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
|
||||
s.resolve();
|
||||
};
|
||||
answer(&mut s);
|
||||
// name 14 + gap 2 + "1 Charge" 14 + gap 2 + description 36 + 2·pad 20 = 88.
|
||||
// name 14 + gap 2 + the charges line 14 + gap 2 + description 36 + 2·pad 20 = 88.
|
||||
s.run(
|
||||
r#"
|
||||
assert(TT:GetWidth() == 270, "wrap column + padding, got " .. TT:GetWidth())
|
||||
@@ -285,17 +285,17 @@ fn real_instance_hover_renders_live_durability() {
|
||||
found = nil
|
||||
for i = 1, tt:NumLines() do
|
||||
local t = getglobal("TTTextLeft" .. i):GetText()
|
||||
if t and string.find(t, "Durability") then found = t end
|
||||
if t and string.find(t, "DURABILITY") then found = t end
|
||||
end
|
||||
assert(found == "Durability 30 / 40", "live pair on a bag hover, got " .. tostring(found))
|
||||
assert(found == "[DURABILITY 30/40]", "live pair on a bag hover, got " .. tostring(found))
|
||||
-- The template/link hover of the SAME item keeps the authored full pair.
|
||||
tt:SetItemById(2264)
|
||||
found = nil
|
||||
for i = 1, tt:NumLines() do
|
||||
local t = getglobal("TTTextLeft" .. i):GetText()
|
||||
if t and string.find(t, "Durability") then found = t end
|
||||
if t and string.find(t, "DURABILITY") then found = t end
|
||||
end
|
||||
assert(found == "Durability 40 / 40", "template hover stays full, got " .. tostring(found))
|
||||
assert(found == "[DURABILITY 40/40]", "template hover stays full, got " .. tostring(found))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -346,9 +346,9 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
found = nil
|
||||
for i = 1, tt:NumLines() do
|
||||
local t = getglobal("TTTextLeft" .. i):GetText()
|
||||
if t and string.find(t, "Durability") then found = t end
|
||||
if t and string.find(t, "DURABILITY") then found = t end
|
||||
end
|
||||
assert(found == "Durability 0 / 40", "broken gear shows its true 0, got " .. tostring(found))
|
||||
assert(found == "[DURABILITY 0/40]", "broken gear shows its true 0, got " .. tostring(found))
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -358,7 +358,7 @@ fn broken_instance_hover_renders_zero_durability() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let dur = lines
|
||||
.iter()
|
||||
.find(|(t, _)| t.starts_with("Durability"))
|
||||
.find(|(t, _)| t.starts_with("[DURABILITY"))
|
||||
.expect("the durability line renders");
|
||||
assert_eq!(
|
||||
dur.1,
|
||||
@@ -431,7 +431,8 @@ fn an_enchanted_instance_renders_its_enchant_line_before_durability() {
|
||||
.unwrap_or_else(|| panic!("no {needle} line in {texts:?}"))
|
||||
};
|
||||
assert!(
|
||||
at("+7 Nature Resistance") < at("Agility +15") && at("Agility +15") < at("Durability"),
|
||||
at("[RESIST_SINGLE +7 [SCHOOL3]]") < at("Agility +15")
|
||||
&& at("Agility +15") < at("[DURABILITY"),
|
||||
"the enchant line sits between resistances and durability: {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -564,7 +565,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
EnchantView {
|
||||
slot: 1,
|
||||
name: "Rockbiter Weapon".into(),
|
||||
remaining_ms: Some(275_000), // 4 min 35 s → "(5 min)"
|
||||
remaining_ms: Some(275_000), // 4 min 35 s → the MIN arm, ceiled to 5
|
||||
charges: 5,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -606,7 +607,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
texts.contains(&"Rockbiter Weapon (5 min) (5 Charges)"),
|
||||
texts.contains(&"[ENCH Rockbiter Weapon 5 m] ([CHARGES 5 x])"),
|
||||
"one line: name, countdown, charges — got {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -623,7 +624,7 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
|
||||
/// (`0x5d8b00`, the same string `GetLootSlotInfo` returns), so the plate reads "… of the Monkey".
|
||||
///
|
||||
/// This is the reported bug's exact shape: through the template path (`SetItemById`) the same
|
||||
/// hover printed `<Random enchantment>` until the item reached a bag.
|
||||
/// hover printed the ITEM_RANDOM_ENCHANT placeholder until the item reached a bag.
|
||||
#[test]
|
||||
fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
let mut s = script();
|
||||
@@ -689,7 +690,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
!texts.contains(&"<Random enchantment>"),
|
||||
!texts.contains(&"[ITEM_RANDOM_ENCHANT]"),
|
||||
"a block source never reaches the placeholder arm — got {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -749,7 +750,7 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
|
||||
.map(|(t, _)| t)
|
||||
.collect();
|
||||
assert!(
|
||||
!texts.iter().any(|t| t == "<Random enchantment>"),
|
||||
!texts.iter().any(|t| t == "[ITEM_RANDOM_ENCHANT]"),
|
||||
"no placeholder on a block source — got {texts:?}"
|
||||
);
|
||||
}
|
||||
@@ -801,7 +802,7 @@ fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
.map(|(t, _)| t)
|
||||
.collect();
|
||||
assert!(
|
||||
!texts.iter().any(|t| t == "<Random enchantment>"),
|
||||
!texts.iter().any(|t| t == "[ITEM_RANDOM_ENCHANT]"),
|
||||
"a link never reaches the placeholder arm — got {texts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -815,7 +816,7 @@ fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `<Random enchantment>` (§E5) — the template-only placeholder: a random-property item with NO
|
||||
/// `ITEM_RANDOM_ENCHANT` (§E5) — the template-only placeholder: a random-property item with NO
|
||||
/// instance to read a roll from prints it, green, and the per-slot lines and this one are mutually
|
||||
/// exclusive by construction. The control is the same template hovered as a real enchanted
|
||||
/// instance: the roll is known, so the placeholder gives way to the slot lines.
|
||||
@@ -844,7 +845,7 @@ fn random_property_template_hover_shows_the_placeholder() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let placeholder = lines
|
||||
.iter()
|
||||
.find(|(t, _)| t == "<Random enchantment>")
|
||||
.find(|(t, _)| t == "[ITEM_RANDOM_ENCHANT]")
|
||||
.expect("the template hover shows the placeholder");
|
||||
assert_eq!(placeholder.1, [0.0, 1.0, 0.0, 1.0], "green");
|
||||
|
||||
@@ -876,7 +877,7 @@ fn random_property_template_hover_shows_the_placeholder() {
|
||||
let lines = super::lines_of(&mut s);
|
||||
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(
|
||||
!texts.contains(&"<Random enchantment>") && texts.contains(&"Stamina +7"),
|
||||
!texts.contains(&"[ITEM_RANDOM_ENCHANT]") && texts.contains(&"Stamina +7"),
|
||||
"an instance's known roll replaces the placeholder — got {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,120 @@
|
||||
//! protocol (engine fires `OnTooltipAddMoney` — merchant open + real instance + repair off),
|
||||
//! and the in-flight-template fallback.
|
||||
|
||||
use super::common::script;
|
||||
use super::common::script as bare_script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A stand-in `GlobalStrings.lua` for the item builder — **deliberately not the shipped wording**.
|
||||
///
|
||||
/// Every sentence the builder draws is a key resolved off the VM's own globals (decision 2045),
|
||||
/// and what these tests establish is *which key* each line reaches and what fills it, never what
|
||||
/// the sentence says. Naming each value after its own key is the point: `INVTYPE_SHIELD`,
|
||||
/// `INVTYPE_WEAPONOFFHAND` and `SECONDARYHANDSLOT` all read "Off Hand" in enUS, `ITEM_REQ_SKILL`
|
||||
/// shares "Requires %s" with the three `LOCKED_WITH_*` keys, and an assertion on the English
|
||||
/// would pass on every one of them. Read an expected line here as "this key, filled with these".
|
||||
///
|
||||
/// The values keep each shipped template's *hole shape* — that is what proves the fill — and
|
||||
/// nothing else about it.
|
||||
fn seed_item_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
CURRENTLY_EQUIPPED = "[CURRENTLY_EQUIPPED]"
|
||||
GUILD_CHARTER_TITLE = "[GUILD_CHARTER_TITLE %s]"
|
||||
GUILD_CHARTER_CREATOR = "[GUILD_CHARTER_CREATOR %s]"
|
||||
PETITION_TITLE = "[PETITION_TITLE %s]"
|
||||
PETITION_CREATOR = "[PETITION_CREATOR %s]"
|
||||
ITEM_SIGNABLE = "[ITEM_SIGNABLE]"
|
||||
ITEM_CONJURED = "[ITEM_CONJURED]"
|
||||
ITEM_SOULBOUND = "[ITEM_SOULBOUND]"
|
||||
ITEM_BIND_QUEST = "[ITEM_BIND_QUEST]"
|
||||
ITEM_BIND_ON_PICKUP = "[ITEM_BIND_ON_PICKUP]"
|
||||
ITEM_BIND_ON_EQUIP = "[ITEM_BIND_ON_EQUIP]"
|
||||
ITEM_BIND_ON_USE = "[ITEM_BIND_ON_USE]"
|
||||
ITEM_UNIQUE = "[ITEM_UNIQUE]"
|
||||
ITEM_UNIQUE_MULTIPLE = "[ITEM_UNIQUE_MULTIPLE %d]"
|
||||
ITEM_STARTS_QUEST = "[ITEM_STARTS_QUEST]"
|
||||
LOCKED = "[LOCKED]"
|
||||
|
||||
INVTYPE_HEAD = "[INVTYPE_HEAD]"; INVTYPE_NECK = "[INVTYPE_NECK]"
|
||||
INVTYPE_SHOULDER = "[INVTYPE_SHOULDER]"; INVTYPE_BODY = "[INVTYPE_BODY]"
|
||||
INVTYPE_CHEST = "[INVTYPE_CHEST]"; INVTYPE_WAIST = "[INVTYPE_WAIST]"
|
||||
INVTYPE_LEGS = "[INVTYPE_LEGS]"; INVTYPE_FEET = "[INVTYPE_FEET]"
|
||||
INVTYPE_WRIST = "[INVTYPE_WRIST]"; INVTYPE_HAND = "[INVTYPE_HAND]"
|
||||
INVTYPE_FINGER = "[INVTYPE_FINGER]"; INVTYPE_TRINKET = "[INVTYPE_TRINKET]"
|
||||
INVTYPE_WEAPON = "[INVTYPE_WEAPON]"; INVTYPE_SHIELD = "[INVTYPE_SHIELD]"
|
||||
INVTYPE_RANGED = "[INVTYPE_RANGED]"; INVTYPE_CLOAK = "[INVTYPE_CLOAK]"
|
||||
INVTYPE_2HWEAPON = "[INVTYPE_2HWEAPON]"; INVTYPE_TABARD = "[INVTYPE_TABARD]"
|
||||
INVTYPE_ROBE = "[INVTYPE_ROBE]"; INVTYPE_WEAPONMAINHAND = "[INVTYPE_WEAPONMAINHAND]"
|
||||
INVTYPE_WEAPONOFFHAND = "[INVTYPE_WEAPONOFFHAND]"; INVTYPE_HOLDABLE = "[INVTYPE_HOLDABLE]"
|
||||
INVTYPE_RELIC = "[INVTYPE_RELIC]"
|
||||
-- INVTYPE_AMMO / _THROWN / _RANGEDRIGHT / _QUIVER are DELIBERATELY absent: 1.12's
|
||||
-- GlobalStrings.lua does not ship them either, so those four types draw no slot cell.
|
||||
|
||||
SPELL_SCHOOL1_CAP = "[SCHOOL1]"; SPELL_SCHOOL2_CAP = "[SCHOOL2]"
|
||||
SPELL_SCHOOL3_CAP = "[SCHOOL3]"; SPELL_SCHOOL4_CAP = "[SCHOOL4]"
|
||||
SPELL_SCHOOL5_CAP = "[SCHOOL5]"; SPELL_SCHOOL6_CAP = "[SCHOOL6]"
|
||||
|
||||
DPS_TEMPLATE = "[DPS %.1f]"
|
||||
ARMOR_TEMPLATE = "[ARMOR %d]"
|
||||
SHIELD_BLOCK_TEMPLATE = "[BLOCK %d]"
|
||||
ITEM_MOD_MANA = "[MOD_MANA %c%d]"
|
||||
ITEM_MOD_HEALTH = "[MOD_HEALTH %c%d]"
|
||||
ITEM_MOD_AGILITY = "[MOD_AGILITY %c%d]"
|
||||
ITEM_MOD_STRENGTH = "[MOD_STRENGTH %c%d]"
|
||||
ITEM_MOD_INTELLECT = "[MOD_INTELLECT %c%d]"
|
||||
ITEM_MOD_SPIRIT = "[MOD_SPIRIT %c%d]"
|
||||
ITEM_MOD_STAMINA = "[MOD_STAMINA %c%d]"
|
||||
ITEM_RESIST_ALL = "[RESIST_ALL %c%d]"
|
||||
ITEM_RESIST_SINGLE = "[RESIST_SINGLE %c%d %s]"
|
||||
|
||||
ITEM_RANDOM_ENCHANT = "[ITEM_RANDOM_ENCHANT]"
|
||||
ITEM_ENCHANT_TIME_LEFT_DAYS = "[ENCH %s %d d]"
|
||||
ITEM_ENCHANT_TIME_LEFT_DAYS_P1 = "[ENCH %s %d dd]"
|
||||
ITEM_ENCHANT_TIME_LEFT_HOURS = "[ENCH %s %d h]"
|
||||
ITEM_ENCHANT_TIME_LEFT_HOURS_P1 = "[ENCH %s %d hh]"
|
||||
ITEM_ENCHANT_TIME_LEFT_MIN = "[ENCH %s %d m]"
|
||||
ITEM_ENCHANT_TIME_LEFT_SEC = "[ENCH %s %d s]"
|
||||
ITEM_SPELL_CHARGES = "[CHARGES %d]"
|
||||
ITEM_SPELL_CHARGES_P1 = "[CHARGES %d x]"
|
||||
DURABILITY_TEMPLATE = "[DURABILITY %d/%d]"
|
||||
-- ITEM_DURATION_* ships no _P1 twin, and neither does this stand-in.
|
||||
ITEM_DURATION_DAYS = "[DURATION %d d]"
|
||||
ITEM_DURATION_HOURS = "[DURATION %d h]"
|
||||
ITEM_DURATION_MIN = "[DURATION %d m]"
|
||||
ITEM_DURATION_SEC = "[DURATION %d s]"
|
||||
|
||||
ITEM_CLASSES_ALLOWED = "[CLASSES %s]"
|
||||
ITEM_RACES_ALLOWED = "[RACES %s]"
|
||||
ITEM_MIN_LEVEL = "[MIN_LEVEL %d]"
|
||||
ITEM_MIN_SKILL = "[MIN_SKILL %s %d]"
|
||||
ITEM_REQ_SKILL = "[REQ_SKILL %s]"
|
||||
ITEM_SPELL_KNOWN = "[ITEM_SPELL_KNOWN]"
|
||||
ITEM_SPELL_TRIGGER_ONUSE = "[ONUSE]"
|
||||
ITEM_SPELL_TRIGGER_ONEQUIP = "[ONEQUIP]"
|
||||
ITEM_SPELL_TRIGGER_ONPROC = "[ONPROC]"
|
||||
|
||||
ITEM_SET_NAME = "[SET_NAME %s %d/%d]"
|
||||
ITEM_SET_BONUS = "[SET_BONUS %s]"
|
||||
ITEM_SET_BONUS_GRAY = "[SET_BONUS_GRAY %d %s]"
|
||||
|
||||
ITEM_WRITTEN_BY = "[WRITTEN_BY %s]"
|
||||
ITEM_CREATED_BY = "|cff00ff00[CREATED_BY %s]|r"
|
||||
ITEM_OPENABLE = "[ITEM_OPENABLE]"
|
||||
ITEM_READABLE = "[ITEM_READABLE]"
|
||||
ITEM_UNSELLABLE = "[ITEM_UNSELLABLE]"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The shared fixture VM, with [`seed_item_strings`] already run — every test in this tree needs
|
||||
/// it, because a builder that resolves nothing draws nothing.
|
||||
fn script() -> UiScript {
|
||||
let mut s = bare_script();
|
||||
seed_item_strings(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// A full weapon view exercising most line families.
|
||||
fn axe() -> ItemTemplateView {
|
||||
ItemTemplateView {
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
use super::{lines_of, script};
|
||||
use crate::script::*;
|
||||
|
||||
/// The §22 SET block, byte-read: a blank gold line, the gold "name (owned/total)" header, the
|
||||
/// skill line (white/red) between header and the member ladder (cream when equipped / gray;
|
||||
/// in-flight names wait), a second blank, then the threshold bonuses SORTED ascending — green
|
||||
/// only when the skill requirement is met AND owned ≥ threshold — plus the ask-once for an
|
||||
/// unseen set id.
|
||||
/// The §22 SET block, byte-read: a blank gold line, the gold `ITEM_SET_NAME` header, the skill
|
||||
/// line (white/red) between header and the member ladder (cream when equipped / gray; in-flight
|
||||
/// names wait), a second blank, then the threshold bonuses SORTED ascending — green only when the
|
||||
/// skill requirement is met AND owned ≥ threshold — plus the ask-once for an unseen set id.
|
||||
///
|
||||
/// **The two bonus arms are two different keys** (`0x52e056..0x52e0d6`): an ACTIVE bonus formats
|
||||
/// `ITEM_SET_BONUS` = "Set: %s" with no count at all, and only an inactive one formats
|
||||
/// `ITEM_SET_BONUS_GRAY` = "(%d) Set: %s". Watching the (2) bonus move between them as the skill
|
||||
/// gate opens and closes is what these assertions are for.
|
||||
#[test]
|
||||
fn item_set_block_counts_and_colors() {
|
||||
let mut s = script();
|
||||
@@ -65,14 +69,16 @@ fn item_set_block_counts_and_colors() {
|
||||
-- in the reference: `0x530270` bails before its `inc [esi+0x31c]`).
|
||||
assert(TTTextLeft3:GetText() == " \n", "blank before the header, got "
|
||||
.. string.format("%q", tostring(TTTextLeft3:GetText())))
|
||||
assert(TTTextLeft4:GetText() == "Defias Leather (2/5)", "got " .. tostring(TTTextLeft4:GetText()))
|
||||
assert(TTTextLeft4:GetText() == "[SET_NAME Defias Leather 2/5]", "got " .. tostring(TTTextLeft4:GetText()))
|
||||
assert(TTTextLeft5:GetText() == " Defias Mark")
|
||||
assert(TTTextLeft8:GetText() == " Defias Boots", "in-flight member renders no line")
|
||||
assert(TTTextLeft9:GetText() == " \n", "blank before the bonuses, got "
|
||||
.. string.format("%q", tostring(TTTextLeft9:GetText())))
|
||||
assert(TTTextLeft10:GetText() == "(2) Set: Increases movement speed slightly.",
|
||||
-- Owned 2 of 5: the (2) bonus is ACTIVE and takes the countless ITEM_SET_BONUS; the
|
||||
-- (4) one is not and takes ITEM_SET_BONUS_GRAY, which is the arm that carries the count.
|
||||
assert(TTTextLeft10:GetText() == "[SET_BONUS Increases movement speed slightly.]",
|
||||
"thresholds sort ascending, got " .. tostring(TTTextLeft10:GetText()))
|
||||
assert(TTTextLeft11:GetText() == "(4) Set: +10 Attack Power.")
|
||||
assert(TTTextLeft11:GetText() == "[SET_BONUS_GRAY 4 +10 Attack Power.]")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -90,7 +96,7 @@ fn item_set_block_counts_and_colors() {
|
||||
let green = [0.0, 1.0, 0.0, 1.0];
|
||||
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
|
||||
assert_eq!(
|
||||
color(&lines, "Defias Leather (2/5)"),
|
||||
color(&lines, "[SET_NAME Defias Leather 2/5]"),
|
||||
gold,
|
||||
"set name is gold"
|
||||
);
|
||||
@@ -105,12 +111,12 @@ fn item_set_block_counts_and_colors() {
|
||||
"unowned member is gray"
|
||||
);
|
||||
assert_eq!(
|
||||
color(&lines, "(2) Set: Increases movement speed slightly."),
|
||||
color(&lines, "[SET_BONUS Increases movement speed slightly.]"),
|
||||
green,
|
||||
"active bonus is green"
|
||||
);
|
||||
assert_eq!(
|
||||
color(&lines, "(4) Set: +10 Attack Power."),
|
||||
color(&lines, "[SET_BONUS_GRAY 4 +10 Attack Power.]"),
|
||||
gray,
|
||||
"inactive bonus is gray"
|
||||
);
|
||||
@@ -124,16 +130,20 @@ fn item_set_block_counts_and_colors() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(6303)
|
||||
assert(TTTextLeft5:GetText() == "Requires Leatherworking (250)",
|
||||
assert(TTTextLeft5:GetText() == "[MIN_SKILL Leatherworking 250]",
|
||||
"the skill line sits between header and members")
|
||||
assert(TTTextLeft6:GetText() == " Defias Mark")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(color(&lines, "Requires Leatherworking (250)"), red);
|
||||
assert_eq!(color(&lines, "[MIN_SKILL Leatherworking 250]"), red);
|
||||
// The gate also moves it onto the OTHER key: an inactive bonus is the counted spelling.
|
||||
assert_eq!(
|
||||
color(&lines, "(2) Set: Increases movement speed slightly."),
|
||||
color(
|
||||
&lines,
|
||||
"[SET_BONUS_GRAY 2 Increases movement speed slightly.]"
|
||||
),
|
||||
gray,
|
||||
"an unmet skill grays even a met threshold"
|
||||
);
|
||||
@@ -144,13 +154,13 @@ fn item_set_block_counts_and_colors() {
|
||||
.unwrap();
|
||||
let lines = lines_of(&mut s);
|
||||
assert_eq!(
|
||||
color(&lines, "Requires Leatherworking (250)"),
|
||||
color(&lines, "[MIN_SKILL Leatherworking 250]"),
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
);
|
||||
assert_eq!(
|
||||
color(&lines, "(2) Set: Increases movement speed slightly."),
|
||||
color(&lines, "[SET_BONUS Increases movement speed slightly.]"),
|
||||
green,
|
||||
"meeting the skill restores the green"
|
||||
"meeting the skill restores the green — and the countless key with it"
|
||||
);
|
||||
// An unseen set id records the ask-once.
|
||||
s.set_item_template(
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
//! The engine unit tooltip builder (decision 0274 P3, law per 0276): the level-line composition
|
||||
//! (four templates, the rank words, "??", Corpse, "Race Class (Player)"), the flag lines
|
||||
//! (four `TOOLTIP_UNIT_LEVEL*` keys, the `ELITE`/`BOSS` rank table, "??", `CORPSE`, "Race Class"
|
||||
//! over `PLAYER` — all seeded here as marked stand-ins, see [`seed_level_strings`]), the flag lines
|
||||
//! (PvP white / Skinnable red / Civilian green), the world-mouseover drive (default anchor +
|
||||
//! `UPDATE_MOUSEOVER_UNIT` recolor + the fade arm on loss), and the health-bar watcher.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::*;
|
||||
|
||||
/// A stand-in string table for the level line — the four `TOOLTIP_UNIT_LEVEL*` templates and the
|
||||
/// three word slots that fill them, **deliberately not the shipped wording**. What these tests
|
||||
/// establish is *which key* each slot combination reaches and what fills it, never what the
|
||||
/// sentence says (decision 2045, "assert the identifier, not the sentence"), and here that is not
|
||||
/// a formality: `TOOLTIP_UNIT_LEVEL_CLASS`'s enUS "Level %s %s" is word-for-word
|
||||
/// `FRIENDS_LEVEL_TEMPLATE`, `UNIT_TYPE_LEVEL_TEMPLATE` and `CHARACTER_SELECT_INFO`, and the bare
|
||||
/// template's "Level %s" is also `ITEM_LEVEL`, `LEVEL_GAINED` and `UNIT_LEVEL_TEMPLATE`. An
|
||||
/// assertion on the English would pass on all seven.
|
||||
fn seed_level_strings(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
TOOLTIP_UNIT_LEVEL = "[LEVEL %s]"
|
||||
TOOLTIP_UNIT_LEVEL_CLASS = "[LEVEL_CLASS %s %s]"
|
||||
TOOLTIP_UNIT_LEVEL_TYPE = "[LEVEL_TYPE %s %s]"
|
||||
TOOLTIP_UNIT_LEVEL_CLASS_TYPE = "[LEVEL_CLASS_TYPE %s %s %s]"
|
||||
CORPSE = "[CORPSE]"
|
||||
PLAYER = "[PLAYER]"
|
||||
ELITE = "[ELITE]"
|
||||
BOSS = "[BOSS]"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn wolf() -> UnitState {
|
||||
UnitState {
|
||||
exists: true,
|
||||
@@ -21,10 +46,11 @@ fn wolf() -> UnitState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The creature law: gold name, subtitle, "Level 10 Beast (Elite)", red Skinnable.
|
||||
/// The creature law: gold name, subtitle, the CLASS_TYPE level line, red Skinnable.
|
||||
#[test]
|
||||
fn creature_line_law() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
let mut u = wolf();
|
||||
u.subtitle = Some("Alpha".into());
|
||||
@@ -41,7 +67,7 @@ fn creature_line_law() {
|
||||
assert(tt:SetUnit("target") == 1, "SetUnit returns 1 on a live unit")
|
||||
assert(TTTextLeft1:GetText() == "Timber Wolf")
|
||||
assert(TTTextLeft2:GetText() == "Alpha")
|
||||
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft3:GetText() == "[LEVEL_CLASS_TYPE 10 Beast [ELITE]]", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft4:GetText() == "Skinnable")
|
||||
-- A RECOGNISED token naming nothing answers nil...
|
||||
assert(tt:SetUnit("party4") == nil, "a recognised but absent unit answers nil")
|
||||
@@ -94,6 +120,7 @@ fn unqueried_wolf() -> UnitState {
|
||||
#[test]
|
||||
fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_unit("target", Some(unqueried_wolf()));
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
@@ -111,7 +138,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
"the title is the GlobalString, got '" .. tostring(TTTextLeft1:GetText()) .. "'")
|
||||
-- The record's other lines are absent with it: no subtitle, and the level line takes the
|
||||
-- bare TOOLTIP_UNIT_LEVEL template because both the CLASS and TYPE slots are the record's.
|
||||
assert(TTTextLeft2:GetText() == "Level 10", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL 10]", "got " .. TTTextLeft2:GetText())
|
||||
-- The descriptor's own lines are NOT the record's and show anyway.
|
||||
assert(TTTextLeft3:GetText() == "Skinnable")
|
||||
"#,
|
||||
@@ -132,7 +159,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
assert(TT:SetUnit("target") == 1)
|
||||
assert(TTTextLeft1:GetText() == "Timber Wolf", "the answer replaced UNKNOWNOBJECT")
|
||||
assert(TTTextLeft2:GetText() == "Alpha")
|
||||
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft3:GetText() == "[LEVEL_CLASS_TYPE 10 Beast [ELITE]]", "got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft4:GetText() == "Skinnable")
|
||||
"#,
|
||||
)
|
||||
@@ -191,6 +218,7 @@ fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
|
||||
#[test]
|
||||
fn faction_line_and_civilian_gate() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 30,
|
||||
@@ -217,7 +245,7 @@ fn faction_line_and_civilian_gate() {
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 20", "friendly creature: no type word; got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL 20]", "friendly creature: no type word; got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "Stormwind", "faction line before PvP; got " .. TTTextLeft3:GetText())
|
||||
assert(TTTextLeft4:GetText() == "PvP")
|
||||
assert(TTTextLeft5 == nil or TTTextLeft5:GetText() == nil, "friendly civilian shows NO Civilian line")
|
||||
@@ -243,7 +271,7 @@ fn faction_line_and_civilian_gate() {
|
||||
r#"
|
||||
TT:SetOwner(UF9, "ANCHOR_RIGHT")
|
||||
TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 20 Humanoid", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS 20 Humanoid]", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "PvP")
|
||||
assert(TTTextLeft4:GetText() == "Civilian", "hostile+grey+pvp civilian warns; got " .. TTTextLeft4:GetText())
|
||||
assert(TTTextLeft5:GetText() == "Leader")
|
||||
@@ -280,6 +308,7 @@ fn faction_line_and_civilian_gate() {
|
||||
#[test]
|
||||
fn level_line_variants() {
|
||||
let mut s = script();
|
||||
seed_level_strings(&mut s);
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
level: 60,
|
||||
@@ -304,7 +333,7 @@ fn level_line_variants() {
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 32 Human Rogue (Player)", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE 32 Human Rogue [PLAYER]]", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft3:GetText() == "PvP")
|
||||
"#,
|
||||
)
|
||||
@@ -326,7 +355,7 @@ fn level_line_variants() {
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT")
|
||||
TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 3 Corpse", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS 3 [CORPSE]]", "got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -347,7 +376,7 @@ fn level_line_variants() {
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT")
|
||||
TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level ?? Demon (Boss)", "got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE ?? Demon [BOSS]]", "got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -367,7 +396,7 @@ fn level_line_variants() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level ?? Beast", "hostile 10-up, got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS ?? Beast]", "hostile 10-up, got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -376,7 +405,7 @@ fn level_line_variants() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 70 Beast", "unfriendly 10-up, got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS 70 Beast]", "unfriendly 10-up, got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -385,7 +414,7 @@ fn level_line_variants() {
|
||||
s.run(
|
||||
r#"
|
||||
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
|
||||
assert(TTTextLeft2:GetText() == "Level 70 Orc Shaman (Player)", "hostile player, got " .. TTTextLeft2:GetText())
|
||||
assert(TTTextLeft2:GetText() == "[LEVEL_CLASS_TYPE 70 Orc Shaman [PLAYER]]", "hostile player, got " .. TTTextLeft2:GetText())
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -462,40 +462,25 @@ pub(in crate::script) fn duration_text(
|
||||
Some(template.replacen("%d", &n.to_string(), 1))
|
||||
}
|
||||
|
||||
/// The `_P1` pick behind `GetText(token, gender, n)`. **The rule is the bare token for exactly one
|
||||
/// and the `_P1` twin otherwise** — so a zero count takes the plural, which is why the lapsing
|
||||
/// second reads "0 second*s* remaining".
|
||||
/// [`crate::strings::plural`] for this module's callers — the `_P1` pick behind
|
||||
/// `GetText(token, gender, n)`, whose byte law (and the refuted `> 1` reading: `703c89` is a
|
||||
/// *signed* test, so a zero ordinal takes the plural arm and a lapsing aura reads "0 seconds
|
||||
/// remaining") now lives with the primitive rather than in a fourth private copy of it. Four
|
||||
/// copies of one rule is the shape decision 2045 wrote about; `fill` had eight.
|
||||
///
|
||||
/// **Byte-pinned** (wow-re §3-BUFF-PLURAL, `aff4df61`), which upgrades what this house had read
|
||||
/// twice by inference — `FriendsFrame.xml`'s `guildPlural` and `TradeSkillFrame.xml`'s
|
||||
/// `pluralAbbr`. The exe resolves nothing itself: `0x52fa50` snprintf's the bare key, then
|
||||
/// `0x703bf0` looks up the **Lua global** `GetText` (`703c43: ba c8 2c 87 00`) and pcalls it with
|
||||
/// (key, ordinal, gender), using what comes back as the printf format. The predicate lives in
|
||||
/// `LocaleProperties.lua`'s `GetPluralIndex` — singular iff `not ordinal or ordinal == 1` — and
|
||||
/// `> 1` is REFUTED: `703c89: 7d 07` is a *signed* test, so a zero ordinal is pushed as the number
|
||||
/// `0` (Lua truthy) and takes the plural arm; only a negative becomes nil. Had it mapped zero to
|
||||
/// nil the lapsing line would read "0 second remaining".
|
||||
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0`
|
||||
/// on both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS
|
||||
/// folds nil to no suffix.
|
||||
///
|
||||
/// We collapse that to Rust rather than route through a `GetText` global because this line is
|
||||
/// computed engine-side off the live clock, and because `LocaleProperties.lua` is a file this house
|
||||
/// does not transcribe.
|
||||
///
|
||||
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0` on
|
||||
/// both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS folds
|
||||
/// nil to no suffix.
|
||||
///
|
||||
/// **Falls back to the bare token when `_P1` is absent** — also byte-pinned: a nil `genderTag`
|
||||
/// sends `GetText` down its short arm to `getglobal(token)`, the singular. (When *both* are absent
|
||||
/// the reference renders an EMPTY line, off its pre-seeded `0x882748`; we render no line at all.
|
||||
/// The divergence is unreachable with shipped data — all eight of this family's keys ship — and off
|
||||
/// an install there is no string table to be faithful to.)
|
||||
fn plural_template(token: &str, n: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
if n != 1 {
|
||||
if let Some(p1) = get(&format!("{token}_P1")).filter(|s| !s.is_empty()) {
|
||||
return Some(p1);
|
||||
}
|
||||
}
|
||||
get(token).filter(|s| !s.is_empty())
|
||||
/// (When BOTH the twin and the bare token are absent the reference renders an EMPTY line, off its
|
||||
/// pre-seeded `0x882748`; we render no line at all. Unreachable with shipped data — all eight of
|
||||
/// the aura family's keys ship — and off an install there is no string table to be faithful to.)
|
||||
pub(in crate::script) fn plural_template(
|
||||
token: &str,
|
||||
n: u32,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
crate::strings::plural(token, Some(n), get)
|
||||
}
|
||||
|
||||
/// A colour-component argument — `lua_tonumber`'s coercion (a numeric string counts, `""` and
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
//!
|
||||
//! **The line law is BYTE-VERIFIED** — the 0274 §5 verdict on `0x52b650`'s emission order
|
||||
//! (wow-re `ui/scratch/tooltip-content-law.md`, §5-cross-checked; folded back 2026-07-10):
|
||||
//! every family's order, gate, and color pointer is the binary's, with the enUS text from the
|
||||
//! extracted GlobalStrings. Not yet built (feeds pending, laws recorded): the instance
|
||||
//! families (soulbound override, enchants, made-by, live durability, cooldown-remaining).
|
||||
//! every family's order, gate, and color pointer is the binary's, and every sentence is a KEY
|
||||
//! resolved off the player's own `GlobalStrings.lua` at render time (decision 2045). Not yet
|
||||
//! built (feeds pending, laws recorded): the instance families (soulbound override, enchants,
|
||||
//! made-by, live durability, cooldown-remaining).
|
||||
//! Residual INTERIMs cited inline: the dual-wield/off-hand proficiency exception
|
||||
//! (`0x5eab70`), the type cell's override red, the set-owned count source.
|
||||
//!
|
||||
@@ -30,8 +31,9 @@
|
||||
//! The sell-price money row is the byte-verified law: only a REAL-INSTANCE source (`SetBagItem`)
|
||||
//! while the merchant window is open and repair mode is off — the engine computes
|
||||
//! `SellPrice × stack` and fires the `OnTooltipAddMoney` script; FrameXML renders the coins
|
||||
//! (`SetTooltipMoney`). A zero sell price in that context prints `ITEM_UNSELLABLE`. Template
|
||||
//! sources (`SetMerchantItem`/`SetItemById`/quest rows) never show money, per the same law.
|
||||
//! (`SetTooltipMoney`). A zero sell price in that context prints `ITEM_UNSELLABLE`
|
||||
//! (`0x854a74`, pushed at `0x52e4a3`). Template sources (`SetMerchantItem`/`SetItemById`/quest
|
||||
//! rows) never show money, per the same law.
|
||||
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
@@ -586,8 +588,12 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
None => {
|
||||
// Template in flight — the slot view's own name holds the plate (the same
|
||||
// 0138 posture as SetBagItem's miss path).
|
||||
// The compare header is a key like every other sentence (2045); this
|
||||
// fallback path shows it for exactly the reason the full render does.
|
||||
if compare {
|
||||
append_line(lua, &this, ("Currently Equipped".into(), GRAY), None, false)?;
|
||||
if let Some(t) = crate::strings::global(lua, "CURRENTLY_EQUIPPED") {
|
||||
append_line(lua, &this, (t, GRAY), None, false)?;
|
||||
}
|
||||
}
|
||||
if let Some(name) = name {
|
||||
let color = if compare {
|
||||
@@ -693,8 +699,8 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
if merchant_open && !repairing {
|
||||
if v.sell_price > 0 {
|
||||
fire_add_money(lua, h, u64::from(v.sell_price) * u64::from(count));
|
||||
} else {
|
||||
append_line(lua, &this, ("No sell price".into(), WHITE), None, false)?;
|
||||
} else if let Some(t) = crate::strings::global(lua, "ITEM_UNSELLABLE") {
|
||||
append_line(lua, &this, (t, WHITE), None, false)?;
|
||||
}
|
||||
}
|
||||
arm_compare(lua, h, &v);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
//! The item tooltip's display vocabulary — the extracted enUS name tables (`INVTYPE_*`,
|
||||
//! subclass, damage school, `ITEM_MOD_*`, class/race lists) and the byte-verified color
|
||||
//! constants the render law paints with. Pure data; the law itself is [`super::render`].
|
||||
//! The item tooltip's display vocabulary — the builder's own **key** tables (`INVTYPE_*`,
|
||||
//! `ITEM_MOD_*`, `SPELL_SCHOOL%d_CAP`), the DBC-sourced subclass/class/race names, and the
|
||||
//! byte-verified color constants the render law paints with. Pure data; the law itself is
|
||||
//! [`super::render`], which resolves every key here against the player's own `GlobalStrings.lua`
|
||||
//! (decision 2045).
|
||||
//!
|
||||
//! **Keys, not sentences, and the difference is not cosmetic.** `INVTYPE_SHIELD` and
|
||||
//! `INVTYPE_WEAPONOFFHAND` both read "Off Hand" in enUS and are separately localizable
|
||||
//! everywhere else; a table that stored the English could not tell them apart, and this one had
|
||||
//! them collapsed into a single arm until 2045's sweep.
|
||||
//!
|
||||
//! **What stays a Rust literal here, and why it is not the same thing**: [`subclass_name`],
|
||||
//! [`CLASS_NAMES`] and [`RACE_NAMES`] name rows the reference reads out of DBC records (the
|
||||
//! `{class,subclass}` table `0xc0db90`, ChrClasses, ChrRaces), never out of `GlobalStrings.lua`.
|
||||
//! Those belong to the DBC-feed question, not to this one.
|
||||
|
||||
/// The client's 7-entry quality→color table (wow-re RF-0055, VERIFIED at `0xc0d3c8` behind
|
||||
/// `GetItemQualityColor 0x48dfb0`): Poor gray, Common white, Uncommon green, Rare blue, Epic
|
||||
@@ -30,33 +42,49 @@ pub(super) const GRAY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0,
|
||||
/// The owned set member's pale cream — byte-read `0xc0d368 = ffffff97` (writer `0x529050`).
|
||||
pub(super) const CREAM: [f32; 4] = [1.0, 1.0, 151.0 / 255.0, 1.0];
|
||||
|
||||
/// InventoryType → the slot line (the client's `INVTYPE_*` GlobalStrings, enUS). 0 (non-equip),
|
||||
/// 18 (bag), 27 (quiver) draw no slot line.
|
||||
pub(super) fn invtype_name(t: u32) -> Option<&'static str> {
|
||||
/// InventoryType → the slot line's GlobalString **key** — the builder's own 30-entry pointer
|
||||
/// table at `0x83ddb0`, indexed by `[record+0x2c]` directly (`0x52c103: mov ecx,[ecx*4+0x83ddb0]`,
|
||||
/// the read the law's §10 names), dumped entry by entry rather than matched by English.
|
||||
///
|
||||
/// **Index 0 and index 29 are the pre-seeded empty string `0x882748`** — a non-equip item and an
|
||||
/// out-of-range type name nothing, which is the `None` here. 18 (bag) and 27 (quiver) *do* have
|
||||
/// entries (`INVTYPE_BAG`/`INVTYPE_QUIVER`) but are unreachable: both are containers, and a
|
||||
/// container takes the CONTAINER_SLOTS line above this call instead.
|
||||
///
|
||||
/// **Four of these keys ship no value, and that is the reference's behaviour, not a gap.**
|
||||
/// `INVTYPE_AMMO` (24), `INVTYPE_THROWN` (25), `INVTYPE_RANGEDRIGHT` (26) and `INVTYPE_QUIVER`
|
||||
/// (27) are in the exe's table but absent from `GlobalStrings.lua`, so `FrameScript_GetText`
|
||||
/// hands the builder an empty left cell and an arrow shows only its type word. We had written
|
||||
/// "Projectile", "Thrown" and "Ranged" into those arms — three sentences 1.12 never shows, and
|
||||
/// exactly the invention class decision 2045 says a text-matching tripwire cannot catch.
|
||||
pub(super) fn invtype_key(t: u32) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
1 => "Head",
|
||||
2 => "Neck",
|
||||
3 => "Shoulder",
|
||||
4 => "Shirt",
|
||||
5 | 20 => "Chest",
|
||||
6 => "Waist",
|
||||
7 => "Legs",
|
||||
8 => "Feet",
|
||||
9 => "Wrist",
|
||||
10 => "Hands",
|
||||
11 => "Finger",
|
||||
12 => "Trinket",
|
||||
13 => "One-Hand",
|
||||
14 | 22 => "Off Hand",
|
||||
15 | 26 => "Ranged",
|
||||
16 => "Back",
|
||||
17 => "Two-Hand",
|
||||
19 => "Tabard",
|
||||
21 => "Main Hand",
|
||||
23 => "Held In Off-hand",
|
||||
24 => "Projectile",
|
||||
25 => "Thrown",
|
||||
28 => "Relic",
|
||||
1 => "INVTYPE_HEAD",
|
||||
2 => "INVTYPE_NECK",
|
||||
3 => "INVTYPE_SHOULDER",
|
||||
4 => "INVTYPE_BODY",
|
||||
5 => "INVTYPE_CHEST",
|
||||
6 => "INVTYPE_WAIST",
|
||||
7 => "INVTYPE_LEGS",
|
||||
8 => "INVTYPE_FEET",
|
||||
9 => "INVTYPE_WRIST",
|
||||
10 => "INVTYPE_HAND",
|
||||
11 => "INVTYPE_FINGER",
|
||||
12 => "INVTYPE_TRINKET",
|
||||
13 => "INVTYPE_WEAPON",
|
||||
14 => "INVTYPE_SHIELD",
|
||||
15 => "INVTYPE_RANGED",
|
||||
16 => "INVTYPE_CLOAK",
|
||||
17 => "INVTYPE_2HWEAPON",
|
||||
19 => "INVTYPE_TABARD",
|
||||
20 => "INVTYPE_ROBE",
|
||||
21 => "INVTYPE_WEAPONMAINHAND",
|
||||
22 => "INVTYPE_WEAPONOFFHAND",
|
||||
23 => "INVTYPE_HOLDABLE",
|
||||
24 => "INVTYPE_AMMO",
|
||||
25 => "INVTYPE_THROWN",
|
||||
26 => "INVTYPE_RANGEDRIGHT",
|
||||
28 => "INVTYPE_RELIC",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -90,29 +118,31 @@ pub(super) fn subclass_name(class: u32, sub: u32) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Damage school suffix for a non-physical damage line ("5 - 9 Fire Damage").
|
||||
pub(super) fn school_name(s: u32) -> Option<&'static str> {
|
||||
Some(match s {
|
||||
1 => "Holy",
|
||||
2 => "Fire",
|
||||
3 => "Nature",
|
||||
4 => "Frost",
|
||||
5 => "Shadow",
|
||||
6 => "Arcane",
|
||||
_ => return None,
|
||||
})
|
||||
/// A damage/resistance school's name key — `SPELL_SCHOOL%d_CAP`, the one string the builder
|
||||
/// composes at runtime rather than naming outright (`0x84e4cc`, pushed at `0x52c2a8` for the
|
||||
/// damage line and `0x52c8d1` for the resistance line; law §11/§16).
|
||||
///
|
||||
/// The index is the school itself: `SPELL_SCHOOL0_CAP` is "Physical" and 1..6 are
|
||||
/// Holy/Fire/Nature/Frost/Shadow/Arcane. **School 0 answers `None`** because the reference
|
||||
/// doesn't name it either — a physical weapon takes the school-less `DAMAGE_TEMPLATE` arm rather
|
||||
/// than printing the word.
|
||||
pub(super) fn school_key(s: u32) -> Option<String> {
|
||||
(1..=6).contains(&s).then(|| format!("SPELL_SCHOOL{s}_CAP"))
|
||||
}
|
||||
|
||||
/// Stat-mod type → its `ITEM_MOD_*` display name (vanilla types 0..7; 2 is unused).
|
||||
pub(super) fn stat_name(t: u32) -> Option<&'static str> {
|
||||
/// Stat-mod type → its `ITEM_MOD_*` key. Not a *name*: the key resolves to the whole line
|
||||
/// template, sign hole and all (`ITEM_MOD_AGILITY = "%c%d Agility"`), which is why 2 has no arm —
|
||||
/// the builder's 8-way jump table `0x52e510` skips it (law §15, keys byte-read at
|
||||
/// `0x52c6eb..0x52c777`).
|
||||
pub(super) fn stat_key(t: u32) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
0 => "Mana",
|
||||
1 => "Health",
|
||||
3 => "Agility",
|
||||
4 => "Strength",
|
||||
5 => "Intellect",
|
||||
6 => "Spirit",
|
||||
7 => "Stamina",
|
||||
0 => "ITEM_MOD_MANA",
|
||||
1 => "ITEM_MOD_HEALTH",
|
||||
3 => "ITEM_MOD_AGILITY",
|
||||
4 => "ITEM_MOD_STRENGTH",
|
||||
5 => "ITEM_MOD_INTELLECT",
|
||||
6 => "ITEM_MOD_SPIRIT",
|
||||
7 => "ITEM_MOD_STAMINA",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
//! The item tooltip's line law — [`render_view`], the byte-verified emission order of the
|
||||
//! client's shared renderer `0x52b650` (see the parent module doc for the law's provenance and
|
||||
//! the compare/red/SET summaries).
|
||||
//!
|
||||
//! **Every sentence here is a key, never a Rust string** (decision 2045). The builder names its
|
||||
//! keys outright — the census of the string pointers reachable from `[0x52b650, 0x52e600)` is
|
||||
//! where each one below came from, so a line's key is the *reference's* choice and not a match on
|
||||
//! English. That distinction is load-bearing: `ITEM_REQ_SKILL`, `LOCKED_WITH_ITEM`,
|
||||
//! `LOCKED_WITH_SPELL` and `LOCKED_WITH_SPELL_KNOWN` are all "Requires %s" in enUS and four
|
||||
//! different strings anywhere else.
|
||||
//!
|
||||
//! A key the player's `GlobalStrings.lua` does not carry emits **no line at all** — never an
|
||||
//! invented one. That is `duration_text`'s disposition and the reference's own: its
|
||||
//! `FrameScript_GetText` hands back the pre-seeded empty string and `AddLine` drops an empty row
|
||||
//! before it ever counts it.
|
||||
|
||||
use mlua::{Lua, Table};
|
||||
|
||||
use crate::script::tooltip::append_line;
|
||||
use crate::script::tooltip::{append_line, duration_text, plural_template};
|
||||
use crate::script::{ItemTemplateView, Model};
|
||||
use crate::strings::{fill, Arg};
|
||||
|
||||
use super::names::*;
|
||||
|
||||
@@ -150,11 +163,23 @@ pub(super) fn render_view(
|
||||
let addw = |l: (String, [f32; 4])| append_line(lua, this, l, None, true);
|
||||
let add2 =
|
||||
|l: (String, [f32; 4]), r: (String, [f32; 4])| append_line(lua, this, l, Some(r), false);
|
||||
// The VM's own `GlobalStrings.lua`, off the player's patch chain — the one source of every
|
||||
// sentence below.
|
||||
let get = |key: &str| crate::strings::global(lua, key);
|
||||
// One line, named by KEY and filled from the template the install carries. A key the table
|
||||
// does not hold emits nothing: the reference's empty `FrameScript_GetText` return reaches
|
||||
// `AddLine`'s `0x530270`, which bails before it increments the line count.
|
||||
let keyed = |key: &str, args: &[Arg<'_>], color: [f32; 4], wrap: bool| -> mlua::Result<()> {
|
||||
match get(key) {
|
||||
Some(t) => append_line(lua, this, (fill(&t, args), color), None, wrap),
|
||||
None => Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
// Compare mode (the shopping tooltips): the gray CURRENTLY_EQUIPPED header (`[arg+0x18]≠0`)
|
||||
// and a WHITE name instead of the quality color (`[arg+0x14]≠0`) — both byte-verified.
|
||||
if compare {
|
||||
add(("Currently Equipped".into(), GRAY))?;
|
||||
keyed("CURRENTLY_EQUIPPED", &[], GRAY, false)?;
|
||||
}
|
||||
let name_color = if compare {
|
||||
WHITE
|
||||
@@ -175,25 +200,25 @@ pub(super) fn render_view(
|
||||
// rule, and the reference's own (its resolve callback repaints the tooltip; ours is the
|
||||
// container re-enter loop).
|
||||
if let Some(p) = inst.and_then(|i| i.petition.as_ref()) {
|
||||
let (title_fmt, creator_fmt) = if p.is_charter {
|
||||
("Guild Name: %s", "Guild Master: %s")
|
||||
let (title_key, creator_key) = if p.is_charter {
|
||||
("GUILD_CHARTER_TITLE", "GUILD_CHARTER_CREATOR")
|
||||
} else {
|
||||
("Petition: %s", "Created by %s")
|
||||
("PETITION_TITLE", "PETITION_CREATOR")
|
||||
};
|
||||
if !p.title.is_empty() {
|
||||
add((title_fmt.replacen("%s", &p.title, 1), WHITE))?;
|
||||
keyed(title_key, &[Arg::S(&p.title)], WHITE, false)?;
|
||||
}
|
||||
if let Some(owner) = p.owner.as_deref().filter(|o| !o.is_empty()) {
|
||||
add((creator_fmt.replacen("%s", owner, 1), WHITE))?;
|
||||
keyed(creator_key, &[Arg::S(owner)], WHITE, false)?;
|
||||
}
|
||||
}
|
||||
// ITEM_SIGNABLE (green) — Flags bit 0x2000 (petitions).
|
||||
if v.flags & 0x2000 != 0 {
|
||||
add(("<Right Click for Details>".into(), GREEN))?;
|
||||
keyed("ITEM_SIGNABLE", &[], GREEN, false)?;
|
||||
}
|
||||
// "Conjured Item" (Flags bit 0x2).
|
||||
// ITEM_CONJURED (Flags bit 0x2).
|
||||
if v.flags & 0x2 != 0 {
|
||||
add(("Conjured Item".into(), WHITE))?;
|
||||
keyed("ITEM_CONJURED", &[], WHITE, false)?;
|
||||
}
|
||||
// The bind line (§6, white, one line). Bonding `[record+0x194]` ∈ {1..5} is what decides
|
||||
// whether a line prints at ALL — a Bonding-0 item says nothing here however it is held.
|
||||
@@ -206,26 +231,28 @@ pub(super) fn render_view(
|
||||
// forever (B310, Frostshake): the template's `bonding` never changes when the item binds —
|
||||
// the instance's flag is the only thing that does.
|
||||
match v.bonding {
|
||||
4 | 5 => add(("Quest Item".into(), WHITE))?,
|
||||
1..=3 if inst.is_some_and(|i| i.already_bound) => add(("Soulbound".into(), WHITE))?,
|
||||
1 => add(("Binds when picked up".into(), WHITE))?,
|
||||
2 => add(("Binds when equipped".into(), WHITE))?,
|
||||
3 => add(("Binds when used".into(), WHITE))?,
|
||||
4 | 5 => keyed("ITEM_BIND_QUEST", &[], WHITE, false)?,
|
||||
1..=3 if inst.is_some_and(|i| i.already_bound) => {
|
||||
keyed("ITEM_SOULBOUND", &[], WHITE, false)?
|
||||
}
|
||||
1 => keyed("ITEM_BIND_ON_PICKUP", &[], WHITE, false)?,
|
||||
2 => keyed("ITEM_BIND_ON_EQUIP", &[], WHITE, false)?,
|
||||
3 => keyed("ITEM_BIND_ON_USE", &[], WHITE, false)?,
|
||||
_ => {}
|
||||
}
|
||||
match v.max_count {
|
||||
1 => add(("Unique".into(), WHITE))?,
|
||||
n if n > 1 => add((format!("Unique ({n})"), WHITE))?,
|
||||
1 => keyed("ITEM_UNIQUE", &[], WHITE, false)?,
|
||||
n if n > 1 => keyed("ITEM_UNIQUE_MULTIPLE", &[Arg::D(n.into())], WHITE, false)?,
|
||||
_ => {}
|
||||
}
|
||||
if v.start_quest != 0 {
|
||||
add(("This Item Begins a Quest".into(), WHITE))?;
|
||||
keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?;
|
||||
}
|
||||
// LOCKED (red) — suppressed once the INSTANCE carries UNLOCKED `0x4` (the law's "and the
|
||||
// item is not already unlocked"; the same bit the openable sub-gate reads). The key-item
|
||||
// "Requires %s" sub-line joins with the Lock.dbc resolve (the GO-locks follow-up).
|
||||
if v.lock_id != 0 && inst.is_none_or(|i| i.flags & 0x4 == 0) {
|
||||
add(("Locked".into(), RED))?;
|
||||
keyed("LOCKED", &[], RED, false)?;
|
||||
}
|
||||
// Slot | type — or, for a bag, the single CONTAINER_SLOTS line in the same seat. The type
|
||||
// cell is suppressed for cloaks (InventoryType 16) and displayFlags-hidden subclasses
|
||||
@@ -241,7 +268,10 @@ pub(super) fn render_view(
|
||||
if v.container_slots > 0 {
|
||||
add((format!("{} Slot Bag", v.container_slots), WHITE))?;
|
||||
} else {
|
||||
let slot = invtype_name(v.inventory_type);
|
||||
// The slot cell is the `0x83ddb0` key table's entry for this InventoryType, resolved here
|
||||
// — so the four types whose keys the string table does not carry (ammo, thrown,
|
||||
// ranged-right, quiver) draw no slot cell, exactly as the reference does.
|
||||
let slot = invtype_key(v.inventory_type).and_then(&get);
|
||||
let ty = if v.inventory_type == 16 || v.hide_subclass {
|
||||
None
|
||||
} else {
|
||||
@@ -264,11 +294,10 @@ pub(super) fn render_view(
|
||||
left_red = true;
|
||||
}
|
||||
match (slot, ty) {
|
||||
(Some(s), Some(t)) => add2(
|
||||
(s.into(), req_color(!left_red)),
|
||||
(t.into(), req_color(!right_red)),
|
||||
)?,
|
||||
(Some(s), None) => add((s.into(), req_color(!left_red)))?,
|
||||
(Some(s), Some(t)) => {
|
||||
add2((s, req_color(!left_red)), (t.into(), req_color(!right_red)))?
|
||||
}
|
||||
(Some(s), None) => add((s, req_color(!left_red)))?,
|
||||
// No slot name: the type stands alone and takes the hard-miss color (the
|
||||
// builder's single-cell fallback keeps flag-1).
|
||||
(None, Some(t)) => add((t.into(), req_color(!right_red)))?,
|
||||
@@ -282,7 +311,7 @@ pub(super) fn render_view(
|
||||
(min + 0.5).floor() as i64,
|
||||
(max + 0.5).floor() as i64
|
||||
);
|
||||
if let Some(s) = school_name(sch) {
|
||||
if let Some(s) = school_key(sch).and_then(|k| get(&k)) {
|
||||
dmg = format!("{dmg} {s}");
|
||||
}
|
||||
dmg.push_str(" Damage");
|
||||
@@ -293,7 +322,7 @@ pub(super) fn render_view(
|
||||
add((dmg, WHITE))?;
|
||||
}
|
||||
for &(emin, emax, esch) in v.damages.iter().skip(1).filter(|d| d.1 > 0.0) {
|
||||
let s = school_name(esch).unwrap_or("");
|
||||
let s = school_key(esch).and_then(|k| get(&k)).unwrap_or_default();
|
||||
let sep = if s.is_empty() { "" } else { " " };
|
||||
add((
|
||||
format!(
|
||||
@@ -312,17 +341,27 @@ pub(super) fn render_view(
|
||||
.filter(|d| d.1 > 0.0)
|
||||
.map(|&(a, b, _)| (a + b) * 0.5)
|
||||
.sum();
|
||||
add((
|
||||
format!("({:.1} damage per second)", f64::from(total) / speed),
|
||||
// The precision is DPS_TEMPLATE's own `%.1f`, not ours — a locale that respells it
|
||||
// gets its own number of decimals with no code change (law §12: "the print precision
|
||||
// is FRAMEXML-DATA").
|
||||
keyed(
|
||||
"DPS_TEMPLATE",
|
||||
&[Arg::F(f64::from(total) / speed)],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if v.armor > 0 {
|
||||
add((format!("{} Armor", v.armor), WHITE))?;
|
||||
keyed("ARMOR_TEMPLATE", &[Arg::D(v.armor.into())], WHITE, false)?;
|
||||
}
|
||||
if v.block > 0 {
|
||||
add((format!("{} Block", v.block), WHITE))?;
|
||||
keyed(
|
||||
"SHIELD_BLOCK_TEMPLATE",
|
||||
&[Arg::D(v.block.into())],
|
||||
WHITE,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// Stat mods (+N Stamina …) in the client's DISPLAY order — the `0x808e88` table (byte-read:
|
||||
// 4,3,7,5,6,1,0 then 8,9,2,10 + zero padding; the builder's outer loop walks the table,
|
||||
@@ -337,31 +376,41 @@ pub(super) fn render_view(
|
||||
if t != want || val == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = stat_name(t) else { continue };
|
||||
let sign = if val > 0 { '+' } else { '-' };
|
||||
add((format!("{sign}{} {name}", val.abs()), WHITE))?;
|
||||
// The `ITEM_MOD_*` template is the whole line, sign hole included
|
||||
// (`"%c%d Agility"`) — the sign is an argument, not a prefix we glue on.
|
||||
let Some(key) = stat_key(t) else { continue };
|
||||
let sign = if val > 0 { "+" } else { "-" };
|
||||
keyed(key, &[Arg::S(sign), Arg::D(val.abs().into())], WHITE, false)?;
|
||||
}
|
||||
}
|
||||
// Resistances: six equal nonzero values collapse to the ALL line; otherwise one line per
|
||||
// nonzero school with HOLY excluded from the singles loop (both byte-verified).
|
||||
const RESIST_NAMES: [&str; 6] = ["Holy", "Fire", "Nature", "Frost", "Shadow", "Arcane"];
|
||||
// nonzero school with HOLY excluded from the singles loop (both byte-verified). The six
|
||||
// fields are schools 1..6, so slot `i` names `SPELL_SCHOOL{i+1}_CAP` — the same `%d`-composed
|
||||
// key the damage line uses one block up.
|
||||
let first_res = v.resistances[0];
|
||||
if first_res != 0 && v.resistances.iter().all(|&r| r == first_res) {
|
||||
let sign = if first_res > 0 { '+' } else { '-' };
|
||||
add((
|
||||
format!("{sign}{} to All Resistances", first_res.abs()),
|
||||
let sign = if first_res > 0 { "+" } else { "-" };
|
||||
keyed(
|
||||
"ITEM_RESIST_ALL",
|
||||
&[Arg::S(sign), Arg::D(first_res.abs().into())],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
} else {
|
||||
for (i, &r) in v.resistances.iter().enumerate() {
|
||||
if r == 0 || i == 0 {
|
||||
continue; // Holy (i == 0) never prints singly in 1.12
|
||||
}
|
||||
let sign = if r > 0 { '+' } else { '-' };
|
||||
add((
|
||||
format!("{sign}{} {} Resistance", r.abs(), RESIST_NAMES[i]),
|
||||
let sign = if r > 0 { "+" } else { "-" };
|
||||
let school = school_key(i as u32 + 1)
|
||||
.and_then(|k| get(&k))
|
||||
.unwrap_or_default();
|
||||
keyed(
|
||||
"ITEM_RESIST_SINGLE",
|
||||
&[Arg::S(sign), Arg::D(r.abs().into()), Arg::S(&school)],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// **Line 17 — the enchant family** (wow-re `tooltip-content-law.md` §1-ENCHANT, byte-carved
|
||||
@@ -401,7 +450,7 @@ pub(super) fn render_view(
|
||||
// where the reference shows the rolled lines).
|
||||
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
|
||||
if no_id_source && !signable && v.random_property != 0 {
|
||||
add(("<Random enchantment>".into(), GREEN))?;
|
||||
keyed("ITEM_RANDOM_ENCHANT", &[], GREEN, false)?;
|
||||
}
|
||||
for e in enchant_slots {
|
||||
let color = match (e.slot < 2, e.negative) {
|
||||
@@ -414,14 +463,23 @@ pub(super) fn render_view(
|
||||
// ceil-vs-truncate split are [`enchant_time_left`]'s; its source is
|
||||
// `SMSG_ITEM_ENCHANT_TIME_UPDATE`, never the item's own duration field.
|
||||
let mut text = match e.remaining_ms {
|
||||
Some(ms) => enchant_time_left(&e.name, ms),
|
||||
// The countdown IS the line — `ITEM_ENCHANT_TIME_LEFT_MIN = "%s (%d min)"` carries
|
||||
// the enchant's own name in its first hole. Without that template there is no line
|
||||
// to compose: the reference printf's an empty format into a zeroed buffer and
|
||||
// `AddLine` drops the empty row, so a missing key skips the slot rather than falling
|
||||
// back to the bare name.
|
||||
Some(ms) => match enchant_time_left(&e.name, ms, &get) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
},
|
||||
None => e.name.clone(),
|
||||
};
|
||||
// " (N Charges)" — the slot's own charges dword through ITEM_SPELL_CHARGES, then the
|
||||
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`). Only an owned item object carries
|
||||
// charges; the session/inspect legs ship ids alone, so this is naturally absent there.
|
||||
if e.charges != 0 {
|
||||
text.push_str(&format!(" ({})", charges_phrase(e.charges)));
|
||||
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`), which is the engine's own format and
|
||||
// not a string table entry. Only an owned item object carries charges; the session/inspect
|
||||
// legs ship ids alone, so this is naturally absent there.
|
||||
if let Some(charges) = charges_phrase(e.charges, &get) {
|
||||
text.push_str(&format!(" ({charges})"));
|
||||
}
|
||||
add((text, color))?;
|
||||
}
|
||||
@@ -430,12 +488,20 @@ pub(super) fn render_view(
|
||||
// "durability (red iff broken==0)", the AddLine colour pointer `0xc0d390`, the same
|
||||
// red as the unmet-requirement lines).
|
||||
let color = if cur == 0 { RED } else { WHITE };
|
||||
add((format!("Durability {cur} / {max}"), color))?;
|
||||
keyed(
|
||||
"DURABILITY_TEMPLATE",
|
||||
&[Arg::D(cur.into()), Arg::D(max.into())],
|
||||
color,
|
||||
false,
|
||||
)?;
|
||||
} else if v.max_durability > 0 {
|
||||
add((
|
||||
format!("Durability {} / {}", v.max_durability, v.max_durability),
|
||||
let full = i64::from(v.max_durability);
|
||||
keyed(
|
||||
"DURABILITY_TEMPLATE",
|
||||
&[Arg::D(full), Arg::D(full)],
|
||||
WHITE,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// **ITEM_DURATION `0x854bb4` — the item's own expiry countdown** (`0x52ce0d`, one of the four
|
||||
// sites that set the builder's `[ebp-0x38]` return, wow-re `tooltip-content-law.md` §E3's
|
||||
@@ -444,9 +510,20 @@ pub(super) fn render_view(
|
||||
// 23) — the numbered law list omits the line entirely, which is the gap decision 1933 names.
|
||||
// Formatted through the same `0x52fa50` ladder as the enchant countdown, with key prefix
|
||||
// `ITEM_DURATION`; unlike its siblings that prefix ships **no `_P1` plural twin**, so every
|
||||
// count reads "days"/"hrs" (`GlobalStrings.lua:2401-2404`).
|
||||
// count reads "days"/"hrs" (`GlobalStrings.lua:2401-2404`) — which `plural_template`'s
|
||||
// fall-back-to-the-bare-token arm produces without a special case.
|
||||
//
|
||||
// **This is `tooltip::duration_text` itself**, not a second copy of the ladder: the two had
|
||||
// drifted into separate implementations of the same four arms, and the local one always
|
||||
// emitted a line (an invented sentence off an install) where the shared one returns `None`.
|
||||
// The one seam is the width — the deadline is parked as `u64` milliseconds and the reference's
|
||||
// formatter takes a dword, so a timer past 49.7 days saturates rather than wrapping. No 1.12
|
||||
// item carries one; the longest shipped duration is a fortnight.
|
||||
if let Some(ms) = inst.and_then(|i| i.duration_ms) {
|
||||
add((duration_left(ms), WHITE))?;
|
||||
let ms = u32::try_from(ms).unwrap_or(u32::MAX);
|
||||
if let Some(line) = duration_text(ms, "ITEM_DURATION", true, &get) {
|
||||
add((line, WHITE))?;
|
||||
}
|
||||
}
|
||||
// Class/race lists — red when the player's own bit is absent (the usable ask).
|
||||
if v.allowable_class > 0
|
||||
@@ -459,7 +536,12 @@ pub(super) fn render_view(
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
let ok = req.class_id > 0 && v.allowable_class & (1 << (req.class_id - 1)) != 0;
|
||||
add((format!("Classes: {}", list.join(", ")), req_color(ok)))?;
|
||||
keyed(
|
||||
"ITEM_CLASSES_ALLOWED",
|
||||
&[Arg::S(&list.join(", "))],
|
||||
req_color(ok),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if v.allowable_race > 0 && (v.allowable_race & full_mask(&RACE_NAMES)) != full_mask(&RACE_NAMES)
|
||||
@@ -471,30 +553,48 @@ pub(super) fn render_view(
|
||||
.collect();
|
||||
if !list.is_empty() {
|
||||
let ok = req.race_id > 0 && v.allowable_race & (1 << (req.race_id - 1)) != 0;
|
||||
add((format!("Races: {}", list.join(", ")), req_color(ok)))?;
|
||||
keyed(
|
||||
"ITEM_RACES_ALLOWED",
|
||||
&[Arg::S(&list.join(", "))],
|
||||
req_color(ok),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// ITEM_MIN_LEVEL prints only for RequiredLevel > 1 (byte-VERIFIED `0x52d2cf`: `cmp esi,0x1 /
|
||||
// jle skip`) — a level-1 requirement gates nothing a logged-in player could fail, so the
|
||||
// real client hides it on all the level-1 consumables that carry one.
|
||||
if v.required_level > 1 {
|
||||
add((
|
||||
format!("Requires Level {}", v.required_level),
|
||||
keyed(
|
||||
"ITEM_MIN_LEVEL",
|
||||
&[Arg::D(v.required_level.into())],
|
||||
req_color(req.level >= v.required_level),
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
// The skill requirement is TWO keys, picked by whether a rank is asked for — `ITEM_MIN_SKILL`
|
||||
// = "Requires %s (%d)" and `ITEM_REQ_SKILL` = "Requires %s" (the builder's own pair at
|
||||
// `0x52d34e`/`0x52d355`). `ITEM_REQ_SKILL` is also what the required-SPELL line below takes
|
||||
// (`0x52d650`): three other 1.12 keys read "Requires %s" in enUS — the `LOCKED_WITH_*` trio —
|
||||
// and none of them is this line.
|
||||
if let Some(skill) = &v.required_skill_name {
|
||||
let have = req.skills.get(&v.required_skill).copied().unwrap_or(0);
|
||||
let line = if v.required_skill_rank > 0 {
|
||||
format!("Requires {skill} ({})", v.required_skill_rank)
|
||||
let color = req_color(have >= v.required_skill_rank.max(1));
|
||||
if v.required_skill_rank > 0 {
|
||||
let args = [Arg::S(skill), Arg::D(v.required_skill_rank.into())];
|
||||
keyed("ITEM_MIN_SKILL", &args, color, false)?;
|
||||
} else {
|
||||
format!("Requires {skill}")
|
||||
};
|
||||
add((line, req_color(have >= v.required_skill_rank.max(1))))?;
|
||||
keyed("ITEM_REQ_SKILL", &[Arg::S(skill)], color, false)?;
|
||||
}
|
||||
}
|
||||
if v.required_spell != 0 {
|
||||
if let Some(name) = &v.required_spell_name {
|
||||
add((format!("Requires {name}"), req_color(known_spell)))?;
|
||||
keyed(
|
||||
"ITEM_REQ_SKILL",
|
||||
&[Arg::S(name)],
|
||||
req_color(known_spell),
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if let Some(rep) = &v.required_rep_line {
|
||||
@@ -513,31 +613,40 @@ pub(super) fn render_view(
|
||||
}
|
||||
// ITEM_SPELL_KNOWN — a taught spell the player already knows, UNCONDITIONALLY red.
|
||||
if taught_known {
|
||||
add(("Already known".into(), RED))?;
|
||||
keyed("ITEM_SPELL_KNOWN", &[], RED, false)?;
|
||||
}
|
||||
// Green trigger lines (Use:/Equip:/Chance on hit:), wrapped — the text is app-resolved (the
|
||||
// spell's name in P1; its $-substituted description via the token engine in P2).
|
||||
// Green trigger lines, wrapped — the text is app-resolved (the spell's name in P1; its
|
||||
// $-substituted description via the token engine in P2). The prefix is a key, joined to the
|
||||
// text by the engine's own `"%s %s"` literal `0x82ef08` (`0x52da7e`) — which is why the
|
||||
// shipped values ("Use:", "Equip:", "Chance on hit:") carry no trailing space of their own.
|
||||
for &(trigger, _, ref text) in &v.spell_triggers {
|
||||
let prefix = match trigger {
|
||||
0 | 5 => "Use: ",
|
||||
1 => "Equip: ",
|
||||
2 => "Chance on hit: ",
|
||||
let key = match trigger {
|
||||
0 | 5 => "ITEM_SPELL_TRIGGER_ONUSE",
|
||||
1 => "ITEM_SPELL_TRIGGER_ONEQUIP",
|
||||
2 => "ITEM_SPELL_TRIGGER_ONPROC",
|
||||
_ => continue,
|
||||
};
|
||||
addw((format!("{prefix}{text}"), GREEN))?;
|
||||
let Some(prefix) = get(key) else { continue };
|
||||
addw((format!("{prefix} {text}"), GREEN))?;
|
||||
}
|
||||
if v.charges > 0 {
|
||||
add((charges_phrase(v.charges as u32), WHITE))?;
|
||||
if let Some(charges) = charges_phrase(v.charges.max(0) as u32, &get) {
|
||||
add((charges, WHITE))?;
|
||||
}
|
||||
// The item-SET block (§22, ABOVE the compact cut), byte-read at the builder's
|
||||
// `0x52d8a0..0x52e0f5`: a blank gold line ([`SET_SPACER`]), the gold "name (owned/total)"
|
||||
// header, the set-level skill line (white, red when short), the member ladder (" name" —
|
||||
// pale-cream `0xc0d368` when equipped, gray otherwise; a member whose template is still in
|
||||
// flight waits — the app re-pushes the view as names land), a second blank, then the threshold
|
||||
// bonuses "(N) Set: text" sorted THRESHOLD-ASCENDING (the builder qsorts the slot indices
|
||||
// via `0x52e5c0` — never the DBC's stored order), green only when the skill requirement
|
||||
// is met (`0x5eaae0`) AND owned ≥ threshold. "Owned" counts EQUIPPED members on both
|
||||
// builder paths (the owner-unit 19-slot scan / the hyperlink walk with section mask 0x1).
|
||||
// bonuses sorted THRESHOLD-ASCENDING (the builder qsorts the slot indices via `0x52e5c0` —
|
||||
// never the DBC's stored order), green only when the skill requirement is met (`0x5eaae0`)
|
||||
// AND owned ≥ threshold. "Owned" counts EQUIPPED members on both builder paths (the
|
||||
// owner-unit 19-slot scan / the hyperlink walk with section mask 0x1).
|
||||
//
|
||||
// **The two bonus arms are two different KEYS, and this is where reading the byte law paid**
|
||||
// (`0x52e056..0x52e0d6`): the met arm formats `ITEM_SET_BONUS` = "Set: %s" with the text
|
||||
// alone, and only the unmet arm formats `ITEM_SET_BONUS_GRAY` = "(%d) Set: %s" with the
|
||||
// count. We had printed the counted form in both colours, so an ACTIVE set bonus read
|
||||
// "(3) Set: …" where 1.12 reads "Set: …".
|
||||
if let Some(set) = &set_view {
|
||||
let owned = set
|
||||
.members
|
||||
@@ -545,22 +654,31 @@ pub(super) fn render_view(
|
||||
.filter(|(id, _)| equipped.contains(id))
|
||||
.count();
|
||||
addw((SET_SPACER.into(), GOLD))?;
|
||||
add((
|
||||
format!("{} ({}/{})", set.name, owned, set.members.len()),
|
||||
keyed(
|
||||
"ITEM_SET_NAME",
|
||||
&[
|
||||
Arg::S(&set.name),
|
||||
Arg::D(owned as i64),
|
||||
Arg::D(set.members.len() as i64),
|
||||
],
|
||||
GOLD,
|
||||
))?;
|
||||
false,
|
||||
)?;
|
||||
let skill_met = set.required_skill == 0 || {
|
||||
let have = req.skills.get(&set.required_skill).copied().unwrap_or(0);
|
||||
have >= set.required_skill_rank
|
||||
};
|
||||
if let Some(skill) = &set.required_skill_name {
|
||||
let line = if set.required_skill_rank > 0 {
|
||||
format!("Requires {skill} ({})", set.required_skill_rank)
|
||||
let color = req_color(skill_met);
|
||||
if set.required_skill_rank > 0 {
|
||||
let args = [Arg::S(skill), Arg::D(set.required_skill_rank.into())];
|
||||
keyed("ITEM_MIN_SKILL", &args, color, false)?;
|
||||
} else {
|
||||
format!("Requires {skill}")
|
||||
};
|
||||
add((line, req_color(skill_met)))?;
|
||||
keyed("ITEM_REQ_SKILL", &[Arg::S(skill)], color, false)?;
|
||||
}
|
||||
}
|
||||
// The member ladder's two-space indent is the engine's own `" %s"` literal `0x854b14`,
|
||||
// not a string-table entry.
|
||||
for (id, name) in &set.members {
|
||||
let Some(name) = name else { continue };
|
||||
let color = if equipped.contains(id) { CREAM } else { GRAY };
|
||||
@@ -570,12 +688,12 @@ pub(super) fn render_view(
|
||||
let mut bonuses: Vec<&(u32, String)> = set.bonuses.iter().collect();
|
||||
bonuses.sort_by_key(|&&(threshold, _)| threshold);
|
||||
for &(threshold, ref text) in bonuses {
|
||||
let color = if skill_met && owned as u32 >= threshold {
|
||||
GREEN
|
||||
if skill_met && owned as u32 >= threshold {
|
||||
keyed("ITEM_SET_BONUS", &[Arg::S(text)], GREEN, true)?;
|
||||
} else {
|
||||
GRAY
|
||||
};
|
||||
addw((format!("({threshold}) Set: {text}"), color))?;
|
||||
let args = [Arg::D(threshold.into()), Arg::S(text)];
|
||||
keyed("ITEM_SET_BONUS_GRAY", &args, GRAY, true)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The compact/compare early-return (`0x52e14c`, `[arg+0x14]≠0`): everything below —
|
||||
@@ -601,11 +719,12 @@ pub(super) fn render_view(
|
||||
// "Made by" (gifts join with the wrap/unwrap arc).
|
||||
if inst.flags & 0x8 == 0 {
|
||||
if let Some(name) = &inst.creator {
|
||||
if inst.has_text {
|
||||
add((format!("Written by {name}"), WHITE))?;
|
||||
let key = if inst.has_text {
|
||||
"ITEM_WRITTEN_BY"
|
||||
} else {
|
||||
add((format!("|cff00ff00<Made by {name}>|r"), WHITE))?;
|
||||
}
|
||||
"ITEM_CREATED_BY"
|
||||
};
|
||||
keyed(key, &[Arg::S(name)], WHITE, false)?;
|
||||
}
|
||||
}
|
||||
// ITEM_OPENABLE / ITEM_READABLE — ONE line, openable wins outright (the `jmp 0x52e35d` past
|
||||
@@ -624,9 +743,9 @@ pub(super) fn render_view(
|
||||
&& ((v.flags & 0x4 != 0 && (v.lock_id == 0 || inst.flags & 0x4 != 0))
|
||||
|| (v.flags & 0x200 != 0 && inst.flags & 0x8 != 0));
|
||||
if openable {
|
||||
add(("<Right Click to Open>".into(), GREEN))?;
|
||||
keyed("ITEM_OPENABLE", &[], GREEN, false)?;
|
||||
} else if v.page_text != 0 || inst.has_text {
|
||||
add(("<Right Click to Read>".into(), GREEN))?;
|
||||
keyed("ITEM_READABLE", &[], GREEN, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -634,124 +753,122 @@ pub(super) fn render_view(
|
||||
/// A temporary enchant's line text — the name with its countdown, the reference's own bucket
|
||||
/// ladder (wow-re `tooltip-content-law.md` §E3 → `0x52fa50`, byte-verified): a runtime key
|
||||
/// `ITEM_ENCHANT_TIME_LEFT_<DAYS|HOURS|MIN|SEC>` chosen by the largest unit that fits, with the
|
||||
/// count taken as **ceil** in the day/hour/minute arms and **truncated** in the seconds arm. The
|
||||
/// day/hour keys carry a `_P1` plural twin in the shipped GlobalStrings; minutes and seconds do
|
||||
/// not, so those read "(1 min)" at one minute exactly as they read "(9 min)".
|
||||
/// count taken as **ceil** in the day/hour/minute arms and **truncated** in the seconds arm, and
|
||||
/// the `_P1` plural twin picked by [`plural_template`]'s byte-pinned rule. The day and hour keys
|
||||
/// ship a twin; minutes and seconds do not, so those read "(1 min)" at one minute exactly as they
|
||||
/// read "(9 min)".
|
||||
///
|
||||
/// Templates are the shipped enUS values (`Interface\FrameXML\GlobalStrings.lua:2406-2411`),
|
||||
/// inlined like every other string in this builder.
|
||||
fn enchant_time_left(name: &str, ms: u64) -> String {
|
||||
match time_bucket(ms) {
|
||||
(TimeBucket::Days, 1) => format!("{name} (1 day)"),
|
||||
(TimeBucket::Days, n) => format!("{name} ({n} days)"),
|
||||
(TimeBucket::Hours, 1) => format!("{name} (1 hour)"),
|
||||
(TimeBucket::Hours, n) => format!("{name} ({n} hrs)"),
|
||||
(TimeBucket::Min, n) => format!("{name} ({n} min)"),
|
||||
(TimeBucket::Sec, n) => format!("{name} ({n} sec)"),
|
||||
}
|
||||
/// **Two holes, which is why this cannot be `duration_text`**: the templates are
|
||||
/// `"%s (%d min)"` — the enchant's own name fills the first, the count the second. That name is
|
||||
/// the `SpellItemEnchantment` row's, DBC data rather than a string-table sentence, so it arrives
|
||||
/// as an argument. `None` = the family is absent from the install's string table, and the caller
|
||||
/// draws no line at all.
|
||||
fn enchant_time_left(name: &str, ms: u64, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
let (suffix, n) = time_bucket(ms);
|
||||
let template = plural_template(&format!("ITEM_ENCHANT_TIME_LEFT_{suffix}"), n, get)?;
|
||||
Some(fill(&template, &[Arg::S(name), Arg::D(i64::from(n))]))
|
||||
}
|
||||
|
||||
/// The item self-expiry line's text — the same `0x52fa50` bucket ladder as
|
||||
/// [`enchant_time_left`] (largest unit that fits; **ceil** in the day/hour/minute arms,
|
||||
/// **truncated** in the seconds arm), composed against key prefix `ITEM_DURATION`.
|
||||
/// `0x52fa50`'s bucket + count for the ENCHANT countdown — the key suffix and the number that
|
||||
/// fills it. The largest unit that fits wins (`>= 1 day` · `>= 1 h` · `>= 1 min` · else seconds);
|
||||
/// the count is **ceil** in the day/hour/minute arms (the `roundUp` argument, which all seven of
|
||||
/// the function's callers pass as 1) and plain **truncation** in the seconds arm.
|
||||
///
|
||||
/// The one difference from its siblings is the absence of a plural split: `ITEM_DURATION_*` ships
|
||||
/// no `_P1` twin (`GlobalStrings.lua:2401-2404`), so a one-day timer reads "Duration: 1 days"
|
||||
/// exactly as the reference's own string table spells it.
|
||||
fn duration_left(ms: u64) -> String {
|
||||
match time_bucket(ms) {
|
||||
(TimeBucket::Days, n) => format!("Duration: {n} days"),
|
||||
(TimeBucket::Hours, n) => format!("Duration: {n} hrs"),
|
||||
(TimeBucket::Min, n) => format!("Duration: {n} min"),
|
||||
(TimeBucket::Sec, n) => format!("Duration: {n} sec"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rung [`time_bucket`] picked — `0x52fa50`'s runtime key suffix, as a type.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum TimeBucket {
|
||||
Days,
|
||||
Hours,
|
||||
Min,
|
||||
Sec,
|
||||
}
|
||||
|
||||
/// `0x52fa50`'s bucket + count, shared by every countdown line the item builder emits (the
|
||||
/// temporary enchant, the item's own expiry, and — when it is fed — the cooldown remaining).
|
||||
/// The largest unit that fits wins (`>= 1 day` · `>= 1 h` · `>= 1 min` · else seconds); the count
|
||||
/// is **ceil** in the day/hour/minute arms (the `roundUp` argument, which all seven of the
|
||||
/// function's callers pass as 1) and plain **truncation** in the seconds arm.
|
||||
fn time_bucket(ms: u64) -> (TimeBucket, u64) {
|
||||
/// The same ladder [`crate::script::tooltip::duration_text`] walks — it stays separate only
|
||||
/// because that one fills a single-hole template and this family has two holes; every other
|
||||
/// countdown in the tooltips goes through `duration_text`.
|
||||
fn time_bucket(ms: u64) -> (&'static str, u32) {
|
||||
const SEC: u64 = 1_000;
|
||||
const MIN: u64 = 60 * SEC;
|
||||
const HOUR: u64 = 60 * MIN;
|
||||
const DAY: u64 = 24 * HOUR;
|
||||
match ms {
|
||||
_ if ms >= DAY => (TimeBucket::Days, ms.div_ceil(DAY)),
|
||||
_ if ms >= HOUR => (TimeBucket::Hours, ms.div_ceil(HOUR)),
|
||||
_ if ms >= MIN => (TimeBucket::Min, ms.div_ceil(MIN)),
|
||||
_ => (TimeBucket::Sec, ms / SEC),
|
||||
}
|
||||
let (suffix, n) = match ms {
|
||||
_ if ms >= DAY => ("DAYS", ms.div_ceil(DAY)),
|
||||
_ if ms >= HOUR => ("HOURS", ms.div_ceil(HOUR)),
|
||||
_ if ms >= MIN => ("MIN", ms.div_ceil(MIN)),
|
||||
_ => ("SEC", ms / SEC),
|
||||
};
|
||||
// The count is a dword in the reference; a timer long enough to overflow one does not exist.
|
||||
(suffix, u32::try_from(n).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` — "%d Charge" / "%d Charges" (`GlobalStrings.lua:2448-2449`, the `_P1`
|
||||
/// plural twin). One rule for both consumers: the standalone charges line (law line 21) and the
|
||||
/// enchant line's ` (…)` suffix (§E3).
|
||||
fn charges_phrase(n: u32) -> String {
|
||||
match n {
|
||||
1 => "1 Charge".into(),
|
||||
n => format!("{n} Charges"),
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin (`0x84e3b4`, pushed at `0x52cae8` for the
|
||||
/// enchant suffix and `0x52db61` for the standalone line). One rule for both consumers: the
|
||||
/// charges line (law line 21) and the enchant line's ` (…)` suffix (§E3).
|
||||
///
|
||||
/// Zero answers `None` — the reference never reaches this with a zero count, and neither
|
||||
/// consumer here has a line to draw without one.
|
||||
fn charges_phrase(n: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let template = plural_template("ITEM_SPELL_CHARGES", n, get)?;
|
||||
Some(fill(&template, &[Arg::D(i64::from(n))]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stand-in string table, **deliberately not the shipped wording** — what is under test is
|
||||
/// which key is reached and what fills it, never what the sentence says (decision 2045,
|
||||
/// "assert the identifier, not the sentence"). Every value carries angle brackets so a
|
||||
/// resolved line cannot be mistaken for a composed one.
|
||||
fn table(key: &str) -> Option<String> {
|
||||
Some(
|
||||
match key {
|
||||
"ITEM_ENCHANT_TIME_LEFT_DAYS" => "<%s :: %d D>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_DAYS_P1" => "<%s :: %d DD>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_HOURS" => "<%s :: %d H>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_HOURS_P1" => "<%s :: %d HH>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_MIN" => "<%s :: %d M>",
|
||||
"ITEM_ENCHANT_TIME_LEFT_SEC" => "<%s :: %d S>",
|
||||
"ITEM_SPELL_CHARGES" => "<%d chg>",
|
||||
"ITEM_SPELL_CHARGES_P1" => "<%d chgs>",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The countdown's bucket ladder (wow-re §1-ENCHANT §E3 → `0x52fa50`): the largest unit that
|
||||
/// fits wins, the count is **ceil** in the day/hour/minute arms and **truncated** in seconds,
|
||||
/// and the day/hour arms have singular/plural twins while minutes and seconds do not.
|
||||
/// and the day/hour arms have `_P1` twins while minutes and seconds do not — so those two
|
||||
/// suffixes reach the same key at every count.
|
||||
#[test]
|
||||
fn enchant_countdown_buckets_and_rounding() {
|
||||
let t = |ms| enchant_time_left("Rockbiter", ms);
|
||||
// Seconds truncate: 1900 ms is "1 sec", not 2.
|
||||
assert_eq!(t(1_900), "Rockbiter (1 sec)");
|
||||
assert_eq!(t(59_999), "Rockbiter (59 sec)");
|
||||
let t = |ms| enchant_time_left("Rockbiter", ms, &table);
|
||||
// Seconds truncate: 1900 ms is 1, not 2.
|
||||
assert_eq!(t(1_900).as_deref(), Some("<Rockbiter :: 1 S>"));
|
||||
assert_eq!(t(59_999).as_deref(), Some("<Rockbiter :: 59 S>"));
|
||||
// Minutes ceil: one second past 4 minutes already reads 5.
|
||||
assert_eq!(t(60_000), "Rockbiter (1 min)");
|
||||
assert_eq!(t(241_000), "Rockbiter (5 min)");
|
||||
// Hours ceil, with the shipped plural spelling ("hrs", not "hours").
|
||||
assert_eq!(t(3_600_000), "Rockbiter (1 hour)");
|
||||
assert_eq!(t(3_600_001), "Rockbiter (2 hrs)");
|
||||
assert_eq!(t(60_000).as_deref(), Some("<Rockbiter :: 1 M>"));
|
||||
assert_eq!(t(241_000).as_deref(), Some("<Rockbiter :: 5 M>"));
|
||||
// Hours ceil, and only the non-singular count takes the `_P1` key.
|
||||
assert_eq!(t(3_600_000).as_deref(), Some("<Rockbiter :: 1 H>"));
|
||||
assert_eq!(t(3_600_001).as_deref(), Some("<Rockbiter :: 2 HH>"));
|
||||
// Days ceil.
|
||||
assert_eq!(t(86_400_000), "Rockbiter (1 day)");
|
||||
assert_eq!(t(86_400_001), "Rockbiter (2 days)");
|
||||
// Zero never reaches here (the app drops an expired timer), but it must not panic.
|
||||
assert_eq!(t(0), "Rockbiter (0 sec)");
|
||||
assert_eq!(t(86_400_000).as_deref(), Some("<Rockbiter :: 1 D>"));
|
||||
assert_eq!(t(86_400_001).as_deref(), Some("<Rockbiter :: 2 DD>"));
|
||||
// Zero never reaches here (the app drops an expired timer), but it must not panic — and
|
||||
// zero takes the PLURAL arm, which is `GetPluralIndex`'s rule, not a rounding accident.
|
||||
assert_eq!(t(0).as_deref(), Some("<Rockbiter :: 0 S>"));
|
||||
}
|
||||
|
||||
/// The item self-expiry line shares the enchant countdown's ladder and differs in exactly one
|
||||
/// way: `ITEM_DURATION_*` ships **no `_P1` plural twin**, so a one-unit count reads the same
|
||||
/// plural spelling as any other ("1 days", "1 hrs") — the shipped enUS strings say so
|
||||
/// (`GlobalStrings.lua:2401-2404`), and inventing a singular here would be our own invention,
|
||||
/// not the reference's.
|
||||
/// A string table that does not carry the family draws **no line**, never an invented one —
|
||||
/// the whole point of resolving by key rather than composing (2045).
|
||||
#[test]
|
||||
fn item_duration_shares_the_ladder_but_not_the_plural() {
|
||||
assert_eq!(duration_left(1_900), "Duration: 1 sec"); // seconds truncate
|
||||
assert_eq!(duration_left(59_999), "Duration: 59 sec");
|
||||
assert_eq!(duration_left(60_000), "Duration: 1 min"); // minutes ceil
|
||||
assert_eq!(duration_left(241_000), "Duration: 5 min");
|
||||
assert_eq!(duration_left(3_600_000), "Duration: 1 hrs"); // no singular twin
|
||||
assert_eq!(duration_left(3_600_001), "Duration: 2 hrs");
|
||||
assert_eq!(duration_left(86_400_000), "Duration: 1 days");
|
||||
assert_eq!(duration_left(1_209_600_000), "Duration: 14 days");
|
||||
fn a_missing_countdown_family_draws_nothing() {
|
||||
assert_eq!(enchant_time_left("Rockbiter", 60_000, &|_| None), None);
|
||||
assert_eq!(charges_phrase(3, &|_| None), None);
|
||||
}
|
||||
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin — one rule, both consumers (the standalone
|
||||
/// charges line and the enchant line's suffix).
|
||||
/// `ITEM_SPELL_CHARGES` and its `_P1` plural twin — one rule, both consumers (the charges
|
||||
/// line and the enchant line's suffix). A zero count has no line either way.
|
||||
#[test]
|
||||
fn charges_phrase_picks_the_plural() {
|
||||
assert_eq!(charges_phrase(1), "1 Charge");
|
||||
assert_eq!(charges_phrase(5), "5 Charges");
|
||||
assert_eq!(charges_phrase(1, &table).as_deref(), Some("<1 chg>"));
|
||||
assert_eq!(charges_phrase(5, &table).as_deref(), Some("<5 chgs>"));
|
||||
assert_eq!(charges_phrase(0, &table), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,10 @@ pub(super) fn spell_view_of(lua: &Lua, spell_id: u32) -> Option<SpellTooltipView
|
||||
/// builder law, after the rank line), the "Next rank:" block, and the green learn hint.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct TalentLines {
|
||||
pub rank_line: String,
|
||||
/// `TOOLTIP_TALENT_RANK` = "Rank %d/%d", already filled by the caller off the player's own
|
||||
/// string table. `None` = the table does not carry the key, and the plate shows no rank row
|
||||
/// (decision 2045 — never an invented one).
|
||||
pub rank_line: Option<String>,
|
||||
pub reqs: Vec<String>,
|
||||
/// The next rank's spell id (0 = none) — asked from the spell store when its description
|
||||
/// hasn't landed yet, so the hover's re-enter completes the block.
|
||||
@@ -187,7 +190,9 @@ fn render_spell(
|
||||
// The talent head: "Rank r/m" (builder line 2, TOOLTIP_TALENT_RANK white) + the red
|
||||
// requirement lines while locked (position CONFIRMED, decision 0305 — TalentLines doc).
|
||||
if let Some(t) = talent {
|
||||
append_line(lua, this, (t.rank_line.clone(), WHITE), None, false)?;
|
||||
if let Some(rank) = &t.rank_line {
|
||||
append_line(lua, this, (rank.clone(), WHITE), None, false)?;
|
||||
}
|
||||
for req in &t.reqs {
|
||||
append_line(lua, this, (req.clone(), RED), None, true)?;
|
||||
}
|
||||
@@ -250,21 +255,22 @@ fn render_spell(
|
||||
let color = if aura { WHITE } else { GOLD };
|
||||
append_line(lua, this, (desc.clone(), color), None, true)?;
|
||||
}
|
||||
// The talent tail: the "Next rank:" block (TOOLTIP_TALENT_NEXT_RANK white + the next rank's
|
||||
// gold description) and the green learn hint (builder line 13, TOOLTIP_TALENT_LEARN).
|
||||
// The talent tail: the TOOLTIP_TALENT_NEXT_RANK header (white, `0x854a10` pushed at
|
||||
// `0x52b2cd`) over the next rank's gold description, and the green learn hint (builder
|
||||
// line 13, TOOLTIP_TALENT_LEARN `0x8549f8` at `0x52b362`). Both are keys into the player's
|
||||
// own string table (decision 2045); an install without them shows the description alone
|
||||
// rather than a sentence of ours.
|
||||
if let Some(t) = talent {
|
||||
if let Some(next) = &t.next_desc {
|
||||
append_line(lua, this, ("Next rank:".to_string(), WHITE), None, false)?;
|
||||
if let Some(header) = crate::strings::global(lua, "TOOLTIP_TALENT_NEXT_RANK") {
|
||||
append_line(lua, this, (header, WHITE), None, false)?;
|
||||
}
|
||||
append_line(lua, this, (next.clone(), GOLD), None, true)?;
|
||||
}
|
||||
if t.learn {
|
||||
append_line(
|
||||
lua,
|
||||
this,
|
||||
("Click to learn".to_string(), GREEN),
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
if let Some(hint) = crate::strings::global(lua, "TOOLTIP_TALENT_LEARN") {
|
||||
append_line(lua, this, (hint, GREEN), None, false)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The duration-remaining line (`SetPlayerBuff` only) is GOLD `0xffffd200` — the same gold as
|
||||
@@ -309,7 +315,9 @@ pub(super) fn set_spell_with_talent(
|
||||
None => {
|
||||
// The view hasn't landed: show the talent head alone (the ask is recorded; the
|
||||
// hover's re-enter repaints complete) — the spell channel's own fallback shape.
|
||||
append_line(lua, this, (talent.rank_line.clone(), WHITE), None, false)?;
|
||||
if let Some(rank) = &talent.rank_line {
|
||||
append_line(lua, this, (rank.clone(), WHITE), None, false)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
//! empty line — the builder's name read is `0x609210`, the same resolver `UnitName` uses, and
|
||||
//! its miss tail is the same string (decisions 2002/2040);
|
||||
//! - the creature SUBTITLE ("Stable Master") — white;
|
||||
//! - the LEVEL line, composed from three slots over the four `TOOLTIP_UNIT_LEVEL*` templates:
|
||||
//! - the LEVEL line, filled from three slots into one of the four `TOOLTIP_UNIT_LEVEL*` templates
|
||||
//! the player's own `GlobalStrings.lua` carries (decision 2045 — the keys are the builder's,
|
||||
//! read off `0x52a622`/`0x52a64d`/`0x52a682`/`0x52a6ac`, never matched on their English):
|
||||
//! level text (`"??"` for a world boss, a much-higher hostile, or level ≤ 0 — the hostile
|
||||
//! delta is INTERIM at +10 pending a byte pin of the comparison), the class slot (the creature
|
||||
//! TYPE word for hostile/neutral creatures, `"Race Class"` for players, `"Corpse"` when dead),
|
||||
//! and the type slot (the rank word `{"", Elite, Elite, Boss, ""}`; `"Player"` for players);
|
||||
//! TYPE word for hostile/neutral creatures, `"Race Class"` for players, `CORPSE` when dead),
|
||||
//! and the type slot (the rank key table `{"", ELITE, ELITE, BOSS, ""}`; `PLAYER` for players);
|
||||
//! - the FACTION NAME ("Stormwind", white) — the builder-tail block `0x52a7a0..` the law's §2
|
||||
//! order originally omitted: the app resolves it (`faction_name`), every gate applied;
|
||||
//! - "PvP" (white) · "Skinnable" (**red**) · "Civilian" (green, `0x612550`: PvP-flagged +
|
||||
@@ -36,6 +38,7 @@ use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empt
|
||||
use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject};
|
||||
use super::{KindState, Model, UnitState};
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::strings::{fill, Arg};
|
||||
use crate::widget::FrameHandle;
|
||||
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
@@ -65,19 +68,31 @@ pub enum TooltipTint {
|
||||
LockOpen,
|
||||
}
|
||||
|
||||
/// The rank word table — byte-verified `0x854158[]`: rare-elite prints ELITE, rare prints
|
||||
/// nothing (there is no distinct "Rare Elite" word in 1.12's builder).
|
||||
fn rank_word(rank: u32) -> Option<&'static str> {
|
||||
/// The rank word's GlobalString **key** — byte-verified `0x854158[]`, the pointer table the
|
||||
/// builder indexes by rank at `0x52a5bd`/`0x52a5d4`. Its entries are key names, not words: index 0
|
||||
/// and 4 are the pre-seeded empty string `0x882748`, 1 and 2 are both `ELITE` (rare-elite prints
|
||||
/// ELITE, rare prints nothing — there is no distinct "Rare Elite" word in 1.12's builder), 3 is
|
||||
/// `BOSS`.
|
||||
fn rank_key(rank: u32) -> Option<&'static str> {
|
||||
match rank {
|
||||
1 | 2 => Some("Elite"),
|
||||
3 => Some("Boss"),
|
||||
1 | 2 => Some("ELITE"),
|
||||
3 => Some("BOSS"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The level line — three slots over the four `TOOLTIP_UNIT_LEVEL*` templates ("Level %s" /
|
||||
/// "Level %s %s" / "Level %s (%s)" / "Level %s %s (%s)", the extracted enUS strings).
|
||||
fn level_line(u: &UnitState, player_level: u32) -> String {
|
||||
/// The level line — three slots over the four `TOOLTIP_UNIT_LEVEL*` templates, each resolved off
|
||||
/// the player's own `GlobalStrings.lua` (decision 2045). The keys are the builder's own, pushed at
|
||||
/// `0x52a622` (`_CLASS_TYPE`), `0x52a64d` (`_CLASS`), `0x52a682` (`_TYPE`) and `0x52a6ac` (the
|
||||
/// bare one) — which matters, because `TOOLTIP_UNIT_LEVEL_CLASS`'s enUS "Level %s %s" is also the
|
||||
/// wording of `FRIENDS_LEVEL_TEMPLATE`, `UNIT_TYPE_LEVEL_TEMPLATE` and `CHARACTER_SELECT_INFO`,
|
||||
/// and the bare one collides with `ITEM_LEVEL`, `LEVEL_GAINED` and `UNIT_LEVEL_TEMPLATE`. Seven
|
||||
/// wrong keys are reachable by matching the English here; none by reading the builder.
|
||||
///
|
||||
/// `None` = the install's string table carries no template for the slot combination in hand, and
|
||||
/// the plate shows no level row rather than a composed one.
|
||||
fn level_line(lua: &Lua, u: &UnitState, player_level: u32) -> Option<String> {
|
||||
let get = |key: &str| crate::strings::global(lua, key);
|
||||
// The "??" gate, byte-pinned (0x529fe0 §2-LEVEL): much-higher HOSTILE — internal reaction
|
||||
// ≤ 1 = hated/hostile = UnitReaction ≤ 2 on our 1..8 API scale (the UnitIsEnemy mapping) —
|
||||
// with playerLevel ≤ targetLevel−10; OR WorldBoss; OR level ≤ 0. Players NEVER read "??".
|
||||
@@ -92,7 +107,7 @@ fn level_line(u: &UnitState, player_level: u32) -> String {
|
||||
// creatures (a friendly creature shows none — the byte law's hostile/neutral-only gate);
|
||||
// "Corpse" when dead.
|
||||
let class_slot = if u.dead {
|
||||
Some("Corpse".to_string())
|
||||
get("CORPSE")
|
||||
} else if u.is_player {
|
||||
match (&u.race, &u.class) {
|
||||
(Some(r), Some(c)) => Some(format!("{r} {c}")),
|
||||
@@ -105,18 +120,28 @@ fn level_line(u: &UnitState, player_level: u32) -> String {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The type slot: the rank word, or "Player" for players.
|
||||
// The type slot: the rank word, or PLAYER for players.
|
||||
let type_slot = if u.is_player {
|
||||
Some("Player".to_string())
|
||||
get("PLAYER")
|
||||
} else {
|
||||
rank_word(u.rank).map(str::to_string)
|
||||
rank_key(u.rank).and_then(&get)
|
||||
};
|
||||
match (class_slot, type_slot) {
|
||||
(Some(c), Some(t)) => format!("Level {level_text} {c} ({t})"),
|
||||
(Some(c), None) => format!("Level {level_text} {c}"),
|
||||
(None, Some(t)) => format!("Level {level_text} ({t})"),
|
||||
(None, None) => format!("Level {level_text}"),
|
||||
}
|
||||
let (key, args): (&str, Vec<Arg<'_>>) = match (&class_slot, &type_slot) {
|
||||
(Some(c), Some(t)) => (
|
||||
"TOOLTIP_UNIT_LEVEL_CLASS_TYPE",
|
||||
vec![Arg::S(&level_text), Arg::S(c), Arg::S(t)],
|
||||
),
|
||||
(Some(c), None) => (
|
||||
"TOOLTIP_UNIT_LEVEL_CLASS",
|
||||
vec![Arg::S(&level_text), Arg::S(c)],
|
||||
),
|
||||
(None, Some(t)) => (
|
||||
"TOOLTIP_UNIT_LEVEL_TYPE",
|
||||
vec![Arg::S(&level_text), Arg::S(t)],
|
||||
),
|
||||
(None, None) => ("TOOLTIP_UNIT_LEVEL", vec![Arg::S(&level_text)]),
|
||||
};
|
||||
Some(fill(&get(key)?, &args))
|
||||
}
|
||||
|
||||
/// Render the unit tooltip for `token`'s current snapshot; returns whether the unit existed.
|
||||
@@ -160,13 +185,9 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
|
||||
if let Some(sub) = &u.subtitle {
|
||||
append_line(lua, this, (sub.clone(), WHITE), None, false)?;
|
||||
}
|
||||
append_line(
|
||||
lua,
|
||||
this,
|
||||
(level_line(&u, player_level), WHITE),
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
if let Some(level) = level_line(lua, &u, player_level) {
|
||||
append_line(lua, this, (level, WHITE), None, false)?;
|
||||
}
|
||||
// The faction-name line ("Stormwind", white) sits between the level line and "PvP" — the
|
||||
// builder-tail block at `0x52a7a0`. The app resolved every gate into `faction_name`.
|
||||
if let Some(faction) = &u.faction_name {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::script::item_stats::item_link;
|
||||
use crate::script::Model;
|
||||
|
||||
use super::view::{
|
||||
build_groups, first_recipe_index, inv_slot_name, num_rows, present_inv_slots, recipe_at, rows,
|
||||
build_groups, first_recipe_index, inv_slot_token, num_rows, present_inv_slots, recipe_at, rows,
|
||||
select, selected_visible_index, set_collapsed, Row,
|
||||
};
|
||||
|
||||
@@ -439,8 +439,15 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let mut out = Vec::new();
|
||||
// Each word is its `0x84dd70` token resolved off the player's own string table
|
||||
// (decision 2045). A token the table does not carry answers the empty string rather
|
||||
// than being dropped: the list is POSITIONAL — `GetTradeSkillInvSlotFilter(index)`
|
||||
// indexes the same order — so a hole would shift every filter after it.
|
||||
for bit in present_inv_slots(&model) {
|
||||
out.push(Value::String(lua.create_string(inv_slot_name(bit))?));
|
||||
let word = inv_slot_token(bit)
|
||||
.and_then(|k| crate::strings::global(lua, k))
|
||||
.unwrap_or_default();
|
||||
out.push(Value::String(lua.create_string(word)?));
|
||||
}
|
||||
Ok(MultiValue::from_vec(out))
|
||||
})?,
|
||||
|
||||
@@ -677,12 +677,38 @@ fn subclass_filter_exclusive_narrows_list_but_not_vocabulary() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A stand-in string table for the `0x84dd70` token family — **deliberately not the shipped
|
||||
/// wording**, because what these tests establish is *which token* each slot bit reaches, never
|
||||
/// what the word says (decision 2045, "assert the identifier, not the sentence"). Naming each
|
||||
/// value after its own key is the point: `SECONDARYHANDSLOT`, `INVTYPE_SHIELD` and
|
||||
/// `INVTYPE_WEAPONOFFHAND` all read "Off Hand" in enUS, so an assertion on the English could not
|
||||
/// tell a correct table from one wired to the item tooltip's family.
|
||||
fn seed_slot_tokens(s: &mut UiScript) {
|
||||
s.run(
|
||||
r#"
|
||||
HEADSLOT = "<HEADSLOT>"; NECKSLOT = "<NECKSLOT>"
|
||||
SHOULDERSLOT = "<SHOULDERSLOT>"; SHIRTSLOT = "<SHIRTSLOT>"
|
||||
CHESTSLOT = "<CHESTSLOT>"; WAISTSLOT = "<WAISTSLOT>"
|
||||
LEGSSLOT = "<LEGSSLOT>"; FEETSLOT = "<FEETSLOT>"
|
||||
WRISTSLOT = "<WRISTSLOT>"; HANDSSLOT = "<HANDSSLOT>"
|
||||
FINGER0SLOT = "<FINGER0SLOT>"; FINGER1SLOT = "<FINGER1SLOT>"
|
||||
TRINKET0SLOT = "<TRINKET0SLOT>"; TRINKET1SLOT = "<TRINKET1SLOT>"
|
||||
BACKSLOT = "<BACKSLOT>"; MAINHANDSLOT = "<MAINHANDSLOT>"
|
||||
SECONDARYHANDSLOT = "<SECONDARYHANDSLOT>"; RANGEDSLOT = "<RANGEDSLOT>"
|
||||
TABARDSLOT = "<TABARDSLOT>"; BAGSLOT = "<BAGSLOT>"
|
||||
NONEQUIPSLOT = "<NONEQUIPSLOT>"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The InvSlot filter: the vocabulary is the distinct slot words ascending by slot bit; an
|
||||
/// exclusive set drops every recipe on other slots AND any group that empties — header
|
||||
/// included.
|
||||
#[test]
|
||||
fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
seed_slot_tokens(&mut s);
|
||||
let mut st = state();
|
||||
// Wind Cloak is the one Back product (16 → bit 14); everything else stays Robe/Chest.
|
||||
st.recipes
|
||||
@@ -695,7 +721,7 @@ fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
assert_eq!(
|
||||
s.eval::<(String, String)>("return GetTradeSkillInvSlots()")
|
||||
.unwrap(),
|
||||
("Chest".to_string(), "Back".to_string()),
|
||||
("<CHESTSLOT>".to_string(), "<BACKSLOT>".to_string()),
|
||||
"ascending slot-bit order (4 before 14)"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -723,6 +749,7 @@ fn invslot_filter_drops_recipes_and_emptied_groups() {
|
||||
#[test]
|
||||
fn one_hand_weapon_spans_both_hand_slots() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
seed_slot_tokens(&mut s);
|
||||
let mut st = two_recipe_state();
|
||||
st.recipes[0].product_inv_type = 13; // Bolt of Linen Cloth becomes a one-hand weapon
|
||||
s.set_trade_skill(Some(st));
|
||||
@@ -731,9 +758,10 @@ fn one_hand_weapon_spans_both_hand_slots() {
|
||||
s.eval::<(String, String, String)>("return GetTradeSkillInvSlots()")
|
||||
.unwrap(),
|
||||
(
|
||||
"Chest".to_string(),
|
||||
"Main Hand".to_string(),
|
||||
"Off Hand".to_string()
|
||||
"<CHESTSLOT>".to_string(),
|
||||
"<MAINHANDSLOT>".to_string(),
|
||||
// Not `INVTYPE_WEAPONOFFHAND`: this dropdown is the paper-doll family.
|
||||
"<SECONDARYHANDSLOT>".to_string()
|
||||
)
|
||||
);
|
||||
// Exclusive "Off Hand" (index 3): the weapon row survives, the robe row hides.
|
||||
|
||||
@@ -40,34 +40,46 @@ fn inv_slot_mask(inv_type: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The InvSlot dropdown's per-bit display word — the real client's 24-entry GlobalString-token
|
||||
/// table (`0x84dd70`, byte-VERIFIED — wow-re `tradeskill` TU-G §2), resolved to the tokens' enUS
|
||||
/// values (`GlobalStrings.lua`: `HEADSLOT = "Head"` … `NONEQUIPSLOT = "Not equippable."`). Bits
|
||||
/// 11/13/20-22 (FINGER1SLOT/TRINKET1SLOT/extra BAGSLOTs) are present in the table but unreachable
|
||||
/// through [`inv_slot_mask`] — kept for the full 24-entry fidelity.
|
||||
pub(super) fn inv_slot_name(bit: u32) -> &'static str {
|
||||
match bit {
|
||||
0 => "Head",
|
||||
1 => "Neck",
|
||||
2 => "Shoulders",
|
||||
3 => "Shirt",
|
||||
4 => "Chest",
|
||||
5 => "Waist",
|
||||
6 => "Legs",
|
||||
7 => "Feet",
|
||||
8 => "Wrist",
|
||||
9 => "Hands",
|
||||
10 | 11 => "Finger",
|
||||
12 | 13 => "Trinket",
|
||||
14 => "Back",
|
||||
15 => "Main Hand",
|
||||
16 => "Off Hand",
|
||||
17 => "Ranged",
|
||||
18 => "Tabard",
|
||||
19..=22 => "Bag",
|
||||
23 => "Not equippable.",
|
||||
_ => "",
|
||||
}
|
||||
/// The InvSlot dropdown's per-bit GlobalString **token** — the real client's 24-entry table
|
||||
/// (`0x84dd70`, byte-VERIFIED — wow-re `tradeskill` TU-G §2), dumped entry by entry. The caller
|
||||
/// resolves it against the player's own `GlobalStrings.lua` (decision 2045); these are the
|
||||
/// paper-doll `*SLOT` family, **not** the `INVTYPE_*` family the item tooltip's slot line uses,
|
||||
/// and that distinction is exactly why the word cannot be stored here: `SECONDARYHANDSLOT`,
|
||||
/// `INVTYPE_SHIELD` and `INVTYPE_WEAPONOFFHAND` are three separately-localizable strings that all
|
||||
/// read "Off Hand" in enUS.
|
||||
///
|
||||
/// Bits 11/13/20-22 (`FINGER1SLOT`/`TRINKET1SLOT`/the extra `BAGSLOT`s) are present in the table
|
||||
/// but unreachable through [`inv_slot_mask`] — kept for the full 24-entry fidelity.
|
||||
///
|
||||
/// **`None` is the out-of-range arm and is unreachable by construction**: the only caller walks
|
||||
/// [`present_inv_slots`], which enumerates bits 0..23, and the reference's table has an entry for
|
||||
/// every one of them. It resolves to the empty string at the call site, which is what
|
||||
/// `FrameScript_GetText` hands back for a token the table has no row for.
|
||||
pub(super) fn inv_slot_token(bit: u32) -> Option<&'static str> {
|
||||
Some(match bit {
|
||||
0 => "HEADSLOT",
|
||||
1 => "NECKSLOT",
|
||||
2 => "SHOULDERSLOT",
|
||||
3 => "SHIRTSLOT",
|
||||
4 => "CHESTSLOT",
|
||||
5 => "WAISTSLOT",
|
||||
6 => "LEGSSLOT",
|
||||
7 => "FEETSLOT",
|
||||
8 => "WRISTSLOT",
|
||||
9 => "HANDSSLOT",
|
||||
10 => "FINGER0SLOT",
|
||||
11 => "FINGER1SLOT",
|
||||
12 => "TRINKET0SLOT",
|
||||
13 => "TRINKET1SLOT",
|
||||
14 => "BACKSLOT",
|
||||
15 => "MAINHANDSLOT",
|
||||
16 => "SECONDARYHANDSLOT",
|
||||
17 => "RANGEDSLOT",
|
||||
18 => "TABARDSLOT",
|
||||
19..=22 => "BAGSLOT",
|
||||
23 => "NONEQUIPSLOT",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The InvSlot filter vocabulary: the set bits of the accumulated slot mask, ascending — the real
|
||||
|
||||
@@ -378,11 +378,23 @@ pub struct UnitState {
|
||||
///
|
||||
/// One resolver, so the verb and the plate can never disagree about what a unit whose name is
|
||||
/// still in flight is called (decisions 2002, 2040).
|
||||
///
|
||||
/// **Both halves of that tail are here, and both are load-bearing.** The lookup is the rule
|
||||
/// (decision 2045: the sentence is the install's, not ours); the literal beside it is the
|
||||
/// reference's own `0x860fa4`, which is what "a literal is legitimate only as a fallback beside a
|
||||
/// lookup" means. Spelled `globals().get::<String>` — the shape `benilla-app`'s
|
||||
/// `reference_strings` tripwire recognises as a resolver — so the fallback reads as the fallback
|
||||
/// it is rather than as undeclared drift. It answers the same three cases the `mlua::Value` read
|
||||
/// it replaces did: a non-empty string is returned, and both a missing global and a present-but-
|
||||
/// EMPTY one take `0x860fa4` (the binary tests the pointer and then its first byte).
|
||||
pub fn unknownobject(lua: &Lua) -> mlua::Result<mlua::String> {
|
||||
match lua.globals().get::<mlua::Value>("UNKNOWNOBJECT") {
|
||||
Ok(mlua::Value::String(s)) if !s.as_bytes().is_empty() => Ok(s),
|
||||
_ => lua.create_string("Unknown Being"),
|
||||
}
|
||||
// One expression, deliberately: `globals().get::<String>` is what the tripwire matches on,
|
||||
// and a line break inside it would hide this resolver from the walk again.
|
||||
let global = lua.globals().get::<String>("UNKNOWNOBJECT").ok();
|
||||
lua.create_string(match global.as_deref() {
|
||||
Some(s) if !s.is_empty() => s,
|
||||
_ => "Unknown Being",
|
||||
})
|
||||
}
|
||||
|
||||
/// The grey-band table `0x80ae98` (a byte-identical twin at `0x81dda8` drives the nameplate's
|
||||
|
||||
@@ -33,18 +33,33 @@
|
||||
//! mis-modelled should look wrong, not look plausible — `ui_instance` established this and it is
|
||||
//! the right call: a visibly broken line gets reported, a quietly wrong one does not.
|
||||
//! - Any other specifier is copied through untouched.
|
||||
//!
|
||||
//! **`%c` and `%f`/`%g` are here because 1.12's own tables use them**, not for completeness. The
|
||||
//! item tooltip's stat and resistance families are spelled with a *character* hole for the sign —
|
||||
//! `ITEM_MOD_AGILITY = "%c%d Agility"`, `ITEM_RESIST_SINGLE = "%c%d %s Resistance"` — and its
|
||||
//! rate lines with a float one: `DPS_TEMPLATE = "(%.1f damage per second)"`,
|
||||
//! `AMMO_DAMAGE_TEMPLATE = "Adds %g damage per second"`. A filler that only knew `%s`/`%d` would
|
||||
//! copy those holes through visibly, which is the same as not being able to show the line.
|
||||
//!
|
||||
//! **The precision is the template's, never the caller's** — `%.1f` is one decimal because
|
||||
//! `DPS_TEMPLATE` says so, and a locale that respells it to `%.2f` gets two without a code change.
|
||||
//! That is the whole point of reading the template at runtime.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// One argument to [`fill`]. `%s` renders either variant as text; `%d` renders either as an
|
||||
/// integer, so a caller that groups its arguments differently from the template still fills in the
|
||||
/// order the template asks for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// One argument to [`fill`]. Every hole renders every variant — `%s` as text, `%d` as an integer,
|
||||
/// `%f`/`%g` as a real — so a caller that groups its arguments differently from the template still
|
||||
/// fills in the order the template asks for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Arg<'a> {
|
||||
/// A string argument — a player name, an item link, a zone.
|
||||
/// A string argument — a player name, an item link, a zone. Also what a `%c` hole takes: the
|
||||
/// only `%c` 1.12 spells is the `+`/`-` sign char of the `ITEM_MOD_*`/`ITEM_RESIST_*` family,
|
||||
/// and a one-character string is how a caller says that.
|
||||
S(&'a str),
|
||||
/// A numeric argument.
|
||||
D(i64),
|
||||
/// A real argument — the rate lines (`DPS_TEMPLATE`, the `AMMO_*` damage templates).
|
||||
F(f64),
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for Arg<'a> {
|
||||
@@ -70,13 +85,29 @@ macro_rules! arg_from_int {
|
||||
}
|
||||
arg_from_int!(u8, u16, u32, i8, i16, i32);
|
||||
|
||||
impl From<f32> for Arg<'_> {
|
||||
fn from(x: f32) -> Self {
|
||||
Arg::F(f64::from(x))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Arg<'_> {
|
||||
fn from(x: f64) -> Self {
|
||||
Arg::F(x)
|
||||
}
|
||||
}
|
||||
|
||||
impl Arg<'_> {
|
||||
/// `%s` — and `%c`, whose only 1.12 use is a one-character sign string.
|
||||
fn as_s(&self, out: &mut String) {
|
||||
match self {
|
||||
Arg::S(s) => out.push_str(s),
|
||||
Arg::D(n) => {
|
||||
let _ = write!(out, "{n}");
|
||||
}
|
||||
Arg::F(x) => {
|
||||
let _ = write!(out, "{x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +120,178 @@ impl Arg<'_> {
|
||||
// is strictly more useful than showing nothing and matches what varargs would do with
|
||||
// a pointer-sized value far better than a zero would.
|
||||
Arg::S(s) => out.push_str(s),
|
||||
// C truncates toward zero here; so does `as`.
|
||||
Arg::F(x) => {
|
||||
let _ = write!(out, "{}", *x as i64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `%f` / `%.Nf` — the precision is the template's, defaulting to C's 6.
|
||||
fn as_f(&self, prec: Option<usize>, out: &mut String) {
|
||||
let p = prec.unwrap_or(6);
|
||||
match self {
|
||||
Arg::F(x) => {
|
||||
let _ = write!(out, "{x:.p$}");
|
||||
}
|
||||
Arg::D(n) => {
|
||||
let _ = write!(out, "{:.p$}", *n as f64);
|
||||
}
|
||||
Arg::S(s) => out.push_str(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// `%g` / `%.Ng` — C's rule, **including the precision**, which is significant digits here
|
||||
/// rather than decimal places.
|
||||
///
|
||||
/// The precision is not decoration on this one. Five 1.12 templates are spelled `%.3g` —
|
||||
/// `SPELL_CAST_TIME_SEC`, `SPELL_CAST_TIME_MIN`, `SPELL_RECAST_TIME_SEC`,
|
||||
/// `SPELL_RECAST_TIME_MIN`, `SPELL_CAST_TIME_RANGED` — and the numbers reaching them are
|
||||
/// divisions: a 100-second cooldown is 1.666… minutes. Under C that is "1.67 min cooldown".
|
||||
/// Under Rust's shortest round-trip `{}` it is "1.6666666666666667 min cooldown", which is
|
||||
/// why `{}` cannot stand in for `%g` in general even though it agrees with it on the one- and
|
||||
/// two-decimal values the ammo templates carry.
|
||||
///
|
||||
/// C's rule, from the standard: let X be the exponent of the value written in `%e` style at
|
||||
/// precision P−1; use `%f` with precision P−1−X when P > X ≥ −4, and `%e` otherwise; then
|
||||
/// drop trailing zeros from the fraction, and the point with them if nothing is left. Taking
|
||||
/// X *after* the rounding is what makes 9.999 at P=3 read "10" rather than "10.0".
|
||||
fn as_g(&self, prec: Option<usize>, out: &mut String) {
|
||||
let Arg::F(x) = self else {
|
||||
return self.as_s(out);
|
||||
};
|
||||
let p = prec.unwrap_or(6).max(1);
|
||||
if *x == 0.0 {
|
||||
out.push('0');
|
||||
return;
|
||||
}
|
||||
// Round first, then read the exponent off the result — the standard's own order.
|
||||
let sci = format!("{:.*e}", p - 1, x);
|
||||
let (mantissa, exp) = sci.split_once('e').unwrap_or((sci.as_str(), "0"));
|
||||
let exp: i32 = exp.parse().unwrap_or(0);
|
||||
if exp < -4 || exp >= p as i32 {
|
||||
// `%e`: C writes at least two exponent digits and always a sign; Rust writes neither.
|
||||
out.push_str(trim_zeros(mantissa));
|
||||
let _ = write!(out, "e{}{:02}", if exp < 0 { '-' } else { '+' }, exp.abs());
|
||||
} else {
|
||||
let decimals = (p as i32 - 1 - exp).max(0) as usize;
|
||||
out.push_str(trim_zeros(&format!("{x:.decimals$}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a fraction's trailing zeros, and the point with them if nothing survives — `%g`'s last
|
||||
/// step, and the reason "1.50" prints as "1.5" and "2.00" as "2".
|
||||
fn trim_zeros(s: &str) -> &str {
|
||||
match s.contains('.') {
|
||||
true => s.trim_end_matches('0').trim_end_matches('.'),
|
||||
false => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed `%…` specifier: where it ends, which argument it names, how precise, and what
|
||||
/// conversion. Split out because the scan now has four optional parts before the conversion
|
||||
/// letter and inlining it made the fill loop unreadable.
|
||||
struct Spec {
|
||||
/// 1-based `%N$` argument, when the specifier is positional.
|
||||
positional: Option<usize>,
|
||||
/// `.N`, when the specifier carries one.
|
||||
precision: Option<usize>,
|
||||
conv: char,
|
||||
/// Index just past the conversion letter.
|
||||
end: usize,
|
||||
}
|
||||
|
||||
/// Scan `[N$][flags][width][.prec]conv` starting just after a `%`. `None` = not a conversion this
|
||||
/// module knows, which the caller copies through untouched.
|
||||
fn parse_spec(chars: &[char], after_percent: usize) -> Option<Spec> {
|
||||
let mut i = after_percent;
|
||||
let digits = |i: &mut usize| {
|
||||
let start = *i;
|
||||
while chars.get(*i).is_some_and(char::is_ascii_digit) {
|
||||
*i += 1;
|
||||
}
|
||||
chars[start..*i].iter().collect::<String>()
|
||||
};
|
||||
// `%N$…` — positional. Bare digits without the `$` are a width, so this only commits when the
|
||||
// `$` is actually there.
|
||||
let mut positional = None;
|
||||
let mut probe = i;
|
||||
let n = digits(&mut probe);
|
||||
if !n.is_empty() && chars.get(probe) == Some(&'$') {
|
||||
positional = n.parse::<usize>().ok();
|
||||
i = probe + 1;
|
||||
}
|
||||
while matches!(chars.get(i), Some('-' | '+' | ' ' | '#' | '0')) {
|
||||
i += 1;
|
||||
}
|
||||
let _width = digits(&mut i);
|
||||
let precision = (chars.get(i) == Some(&'.')).then(|| {
|
||||
i += 1;
|
||||
digits(&mut i).parse::<usize>().unwrap_or(0)
|
||||
});
|
||||
let conv = *chars.get(i)?;
|
||||
matches!(conv, 's' | 'd' | 'c' | 'f' | 'g').then_some(Spec {
|
||||
positional,
|
||||
precision,
|
||||
conv,
|
||||
end: i + 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// **The other half of decision 2045**: a reference string off the VM's own globals, `None` when
|
||||
/// the key is absent **or empty**. `load_global_strings` runs the player's own
|
||||
/// `GlobalStrings.lua` — and the `Localize()` patch over it — into this VM at boot, so this is
|
||||
/// the whole of "read the sentence from the install".
|
||||
///
|
||||
/// **Empty counts as missing, and that is the reference's own test, not a convenience.**
|
||||
/// `FrameScript_GetText` hands back a pre-seeded empty string (`0x882748`) for a key the table
|
||||
/// does not carry, and the callers that care test both — `GetPVPRankInfo` checks the pointer at
|
||||
/// `0x51aa1c` and then its first byte at `0x51aa20`. A caller here gets `None` for both, and the
|
||||
/// house rule is that **`None` emits no line at all rather than an invented one** (the
|
||||
/// disposition [`crate::script`]'s `duration_text` took, and 0620 §5 before it).
|
||||
///
|
||||
/// Spelled `globals().get::<String>` deliberately: that is the shape
|
||||
/// `benilla-app/tests/reference_strings.rs` recognises as a key lookup, so a function that
|
||||
/// resolves through this is visible to the tripwire as resolving.
|
||||
pub fn global(lua: &mlua::Lua, key: &str) -> Option<String> {
|
||||
// Bound before the filter so the lookup stays one unbroken `globals().get::<String>` — a
|
||||
// rustfmt-split chain is a resolver the tripwire's substring match cannot see.
|
||||
let value = lua.globals().get::<String>(key).ok();
|
||||
value.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// **`GetText(token, nil, ordinal)`'s plural pick**, resolved: the bare token for exactly one, the
|
||||
/// `_P1` twin for anything else — **zero included** — and `GetText`'s own fall-back to the bare
|
||||
/// token when the twin is absent.
|
||||
///
|
||||
/// **Byte-pinned, and the `> 1` reading is refuted.** `0x52fa50` snprintf's the bare key, then
|
||||
/// `0x703bf0` looks up the Lua global `GetText` and pcalls it with `(key, ordinal, gender)`; the
|
||||
/// predicate is `LocaleProperties.lua`'s `GetPluralIndex`, singular iff `not ordinal or
|
||||
/// ordinal == 1`, and `703c89: 7d 07` is a **signed** test — a zero ordinal is pushed as the
|
||||
/// number `0`, which Lua reads as truthy, so it takes the plural arm. Only a negative becomes
|
||||
/// nil. Had zero mapped to nil, a lapsing aura would read "0 second remaining".
|
||||
///
|
||||
/// Lives here, beside [`fill`], because it is the second half of the same job and it had grown
|
||||
/// **four** private copies that had to agree and could not be made to: the aura ladder's, the
|
||||
/// instance lockouts', the talent pane's, and one inlined in the death screen's sickness timer.
|
||||
/// That is the shape decision 2045 wrote about — the first `fill` had eight.
|
||||
///
|
||||
/// `None` = neither the twin nor the bare token resolves, which is the reference's
|
||||
/// data-suppression face and means **no line at all** rather than an invented one.
|
||||
pub fn plural(
|
||||
token: &str,
|
||||
ordinal: Option<u32>,
|
||||
get: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
// `not ordinal or ordinal == 1` is the SINGULAR arm, so an absent ordinal takes the bare
|
||||
// token — the same `GetText(token)` short arm a nil `genderTag` sends the reference down.
|
||||
if ordinal.is_some_and(|n| n != 1) {
|
||||
if let Some(twin) = get(&format!("{token}_P1")).filter(|s| !s.is_empty()) {
|
||||
return Some(twin);
|
||||
}
|
||||
}
|
||||
get(token).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Fill a reference template. See the module doc for the rules.
|
||||
@@ -111,62 +312,34 @@ pub fn fill(template: &str, args: &[Arg<'_>]) -> String {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// `%N$s` / `%N$d` — positional, does not move the cursor
|
||||
let mut j = i + 1;
|
||||
let mut digits = String::new();
|
||||
while j < chars.len() && chars[j].is_ascii_digit() {
|
||||
digits.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
if !digits.is_empty() && chars.get(j) == Some(&'$') {
|
||||
let spec = chars.get(j + 1).copied();
|
||||
let idx = digits.parse::<usize>().unwrap_or(0);
|
||||
if matches!(spec, Some('s') | Some('d')) {
|
||||
match idx.checked_sub(1).and_then(|k| args.get(k)) {
|
||||
Some(a) => {
|
||||
if spec == Some('s') {
|
||||
a.as_s(&mut out);
|
||||
} else {
|
||||
a.as_d(&mut out);
|
||||
}
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
// starved: copy the whole specifier through
|
||||
None => {
|
||||
out.extend(&chars[i..j + 2]);
|
||||
i = j + 2;
|
||||
continue;
|
||||
}
|
||||
let Some(spec) = parse_spec(&chars, i + 1) else {
|
||||
// Not a conversion we know — copy the `%` and rescan from the next char, so `50% off`
|
||||
// survives intact.
|
||||
out.push('%');
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
// A positional names its argument outright and does NOT move the sequential cursor.
|
||||
let idx = match spec.positional {
|
||||
Some(n) => n.checked_sub(1),
|
||||
None => Some(next),
|
||||
};
|
||||
match idx.and_then(|k| args.get(k)) {
|
||||
Some(a) => {
|
||||
match spec.conv {
|
||||
's' | 'c' => a.as_s(&mut out),
|
||||
'd' => a.as_d(&mut out),
|
||||
'f' => a.as_f(spec.precision, &mut out),
|
||||
_ => a.as_g(spec.precision, &mut out),
|
||||
}
|
||||
if spec.positional.is_none() {
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
// starved — copy the whole specifier through so a mis-modelled template looks wrong
|
||||
None => out.extend(&chars[i..spec.end]),
|
||||
}
|
||||
// plain `%s` / `%d`
|
||||
match chars.get(i + 1) {
|
||||
Some('s') | Some('d') => {
|
||||
let spec = chars[i + 1];
|
||||
match args.get(next) {
|
||||
Some(a) => {
|
||||
if spec == 's' {
|
||||
a.as_s(&mut out);
|
||||
} else {
|
||||
a.as_d(&mut out);
|
||||
}
|
||||
next += 1;
|
||||
i += 2;
|
||||
}
|
||||
None => {
|
||||
// starved — copy it through so a mis-modelled template looks wrong
|
||||
out.push('%');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
i = spec.end;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -188,6 +361,71 @@ mod tests {
|
||||
assert_eq!(fill("no fills", &[Arg::D(7)]), "no fills");
|
||||
}
|
||||
|
||||
/// **`%.3g` is significant digits, and the precision is load-bearing.** Five 1.12 templates
|
||||
/// carry it, all of them fed by a division: `SPELL_RECAST_TIME_MIN = "%.3g min cooldown"`
|
||||
/// against a 100-second cooldown is 1.666… minutes. Rust's shortest round-trip `{}` — the
|
||||
/// obvious stand-in for `%g`, and what this filler shipped with for a day — writes all
|
||||
/// seventeen digits of that. C writes three significant ones.
|
||||
#[test]
|
||||
fn g_is_significant_digits_and_drops_trailing_zeros() {
|
||||
let g = |t: &str, v: f64| fill(t, &[Arg::F(v)]);
|
||||
assert_eq!(g("%.3g min cooldown", 100.0 / 60.0), "1.67 min cooldown");
|
||||
assert_eq!(g("%.3g sec cast", 1.5), "1.5 sec cast");
|
||||
// Trailing zeros go, and the point with them — 2.00 is "2", not "2.00".
|
||||
assert_eq!(g("%.3g sec cast", 2.0), "2 sec cast");
|
||||
// The exponent is taken AFTER the rounding, which is what makes this "10" and not "10.0".
|
||||
assert_eq!(g("%.3g", 9.999), "10");
|
||||
// A bare `%g` is C's default precision of six.
|
||||
assert_eq!(g("%g", 1.0 / 3.0), "0.333333");
|
||||
// Outside `P > X >= -4` the style is `%e`, with C's signed two-digit exponent.
|
||||
assert_eq!(g("%.3g", 0.000_012_345), "1.23e-05");
|
||||
assert_eq!(g("%.3g", 123_456.0), "1.23e+05");
|
||||
}
|
||||
|
||||
/// `%f` takes the template's precision, not the caller's — `DPS_TEMPLATE` is one decimal
|
||||
/// because the shipped string says `%.1f`, and a locale that respells it gets two for free.
|
||||
#[test]
|
||||
fn f_takes_the_templates_precision() {
|
||||
assert_eq!(
|
||||
fill("(%.1f damage per second)", &[Arg::F(41.25)]),
|
||||
"(41.2 damage per second)"
|
||||
);
|
||||
assert_eq!(fill("%.2f", &[Arg::F(41.25)]), "41.25");
|
||||
}
|
||||
|
||||
/// **The plural pick's zero, which is the half people get wrong.** `GetText`'s predicate is
|
||||
/// `not ordinal or ordinal == 1` — so an ABSENT ordinal is singular, and a zero one is
|
||||
/// **plural**, because `703c89` is a signed test and Lua reads the number `0` as truthy. The
|
||||
/// tempting `> 1` reading makes a lapsing aura read "0 second remaining".
|
||||
#[test]
|
||||
fn plural_takes_the_twin_for_zero_and_the_bare_token_for_none() {
|
||||
let table = |key: &str| -> Option<String> {
|
||||
// Deliberately not the shipped wording: which key is reached is the whole question.
|
||||
match key {
|
||||
"T" => Some("<one>".into()),
|
||||
"T_P1" => Some("<many>".into()),
|
||||
"ONLY" => Some("<only>".into()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
assert_eq!(plural("T", Some(1), &table).as_deref(), Some("<one>"));
|
||||
assert_eq!(plural("T", Some(2), &table).as_deref(), Some("<many>"));
|
||||
assert_eq!(
|
||||
plural("T", Some(0), &table).as_deref(),
|
||||
Some("<many>"),
|
||||
"zero is plural — the signed test, not `> 1`"
|
||||
);
|
||||
assert_eq!(
|
||||
plural("T", None, &table).as_deref(),
|
||||
Some("<one>"),
|
||||
"no ordinal at all takes GetText's short arm"
|
||||
);
|
||||
// A token with no `_P1` twin falls back to itself, at any count.
|
||||
assert_eq!(plural("ONLY", Some(7), &table).as_deref(), Some("<only>"));
|
||||
// Neither resolving is no line at all, never an invented one.
|
||||
assert_eq!(plural("ABSENT", Some(2), &table), None);
|
||||
}
|
||||
|
||||
/// **The case an ordered filler cannot express**, and the reason positional specifiers exist:
|
||||
/// 1.12's duel pair reorders the same two names, so filling them left-to-right names the wrong
|
||||
/// winner. Both templates are quoted from GlobalStrings (958/959).
|
||||
@@ -234,4 +472,42 @@ mod tests {
|
||||
assert_eq!(fill("%s/%d", &[Arg::D(3), Arg::D(5)]), "3/5");
|
||||
assert_eq!(fill("%d", &[Arg::S("many")]), "many");
|
||||
}
|
||||
|
||||
/// The item tooltip's own two exotic holes, quoted from GlobalStrings (2416, 2441, 942): a
|
||||
/// `%c` sign character and a `%.1f` rate. Both consume an argument like any other hole — the
|
||||
/// bug they replace is a filler that copied them through and showed the player `"%c+5"`.
|
||||
#[test]
|
||||
fn the_sign_and_rate_holes_the_item_builder_uses() {
|
||||
assert_eq!(
|
||||
fill("%c%d Agility", &[Arg::S("+"), Arg::D(9)]),
|
||||
"+9 Agility"
|
||||
);
|
||||
assert_eq!(
|
||||
fill(
|
||||
"%c%d %s Resistance",
|
||||
&[Arg::S("-"), Arg::D(10), Arg::S("Frost")]
|
||||
),
|
||||
"-10 Frost Resistance"
|
||||
);
|
||||
assert_eq!(
|
||||
fill("(%.1f damage per second)", &[Arg::F(24.428_571)]),
|
||||
"(24.4 damage per second)"
|
||||
);
|
||||
// The PRECISION is the template's: respelling it changes the line with no code change.
|
||||
assert_eq!(fill("%.3f", &[Arg::F(1.5)]), "1.500");
|
||||
assert_eq!(fill("%f", &[Arg::F(1.5)]), "1.500000", "C's default is 6");
|
||||
// `%g` drops the trailing zeros the ammo templates would otherwise show.
|
||||
assert_eq!(fill("Adds %g damage", &[Arg::F(7.0)]), "Adds 7 damage");
|
||||
assert_eq!(fill("Adds %g damage", &[Arg::F(7.5)]), "Adds 7.5 damage");
|
||||
}
|
||||
|
||||
/// A width or flag between the `%` and the letter is skipped rather than breaking the scan,
|
||||
/// and a starved `%.1f` still copies through whole.
|
||||
#[test]
|
||||
fn widths_and_flags_do_not_derail_the_scan() {
|
||||
assert_eq!(fill("%5d|%-3s", &[Arg::D(7), Arg::S("x")]), "7|x");
|
||||
assert_eq!(fill("%.1f and %.1f", &[Arg::F(1.0)]), "1.0 and %.1f");
|
||||
// A bare `%` before a digit that never reaches a conversion letter is still just a `%`.
|
||||
assert_eq!(fill("100%1 of it", &[Arg::D(3)]), "100%1 of it");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user