Add ruRU client localization #2

Merged
sindoring merged 1 commits from feat/localization-ruru into main 2026-10-10 16:34:23 +03:00
33 changed files with 982 additions and 51 deletions
+11
View File
@@ -68,6 +68,17 @@ benilla builds and runs on macOS, Linux and Windows. You need:
Xcode command line tools, on Linux the ALSA and udev development packages and pkg-config, on
Windows the MSVC build tools that the Rust installer sets up.
This fork also reads community **ruRU language packs for 1.12.1** from `Data/ruRU`.
Keep the base MPQs in `Data` and the Russian `patch-*.MPQ` files in that subfolder;
`WOW_DATA` points to `Data`, not to `ruRU`. An installed pack selects Russian automatically.
`WOW_LOCALE=ruRU` requires the pack, `WOW_LOCALE=enUS` selects the English base chain, and
`WOW_LOCALE=auto` restores automatic selection. Restart the client after changing the language.
The selected chain supplies DBC names, interface strings, fonts, textures and audio; missing
localized files fall back to the base chain. Benilla's own settings and menu captions also have
Russian translations. The login sends the selected locale to the server; server-supplied quest,
NPC and item text requires translations on that server. Packs for later expansions are unsupported.
`moonwell-deploy stage` preserves an installed `ruRU` archive folder in the distribution.
```sh
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
```
@@ -39,7 +39,7 @@
-- space: a width or height of 0 is no size in 1.12's layout, not a flat rect.
-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu".
GameMenuButtonOptions:SetText("Options")
GameMenuButtonOptions:SetText(BenillaUiText("Options"))
GameMenuButtonOptions:ClearAllPoints()
GameMenuButtonOptions:SetPoint("TOP", GameMenuFrame, "TOP", 0, -32)
GameMenuButtonOptions:SetScript("OnClick", function()
@@ -66,12 +66,12 @@
local editMode = CreateFrame("Button", "BenillaGameMenuButtonEditMode", GameMenuFrame,
"GameMenuButtonTemplate")
editMode:SetPoint("TOP", GameMenuButtonKeybindings, "TOP", 0, -19)
editMode:SetText("Edit Mode")
editMode:SetText(BenillaUiText("Edit Mode"))
editMode:Disable()
local support = CreateFrame("Button", "BenillaGameMenuButtonSupport", GameMenuFrame,
"GameMenuButtonTemplate")
support:SetPoint("TOP", editMode, "BOTTOM", 0, 0)
support:SetText("Support")
support:SetText(BenillaUiText("Support"))
support:Disable()
GameMenuButtonMacros:SetPoint("TOP", support, "BOTTOM", 0, 0)
@@ -471,7 +471,7 @@
}
-- Reset To Default confirms, as the era's does; our string, since 1.12 has none.
StaticPopupDialogs["BENILLA_CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
text = BenillaUiText("Are you sure you want to reset all keybindings to their defaults?"),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
@@ -491,7 +491,7 @@
for i = 1, GetNumBindings() do
local command = GetBinding(i)
if not BenillaKeyBindings_IsHeader(command) then
local tag = string.upper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local tag = BenillaUiUpper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local s = BenillaOptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
+26 -5
View File
@@ -118,6 +118,7 @@
-- The wash at the era atlas size, in the 1.14 hover steel-blue.
function BenillaOptionsCategoryRow_OnLoad(row, key, label)
label = BenillaUiText(label)
row.categoryKey = key
row.pageTitle = label
row:SetText(label)
@@ -180,7 +181,7 @@
-- Results are the live rows, so writes work mid-search.
function BenillaOptionsSearch_Words(text)
local up = string.upper(text)
local up = BenillaUiUpper(text)
local words = { up }
for w in string.gfind(up, "([^, ]+)") do
table.insert(words, w)
@@ -324,7 +325,7 @@
BenillaKeyBindingsPage.searchPainted = nil
BenillaKeyBindings_SetSelected(nil)
BenillaOptionsFrameContainerUnbind:Hide()
BenillaOptionsFrameContainerTitle:SetText("Search Results")
BenillaOptionsFrameContainerTitle:SetText(BenillaUiText("Search Results"))
BenillaOptionsFrameContainerDefaults:Hide()
local order, groups = BenillaOptionsSearch_FindMatches(text)
@@ -434,6 +435,24 @@
.. "which it could only do in exclusive fullscreen. benilla applies it to the "
.. "finished image instead, so it works in both display modes."
if GetLocale() == "ruRU" then
BENILLA_TOOLTIP_RENDER_SCALE =
"Меняет разрешение трёхмерного мира относительно размера окна. Интерфейс не меняется.\n\n"
.. "Значения ниже 100% повышают частоту кадров, но снижают чёткость. "
.. "Значения выше 100% сглаживают края за счёт производительности. "
.. "Изменение вступает в силу после нажатия кнопки «Применить»."
BENILLA_TOOLTIP_BACKGROUND_SOUND =
"Продолжает воспроизведение звука игры, когда активно другое окно.\n\n"
.. "Если выключено, при переключении в другое окно игра становится беззвучной. "
.. "Звуки при этом продолжают идти и могут закончиться до вашего возвращения."
BENILLA_TOOLTIP_DISPLAY_MODE =
"Оконный режим на весь экран использует окно без рамки. Обычный оконный режим "
.. "оставляет рамку и заголовок окна.\n\nЭксклюзивный полноэкранный режим не используется."
BENILLA_TOOLTIP_BRIGHTNESS =
"Делает изображение светлее или темнее, в том числе ночью, в пещерах и помещениях.\n\n"
.. "Коррекция применяется к готовому изображению и работает в обоих режимах экрана."
end
BENILLA_OPTIONS_PAGE_ROWS = {
-- The era Controls order first; the rest follow their 1.12 box's columns.
Controls = {
@@ -493,9 +512,10 @@
-- `tip` is a GlobalStrings key, resolved at hover as UIOptionsFrame.lua:265 does.
function BenillaOptionsRow_OnLoad(row, cvar, label, tip, child)
label = BenillaUiText(label)
row.cvar = cvar
row.tip = tip
row.searchTag = string.upper(label)
row.searchTag = BenillaUiUpper(label)
-- BenillaOptionsRow_SetEnabled's disabled font must keep the child size.
row.child = child
local l = getglobal(row:GetName() .. "Label")
@@ -633,7 +653,7 @@
local row = dd:GetParent()
for _, e in ipairs(row.entries) do
local info = {}
info.text = e.text
info.text = BenillaUiText(e.text)
info.value = e.value
info.func = BenillaOptionsDropdownEntry_OnClick
UIDropDownMenu_AddButton(info)
@@ -670,7 +690,7 @@
end
if match then
dd.selectedValue = match.value
UIDropDownMenu_SetText(match.text, dd)
UIDropDownMenu_SetText(BenillaUiText(match.text), dd)
-- As UIOptionsFrameCameraDropDown_OnClick retargets OPTION_TOOLTIP_CAMERA<id>.
if match.tip then row.tip = match.tip end
else
@@ -3075,6 +3095,7 @@
wheel stay off until a capsule is selected (BenillaKeyBindings_SetSelected). -->
<OnLoad>
this:EnableKeyboard(false)
BenillaUiLocalize(this)
this:EnableMouseWheel(false)
</OnLoad>
<OnKeyDown>BenillaKeyBindings_OnKeyDown()</OnKeyDown>
@@ -78,13 +78,13 @@
local summary
if total == 0 then
summary = "No problems this session."
summary = BenillaUiText("No problems this session.")
elseif total > shown then
summary = format("%d problems (%d older not kept)", total, total - shown)
summary = format(BenillaUiText("%d problems (%d older not kept)"), total, total - shown)
elseif total == 1 then
summary = "1 problem"
summary = BenillaUiText("1 problem")
else
summary = format("%d problems", total)
summary = format(BenillaUiText("%d problems"), total)
end
getglobal("BenillaScriptLogSummary"):SetText(summary)
@@ -370,7 +370,7 @@
</Button>
</Frames>
<Scripts>
<OnLoad>BenillaScriptLogFrame_OnLoad()</OnLoad>
<OnLoad>BenillaUiLocalize(this); BenillaScriptLogFrame_OnLoad()</OnLoad>
<!-- A spin bubbles up the parent chain; the detail pane's own handler wins there. -->
<OnMouseWheel>BenillaScriptLogFrame_OnMouseWheel(arg1)</OnMouseWheel>
<OnShow>BenillaScriptLog_Update()</OnShow>
@@ -7,6 +7,127 @@
because the loader runs every `<Scripts>` block in order.
-->
<Script><![CDATA[
-- Shared captions for our layer: stock labels resolve from the installed GlobalStrings;
-- only captions authored by benilla have translations here.
local benillaLabelKeys = {
["Action Bars"] = "ACTIONBAR_LABEL", ["Ambience Volume"] = "AMBIENCE_VOLUME",
["Attack on assist"] = "ASSIST_ATTACK", ["Auto Clear AFK"] = "CLEAR_AFK",
["Auto Self Cast"] = "AUTO_SELF_CAST_TEXT", ["Auto-Follow Speed"] = "AUTO_FOLLOW_SPEED",
["Block Trades"] = "BLOCK_TRADES", ["Brightness"] = "OPTIONS_BRIGHTNESS", ["Chat"] = "CHAT_LABEL",
["Click-to-Move"] = "CLICK_TO_MOVE", ["Close"] = "CLOSE",
["Controls"] = "CONTROLS_LABEL", ["Defaults"] = "DEFAULTS",
["Disable Spam Filter"] = "DISABLE_SPAM_FILTER", ["Enable All Sound"] = "ENABLE_ALL_SOUND",
["Enable Ambience"] = "ENABLE_AMBIENCE", ["Enable Error Speech"] = "ENABLE_ERROR_SPEECH",
["Enable Music"] = "ENABLE_MUSIC", ["Enhanced Tooltips"] = "USE_UBERTOOLTIPS",
["Environment Detail"] = "ENVIRONMENT_DETAIL", ["Follow Terrain"] = "FOLLOW_TERRAIN",
["Head Bob"] = "HEAD_BOB", ["Invert Mouse"] = "INVERT_MOUSE",
["Keybindings"] = "KEY_BINDINGS", ["Master Volume"] = "MASTER_VOLUME",
["Mouse Look Speed"] = "MOUSE_LOOK_SPEED", ["Mouse Sensitivity"] = "MOUSE_SENSITIVITY",
["Music Volume"] = "MUSIC_VOLUME", ["NPC Names"] = "SHOW_NPC_NAMES",
["Periodic Damage"] = "LOG_PERIODIC_EFFECTS", ["Pet Damage"] = "SHOW_PET_MELEE_DAMAGE",
["Player Guild Names"] = "SHOW_GUILD_NAMES", ["Player Names"] = "SHOW_PLAYER_NAMES",
["Player Titles"] = "SHOW_PLAYER_TITLES", ["Profanity Filter"] = "PROFANITY_FILTER",
["Search"] = "SEARCH", ["Show Own Name"] = "SHOW_OWN_NAME",
["Show Target Damage"] = "SHOW_DAMAGE_TEXT", ["Smart Pivot"] = "SMART_PIVOT",
["Sound Volume"] = "SOUND_VOLUME", ["Sticky Targeting"] = "GAMEFIELD_DESELECT_TEXT",
["Terrain Distance"] = "FARCLIP", ["UI Scale"] = "UI_SCALE",
["Vertical Sync"] = "VERTICAL_SYNC", ["Water Collision"] = "WATER_COLLISION",
["Weather Intensity"] = "WEATHER_DETAIL",
["Smart"] = "CAMERA_SMART", ["Always"] = "ALWAYS", ["Never"] = "CAMERA_NEVER",
["Raid"] = "RAID", ["Party"] = "PARTY", ["Solo"] = "SOLO",
["Scroll Up"] = "COMBAT_TEXT_SCROLL_UP", ["Scroll Down"] = "COMBAT_TEXT_SCROLL_DOWN",
["Arc"] = "COMBAT_TEXT_SCROLL_ARC",
["Always Show ActionBars"] = "ALWAYS_SHOW_MULTIBARS_TEXT",
["Automatic Quest Tracking"] = "AUTO_QUEST_WATCH_TEXT",
["Buff Durations"] = "SHOW_BUFF_DURATION_TEXT",
["Camera Following Style"] = "CAMERA_FOLLOWING_STYLE",
["Combat Text Float Mode"] = "COMBAT_TEXT_FLOAT_MODE_LABEL",
["Detailed Loot Information"] = "SHOW_LOOT_SPAM",
["Enable Floating Combat Text"] = "SHOW_COMBAT_TEXT_TEXT",
["Guild Member Alert"] = "GUILDMEMBER_ALERT",
["Instant Quest Text"] = "SHOW_QUEST_FADING_TEXT",
["Lock ActionBars"] = "LOCK_ACTIONBAR_TEXT", ["Max Camera Distance"] = "MAX_FOLLOW_DIST",
["Remove Chat Hover Delay"] = "REMOVE_CHAT_DELAY_TEXT",
["Show Auras"] = "COMBAT_TEXT_SHOW_AURAS_TEXT",
["Show Chat Bubbles"] = "CHAT_BUBBLES_TEXT",
["Show Combat State"] = "COMBAT_TEXT_SHOW_COMBAT_STATE_TEXT",
["Show Combo Points"] = "COMBAT_TEXT_SHOW_COMBO_POINTS_TEXT",
["Show Damage Reduction"] = "COMBAT_TEXT_SHOW_RESISTANCES_TEXT",
["Show Detailed Tooltips"] = "SHOW_NEWBIE_TIPS_TEXT",
["Show Dodge/Parries/Misses"] = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS_TEXT",
["Show Energy Gains"] = "COMBAT_TEXT_SHOW_MANA_TEXT",
["Show Fading Auras"] = "COMBAT_TEXT_SHOW_AURA_FADE_TEXT",
["Show Friendly Healer Names"] = "COMBAT_TEXT_SHOW_FRIENDLY_NAMES_TEXT",
["Show Honor Gained"] = "COMBAT_TEXT_SHOW_HONOR_GAINED_TEXT",
["Show Loading Screen Tips"] = "SHOW_TIPOFTHEDAY_TEXT",
["Show Low Mana and Health"] = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA_TEXT",
["Show Party Chat Bubbles"] = "PARTY_CHAT_BUBBLES_TEXT",
["Show Reactive Spells and Abilities"] = "COMBAT_TEXT_SHOW_REACTIVES_TEXT",
["Show Reputation"] = "COMBAT_TEXT_SHOW_REPUTATION_TEXT",
["Show Target of Target"] = "SHOW_TARGET_OF_TARGET_TEXT", ["Status Bar Text"] = "STATUS_BAR_TEXT",
["Show Bottom Left ActionBar"] = "SHOW_MULTIBAR1_TEXT",
["Show Bottom Right ActionBar"] = "SHOW_MULTIBAR2_TEXT",
["Show Right ActionBar"] = "SHOW_MULTIBAR3_TEXT", ["Show Right ActionBar 2"] = "SHOW_MULTIBAR4_TEXT",
["Show Cloak"] = "SHOW_CLOAK", ["Show Helm"] = "SHOW_HELM", ["Show Tutorials"] = "SHOW_TUTORIALS",
}
local benillaRussianLabels = {
["Options"] = "Настройки", ["Game"] = "Игра", ["AddOns"] = "Аддоны",
["Gameplay"] = "Игровой процесс", ["System"] = "Система",
["Interface"] = "Интерфейс", ["Combat"] = "Бой", ["Graphics"] = "Графика",
["Audio"] = "Звук", ["Nameplates"] = "Индикаторы здоровья",
["Search Results"] = "Результаты поиска", ["No results found."] = "Ничего не найдено.",
["Apply"] = "Применить", ["Auto Loot"] = "Автосбор добычи",
["Display Mode"] = "Режим экрана", ["Render Scale"] = "Масштаб рендеринга",
["Windowed"] = "Оконный", ["Windowed (Fullscreen)"] = "Оконный (на весь экран)",
["Party or Raid"] = "Группа или рейд",
["Enable Sound in Background"] = "Звук в фоне", ["Show When"] = "Показывать",
["Enemy Name Plates"] = "Индикаторы врагов", ["Friendly Name Plates"] = "Индикаторы союзников",
["On"] = "Вкл.", ["Off"] = "Выкл.",
["Are you sure you want to reset all keybindings to their defaults?"] =
"Сбросить все назначения клавиш к значениям по умолчанию?",
["Edit Mode"] = "Режим редактирования", ["Support"] = "Поддержка",
["Script Errors"] = "Ошибки скриптов", ["Clear"] = "Очистить",
["No problems this session."] = "В этом сеансе ошибок нет.",
["1 problem"] = "Одна ошибка", ["%d problems"] = "Ошибок: %d",
["%d problems (%d older not kept)"] = "Ошибок: %d (старых не сохранено: %d)",
}
function BenillaUiText(text)
if GetLocale() ~= "ruRU" then return text end
local key = benillaLabelKeys[text]
return (key and getglobal(key)) or benillaRussianLabels[text] or text
end
-- Lua's byte-wise upper leaves UTF-8 Cyrillic unchanged. Both the labels and the query
-- use this fold, so the options search matches Russian regardless of letter case.
function BenillaUiUpper(text)
local result = string.upper(text)
if GetLocale() == "ruRU" then
local lower = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
local upper = "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"
for i = 1, string.len(lower), 2 do
result = string.gsub(result, string.sub(lower, i, i + 1), string.sub(upper, i, i + 1))
end
end
return result
end
-- Called at creation, after the children exist, for static captions owned by our windows.
function BenillaUiLocalize(frame)
if GetLocale() ~= "ruRU" then return end
local function caption(object)
if object.GetText and object.SetText then
local text = object:GetText()
if text then
local translated = BenillaUiText(text)
if translated ~= text then object:SetText(translated) end
end
end
end
caption(frame)
for _, region in ipairs({frame:GetRegions()}) do caption(region) end
for _, child in ipairs({frame:GetChildren()}) do BenillaUiLocalize(child) end
end
-- One valueStep per step, where the reference's arrows and wheel move half the bar
-- (UIPanelTemplates.xml:137/149, UIPanelTemplates.lua:150-157).
-- Soundless: the reference plays UChatScrollButton on an arrow click, never on the wheel.
+21
View File
@@ -149,6 +149,27 @@ pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueString
mod tests {
use super::*;
#[test]
fn the_russian_pack_localizes_glue_captions_as_utf8() {
let data = benilla_formats::wow_data_or_skip!();
let locale = benilla_formats::ClientLocale::RuRu;
if locale.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, locale).unwrap();
let table = table_from_chain(&mut chain);
for key in ["LOGIN", "ACCOUNT_NAME", "PASSWORD"] {
let caption = table.get(key).expect("localized glue caption");
assert!(
caption
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)),
"{key}: {caption}"
);
}
}
#[test]
fn parses_assignments_and_skips_the_rest() {
let src = r#"
+11
View File
@@ -312,8 +312,19 @@ fn launch(build: BuildId, extend: Option<Extension<'_>>) -> AppExit {
// absent and terrain loads fail gracefully.
match benilla_formats::wow_data() {
Some(data_dir) => {
let locale = match benilla_formats::ClientLocale::for_install(&data_dir) {
Ok(locale) => locale,
Err(e) => {
eprintln!("benilla: {e:#}");
return AppExit::error();
}
};
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
// A selected pack must not degrade into an English login with no client data.
if locale == benilla_formats::ClientLocale::RuRu {
return AppExit::error();
}
}
}
None => eprintln!(
+5
View File
@@ -147,6 +147,7 @@ pub(super) struct Attempt<'w> {
submit: Res<'w, LoginSubmit>,
abandon: Res<'w, LoginAbandon>,
realmlist: Res<'w, crate::realmlist::Realmlist>,
assets: Option<Res<'w, benilla_assets::WorldAssets>>,
}
impl Attempt<'_> {
@@ -161,6 +162,10 @@ impl Attempt<'_> {
user: user.to_string(),
pass: pass.to_string(),
host: self.realmlist.address().to_string(),
locale: self.assets.as_ref().map_or(*b"enUS", |assets| {
use benilla_assets::LockRecover;
assets.chain.lock_recover().locale().auth_tag()
}),
generation: self.abandon.0.load(Ordering::SeqCst),
});
}
+3 -1
View File
@@ -64,6 +64,8 @@ pub(crate) struct LoginRequest {
/// The realmlist to dial, `host[:port]`, per attempt so an edit mid-dial cannot retarget the
/// connection in flight.
pub(crate) host: String,
/// The mounted client language captured at submit time, in canonical ASCII tag order.
pub(crate) locale: [u8; 4],
pub(crate) generation: u64,
}
@@ -355,7 +357,7 @@ fn run(
// Logon: the dial and the SRP6 exchange against realmd.
let mut logon = {
stage(LoginStage::Connecting);
match benilla_protocol::logon(&req.host, &req.user, &req.pass) {
match benilla_protocol::logon_with_locale(&req.host, &req.user, &req.pass, req.locale) {
Ok(l) => l,
Err(e) => {
if canceled() {
@@ -248,6 +248,7 @@ fn an_empty_account_parks_at_select_without_creating_a_character() {
user: USER.into(),
pass: PASS.into(),
host: realmd,
locale: *b"enUS",
generation: 0,
})
.unwrap();
@@ -488,6 +488,7 @@ fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GameMenuFrame.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "GameMenuAdapters.xml");
s.run(
@@ -26,6 +26,7 @@ fn harness_with(extra: &[&str]) -> UiScript {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\GameMenuFrame.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"ScrollTemplates.xml",
"GameMenuAdapters.xml",
];
for file in super::test_ui::production_order(&[MENU, extra]) {
@@ -32,7 +32,10 @@ fn install_boot_vm(world: &mut World) {
/// A fresh VM on the process clock with the addon asset probes wired, or `None` (the world's VM
/// removed) when the VM cannot start.
fn new_vm(world: &mut World) -> Option<UiScript> {
let mut script = match UiScript::new() {
let locale = world
.get_resource::<benilla_assets::WorldAssets>()
.map_or("enUS", |assets| assets.chain.lock_recover().locale().code());
let mut script = match UiScript::new_with_locale(locale) {
Ok(s) => s,
Err(e) => {
error!("ui_script: VM init failed: {e}");
@@ -8,6 +8,102 @@ fn harness() -> UiScript {
harness_on(UiScript::new().unwrap())
}
#[test]
fn the_russian_pack_localizes_the_window_search_and_dropdown_without_changing_values() {
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 s = UiScript::new_with_locale("ruRU").unwrap();
s.register_cvars(crate::cvars::registered_pairs());
s.set_cvar_host("gxWindow", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let labels: Vec<String> = s.eval(r#"
local labels = {}
for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do
for _, row in ipairs(rows) do
local label = getglobal("BenillaOptionsFrameContainerBody" .. page .. row .. "Label")
if label then table.insert(labels, label:GetText() or "") end
end
end
return labels
"#).unwrap();
assert!(labels.len() > 70, "the settings rows were enumerated");
for label in labels {
assert!(
label
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)),
"unlocalized settings label: {label}"
);
}
assert!(s.eval::<bool>(
"return BenillaOptionsFrameContainerTitle:GetText() == CONTROLS_LABEL and CONTROLS_LABEL ~= 'Controls'"
).unwrap());
assert_eq!(
s.eval::<String>("return GameMenuButtonOptions:GetText()")
.unwrap(),
"Настройки"
);
assert_eq!(
s.eval::<String>("return BenillaUiUpper('МуЗыКа ёж')")
.unwrap(),
"МУЗЫКА ЁЖ"
);
for query in ["музы", "МУЗЫ"] {
s.run(&format!("BenillaOptionsFrameSearchBox:SetText('{query}')"))
.unwrap();
s.tick(0.0);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Результаты поиска"
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudioRowMusic:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaOptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
}
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
// Clearing the search drains OnTextChanged on the next frame, restoring the hidden rows.
s.tick(0.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowDisplayMode";
assert_eq!(
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
.unwrap(),
"Режим экрана"
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"Оконный (на весь экран)"
);
s.take_cvar_changes();
s.run(&format!(
"{ROW}DropdownButton:Click(); DropDownList1Button2:Click()"
))
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"Оконный"
);
assert_eq!(
s.take_cvar_changes(),
vec![("gxWindow".to_string(), "1".to_string())]
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Load the manifest slice onto `s`, so a page test can seed CVars before the XML loads, as the
/// app does. GameTooltip.xml precedes UIDropDownMenu.xml for the kit's `TOOLTIP_DEFAULT_COLOR`.
fn harness_on(s: UiScript) -> UiScript {
@@ -101,7 +101,12 @@ fn chain() -> Option<&'static Chain> {
);
return None;
};
match benilla_formats::open_chain(&data) {
// Reference assertions use the base chain; the running client uses its language pack.
#[cfg(test)]
let opened = Chain::open(&data);
#[cfg(not(test))]
let opened = Chain::open_client(&data);
match opened {
Ok(chain) => Some(chain),
Err(e) => {
error!("ui_script: opening the patch chain to source the reference UI: {e:#}");
@@ -121,6 +126,7 @@ pub(super) mod fixture {
thread_local! {
static OVERLAY: RefCell<HashMap<String, Option<Vec<u8>>>> = RefCell::new(HashMap::new());
static SELECTED_CHAIN: RefCell<Option<benilla_formats::Chain>> = const { RefCell::new(None) };
}
fn key(path: &str) -> String {
@@ -141,7 +147,32 @@ pub(super) mod fixture {
/// The overlay's answer for `path`, `None` when it has no entry.
pub(super) fn read(path: &str) -> Option<Option<Vec<u8>>> {
OVERLAY.with(|o| o.borrow().get(&key(path)).cloned())
OVERLAY
.with(|o| o.borrow().get(&key(path)).cloned())
.or_else(|| {
SELECTED_CHAIN
.with(|chain| chain.borrow().as_ref().map(|chain| chain.read(path).ok()))
})
}
/// Read an actual selected install chain through the production loader on this test thread.
pub(in crate::ui_script) fn use_chain(chain: benilla_formats::Chain) -> ChainGuard {
SELECTED_CHAIN.with(|selected| {
let mut selected = selected.borrow_mut();
assert!(selected.is_none(), "a chain fixture cannot nest");
*selected = Some(chain);
});
ChainGuard
}
pub(in crate::ui_script) struct ChainGuard;
impl Drop for ChainGuard {
fn drop(&mut self) {
SELECTED_CHAIN.with(|selected| {
selected.borrow_mut().take();
});
}
}
/// Clears the overlay on drop.
@@ -45,6 +45,54 @@ fn the_whole_shipped_manifest_loads_without_errors() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn the_russian_pack_loads_the_complete_interface_and_takes_variables_loaded() {
let data = benilla_formats::wow_data_or_skip!();
let locale = benilla_formats::ClientLocale::RuRu;
if locale.pack_folder(&data).is_none() {
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
let _selected = super::reference_ui::fixture::use_chain(chain);
let mut s = benilla_ui::script::UiScript::new_with_locale(locale.code()).unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(
failures.is_empty(),
"Russian manifest load errors: {failures:#?}"
);
assert!(s
.eval::<bool>("return GetLocale() == 'ruRU' and LOCALE_ruRU == true")
.unwrap());
let cancel = s.eval::<String>("return CANCEL").unwrap();
assert!(cancel
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
assert_eq!(
s.eval::<String>("return GameMenuButtonOptions:GetText()")
.unwrap(),
"Настройки"
);
s.fire_event("VARIABLES_LOADED", vec![]);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
assert!(
s.errors().is_empty(),
"Russian interface script errors: {:?}",
s.errors()
);
}
/// Loading the UI makes no sound: a dropdown's OnLoad runs `UIDropDownMenu_Initialize`, which calls
/// its init at once (`UIDropDownMenu.lua:50`), and a unit popup's init reaches `UnitPopup_ShowMenu`
/// and its closing `PlaySound("igMainMenuOpen")` unless `UnitPopup_HideButtons` leaves only CANCEL
@@ -88,6 +88,26 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
// The app registers its CVars before any file loads: stock `UIOptionsFrame.xml` reads two
// camera CVars at `OnLoad` and raises on a nil. A value a test already set stands.
s.register_cvars(crate::cvars::registered_pairs());
// A localized VM reads the same selected patch chain as production; English kits keep the
// raw reference chain, independent of the process's WOW_LOCALE.
let chain = match s.eval::<String>("return GetLocale()").unwrap().as_str() {
"ruRU" => Some(
benilla_formats::Chain::open_localized(
&benilla_formats::wow_data().expect("a localized kit needs client data"),
benilla_formats::ClientLocale::RuRu,
)
.expect("the selected language pack"),
),
_ => None,
};
let read = |req: &str| {
if let Some(chain) = &chain {
if super::reference_ui::is_chain_entry(req) {
return chain.read(req).ok();
}
}
read(req)
};
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
if path.to_ascii_lowercase().ends_with(".lua") {
+44 -4
View File
@@ -146,14 +146,14 @@ pub(crate) struct TextEngine {
/// the database that can draw a character, so a system face would silently stand in for a client
/// face. A character no client or addon face carries draws nothing, as in the reference, and
/// measures 0. The locale only picks which language's family name a face answers to.
fn client_font_system() -> FontSystem {
FontSystem::new_with_locale_and_db("en-US".to_string(), fontdb::Database::new())
fn client_font_system(locale: benilla_formats::ClientLocale) -> FontSystem {
FontSystem::new_with_locale_and_db(locale.language_tag().to_string(), fontdb::Database::new())
}
impl TextEngine {
/// Register the client TTFs off the patch chain; `None`, and no text, without Friz Quadrata.
fn load(world_assets: &WorldAssets, images: &Assets<Image>, dpi: f32) -> Option<Self> {
let mut font_system = client_font_system();
let mut font_system = client_font_system(world_assets.chain.lock_recover().locale());
let mut faces: Vec<Face> = Vec::new();
let mut path_to_face = HashMap::new();
for &path in CLIENT_FONTS {
@@ -603,7 +603,15 @@ fn read_font_bytes(source: &FontSource, path: &str) -> Option<Vec<u8>> {
pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
let data = benilla_formats::wow_data()?;
let chain = benilla_formats::open_chain(&data).ok()?;
let mut font_system = client_font_system();
test_engine_from_chain(chain, dpi)
}
#[cfg(test)]
pub(super) fn test_engine_from_chain(
chain: benilla_formats::Chain,
dpi: f32,
) -> Option<TextEngine> {
let mut font_system = client_font_system(chain.locale());
let mut faces: Vec<Face> = Vec::new();
let mut path_to_face = HashMap::new();
for &path in CLIENT_FONTS {
@@ -810,6 +818,38 @@ mod differential_tests {
mod ppem_tests {
use super::*;
#[test]
fn the_russian_pack_rasterizes_cyrillic_in_every_client_face() {
let data = benilla_formats::wow_data_or_skip!();
let locale = benilla_formats::ClientLocale::RuRu;
if locale.pack_folder(&data).is_none() {
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
let mut e = test_engine_from_chain(chain, 1.0).expect("localized client fonts");
for &path in TEST_FACES {
let face = e.face_for(Some(path));
assert_eq!(e.faces[face].path, path);
e.ensure_str(face, 16, 0, "АаЯяЁё");
for ch in "АаЯяЁё".chars() {
let shaped = e
.char_cell(face, 16, ch)
.expect("a Cyrillic character shaped");
assert!(!shaped.glyphs.is_empty(), "{path}: {ch}");
for glyph in &shaped.glyphs {
assert_ne!(glyph.glyph_id, 0, "{path}: {ch} must not be .notdef");
let cell = e.cell(face, 16, 0, glyph.glyph_id).expect("Cyrillic ink");
assert!(cell.px_w > 0.0 && cell.px_h > 0.0, "{path}: {ch}");
}
}
}
assert!(
e.substituted.is_empty(),
"Cyrillic uses the selected client faces"
);
}
fn engine_or_skip() -> Option<TextEngine> {
match test_engine(1.0) {
Some(e) => Some(e),
+1 -1
View File
@@ -71,7 +71,7 @@ impl MpqAssetReader {
/// Open the patch chain from a `Data` directory or a single `.MPQ`.
pub fn open(data_dir: &Path) -> Result<Self> {
Ok(Self {
chain: Arc::new(Chain::open(data_dir)?),
chain: Arc::new(Chain::open_client(data_dir)?),
})
}
}
+163 -2
View File
@@ -8,7 +8,7 @@ use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use benilla_mpq::Archive;
use crate::VANILLA_BASE_ORDER;
use crate::{ClientLocale, VANILLA_BASE_ORDER};
/// One entry from a chain listing: an internal path and its uncompressed size.
pub struct ChainEntry {
@@ -20,6 +20,7 @@ pub struct ChainEntry {
pub struct Chain {
/// Ascending priority: later archives win.
archives: Vec<Archive>,
locale: ClientLocale,
}
/// `patch-?.MPQ` as the reference's `FindFirstFileW` glob matches it (template `0x82edbc`, wrapper
@@ -93,7 +94,36 @@ impl Chain {
Archive::open(path).with_context(|| format!("opening MPQ {}", path.display()))?,
);
}
Ok(Self { archives })
Ok(Self {
archives,
locale: ClientLocale::EnUs,
})
}
/// Open the runtime chain with `$WOW_LOCALE`, or the installed ruRU pack when unset.
/// [`Self::open`] stays the unlocalized reference chain for extraction and reference tests.
pub fn open_client(path: &Path) -> Result<Self> {
Self::open_localized(path, ClientLocale::for_install(path)?)
}
/// Mount the selected language's archives after the vanilla chain. Missing files fall through
/// to the base archives; an explicitly selected missing or corrupt pack is an error.
pub fn open_localized(path: &Path, locale: ClientLocale) -> Result<Self> {
let mut chain = Self::open(path)?;
if locale == ClientLocale::RuRu && path.is_dir() {
let folder = crate::locale::locale_folder(path, locale)
.ok_or_else(|| anyhow!("ruRU language pack not found in {}", path.display()))?;
let overlay = Self::open(&folder)
.with_context(|| format!("opening {} language pack", locale.code()))?;
chain.archives.extend(overlay.archives);
}
chain.locale = locale;
Ok(chain)
}
/// The language mounted for this chain, also used by Lua and authentication.
pub fn locale(&self) -> ClientLocale {
self.locale
}
/// The highest-priority archive with an entry for `name`, a delete marker included, as a
@@ -178,6 +208,137 @@ impl Chain {
mod tests {
use super::*;
struct TestInstall(std::path::PathBuf);
impl TestInstall {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("benilla-locale-{}-{id}", std::process::id()));
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(root.join("RUrU")).unwrap();
// Valid empty V1 archives: the tables point at EOF and have zero entries.
let mut header = b"MPQ\x1a".to_vec();
for field in [32u32, 32, 3 << 16, 32, 32, 0, 0] {
header.extend_from_slice(&field.to_le_bytes());
}
for name in [
"dbc.MPQ",
"patch-2.MPQ",
"RUrU/patch-1.MPQ",
"RUrU/patch-3.MPQ",
] {
std::fs::write(root.join(name), &header).unwrap();
}
Self(root)
}
}
impl Drop for TestInstall {
fn drop(&mut self) {
for name in [
"dbc.MPQ",
"patch-2.MPQ",
"RUrU/patch-1.MPQ",
"RUrU/patch-3.MPQ",
] {
let _ = std::fs::remove_file(self.0.join(name));
}
let _ = std::fs::remove_dir(self.0.join("RUrU"));
let _ = std::fs::remove_dir(&self.0);
}
}
#[test]
fn language_archives_mount_after_base_patches_and_english_is_isolated() {
let install = TestInstall::new();
assert_eq!(
ClientLocale::select(&install.0, None).unwrap(),
ClientLocale::RuRu
);
assert_eq!(
ClientLocale::select(&install.0, Some("enUS")).unwrap(),
ClientLocale::EnUs
);
let russian = Chain::open_localized(&install.0, ClientLocale::RuRu).unwrap();
let mounted: Vec<_> = russian
.archives
.iter()
.map(|a| {
a.path()
.strip_prefix(&install.0)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert_eq!(
mounted,
[
"dbc.MPQ",
"patch-2.MPQ",
"RUrU/patch-1.MPQ",
"RUrU/patch-3.MPQ"
]
);
assert_eq!(russian.locale(), ClientLocale::RuRu);
let english = Chain::open_localized(&install.0, ClientLocale::EnUs).unwrap();
assert_eq!(english.archives.len(), 2);
assert_eq!(english.locale(), ClientLocale::EnUs);
}
#[test]
fn a_missing_or_corrupt_selected_language_is_not_silently_english() {
let install = TestInstall::new();
std::fs::write(install.0.join("RUrU/patch-1.MPQ"), b"corrupt").unwrap();
assert!(Chain::open_localized(&install.0, ClientLocale::RuRu).is_err());
assert!(Chain::open_localized(&install.0, ClientLocale::EnUs).is_ok());
for name in ["RUrU/patch-1.MPQ", "RUrU/patch-3.MPQ"] {
std::fs::remove_file(install.0.join(name)).unwrap();
}
std::fs::remove_dir(install.0.join("RUrU")).unwrap();
assert!(Chain::open_localized(&install.0, ClientLocale::RuRu).is_err());
assert_eq!(
ClientLocale::select(&install.0, None).unwrap(),
ClientLocale::EnUs
);
}
#[test]
fn the_installed_russian_pack_localizes_spell_names_and_keeps_base_fallbacks() {
let data = crate::wow_data_or_skip!();
if crate::locale::locale_folder(&data, ClientLocale::RuRu).is_none() {
crate::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let mut russian = Chain::open_localized(&data, ClientLocale::RuRu).unwrap();
let mut english = Chain::open(&data).unwrap();
let names = crate::load_spell_catalog(&mut russian).expect("localized Spell.dbc layout");
let base_names = crate::load_spell_catalog(&mut english).unwrap();
let localized = &names.get(133).expect("Fireball").name;
assert!(localized
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
assert_ne!(localized, &base_names.get(133).unwrap().name);
let icons = "DBFilesClient\\SpellIcon.dbc";
assert_eq!(russian.read(icons).unwrap(), english.read(icons).unwrap());
for file in [
"Fonts\\FRIZQT__.TTF",
"Interface\\FrameXML\\GlobalStrings.lua",
] {
let archive = russian.find_file_archive(file).expect("localized resource");
assert!(archive
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.eq_ignore_ascii_case("ruRU"));
assert_ne!(russian.read(file).unwrap(), english.read(file).unwrap());
}
}
fn owned(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
+12
View File
@@ -128,6 +128,18 @@ pub fn skipped(what: &str, looked_in: &[PathBuf]) {
what,
looked_in,
);
log_skip(what);
}
/// A test requiring optional data (such as a language pack) records a skip even when the base
/// install is required. A pack that exists but fails to open must still fail its test.
#[doc(hidden)]
pub fn skipped_optional(what: &str, looked_in: &[PathBuf]) {
skipped_under(false, what, looked_in);
log_skip(what);
}
fn log_skip(what: &str) {
// `$BENILLA_SKIP_LOG`: libtest hides the line above, so the gate counts skips from this file.
if let Some(log) = std::env::var_os("BENILLA_SKIP_LOG").filter(|p| !p.is_empty()) {
use std::io::Write;
+3 -1
View File
@@ -13,6 +13,8 @@ pub use benilla_blp::BlpTexels;
mod chain;
pub use chain::{Chain, ChainEntry};
mod locale;
pub use locale::ClientLocale;
/// Loose `.tga` art, as addon folders ship it.
mod tga;
pub use tga::tga_to_rgba;
@@ -20,7 +22,7 @@ pub use tga::tga_to_rgba;
mod install;
pub use install::{
addon_corpus, addon_corpus_candidates, candidates, project_folder, set_project_folder, skipped,
wow_data,
skipped_optional, wow_data,
};
mod characters;
pub use characters::{
+124
View File
@@ -0,0 +1,124 @@
//! The language of the client data, Lua UI and realmd challenge.
//!
//! Deviation: the community 1.12 ruRU pack lives in `Data/ruRU` and overlays the vanilla chain.
//! Its DBCs keep the 1.12 layout and put Russian strings in slot zero, like the base tables.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// The supported language packs. English reads the original vanilla chain unchanged.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ClientLocale {
#[default]
EnUs,
RuRu,
}
impl ClientLocale {
/// The spelling returned by `GetLocale` and sent to realmd.
pub const fn code(self) -> &'static str {
match self {
Self::EnUs => "enUS",
Self::RuRu => "ruRU",
}
}
/// The four-character auth tag, before the protocol reverses its byte order.
pub const fn auth_tag(self) -> [u8; 4] {
match self {
Self::EnUs => *b"enUS",
Self::RuRu => *b"ruRU",
}
}
/// The optional language-pack folder under `Data`, found case-insensitively.
pub fn pack_folder(self, data: &Path) -> Option<PathBuf> {
locale_folder(data, self)
}
/// The language tag used for localized font family names.
pub const fn language_tag(self) -> &'static str {
match self {
Self::EnUs => "en-US",
Self::RuRu => "ru-RU",
}
}
/// Parse an explicit language; unknown values fail rather than silently loading another one.
pub fn from_code(code: &str) -> Result<Self> {
if code.eq_ignore_ascii_case("enUS") {
Ok(Self::EnUs)
} else if code.eq_ignore_ascii_case("ruRU") {
Ok(Self::RuRu)
} else {
bail!("unsupported client locale {code:?}; expected enUS, ruRU or auto");
}
}
/// Runtime selection: `$WOW_LOCALE=enUS|ruRU|auto`; unset or `auto` uses an installed ruRU
/// folder, otherwise English. Selection is made before any client resources load.
pub fn for_install(data: &Path) -> Result<Self> {
let selected = std::env::var("WOW_LOCALE").context("reading WOW_LOCALE");
match selected {
Ok(value) => Self::select(data, Some(&value)),
Err(_) if std::env::var_os("WOW_LOCALE").is_none() => Self::select(data, None),
Err(error) => Err(error),
}
}
pub(crate) fn select(data: &Path, value: Option<&str>) -> Result<Self> {
match value {
Some(code) if !code.eq_ignore_ascii_case("auto") => Self::from_code(code),
_ => Ok(if locale_folder(data, Self::RuRu).is_some() {
Self::RuRu
} else {
Self::EnUs
}),
}
}
}
/// Find the locale directory case-insensitively, including on a case-sensitive filesystem.
pub(crate) fn locale_folder(data: &Path, locale: ClientLocale) -> Option<PathBuf> {
let mut folders: Vec<_> = std::fs::read_dir(data)
.ok()?
.filter_map(Result::ok)
.filter(|entry| {
entry.path().is_dir()
&& entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(locale.code())
})
.map(|entry| entry.path())
.collect();
folders.sort();
folders.into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_is_explicit_and_unknown_values_are_errors() {
let absent = Path::new("/no-client-data-here");
assert_eq!(
ClientLocale::select(absent, None).unwrap(),
ClientLocale::EnUs
);
assert_eq!(
ClientLocale::select(absent, Some("auto")).unwrap(),
ClientLocale::EnUs
);
assert_eq!(
ClientLocale::select(absent, Some("RURU")).unwrap(),
ClientLocale::RuRu
);
for invalid in ["", "frFR", "../ruRU"] {
assert!(ClientLocale::select(absent, Some(invalid)).is_err());
}
assert_eq!(ClientLocale::RuRu.auth_tag(), *b"ruRU");
}
}
+26 -1
View File
@@ -69,6 +69,17 @@ pub fn write_logon_challenge(
w: &mut impl Write,
account_name: &str,
build: u16,
) -> std::io::Result<()> {
write_logon_challenge_with_locale(w, account_name, build, LOCALE_EN_US.to_be_bytes())
}
/// The challenge with the mounted client's locale, a four-character tag such as `ruRU`.
/// realmd reverses the country bytes before matching the locale (`AuthSocket::ReadChallengeRequest`).
pub fn write_logon_challenge_with_locale(
w: &mut impl Write,
account_name: &str,
build: u16,
locale: [u8; 4],
) -> std::io::Result<()> {
// Everything after the 2-byte size field, assembled first so we can prefix its length.
let mut body = Vec::with_capacity(34 + account_name.len());
@@ -79,7 +90,7 @@ pub fn write_logon_challenge(
body.extend_from_slice(&build.to_le_bytes());
body.extend_from_slice(&PLATFORM_X86.to_le_bytes());
body.extend_from_slice(&client_os().to_le_bytes());
body.extend_from_slice(&LOCALE_EN_US.to_le_bytes());
body.extend_from_slice(&u32::from_be_bytes(locale).to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // utc_timezone_offset
body.extend_from_slice(&Ipv4Addr::LOCALHOST.octets()); // client_ip_address (network order)
body.push(account_name.len() as u8);
@@ -289,6 +300,20 @@ pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
mod tests {
use super::*;
#[test]
fn the_logon_challenge_carries_the_selected_locale_in_wire_order() {
let mut english = Vec::new();
write_logon_challenge(&mut english, "PROBE", 5875).unwrap();
let mut explicit = Vec::new();
write_logon_challenge_with_locale(&mut explicit, "PROBE", 5875, *b"enUS").unwrap();
assert_eq!(explicit, english);
let mut russian = Vec::new();
write_logon_challenge_with_locale(&mut russian, "PROBE", 5875, *b"ruRU").unwrap();
assert_eq!(&russian[21..25], b"URur");
assert_eq!(&russian[..21], &english[..21]);
assert_eq!(&russian[25..], &english[25..]);
}
#[test]
fn os_tags_reverse_to_the_names_servers_match_on() {
let spelled = |tag: u32| {
+18 -2
View File
@@ -165,6 +165,17 @@ impl Logon {
/// The full SRP6 logon against a vanilla `realmd`, then the realm list.
pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
logon_with_locale(host, username, password, *b"enUS")
}
/// The SRP6 logon with the client's selected four-character locale tag, used by the server for
/// localized world responses. [`logon`] retains the English default for protocol instruments.
pub fn logon_with_locale(
host: &str,
username: &str,
password: &str,
locale: [u8; 4],
) -> Result<Logon> {
let (host, port) = host_port(host, AUTH_PORT);
let username_n =
@@ -179,8 +190,13 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
let mut stream = dial(host, port)?;
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
auth::write_logon_challenge_with_locale(
&mut stream,
&username.to_uppercase(),
CLIENT_BUILD,
locale,
)
.context("sending logon challenge")?;
let reply =
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
+27 -5
View File
@@ -11,16 +11,16 @@ const VERSION: &str = "1.12.1";
const BUILD: &str = "5875";
const BUILD_DATE: &str = "Sep 19 2006";
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
pub(super) fn install(lua: &Lua, locale: &str) -> mlua::Result<()> {
let g = lua.globals();
// `GetLocale()` (glue `0x46ce40`, in-game `0x48d8b0`) returns one string, `enUS`, as benilla
// is enUS-only. `IsMacClient()` (`0x48c980`) returns one nil, the PC build's answer, which
// takes FrameXML's PC key-label arm.
// `GetLocale()` (glue `0x46ce40`, in-game `0x48d8b0`) returns the mounted client language.
let locale = locale.to_string();
g.set(
"GetLocale",
lua.create_function(|lua, ()| lua.create_string("enUS"))?,
lua.create_function(move |lua, ()| lua.create_string(&locale))?,
)?;
// `IsMacClient()` (`0x48c980`) is the PC build's nil answer, used for key labels.
g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?;
// The PC build's other two platform answers: `IsWindowsClient()` (`0x48c960`) pushes the
// number 1 and `IsLinuxClient()` (`0x48c990`) one nil. Arguments are ignored.
@@ -312,6 +312,28 @@ mod tests {
);
}
#[test]
fn locale_is_available_before_addons_run_and_is_specific_to_each_vm() {
let russian = UiScript::new_with_locale("ruRU").unwrap();
russian
.run("if GetLocale() == 'ruRU' then LocaleProbe = 'Русский' end")
.unwrap();
assert_eq!(
russian.eval::<String>("return LocaleProbe").unwrap(),
"Русский"
);
assert_eq!(russian.arity("GetLocale()").unwrap(), 1);
let english = UiScript::new().unwrap();
assert_eq!(
english.eval::<String>("return GetLocale()").unwrap(),
"enUS"
);
assert_eq!(
russian.eval::<String>("return GetLocale()").unwrap(),
"ruRU"
);
}
/// The reference's arities: `GetLocale` (`0x48d8b0`) one string, `IsMacClient` (`0x48c980`)
/// one nil.
#[test]
+6 -1
View File
@@ -489,6 +489,11 @@ pub fn addon_chunk_name(folder: &str, file: &str) -> String {
impl UiScript {
/// Build a fully sandboxed, stdlib- and object-model-equipped host.
pub fn new() -> mlua::Result<UiScript> {
Self::new_with_locale("enUS")
}
/// Build the host for the mounted client language, before FrameXML or addons run.
pub fn new_with_locale(locale: &str) -> mlua::Result<UiScript> {
let lua = Lua::new();
lua.set_app_data(Model::new());
// Before anything can fire a handler and while no app-data borrow is held, as the
@@ -502,7 +507,7 @@ impl UiScript {
chat_misc::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::install(&lua)?;
client::install(&lua, locale)?;
screenshot::install(&lua)?;
stdlib::sandbox(&lua)?;
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions.
-2
View File
@@ -239,8 +239,6 @@ end
function getglobal(name) return _G[name] end
function setglobal(name, value) _G[name] = value end
-- ── GetLocale: benilla ships/reads enUS data only (the 5875 MPQs + Spell.dbc enUS column) ──────
function GetLocale() return "enUS" end
-- ── NOT here: wipe / tostringall / strsplit / strjoin / strconcat / strtrim ────────────────────
-- Six 2.0+ names benilla used to define. 1.12 has none of them (absent from
@@ -6,7 +6,7 @@ use super::common::script;
fn get_locale_and_set_all_points() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// `GetLocale`: benilla is enUS-only.
// A VM with no selected language retains the English default.
assert_eq!(s.eval::<String>("return GetLocale()").unwrap(), "enUS");
s.run(
+6 -3
View File
@@ -6,7 +6,7 @@ use bevy::render::renderer::RenderDevice;
use crate::art_scope::{ArtScope, ArtSlot};
use benilla_assets::{AssetSet, RenderConfig, WorldAssets};
use benilla_formats::open_chain;
use benilla_formats::Chain;
/// Opens the patch chain at startup and inserts the shared [`WorldAssets`] and [`RenderConfig`].
pub(crate) struct AssetPlugin;
@@ -65,8 +65,11 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
.unwrap_or(1);
commands.insert_resource(RenderConfig { unload_budget });
match open_chain(&data) {
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)),
match Chain::open_client(&data) {
Ok(chain) => {
info!("client data: locale {}", chain.locale().code());
commands.insert_resource(WorldAssets::open(chain, light_buf));
}
Err(e) => error!("failed to open client data: {e:#}"),
}
}
+137 -8
View File
@@ -53,8 +53,8 @@ fn valid_auth_host(host: &str) -> bool {
})
}
fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
ensure!(data.is_dir(), "vanilla Data directory was not found");
fn mounted_archives(data: &Path) -> Result<Vec<std::path::PathBuf>> {
ensure!(data.is_dir(), "Data directory was not found");
let mut names = HashSet::new();
let mut files = Vec::new();
for entry in fs::read_dir(data)? {
@@ -82,13 +82,27 @@ fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
);
files.push(entry.path());
}
files.sort();
Ok(files)
}
fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
let files = mounted_archives(data)?;
let names: HashSet<_> = files
.iter()
.map(|p| {
p.file_name()
.unwrap()
.to_string_lossy()
.to_ascii_lowercase()
})
.collect();
for base in BASE_ARCHIVES {
ensure!(
names.contains(*base),
"missing vanilla base archive: {base}"
);
}
files.sort();
// Modified installs can mimic vanilla archive names; check the winning 5875 Spell.dbc too.
let mut spell_archives: Vec<_> = files.iter().collect();
// Match benilla-formats' vanilla mount order, including speech2's final priority.
@@ -133,6 +147,44 @@ fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
Ok(files)
}
/// Only mounted archives from the optional 1.12 Russian overlay; unrelated locale files stay out.
fn russian_archives(data: &Path) -> Result<Vec<std::path::PathBuf>> {
let mut folders = Vec::new();
for entry in fs::read_dir(data)? {
let entry = entry?;
if entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case("ruRU")
&& entry.path().is_dir()
{
folders.push(entry.path());
}
}
ensure!(folders.len() <= 1, "duplicate ruRU language folders");
let Some(folder) = folders.first() else {
return Ok(Vec::new());
};
let archives = mounted_archives(folder)?;
ensure!(
!archives.is_empty(),
"ruRU language pack has no mounted archives"
);
for archive in &archives {
let archive = benilla_mpq::Archive::open(archive)?;
if archive.contains(r"DBFilesClient\Spell.dbc") {
let bytes = archive.read_file(r"DBFilesClient\Spell.dbc")?;
ensure!(
bytes.len() >= 20
&& &bytes[..4] == b"WDBC"
&& u32::from_le_bytes(bytes[8..12].try_into().unwrap()) == 173,
"ruRU Spell.dbc is not the vanilla 5875 layout"
);
}
}
Ok(archives)
}
fn ensure_outside(input: &Path, output: &Path) -> Result<()> {
let input = input.canonicalize()?;
let absolute = if output.is_absolute() {
@@ -143,12 +195,26 @@ fn ensure_outside(input: &Path, output: &Path) -> Result<()> {
let parent = absolute
.parent()
.context("output needs a parent directory")?;
fs::create_dir_all(parent)?;
let output = if absolute.exists() {
absolute.canonicalize()?
} else {
parent
// Resolve through the nearest existing ancestor without creating anything in an input.
let mut existing = parent;
while !existing.exists() {
existing = existing
.parent()
.context("output has no existing ancestor")?;
}
let suffix = parent.strip_prefix(existing)?;
ensure!(
!suffix
.components()
.any(|c| matches!(c, std::path::Component::ParentDir)),
"a nonexistent output parent cannot contain .."
);
existing
.canonicalize()?
.join(suffix)
.join(absolute.file_name().context("output needs a filename")?)
};
ensure!(
@@ -174,8 +240,10 @@ pub fn pack(source: &Path, output: &Path) -> Result<()> {
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF-8 patch name"))?;
ensure!(patch.file_type()?.is_dir() && patch_name(&name),
"unsupported vanilla patch source: {name}; locale subdirectories are not supported by the client");
ensure!(
patch.file_type()?.is_dir() && patch_name(&name),
"unsupported vanilla patch source: {name}; expected a Data/patch-? source directory"
);
let target = output.join("Data").join(format!("{name}.MPQ"));
ensure!(
!target.exists(),
@@ -274,6 +342,7 @@ pub fn validate_distribution(root: &Path) -> Result<()> {
"MoonWell binary is not a native executable"
);
validate_data(&root.join("Data"))?;
russian_archives(&root.join("Data"))?;
ensure!(
root.join("realmlist.wtf").is_file(),
"realmlist.wtf is missing"
@@ -294,7 +363,12 @@ pub fn stage(
"auth host must be hostname or IPv4[:port]"
);
let archives = validate_data(data)?;
let localized = russian_archives(data)?;
ensure_outside(data, output)?;
if let Some(archive) = localized.first() {
// A locale directory may point outside Data; it remains a read-only input as well.
ensure_outside(archive.parent().context("language pack directory")?, output)?;
}
ensure_outside(binary, output)?;
if let Some(source) = source {
ensure_outside(source, output)?;
@@ -311,7 +385,7 @@ pub fn stage(
),
"use a MoonWell player executable or .app"
);
fs::create_dir(output)?;
fs::create_dir_all(output)?;
if binary.is_dir() {
for entry in WalkDir::new(binary).follow_links(false) {
let entry = entry?;
@@ -350,6 +424,16 @@ pub fn stage(
fs::copy(&archive, dest)?;
}
}
if !localized.is_empty() {
let locale_data = target_data.join("ruRU");
fs::create_dir(&locale_data)?;
for archive in localized {
fs::copy(
&archive,
locale_data.join(archive.file_name().expect("archive name")),
)?;
}
}
fs::write(
output.join("realmlist.wtf"),
format!("set realmlist {host}\n"),
@@ -414,6 +498,10 @@ mod tests {
crate::vanilla_mpq::normalize(&data.join(name)).unwrap();
}
let original = fs::read(data.join("patch-m.mpq")).unwrap();
let locale = data.join("RURU");
fs::create_dir(&locale).unwrap();
fs::copy(data.join("dbc.mpq"), locale.join("patch-1.MPQ")).unwrap();
fs::write(locale.join("notes.txt"), b"not a mounted archive").unwrap();
let source = temp.path().join("src");
fs::create_dir_all(source.join("Data/patch-M")).unwrap();
fs::write(source.join("Data/patch-M/Test.txt"), b"custom").unwrap();
@@ -422,6 +510,11 @@ mod tests {
let out = temp.path().join("out");
stage(&binary, &data, &out, Some(&source), "127.0.0.1:3724").unwrap();
assert_eq!(fs::read(data.join("patch-m.mpq")).unwrap(), original);
assert_eq!(
fs::read(out.join("Data/ruRU/patch-1.MPQ")).unwrap(),
fs::read(locale.join("patch-1.MPQ")).unwrap()
);
assert!(!out.join("Data/ruRU/notes.txt").exists());
let archive = benilla_mpq::Archive::open(out.join("Data/patch-M.MPQ")).unwrap();
assert_eq!(archive.read_file("Test.txt").unwrap(), b"custom");
assert_eq!(
@@ -450,4 +543,40 @@ mod tests {
.unwrap();
assert!(validate_data(temp.path()).is_err());
}
#[test]
fn a_wrath_spell_table_in_a_russian_overlay_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let locale = temp.path().join("ruRU");
fs::create_dir(&locale).unwrap();
let mut spell = [0u8; 20];
spell[..4].copy_from_slice(b"WDBC");
spell[8..12].copy_from_slice(&234u32.to_le_bytes());
let archive = locale.join("patch-1.MPQ");
ArchiveBuilder::new()
.version(FormatVersion::V1)
.add_file_data(spell.to_vec(), r"DBFilesClient\Spell.dbc")
.build(&archive)
.unwrap();
crate::vanilla_mpq::normalize(&archive).unwrap();
assert!(russian_archives(temp.path())
.unwrap_err()
.to_string()
.contains("5875 layout"));
}
#[test]
fn rejecting_an_output_inside_an_input_creates_no_directories() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("install");
fs::create_dir(&input).unwrap();
assert!(ensure_outside(&input, &input.join("scratch/out")).is_err());
assert!(!input.join("scratch").exists());
let outside = temp.path().join("new-parent/out");
ensure_outside(&input, &outside).unwrap();
assert!(
!outside.parent().unwrap().exists(),
"validation is read-only"
);
}
}
+2 -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 lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world
@@ -304,6 +304,7 @@
- `WOW_LIVE_SHOT_AT` — benilla-app/src/capture/live_shot.rs
- `WOW_LIVE_SHOT_COUNT` — benilla-app/src/capture/live_shot.rs
- `WOW_LIVE_SHOT_EVERY` — benilla-app/src/capture/live_shot.rs
- `WOW_LOCALE` — benilla-formats/src/locale.rs
- `WOW_LOGIN_SHOT` — benilla-world/src/bgwin.rs
- `WOW_LOGIN_SHOT_OUT` — benilla-app/src/login/screen.rs
- `WOW_LOGIN_SMOKE` — benilla-app/src/char_select/mod.rs, benilla-app/src/login/mod.rs, benilla-app/src/login/smoke.rs, benilla-world/src/bgwin.rs