Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80fe28f9b5 | |||
| 9c5a1c1f6b | |||
| 2ef92a2032 | |||
| ac0a4fbc47 | |||
| 53b0907393 |
+14
-1
@@ -46,11 +46,19 @@ 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; the warm incremental rebuild cost is ~0.
|
||||
# Stormwind drops from a pinned 60 fps to ~45.
|
||||
[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.
|
||||
|
||||
@@ -71,3 +79,8 @@ 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
|
||||
|
||||
@@ -235,7 +235,8 @@ impl CreateSelection {
|
||||
.and_then(|c| c.0.classes_for_race(1).first().copied())
|
||||
.unwrap_or(1);
|
||||
self.dials = [0; 5];
|
||||
self.name.set_text("");
|
||||
// CharacterCreate.xml declares CharacterCreateNameEdit letters="12", not a byte cap.
|
||||
self.name = textinput::field(12, false);
|
||||
self.creating = false;
|
||||
}
|
||||
|
||||
@@ -616,6 +617,24 @@ fn char_result_text<'a>(strings: &'a GlueStrings, code: u8) -> &'a str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_russian_character_name_reaches_the_wire_unchanged() {
|
||||
let mut sel = super::CreateSelection::default();
|
||||
sel.reset(None);
|
||||
assert_eq!(sel.name.max_letters, 12);
|
||||
sel.name.insert("Ёлочка");
|
||||
let req = sel.request();
|
||||
assert_eq!(req.name, "Ёлочка");
|
||||
let body = benilla_protocol::messages::char_create(&req);
|
||||
assert_eq!(&body[..req.name.len()], "Ёлочка".as_bytes());
|
||||
assert_eq!(body[req.name.len()], 0);
|
||||
assert_eq!(body.len(), req.name.len() + 10);
|
||||
sel.name.set_text("");
|
||||
sel.name.insert("Абвгдежзийклмноп");
|
||||
assert_eq!(sel.request().name, "Абвгдежзийкл");
|
||||
assert_eq!(sel.request().name.len(), 24);
|
||||
}
|
||||
|
||||
/// The chain is assembled by the loader's own helper, `GlueLocalization.lua`'s `Localize()`
|
||||
/// over `GlueStrings.lua`, so this asserts what the running client shows.
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
use bevy::input::mouse::{AccumulatedMouseMotion, MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::{class_name, race_name};
|
||||
use crate::entities::CharCreate;
|
||||
use crate::glue_strings::GlueStrings;
|
||||
|
||||
@@ -123,8 +122,8 @@ pub(super) fn refresh_dynamic(
|
||||
if alliance { "Alliance" } else { "Horde" },
|
||||
)
|
||||
.to_string(),
|
||||
DynText::InfoTitle(InfoKind::Race) => race_name(sel.race).to_string(),
|
||||
DynText::InfoTitle(InfoKind::Class) => class_name(sel.class).to_string(),
|
||||
DynText::InfoTitle(InfoKind::Race) => strings.race_name(sel.race).to_string(),
|
||||
DynText::InfoTitle(InfoKind::Class) => strings.class_name(sel.class).to_string(),
|
||||
// Never trimmed: each `FACTION_INFO_*`, `RACE_INFO_*` and `CLASS_*` string opens with
|
||||
// eight spaces, which is the reference's first-line clearance past the header icon.
|
||||
DynText::InfoBody(InfoKind::Faction) => strings
|
||||
@@ -155,7 +154,7 @@ pub(super) fn refresh_dynamic(
|
||||
.to_string(),
|
||||
DynText::ClassSlotLabel(i) => classes
|
||||
.get(*i as usize)
|
||||
.map(|&c| class_name(c).to_string())
|
||||
.map(|&c| strings.class_name(c).to_string())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
if t.0 != new {
|
||||
@@ -402,3 +401,61 @@ pub(super) fn scroll_visuals(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod localization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn russian_creation_headers_and_class_labels_follow_selection() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut chain =
|
||||
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
|
||||
.unwrap();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<CreateSelection>()
|
||||
.init_resource::<GlueArt>()
|
||||
.insert_resource(crate::glue_strings::table_from_chain(&mut chain))
|
||||
.insert_resource(CharCreate(
|
||||
benilla_formats::CharCreateCatalog::load(&mut chain).unwrap(),
|
||||
))
|
||||
.add_systems(Update, refresh_dynamic);
|
||||
app.world_mut().resource_mut::<CreateSelection>().race = 1;
|
||||
app.world_mut().resource_mut::<CreateSelection>().class = 1;
|
||||
let race = app
|
||||
.world_mut()
|
||||
.spawn((DynText::InfoTitle(InfoKind::Race), Text::default()))
|
||||
.id();
|
||||
let class = app
|
||||
.world_mut()
|
||||
.spawn((DynText::InfoTitle(InfoKind::Class), Text::default()))
|
||||
.id();
|
||||
let label = app
|
||||
.world_mut()
|
||||
.spawn((DynText::ClassSlotLabel(0), Text::default()))
|
||||
.id();
|
||||
let second_label = app
|
||||
.world_mut()
|
||||
.spawn((DynText::ClassSlotLabel(1), Text::default()))
|
||||
.id();
|
||||
app.update();
|
||||
assert_eq!(app.world().get::<Text>(race).unwrap().0, "Человек");
|
||||
assert_eq!(app.world().get::<Text>(class).unwrap().0, "Воин");
|
||||
assert_eq!(app.world().get::<Text>(label).unwrap().0, "Воин");
|
||||
assert_eq!(app.world().get::<Text>(second_label).unwrap().0, "Паладин");
|
||||
app.world_mut().resource_mut::<CreateSelection>().race = 6;
|
||||
app.world_mut().resource_mut::<CreateSelection>().class = 11;
|
||||
app.update();
|
||||
assert_eq!(app.world().get::<Text>(race).unwrap().0, "Таурен");
|
||||
assert_eq!(app.world().get::<Text>(class).unwrap().0, "Друид");
|
||||
assert_eq!(app.world().get::<Text>(label).unwrap().0, "Воин");
|
||||
assert_eq!(app.world().get::<Text>(second_label).unwrap().0, "Охотник");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use bevy::prelude::*;
|
||||
use bevy::ui_render::ui_material::MaterialNode;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::char_select::{race_name, wow_font};
|
||||
use crate::char_select::wow_font;
|
||||
use crate::entities::CharCreate;
|
||||
use crate::glue_strings::GlueStrings;
|
||||
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
|
||||
@@ -280,7 +280,7 @@ fn left_tower(
|
||||
Some(DynIcon::Race(race)),
|
||||
None,
|
||||
None::<DynText>,
|
||||
race_name(race),
|
||||
strings.race_name(race),
|
||||
art,
|
||||
s,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ use super::wow_font;
|
||||
pub(super) struct DeleteDialog {
|
||||
pub(super) open: bool,
|
||||
/// The snapshotted target: guid, name, level, class name.
|
||||
target: Option<(u64, String, u8, &'static str)>,
|
||||
target: Option<(u64, String, u8, String)>,
|
||||
/// The confirm box's text; the reference's `letters="32"` cap is its `max_letters`.
|
||||
pub(super) typed: EditBoxState,
|
||||
/// The spawned dialog root, while up.
|
||||
@@ -35,9 +35,9 @@ pub(super) struct DeleteDialog {
|
||||
}
|
||||
|
||||
impl DeleteDialog {
|
||||
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &'static str) {
|
||||
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &str) {
|
||||
self.open = true;
|
||||
self.target = Some((guid, name, level, class));
|
||||
self.target = Some((guid, name, level, class.to_string()));
|
||||
self.typed.set_text("");
|
||||
}
|
||||
|
||||
@@ -204,17 +204,10 @@ fn spawn_dialog(
|
||||
let (name, level, class) = dialog
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|(_, n, l, c)| (n.clone(), *l, *c))
|
||||
.map(|(_, n, l, c)| (n.clone(), *l, c.as_str()))
|
||||
.unwrap_or_default();
|
||||
// `CONFIRM_CHAR_DELETE`'s `|cffffffff` span splits it: gold lead above, white identity below.
|
||||
let raw = strings.text(
|
||||
"CONFIRM_CHAR_DELETE",
|
||||
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
|
||||
);
|
||||
let filled = raw
|
||||
.replacen("%s", &name, 1)
|
||||
.replacen("%d", &level.to_string(), 1)
|
||||
.replacen("%s", class, 1);
|
||||
let filled = delete_prompt(strings, &name, level, class);
|
||||
let (lead, identity) = match filled.split_once("|cffffffff") {
|
||||
Some((a, b)) => (a.trim_end_matches('\n').to_string(), b.replace("|r", "")),
|
||||
None => (filled.clone(), String::new()),
|
||||
@@ -452,3 +445,41 @@ fn spawn_dialog(
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
fn delete_prompt(strings: &GlueStrings, name: &str, level: u8, class: &str) -> String {
|
||||
let template = strings.text(
|
||||
"CONFIRM_CHAR_DELETE",
|
||||
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
|
||||
);
|
||||
benilla_ui::strings::fill(template, &[name.into(), level.into(), class.into()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod localization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn russian_delete_prompt_reorders_name_class_and_level_without_deleting() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut chain =
|
||||
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
|
||||
.unwrap();
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
let prompt = delete_prompt(&strings, "Qera", 34, strings.class_name(11));
|
||||
assert_eq!(
|
||||
prompt.split_once("|cffffffff").unwrap().1,
|
||||
"Qera, Друид 34-го уровня|r"
|
||||
);
|
||||
assert_eq!(
|
||||
delete_prompt(&GlueStrings::default(), "Agvin", 12, "Warrior"),
|
||||
"Do you want to delete\n|cffffffffAgvin Level 12 Warrior|r?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::sound::GlueSound;
|
||||
|
||||
use super::dialog::DeleteDialog;
|
||||
use super::screen::SelectAction;
|
||||
use super::{class_name, send_pick, ClientState, Roster};
|
||||
use super::{send_pick, ClientState, Roster};
|
||||
|
||||
use crate::glue::{drag_yaw, ROTATE_RATE};
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(super) fn select_input(
|
||||
mut realms: ResMut<crate::realm_select::Realms>,
|
||||
pick: Res<CharPick>,
|
||||
mut dialog: ResMut<DeleteDialog>,
|
||||
strings: Option<Res<crate::glue_strings::GlueStrings>>,
|
||||
mut panel: ResMut<super::addons::AddonsPanel>,
|
||||
choice: Res<crate::net::RealmChoice>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
@@ -71,7 +72,9 @@ pub(super) fn select_input(
|
||||
// With no selection the click only plays the sound (`selectedIndex > 0` gate).
|
||||
sounds.write(GlueSound("gsCharacterSelectionDelCharacter"));
|
||||
if let Some(c) = roster.selected_char() {
|
||||
dialog.open_for(c.guid, c.name.clone(), c.level, class_name(c.class));
|
||||
let empty = crate::glue_strings::GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
dialog.open_for(c.guid, c.name.clone(), c.level, strings.class_name(c.class));
|
||||
}
|
||||
}
|
||||
SelectAction::CreateChar => {
|
||||
|
||||
@@ -192,7 +192,7 @@ impl Roster {
|
||||
if let Some(row) = self
|
||||
.chars
|
||||
.iter()
|
||||
.position(|c| c.name.eq_ignore_ascii_case(name))
|
||||
.position(|c| same_character_name(&c.name, name))
|
||||
{
|
||||
self.select(Some(row));
|
||||
self.just_created = None;
|
||||
@@ -262,6 +262,13 @@ impl Roster {
|
||||
/// default `"0"`, pointer at `[0x882674]`), written engine-side, named by no GlueXML.
|
||||
pub(crate) const CVAR_LAST_CHARACTER: &str = "lastCharacterIndex";
|
||||
|
||||
/// vmangos normalizes UTF-8 player names to title case, including Cyrillic (normalizePlayerName).
|
||||
fn same_character_name(a: &str, b: &str) -> bool {
|
||||
a.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.eq(b.chars().flat_map(char::to_lowercase))
|
||||
}
|
||||
|
||||
/// The stored index is 0-based, `"0"` the first character: the engine's selection cell
|
||||
/// `[0x83856c]` printed with `"%d"` (`0x473470` decrements the Lua's 1-based row).
|
||||
/// `CVar::SaveConfig` (`0x63d980`) skips a value equal to the default, so row 0 writes no line.
|
||||
@@ -374,7 +381,7 @@ fn apply_roster_policy(
|
||||
match roster
|
||||
.chars
|
||||
.iter()
|
||||
.position(|c| c.name.eq_ignore_ascii_case(&name))
|
||||
.position(|c| same_character_name(&c.name, &name))
|
||||
{
|
||||
Some(row) => {
|
||||
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
|
||||
@@ -390,7 +397,7 @@ fn apply_roster_policy(
|
||||
match roster
|
||||
.chars
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(&name))
|
||||
.find(|c| same_character_name(&c.name, &name))
|
||||
{
|
||||
Some(c) => {
|
||||
let guid = c.guid;
|
||||
@@ -758,6 +765,7 @@ fn clear_select_entry(mut commands: Commands) {
|
||||
/// is up, with `<typed>` pre-typed, for a shot.
|
||||
fn debug_select_dialog(
|
||||
roster: Res<Roster>,
|
||||
strings: Option<Res<crate::glue_strings::GlueStrings>>,
|
||||
mut dialog: ResMut<dialog::DeleteDialog>,
|
||||
time: Res<Time>,
|
||||
entered_at: Res<CharSelectEnteredAt>,
|
||||
@@ -776,7 +784,9 @@ fn debug_select_dialog(
|
||||
let Some(c) = roster.selected_char() else {
|
||||
return;
|
||||
};
|
||||
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, class_name(c.class));
|
||||
let empty = crate::glue_strings::GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, strings.class_name(c.class));
|
||||
dialog.open_for(guid, name, level, class);
|
||||
dialog.typed.set_text(&typed);
|
||||
info!("char select: dialog instrument opened the delete confirm");
|
||||
@@ -1174,13 +1184,20 @@ mod tests {
|
||||
/// The name key is case-insensitive: vmangos's `normalizePlayerName` recases a created name.
|
||||
#[test]
|
||||
fn the_created_name_matches_the_servers_normalized_spelling() {
|
||||
let mut roster = Roster {
|
||||
chars: vec![character(1, "Kerwind"), character(2, "Zzbullone")],
|
||||
selected: Some(0),
|
||||
..default()
|
||||
};
|
||||
roster.note_created("ZZBULLONE".to_string()); // as typed
|
||||
assert_eq!(roster.selected, Some(1));
|
||||
for (typed, stored) in [
|
||||
("ZZBULLONE", "Zzbullone"),
|
||||
("ЁЛОЧКА", "Ёлочка"),
|
||||
("ёлочка", "Ёлочка"),
|
||||
] {
|
||||
let mut roster = Roster {
|
||||
chars: vec![character(1, "Kerwind"), character(2, stored)],
|
||||
selected: Some(0),
|
||||
..default()
|
||||
};
|
||||
roster.note_created(typed.to_string());
|
||||
assert_eq!(roster.selected, Some(1), "{typed} must match {stored}");
|
||||
assert!(roster.just_created.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── The remembered character (`lastCharacterIndex`) ───────────────────────────
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::net::NetStatus;
|
||||
use crate::portrait::{GlueLook, GluePreview, SelectLook};
|
||||
|
||||
use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS};
|
||||
use super::{class_name, Roster};
|
||||
use super::Roster;
|
||||
|
||||
/// Refill the rows, the selected name and the realm banner when the roster changes or the screen
|
||||
/// was just spawned (a return from the create screen finds the roster unchanged).
|
||||
@@ -61,10 +61,10 @@ pub(super) fn refresh_list(
|
||||
} else {
|
||||
("CHARACTER_SELECT_INFO", "Level %d %s")
|
||||
};
|
||||
strings
|
||||
.text(key.0, key.1)
|
||||
.replacen("%d", &c.level.to_string(), 1)
|
||||
.replacen("%s", class_name(c.class), 1)
|
||||
benilla_ui::strings::fill(
|
||||
strings.text(key.0, key.1),
|
||||
&[c.level.into(), strings.class_name(c.class).into()],
|
||||
)
|
||||
}
|
||||
_ => areas
|
||||
.as_deref()
|
||||
@@ -201,6 +201,50 @@ pub(super) fn feed_glue_preview(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn russian_roster_rows_fill_the_real_level_template_including_ghosts() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut chain =
|
||||
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
|
||||
.unwrap();
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
let mut live = crate::char_select::test_character(1, "Agvin");
|
||||
live.level = 12;
|
||||
let mut ghost = crate::char_select::test_character(2, "Qera");
|
||||
ghost.level = 34;
|
||||
ghost.class = 11;
|
||||
ghost.flags = benilla_protocol::CHARACTER_FLAG_GHOST;
|
||||
let mut app = App::new();
|
||||
app.insert_resource(Roster::with_pending_pick(vec![live, ghost], 1))
|
||||
.init_resource::<NetStatus>()
|
||||
.insert_resource(strings)
|
||||
.add_systems(Update, refresh_list);
|
||||
let live = app
|
||||
.world_mut()
|
||||
.spawn((RowText::Info(0), Text::default()))
|
||||
.id();
|
||||
let ghost = app
|
||||
.world_mut()
|
||||
.spawn((RowText::Info(1), Text::default()))
|
||||
.id();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().get::<Text>(live).unwrap().0,
|
||||
"Воин 12-го уровня"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().get::<Text>(ghost).unwrap().0,
|
||||
"Друид 34-го уровня (призрак)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The composition order of `CharacterSelect.lua:48-64`.
|
||||
#[test]
|
||||
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
|
||||
|
||||
@@ -215,7 +215,7 @@ mod tests {
|
||||
forms: Some(&forms),
|
||||
};
|
||||
let word = |store: &ObjectStore| {
|
||||
crate::ui_unit::snapshot(store, 0, None, 0, None, sources).creature_type_name
|
||||
crate::ui_unit::snapshot(store, 0, None, 0, None, sources, None).creature_type_name
|
||||
};
|
||||
assert_eq!(word(&store(0, 1, 1)).as_deref(), Some("Beast"), "cat form");
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,23 +17,41 @@ const GLUE_LOCALIZATION: &str = "Interface\\GlueXML\\GlueLocalization.lua";
|
||||
|
||||
/// The glue string table; empty without client data, and callers fall back to built-in captions.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GlueStrings(HashMap<String, String>);
|
||||
pub(crate) struct GlueStrings {
|
||||
strings: HashMap<String, String>,
|
||||
names: benilla_formats::CharacterNames,
|
||||
}
|
||||
|
||||
impl GlueStrings {
|
||||
/// A table from parsed pairs, for tests that resolve against the real file.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_map(map: HashMap<String, String>) -> Self {
|
||||
Self(map)
|
||||
Self {
|
||||
strings: map,
|
||||
names: benilla_formats::CharacterNames::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pairs back out, for a test that has to alter one key and re-resolve.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_map(self) -> HashMap<String, String> {
|
||||
self.0
|
||||
self.strings
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).map(String::as_str)
|
||||
self.strings.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn race_name(&self, race: u8) -> &str {
|
||||
self.names
|
||||
.race(race)
|
||||
.unwrap_or(crate::char_select::race_name(race))
|
||||
}
|
||||
|
||||
pub(crate) fn class_name(&self, class: u8) -> &str {
|
||||
self.names
|
||||
.class(class)
|
||||
.unwrap_or(crate::char_select::class_name(class))
|
||||
}
|
||||
|
||||
/// The string for a key, falling back to a built-in caption.
|
||||
@@ -48,10 +66,14 @@ pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<World
|
||||
let mut table = GlueStrings::default();
|
||||
if let Some(assets) = assets {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
match benilla_formats::CharacterNames::load(&mut chain) {
|
||||
Ok(names) => table.names = names,
|
||||
Err(e) => warn!("character names unavailable ({e:#}) — built-in names only"),
|
||||
}
|
||||
match chain.read_file(GLUE_STRINGS) {
|
||||
Ok(bytes) => {
|
||||
table.0 = parse_glue_strings(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} entries", table.0.len());
|
||||
table.strings = parse_glue_strings(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} entries", table.strings.len());
|
||||
}
|
||||
Err(e) => warn!("glue strings unavailable ({e:#}) — built-in captions only"),
|
||||
}
|
||||
@@ -59,7 +81,7 @@ pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<World
|
||||
Ok(bytes) => {
|
||||
let patch = parse_localize_overrides(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} locale overrides", patch.len());
|
||||
table.0.extend(patch);
|
||||
table.strings.extend(patch);
|
||||
}
|
||||
Err(e) => warn!("glue localization unavailable ({e:#}) — unpatched glue strings"),
|
||||
}
|
||||
@@ -142,7 +164,10 @@ pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueString
|
||||
.read_file(GLUE_LOCALIZATION)
|
||||
.expect("GlueLocalization.lua");
|
||||
map.extend(parse_localize_overrides(&String::from_utf8_lossy(&patch)));
|
||||
GlueStrings::from_map(map)
|
||||
GlueStrings {
|
||||
strings: map,
|
||||
names: benilla_formats::CharacterNames::load(chain).expect("character names"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -251,6 +276,6 @@ end
|
||||
|
||||
// The patch overwrites keys; it does not replace the table.
|
||||
assert!(table.get("FACTION_INFO_HORDE").is_some(), "base lost");
|
||||
assert!(base.len() > 100 && table.0.len() >= base.len());
|
||||
assert!(base.len() > 100 && table.strings.len() >= base.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_assets::{LockRecover, WorldAssets};
|
||||
use benilla_formats::ClientLocale;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::Roster;
|
||||
@@ -41,6 +43,7 @@ impl Plugin for NamePersistPlugin {
|
||||
pub(crate) struct NameCacheFile {
|
||||
/// The realm the loaded file belongs to; a change triggers a load.
|
||||
realm: Option<String>,
|
||||
locale: ClientLocale,
|
||||
path: Option<PathBuf>,
|
||||
/// [`NameCache::generation`] at the last write: the dirty bit, which ticks on an answer.
|
||||
saved_generation: u64,
|
||||
@@ -50,6 +53,7 @@ pub(crate) struct NameCacheFile {
|
||||
/// Loads the realm's cache the first frame its identity is known, and on a realm change.
|
||||
fn load_name_cache(
|
||||
roster: Res<Roster>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut file: ResMut<NameCacheFile>,
|
||||
mut names: ResMut<NameCache>,
|
||||
) {
|
||||
@@ -59,8 +63,17 @@ fn load_name_cache(
|
||||
if file.realm.as_deref() == Some(realm.as_str()) {
|
||||
return;
|
||||
}
|
||||
// The chain's locale is fixed at boot; read it once per realm, outside the frame loop.
|
||||
let locale = assets
|
||||
.as_deref()
|
||||
.map_or(ClientLocale::EnUs, |a| a.chain.lock_recover().locale());
|
||||
let path = crate::local_state::name_cache_path(&realm);
|
||||
// A realm change must not retain templates loaded for the previous realm.
|
||||
if file.realm.is_some() {
|
||||
names.install_persisted(NameCache::default());
|
||||
}
|
||||
file.realm = Some(realm.clone());
|
||||
file.locale = locale;
|
||||
file.path = path.clone();
|
||||
file.saved_generation = names.generation();
|
||||
let Some(path) = path else {
|
||||
@@ -69,7 +82,7 @@ fn load_name_cache(
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return; // no cache yet
|
||||
};
|
||||
match NameCache::from_tsv(&text, &realm) {
|
||||
match NameCache::from_tsv(&text, &realm, locale) {
|
||||
Some(loaded) => {
|
||||
let n = loaded.len();
|
||||
// Install only the file's records: this runs as the pick is in flight, beside the
|
||||
@@ -117,7 +130,7 @@ fn write_now(file: &mut NameCacheFile, names: &NameCache) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm)) {
|
||||
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm, file.locale)) {
|
||||
Ok(()) => {
|
||||
file.saved_generation = names.generation();
|
||||
debug!("names: wrote {} records to {}", names.len(), path.display());
|
||||
@@ -205,7 +218,11 @@ mod tests {
|
||||
);
|
||||
let path = crate::local_state::name_cache_path("Realm").expect("a state folder");
|
||||
std::fs::create_dir_all(path.parent().expect("cache dir")).expect("cache dir");
|
||||
std::fs::write(&path, on_disk.to_tsv("Realm")).expect("write the realm cache");
|
||||
std::fs::write(
|
||||
&path,
|
||||
on_disk.to_tsv("Realm", benilla_formats::ClientLocale::EnUs),
|
||||
)
|
||||
.expect("write the realm cache");
|
||||
|
||||
// The login has already seeded our own name and a pet's, as `net::session::connected` does.
|
||||
let mut names = NameCache::default();
|
||||
|
||||
@@ -320,15 +320,13 @@ const CACHE_MAGIC: &str = "benilla-namecache";
|
||||
const CACHE_FORMAT: u32 = 2;
|
||||
/// The client build, the reference's `0x16f3`.
|
||||
const CACHE_BUILD: u32 = 5875;
|
||||
/// In the header as in the reference's; benilla reads DBC locale slot 0 only.
|
||||
const CACHE_LOCALE: &str = "enUS";
|
||||
|
||||
impl NameCache {
|
||||
/// Serialize as TSV: the header line, then one line per creature record. In-flight asks are
|
||||
/// not written.
|
||||
pub(crate) fn to_tsv(&self, realm: &str) -> String {
|
||||
let mut out =
|
||||
format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{CACHE_LOCALE}\t{realm}\n");
|
||||
pub(crate) fn to_tsv(&self, realm: &str, locale: benilla_formats::ClientLocale) -> String {
|
||||
let locale = locale.code();
|
||||
let mut out = format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{locale}\t{realm}\n");
|
||||
for (entry, rec) in self.creatures.iter() {
|
||||
match rec {
|
||||
Some(r) => out.push_str(&format!(
|
||||
@@ -351,14 +349,18 @@ impl NameCache {
|
||||
|
||||
/// Rebuild a cache from [`Self::to_tsv`]'s output, `None` when the header differs; a
|
||||
/// malformed line is skipped.
|
||||
pub(crate) fn from_tsv(text: &str, realm: &str) -> Option<Self> {
|
||||
pub(crate) fn from_tsv(
|
||||
text: &str,
|
||||
realm: &str,
|
||||
locale: benilla_formats::ClientLocale,
|
||||
) -> Option<Self> {
|
||||
let mut lines = text.lines();
|
||||
let header: Vec<&str> = lines.next()?.split('\t').collect();
|
||||
if header.len() != 5
|
||||
|| header[0] != CACHE_MAGIC
|
||||
|| header[1] != CACHE_FORMAT.to_string()
|
||||
|| header[2] != CACHE_BUILD.to_string()
|
||||
|| header[3] != CACHE_LOCALE
|
||||
|| header[3] != locale.code()
|
||||
|| header[4] != realm
|
||||
{
|
||||
return None;
|
||||
@@ -551,6 +553,33 @@ pub(crate) mod net {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_russian_session_discards_english_creature_names_and_preserves_russian_names() {
|
||||
use benilla_formats::ClientLocale::{EnUs, RuRu};
|
||||
let mut cache = NameCache::default();
|
||||
cache.insert_creature(
|
||||
261,
|
||||
Some(CreatureRecord {
|
||||
name: "Guard Thomas".into(),
|
||||
subname: None,
|
||||
creature_type: 7,
|
||||
pet_family: 0,
|
||||
rank: 0,
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
let english = cache.to_tsv("R", EnUs);
|
||||
assert!(NameCache::from_tsv(&english, "R", RuRu).is_none());
|
||||
cache.creatures.get_mut(261).unwrap().name = "Стражник Томас".into();
|
||||
let russian = cache.to_tsv("R", RuRu);
|
||||
assert!(NameCache::from_tsv(&russian, "R", EnUs).is_none());
|
||||
let back = NameCache::from_tsv(&russian, "R", RuRu).unwrap();
|
||||
assert_eq!(back.creature_record(261).unwrap().name, "Стражник Томас");
|
||||
}
|
||||
use crossbeam_channel::TryRecvError;
|
||||
|
||||
/// Compose a guid the way the server does: `counter | (entry << 24) | (high << 48)`.
|
||||
@@ -668,8 +697,13 @@ mod tests {
|
||||
);
|
||||
cache.insert_creature(1234, None); // the server flagged the entry unknown
|
||||
|
||||
let text = cache.to_tsv("Hydraxian Waterlords");
|
||||
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
|
||||
let text = cache.to_tsv("Hydraxian Waterlords", benilla_formats::ClientLocale::EnUs);
|
||||
let back = NameCache::from_tsv(
|
||||
&text,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
)
|
||||
.expect("header matches");
|
||||
|
||||
// Present-and-None, not absent.
|
||||
assert!(back.creatures.answered(1234));
|
||||
@@ -697,7 +731,12 @@ mod tests {
|
||||
display_id: 1,
|
||||
}),
|
||||
);
|
||||
let back = NameCache::from_tsv(&cache.to_tsv("R"), "R").expect("header");
|
||||
let back = NameCache::from_tsv(
|
||||
&cache.to_tsv("R", benilla_formats::ClientLocale::EnUs),
|
||||
"R",
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
)
|
||||
.expect("header");
|
||||
assert_eq!(back.creature_record(70).unwrap().subname, None);
|
||||
}
|
||||
|
||||
@@ -723,12 +762,13 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
|
||||
let text = cache.to_tsv("R");
|
||||
let text = cache.to_tsv("R", benilla_formats::ClientLocale::EnUs);
|
||||
assert!(
|
||||
!text.contains("Moamtester") && !text.contains("Rex"),
|
||||
"no guid- or spawn-keyed name may reach the disk:\n{text}"
|
||||
);
|
||||
let back = NameCache::from_tsv(&text, "R").expect("header matches");
|
||||
let back = NameCache::from_tsv(&text, "R", benilla_formats::ClientLocale::EnUs)
|
||||
.expect("header matches");
|
||||
assert_eq!(
|
||||
back.peek(0x11),
|
||||
None,
|
||||
@@ -780,11 +820,19 @@ mod tests {
|
||||
fn a_header_that_differs_in_any_field_discards_the_whole_file() {
|
||||
let mut cache = NameCache::default();
|
||||
cache.insert_creature(1234, None);
|
||||
let good = cache.to_tsv("Hydraxian Waterlords");
|
||||
assert!(NameCache::from_tsv(&good, "Hydraxian Waterlords").is_some());
|
||||
let good = cache.to_tsv("Hydraxian Waterlords", benilla_formats::ClientLocale::EnUs);
|
||||
assert!(NameCache::from_tsv(
|
||||
&good,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs
|
||||
)
|
||||
.is_some());
|
||||
|
||||
// The realm: our field, not the reference's.
|
||||
assert!(NameCache::from_tsv(&good, "Another Realm").is_none());
|
||||
assert!(
|
||||
NameCache::from_tsv(&good, "Another Realm", benilla_formats::ClientLocale::EnUs)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let head = good.lines().next().unwrap();
|
||||
for (field, bad) in [
|
||||
@@ -797,23 +845,40 @@ mod tests {
|
||||
parts[field] = bad;
|
||||
let text = format!("{}\n{}", parts.join("\t"), good.lines().nth(1).unwrap());
|
||||
assert!(
|
||||
NameCache::from_tsv(&text, "Hydraxian Waterlords").is_none(),
|
||||
NameCache::from_tsv(
|
||||
&text,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs
|
||||
)
|
||||
.is_none(),
|
||||
"header field {field} = {bad} must discard the file"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(NameCache::from_tsv("", "R").is_none());
|
||||
assert!(NameCache::from_tsv("nonsense", "R").is_none());
|
||||
assert!(NameCache::from_tsv("", "R", benilla_formats::ClientLocale::EnUs).is_none());
|
||||
assert!(
|
||||
NameCache::from_tsv("nonsense", "R", benilla_formats::ClientLocale::EnUs).is_none()
|
||||
);
|
||||
let text = format!("{}\nC\tnotanumber\t1\t2\t0\tX\nc\t1234\n", head);
|
||||
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header still good");
|
||||
let back = NameCache::from_tsv(
|
||||
&text,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
)
|
||||
.expect("header still good");
|
||||
assert_eq!(back.len(), 1, "the bad line is skipped, the good one kept");
|
||||
|
||||
// A version-1 file, which held player names, is discarded whole.
|
||||
let v1 = format!(
|
||||
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\t{CACHE_LOCALE}\tHydraxian Waterlords\n\
|
||||
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\tenUS\tHydraxian Waterlords\n\
|
||||
P\t7\t1\t1\t0\tMoamtester\n"
|
||||
);
|
||||
assert!(NameCache::from_tsv(&v1, "Hydraxian Waterlords").is_none());
|
||||
assert!(NameCache::from_tsv(
|
||||
&v1,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -43,7 +43,7 @@ pub(crate) fn mods_now(keys: &ButtonInput<KeyCode>) -> Mods {
|
||||
pub(crate) enum CharFilter {
|
||||
/// Everything printable (the login boxes).
|
||||
Any,
|
||||
/// ASCII letters only (the character-name box).
|
||||
/// Unicode letters (the character-name box); the server enforces its naming policy.
|
||||
Letters,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ impl CharFilter {
|
||||
fn allows(self, c: char) -> bool {
|
||||
match self {
|
||||
CharFilter::Any => true,
|
||||
CharFilter::Letters => c.is_ascii_alphabetic(),
|
||||
CharFilter::Letters => c.is_alphabetic(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,50 @@ pub(crate) fn tick_caret(field: &mut EditBoxState, focused: bool, dt: f32) -> bo
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ruru_name_input_and_paste_count_letters_not_utf8_bytes() {
|
||||
let mut f = field(12, false);
|
||||
let mut clipboard = HostClipboard::default();
|
||||
for c in "Александр".chars() {
|
||||
let ev = KeyboardInput {
|
||||
key_code: KeyCode::KeyA,
|
||||
logical_key: bevy::input::keyboard::Key::Character(c.to_string().into()),
|
||||
text: Some(c.to_string().into()),
|
||||
state: ButtonState::Pressed,
|
||||
repeat: false,
|
||||
window: Entity::PLACEHOLDER,
|
||||
};
|
||||
assert_eq!(
|
||||
feed_key(
|
||||
&mut f,
|
||||
&ev,
|
||||
Mods::default(),
|
||||
&crate::bindings::LayoutNames::default(),
|
||||
&mut clipboard,
|
||||
None,
|
||||
CharFilter::Letters
|
||||
),
|
||||
FieldKey::Consumed
|
||||
);
|
||||
}
|
||||
assert_eq!(f.text, "Александр");
|
||||
f.highlight_text(0, -1);
|
||||
f.paste(&CharFilter::Letters.keep("Ёлочка123!"));
|
||||
assert_eq!(f.text, "Ёлочка");
|
||||
f.set_text("");
|
||||
f.paste(&CharFilter::Letters.keep("Абвгдежзийклмноп"));
|
||||
assert_eq!(f.text, "Абвгдежзийкл");
|
||||
assert_eq!(f.text.len(), 24);
|
||||
f.apply(benilla_ui::script::EditAction::Delete {
|
||||
unit: benilla_ui::script::EditUnit::Char,
|
||||
back: true,
|
||||
});
|
||||
assert_eq!(f.text, "Абвгдежзийк");
|
||||
eprintln!(
|
||||
"locale-name: typed=Александр pasted=Ёлочка limit=12 UTF-8 bytes=24 backspace=clean"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_filter_applies_to_pasted_text() {
|
||||
assert_eq!(CharFilter::Letters.keep("Bob123"), "Bob");
|
||||
|
||||
@@ -162,7 +162,7 @@ pub(super) fn feed_party(
|
||||
let area_moved = fed.area.moved(here.area().map_or(u64::MAX, u64::from));
|
||||
let group_changed = group.is_changed();
|
||||
let index_changed = index.is_changed();
|
||||
let look = pets::Lookup::new(&index, &stores, &names, types);
|
||||
let look = pets::Lookup::new(&index, &stores, &names, types, tables.names());
|
||||
// Only the stores the merged view reads: a crowd's other stores change every frame. The
|
||||
// members' pets are the group's too, `partypetN` and `raidpetN` reading their descriptors.
|
||||
let stores_changed = self_q
|
||||
@@ -325,6 +325,7 @@ pub(super) fn feed_party(
|
||||
own_group.clone(),
|
||||
chr,
|
||||
types,
|
||||
tables.names(),
|
||||
)
|
||||
});
|
||||
if fed.units[i] != snap {
|
||||
@@ -393,7 +394,8 @@ pub(super) fn feed_party(
|
||||
if Some(*guid) == self_guid {
|
||||
let (_, _, store) = self_pair?;
|
||||
let name = names.peek(*guid).map(str::to_string);
|
||||
let mut s = crate::ui_unit::snapshot(store, *guid, name, 0, chr, types);
|
||||
let mut s =
|
||||
crate::ui_unit::snapshot(store, *guid, name, 0, chr, types, tables.names());
|
||||
s.is_player = true;
|
||||
s.raid_target = group.raid_target_index(*guid);
|
||||
s.faction_group = own_group.clone();
|
||||
@@ -410,6 +412,7 @@ pub(super) fn feed_party(
|
||||
own_group.clone(),
|
||||
chr,
|
||||
types,
|
||||
tables.names(),
|
||||
))
|
||||
}
|
||||
});
|
||||
@@ -752,6 +755,7 @@ fn raid_roster(
|
||||
}
|
||||
|
||||
/// One member's merged-view unit snapshot (see [`feed_party`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn member_unit_state(
|
||||
m: &GroupMemberEntry,
|
||||
stats: Option<&PartyMemberStatsInfo>,
|
||||
@@ -764,11 +768,18 @@ fn member_unit_state(
|
||||
classes: Option<&benilla_formats::ChrClasses>,
|
||||
// The creature type resolves from the live descriptor alone, so an unstreamed member has none.
|
||||
types: CreatureTypeSources<'_>,
|
||||
strings: Option<&crate::glue_strings::GlueStrings>,
|
||||
) -> UnitState {
|
||||
let mut s = match store {
|
||||
Some(store) => {
|
||||
crate::ui_unit::snapshot(store, m.guid, Some(m.name.clone()), 0, classes, types)
|
||||
}
|
||||
Some(store) => crate::ui_unit::snapshot(
|
||||
store,
|
||||
m.guid,
|
||||
Some(m.name.clone()),
|
||||
0,
|
||||
classes,
|
||||
types,
|
||||
strings,
|
||||
),
|
||||
// Unstreamed: the roster record, snapshotted from the descriptor at despawn (`0x5f0880`),
|
||||
// seated at 1/1 for a member never seen (`0x4e82d0`) and patched by the wire. The
|
||||
// reference's getters read the descriptor, then the party record (`0x496400`), then the
|
||||
@@ -1526,6 +1537,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.health, s.max_health),
|
||||
@@ -1549,6 +1561,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!((bare.health, bare.max_health, bare.power), (0, 0, 0));
|
||||
assert!(bare.exists);
|
||||
@@ -1577,6 +1590,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
s.dead,
|
||||
@@ -1596,6 +1610,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert!(s.ghost);
|
||||
assert!(!s.dead, "a released ghost is not `dead` — only a ghost");
|
||||
@@ -1613,6 +1628,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert!(s.dead);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(super) struct Lookup<'a, 'w, 's, 'q> {
|
||||
names: &'a NameCache,
|
||||
commands: &'a NetCommands,
|
||||
types: CreatureTypeSources<'a>,
|
||||
strings: Option<&'a crate::glue_strings::GlueStrings>,
|
||||
}
|
||||
|
||||
impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
|
||||
@@ -61,6 +62,7 @@ impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
|
||||
stores: &'a Query<'w, 's, &'q ObjectStore>,
|
||||
names: &'a Names,
|
||||
types: CreatureTypeSources<'a>,
|
||||
strings: Option<&'a crate::glue_strings::GlueStrings>,
|
||||
) -> Self {
|
||||
Self {
|
||||
index,
|
||||
@@ -68,6 +70,7 @@ impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
|
||||
names: &names.cache,
|
||||
commands: &names.commands,
|
||||
types,
|
||||
strings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +152,7 @@ pub(super) fn member_pet(
|
||||
.names
|
||||
.resolve_unit(guid, Some(store), look.commands)
|
||||
.map(str::to_string);
|
||||
crate::ui_unit::snapshot(store, guid, name, 0, None, look.types)
|
||||
crate::ui_unit::snapshot(store, guid, name, 0, None, look.types, look.strings)
|
||||
}
|
||||
None => record_state(
|
||||
group,
|
||||
|
||||
@@ -48,6 +48,7 @@ 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 {
|
||||
@@ -87,7 +88,7 @@ pub(super) fn feed_pet_unit(
|
||||
.map(str::to_string);
|
||||
// No `ChrClasses.dbc`: the reference reads a class only for TYPEMASK_PLAYER.
|
||||
let types = CreatureTypeSources::of_resources(&names, spells.as_deref());
|
||||
snapshot(store, pet_guid, name, 0, None, types)
|
||||
snapshot(store, pet_guid, name, 0, None, types, strings.as_deref())
|
||||
});
|
||||
|
||||
let dirty = match (&fresh, &memory.pushed) {
|
||||
|
||||
@@ -133,7 +133,7 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
|
||||
.collect();
|
||||
s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
|
||||
s.set_realm_name("Harness");
|
||||
let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
|
||||
let player = super::seat_from_roster(&roster(), None).expect("a pending pick seats a player");
|
||||
if seat == Seat::BeforeAddons {
|
||||
// A `"player"` push with a name also seeds the player record, as the entry load does, so
|
||||
// `UnitName("player")` answers at addon file scope and Bagnon reads the live bags.
|
||||
@@ -440,7 +440,7 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() {
|
||||
/// What the roster seat puts in the VM, and `None` with no pick, so a capture loads unchanged.
|
||||
#[test]
|
||||
fn the_roster_seat_names_the_character_the_addons_will_meet() {
|
||||
let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
|
||||
let seat = super::seat_from_roster(&roster(), None).expect("a pending pick seats a player");
|
||||
assert_eq!(seat.name.as_deref(), Some("Harness"));
|
||||
// 0, not 60: `UnitLevel` (`0x517fc0`) has no `"player"` fast path, so with no object it pushes
|
||||
// the number 0 (`0x51813e`). The level arrives with the descriptor.
|
||||
@@ -461,7 +461,7 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() {
|
||||
assert_eq!((seat.health, seat.max_health), (0, 0));
|
||||
|
||||
assert!(
|
||||
super::seat_from_roster(&crate::char_select::Roster::default()).is_none(),
|
||||
super::seat_from_roster(&crate::char_select::Roster::default(), None).is_none(),
|
||||
"no pick, no seat — a capture must load exactly as it did before"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -343,7 +343,12 @@ 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(record_from_roster)
|
||||
.and_then(|roster| {
|
||||
record_from_roster(
|
||||
roster,
|
||||
world.get_resource::<crate::glue_strings::GlueStrings>(),
|
||||
)
|
||||
})
|
||||
{
|
||||
script.set_player_record(record);
|
||||
}
|
||||
@@ -356,9 +361,15 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
|
||||
script.set_player_xp(live.xp.0, live.xp.1);
|
||||
}
|
||||
None => {
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
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>(),
|
||||
)
|
||||
})
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
}
|
||||
@@ -493,14 +504,25 @@ fn roster_sex(gender: u8) -> u8 {
|
||||
/// the descriptor.
|
||||
pub(crate) fn record_from_roster(
|
||||
roster: &crate::char_select::Roster,
|
||||
strings: Option<&crate::glue_strings::GlueStrings>,
|
||||
) -> Option<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)| (n.to_string(), f.to_string())),
|
||||
class: class.map(|(n, f)| (n.to_string(), f.to_string())),
|
||||
race: race.map(|(n, f)| {
|
||||
(
|
||||
strings.map_or(n, |s| s.race_name(row.race)).to_string(),
|
||||
f.to_string(),
|
||||
)
|
||||
}),
|
||||
class: class.map(|(n, f)| {
|
||||
(
|
||||
strings.map_or(n, |s| s.class_name(row.class)).to_string(),
|
||||
f.to_string(),
|
||||
)
|
||||
}),
|
||||
sex: roster_sex(row.gender),
|
||||
})
|
||||
}
|
||||
@@ -512,6 +534,7 @@ pub(crate) fn record_from_roster(
|
||||
/// to say.
|
||||
pub(crate) fn seat_from_roster(
|
||||
roster: &crate::char_select::Roster,
|
||||
strings: Option<&crate::glue_strings::GlueStrings>,
|
||||
) -> Option<benilla_ui::script::UnitState> {
|
||||
let row = roster.pending_row()?;
|
||||
let race = crate::ui_unit::race_names(row.race);
|
||||
@@ -524,9 +547,9 @@ pub(crate) fn seat_from_roster(
|
||||
// `UnitLevel` (`0x517fc0`) answers the number 0 (`0x51813e push 0`); the two misses differ.
|
||||
exists: false,
|
||||
name: Some(row.name.clone()),
|
||||
race: race.map(|(n, _)| n.to_string()),
|
||||
race: race.map(|(n, _)| strings.map_or(n, |s| s.race_name(row.race)).to_string()),
|
||||
race_file: race.map(|(_, f)| f.to_string()),
|
||||
class: class.map(|(n, _)| n.to_string()),
|
||||
class: class.map(|(n, _)| strings.map_or(n, |s| s.class_name(row.class)).to_string()),
|
||||
class_file: class.map(|(_, f)| f.to_string()),
|
||||
sex,
|
||||
is_player: true,
|
||||
|
||||
@@ -70,7 +70,15 @@ fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> U
|
||||
(POWER1 + slot, power),
|
||||
(MAXPOWER1 + slot, max_power),
|
||||
]));
|
||||
crate::ui_unit::snapshot(&store, 0, Some(name.into()), 0, None, Default::default())
|
||||
crate::ui_unit::snapshot(
|
||||
&store,
|
||||
0,
|
||||
Some(name.into()),
|
||||
0,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Every texture path drawn this frame, with its vertex tint.
|
||||
|
||||
@@ -53,6 +53,90 @@ fn roster_named(name: &str, guid: u64) -> Roster {
|
||||
Roster::with_pending_pick(vec![row], guid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruru_player_names_reach_the_character_panel_and_live_units() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
for (locale, race, class, line) in [
|
||||
(
|
||||
benilla_formats::ClientLocale::RuRu,
|
||||
"Человек",
|
||||
"Воин",
|
||||
"Человек, Воин 60-го уровня",
|
||||
),
|
||||
(
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
"Human",
|
||||
"Warrior",
|
||||
"Level 60 Human Warrior",
|
||||
),
|
||||
] {
|
||||
let mut chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
let roster = roster_named("Александр", 7);
|
||||
let record = super::lifecycle::record_from_roster(&roster, Some(&strings)).unwrap();
|
||||
let mut seat = super::seat_from_roster(&roster, Some(&strings)).unwrap();
|
||||
seat.level = 60;
|
||||
let mut vm = benilla_ui::script::UiScript::new_with_locale(locale.code()).unwrap();
|
||||
vm.set_screen_size(1024.0, 768.0);
|
||||
vm.set_player_record(record);
|
||||
vm.set_unit("player", Some(seat));
|
||||
for file in super::test_ui::CHARACTER_UI {
|
||||
super::test_ui::load_ui_strict(&vm, file);
|
||||
}
|
||||
vm.run("PaperDollFrame_SetLevel()").unwrap();
|
||||
let shown: String = vm.eval("return CharacterLevelText:GetText()").unwrap();
|
||||
eprintln!("locale-panel: {} text={shown}", locale.code());
|
||||
assert_eq!(shown, line);
|
||||
assert_eq!(
|
||||
vm.eval::<(String, String)>("return UnitRace('player')")
|
||||
.unwrap(),
|
||||
(race.into(), "Human".into())
|
||||
);
|
||||
assert_eq!(
|
||||
vm.eval::<(String, String)>("return UnitClass('player')")
|
||||
.unwrap(),
|
||||
(class.into(), "WARRIOR".into())
|
||||
);
|
||||
// The descriptor path, including a /reload seat, and a different race/class on target.
|
||||
for (race_id, class_id) in [(1, 1), (6, 11)] {
|
||||
let store = crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
|
||||
36,
|
||||
race_id | (class_id << 8),
|
||||
)]));
|
||||
let state = crate::ui_unit::snapshot(
|
||||
&store,
|
||||
8,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
Default::default(),
|
||||
Some(&strings),
|
||||
);
|
||||
assert_eq!(
|
||||
state.race.as_deref(),
|
||||
Some(strings.race_name(race_id as u8))
|
||||
);
|
||||
assert_eq!(
|
||||
state.class.as_deref(),
|
||||
Some(strings.class_name(class_id as u8))
|
||||
);
|
||||
vm.set_unit("target", Some(state));
|
||||
assert_eq!(
|
||||
vm.eval::<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) {
|
||||
|
||||
@@ -138,7 +138,7 @@ fn drive_mouseover_tooltip(
|
||||
let reaction = unit_reaction(rx.factions.as_deref(), &rx.reputations, store, self_store);
|
||||
// The hovered guid, the pair `0x492890` writes to `0xb4e2c8`/`0xb4e2cc` and the token
|
||||
// resolver `0x515970` reads for `"mouseover"`, so `UnitIsUnit` can match it.
|
||||
let mut s = snapshot(store, guid, name, reaction, chr, types);
|
||||
let mut s = snapshot(store, guid, name, reaction, chr, types, tables.names());
|
||||
enrich_unit(
|
||||
&mut s,
|
||||
guid,
|
||||
|
||||
@@ -64,8 +64,8 @@ pub(super) struct ViewCtx<'a, 'w, 's> {
|
||||
|
||||
/// Build one spell's tooltip view, every cell resolved here where the catalogs live, and what it
|
||||
/// read ([`Deps`]). The range, cast, cooldown, form and item cells fill their `GlobalStrings.lua`
|
||||
/// keys; the cost, reagents and chance cells are composed in English, where the reference fills
|
||||
/// `MANA_COST` and its kin, `SPELL_REAGENTS` and `CHANCE_TO_*`.
|
||||
/// keys, including the power-specific cost templates; the reagents and chance cells are composed
|
||||
/// in English, where the reference fills `SPELL_REAGENTS` and `CHANCE_TO_*`.
|
||||
pub(super) fn build_view(
|
||||
spell_id: u32,
|
||||
spells: &Spells,
|
||||
@@ -154,24 +154,27 @@ pub(super) fn build_view(
|
||||
let cost = {
|
||||
// The shared per-type divisor (`0x6e7130`), never a local `power_type == 1` test.
|
||||
let div = benilla_protocol::messages::power_display_scale(d.power_type);
|
||||
let unit = match d.power_type {
|
||||
0 => "Mana",
|
||||
1 => "Rage",
|
||||
2 => "Focus",
|
||||
3 => "Energy",
|
||||
4 => "Happiness",
|
||||
_ => "Health",
|
||||
let key = match d.power_type {
|
||||
0 => "MANA_COST",
|
||||
1 => "RAGE_COST",
|
||||
2 => "FOCUS_COST",
|
||||
3 => "ENERGY_COST",
|
||||
4 => "HAPPINESS_COST",
|
||||
_ => "HEALTH_COST",
|
||||
};
|
||||
if resolved_cost == 0 && d.mana_per_second == 0 {
|
||||
None
|
||||
} else if d.mana_per_second > 0 {
|
||||
Some(format!(
|
||||
"{} {unit}, plus {} per sec",
|
||||
resolved_cost / div,
|
||||
d.mana_per_second / div
|
||||
))
|
||||
keyed(
|
||||
vctx.get,
|
||||
&format!("{key}_PER_TIME"),
|
||||
&[
|
||||
Arg::from(resolved_cost / div),
|
||||
Arg::from(d.mana_per_second / div),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
Some(format!("{} {unit}", resolved_cost / div))
|
||||
keyed(vctx.get, key, &[Arg::from(resolved_cost / div)])
|
||||
}
|
||||
};
|
||||
// The range cell: two attribute gates, `GetMinMaxRange` (`0x6e3480`, `0x52e9c2`), then
|
||||
|
||||
@@ -33,9 +33,13 @@ 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),
|
||||
@@ -105,6 +109,63 @@ impl TestCtx {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruru_spell_costs_use_the_packs_templates() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let Some(spells) = real_spells() else { return };
|
||||
let mut objs = no_objects();
|
||||
let objects = objs.get();
|
||||
let store = ObjectStore(unit(60, 1000));
|
||||
for (locale, expected) in [
|
||||
(
|
||||
"ruRU",
|
||||
[
|
||||
"Мана: 30",
|
||||
"Ярость: 15",
|
||||
"Энергия: 45",
|
||||
"Здоровье: 11 плюс 5/сек.",
|
||||
],
|
||||
),
|
||||
(
|
||||
"enUS",
|
||||
[
|
||||
"30 Mana",
|
||||
"15 Rage",
|
||||
"45 Energy",
|
||||
"11 Health, plus 5 per sec",
|
||||
],
|
||||
),
|
||||
] {
|
||||
let vm = benilla_ui::script::UiScript::new_with_locale(locale).unwrap();
|
||||
crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
let mut t = TestCtx::with_vm(vm);
|
||||
for (id, expected) in [133, 78, 1752, 755].into_iter().zip(expected) {
|
||||
let view =
|
||||
spell_tooltip_view(id, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
|
||||
.unwrap();
|
||||
eprintln!("locale-cost: {locale} spell={id} cost={:?}", view.cost);
|
||||
assert_eq!(view.cost.as_deref(), Some(expected));
|
||||
}
|
||||
let view = spell_tooltip_view(
|
||||
100,
|
||||
&spells,
|
||||
&mut t.ctx_for(&objects, 0, None, Some(&store)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
view.cost, None,
|
||||
"Charge has no resource cost in either locale"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit's fields: level (34) and base mana (162).
|
||||
fn unit(level: u32, base_mana: u32) -> benilla_protocol::ObjectFields {
|
||||
benilla_protocol::ObjectFields::from_pairs(&[(22u16, 100u32), (34, level), (162, base_mana)])
|
||||
|
||||
@@ -804,14 +804,20 @@ pub(crate) struct UnitStores<'w, 's> {
|
||||
|
||||
/// The client tables [`snapshot`] reads beside a descriptor, as one parameter for the feeders that
|
||||
/// sit at Bevy's 16-parameter limit: `ChrClasses.dbc` for the relic slot, and the form table
|
||||
/// the creature-type resolver's first stage reads. Each is absent without game data.
|
||||
/// the creature-type resolver's first stage reads, plus the selected race/class display names.
|
||||
/// Each is absent without game data.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct SnapshotTables<'w> {
|
||||
classes: Option<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)
|
||||
@@ -845,6 +851,7 @@ pub(crate) fn snapshot(
|
||||
reaction: u8,
|
||||
classes: Option<&ChrClasses>,
|
||||
types: CreatureTypeSources<'_>,
|
||||
strings: Option<&crate::glue_strings::GlueStrings>,
|
||||
) -> UnitState {
|
||||
let power_type = store.0.unit_power_type();
|
||||
let race = store.0.unit_race().and_then(race_names);
|
||||
@@ -886,9 +893,19 @@ pub(crate) fn snapshot(
|
||||
// The one resolver `0x605570` behind `UnitCreatureType` (`0x51a2bc`) and the tooltip's
|
||||
// type slot (`0x52a2e5`): the form's type, else the template's, else the race's.
|
||||
creature_type_name: creature_type_word(types.of(store)).map(str::to_string),
|
||||
race: race.map(|(n, _)| n.to_string()),
|
||||
// UnitRace/UnitClass resolve display names through the selected ChrRaces/ChrClasses;
|
||||
// their file tokens remain locale independent (0x518200/0x518350).
|
||||
race: race.map(|(n, _)| {
|
||||
strings
|
||||
.map_or(n, |s| s.race_name(store.0.unit_race().unwrap_or(0)))
|
||||
.to_string()
|
||||
}),
|
||||
race_file: race.map(|(_, f)| f.to_string()),
|
||||
class: class.map(|(n, _)| n.to_string()),
|
||||
class: class.map(|(n, _)| {
|
||||
strings
|
||||
.map_or(n, |s| s.class_name(class_id.unwrap_or(0)))
|
||||
.to_string()
|
||||
}),
|
||||
class_file: class.map(|(_, f)| f.to_string()),
|
||||
// `UnitHasRelicSlot` (`0x519e50`): TYPEMASK_PLAYER first (`0x519e8d`), then the class
|
||||
// byte against `ChrClasses.dbc` field 16; without the player test a class-2 NPC answers 1.
|
||||
@@ -1210,7 +1227,15 @@ fn player_snapshot(
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), commands)
|
||||
.map(str::to_string);
|
||||
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
|
||||
let mut s = snapshot(
|
||||
store,
|
||||
guid,
|
||||
name,
|
||||
0,
|
||||
tables.classes(),
|
||||
tables.types(names),
|
||||
tables.names(),
|
||||
);
|
||||
s.is_player = true;
|
||||
s.raid_target = group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, factions);
|
||||
@@ -1385,6 +1410,7 @@ fn feed_units(
|
||||
self_store: self_pair.map(|(s, _)| s),
|
||||
classes: chr,
|
||||
types,
|
||||
strings: tables.names(),
|
||||
};
|
||||
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
|
||||
let store = stores.all.get(e).ok()?;
|
||||
@@ -2523,6 +2549,7 @@ mod tests {
|
||||
0,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
)
|
||||
.pvp_team
|
||||
};
|
||||
@@ -3239,6 +3266,7 @@ mod tests {
|
||||
0,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!((alive.health, alive.max_health), (1200, 1500));
|
||||
assert_eq!((alive.power, alive.max_power), (300, 900));
|
||||
@@ -3253,6 +3281,7 @@ mod tests {
|
||||
0,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
(feigning.health, feigning.max_health),
|
||||
@@ -3303,6 +3332,7 @@ mod tests {
|
||||
0,
|
||||
None,
|
||||
Default::default(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
let player = snap(0x19);
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) struct HeldUnits<'a> {
|
||||
pub(super) self_store: Option<&'a ObjectStore>,
|
||||
pub(super) classes: Option<&'a ChrClasses>,
|
||||
pub(super) types: CreatureTypeSources<'a>,
|
||||
pub(super) strings: Option<&'a crate::glue_strings::GlueStrings>,
|
||||
}
|
||||
|
||||
impl HeldUnits<'_> {
|
||||
@@ -44,7 +45,15 @@ impl HeldUnits<'_> {
|
||||
.resolve_unit(guid, Some(store), self.commands)
|
||||
.map(str::to_string);
|
||||
let reaction = unit_reaction(self.factions, self.reputations, store, self.self_store);
|
||||
let mut s = snapshot(store, guid, name, reaction, self.classes, self.types);
|
||||
let mut s = snapshot(
|
||||
store,
|
||||
guid,
|
||||
name,
|
||||
reaction,
|
||||
self.classes,
|
||||
self.types,
|
||||
self.strings,
|
||||
);
|
||||
s.raid_target = self.group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, self.factions);
|
||||
s.faction_group_localized = faction_group_localized(store, self.factions);
|
||||
@@ -143,6 +152,7 @@ pub(super) fn feed_chain_units(
|
||||
self_store: self_q.iter().next(),
|
||||
classes: tables.classes(),
|
||||
types: tables.types(&names),
|
||||
strings: tables.names(),
|
||||
};
|
||||
let built: HashMap<u64, UnitState> = ends
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Localized display names from the 1.12 ChrRaces and ChrClasses name blocks.
|
||||
//! The community ruRU patch stores its translated names in locale slot zero.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// The display names, distinct from the unlocalized race/class file tokens.
|
||||
pub struct CharacterNames {
|
||||
races: HashMap<u32, String>,
|
||||
classes: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl CharacterNames {
|
||||
pub fn load(chain: &mut Chain) -> Result<Self> {
|
||||
Ok(Self {
|
||||
races: load_names(chain, "ChrRaces", 29, 17)?,
|
||||
classes: load_names(chain, "ChrClasses", 17, 5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn race(&self, id: u8) -> Option<&str> {
|
||||
self.races.get(&u32::from(id)).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn class(&self, id: u8) -> Option<&str> {
|
||||
self.classes.get(&u32::from(id)).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_names(
|
||||
chain: &mut Chain,
|
||||
table: &str,
|
||||
fields: usize,
|
||||
name_field: usize,
|
||||
) -> Result<HashMap<u32, String>> {
|
||||
let path = format!("DBFilesClient\\{table}.dbc");
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let mut schema = Schema::new(table);
|
||||
for i in 0..fields {
|
||||
schema.add_field(SchemaField::new(
|
||||
format!("F{i}"),
|
||||
if i == name_field {
|
||||
FieldType::String
|
||||
} else {
|
||||
FieldType::UInt32
|
||||
},
|
||||
));
|
||||
}
|
||||
let rows = parse(&bytes, schema, table)?;
|
||||
Ok(rows
|
||||
.records()
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let id = u32_at(r, 0)?;
|
||||
let name = str_at(&rows, r, name_field)?;
|
||||
(!name.is_empty()).then_some((id, name))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ClientLocale;
|
||||
|
||||
#[test]
|
||||
fn character_names_follow_the_selected_patch_chain() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut english = Chain::open(&data).unwrap();
|
||||
let names = CharacterNames::load(&mut english).unwrap();
|
||||
assert_eq!(names.race(1), Some("Human"));
|
||||
assert_eq!(names.class(1), Some("Warrior"));
|
||||
assert_eq!(names.race(255), None);
|
||||
assert_eq!(names.class(255), None);
|
||||
if ClientLocale::RuRu.pack_folder(&data).is_none() {
|
||||
crate::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut russian = Chain::open_localized(&data, ClientLocale::RuRu).unwrap();
|
||||
let names = CharacterNames::load(&mut russian).unwrap();
|
||||
assert_eq!(names.race(1), Some("Человек"));
|
||||
assert_eq!(names.class(1), Some("Воин"));
|
||||
assert_eq!(names.race(6), Some("Таурен"));
|
||||
assert_eq!(names.class(11), Some("Друид"));
|
||||
for id in 1..=8 {
|
||||
assert!(names
|
||||
.race(id)
|
||||
.unwrap()
|
||||
.chars()
|
||||
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
|
||||
}
|
||||
for id in [1, 2, 3, 4, 5, 7, 8, 9, 11] {
|
||||
assert!(names
|
||||
.class(id)
|
||||
.unwrap()
|
||||
.chars()
|
||||
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ mod chain;
|
||||
pub use chain::{Chain, ChainEntry};
|
||||
mod locale;
|
||||
pub use locale::ClientLocale;
|
||||
mod character_names;
|
||||
pub use character_names::CharacterNames;
|
||||
/// Loose `.tga` art, as addon folders ship it.
|
||||
mod tga;
|
||||
pub use tga::tga_to_rgba;
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@
|
||||
- **benilla-adt** (lib.rs): combined_alpha
|
||||
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim creature_type cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
|
||||
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
|
||||
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
|
||||
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain character_names characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
|
||||
- **benilla-m2** (lib.rs): error model skin tests track
|
||||
- **benilla-mpq** (lib.rs): crypto
|
||||
- **benilla-protocol** (lib.rs): auth events guid messages wire world
|
||||
|
||||
Reference in New Issue
Block a user