Files
benilla-octo/crates/benilla-ui/src/strings.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

393 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}