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:
Sam
2026-09-08 16:19:46 +07:00
co-authored by Claude Fable 5.1
parent 520b6dc2aa
commit 663851b17d
54 changed files with 3874 additions and 1958 deletions
+36 -14
View File
@@ -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
+16 -21
View File
@@ -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`
+17 -11
View File
@@ -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.
+10 -4
View File
@@ -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);
}
+37 -34
View File
@@ -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,
+21 -13
View File
@@ -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();
+83 -36
View File
@@ -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
+44 -9
View File
@@ -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)"
+1 -1
View File
@@ -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"
);
}
+211 -154
View File
@@ -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),
);
}
}
+98 -96
View File
@@ -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)
}
+47 -23
View File
@@ -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)
},
),
+258 -91
View File
@@ -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"
);
}
+2
View File
@@ -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
+164 -63
View File
@@ -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"
);
}
}
}
+31 -31
View File
@@ -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(),
+78 -34
View File
@@ -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"),
+2 -2
View File
@@ -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.
+126 -62
View File
@@ -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"
);
}
}
+15 -10
View File
@@ -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
+115 -183
View File
@@ -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"
);
}
+7 -3
View File
@@ -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
}
}
+208 -69
View File
@@ -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 ──────────────────────────────────────────────────────────────────────────────
+20
View File
@@ -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)]
+150 -88
View File
@@ -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"
);
}
+123 -77
View File
@@ -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);
}
}
+58 -43
View File
@@ -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)])),
);
}
}
}
+159 -82
View File
@@ -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(),
}
}
}
+13 -7
View File
@@ -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))
+52 -18
View File
@@ -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
+45 -5
View File
@@ -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");
}
}
+104 -48
View File
@@ -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();
+122 -69
View File
@@ -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)> {
+123 -15
View File
@@ -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),
+13 -1
View File
@@ -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,
+26 -9
View File
@@ -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();
+17 -32
View File
@@ -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);
}
}
+21 -13
View File
@@ -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);
+49 -28
View File
@@ -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 -28
View File
@@ -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
+16 -4
View File
@@ -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
+334 -58
View File
@@ -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");
}
}