The combat log fills deDE's numbered holes, so a German client prints its fights (#1994)

- The combat log fills deDE's numbered holes, so a German client prints its fights
- The fill's comment cites the client's ordered printf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
(cherry picked from commit b14793c294f6f33ad1c3491c99bf973011d21d28)
This commit is contained in:
andi79h-claude
2026-10-25 23:26:12 -06:00
committed by grom
parent 8f658cb516
commit 2b1e285101
2 changed files with 84 additions and 20 deletions
+29 -13
View File
@@ -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<String> {
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::<usize>().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
+55 -7
View File
@@ -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<char> {
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::<usize>()
.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<char> {