//! NPC-text macros — the client-side `$`-token expansion the 1.12 wire leaves undone in every //! server-authored text the player reads: gossip greetings, questgiver panel texts, and the quest //! log's description/objectives (the director's screenshot showed a literal `$N` in a quest //! description). One shared mechanism (the 0109 look fix promoted it out of `ui_gossip`); every feed //! seam that pushes NPC text into the VM runs it — as the reference does, routing all fourteen of //! its call sites through the one expander. //! //! The grammar is the reference's, carved at the bytes (wow-re `QuestTextParser.cpp`, driver //! `0x506f70` → token handler `0x5070a0`): //! //! - the accepted set is **exactly** `B C E G N R T W`, in either case. Anything else re-emits the //! `$` and lets the letter fall through as ordinary literal text; //! - an optional decimal prefix is scanned and consumed ahead of *every* token, but only `W`/`E` //! read it; //! - **case is the output switch, not a separate token**: `$R` is the race string verbatim //! (`"Night Elf"`), `$r` is that same string through `_strlwr` (`"night elf"`). Same for //! `$C`/`$c` and `$T`/`$t`; `$N`/`$B`/`$G` are case-insensitive. //! //! The reference's *other* `$`-expander — spell descriptions, with its `$/N;` scale prefixes and //! `$` cross-references — is a different function over a different source. Ours is //! [`benilla_formats::substitute`], kept separate here exactly as it is there. use bevy::prelude::*; use crate::names::NameCache; use crate::net::{Guid, NetCommands, ObjectStore, SelfPlayer}; /// The unit a `$`-macro expands against. Every seam we have passes the **self player** (the /// reference resolves the subject from the GUID its call site hands over, and only its four chat /// sites pass the speaker instead), so the reference's non-player arm — where `$R`/`$C` emit the /// unit's *name* in place of a race/class — is not reachable from here. pub(crate) struct Subject { pub name: String, /// `UNIT_FIELD_BYTES_0` byte 0 / byte 1 — indices into the `ChrRaces`/`ChrClasses` tables the /// reference reads by locale column. Ours are [`crate::ui_unit`]'s hardcoded English rows, which /// were checked character-for-character against the shipped DBCs. pub race: u8, pub class: u8, /// `UNIT_FIELD_BYTES_0` byte 2. The `$G`/`$T` branch test is `== 0` → the *first* arm, anything /// else → the second — the reference compares against zero, so this is not "1 = female". pub gender: u8, } /// Everything a `$`-token can read: the [`Subject`] the person-tokens expand against, and the /// world-state table `$w`/`$e` index. The reference reads the latter from a process global /// (`[0xb71ec8]`); we hold it as a resource, so the expander takes it explicitly — the same shape /// the sibling spell expander already uses (`benilla_formats::TokenContext`). pub(crate) struct MacroContext<'a> { /// `None` is the reference's no-subject case — see [`substitute`]. pub subject: Option<&'a Subject>, pub states: &'a crate::world_state::WorldStates, } /// Substitute the macros in `text` against `ctx` (see the module doc for the grammar). A `None` /// subject is the reference's no-subject case: every token that needs one fails, which re-emits the /// `$` and leaves the rest of the text literal — so an un-landed player name shows `$N` for the /// moment rather than a hole, and the feeds re-substitute when it arrives. pub(crate) fn substitute(text: &str, ctx: &MacroContext) -> String { let subject = ctx.subject; let chars: Vec = text.chars().collect(); let mut out = String::with_capacity(text.len()); let mut i = 0; while i < chars.len() { if chars[i] != '$' { out.push(chars[i]); i += 1; continue; } // The decimal prefix is consumed ahead of the token letter whatever the token turns out to // be — so `$5N` is just `$N`, and an unaccepted `$5X` loses the digits on its way out. let mut j = i + 1; while chars.get(j).is_some_and(char::is_ascii_digit) { j += 1; } // `fail` is the reference's `false` return: the `$` comes back and the cursor stays on the // letter, which the literal path then copies. let mut fail = false; match chars.get(j).copied() { // Exactly one `\n` (no CR) — the text renderer splits on it. Needs no subject. Some('B' | 'b') => { out.push('\n'); i = j + 1; } // `$W` / `$E` — the world-state table ([`crate::world_state`]), filled by // SMSG_INIT_WORLD_STATES (`0x2C2`) and SMSG_UPDATE_WORLD_STATE (`0x2C3`); `$…E` reads // that same table at the *negated* key, and both render `%d`. A miss prints `"0"` — // which is every lookup until a zone actually sends states, and is what the reference // prints then too. Some(tok @ ('W' | 'w' | 'E' | 'e')) => { // `SStrToInt` over the prefix. No digits at all reads an uninitialized buffer in // the reference (undefined); we take it as key 0, and an id too wide for the // dword the wire carries goes the same way rather than wrapping to a live key. let n: u32 = chars[i + 1..j] .iter() .collect::() .parse() .unwrap_or(0); let key = if matches!(tok, 'E' | 'e') { n.wrapping_neg() } else { n }; out.push_str(&ctx.states.get(key).to_string()); i = j + 1; } Some('N' | 'n') => match subject { Some(s) => { out.push_str(&s.name); i = j + 1; } None => fail = true, }, Some(tok @ ('R' | 'r' | 'C' | 'c')) => match subject { Some(s) => { // An id outside the table is where the reference dereferences near-NULL and // faults outright; we emit nothing. let name = if matches!(tok, 'R' | 'r') { crate::ui_unit::race_names(s.race).map_or("", |(display, _)| display) } else { crate::ui_unit::class_names(s.class).map_or("", |(display, _)| display) }; if tok.is_ascii_lowercase() { out.push_str(&name.to_ascii_lowercase()); } else { out.push_str(name); } i = j + 1; } None => fail = true, }, // `$T`/`$t` is the PvP rank title, with this same gender branch as its fallback when the // `PVP_RANK__` GlobalString misses — which it always does here, since we // ship no rank titles. So the two tokens share one path, and `$t` lower-cases nothing: // the reference never lower-cases the fallback text either. Some('G' | 'g' | 'T' | 't') => match subject { Some(s) => { let mut k = j + 1; while chars.get(k) == Some(&' ') { k += 1; } match parse_branch(&chars, k) { Some((first, second, end)) => { out.extend(if s.gender == 0 { first } else { second }); i = end; } // A malformed branch swallows the marker and the spaces after it and emits // nothing at all — its argument survives as plain text, so `$G male female;` // renders `male female;`. (We used to pass the whole token through; the // bytes say the marker is consumed.) None => i = k, } } None => fail = true, }, // Not in `B C E G N R T W`, or the string ended on the `$`. _ => fail = true, } if fail { out.push('$'); i = j; } } out } /// Parse a `$G`/`$T` branch body `first:second;` starting at `start` (already past the marker and /// the spaces behind it), returning the two arms and the index just past the terminating `;`. /// `None` — malformed — when there is no `:` before the `;`, or no `;` at all. fn parse_branch(chars: &[char], start: usize) -> Option<(&[char], &[char], usize)> { let colon = (start..chars.len()).find(|&j| chars[j] == ':' || chars[j] == ';')?; if chars[colon] != ':' { return None; } let semi = (colon + 1..chars.len()).find(|&j| chars[j] == ';')?; Some(( trim_spaces(&chars[start..colon]), trim_spaces(&chars[colon + 1..semi]), semi + 1, )) } /// Drop the spaces the reference's branch copy drops: the leading run (after the marker and after /// the `:`) and the trailing one. Only `' '` — its skip loops test that byte, not whitespace. fn trim_spaces(arm: &[char]) -> &[char] { let start = arm.iter().position(|&c| c != ' ').unwrap_or(arm.len()); let end = arm.iter().rposition(|&c| c != ' ').map_or(start, |p| p + 1); &arm[start..end] } /// The self player as a macro [`Subject`] — the name from the [`NameCache`] (a miss queries the /// server once, like the unit frames), race/class/gender from the descriptor. `None` until the /// player is streamed *and* its name has landed; the feeds diff on the substituted text, so it /// re-substitutes when that happens. pub(crate) fn player_identity( self_q: &Query<(&ObjectStore, &Guid), With>, names: &mut NameCache, commands: &NetCommands, ) -> Option { let (store, guid) = self_q.iter().next()?; Some(Subject { name: names.resolve(guid.0, commands)?.to_string(), race: store.0.unit_race().unwrap_or(0), class: store.0.unit_class().unwrap_or(0), gender: store.0.unit_gender().unwrap_or(0), }) } #[cfg(test)] mod tests { use super::{substitute, MacroContext, Subject}; use crate::world_state::WorldStates; /// Thrall the night-elf priest — race 4 / class 5, so both table lookups are real rows. fn subject(gender: u8) -> Subject { Subject { name: "Thrall".into(), race: 4, class: 5, gender, } } /// Expand against a subject and an empty world-state table — the shape of every test whose /// concern is a person-token. fn expand(text: &str, subject: Option<&Subject>) -> String { substitute( text, &MacroContext { subject, states: &WorldStates::default(), }, ) } #[test] fn name_and_newline() { let s = subject(0); assert_eq!(expand("Greetings $N", Some(&s)), "Greetings Thrall"); assert_eq!(expand("Greetings $n", Some(&s)), "Greetings Thrall"); assert_eq!(expand("Hail,$Bfriend", Some(&s)), "Hail,\nfriend"); } #[test] fn race_and_class_follow_the_token_case() { let s = subject(0); assert_eq!(expand("A $C of $R", Some(&s)), "A Priest of Night Elf"); assert_eq!(expand("a $c of $r", Some(&s)), "a priest of night elf"); // An id outside the table emits nothing (where the reference faults). let unknown = Subject { class: 6, ..subject(0) }; assert_eq!(expand("[$C]", Some(&unknown)), "[]"); } #[test] fn gender_branches_on_zero() { assert_eq!( expand("Well met, $Glad:lass;.", Some(&subject(0))), "Well met, lad." ); assert_eq!( expand("Well met, $Glad:lass;.", Some(&subject(1))), "Well met, lass." ); // Not "1 = female": zero picks the first arm and everything else the second. assert_eq!(expand("$Glad:lass;", Some(&subject(2))), "lass"); // The spaces after the marker, after the `:`, and at each arm's end are dropped. assert_eq!( expand("Well met, $G lad : lass ;.", Some(&subject(1))), "Well met, lass." ); // `$T` has no rank title to find, so it falls back to this same branch, un-lower-cased. assert_eq!(expand("$tLad:Lass;", Some(&subject(0))), "Lad"); } #[test] fn malformed_branch_drops_the_marker_not_the_text() { let s = subject(0); assert_eq!(expand("Broken $Gbranch", Some(&s)), "Broken branch"); assert_eq!(expand("$G male female;", Some(&s)), "male female;"); assert_eq!(expand("$G male:female", Some(&s)), "male:female"); } #[test] fn world_state_tokens_read_an_empty_table() { let s = subject(0); assert_eq!(expand("$2077w gathered", Some(&s)), "0 gathered"); assert_eq!(expand("$2077e gathered", Some(&s)), "0 gathered"); // A bare `$w` is key 0 — a miss like any other. assert_eq!(expand("$w", Some(&s)), "0"); } /// The point of the table: once a zone's states land, `$w` renders the value rather than the /// standing `"0"`. `$e` reads the SAME table at the negated key, so the two tokens with the /// same digits are different lookups — and a value is rendered `%d`, sign and all. #[test] fn world_state_tokens_render_received_values() { let s = subject(0); let mut states = WorldStates::default(); states.write(&[ (2077, 12), (2077u32.wrapping_neg(), 3), (2264, -5i32 as u32), ]); let filled = |text: &str| { substitute( text, &MacroContext { subject: Some(&s), states: &states, }, ) }; assert_eq!(filled("$2077w gathered"), "12 gathered"); assert_eq!(filled("$2077e gathered"), "3 gathered"); assert_eq!(filled("$2264w"), "-5", "rendered %d, not %u"); assert_eq!(filled("$9999w"), "0", "an id the zone never sent"); // Case is not the output switch here — unlike `$R`/`$C`, `W` and `w` are one token. assert_eq!(filled("$2077W"), "12"); } #[test] fn unaccepted_tokens_keep_the_dollar() { let s = subject(0); assert_eq!(expand("A $X here", Some(&s)), "A $X here"); // The digits are consumed before the letter is judged, so they are lost with it. assert_eq!(expand("A $5X here", Some(&s)), "A $X here"); assert_eq!(expand("Cost: 5$", Some(&s)), "Cost: 5$"); } #[test] fn no_subject_leaves_the_text_literal() { assert_eq!(expand("Greetings $N", None), "Greetings $N"); assert_eq!(expand("$Glad:lass; $C", None), "$Glad:lass; $C"); // `$B` and the world-state tokens need no subject. assert_eq!(expand("Hail,$Bfriend $w", None), "Hail,\nfriend 0"); } }