diff --git a/crates/benilla-app/src/ui_chat/combat.rs b/crates/benilla-app/src/ui_chat/combat.rs index d8706921..88ddadbf 100644 --- a/crates/benilla-app/src/ui_chat/combat.rs +++ b/crates/benilla-app/src/ui_chat/combat.rs @@ -723,31 +723,47 @@ enum Arg<'a> { Num(i64), } -/// `vsnprintf` over the `%s`/`%d` subset the combat-log templates use; `None` on any mismatch. +/// The line's formatter over the `%s`/`%d` subset the combat-log templates use. `0x626850` formats +/// through `SStrVPrintf 0x64a8b0`, which takes Storm's ordered printf (`0x6697d0`) once option +/// `0xa` is set, as the client does at start (`0x402141`, `0x64ede9`); that printf reads `%N$` +/// (`0x669af2`), so a numbered hole (`%2$d`, the deDE templates') names its argument in the list +/// enUS fills in sequence (`Ihr trefft %1$s. Schaden: %2$d.`). `None` on any mismatch, an unused +/// argument included. fn fill(template: &str, args: &[Arg<'_>]) -> Option { let mut out = String::with_capacity(template.len() + 32); - let mut next = args.iter(); + let mut used = vec![false; args.len()]; + let mut cursor = 0; let mut chars = template.chars().peekable(); while let Some(c) = chars.next() { if c != '%' { out.push(c); continue; } - match chars.next() { - Some('%') => out.push('%'), - Some('s') => match next.next()? { - Arg::Str(s) => out.push_str(s), - Arg::Num(_) => return None, - }, - Some('d') => match next.next()? { - Arg::Num(n) => out.push_str(&n.to_string()), - Arg::Str(_) => return None, - }, + if chars.next_if_eq(&'%').is_some() { + out.push('%'); + continue; + } + let mut number = String::new(); + while let Some(d) = chars.next_if(char::is_ascii_digit) { + number.push(d); + } + let index = if number.is_empty() { + cursor += 1; + cursor - 1 + } else { + chars.next_if_eq(&'$')?; + number.parse::().ok()?.checked_sub(1)? + }; + let arg = args.get(index)?; + used[index] = true; + match (chars.next(), arg) { + (Some('s'), Arg::Str(s)) => out.push_str(s), + (Some('d'), Arg::Num(n)) => out.push_str(&n.to_string()), // Any other conversion is a mismatch, never a half-filled sentence. _ => return None, } } - next.next().is_none().then_some(out) + used.iter().all(|&u| u).then_some(out) } /// A GlobalString off the VM's globals, `None` when absent or empty: the two tests `0x703bf0`'s diff --git a/crates/benilla-app/src/ui_chat/combat/tests.rs b/crates/benilla-app/src/ui_chat/combat/tests.rs index b4e2b205..dad042cf 100644 --- a/crates/benilla-app/src/ui_chat/combat/tests.rs +++ b/crates/benilla-app/src/ui_chat/combat/tests.rs @@ -3,24 +3,72 @@ use super::*; -/// A template's `%s`/`%d` conversions in order; two templates with the same one take the same -/// arguments. +/// A template's `%s`/`%d` conversions by argument, a numbered hole (`%2$d`, deDE's) at its number; +/// two templates with the same one take the same arguments. fn signature(template: &str) -> Vec { let mut out = Vec::new(); + let mut cursor = 0; let mut chars = template.chars().peekable(); while let Some(c) = chars.next() { - if c != '%' { + if c != '%' || chars.next_if_eq(&'%').is_some() { continue; } - match chars.next() { - Some('%') => {} - Some(k) => out.push(k), - None => {} + let mut number = String::new(); + while let Some(d) = chars.next_if(char::is_ascii_digit) { + number.push(d); + } + let index = if number.is_empty() { + cursor += 1; + cursor - 1 + } else { + chars.next_if_eq(&'$'); + number + .parse::() + .map_or(usize::MAX, |n| n.saturating_sub(1)) + }; + let Some(k) = chars.next() else { break }; + if index >= out.len() { + out.resize(index.saturating_add(1).min(64), '?'); + } + if let Some(slot) = out.get_mut(index) { + *slot = k; } } out } +/// deDE numbers its holes in the argument order enUS fills in sequence, so both read one list; a +/// hole past the list, a wrong type or an unused argument stays a mismatch. +#[test] +fn numbered_holes_fill_from_the_same_argument_list() { + let hit = [Arg::Str("Ragnaros"), Arg::Num(1000)]; + assert_eq!( + fill("You hit %s for %d.", &hit).as_deref(), + Some("You hit Ragnaros for 1000.") + ); + assert_eq!( + fill("Ihr trefft %1$s. Schaden: %2$d.", &hit).as_deref(), + Some("Ihr trefft Ragnaros. Schaden: 1000.") + ); + let reflect = [Arg::Num(50), Arg::Str("Feuer"), Arg::Str("Ragnaros")]; + assert_eq!( + fill("Ihr reflektiert %1$d %2$sschaden auf %3$s.", &reflect).as_deref(), + Some("Ihr reflektiert 50 Feuerschaden auf Ragnaros.") + ); + assert_eq!( + fill("%2$s von %1$s", &[Arg::Str("A"), Arg::Str("B")]).as_deref(), + Some("B von A") + ); + assert_eq!( + fill("100%% %1$s", &[Arg::Str("x")]).as_deref(), + Some("100% x") + ); + assert_eq!(fill("%3$s", &[Arg::Str("a")]), None); + assert_eq!(fill("%1$d", &[Arg::Str("a")]), None); + assert_eq!(fill("%1$s", &[Arg::Str("a"), Arg::Num(1)]), None); + assert_eq!(fill("%0$s", &[Arg::Str("a")]), None); +} + /// The signature a family's declared slots give for one variant; `Attacker` and `Victim` count /// only where the variant names them. fn declared_signature(family: Family, variant: Variant) -> Vec {