Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 443bda246d |
+1
-14
@@ -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
|
||||
|
||||
@@ -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, "Охотник");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -758,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>,
|
||||
@@ -777,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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user