Compare commits

..

1 Commits

Author SHA1 Message Date
sindoring 443bda246d Add ruRU client localization from installed language packs
Mount the community 1.12 ruRU archives after the vanilla chain, with automatic
selection and WOW_LOCALE overrides. Use the same locale for client assets,
FrameXML/addons, font shaping and the realmd challenge. Translate benilla's own
settings, menu and script log, including case-insensitive Cyrillic search.
Preserve language archives when staging MoonWell without writing to the install.

Reference: the supplied 1.12 pack retains vanilla DBC layouts with Russian text
in slot zero and supplies localized GlobalStrings, GlueStrings and four fonts.
The community overlay convention is documented as a fork deviation in locale.rs.

Verified on Windows with scripts/gates.sh: fmt, clippy with warnings denied,
workspace tests with canonical 1.12 data and without an install, doc-links,
pass-span lint, player tests without dev, and both engine boot checks passed.
The ruRU pack tests passed for full FrameXML, all settings captions, Cyrillic
search/dropdown values, DBC names, base fallback, glue captions and glyph ink.
31 addon-corpus tests skipped because the corpus is not installed.
A separate unattended silent engine check on C:\Games\WoW\Data selected ruRU
automatically and exited successfully. MAP regenerated by scripts/genmap.sh.
Live authentication smoke was not run: no declared test account is available.
macOS and Linux were not built. No files from the install are committed.
2026-10-10 16:48:37 +04:00
28 changed files with 116 additions and 800 deletions
+1 -14
View File
@@ -46,19 +46,11 @@ kira = { path = "third_party/kira" }
# Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps.
[profile.dev.package."*"]
opt-level = 3
# One codegen unit each, since `lto = "off"` below drops the ThinLTO that inlines across a crate's
# units: at 16 units the dependencies cost +1.2 cpu_ms at the leg pin, at one nothing, and a cold
# build is no slower.
codegen-units = 1
# Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0
# Stormwind drops from a pinned 60 fps to ~45.
# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0.
[profile.dev]
opt-level = 1
# No ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and an
# incremental rebuild redoes it whole: 19 s of a one-line `benilla-app` edit's 25 s, and half its
# 5.3 GB. Off, our crates cost +0.7 cpu_ms (+6 %) at the leg pin.
lto = "off"
# No debuginfo trimming: `debug = "line-tables-only"` measures no recurring win on macOS + sccache.
@@ -79,8 +71,3 @@ incremental = true
# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols
# unresolved and the link fails intermittently (rustc #86049).
lto = "off"
# One codegen unit each, as in dev: at 16 the dependencies cost +1.2 cpu_ms against `release` at
# the leg pin, at one the profile is +0.5, and a cold build is no slower.
[profile.play.package."*"]
codegen-units = 1
+1 -20
View File
@@ -235,8 +235,7 @@ impl CreateSelection {
.and_then(|c| c.0.classes_for_race(1).first().copied())
.unwrap_or(1);
self.dials = [0; 5];
// CharacterCreate.xml declares CharacterCreateNameEdit letters="12", not a byte cap.
self.name = textinput::field(12, false);
self.name.set_text("");
self.creating = false;
}
@@ -617,24 +616,6 @@ 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]
+4 -61
View File
@@ -4,6 +4,7 @@
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;
@@ -122,8 +123,8 @@ pub(super) fn refresh_dynamic(
if alliance { "Alliance" } else { "Horde" },
)
.to_string(),
DynText::InfoTitle(InfoKind::Race) => strings.race_name(sel.race).to_string(),
DynText::InfoTitle(InfoKind::Class) => strings.class_name(sel.class).to_string(),
DynText::InfoTitle(InfoKind::Race) => race_name(sel.race).to_string(),
DynText::InfoTitle(InfoKind::Class) => 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
@@ -154,7 +155,7 @@ pub(super) fn refresh_dynamic(
.to_string(),
DynText::ClassSlotLabel(i) => classes
.get(*i as usize)
.map(|&c| strings.class_name(c).to_string())
.map(|&c| class_name(c).to_string())
.unwrap_or_default(),
};
if t.0 != new {
@@ -401,61 +402,3 @@ 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::wow_font;
use crate::char_select::{race_name, 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>,
strings.race_name(race),
race_name(race),
art,
s,
);
+12 -43
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, String)>,
target: Option<(u64, String, u8, &'static str)>,
/// 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: &str) {
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &'static str) {
self.open = true;
self.target = Some((guid, name, level, class.to_string()));
self.target = Some((guid, name, level, class));
self.typed.set_text("");
}
@@ -204,10 +204,17 @@ fn spawn_dialog(
let (name, level, class) = dialog
.target
.as_ref()
.map(|(_, n, l, c)| (n.clone(), *l, c.as_str()))
.map(|(_, n, l, c)| (n.clone(), *l, *c))
.unwrap_or_default();
// `CONFIRM_CHAR_DELETE`'s `|cffffffff` span splits it: gold lead above, white identity below.
let filled = delete_prompt(strings, &name, level, class);
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 (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()),
@@ -445,41 +452,3 @@ 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?"
);
}
}
+2 -5
View File
@@ -12,7 +12,7 @@ use crate::sound::GlueSound;
use super::dialog::DeleteDialog;
use super::screen::SelectAction;
use super::{send_pick, ClientState, Roster};
use super::{class_name, send_pick, ClientState, Roster};
use crate::glue::{drag_yaw, ROTATE_RATE};
@@ -30,7 +30,6 @@ 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>>,
@@ -72,9 +71,7 @@ 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() {
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));
dialog.open_for(c.guid, c.name.clone(), c.level, class_name(c.class));
}
}
SelectAction::CreateChar => {
+11 -28
View File
@@ -192,7 +192,7 @@ impl Roster {
if let Some(row) = self
.chars
.iter()
.position(|c| same_character_name(&c.name, name))
.position(|c| c.name.eq_ignore_ascii_case(name))
{
self.select(Some(row));
self.just_created = None;
@@ -262,13 +262,6 @@ 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.
@@ -381,7 +374,7 @@ fn apply_roster_policy(
match roster
.chars
.iter()
.position(|c| same_character_name(&c.name, &name))
.position(|c| c.name.eq_ignore_ascii_case(&name))
{
Some(row) => {
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
@@ -397,7 +390,7 @@ fn apply_roster_policy(
match roster
.chars
.iter()
.find(|c| same_character_name(&c.name, &name))
.find(|c| c.name.eq_ignore_ascii_case(&name))
{
Some(c) => {
let guid = c.guid;
@@ -765,7 +758,6 @@ 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>,
@@ -784,9 +776,7 @@ fn debug_select_dialog(
let Some(c) = roster.selected_char() else {
return;
};
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));
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, 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");
@@ -1184,20 +1174,13 @@ 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() {
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());
}
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));
}
// ── The remembered character (`lastCharacterIndex`) ───────────────────────────
+5 -49
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::Roster;
use super::{class_name, 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")
};
benilla_ui::strings::fill(
strings.text(key.0, key.1),
&[c.level.into(), strings.class_name(c.class).into()],
)
strings
.text(key.0, key.1)
.replacen("%d", &c.level.to_string(), 1)
.replacen("%s", class_name(c.class), 1)
}
_ => areas
.as_deref()
@@ -201,50 +201,6 @@ 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() {
+1 -1
View File
@@ -215,7 +215,7 @@ mod tests {
forms: Some(&forms),
};
let word = |store: &ObjectStore| {
crate::ui_unit::snapshot(store, 0, None, 0, None, sources, None).creature_type_name
crate::ui_unit::snapshot(store, 0, None, 0, None, sources).creature_type_name
};
assert_eq!(word(&store(0, 1, 1)).as_deref(), Some("Beast"), "cat form");
assert_eq!(
+9 -34
View File
@@ -17,41 +17,23 @@ 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 {
strings: HashMap<String, String>,
names: benilla_formats::CharacterNames,
}
pub(crate) struct GlueStrings(HashMap<String, String>);
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 {
strings: map,
names: benilla_formats::CharacterNames::default(),
}
Self(map)
}
/// 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.strings
self.0
}
pub(crate) fn get(&self, key: &str) -> Option<&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))
self.0.get(key).map(String::as_str)
}
/// The string for a key, falling back to a built-in caption.
@@ -66,14 +48,10 @@ 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.strings = parse_glue_strings(&String::from_utf8_lossy(&bytes));
info!("glue strings: {} entries", table.strings.len());
table.0 = parse_glue_strings(&String::from_utf8_lossy(&bytes));
info!("glue strings: {} entries", table.0.len());
}
Err(e) => warn!("glue strings unavailable ({e:#}) — built-in captions only"),
}
@@ -81,7 +59,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.strings.extend(patch);
table.0.extend(patch);
}
Err(e) => warn!("glue localization unavailable ({e:#}) — unpatched glue strings"),
}
@@ -164,10 +142,7 @@ 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 {
strings: map,
names: benilla_formats::CharacterNames::load(chain).expect("character names"),
}
GlueStrings::from_map(map)
}
#[cfg(test)]
@@ -276,6 +251,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.strings.len() >= base.len());
assert!(base.len() > 100 && table.0.len() >= base.len());
}
}
+3 -20
View File
@@ -17,8 +17,6 @@
use std::path::PathBuf;
use benilla_assets::{LockRecover, WorldAssets};
use benilla_formats::ClientLocale;
use bevy::prelude::*;
use crate::char_select::Roster;
@@ -43,7 +41,6 @@ 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,
@@ -53,7 +50,6 @@ 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>,
) {
@@ -63,17 +59,8 @@ 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 {
@@ -82,7 +69,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, locale) {
match NameCache::from_tsv(&text, &realm) {
Some(loaded) => {
let n = loaded.len();
// Install only the file's records: this runs as the pick is in flight, beside the
@@ -130,7 +117,7 @@ fn write_now(file: &mut NameCacheFile, names: &NameCache) {
return;
}
}
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm, file.locale)) {
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm)) {
Ok(()) => {
file.saved_generation = names.generation();
debug!("names: wrote {} records to {}", names.len(), path.display());
@@ -218,11 +205,7 @@ 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", benilla_formats::ClientLocale::EnUs),
)
.expect("write the realm cache");
std::fs::write(&path, on_disk.to_tsv("Realm")).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();
+21 -86
View File
@@ -320,13 +320,15 @@ 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, 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");
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");
for (entry, rec) in self.creatures.iter() {
match rec {
Some(r) => out.push_str(&format!(
@@ -349,18 +351,14 @@ 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,
locale: benilla_formats::ClientLocale,
) -> Option<Self> {
pub(crate) fn from_tsv(text: &str, realm: &str) -> 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] != locale.code()
|| header[3] != CACHE_LOCALE
|| header[4] != realm
{
return None;
@@ -553,33 +551,6 @@ 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)`.
@@ -697,13 +668,8 @@ mod tests {
);
cache.insert_creature(1234, None); // the server flagged the entry unknown
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");
let text = cache.to_tsv("Hydraxian Waterlords");
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
// Present-and-None, not absent.
assert!(back.creatures.answered(1234));
@@ -731,12 +697,7 @@ mod tests {
display_id: 1,
}),
);
let back = NameCache::from_tsv(
&cache.to_tsv("R", benilla_formats::ClientLocale::EnUs),
"R",
benilla_formats::ClientLocale::EnUs,
)
.expect("header");
let back = NameCache::from_tsv(&cache.to_tsv("R"), "R").expect("header");
assert_eq!(back.creature_record(70).unwrap().subname, None);
}
@@ -762,13 +723,12 @@ mod tests {
}),
);
let text = cache.to_tsv("R", benilla_formats::ClientLocale::EnUs);
let text = cache.to_tsv("R");
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", benilla_formats::ClientLocale::EnUs)
.expect("header matches");
let back = NameCache::from_tsv(&text, "R").expect("header matches");
assert_eq!(
back.peek(0x11),
None,
@@ -820,19 +780,11 @@ 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", benilla_formats::ClientLocale::EnUs);
assert!(NameCache::from_tsv(
&good,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs
)
.is_some());
let good = cache.to_tsv("Hydraxian Waterlords");
assert!(NameCache::from_tsv(&good, "Hydraxian Waterlords").is_some());
// The realm: our field, not the reference's.
assert!(
NameCache::from_tsv(&good, "Another Realm", benilla_formats::ClientLocale::EnUs)
.is_none()
);
assert!(NameCache::from_tsv(&good, "Another Realm").is_none());
let head = good.lines().next().unwrap();
for (field, bad) in [
@@ -845,40 +797,23 @@ mod tests {
parts[field] = bad;
let text = format!("{}\n{}", parts.join("\t"), good.lines().nth(1).unwrap());
assert!(
NameCache::from_tsv(
&text,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs
)
.is_none(),
NameCache::from_tsv(&text, "Hydraxian Waterlords").is_none(),
"header field {field} = {bad} must discard the file"
);
}
assert!(NameCache::from_tsv("", "R", benilla_formats::ClientLocale::EnUs).is_none());
assert!(
NameCache::from_tsv("nonsense", "R", benilla_formats::ClientLocale::EnUs).is_none()
);
assert!(NameCache::from_tsv("", "R").is_none());
assert!(NameCache::from_tsv("nonsense", "R").is_none());
let text = format!("{}\nC\tnotanumber\t1\t2\t0\tX\nc\t1234\n", head);
let back = NameCache::from_tsv(
&text,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs,
)
.expect("header still good");
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").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}\tenUS\tHydraxian Waterlords\n\
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\t{CACHE_LOCALE}\tHydraxian Waterlords\n\
P\t7\t1\t1\t0\tMoamtester\n"
);
assert!(NameCache::from_tsv(
&v1,
"Hydraxian Waterlords",
benilla_formats::ClientLocale::EnUs
)
.is_none());
assert!(NameCache::from_tsv(&v1, "Hydraxian Waterlords").is_none());
}
#[test]
+2 -46
View File
@@ -43,7 +43,7 @@ pub(crate) fn mods_now(keys: &ButtonInput<KeyCode>) -> Mods {
pub(crate) enum CharFilter {
/// Everything printable (the login boxes).
Any,
/// Unicode letters (the character-name box); the server enforces its naming policy.
/// ASCII letters only (the character-name box).
Letters,
}
@@ -51,7 +51,7 @@ impl CharFilter {
fn allows(self, c: char) -> bool {
match self {
CharFilter::Any => true,
CharFilter::Letters => c.is_alphabetic(),
CharFilter::Letters => c.is_ascii_alphabetic(),
}
}
@@ -163,50 +163,6 @@ 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");
+5 -21
View File
@@ -162,7 +162,7 @@ pub(super) fn feed_party(
let area_moved = fed.area.moved(here.area().map_or(u64::MAX, u64::from));
let group_changed = group.is_changed();
let index_changed = index.is_changed();
let look = pets::Lookup::new(&index, &stores, &names, types, tables.names());
let look = pets::Lookup::new(&index, &stores, &names, types);
// 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,7 +325,6 @@ pub(super) fn feed_party(
own_group.clone(),
chr,
types,
tables.names(),
)
});
if fed.units[i] != snap {
@@ -394,8 +393,7 @@ 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, tables.names());
let mut s = crate::ui_unit::snapshot(store, *guid, name, 0, chr, types);
s.is_player = true;
s.raid_target = group.raid_target_index(*guid);
s.faction_group = own_group.clone();
@@ -412,7 +410,6 @@ pub(super) fn feed_party(
own_group.clone(),
chr,
types,
tables.names(),
))
}
});
@@ -755,7 +752,6 @@ 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>,
@@ -768,18 +764,11 @@ 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,
strings,
),
Some(store) => {
crate::ui_unit::snapshot(store, m.guid, Some(m.name.clone()), 0, classes, types)
}
// 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
@@ -1537,7 +1526,6 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert_eq!(
(s.health, s.max_health),
@@ -1561,7 +1549,6 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert_eq!((bare.health, bare.max_health, bare.power), (0, 0, 0));
assert!(bare.exists);
@@ -1590,7 +1577,6 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert!(
s.dead,
@@ -1610,7 +1596,6 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert!(s.ghost);
assert!(!s.dead, "a released ghost is not `dead` — only a ghost");
@@ -1628,7 +1613,6 @@ mod tests {
None,
None,
Default::default(),
None,
);
assert!(s.dead);
}
+1 -4
View File
@@ -53,7 +53,6 @@ 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> {
@@ -62,7 +61,6 @@ 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,
@@ -70,7 +68,6 @@ impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
names: &names.cache,
commands: &names.commands,
types,
strings,
}
}
@@ -152,7 +149,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, look.strings)
crate::ui_unit::snapshot(store, guid, name, 0, None, look.types)
}
None => record_state(
group,
+1 -2
View File
@@ -48,7 +48,6 @@ pub(super) fn feed_pet_unit(
commands: Res<NetCommands>,
// The form table, the creature-type resolver's first stage.
spells: Option<Res<crate::ui_action::Spells>>,
strings: Option<Res<crate::glue_strings::GlueStrings>>,
mut memory: Local<crate::ui_script::VmMemo<PetUnitMemory>>,
) {
let Some(mut script) = script else {
@@ -88,7 +87,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, strings.as_deref())
snapshot(store, pet_guid, name, 0, None, types)
});
let dirty = match (&fresh, &memory.pushed) {
@@ -133,7 +133,7 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
.collect();
s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
s.set_realm_name("Harness");
let player = super::seat_from_roster(&roster(), None).expect("a pending pick seats a player");
let player = super::seat_from_roster(&roster()).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(), None).expect("a pending pick seats a player");
let seat = super::seat_from_roster(&roster()).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(), None).is_none(),
super::seat_from_roster(&crate::char_select::Roster::default()).is_none(),
"no pick, no seat — a capture must load exactly as it did before"
);
}
+8 -31
View File
@@ -343,12 +343,7 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
// streams in.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(|roster| {
record_from_roster(
roster,
world.get_resource::<crate::glue_strings::GlueStrings>(),
)
})
.and_then(record_from_roster)
{
script.set_player_record(record);
}
@@ -361,15 +356,9 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
script.set_player_xp(live.xp.0, live.xp.1);
}
None => {
if let Some(seat) =
world
.get_resource::<crate::char_select::Roster>()
.and_then(|roster| {
seat_from_roster(
roster,
world.get_resource::<crate::glue_strings::GlueStrings>(),
)
})
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
{
script.set_unit("player", Some(seat));
}
@@ -504,25 +493,14 @@ fn roster_sex(gender: u8) -> u8 {
/// the descriptor.
pub(crate) fn record_from_roster(
roster: &crate::char_select::Roster,
strings: Option<&crate::glue_strings::GlueStrings>,
) -> Option<benilla_ui::script::PlayerRecord> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
Some(benilla_ui::script::PlayerRecord {
name: row.name.clone(),
race: race.map(|(n, f)| {
(
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(),
)
}),
race: race.map(|(n, f)| (n.to_string(), f.to_string())),
class: class.map(|(n, f)| (n.to_string(), f.to_string())),
sex: roster_sex(row.gender),
})
}
@@ -534,7 +512,6 @@ pub(crate) fn record_from_roster(
/// to say.
pub(crate) fn seat_from_roster(
roster: &crate::char_select::Roster,
strings: Option<&crate::glue_strings::GlueStrings>,
) -> Option<benilla_ui::script::UnitState> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
@@ -547,9 +524,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, _)| strings.map_or(n, |s| s.race_name(row.race)).to_string()),
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| strings.map_or(n, |s| s.class_name(row.class)).to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
sex,
is_player: true,
@@ -70,15 +70,7 @@ 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(),
None,
)
crate::ui_unit::snapshot(&store, 0, Some(name.into()), 0, None, Default::default())
}
/// Every texture path drawn this frame, with its vertex tint.
@@ -53,90 +53,6 @@ fn roster_named(name: &str, guid: u64) -> Roster {
Roster::with_pending_pick(vec![row], guid)
}
#[test]
fn ruru_player_names_reach_the_character_panel_and_live_units() {
let data = benilla_formats::wow_data_or_skip!();
if benilla_formats::ClientLocale::RuRu
.pack_folder(&data)
.is_none()
{
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
for (locale, race, class, line) in [
(
benilla_formats::ClientLocale::RuRu,
"Человек",
"Воин",
"Человек, Воин 60-го уровня",
),
(
benilla_formats::ClientLocale::EnUs,
"Human",
"Warrior",
"Level 60 Human Warrior",
),
] {
let mut chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
let strings = crate::glue_strings::table_from_chain(&mut chain);
let roster = roster_named("Александр", 7);
let record = super::lifecycle::record_from_roster(&roster, Some(&strings)).unwrap();
let mut seat = super::seat_from_roster(&roster, Some(&strings)).unwrap();
seat.level = 60;
let mut vm = benilla_ui::script::UiScript::new_with_locale(locale.code()).unwrap();
vm.set_screen_size(1024.0, 768.0);
vm.set_player_record(record);
vm.set_unit("player", Some(seat));
for file in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&vm, file);
}
vm.run("PaperDollFrame_SetLevel()").unwrap();
let shown: String = vm.eval("return CharacterLevelText:GetText()").unwrap();
eprintln!("locale-panel: {} text={shown}", locale.code());
assert_eq!(shown, line);
assert_eq!(
vm.eval::<(String, String)>("return UnitRace('player')")
.unwrap(),
(race.into(), "Human".into())
);
assert_eq!(
vm.eval::<(String, String)>("return UnitClass('player')")
.unwrap(),
(class.into(), "WARRIOR".into())
);
// The descriptor path, including a /reload seat, and a different race/class on target.
for (race_id, class_id) in [(1, 1), (6, 11)] {
let store = crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
36,
race_id | (class_id << 8),
)]));
let state = crate::ui_unit::snapshot(
&store,
8,
None,
0,
None,
Default::default(),
Some(&strings),
);
assert_eq!(
state.race.as_deref(),
Some(strings.race_name(race_id as u8))
);
assert_eq!(
state.class.as_deref(),
Some(strings.class_name(class_id as u8))
);
vm.set_unit("target", Some(state));
assert_eq!(
vm.eval::<String>("return UnitClass('target')").unwrap(),
strings.class_name(class_id as u8)
);
}
assert!(vm.take_errors().is_empty());
}
}
/// A hermetic state folder holding the probe addon, and the guards that point the client at it;
/// every guard must outlive the world.
fn hermetic_probe(tag: &str) -> (std::path::PathBuf, EnvGuard, EnvGuard) {
+1 -1
View File
@@ -138,7 +138,7 @@ fn drive_mouseover_tooltip(
let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store);
// The hovered guid, the pair `0x492890` writes to `0xb4e2c8`/`0xb4e2cc` and the token
// resolver `0x515970` reads for `"mouseover"`, so `UnitIsUnit` can match it.
let mut s = snapshot(store, guid, name, reaction, chr, types, tables.names());
let mut s = snapshot(store, guid, name, reaction, chr, types);
enrich_unit(
&mut s,
guid,
+15 -18
View File
@@ -64,8 +64,8 @@ pub(super) struct ViewCtx<'a, 'w, 's> {
/// Build one spell's tooltip view, every cell resolved here where the catalogs live, and what it
/// read ([`Deps`]). The range, cast, cooldown, form and item cells fill their `GlobalStrings.lua`
/// keys, including the power-specific cost templates; the reagents and chance cells are composed
/// in English, where the reference fills `SPELL_REAGENTS` and `CHANCE_TO_*`.
/// 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_*`.
pub(super) fn build_view(
spell_id: u32,
spells: &Spells,
@@ -154,27 +154,24 @@ 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 key = match d.power_type {
0 => "MANA_COST",
1 => "RAGE_COST",
2 => "FOCUS_COST",
3 => "ENERGY_COST",
4 => "HAPPINESS_COST",
_ => "HEALTH_COST",
let unit = match d.power_type {
0 => "Mana",
1 => "Rage",
2 => "Focus",
3 => "Energy",
4 => "Happiness",
_ => "Health",
};
if resolved_cost == 0 && d.mana_per_second == 0 {
None
} else if d.mana_per_second > 0 {
keyed(
vctx.get,
&format!("{key}_PER_TIME"),
&[
Arg::from(resolved_cost / div),
Arg::from(d.mana_per_second / div),
],
)
Some(format!(
"{} {unit}, plus {} per sec",
resolved_cost / div,
d.mana_per_second / div
))
} else {
keyed(vctx.get, key, &[Arg::from(resolved_cost / div)])
Some(format!("{} {unit}", resolved_cost / div))
}
};
// The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then
+1 -62
View File
@@ -33,13 +33,9 @@ type Getter = dyn Fn(&str) -> Option<String>;
impl TestCtx {
pub(super) fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
let vm = benilla_ui::script::UiScript::new().expect("VM");
crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua");
Self::with_vm(vm)
}
fn with_vm(vm: benilla_ui::script::UiScript) -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
Self {
items: Items::default(),
commands: NetCommands(tx),
@@ -109,63 +105,6 @@ 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)])
+4 -34
View File
@@ -804,20 +804,14 @@ 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, plus the selected race/class display names.
/// Each is absent without game data.
/// the creature-type resolver's first stage reads. Each is absent without game data.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct SnapshotTables<'w> {
classes: Option<Res<'w, crate::chr_classes::ChrClassTable>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
names: Option<Res<'w, crate::glue_strings::GlueStrings>>,
}
impl SnapshotTables<'_> {
pub(crate) fn names(&self) -> Option<&crate::glue_strings::GlueStrings> {
self.names.as_deref()
}
/// `ChrClasses.dbc`; without it no class has a relic slot, the reference's bounds leg.
pub(crate) fn classes(&self) -> Option<&ChrClasses> {
self.classes.as_deref().map(|t| &t.0)
@@ -851,7 +845,6 @@ 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);
@@ -893,19 +886,9 @@ 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),
// 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: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| {
strings
.map_or(n, |s| s.class_name(class_id.unwrap_or(0)))
.to_string()
}),
class: class.map(|(n, _)| n.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.
@@ -1227,15 +1210,7 @@ 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),
tables.names(),
);
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
s.is_player = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions);
@@ -1410,7 +1385,6 @@ 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()?;
@@ -2549,7 +2523,6 @@ mod tests {
0,
None,
Default::default(),
None,
)
.pvp_team
};
@@ -3266,7 +3239,6 @@ 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));
@@ -3281,7 +3253,6 @@ mod tests {
0,
None,
Default::default(),
None,
);
assert_eq!(
(feigning.health, feigning.max_health),
@@ -3332,7 +3303,6 @@ mod tests {
0,
None,
Default::default(),
None,
)
};
let player = snap(0x19);
+1 -11
View File
@@ -32,7 +32,6 @@ 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<'_> {
@@ -45,15 +44,7 @@ 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,
self.strings,
);
let mut s = snapshot(store, guid, name, reaction, self.classes, self.types);
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);
@@ -152,7 +143,6 @@ pub(super) fn feed_chain_units(
self_store: self_q.iter().next(),
classes: tables.classes(),
types: tables.types(&names),
strings: tables.names(),
};
let built: HashMap<u64, UnitState> = ends
.iter()
@@ -1,108 +0,0 @@
//! 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,8 +15,6 @@ 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 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-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-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world