From 9c5a1c1f6b92ab994571a4f924c66ba511fec8b2 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 11 Oct 2026 17:37:02 +0400 Subject: [PATCH 1/2] Localize spell costs and race/class labels; accept Cyrillic names Fill the selected GlobalStrings resource-cost templates and carry selected ChrRaces/ChrClasses display names into the player record and live unit feeds. Keep race/class file tokens unchanged. Accept Unicode letters in name input and compare recased UTF-8 roster names when selecting a created character. Reference: stock PaperDollFrame_SetLevel reads UnitRace/UnitClass; ruRU data provides the cost templates and DBC names. vmangos normalizePlayerName reads UTF-8, uppercases the first letter and lowercases the rest. Verified: original symptoms reproduced by three numeric instruments on real client data, then all three passed with the corrected paths in ruRU and enUS. The probes cover the actual CharacterLevelText, spell costs, a second unit, keyboard input, paste, backspace and a 12-character/24-byte Cyrillic limit. Formatting and workspace clippy are green. Full land gates run before merge. Built on Windows; no live player-account login or character creation. --- crates/benilla-app/src/char_create/mod.rs | 14 ++++ crates/benilla-app/src/char_select/mod.rs | 34 +++++--- crates/benilla-app/src/creature_type.rs | 2 +- crates/benilla-app/src/textinput/mod.rs | 48 ++++++++++- crates/benilla-app/src/ui_party/feed.rs | 26 ++++-- crates/benilla-app/src/ui_party/pets.rs | 5 +- crates/benilla-app/src/ui_pet/unit.rs | 3 +- .../src/ui_script/bagnon_render_tests.rs | 6 +- crates/benilla-app/src/ui_script/lifecycle.rs | 39 +++++++-- .../src/ui_script/pet_frame_tests.rs | 10 ++- .../src/ui_script/world_entry_tests.rs | 84 +++++++++++++++++++ crates/benilla-app/src/ui_tooltip/mod.rs | 2 +- .../benilla-app/src/ui_tooltip/spell_feed.rs | 33 ++++---- crates/benilla-app/src/ui_tooltip/tests.rs | 63 +++++++++++++- crates/benilla-app/src/ui_unit.rs | 38 ++++++++- crates/benilla-app/src/ui_unit/held.rs | 12 ++- 16 files changed, 365 insertions(+), 54 deletions(-) diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index b0d89aa5b..07be7950e 100644 --- a/crates/benilla-app/src/char_create/mod.rs +++ b/crates/benilla-app/src/char_create/mod.rs @@ -616,6 +616,20 @@ 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); + sel.name = crate::textinput::field(12, false); + 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); + } + /// 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() -- 2.52.0 From 80fe28f9b5afc84aa9f9f31ba20b0236030a5701 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 11 Oct 2026 17:52:14 +0400 Subject: [PATCH 2/2] Apply the reference name-field limit on character creation Initialize the actual CreateSelection name field with 12 letters, as the stock CharacterCreate.xml CharacterCreateNameEdit letters="12" declares. The UTF-8 packet test now uses reset's actual field and checks both a short Russian name and truncation at 12 Cyrillic letters / 24 bytes. Verified the field property in the real MPQ's GlueXML and the reset path. Formatting and workspace clippy pass; full land gates are running on this source tree before merge. The preceding three symptom instruments passed on real ruRU and adjacent English UI data. Windows only. --- crates/benilla-app/src/char_create/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/benilla-app/src/char_create/mod.rs b/crates/benilla-app/src/char_create/mod.rs index 07be7950e..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; } @@ -620,7 +621,7 @@ mod tests { fn a_russian_character_name_reaches_the_wire_unchanged() { let mut sel = super::CreateSelection::default(); sel.reset(None); - sel.name = crate::textinput::field(12, false); + assert_eq!(sel.name.max_letters, 12); sel.name.insert("Ёлочка"); let req = sel.request(); assert_eq!(req.name, "Ёлочка"); @@ -628,6 +629,10 @@ mod tests { 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()` -- 2.52.0