Compare commits

..

5 Commits

Author SHA1 Message Date
sindoring 80fe28f9b5 Apply the reference name-field limit on character creation
Initialize the actual CreateSelection name field with 12 letters, as the
stock CharacterCreate.xml CharacterCreateNameEdit letters="12" declares.
The UTF-8 packet test now uses reset's actual field and checks both a short
Russian name and truncation at 12 Cyrillic letters / 24 bytes.

Verified the field property in the real MPQ's GlueXML and the reset path.
Formatting and workspace clippy pass; full land gates are running on this
source tree before merge. The preceding three symptom instruments passed
on real ruRU and adjacent English UI data. Windows only.
2026-10-11 17:52:14 +04:00
sindoring 9c5a1c1f6b Localize spell costs and race/class labels; accept Cyrillic names
Fill the selected GlobalStrings resource-cost templates and carry selected
ChrRaces/ChrClasses display names into the player record and live unit feeds.
Keep race/class file tokens unchanged. Accept Unicode letters in name input
and compare recased UTF-8 roster names when selecting a created character.

Reference: stock PaperDollFrame_SetLevel reads UnitRace/UnitClass; ruRU data
provides the cost templates and DBC names. vmangos normalizePlayerName reads
UTF-8, uppercases the first letter and lowercases the rest.

Verified: original symptoms reproduced by three numeric instruments on real
client data, then all three passed with the corrected paths in ruRU and enUS.
The probes cover the actual CharacterLevelText, spell costs, a second unit,
keyboard input, paste, backspace and a 12-character/24-byte Cyrillic limit.
Formatting and workspace clippy are green. Full land gates run before merge.
Built on Windows; no live player-account login or character creation.
2026-10-11 17:37:02 +04:00
sindoring 2ef92a2032 Sync GitHub upstream build profiles through 710eb8f5 (#5)
Bring the fork from its already integrated upstream c2fc3cf3 snapshot to 710eb8f531. The new upstream commit changes only development/play build profiles: dev ThinLTO is disabled and dependencies use one codegen unit. Production release and ship profiles are unchanged; MoonWell integration and Russian localization are retained.

Source: upstream commit 710eb8f5, PR samwhosung/benilla#857. The patch is applied unchanged. MAP was regenerated and has no delta.

Validation on Windows: formatting and locked workspace metadata pass. All automated land gates passed on the final tree (4100 seconds): clippy, workspace tests with and without installed data, documentation and render-pass checks, player-build tests and both engine boot checks. The only skipped tests are 31 optional vanilla-addon/Quiver corpus tests, because that corpus is unavailable. Parsed TOML confirms that all profiles match upstream and that release/ship profiles and workspace dependencies are unchanged. Russian roster labels, character creation names, deletion text and locale-aware NPC-cache tests passed against the real archives. No declared probe account is available for a live game login. Upstream performance figures were measured on macOS and are not claimed for Windows.
2026-10-11 13:31:24 +03:00
sindoring ac0a4fbc47 Fix Russian character captions and NPC locale cache (#4)
Russian character selection displays literal numbered placeholders, and character creation uses English race/class names even when its descriptions are translated. Resolve display names from the selected ChrRaces/ChrClasses DBCs and use the existing numbered-argument formatter for roster rows and deletion prompts.

Creature caches were always written and accepted as enUS. Write the selected client locale into the header and reject caches from another language, allowing the server to answer fresh NPC-name queries.

Reference: the supplied ruRU pack has CHARACTER_SELECT_INFO ordered as class then level, and its DBC name slots contain the Russian labels. The deployed server has 9,755 Russian NPC names; existing local caches contain 101 English-only NPC records. The reported quest giver is Guard Thomas (261), starting quest 37; its Russian name exists in the deployed database.

Validation: ten targeted Windows checks passed against the supplied archives with data required and no skips. All automated land gates passed: formatting, clippy, tests with and without installed data, player-build tests, documentation and enforcers. The 31 optional addon-corpus tests skip because the vanilla addon/Quiver corpus is unavailable. Tests cover real ruRU roster rows including ghosts, Human/Warrior and Tauren/Druid creation headers, changing class-button labels, deletion text without deleting, English fallback, and cache rejection across enUS/ruRU. Windows only. No unattended game login was performed because this checkout has no declared probe account.
2026-10-10 19:22:46 +03:00
sindoring 53b0907393 Add ruRU client localization (#2)
The client selects an installed Data/ruRU pack for assets, DBCs, Lua GetLocale, Cyrillic fonts and realmd locale. WOW_LOCALE supports auto, enUS and ruRU. Localize MoonWell options, menu, bindings and script log; preserve English reference loading and fallback. The deployment tool stages and validates the supplied ruRU archives without changing the install.

Rebased on main 97a92194, which integrates approved GitHub upstream c2fc3cf3. No conflicts were required for localization.

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