Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one. - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`. - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale. - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent. - A StatusBar fills from its internal normalised fraction, not from `GetValue()`. - The addresses of `StartDuel` and `StartDuelUnit` were swapped. - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
//! Fills the `%` holes of a `GlobalStrings.lua` or `GlueStrings.lua` template read at runtime, as
|
||
//! the client's `SStrPrintf` does: `%s`, `%d`, `%c`, `%f` and `%g` take the next argument left to
|
||
//! right, `%N$s` takes argument N without moving that cursor (`DUEL_WINNER_RETREAT` reorders its
|
||
//! two names), `%%` is `%`, and the precision is the template's. A hole with no argument, or one
|
||
//! this does not know, is copied through literally so a mis-modelled template shows.
|
||
|
||
use std::fmt::Write as _;
|
||
|
||
/// One argument to [`fill`]; any variant renders in any hole, so the template's order decides.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum Arg<'a> {
|
||
/// Text; also what `%c` takes, whose one 1.12 use is the `+`/`-` sign of `ITEM_MOD_*` and
|
||
/// `ITEM_RESIST_*`.
|
||
S(&'a str),
|
||
D(i64),
|
||
/// A real, for the rate lines (`DPS_TEMPLATE`, the `AMMO_*` damage templates).
|
||
F(f64),
|
||
}
|
||
|
||
impl<'a> From<&'a str> for Arg<'a> {
|
||
fn from(s: &'a str) -> Self {
|
||
Arg::S(s)
|
||
}
|
||
}
|
||
|
||
impl<'a> From<&'a String> for Arg<'a> {
|
||
fn from(s: &'a String) -> Self {
|
||
Arg::S(s.as_str())
|
||
}
|
||
}
|
||
|
||
macro_rules! arg_from_int {
|
||
($($t:ty),*) => {$(
|
||
impl From<$t> for Arg<'_> {
|
||
fn from(n: $t) -> Self {
|
||
Arg::D(i64::from(n))
|
||
}
|
||
}
|
||
)*};
|
||
}
|
||
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`.
|
||
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}");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn as_d(&self, out: &mut String) {
|
||
match self {
|
||
Arg::D(n) => {
|
||
let _ = write!(out, "{n}");
|
||
}
|
||
// A string in a `%d` hole is a caller bug; showing it beats showing nothing.
|
||
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 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` by C's rule, the precision P counting significant digits: with X the exponent
|
||
/// of the value in `%e` at precision P−1, use `%f` at P−1−X when P > X ≥ −4, else `%e`, then
|
||
/// drop trailing zeros. 1.12's five `%.3g` cast and cooldown templates need it: a 100-second
|
||
/// cooldown is "1.67 min", where Rust's shortest round-trip `{}` prints 17 digits.
|
||
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, so 9.999 at P=3 is "10", not "10.0".
|
||
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$}")));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `%g`'s last step: drop a fraction's trailing zeros, and the point if nothing is left.
|
||
fn trim_zeros(s: &str) -> &str {
|
||
match s.contains('.') {
|
||
true => s.trim_end_matches('0').trim_end_matches('.'),
|
||
false => s,
|
||
}
|
||
}
|
||
|
||
/// One parsed `%…` specifier.
|
||
struct Spec {
|
||
/// The 1-based `N` of `%N$`.
|
||
positional: Option<usize>,
|
||
/// `.N`.
|
||
precision: Option<usize>,
|
||
conv: char,
|
||
/// Index just past the conversion letter.
|
||
end: usize,
|
||
}
|
||
|
||
/// Scan `[N$][flags][width][.prec]conv` just after a `%`; `None` for a conversion this does not
|
||
/// know.
|
||
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>()
|
||
};
|
||
// Digits are a position only when a `$` follows; otherwise they are a width.
|
||
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,
|
||
})
|
||
}
|
||
|
||
/// A string from the VM's globals, where the install's `GlobalStrings.lua` is loaded at boot;
|
||
/// `None`, and so no line, when absent or empty. `FrameScript_GetText` returns an empty string
|
||
/// (`0x882748`) for a missing key, and callers test both (`GetPVPRankInfo`, `0x51aa1c`/`0x51aa20`).
|
||
pub fn global(lua: &mlua::Lua, key: &str) -> Option<String> {
|
||
// Bound before the filter so `globals().get::<String>` stays one unbroken substring, which is
|
||
// what `benilla-app/tests/reference_strings.rs` matches as a key lookup.
|
||
let value = lua.globals().get::<String>(key).ok();
|
||
value.filter(|s| !s.is_empty())
|
||
}
|
||
|
||
/// `GetText(token, nil, ordinal)`'s plural pick: `0x52fa50` formats the key and `0x703bf0`
|
||
/// pcalls the Lua `GetText`, where `GetPluralIndex` (`LocaleProperties.lua:56`) is singular for
|
||
/// `not ordinal or ordinal == 1`. So one or no ordinal takes the bare token, anything else the
|
||
/// `_P1` twin, falling back to the bare token. Zero is plural: the test at `0x703c89` is signed,
|
||
/// only a negative becomes nil, and the number `0` is truthy in Lua.
|
||
pub fn plural(
|
||
token: &str,
|
||
ordinal: Option<u32>,
|
||
get: &dyn Fn(&str) -> Option<String>,
|
||
) -> Option<String> {
|
||
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, by the rules in the module doc.
|
||
pub fn fill(template: &str, args: &[Arg<'_>]) -> String {
|
||
let mut out = String::with_capacity(template.len() + 16);
|
||
let chars: Vec<char> = template.chars().collect();
|
||
let mut i = 0;
|
||
let mut next = 0usize; // the sequential cursor
|
||
while i < chars.len() {
|
||
if chars[i] != '%' {
|
||
out.push(chars[i]);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if chars.get(i + 1) == Some(&'%') {
|
||
out.push('%');
|
||
i += 2;
|
||
continue;
|
||
}
|
||
let Some(spec) = parse_spec(&chars, i + 1) else {
|
||
// Unknown: copy the `%` and rescan from the next char, so `50% off` survives.
|
||
out.push('%');
|
||
i += 1;
|
||
continue;
|
||
};
|
||
// A positional 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 specifier through so a mis-modelled template shows.
|
||
None => out.extend(&chars[i..spec.end]),
|
||
}
|
||
i = spec.end;
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn ordered_fills_stop_at_the_arguments_they_have() {
|
||
assert_eq!(
|
||
fill("%s: %d/%d", &[Arg::S("MC"), Arg::D(2), Arg::D(5)]),
|
||
"MC: 2/5"
|
||
);
|
||
assert_eq!(fill("%s: %d/%d", &[Arg::S("MC"), Arg::D(2)]), "MC: 2/%d");
|
||
assert_eq!(fill("100%% sure", &[]), "100% sure");
|
||
assert_eq!(fill("no fills", &[Arg::D(7)]), "no fills");
|
||
}
|
||
|
||
#[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");
|
||
assert_eq!(g("%.3g sec cast", 2.0), "2 sec cast");
|
||
// The exponent is read after rounding.
|
||
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");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[test]
|
||
fn plural_takes_the_twin_for_zero_and_the_bare_token_for_none() {
|
||
let table = |key: &str| -> Option<String> {
|
||
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"
|
||
);
|
||
assert_eq!(plural("ONLY", Some(7), &table).as_deref(), Some("<only>"));
|
||
assert_eq!(plural("ABSENT", Some(2), &table), None);
|
||
}
|
||
|
||
/// The duel pair, `GlobalStrings.lua:958-959`.
|
||
#[test]
|
||
fn positional_specifiers_reorder_rather_than_consume() {
|
||
let (a, b) = (Arg::S("Alice"), Arg::S("Bob"));
|
||
assert_eq!(
|
||
fill("%1$s has defeated %2$s in a duel", &[a, b]),
|
||
"Alice has defeated Bob in a duel"
|
||
);
|
||
assert_eq!(
|
||
fill("%2$s has fled from %1$s in a duel", &[a, b]),
|
||
"Bob has fled from Alice in a duel"
|
||
);
|
||
assert_eq!(fill("%1$s vs %1$s", &[a]), "Alice vs Alice");
|
||
}
|
||
|
||
#[test]
|
||
fn each_hole_takes_its_own_argument() {
|
||
assert_eq!(
|
||
fill(
|
||
"%s has promoted %s to %s.",
|
||
&[Arg::S("A"), Arg::S("B"), Arg::S("Knight")]
|
||
),
|
||
"A has promoted B to Knight."
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn starved_and_unknown_specifiers_survive_visibly() {
|
||
assert_eq!(fill("%1$s and %2$s", &[Arg::S("only")]), "only and %2$s");
|
||
assert_eq!(fill("50% off", &[]), "50% off");
|
||
assert_eq!(fill("%q", &[Arg::S("x")]), "%q");
|
||
}
|
||
|
||
#[test]
|
||
fn arguments_render_for_whichever_hole_they_meet() {
|
||
assert_eq!(fill("%s/%d", &[Arg::D(3), Arg::D(5)]), "3/5");
|
||
assert_eq!(fill("%d", &[Arg::S("many")]), "many");
|
||
}
|
||
|
||
/// `ITEM_MOD_AGILITY`, `ITEM_RESIST_SINGLE` and `DPS_TEMPLATE`, `GlobalStrings.lua:2416`,
|
||
/// `:2441` and `:942`.
|
||
#[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)"
|
||
);
|
||
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");
|
||
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");
|
||
}
|
||
|
||
#[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");
|
||
// `%1 ` never reaches a conversion letter, so the `%` stays literal.
|
||
assert_eq!(fill("100%1 of it", &[Arg::D(3)]), "100%1 of it");
|
||
}
|
||
}
|