Compare commits

...

1 Commits

Author SHA1 Message Date
sindoring cd887e0283 Fix Russian character captions and locale-scoped NPC names
Resolve race/class display names from the selected 1.12 DBCs. Use the existing numbered-argument formatter for character selection and deletion, matching the supplied ruRU GlueStrings templates. Reject persisted creature names from a different client locale instead of always accepting enUS.

Verified on Windows: real-pack race/class names, live and ghost roster texts, two creation selections and changing class labels, deletion text without deletion, Cyrillic fonts and FrameXML/options, cross-locale NPC cache rejection and preservation of live player/pet names. Ten focused checks passed with data required and no skips. Full land gates are running. No game login: this checkout declares no probe account.
2026-10-10 18:39:49 +04:00
12 changed files with 415 additions and 60 deletions
+61 -4
View File
@@ -4,7 +4,6 @@
use bevy::input::mouse::{AccumulatedMouseMotion, MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use crate::char_select::{class_name, race_name};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
@@ -123,8 +122,8 @@ pub(super) fn refresh_dynamic(
if alliance { "Alliance" } else { "Horde" },
)
.to_string(),
DynText::InfoTitle(InfoKind::Race) => race_name(sel.race).to_string(),
DynText::InfoTitle(InfoKind::Class) => class_name(sel.class).to_string(),
DynText::InfoTitle(InfoKind::Race) => strings.race_name(sel.race).to_string(),
DynText::InfoTitle(InfoKind::Class) => strings.class_name(sel.class).to_string(),
// Never trimmed: each `FACTION_INFO_*`, `RACE_INFO_*` and `CLASS_*` string opens with
// eight spaces, which is the reference's first-line clearance past the header icon.
DynText::InfoBody(InfoKind::Faction) => strings
@@ -155,7 +154,7 @@ pub(super) fn refresh_dynamic(
.to_string(),
DynText::ClassSlotLabel(i) => classes
.get(*i as usize)
.map(|&c| class_name(c).to_string())
.map(|&c| strings.class_name(c).to_string())
.unwrap_or_default(),
};
if t.0 != new {
@@ -402,3 +401,61 @@ pub(super) fn scroll_visuals(
};
}
}
#[cfg(test)]
mod localization_tests {
use super::*;
#[test]
fn russian_creation_headers_and_class_labels_follow_selection() {
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 mut chain =
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
.unwrap();
let mut app = App::new();
app.init_resource::<CreateSelection>()
.init_resource::<GlueArt>()
.insert_resource(crate::glue_strings::table_from_chain(&mut chain))
.insert_resource(CharCreate(
benilla_formats::CharCreateCatalog::load(&mut chain).unwrap(),
))
.add_systems(Update, refresh_dynamic);
app.world_mut().resource_mut::<CreateSelection>().race = 1;
app.world_mut().resource_mut::<CreateSelection>().class = 1;
let race = app
.world_mut()
.spawn((DynText::InfoTitle(InfoKind::Race), Text::default()))
.id();
let class = app
.world_mut()
.spawn((DynText::InfoTitle(InfoKind::Class), Text::default()))
.id();
let label = app
.world_mut()
.spawn((DynText::ClassSlotLabel(0), Text::default()))
.id();
let second_label = app
.world_mut()
.spawn((DynText::ClassSlotLabel(1), Text::default()))
.id();
app.update();
assert_eq!(app.world().get::<Text>(race).unwrap().0, "Человек");
assert_eq!(app.world().get::<Text>(class).unwrap().0, "Воин");
assert_eq!(app.world().get::<Text>(label).unwrap().0, "Воин");
assert_eq!(app.world().get::<Text>(second_label).unwrap().0, "Паладин");
app.world_mut().resource_mut::<CreateSelection>().race = 6;
app.world_mut().resource_mut::<CreateSelection>().class = 11;
app.update();
assert_eq!(app.world().get::<Text>(race).unwrap().0, "Таурен");
assert_eq!(app.world().get::<Text>(class).unwrap().0, "Друид");
assert_eq!(app.world().get::<Text>(label).unwrap().0, "Воин");
assert_eq!(app.world().get::<Text>(second_label).unwrap().0, "Охотник");
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
use bevy::window::PrimaryWindow;
use crate::char_select::{race_name, wow_font};
use crate::char_select::wow_font;
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
@@ -280,7 +280,7 @@ fn left_tower(
Some(DynIcon::Race(race)),
None,
None::<DynText>,
race_name(race),
strings.race_name(race),
art,
s,
);
+43 -12
View File
@@ -25,7 +25,7 @@ use super::wow_font;
pub(super) struct DeleteDialog {
pub(super) open: bool,
/// The snapshotted target: guid, name, level, class name.
target: Option<(u64, String, u8, &'static str)>,
target: Option<(u64, String, u8, String)>,
/// The confirm box's text; the reference's `letters="32"` cap is its `max_letters`.
pub(super) typed: EditBoxState,
/// The spawned dialog root, while up.
@@ -35,9 +35,9 @@ pub(super) struct DeleteDialog {
}
impl DeleteDialog {
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &'static str) {
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &str) {
self.open = true;
self.target = Some((guid, name, level, class));
self.target = Some((guid, name, level, class.to_string()));
self.typed.set_text("");
}
@@ -204,17 +204,10 @@ fn spawn_dialog(
let (name, level, class) = dialog
.target
.as_ref()
.map(|(_, n, l, c)| (n.clone(), *l, *c))
.map(|(_, n, l, c)| (n.clone(), *l, c.as_str()))
.unwrap_or_default();
// `CONFIRM_CHAR_DELETE`'s `|cffffffff` span splits it: gold lead above, white identity below.
let raw = strings.text(
"CONFIRM_CHAR_DELETE",
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
);
let filled = raw
.replacen("%s", &name, 1)
.replacen("%d", &level.to_string(), 1)
.replacen("%s", class, 1);
let filled = delete_prompt(strings, &name, level, class);
let (lead, identity) = match filled.split_once("|cffffffff") {
Some((a, b)) => (a.trim_end_matches('\n').to_string(), b.replace("|r", "")),
None => (filled.clone(), String::new()),
@@ -452,3 +445,41 @@ fn spawn_dialog(
})
.id()
}
fn delete_prompt(strings: &GlueStrings, name: &str, level: u8, class: &str) -> String {
let template = strings.text(
"CONFIRM_CHAR_DELETE",
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
);
benilla_ui::strings::fill(template, &[name.into(), level.into(), class.into()])
}
#[cfg(test)]
mod localization_tests {
use super::*;
#[test]
fn russian_delete_prompt_reorders_name_class_and_level_without_deleting() {
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 mut chain =
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
.unwrap();
let strings = crate::glue_strings::table_from_chain(&mut chain);
let prompt = delete_prompt(&strings, "Qera", 34, strings.class_name(11));
assert_eq!(
prompt.split_once("|cffffffff").unwrap().1,
"Qera, Друид 34-го уровня|r"
);
assert_eq!(
delete_prompt(&GlueStrings::default(), "Agvin", 12, "Warrior"),
"Do you want to delete\n|cffffffffAgvin Level 12 Warrior|r?"
);
}
}
+5 -2
View File
@@ -12,7 +12,7 @@ use crate::sound::GlueSound;
use super::dialog::DeleteDialog;
use super::screen::SelectAction;
use super::{class_name, send_pick, ClientState, Roster};
use super::{send_pick, ClientState, Roster};
use crate::glue::{drag_yaw, ROTATE_RATE};
@@ -30,6 +30,7 @@ pub(super) fn select_input(
mut realms: ResMut<crate::realm_select::Realms>,
pick: Res<CharPick>,
mut dialog: ResMut<DeleteDialog>,
strings: Option<Res<crate::glue_strings::GlueStrings>>,
mut panel: ResMut<super::addons::AddonsPanel>,
choice: Res<crate::net::RealmChoice>,
mut next: ResMut<NextState<ClientState>>,
@@ -71,7 +72,9 @@ pub(super) fn select_input(
// With no selection the click only plays the sound (`selectedIndex > 0` gate).
sounds.write(GlueSound("gsCharacterSelectionDelCharacter"));
if let Some(c) = roster.selected_char() {
dialog.open_for(c.guid, c.name.clone(), c.level, class_name(c.class));
let empty = crate::glue_strings::GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
dialog.open_for(c.guid, c.name.clone(), c.level, strings.class_name(c.class));
}
}
SelectAction::CreateChar => {
+4 -1
View File
@@ -758,6 +758,7 @@ fn clear_select_entry(mut commands: Commands) {
/// is up, with `<typed>` pre-typed, for a shot.
fn debug_select_dialog(
roster: Res<Roster>,
strings: Option<Res<crate::glue_strings::GlueStrings>>,
mut dialog: ResMut<dialog::DeleteDialog>,
time: Res<Time>,
entered_at: Res<CharSelectEnteredAt>,
@@ -776,7 +777,9 @@ fn debug_select_dialog(
let Some(c) = roster.selected_char() else {
return;
};
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, class_name(c.class));
let empty = crate::glue_strings::GlueStrings::default();
let strings = strings.as_deref().unwrap_or(&empty);
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, strings.class_name(c.class));
dialog.open_for(guid, name, level, class);
dialog.typed.set_text(&typed);
info!("char select: dialog instrument opened the delete confirm");
+49 -5
View File
@@ -10,7 +10,7 @@ use crate::net::NetStatus;
use crate::portrait::{GlueLook, GluePreview, SelectLook};
use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS};
use super::{class_name, Roster};
use super::Roster;
/// Refill the rows, the selected name and the realm banner when the roster changes or the screen
/// was just spawned (a return from the create screen finds the roster unchanged).
@@ -61,10 +61,10 @@ pub(super) fn refresh_list(
} else {
("CHARACTER_SELECT_INFO", "Level %d %s")
};
strings
.text(key.0, key.1)
.replacen("%d", &c.level.to_string(), 1)
.replacen("%s", class_name(c.class), 1)
benilla_ui::strings::fill(
strings.text(key.0, key.1),
&[c.level.into(), strings.class_name(c.class).into()],
)
}
_ => areas
.as_deref()
@@ -201,6 +201,50 @@ pub(super) fn feed_glue_preview(
mod tests {
use super::*;
#[test]
fn russian_roster_rows_fill_the_real_level_template_including_ghosts() {
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 mut chain =
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
.unwrap();
let strings = crate::glue_strings::table_from_chain(&mut chain);
let mut live = crate::char_select::test_character(1, "Agvin");
live.level = 12;
let mut ghost = crate::char_select::test_character(2, "Qera");
ghost.level = 34;
ghost.class = 11;
ghost.flags = benilla_protocol::CHARACTER_FLAG_GHOST;
let mut app = App::new();
app.insert_resource(Roster::with_pending_pick(vec![live, ghost], 1))
.init_resource::<NetStatus>()
.insert_resource(strings)
.add_systems(Update, refresh_list);
let live = app
.world_mut()
.spawn((RowText::Info(0), Text::default()))
.id();
let ghost = app
.world_mut()
.spawn((RowText::Info(1), Text::default()))
.id();
app.update();
assert_eq!(
app.world().get::<Text>(live).unwrap().0,
"Воин 12-го уровня"
);
assert_eq!(
app.world().get::<Text>(ghost).unwrap().0,
"Друид 34-го уровня (призрак)"
);
}
/// The composition order of `CharacterSelect.lua:48-64`.
#[test]
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
+34 -9
View File
@@ -17,23 +17,41 @@ const GLUE_LOCALIZATION: &str = "Interface\\GlueXML\\GlueLocalization.lua";
/// The glue string table; empty without client data, and callers fall back to built-in captions.
#[derive(Resource, Default)]
pub(crate) struct GlueStrings(HashMap<String, String>);
pub(crate) struct GlueStrings {
strings: HashMap<String, String>,
names: benilla_formats::CharacterNames,
}
impl GlueStrings {
/// A table from parsed pairs, for tests that resolve against the real file.
#[cfg(test)]
pub(crate) fn from_map(map: HashMap<String, String>) -> Self {
Self(map)
Self {
strings: map,
names: benilla_formats::CharacterNames::default(),
}
}
/// The pairs back out, for a test that has to alter one key and re-resolve.
#[cfg(test)]
pub(crate) fn into_map(self) -> HashMap<String, String> {
self.0
self.strings
}
pub(crate) fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(String::as_str)
self.strings.get(key).map(String::as_str)
}
pub(crate) fn race_name(&self, race: u8) -> &str {
self.names
.race(race)
.unwrap_or(crate::char_select::race_name(race))
}
pub(crate) fn class_name(&self, class: u8) -> &str {
self.names
.class(class)
.unwrap_or(crate::char_select::class_name(class))
}
/// The string for a key, falling back to a built-in caption.
@@ -48,10 +66,14 @@ pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<World
let mut table = GlueStrings::default();
if let Some(assets) = assets {
let mut chain = assets.chain.lock_recover();
match benilla_formats::CharacterNames::load(&mut chain) {
Ok(names) => table.names = names,
Err(e) => warn!("character names unavailable ({e:#}) — built-in names only"),
}
match chain.read_file(GLUE_STRINGS) {
Ok(bytes) => {
table.0 = parse_glue_strings(&String::from_utf8_lossy(&bytes));
info!("glue strings: {} entries", table.0.len());
table.strings = parse_glue_strings(&String::from_utf8_lossy(&bytes));
info!("glue strings: {} entries", table.strings.len());
}
Err(e) => warn!("glue strings unavailable ({e:#}) — built-in captions only"),
}
@@ -59,7 +81,7 @@ pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<World
Ok(bytes) => {
let patch = parse_localize_overrides(&String::from_utf8_lossy(&bytes));
info!("glue strings: {} locale overrides", patch.len());
table.0.extend(patch);
table.strings.extend(patch);
}
Err(e) => warn!("glue localization unavailable ({e:#}) — unpatched glue strings"),
}
@@ -142,7 +164,10 @@ pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueString
.read_file(GLUE_LOCALIZATION)
.expect("GlueLocalization.lua");
map.extend(parse_localize_overrides(&String::from_utf8_lossy(&patch)));
GlueStrings::from_map(map)
GlueStrings {
strings: map,
names: benilla_formats::CharacterNames::load(chain).expect("character names"),
}
}
#[cfg(test)]
@@ -251,6 +276,6 @@ end
// The patch overwrites keys; it does not replace the table.
assert!(table.get("FACTION_INFO_HORDE").is_some(), "base lost");
assert!(base.len() > 100 && table.0.len() >= base.len());
assert!(base.len() > 100 && table.strings.len() >= base.len());
}
}
+20 -3
View File
@@ -17,6 +17,8 @@
use std::path::PathBuf;
use benilla_assets::{LockRecover, WorldAssets};
use benilla_formats::ClientLocale;
use bevy::prelude::*;
use crate::char_select::Roster;
@@ -41,6 +43,7 @@ impl Plugin for NamePersistPlugin {
pub(crate) struct NameCacheFile {
/// The realm the loaded file belongs to; a change triggers a load.
realm: Option<String>,
locale: ClientLocale,
path: Option<PathBuf>,
/// [`NameCache::generation`] at the last write: the dirty bit, which ticks on an answer.
saved_generation: u64,
@@ -50,6 +53,7 @@ pub(crate) struct NameCacheFile {
/// Loads the realm's cache the first frame its identity is known, and on a realm change.
fn load_name_cache(
roster: Res<Roster>,
assets: Option<Res<WorldAssets>>,
mut file: ResMut<NameCacheFile>,
mut names: ResMut<NameCache>,
) {
@@ -59,8 +63,17 @@ fn load_name_cache(
if file.realm.as_deref() == Some(realm.as_str()) {
return;
}
// The chain's locale is fixed at boot; read it once per realm, outside the frame loop.
let locale = assets
.as_deref()
.map_or(ClientLocale::EnUs, |a| a.chain.lock_recover().locale());
let path = crate::local_state::name_cache_path(&realm);
// A realm change must not retain templates loaded for the previous realm.
if file.realm.is_some() {
names.install_persisted(NameCache::default());
}
file.realm = Some(realm.clone());
file.locale = locale;
file.path = path.clone();
file.saved_generation = names.generation();
let Some(path) = path else {
@@ -69,7 +82,7 @@ fn load_name_cache(
let Ok(text) = std::fs::read_to_string(&path) else {
return; // no cache yet
};
match NameCache::from_tsv(&text, &realm) {
match NameCache::from_tsv(&text, &realm, locale) {
Some(loaded) => {
let n = loaded.len();
// Install only the file's records: this runs as the pick is in flight, beside the
@@ -117,7 +130,7 @@ fn write_now(file: &mut NameCacheFile, names: &NameCache) {
return;
}
}
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm)) {
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm, file.locale)) {
Ok(()) => {
file.saved_generation = names.generation();
debug!("names: wrote {} records to {}", names.len(), path.display());
@@ -205,7 +218,11 @@ mod tests {
);
let path = crate::local_state::name_cache_path("Realm").expect("a state folder");
std::fs::create_dir_all(path.parent().expect("cache dir")).expect("cache dir");
std::fs::write(&path, on_disk.to_tsv("Realm")).expect("write the realm cache");
std::fs::write(
&path,
on_disk.to_tsv("Realm", benilla_formats::ClientLocale::EnUs),
)
.expect("write the realm cache");
// The login has already seeded our own name and a pet's, as `net::session::connected` does.
let mut names = NameCache::default();
+86 -21
View File
@@ -320,15 +320,13 @@ const CACHE_MAGIC: &str = "benilla-namecache";
const CACHE_FORMAT: u32 = 2;
/// The client build, the reference's `0x16f3`.
const CACHE_BUILD: u32 = 5875;
/// In the header as in the reference's; benilla reads DBC locale slot 0 only.
const CACHE_LOCALE: &str = "enUS";
impl NameCache {
/// Serialize as TSV: the header line, then one line per creature record. In-flight asks are
/// not written.
pub(crate) fn to_tsv(&self, realm: &str) -> String {
let mut out =
format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{CACHE_LOCALE}\t{realm}\n");
pub(crate) fn to_tsv(&self, realm: &str, locale: benilla_formats::ClientLocale) -> String {
let locale = locale.code();
let mut out = format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{locale}\t{realm}\n");
for (entry, rec) in self.creatures.iter() {
match rec {
Some(r) => out.push_str(&format!(
@@ -351,14 +349,18 @@ impl NameCache {
/// Rebuild a cache from [`Self::to_tsv`]'s output, `None` when the header differs; a
/// malformed line is skipped.
pub(crate) fn from_tsv(text: &str, realm: &str) -> Option<Self> {
pub(crate) fn from_tsv(
text: &str,
realm: &str,
locale: benilla_formats::ClientLocale,
) -> Option<Self> {
let mut lines = text.lines();
let header: Vec<&str> = lines.next()?.split('\t').collect();
if header.len() != 5
|| header[0] != CACHE_MAGIC
|| header[1] != CACHE_FORMAT.to_string()
|| header[2] != CACHE_BUILD.to_string()
|| header[3] != CACHE_LOCALE
|| header[3] != locale.code()
|| header[4] != realm
{
return None;
@@ -551,6 +553,33 @@ pub(crate) mod net {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_russian_session_discards_english_creature_names_and_preserves_russian_names() {
use benilla_formats::ClientLocale::{EnUs, RuRu};
let mut cache = NameCache::default();
cache.insert_creature(
261,
Some(CreatureRecord {
name: "Guard Thomas".into(),
subname: None,
creature_type: 7,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
display_id: 0,
}),
);
let english = cache.to_tsv("R", EnUs);
assert!(NameCache::from_tsv(&english, "R", RuRu).is_none());
cache.creatures.get_mut(261).unwrap().name = "Стражник Томас".into();
let russian = cache.to_tsv("R", RuRu);
assert!(NameCache::from_tsv(&russian, "R", EnUs).is_none());
let back = NameCache::from_tsv(&russian, "R", RuRu).unwrap();
assert_eq!(back.creature_record(261).unwrap().name, "Стражник Томас");
}
use crossbeam_channel::TryRecvError;
/// Compose a guid the way the server does: `counter | (entry << 24) | (high << 48)`.
@@ -668,8 +697,13 @@ mod tests {
);
cache.insert_creature(1234, None); // the server flagged the entry unknown
let text = cache.to_tsv("Hydraxian Waterlords");
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
let text = cache.to_tsv("Hydraxian Waterlords", benilla_formats::ClientLocale::EnUs);
let back = NameCache::from_tsv(
&text,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs,
)
.expect("header matches");
// Present-and-None, not absent.
assert!(back.creatures.answered(1234));
@@ -697,7 +731,12 @@ mod tests {
display_id: 1,
}),
);
let back = NameCache::from_tsv(&cache.to_tsv("R"), "R").expect("header");
let back = NameCache::from_tsv(
&cache.to_tsv("R", benilla_formats::ClientLocale::EnUs),
"R",
benilla_formats::ClientLocale::EnUs,
)
.expect("header");
assert_eq!(back.creature_record(70).unwrap().subname, None);
}
@@ -723,12 +762,13 @@ mod tests {
}),
);
let text = cache.to_tsv("R");
let text = cache.to_tsv("R", benilla_formats::ClientLocale::EnUs);
assert!(
!text.contains("Moamtester") && !text.contains("Rex"),
"no guid- or spawn-keyed name may reach the disk:\n{text}"
);
let back = NameCache::from_tsv(&text, "R").expect("header matches");
let back = NameCache::from_tsv(&text, "R", benilla_formats::ClientLocale::EnUs)
.expect("header matches");
assert_eq!(
back.peek(0x11),
None,
@@ -780,11 +820,19 @@ mod tests {
fn a_header_that_differs_in_any_field_discards_the_whole_file() {
let mut cache = NameCache::default();
cache.insert_creature(1234, None);
let good = cache.to_tsv("Hydraxian Waterlords");
assert!(NameCache::from_tsv(&good, "Hydraxian Waterlords").is_some());
let good = cache.to_tsv("Hydraxian Waterlords", benilla_formats::ClientLocale::EnUs);
assert!(NameCache::from_tsv(
&good,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs
)
.is_some());
// The realm: our field, not the reference's.
assert!(NameCache::from_tsv(&good, "Another Realm").is_none());
assert!(
NameCache::from_tsv(&good, "Another Realm", benilla_formats::ClientLocale::EnUs)
.is_none()
);
let head = good.lines().next().unwrap();
for (field, bad) in [
@@ -797,23 +845,40 @@ mod tests {
parts[field] = bad;
let text = format!("{}\n{}", parts.join("\t"), good.lines().nth(1).unwrap());
assert!(
NameCache::from_tsv(&text, "Hydraxian Waterlords").is_none(),
NameCache::from_tsv(
&text,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs
)
.is_none(),
"header field {field} = {bad} must discard the file"
);
}
assert!(NameCache::from_tsv("", "R").is_none());
assert!(NameCache::from_tsv("nonsense", "R").is_none());
assert!(NameCache::from_tsv("", "R", benilla_formats::ClientLocale::EnUs).is_none());
assert!(
NameCache::from_tsv("nonsense", "R", benilla_formats::ClientLocale::EnUs).is_none()
);
let text = format!("{}\nC\tnotanumber\t1\t2\t0\tX\nc\t1234\n", head);
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header still good");
let back = NameCache::from_tsv(
&text,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs,
)
.expect("header still good");
assert_eq!(back.len(), 1, "the bad line is skipped, the good one kept");
// A version-1 file, which held player names, is discarded whole.
let v1 = format!(
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\t{CACHE_LOCALE}\tHydraxian Waterlords\n\
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\tenUS\tHydraxian Waterlords\n\
P\t7\t1\t1\t0\tMoamtester\n"
);
assert!(NameCache::from_tsv(&v1, "Hydraxian Waterlords").is_none());
assert!(NameCache::from_tsv(
&v1,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs
)
.is_none());
}
#[test]
@@ -0,0 +1,108 @@
//! Localized display names from the 1.12 ChrRaces and ChrClasses name blocks.
//! The community ruRU patch stores its translated names in locale slot zero.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
#[derive(Debug, Default)]
/// The display names, distinct from the unlocalized race/class file tokens.
pub struct CharacterNames {
races: HashMap<u32, String>,
classes: HashMap<u32, String>,
}
impl CharacterNames {
pub fn load(chain: &mut Chain) -> Result<Self> {
Ok(Self {
races: load_names(chain, "ChrRaces", 29, 17)?,
classes: load_names(chain, "ChrClasses", 17, 5)?,
})
}
pub fn race(&self, id: u8) -> Option<&str> {
self.races.get(&u32::from(id)).map(String::as_str)
}
pub fn class(&self, id: u8) -> Option<&str> {
self.classes.get(&u32::from(id)).map(String::as_str)
}
}
fn load_names(
chain: &mut Chain,
table: &str,
fields: usize,
name_field: usize,
) -> Result<HashMap<u32, String>> {
let path = format!("DBFilesClient\\{table}.dbc");
let bytes = chain
.read_file(&path)
.with_context(|| format!("reading {path}"))?;
let mut schema = Schema::new(table);
for i in 0..fields {
schema.add_field(SchemaField::new(
format!("F{i}"),
if i == name_field {
FieldType::String
} else {
FieldType::UInt32
},
));
}
let rows = parse(&bytes, schema, table)?;
Ok(rows
.records()
.iter()
.filter_map(|r| {
let id = u32_at(r, 0)?;
let name = str_at(&rows, r, name_field)?;
(!name.is_empty()).then_some((id, name))
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ClientLocale;
#[test]
fn character_names_follow_the_selected_patch_chain() {
let data = crate::wow_data_or_skip!();
let mut english = Chain::open(&data).unwrap();
let names = CharacterNames::load(&mut english).unwrap();
assert_eq!(names.race(1), Some("Human"));
assert_eq!(names.class(1), Some("Warrior"));
assert_eq!(names.race(255), None);
assert_eq!(names.class(255), None);
if ClientLocale::RuRu.pack_folder(&data).is_none() {
crate::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let mut russian = Chain::open_localized(&data, ClientLocale::RuRu).unwrap();
let names = CharacterNames::load(&mut russian).unwrap();
assert_eq!(names.race(1), Some("Человек"));
assert_eq!(names.class(1), Some("Воин"));
assert_eq!(names.race(6), Some("Таурен"));
assert_eq!(names.class(11), Some("Друид"));
for id in 1..=8 {
assert!(names
.race(id)
.unwrap()
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
}
for id in [1, 2, 3, 4, 5, 7, 8, 9, 11] {
assert!(names
.class(id)
.unwrap()
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
}
}
}
+2
View File
@@ -15,6 +15,8 @@ mod chain;
pub use chain::{Chain, ChainEntry};
mod locale;
pub use locale::ClientLocale;
mod character_names;
pub use character_names::CharacterNames;
/// Loose `.tga` art, as addon folders ship it.
mod tga;
pub use tga::tga_to_rgba;
+1 -1
View File
@@ -106,7 +106,7 @@
- **benilla-adt** (lib.rs): combined_alpha
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim creature_type cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain character_names characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world