Add ruRU client localization #2
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user