Compare commits

..

2 Commits

Author SHA1 Message Date
sindoring 443bda246d Add ruRU client localization from installed language packs
Mount the community 1.12 ruRU archives after the vanilla chain, with automatic
selection and WOW_LOCALE overrides. Use the same locale for client assets,
FrameXML/addons, font shaping and the realmd challenge. Translate benilla's own
settings, menu and script log, including case-insensitive Cyrillic search.
Preserve language archives when staging MoonWell without writing to the install.

Reference: the supplied 1.12 pack retains vanilla DBC layouts with Russian text
in slot zero and supplies localized GlobalStrings, GlueStrings and four fonts.
The community overlay convention is documented as a fork deviation in locale.rs.

Verified on Windows with scripts/gates.sh: fmt, clippy with warnings denied,
workspace tests with canonical 1.12 data and without an install, doc-links,
pass-span lint, player tests without dev, and both engine boot checks passed.
The ruRU pack tests passed for full FrameXML, all settings captions, Cyrillic
search/dropdown values, DBC names, base fallback, glue captions and glyph ink.
31 addon-corpus tests skipped because the corpus is not installed.
A separate unattended silent engine check on C:\Games\WoW\Data selected ruRU
automatically and exited successfully. MAP regenerated by scripts/genmap.sh.
Live authentication smoke was not run: no declared test account is available.
macOS and Linux were not built. No files from the install are committed.
2026-10-10 16:48:37 +04:00
sindoring 97a92194cc Integrate GitHub upstream UI fidelity updates (#3)
Update the fork to the approved upstream/main snapshot c2fc3cf3, including UI scale, load/reload lifecycle and visibility fixes. Preserve MoonWell integration and live-player seating during ReloadUI. MAP was regenerated.

Windows validation: all scripts/gates.sh gates green (2963 seconds), with and without vanilla data, player build tests and engine boot checks. 31 tests skipped only for the absent addon corpus. No declared account for live login; other platforms were not built.
2026-10-10 15:48:17 +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