Fix Russian spell costs, player labels and character names (#6)

Spell resource costs, UnitRace/UnitClass and the character-name field were still using English-only paths after enabling ruRU. Costs now fill the selected GlobalStrings templates, the player record and live unit feeds use ChrRaces/ChrClasses display names while retaining file tokens, and typed/pasted Unicode letters reach the UTF-8 create packet. CharacterCreateNameEdit now initializes its reference limit of 12 letters; a 12-letter Cyrillic name retains all 24 UTF-8 bytes. Roster selection also matches the server's recased Cyrillic name.

Reference: 1.12 PaperDollFrame_SetLevel reads UnitRace/UnitClass; ruRU GlobalStrings supplies MANA_COST/RAGE_COST/ENERGY_COST and their PER_TIME forms; the selected ChrRaces/ChrClasses DBCs supply display names. vmangos normalizePlayerName decodes UTF-8 and recases letters: https://github.com/vmangos/core/blob/development/src/game/ObjectMgr.cpp#L60.

Original symptoms reproduced by instruments with real client data: 30 Mana, Human/Warrior in CharacterLevelText, and Cyrillic keyboard text rejected. The same instruments check Russian and adjacent English text, Human/Warrior and Tauren/Druid, Unicode typing/paste/backspace and the 12-letter cap. The create packet and normalized roster spelling have separate checks.

Validation: the original three symptoms reproduced, then the same three instruments passed with real ruRU and adjacent enUS data. Full land gates passed on the final source tree (816 s): fmt, workspace clippy, workspace tests with data and without an install, doc links, render-pass lint, player tests without dev, and engine boot with/without data. Only the 31 absent addon-corpus tests skipped on each real-data test rung. The strengthened create-packet check verifies the actual reset field, its 12-letter cap and unmodified UTF-8. Built on Windows. No live character creation or player-account login (no declared probe account).
This commit was merged in pull request #6.
This commit is contained in:
2026-10-11 17:04:58 +03:00
parent 2ef92a2032
commit d201c8e469
16 changed files with 371 additions and 55 deletions
+20 -1
View File
@@ -235,7 +235,8 @@ impl CreateSelection {
.and_then(|c| c.0.classes_for_race(1).first().copied())
.unwrap_or(1);
self.dials = [0; 5];
self.name.set_text("");
// CharacterCreate.xml declares CharacterCreateNameEdit letters="12", not a byte cap.
self.name = textinput::field(12, false);
self.creating = false;
}
@@ -616,6 +617,24 @@ fn char_result_text<'a>(strings: &'a GlueStrings, code: u8) -> &'a str {
#[cfg(test)]
mod tests {
#[test]
fn a_russian_character_name_reaches_the_wire_unchanged() {
let mut sel = super::CreateSelection::default();
sel.reset(None);
assert_eq!(sel.name.max_letters, 12);
sel.name.insert("Ёлочка");
let req = sel.request();
assert_eq!(req.name, "Ёлочка");
let body = benilla_protocol::messages::char_create(&req);
assert_eq!(&body[..req.name.len()], "Ёлочка".as_bytes());
assert_eq!(body[req.name.len()], 0);
assert_eq!(body.len(), req.name.len() + 10);
sel.name.set_text("");
sel.name.insert("Абвгдежзийклмноп");
assert_eq!(sel.request().name, "Абвгдежзийкл");
assert_eq!(sel.request().name.len(), 24);
}
/// The chain is assembled by the loader's own helper, `GlueLocalization.lua`'s `Localize()`
/// over `GlueStrings.lua`, so this asserts what the running client shows.
#[test]
+24 -10
View File
@@ -192,7 +192,7 @@ impl Roster {
if let Some(row) = self
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
.position(|c| same_character_name(&c.name, name))
{
self.select(Some(row));
self.just_created = None;
@@ -262,6 +262,13 @@ impl Roster {
/// default `"0"`, pointer at `[0x882674]`), written engine-side, named by no GlueXML.
pub(crate) const CVAR_LAST_CHARACTER: &str = "lastCharacterIndex";
/// vmangos normalizes UTF-8 player names to title case, including Cyrillic (normalizePlayerName).
fn same_character_name(a: &str, b: &str) -> bool {
a.chars()
.flat_map(char::to_lowercase)
.eq(b.chars().flat_map(char::to_lowercase))
}
/// The stored index is 0-based, `"0"` the first character: the engine's selection cell
/// `[0x83856c]` printed with `"%d"` (`0x473470` decrements the Lua's 1-based row).
/// `CVar::SaveConfig` (`0x63d980`) skips a value equal to the default, so row 0 writes no line.
@@ -374,7 +381,7 @@ fn apply_roster_policy(
match roster
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(&name))
.position(|c| same_character_name(&c.name, &name))
{
Some(row) => {
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
@@ -390,7 +397,7 @@ fn apply_roster_policy(
match roster
.chars
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&name))
.find(|c| same_character_name(&c.name, &name))
{
Some(c) => {
let guid = c.guid;
@@ -1177,13 +1184,20 @@ mod tests {
/// The name key is case-insensitive: vmangos's `normalizePlayerName` recases a created name.
#[test]
fn the_created_name_matches_the_servers_normalized_spelling() {
let mut roster = Roster {
chars: vec![character(1, "Kerwind"), character(2, "Zzbullone")],
selected: Some(0),
..default()
};
roster.note_created("ZZBULLONE".to_string()); // as typed
assert_eq!(roster.selected, Some(1));
for (typed, stored) in [
("ZZBULLONE", "Zzbullone"),
("ЁЛОЧКА", "Ёлочка"),
("ёлочка", "Ёлочка"),
] {
let mut roster = Roster {
chars: vec![character(1, "Kerwind"), character(2, stored)],
selected: Some(0),
..default()
};
roster.note_created(typed.to_string());
assert_eq!(roster.selected, Some(1), "{typed} must match {stored}");
assert!(roster.just_created.is_none());
}
}
// ── The remembered character (`lastCharacterIndex`) ───────────────────────────
+1 -1
View File
@@ -215,7 +215,7 @@ mod tests {
forms: Some(&forms),
};
let word = |store: &ObjectStore| {
crate::ui_unit::snapshot(store, 0, None, 0, None, sources).creature_type_name
crate::ui_unit::snapshot(store, 0, None, 0, None, sources, None).creature_type_name
};
assert_eq!(word(&store(0, 1, 1)).as_deref(), Some("Beast"), "cat form");
assert_eq!(
+46 -2
View File
@@ -43,7 +43,7 @@ pub(crate) fn mods_now(keys: &ButtonInput<KeyCode>) -> Mods {
pub(crate) enum CharFilter {
/// Everything printable (the login boxes).
Any,
/// ASCII letters only (the character-name box).
/// Unicode letters (the character-name box); the server enforces its naming policy.
Letters,
}
@@ -51,7 +51,7 @@ impl CharFilter {
fn allows(self, c: char) -> bool {
match self {
CharFilter::Any => true,
CharFilter::Letters => c.is_ascii_alphabetic(),
CharFilter::Letters => c.is_alphabetic(),
}
}
@@ -163,6 +163,50 @@ pub(crate) fn tick_caret(field: &mut EditBoxState, focused: bool, dt: f32) -> bo
mod tests {
use super::*;
#[test]
fn ruru_name_input_and_paste_count_letters_not_utf8_bytes() {
let mut f = field(12, false);
let mut clipboard = HostClipboard::default();
for c in "Александр".chars() {
let ev = KeyboardInput {
key_code: KeyCode::KeyA,
logical_key: bevy::input::keyboard::Key::Character(c.to_string().into()),
text: Some(c.to_string().into()),
state: ButtonState::Pressed,
repeat: false,
window: Entity::PLACEHOLDER,
};
assert_eq!(
feed_key(
&mut f,
&ev,
Mods::default(),
&crate::bindings::LayoutNames::default(),
&mut clipboard,
None,
CharFilter::Letters
),
FieldKey::Consumed
);
}
assert_eq!(f.text, "Александр");
f.highlight_text(0, -1);
f.paste(&CharFilter::Letters.keep("Ёлочка123!"));
assert_eq!(f.text, "Ёлочка");
f.set_text("");
f.paste(&CharFilter::Letters.keep("Абвгдежзийклмноп"));
assert_eq!(f.text, "Абвгдежзийкл");
assert_eq!(f.text.len(), 24);
f.apply(benilla_ui::script::EditAction::Delete {
unit: benilla_ui::script::EditUnit::Char,
back: true,
});
assert_eq!(f.text, "Абвгдежзийк");
eprintln!(
"locale-name: typed=Александр pasted=Ёлочка limit=12 UTF-8 bytes=24 backspace=clean"
);
}
#[test]
fn letters_filter_applies_to_pasted_text() {
assert_eq!(CharFilter::Letters.keep("Bob123"), "Bob");
+21 -5
View File
@@ -162,7 +162,7 @@ pub(super) fn feed_party(
let area_moved = fed.area.moved(here.area().map_or(u64::MAX, u64::from));
let group_changed = group.is_changed();
let index_changed = index.is_changed();
let look = pets::Lookup::new(&index, &stores, &names, types);
let look = pets::Lookup::new(&index, &stores, &names, types, tables.names());
// Only the stores the merged view reads: a crowd's other stores change every frame. The
// members' pets are the group's too, `partypetN` and `raidpetN` reading their descriptors.
let stores_changed = self_q
@@ -325,6 +325,7 @@ pub(super) fn feed_party(
own_group.clone(),
chr,
types,
tables.names(),
)
});
if fed.units[i] != snap {
@@ -393,7 +394,8 @@ pub(super) fn feed_party(
if Some(*guid) == self_guid {
let (_, _, store) = self_pair?;
let name = names.peek(*guid).map(str::to_string);
let mut s = crate::ui_unit::snapshot(store, *guid, name, 0, chr, types);
let mut s =
crate::ui_unit::snapshot(store, *guid, name, 0, chr, types, tables.names());
s.is_player = true;
s.raid_target = group.raid_target_index(*guid);
s.faction_group = own_group.clone();
@@ -410,6 +412,7 @@ pub(super) fn feed_party(
own_group.clone(),
chr,
types,
tables.names(),
))
}
});
@@ -752,6 +755,7 @@ fn raid_roster(
}
/// One member's merged-view unit snapshot (see [`feed_party`]).
#[allow(clippy::too_many_arguments)]
fn member_unit_state(
m: &GroupMemberEntry,
stats: Option<&PartyMemberStatsInfo>,
@@ -764,11 +768,18 @@ fn member_unit_state(
classes: Option<&benilla_formats::ChrClasses>,
// The creature type resolves from the live descriptor alone, so an unstreamed member has none.
types: CreatureTypeSources<'_>,
strings: Option<&crate::glue_strings::GlueStrings>,
) -> UnitState {
let mut s = match store {
Some(store) => {
crate::ui_unit::snapshot(store, m.guid, Some(m.name.clone()), 0, classes, types)
}
Some(store) => crate::ui_unit::snapshot(
store,
m.guid,
Some(m.name.clone()),
0,
classes,
types,
strings,
),
// Unstreamed: the roster record, snapshotted from the descriptor at despawn (`0x5f0880`),
// seated at 1/1 for a member never seen (`0x4e82d0`) and patched by the wire. The
// reference's getters read the descriptor, then the party record (`0x496400`), then the
@@ -1526,6 +1537,7 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert_eq!(
(s.health, s.max_health),
@@ -1549,6 +1561,7 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert_eq!((bare.health, bare.max_health, bare.power), (0, 0, 0));
assert!(bare.exists);
@@ -1577,6 +1590,7 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert!(
s.dead,
@@ -1596,6 +1610,7 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert!(s.ghost);
assert!(!s.dead, "a released ghost is not `dead` — only a ghost");
@@ -1613,6 +1628,7 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert!(s.dead);
}
+4 -1
View File
@@ -53,6 +53,7 @@ pub(super) struct Lookup<'a, 'w, 's, 'q> {
names: &'a NameCache,
commands: &'a NetCommands,
types: CreatureTypeSources<'a>,
strings: Option<&'a crate::glue_strings::GlueStrings>,
}
impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
@@ -61,6 +62,7 @@ impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
stores: &'a Query<'w, 's, &'q ObjectStore>,
names: &'a Names,
types: CreatureTypeSources<'a>,
strings: Option<&'a crate::glue_strings::GlueStrings>,
) -> Self {
Self {
index,
@@ -68,6 +70,7 @@ impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
names: &names.cache,
commands: &names.commands,
types,
strings,
}
}
@@ -149,7 +152,7 @@ pub(super) fn member_pet(
.names
.resolve_unit(guid, Some(store), look.commands)
.map(str::to_string);
crate::ui_unit::snapshot(store, guid, name, 0, None, look.types)
crate::ui_unit::snapshot(store, guid, name, 0, None, look.types, look.strings)
}
None => record_state(
group,
+2 -1
View File
@@ -48,6 +48,7 @@ pub(super) fn feed_pet_unit(
commands: Res<NetCommands>,
// The form table, the creature-type resolver's first stage.
spells: Option<Res<crate::ui_action::Spells>>,
strings: Option<Res<crate::glue_strings::GlueStrings>>,
mut memory: Local<crate::ui_script::VmMemo<PetUnitMemory>>,
) {
let Some(mut script) = script else {
@@ -87,7 +88,7 @@ pub(super) fn feed_pet_unit(
.map(str::to_string);
// No `ChrClasses.dbc`: the reference reads a class only for TYPEMASK_PLAYER.
let types = CreatureTypeSources::of_resources(&names, spells.as_deref());
snapshot(store, pet_guid, name, 0, None, types)
snapshot(store, pet_guid, name, 0, None, types, strings.as_deref())
});
let dirty = match (&fresh, &memory.pushed) {
@@ -133,7 +133,7 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
.collect();
s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
s.set_realm_name("Harness");
let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
let player = super::seat_from_roster(&roster(), None).expect("a pending pick seats a player");
if seat == Seat::BeforeAddons {
// A `"player"` push with a name also seeds the player record, as the entry load does, so
// `UnitName("player")` answers at addon file scope and Bagnon reads the live bags.
@@ -440,7 +440,7 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() {
/// What the roster seat puts in the VM, and `None` with no pick, so a capture loads unchanged.
#[test]
fn the_roster_seat_names_the_character_the_addons_will_meet() {
let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
let seat = super::seat_from_roster(&roster(), None).expect("a pending pick seats a player");
assert_eq!(seat.name.as_deref(), Some("Harness"));
// 0, not 60: `UnitLevel` (`0x517fc0`) has no `"player"` fast path, so with no object it pushes
// the number 0 (`0x51813e`). The level arrives with the descriptor.
@@ -461,7 +461,7 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() {
assert_eq!((seat.health, seat.max_health), (0, 0));
assert!(
super::seat_from_roster(&crate::char_select::Roster::default()).is_none(),
super::seat_from_roster(&crate::char_select::Roster::default(), None).is_none(),
"no pick, no seat — a capture must load exactly as it did before"
);
}
+31 -8
View File
@@ -343,7 +343,12 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
// streams in.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(record_from_roster)
.and_then(|roster| {
record_from_roster(
roster,
world.get_resource::<crate::glue_strings::GlueStrings>(),
)
})
{
script.set_player_record(record);
}
@@ -356,9 +361,15 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
script.set_player_xp(live.xp.0, live.xp.1);
}
None => {
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
if let Some(seat) =
world
.get_resource::<crate::char_select::Roster>()
.and_then(|roster| {
seat_from_roster(
roster,
world.get_resource::<crate::glue_strings::GlueStrings>(),
)
})
{
script.set_unit("player", Some(seat));
}
@@ -493,14 +504,25 @@ fn roster_sex(gender: u8) -> u8 {
/// the descriptor.
pub(crate) fn record_from_roster(
roster: &crate::char_select::Roster,
strings: Option<&crate::glue_strings::GlueStrings>,
) -> Option<benilla_ui::script::PlayerRecord> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
Some(benilla_ui::script::PlayerRecord {
name: row.name.clone(),
race: race.map(|(n, f)| (n.to_string(), f.to_string())),
class: class.map(|(n, f)| (n.to_string(), f.to_string())),
race: race.map(|(n, f)| {
(
strings.map_or(n, |s| s.race_name(row.race)).to_string(),
f.to_string(),
)
}),
class: class.map(|(n, f)| {
(
strings.map_or(n, |s| s.class_name(row.class)).to_string(),
f.to_string(),
)
}),
sex: roster_sex(row.gender),
})
}
@@ -512,6 +534,7 @@ pub(crate) fn record_from_roster(
/// to say.
pub(crate) fn seat_from_roster(
roster: &crate::char_select::Roster,
strings: Option<&crate::glue_strings::GlueStrings>,
) -> Option<benilla_ui::script::UnitState> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
@@ -524,9 +547,9 @@ pub(crate) fn seat_from_roster(
// `UnitLevel` (`0x517fc0`) answers the number 0 (`0x51813e push 0`); the two misses differ.
exists: false,
name: Some(row.name.clone()),
race: race.map(|(n, _)| n.to_string()),
race: race.map(|(n, _)| strings.map_or(n, |s| s.race_name(row.race)).to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class: class.map(|(n, _)| strings.map_or(n, |s| s.class_name(row.class)).to_string()),
class_file: class.map(|(_, f)| f.to_string()),
sex,
is_player: true,
@@ -70,7 +70,15 @@ fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> U
(POWER1 + slot, power),
(MAXPOWER1 + slot, max_power),
]));
crate::ui_unit::snapshot(&store, 0, Some(name.into()), 0, None, Default::default())
crate::ui_unit::snapshot(
&store,
0,
Some(name.into()),
0,
None,
Default::default(),
None,
)
}
/// Every texture path drawn this frame, with its vertex tint.
@@ -53,6 +53,90 @@ fn roster_named(name: &str, guid: u64) -> Roster {
Roster::with_pending_pick(vec![row], guid)
}
#[test]
fn ruru_player_names_reach_the_character_panel_and_live_units() {
let data = benilla_formats::wow_data_or_skip!();
if benilla_formats::ClientLocale::RuRu
.pack_folder(&data)
.is_none()
{
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
for (locale, race, class, line) in [
(
benilla_formats::ClientLocale::RuRu,
"Человек",
"Воин",
"Человек, Воин 60-го уровня",
),
(
benilla_formats::ClientLocale::EnUs,
"Human",
"Warrior",
"Level 60 Human Warrior",
),
] {
let mut chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
let strings = crate::glue_strings::table_from_chain(&mut chain);
let roster = roster_named("Александр", 7);
let record = super::lifecycle::record_from_roster(&roster, Some(&strings)).unwrap();
let mut seat = super::seat_from_roster(&roster, Some(&strings)).unwrap();
seat.level = 60;
let mut vm = benilla_ui::script::UiScript::new_with_locale(locale.code()).unwrap();
vm.set_screen_size(1024.0, 768.0);
vm.set_player_record(record);
vm.set_unit("player", Some(seat));
for file in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&vm, file);
}
vm.run("PaperDollFrame_SetLevel()").unwrap();
let shown: String = vm.eval("return CharacterLevelText:GetText()").unwrap();
eprintln!("locale-panel: {} text={shown}", locale.code());
assert_eq!(shown, line);
assert_eq!(
vm.eval::<(String, String)>("return UnitRace('player')")
.unwrap(),
(race.into(), "Human".into())
);
assert_eq!(
vm.eval::<(String, String)>("return UnitClass('player')")
.unwrap(),
(class.into(), "WARRIOR".into())
);
// The descriptor path, including a /reload seat, and a different race/class on target.
for (race_id, class_id) in [(1, 1), (6, 11)] {
let store = crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
36,
race_id | (class_id << 8),
)]));
let state = crate::ui_unit::snapshot(
&store,
8,
None,
0,
None,
Default::default(),
Some(&strings),
);
assert_eq!(
state.race.as_deref(),
Some(strings.race_name(race_id as u8))
);
assert_eq!(
state.class.as_deref(),
Some(strings.class_name(class_id as u8))
);
vm.set_unit("target", Some(state));
assert_eq!(
vm.eval::<String>("return UnitClass('target')").unwrap(),
strings.class_name(class_id as u8)
);
}
assert!(vm.take_errors().is_empty());
}
}
/// A hermetic state folder holding the probe addon, and the guards that point the client at it;
/// every guard must outlive the world.
fn hermetic_probe(tag: &str) -> (std::path::PathBuf, EnvGuard, EnvGuard) {
+1 -1
View File
@@ -138,7 +138,7 @@ fn drive_mouseover_tooltip(
let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store);
// The hovered guid, the pair `0x492890` writes to `0xb4e2c8`/`0xb4e2cc` and the token
// resolver `0x515970` reads for `"mouseover"`, so `UnitIsUnit` can match it.
let mut s = snapshot(store, guid, name, reaction, chr, types);
let mut s = snapshot(store, guid, name, reaction, chr, types, tables.names());
enrich_unit(
&mut s,
guid,
+18 -15
View File
@@ -64,8 +64,8 @@ pub(super) struct ViewCtx<'a, 'w, 's> {
/// Build one spell's tooltip view, every cell resolved here where the catalogs live, and what it
/// read ([`Deps`]). The range, cast, cooldown, form and item cells fill their `GlobalStrings.lua`
/// keys; the cost, reagents and chance cells are composed in English, where the reference fills
/// `MANA_COST` and its kin, `SPELL_REAGENTS` and `CHANCE_TO_*`.
/// keys, including the power-specific cost templates; the reagents and chance cells are composed
/// in English, where the reference fills `SPELL_REAGENTS` and `CHANCE_TO_*`.
pub(super) fn build_view(
spell_id: u32,
spells: &Spells,
@@ -154,24 +154,27 @@ pub(super) fn build_view(
let cost = {
// The shared per-type divisor (`0x6e7130`), never a local `power_type == 1` test.
let div = benilla_protocol::messages::power_display_scale(d.power_type);
let unit = match d.power_type {
0 => "Mana",
1 => "Rage",
2 => "Focus",
3 => "Energy",
4 => "Happiness",
_ => "Health",
let key = match d.power_type {
0 => "MANA_COST",
1 => "RAGE_COST",
2 => "FOCUS_COST",
3 => "ENERGY_COST",
4 => "HAPPINESS_COST",
_ => "HEALTH_COST",
};
if resolved_cost == 0 && d.mana_per_second == 0 {
None
} else if d.mana_per_second > 0 {
Some(format!(
"{} {unit}, plus {} per sec",
resolved_cost / div,
d.mana_per_second / div
))
keyed(
vctx.get,
&format!("{key}_PER_TIME"),
&[
Arg::from(resolved_cost / div),
Arg::from(d.mana_per_second / div),
],
)
} else {
Some(format!("{} {unit}", resolved_cost / div))
keyed(vctx.get, key, &[Arg::from(resolved_cost / div)])
}
};
// The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then
+62 -1
View File
@@ -33,9 +33,13 @@ type Getter = dyn Fn(&str) -> Option<String>;
impl TestCtx {
pub(super) fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
let vm = benilla_ui::script::UiScript::new().expect("VM");
crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua");
Self::with_vm(vm)
}
fn with_vm(vm: benilla_ui::script::UiScript) -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self {
items: Items::default(),
commands: NetCommands(tx),
@@ -105,6 +109,63 @@ impl TestCtx {
}
}
#[test]
fn ruru_spell_costs_use_the_packs_templates() {
let data = benilla_formats::wow_data_or_skip!();
if benilla_formats::ClientLocale::RuRu
.pack_folder(&data)
.is_none()
{
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let Some(spells) = real_spells() else { return };
let mut objs = no_objects();
let objects = objs.get();
let store = ObjectStore(unit(60, 1000));
for (locale, expected) in [
(
"ruRU",
[
"Мана: 30",
"Ярость: 15",
"Энергия: 45",
"Здоровье: 11 плюс 5/сек.",
],
),
(
"enUS",
[
"30 Mana",
"15 Rage",
"45 Energy",
"11 Health, plus 5 per sec",
],
),
] {
let vm = benilla_ui::script::UiScript::new_with_locale(locale).unwrap();
crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua");
let mut t = TestCtx::with_vm(vm);
for (id, expected) in [133, 78, 1752, 755].into_iter().zip(expected) {
let view =
spell_tooltip_view(id, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.unwrap();
eprintln!("locale-cost: {locale} spell={id} cost={:?}", view.cost);
assert_eq!(view.cost.as_deref(), Some(expected));
}
let view = spell_tooltip_view(
100,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.unwrap();
assert_eq!(
view.cost, None,
"Charge has no resource cost in either locale"
);
}
}
/// A unit's fields: level (34) and base mana (162).
fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields {
benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)])
+34 -4
View File
@@ -804,14 +804,20 @@ pub(crate) struct UnitStores<'w, 's> {
/// The client tables [`snapshot`] reads beside a descriptor, as one parameter for the feeders that
/// sit at Bevy's 16-parameter limit: `ChrClasses.dbc` for the relic slot, and the form table
/// the creature-type resolver's first stage reads. Each is absent without game data.
/// the creature-type resolver's first stage reads, plus the selected race/class display names.
/// Each is absent without game data.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct SnapshotTables<'w> {
classes: Option<Res<'w, crate::chr_classes::ChrClassTable>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
names: Option<Res<'w, crate::glue_strings::GlueStrings>>,
}
impl SnapshotTables<'_> {
pub(crate) fn names(&self) -> Option<&crate::glue_strings::GlueStrings> {
self.names.as_deref()
}
/// `ChrClasses.dbc`; without it no class has a relic slot, the reference's bounds leg.
pub(crate) fn classes(&self) -> Option<&ChrClasses> {
self.classes.as_deref().map(|t| &t.0)
@@ -845,6 +851,7 @@ pub(crate) fn snapshot(
reaction: u8,
classes: Option<&ChrClasses>,
types: CreatureTypeSources<'_>,
strings: Option<&crate::glue_strings::GlueStrings>,
) -> UnitState {
let power_type = store.0.unit_power_type();
let race = store.0.unit_race().and_then(race_names);
@@ -886,9 +893,19 @@ pub(crate) fn snapshot(
// The one resolver `0x605570` behind `UnitCreatureType` (`0x51a2bc`) and the tooltip's
// type slot (`0x52a2e5`): the form's type, else the template's, else the race's.
creature_type_name: creature_type_word(types.of(store)).map(str::to_string),
race: race.map(|(n, _)| n.to_string()),
// UnitRace/UnitClass resolve display names through the selected ChrRaces/ChrClasses;
// their file tokens remain locale independent (0x518200/0x518350).
race: race.map(|(n, _)| {
strings
.map_or(n, |s| s.race_name(store.0.unit_race().unwrap_or(0)))
.to_string()
}),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class: class.map(|(n, _)| {
strings
.map_or(n, |s| s.class_name(class_id.unwrap_or(0)))
.to_string()
}),
class_file: class.map(|(_, f)| f.to_string()),
// `UnitHasRelicSlot` (`0x519e50`): TYPEMASK_PLAYER first (`0x519e8d`), then the class
// byte against `ChrClasses.dbc` field 16; without the player test a class-2 NPC answers 1.
@@ -1210,7 +1227,15 @@ fn player_snapshot(
let name = names
.resolve_unit(guid, Some(store), commands)
.map(str::to_string);
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
let mut s = snapshot(
store,
guid,
name,
0,
tables.classes(),
tables.types(names),
tables.names(),
);
s.is_player = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions);
@@ -1385,6 +1410,7 @@ fn feed_units(
self_store: self_pair.map(|(s, _)| s),
classes: chr,
types,
strings: tables.names(),
};
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
let store = stores.all.get(e).ok()?;
@@ -2523,6 +2549,7 @@ mod tests {
0,
None,
Default::default(),
None,
)
.pvp_team
};
@@ -3239,6 +3266,7 @@ mod tests {
0,
None,
Default::default(),
None,
);
assert_eq!((alive.health, alive.max_health), (1200, 1500));
assert_eq!((alive.power, alive.max_power), (300, 900));
@@ -3253,6 +3281,7 @@ mod tests {
0,
None,
Default::default(),
None,
);
assert_eq!(
(feigning.health, feigning.max_health),
@@ -3303,6 +3332,7 @@ mod tests {
0,
None,
Default::default(),
None,
)
};
let player = snap(0x19);
+11 -1
View File
@@ -32,6 +32,7 @@ pub(super) struct HeldUnits<'a> {
pub(super) self_store: Option<&'a ObjectStore>,
pub(super) classes: Option<&'a ChrClasses>,
pub(super) types: CreatureTypeSources<'a>,
pub(super) strings: Option<&'a crate::glue_strings::GlueStrings>,
}
impl HeldUnits<'_> {
@@ -44,7 +45,15 @@ impl HeldUnits<'_> {
.resolve_unit(guid, Some(store), self.commands)
.map(str::to_string);
let reaction = unit_reaction(self.factions, self.reputations, store, self.self_store);
let mut s = snapshot(store, guid, name, reaction, self.classes, self.types);
let mut s = snapshot(
store,
guid,
name,
reaction,
self.classes,
self.types,
self.strings,
);
s.raid_target = self.group.raid_target_index(guid);
s.faction_group = faction_group(store, self.factions);
s.faction_group_localized = faction_group_localized(store, self.factions);
@@ -143,6 +152,7 @@ pub(super) fn feed_chain_units(
self_store: self_q.iter().next(),
classes: tables.classes(),
types: tables.types(&names),
strings: tables.names(),
};
let built: HashMap<u64, UnitState> = ends
.iter()