Compare commits

...

2 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
16 changed files with 371 additions and 55 deletions
+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]
+24 -10
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;
@@ -1177,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`) ───────────────────────────
+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!(
+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()