diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index b0d89aa5b..2e6670cea 100644 --- a/crates/benilla-app/src/char_create/mod.rs +++ b/crates/benilla-app/src/char_create/mod.rs @@ -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] diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index ed076f42e..f8ea53212 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -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`) ─────────────────────────── diff --git a/crates/benilla-app/src/creature_type.rs b/crates/benilla-app/src/creature_type.rs index 94ef215ce..0d974582c 100644 --- a/crates/benilla-app/src/creature_type.rs +++ b/crates/benilla-app/src/creature_type.rs @@ -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!( diff --git a/crates/benilla-app/src/textinput/mod.rs b/crates/benilla-app/src/textinput/mod.rs index 2578bbd9a..6c0859517 100644 --- a/crates/benilla-app/src/textinput/mod.rs +++ b/crates/benilla-app/src/textinput/mod.rs @@ -43,7 +43,7 @@ pub(crate) fn mods_now(keys: &ButtonInput) -> 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"); diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 5a89297cc..42a8c2b3a 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -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); } diff --git a/crates/benilla-app/src/ui_party/pets.rs b/crates/benilla-app/src/ui_party/pets.rs index 669cab8ff..b8a881d22 100644 --- a/crates/benilla-app/src/ui_party/pets.rs +++ b/crates/benilla-app/src/ui_party/pets.rs @@ -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, diff --git a/crates/benilla-app/src/ui_pet/unit.rs b/crates/benilla-app/src/ui_pet/unit.rs index 31d3471ac..6cb142b92 100644 --- a/crates/benilla-app/src/ui_pet/unit.rs +++ b/crates/benilla-app/src/ui_pet/unit.rs @@ -48,6 +48,7 @@ pub(super) fn feed_pet_unit( commands: Res, // The form table, the creature-type resolver's first stage. spells: Option>, + strings: Option>, mut memory: Local>, ) { 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) { diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index 76cd55533..40b92e77b 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -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" ); } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 83176dfde..c8cc6b9d1 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -343,7 +343,12 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { // streams in. if let Some(record) = world .get_resource::() - .and_then(record_from_roster) + .and_then(|roster| { + record_from_roster( + roster, + world.get_resource::(), + ) + }) { 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::() - .and_then(seat_from_roster) + if let Some(seat) = + world + .get_resource::() + .and_then(|roster| { + seat_from_roster( + roster, + world.get_resource::(), + ) + }) { 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 { 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 { 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, diff --git a/crates/benilla-app/src/ui_script/pet_frame_tests.rs b/crates/benilla-app/src/ui_script/pet_frame_tests.rs index 7327e6a9a..d7d908dcd 100644 --- a/crates/benilla-app/src/ui_script/pet_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_frame_tests.rs @@ -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. diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index d01dbc0b0..79edd4f37 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -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::("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) { diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 16df94a96..f0fac95c0 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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, diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index 234afacce..aafa37c3c 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -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 diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 8d9534e41..175422eb7 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -33,9 +33,13 @@ type Getter = dyn Fn(&str) -> Option; 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)]) diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 1f363c8f9..f114e1a58 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -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>, spells: Option>, + names: Option>, } 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); diff --git a/crates/benilla-app/src/ui_unit/held.rs b/crates/benilla-app/src/ui_unit/held.rs index 5242357fd..b55e91424 100644 --- a/crates/benilla-app/src/ui_unit/held.rs +++ b/crates/benilla-app/src/ui_unit/held.rs @@ -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 = ends .iter()