Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 020ab827ce | |||
| ac0a4fbc47 | |||
| 53b0907393 |
+14
-1
@@ -46,11 +46,19 @@ kira = { path = "third_party/kira" }
|
||||
# Dependencies at opt-level 3 in dev: at opt-level 0, avian/parry collision drops a run to <1 fps.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
# One codegen unit each, since `lto = "off"` below drops the ThinLTO that inlines across a crate's
|
||||
# units: at 16 units the dependencies cost +1.2 cpu_ms at the leg pin, at one nothing, and a cold
|
||||
# build is no slower.
|
||||
codegen-units = 1
|
||||
|
||||
# Our crates at opt-level 1: Bevy's generic hot paths monomorphize into them, and at opt-level 0
|
||||
# Stormwind drops from a pinned 60 fps to ~45; the warm incremental rebuild cost is ~0.
|
||||
# Stormwind drops from a pinned 60 fps to ~45.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# No ThinLTO: above opt-level 0 rustc runs it over a crate's own codegen units by default, and an
|
||||
# incremental rebuild redoes it whole: 19 s of a one-line `benilla-app` edit's 25 s, and half its
|
||||
# 5.3 GB. Off, our crates cost +0.7 cpu_ms (+6 %) at the leg pin.
|
||||
lto = "off"
|
||||
|
||||
# No debuginfo trimming: `debug = "line-tables-only"` measures no recurring win on macOS + sccache.
|
||||
|
||||
@@ -71,3 +79,8 @@ incremental = true
|
||||
# Not for speed: under `incremental`, thin-local LTO leaves promoted `anon.*.llvm.*` symbols
|
||||
# unresolved and the link fails intermittently (rustc #86049).
|
||||
lto = "off"
|
||||
|
||||
# One codegen unit each, as in dev: at 16 the dependencies cost +1.2 cpu_ms against `release` at
|
||||
# the leg pin, at one the profile is +0.5, and a cold build is no slower.
|
||||
[profile.play.package."*"]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
use bevy::input::mouse::{AccumulatedMouseMotion, MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::{class_name, race_name};
|
||||
use crate::entities::CharCreate;
|
||||
use crate::glue_strings::GlueStrings;
|
||||
|
||||
@@ -123,8 +122,8 @@ pub(super) fn refresh_dynamic(
|
||||
if alliance { "Alliance" } else { "Horde" },
|
||||
)
|
||||
.to_string(),
|
||||
DynText::InfoTitle(InfoKind::Race) => race_name(sel.race).to_string(),
|
||||
DynText::InfoTitle(InfoKind::Class) => class_name(sel.class).to_string(),
|
||||
DynText::InfoTitle(InfoKind::Race) => strings.race_name(sel.race).to_string(),
|
||||
DynText::InfoTitle(InfoKind::Class) => strings.class_name(sel.class).to_string(),
|
||||
// Never trimmed: each `FACTION_INFO_*`, `RACE_INFO_*` and `CLASS_*` string opens with
|
||||
// eight spaces, which is the reference's first-line clearance past the header icon.
|
||||
DynText::InfoBody(InfoKind::Faction) => strings
|
||||
@@ -155,7 +154,7 @@ pub(super) fn refresh_dynamic(
|
||||
.to_string(),
|
||||
DynText::ClassSlotLabel(i) => classes
|
||||
.get(*i as usize)
|
||||
.map(|&c| class_name(c).to_string())
|
||||
.map(|&c| strings.class_name(c).to_string())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
if t.0 != new {
|
||||
@@ -402,3 +401,61 @@ pub(super) fn scroll_visuals(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod localization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn russian_creation_headers_and_class_labels_follow_selection() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut chain =
|
||||
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
|
||||
.unwrap();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<CreateSelection>()
|
||||
.init_resource::<GlueArt>()
|
||||
.insert_resource(crate::glue_strings::table_from_chain(&mut chain))
|
||||
.insert_resource(CharCreate(
|
||||
benilla_formats::CharCreateCatalog::load(&mut chain).unwrap(),
|
||||
))
|
||||
.add_systems(Update, refresh_dynamic);
|
||||
app.world_mut().resource_mut::<CreateSelection>().race = 1;
|
||||
app.world_mut().resource_mut::<CreateSelection>().class = 1;
|
||||
let race = app
|
||||
.world_mut()
|
||||
.spawn((DynText::InfoTitle(InfoKind::Race), Text::default()))
|
||||
.id();
|
||||
let class = app
|
||||
.world_mut()
|
||||
.spawn((DynText::InfoTitle(InfoKind::Class), Text::default()))
|
||||
.id();
|
||||
let label = app
|
||||
.world_mut()
|
||||
.spawn((DynText::ClassSlotLabel(0), Text::default()))
|
||||
.id();
|
||||
let second_label = app
|
||||
.world_mut()
|
||||
.spawn((DynText::ClassSlotLabel(1), Text::default()))
|
||||
.id();
|
||||
app.update();
|
||||
assert_eq!(app.world().get::<Text>(race).unwrap().0, "Человек");
|
||||
assert_eq!(app.world().get::<Text>(class).unwrap().0, "Воин");
|
||||
assert_eq!(app.world().get::<Text>(label).unwrap().0, "Воин");
|
||||
assert_eq!(app.world().get::<Text>(second_label).unwrap().0, "Паладин");
|
||||
app.world_mut().resource_mut::<CreateSelection>().race = 6;
|
||||
app.world_mut().resource_mut::<CreateSelection>().class = 11;
|
||||
app.update();
|
||||
assert_eq!(app.world().get::<Text>(race).unwrap().0, "Таурен");
|
||||
assert_eq!(app.world().get::<Text>(class).unwrap().0, "Друид");
|
||||
assert_eq!(app.world().get::<Text>(label).unwrap().0, "Воин");
|
||||
assert_eq!(app.world().get::<Text>(second_label).unwrap().0, "Охотник");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use bevy::prelude::*;
|
||||
use bevy::ui_render::ui_material::MaterialNode;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use crate::char_select::{race_name, wow_font};
|
||||
use crate::char_select::wow_font;
|
||||
use crate::entities::CharCreate;
|
||||
use crate::glue_strings::GlueStrings;
|
||||
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
|
||||
@@ -280,7 +280,7 @@ fn left_tower(
|
||||
Some(DynIcon::Race(race)),
|
||||
None,
|
||||
None::<DynText>,
|
||||
race_name(race),
|
||||
strings.race_name(race),
|
||||
art,
|
||||
s,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ use super::wow_font;
|
||||
pub(super) struct DeleteDialog {
|
||||
pub(super) open: bool,
|
||||
/// The snapshotted target: guid, name, level, class name.
|
||||
target: Option<(u64, String, u8, &'static str)>,
|
||||
target: Option<(u64, String, u8, String)>,
|
||||
/// The confirm box's text; the reference's `letters="32"` cap is its `max_letters`.
|
||||
pub(super) typed: EditBoxState,
|
||||
/// The spawned dialog root, while up.
|
||||
@@ -35,9 +35,9 @@ pub(super) struct DeleteDialog {
|
||||
}
|
||||
|
||||
impl DeleteDialog {
|
||||
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &'static str) {
|
||||
pub(super) fn open_for(&mut self, guid: u64, name: String, level: u8, class: &str) {
|
||||
self.open = true;
|
||||
self.target = Some((guid, name, level, class));
|
||||
self.target = Some((guid, name, level, class.to_string()));
|
||||
self.typed.set_text("");
|
||||
}
|
||||
|
||||
@@ -204,17 +204,10 @@ fn spawn_dialog(
|
||||
let (name, level, class) = dialog
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|(_, n, l, c)| (n.clone(), *l, *c))
|
||||
.map(|(_, n, l, c)| (n.clone(), *l, c.as_str()))
|
||||
.unwrap_or_default();
|
||||
// `CONFIRM_CHAR_DELETE`'s `|cffffffff` span splits it: gold lead above, white identity below.
|
||||
let raw = strings.text(
|
||||
"CONFIRM_CHAR_DELETE",
|
||||
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
|
||||
);
|
||||
let filled = raw
|
||||
.replacen("%s", &name, 1)
|
||||
.replacen("%d", &level.to_string(), 1)
|
||||
.replacen("%s", class, 1);
|
||||
let filled = delete_prompt(strings, &name, level, class);
|
||||
let (lead, identity) = match filled.split_once("|cffffffff") {
|
||||
Some((a, b)) => (a.trim_end_matches('\n').to_string(), b.replace("|r", "")),
|
||||
None => (filled.clone(), String::new()),
|
||||
@@ -452,3 +445,41 @@ fn spawn_dialog(
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
fn delete_prompt(strings: &GlueStrings, name: &str, level: u8, class: &str) -> String {
|
||||
let template = strings.text(
|
||||
"CONFIRM_CHAR_DELETE",
|
||||
"Do you want to delete\n|cffffffff%s Level %d %s|r?",
|
||||
);
|
||||
benilla_ui::strings::fill(template, &[name.into(), level.into(), class.into()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod localization_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn russian_delete_prompt_reorders_name_class_and_level_without_deleting() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut chain =
|
||||
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
|
||||
.unwrap();
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
let prompt = delete_prompt(&strings, "Qera", 34, strings.class_name(11));
|
||||
assert_eq!(
|
||||
prompt.split_once("|cffffffff").unwrap().1,
|
||||
"Qera, Друид 34-го уровня|r"
|
||||
);
|
||||
assert_eq!(
|
||||
delete_prompt(&GlueStrings::default(), "Agvin", 12, "Warrior"),
|
||||
"Do you want to delete\n|cffffffffAgvin Level 12 Warrior|r?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::sound::GlueSound;
|
||||
|
||||
use super::dialog::DeleteDialog;
|
||||
use super::screen::SelectAction;
|
||||
use super::{class_name, send_pick, ClientState, Roster};
|
||||
use super::{send_pick, ClientState, Roster};
|
||||
|
||||
use crate::glue::{drag_yaw, ROTATE_RATE};
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(super) fn select_input(
|
||||
mut realms: ResMut<crate::realm_select::Realms>,
|
||||
pick: Res<CharPick>,
|
||||
mut dialog: ResMut<DeleteDialog>,
|
||||
strings: Option<Res<crate::glue_strings::GlueStrings>>,
|
||||
mut panel: ResMut<super::addons::AddonsPanel>,
|
||||
choice: Res<crate::net::RealmChoice>,
|
||||
mut next: ResMut<NextState<ClientState>>,
|
||||
@@ -71,7 +72,9 @@ pub(super) fn select_input(
|
||||
// With no selection the click only plays the sound (`selectedIndex > 0` gate).
|
||||
sounds.write(GlueSound("gsCharacterSelectionDelCharacter"));
|
||||
if let Some(c) = roster.selected_char() {
|
||||
dialog.open_for(c.guid, c.name.clone(), c.level, class_name(c.class));
|
||||
let empty = crate::glue_strings::GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
dialog.open_for(c.guid, c.name.clone(), c.level, strings.class_name(c.class));
|
||||
}
|
||||
}
|
||||
SelectAction::CreateChar => {
|
||||
|
||||
@@ -758,6 +758,7 @@ fn clear_select_entry(mut commands: Commands) {
|
||||
/// is up, with `<typed>` pre-typed, for a shot.
|
||||
fn debug_select_dialog(
|
||||
roster: Res<Roster>,
|
||||
strings: Option<Res<crate::glue_strings::GlueStrings>>,
|
||||
mut dialog: ResMut<dialog::DeleteDialog>,
|
||||
time: Res<Time>,
|
||||
entered_at: Res<CharSelectEnteredAt>,
|
||||
@@ -776,7 +777,9 @@ fn debug_select_dialog(
|
||||
let Some(c) = roster.selected_char() else {
|
||||
return;
|
||||
};
|
||||
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, class_name(c.class));
|
||||
let empty = crate::glue_strings::GlueStrings::default();
|
||||
let strings = strings.as_deref().unwrap_or(&empty);
|
||||
let (guid, name, level, class) = (c.guid, c.name.clone(), c.level, strings.class_name(c.class));
|
||||
dialog.open_for(guid, name, level, class);
|
||||
dialog.typed.set_text(&typed);
|
||||
info!("char select: dialog instrument opened the delete confirm");
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::net::NetStatus;
|
||||
use crate::portrait::{GlueLook, GluePreview, SelectLook};
|
||||
|
||||
use super::screen::{RealmBanner, RowText, SelectAction, SelectedName, MAX_ROWS};
|
||||
use super::{class_name, Roster};
|
||||
use super::Roster;
|
||||
|
||||
/// Refill the rows, the selected name and the realm banner when the roster changes or the screen
|
||||
/// was just spawned (a return from the create screen finds the roster unchanged).
|
||||
@@ -61,10 +61,10 @@ pub(super) fn refresh_list(
|
||||
} else {
|
||||
("CHARACTER_SELECT_INFO", "Level %d %s")
|
||||
};
|
||||
strings
|
||||
.text(key.0, key.1)
|
||||
.replacen("%d", &c.level.to_string(), 1)
|
||||
.replacen("%s", class_name(c.class), 1)
|
||||
benilla_ui::strings::fill(
|
||||
strings.text(key.0, key.1),
|
||||
&[c.level.into(), strings.class_name(c.class).into()],
|
||||
)
|
||||
}
|
||||
_ => areas
|
||||
.as_deref()
|
||||
@@ -201,6 +201,50 @@ pub(super) fn feed_glue_preview(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn russian_roster_rows_fill_the_real_level_template_including_ghosts() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
if benilla_formats::ClientLocale::RuRu
|
||||
.pack_folder(&data)
|
||||
.is_none()
|
||||
{
|
||||
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut chain =
|
||||
benilla_formats::Chain::open_localized(&data, benilla_formats::ClientLocale::RuRu)
|
||||
.unwrap();
|
||||
let strings = crate::glue_strings::table_from_chain(&mut chain);
|
||||
let mut live = crate::char_select::test_character(1, "Agvin");
|
||||
live.level = 12;
|
||||
let mut ghost = crate::char_select::test_character(2, "Qera");
|
||||
ghost.level = 34;
|
||||
ghost.class = 11;
|
||||
ghost.flags = benilla_protocol::CHARACTER_FLAG_GHOST;
|
||||
let mut app = App::new();
|
||||
app.insert_resource(Roster::with_pending_pick(vec![live, ghost], 1))
|
||||
.init_resource::<NetStatus>()
|
||||
.insert_resource(strings)
|
||||
.add_systems(Update, refresh_list);
|
||||
let live = app
|
||||
.world_mut()
|
||||
.spawn((RowText::Info(0), Text::default()))
|
||||
.id();
|
||||
let ghost = app
|
||||
.world_mut()
|
||||
.spawn((RowText::Info(1), Text::default()))
|
||||
.id();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world().get::<Text>(live).unwrap().0,
|
||||
"Воин 12-го уровня"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().get::<Text>(ghost).unwrap().0,
|
||||
"Друид 34-го уровня (призрак)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The composition order of `CharacterSelect.lua:48-64`.
|
||||
#[test]
|
||||
fn the_down_note_hangs_off_the_name_and_the_type_suffix_follows_it() {
|
||||
|
||||
@@ -17,23 +17,41 @@ const GLUE_LOCALIZATION: &str = "Interface\\GlueXML\\GlueLocalization.lua";
|
||||
|
||||
/// The glue string table; empty without client data, and callers fall back to built-in captions.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GlueStrings(HashMap<String, String>);
|
||||
pub(crate) struct GlueStrings {
|
||||
strings: HashMap<String, String>,
|
||||
names: benilla_formats::CharacterNames,
|
||||
}
|
||||
|
||||
impl GlueStrings {
|
||||
/// A table from parsed pairs, for tests that resolve against the real file.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_map(map: HashMap<String, String>) -> Self {
|
||||
Self(map)
|
||||
Self {
|
||||
strings: map,
|
||||
names: benilla_formats::CharacterNames::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pairs back out, for a test that has to alter one key and re-resolve.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn into_map(self) -> HashMap<String, String> {
|
||||
self.0
|
||||
self.strings
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).map(String::as_str)
|
||||
self.strings.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn race_name(&self, race: u8) -> &str {
|
||||
self.names
|
||||
.race(race)
|
||||
.unwrap_or(crate::char_select::race_name(race))
|
||||
}
|
||||
|
||||
pub(crate) fn class_name(&self, class: u8) -> &str {
|
||||
self.names
|
||||
.class(class)
|
||||
.unwrap_or(crate::char_select::class_name(class))
|
||||
}
|
||||
|
||||
/// The string for a key, falling back to a built-in caption.
|
||||
@@ -48,10 +66,14 @@ pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<World
|
||||
let mut table = GlueStrings::default();
|
||||
if let Some(assets) = assets {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
match benilla_formats::CharacterNames::load(&mut chain) {
|
||||
Ok(names) => table.names = names,
|
||||
Err(e) => warn!("character names unavailable ({e:#}) — built-in names only"),
|
||||
}
|
||||
match chain.read_file(GLUE_STRINGS) {
|
||||
Ok(bytes) => {
|
||||
table.0 = parse_glue_strings(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} entries", table.0.len());
|
||||
table.strings = parse_glue_strings(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} entries", table.strings.len());
|
||||
}
|
||||
Err(e) => warn!("glue strings unavailable ({e:#}) — built-in captions only"),
|
||||
}
|
||||
@@ -59,7 +81,7 @@ pub(crate) fn load_glue_strings(mut commands: Commands, assets: Option<Res<World
|
||||
Ok(bytes) => {
|
||||
let patch = parse_localize_overrides(&String::from_utf8_lossy(&bytes));
|
||||
info!("glue strings: {} locale overrides", patch.len());
|
||||
table.0.extend(patch);
|
||||
table.strings.extend(patch);
|
||||
}
|
||||
Err(e) => warn!("glue localization unavailable ({e:#}) — unpatched glue strings"),
|
||||
}
|
||||
@@ -142,13 +164,37 @@ pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueString
|
||||
.read_file(GLUE_LOCALIZATION)
|
||||
.expect("GlueLocalization.lua");
|
||||
map.extend(parse_localize_overrides(&String::from_utf8_lossy(&patch)));
|
||||
GlueStrings::from_map(map)
|
||||
GlueStrings {
|
||||
strings: map,
|
||||
names: benilla_formats::CharacterNames::load(chain).expect("character names"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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#"
|
||||
@@ -230,6 +276,6 @@ end
|
||||
|
||||
// The patch overwrites keys; it does not replace the table.
|
||||
assert!(table.get("FACTION_INFO_HORDE").is_some(), "base lost");
|
||||
assert!(base.len() > 100 && table.0.len() >= base.len());
|
||||
assert!(base.len() > 100 && table.strings.len() >= base.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_assets::{LockRecover, WorldAssets};
|
||||
use benilla_formats::ClientLocale;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::Roster;
|
||||
@@ -41,6 +43,7 @@ impl Plugin for NamePersistPlugin {
|
||||
pub(crate) struct NameCacheFile {
|
||||
/// The realm the loaded file belongs to; a change triggers a load.
|
||||
realm: Option<String>,
|
||||
locale: ClientLocale,
|
||||
path: Option<PathBuf>,
|
||||
/// [`NameCache::generation`] at the last write: the dirty bit, which ticks on an answer.
|
||||
saved_generation: u64,
|
||||
@@ -50,6 +53,7 @@ pub(crate) struct NameCacheFile {
|
||||
/// Loads the realm's cache the first frame its identity is known, and on a realm change.
|
||||
fn load_name_cache(
|
||||
roster: Res<Roster>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
mut file: ResMut<NameCacheFile>,
|
||||
mut names: ResMut<NameCache>,
|
||||
) {
|
||||
@@ -59,8 +63,17 @@ fn load_name_cache(
|
||||
if file.realm.as_deref() == Some(realm.as_str()) {
|
||||
return;
|
||||
}
|
||||
// The chain's locale is fixed at boot; read it once per realm, outside the frame loop.
|
||||
let locale = assets
|
||||
.as_deref()
|
||||
.map_or(ClientLocale::EnUs, |a| a.chain.lock_recover().locale());
|
||||
let path = crate::local_state::name_cache_path(&realm);
|
||||
// A realm change must not retain templates loaded for the previous realm.
|
||||
if file.realm.is_some() {
|
||||
names.install_persisted(NameCache::default());
|
||||
}
|
||||
file.realm = Some(realm.clone());
|
||||
file.locale = locale;
|
||||
file.path = path.clone();
|
||||
file.saved_generation = names.generation();
|
||||
let Some(path) = path else {
|
||||
@@ -69,7 +82,7 @@ fn load_name_cache(
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return; // no cache yet
|
||||
};
|
||||
match NameCache::from_tsv(&text, &realm) {
|
||||
match NameCache::from_tsv(&text, &realm, locale) {
|
||||
Some(loaded) => {
|
||||
let n = loaded.len();
|
||||
// Install only the file's records: this runs as the pick is in flight, beside the
|
||||
@@ -117,7 +130,7 @@ fn write_now(file: &mut NameCacheFile, names: &NameCache) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm)) {
|
||||
match crate::local_state::write_atomic(&path, &names.to_tsv(&realm, file.locale)) {
|
||||
Ok(()) => {
|
||||
file.saved_generation = names.generation();
|
||||
debug!("names: wrote {} records to {}", names.len(), path.display());
|
||||
@@ -205,7 +218,11 @@ mod tests {
|
||||
);
|
||||
let path = crate::local_state::name_cache_path("Realm").expect("a state folder");
|
||||
std::fs::create_dir_all(path.parent().expect("cache dir")).expect("cache dir");
|
||||
std::fs::write(&path, on_disk.to_tsv("Realm")).expect("write the realm cache");
|
||||
std::fs::write(
|
||||
&path,
|
||||
on_disk.to_tsv("Realm", benilla_formats::ClientLocale::EnUs),
|
||||
)
|
||||
.expect("write the realm cache");
|
||||
|
||||
// The login has already seeded our own name and a pet's, as `net::session::connected` does.
|
||||
let mut names = NameCache::default();
|
||||
|
||||
@@ -320,15 +320,13 @@ const CACHE_MAGIC: &str = "benilla-namecache";
|
||||
const CACHE_FORMAT: u32 = 2;
|
||||
/// The client build, the reference's `0x16f3`.
|
||||
const CACHE_BUILD: u32 = 5875;
|
||||
/// In the header as in the reference's; benilla reads DBC locale slot 0 only.
|
||||
const CACHE_LOCALE: &str = "enUS";
|
||||
|
||||
impl NameCache {
|
||||
/// Serialize as TSV: the header line, then one line per creature record. In-flight asks are
|
||||
/// not written.
|
||||
pub(crate) fn to_tsv(&self, realm: &str) -> String {
|
||||
let mut out =
|
||||
format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{CACHE_LOCALE}\t{realm}\n");
|
||||
pub(crate) fn to_tsv(&self, realm: &str, locale: benilla_formats::ClientLocale) -> String {
|
||||
let locale = locale.code();
|
||||
let mut out = format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{locale}\t{realm}\n");
|
||||
for (entry, rec) in self.creatures.iter() {
|
||||
match rec {
|
||||
Some(r) => out.push_str(&format!(
|
||||
@@ -351,14 +349,18 @@ impl NameCache {
|
||||
|
||||
/// Rebuild a cache from [`Self::to_tsv`]'s output, `None` when the header differs; a
|
||||
/// malformed line is skipped.
|
||||
pub(crate) fn from_tsv(text: &str, realm: &str) -> Option<Self> {
|
||||
pub(crate) fn from_tsv(
|
||||
text: &str,
|
||||
realm: &str,
|
||||
locale: benilla_formats::ClientLocale,
|
||||
) -> Option<Self> {
|
||||
let mut lines = text.lines();
|
||||
let header: Vec<&str> = lines.next()?.split('\t').collect();
|
||||
if header.len() != 5
|
||||
|| header[0] != CACHE_MAGIC
|
||||
|| header[1] != CACHE_FORMAT.to_string()
|
||||
|| header[2] != CACHE_BUILD.to_string()
|
||||
|| header[3] != CACHE_LOCALE
|
||||
|| header[3] != locale.code()
|
||||
|| header[4] != realm
|
||||
{
|
||||
return None;
|
||||
@@ -551,6 +553,33 @@ pub(crate) mod net {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_russian_session_discards_english_creature_names_and_preserves_russian_names() {
|
||||
use benilla_formats::ClientLocale::{EnUs, RuRu};
|
||||
let mut cache = NameCache::default();
|
||||
cache.insert_creature(
|
||||
261,
|
||||
Some(CreatureRecord {
|
||||
name: "Guard Thomas".into(),
|
||||
subname: None,
|
||||
creature_type: 7,
|
||||
pet_family: 0,
|
||||
rank: 0,
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
let english = cache.to_tsv("R", EnUs);
|
||||
assert!(NameCache::from_tsv(&english, "R", RuRu).is_none());
|
||||
cache.creatures.get_mut(261).unwrap().name = "Стражник Томас".into();
|
||||
let russian = cache.to_tsv("R", RuRu);
|
||||
assert!(NameCache::from_tsv(&russian, "R", EnUs).is_none());
|
||||
let back = NameCache::from_tsv(&russian, "R", RuRu).unwrap();
|
||||
assert_eq!(back.creature_record(261).unwrap().name, "Стражник Томас");
|
||||
}
|
||||
use crossbeam_channel::TryRecvError;
|
||||
|
||||
/// Compose a guid the way the server does: `counter | (entry << 24) | (high << 48)`.
|
||||
@@ -668,8 +697,13 @@ mod tests {
|
||||
);
|
||||
cache.insert_creature(1234, None); // the server flagged the entry unknown
|
||||
|
||||
let text = cache.to_tsv("Hydraxian Waterlords");
|
||||
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
|
||||
let text = cache.to_tsv("Hydraxian Waterlords", benilla_formats::ClientLocale::EnUs);
|
||||
let back = NameCache::from_tsv(
|
||||
&text,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
)
|
||||
.expect("header matches");
|
||||
|
||||
// Present-and-None, not absent.
|
||||
assert!(back.creatures.answered(1234));
|
||||
@@ -697,7 +731,12 @@ mod tests {
|
||||
display_id: 1,
|
||||
}),
|
||||
);
|
||||
let back = NameCache::from_tsv(&cache.to_tsv("R"), "R").expect("header");
|
||||
let back = NameCache::from_tsv(
|
||||
&cache.to_tsv("R", benilla_formats::ClientLocale::EnUs),
|
||||
"R",
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
)
|
||||
.expect("header");
|
||||
assert_eq!(back.creature_record(70).unwrap().subname, None);
|
||||
}
|
||||
|
||||
@@ -723,12 +762,13 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
|
||||
let text = cache.to_tsv("R");
|
||||
let text = cache.to_tsv("R", benilla_formats::ClientLocale::EnUs);
|
||||
assert!(
|
||||
!text.contains("Moamtester") && !text.contains("Rex"),
|
||||
"no guid- or spawn-keyed name may reach the disk:\n{text}"
|
||||
);
|
||||
let back = NameCache::from_tsv(&text, "R").expect("header matches");
|
||||
let back = NameCache::from_tsv(&text, "R", benilla_formats::ClientLocale::EnUs)
|
||||
.expect("header matches");
|
||||
assert_eq!(
|
||||
back.peek(0x11),
|
||||
None,
|
||||
@@ -780,11 +820,19 @@ mod tests {
|
||||
fn a_header_that_differs_in_any_field_discards_the_whole_file() {
|
||||
let mut cache = NameCache::default();
|
||||
cache.insert_creature(1234, None);
|
||||
let good = cache.to_tsv("Hydraxian Waterlords");
|
||||
assert!(NameCache::from_tsv(&good, "Hydraxian Waterlords").is_some());
|
||||
let good = cache.to_tsv("Hydraxian Waterlords", benilla_formats::ClientLocale::EnUs);
|
||||
assert!(NameCache::from_tsv(
|
||||
&good,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs
|
||||
)
|
||||
.is_some());
|
||||
|
||||
// The realm: our field, not the reference's.
|
||||
assert!(NameCache::from_tsv(&good, "Another Realm").is_none());
|
||||
assert!(
|
||||
NameCache::from_tsv(&good, "Another Realm", benilla_formats::ClientLocale::EnUs)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let head = good.lines().next().unwrap();
|
||||
for (field, bad) in [
|
||||
@@ -797,23 +845,40 @@ mod tests {
|
||||
parts[field] = bad;
|
||||
let text = format!("{}\n{}", parts.join("\t"), good.lines().nth(1).unwrap());
|
||||
assert!(
|
||||
NameCache::from_tsv(&text, "Hydraxian Waterlords").is_none(),
|
||||
NameCache::from_tsv(
|
||||
&text,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs
|
||||
)
|
||||
.is_none(),
|
||||
"header field {field} = {bad} must discard the file"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(NameCache::from_tsv("", "R").is_none());
|
||||
assert!(NameCache::from_tsv("nonsense", "R").is_none());
|
||||
assert!(NameCache::from_tsv("", "R", benilla_formats::ClientLocale::EnUs).is_none());
|
||||
assert!(
|
||||
NameCache::from_tsv("nonsense", "R", benilla_formats::ClientLocale::EnUs).is_none()
|
||||
);
|
||||
let text = format!("{}\nC\tnotanumber\t1\t2\t0\tX\nc\t1234\n", head);
|
||||
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header still good");
|
||||
let back = NameCache::from_tsv(
|
||||
&text,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs,
|
||||
)
|
||||
.expect("header still good");
|
||||
assert_eq!(back.len(), 1, "the bad line is skipped, the good one kept");
|
||||
|
||||
// A version-1 file, which held player names, is discarded whole.
|
||||
let v1 = format!(
|
||||
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\t{CACHE_LOCALE}\tHydraxian Waterlords\n\
|
||||
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\tenUS\tHydraxian Waterlords\n\
|
||||
P\t7\t1\t1\t0\tMoamtester\n"
|
||||
);
|
||||
assert!(NameCache::from_tsv(&v1, "Hydraxian Waterlords").is_none());
|
||||
assert!(NameCache::from_tsv(
|
||||
&v1,
|
||||
"Hydraxian Waterlords",
|
||||
benilla_formats::ClientLocale::EnUs
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Localized display names from the 1.12 ChrRaces and ChrClasses name blocks.
|
||||
//! The community ruRU patch stores its translated names in locale slot zero.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// The display names, distinct from the unlocalized race/class file tokens.
|
||||
pub struct CharacterNames {
|
||||
races: HashMap<u32, String>,
|
||||
classes: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl CharacterNames {
|
||||
pub fn load(chain: &mut Chain) -> Result<Self> {
|
||||
Ok(Self {
|
||||
races: load_names(chain, "ChrRaces", 29, 17)?,
|
||||
classes: load_names(chain, "ChrClasses", 17, 5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn race(&self, id: u8) -> Option<&str> {
|
||||
self.races.get(&u32::from(id)).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn class(&self, id: u8) -> Option<&str> {
|
||||
self.classes.get(&u32::from(id)).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_names(
|
||||
chain: &mut Chain,
|
||||
table: &str,
|
||||
fields: usize,
|
||||
name_field: usize,
|
||||
) -> Result<HashMap<u32, String>> {
|
||||
let path = format!("DBFilesClient\\{table}.dbc");
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let mut schema = Schema::new(table);
|
||||
for i in 0..fields {
|
||||
schema.add_field(SchemaField::new(
|
||||
format!("F{i}"),
|
||||
if i == name_field {
|
||||
FieldType::String
|
||||
} else {
|
||||
FieldType::UInt32
|
||||
},
|
||||
));
|
||||
}
|
||||
let rows = parse(&bytes, schema, table)?;
|
||||
Ok(rows
|
||||
.records()
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let id = u32_at(r, 0)?;
|
||||
let name = str_at(&rows, r, name_field)?;
|
||||
(!name.is_empty()).then_some((id, name))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ClientLocale;
|
||||
|
||||
#[test]
|
||||
fn character_names_follow_the_selected_patch_chain() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut english = Chain::open(&data).unwrap();
|
||||
let names = CharacterNames::load(&mut english).unwrap();
|
||||
assert_eq!(names.race(1), Some("Human"));
|
||||
assert_eq!(names.class(1), Some("Warrior"));
|
||||
assert_eq!(names.race(255), None);
|
||||
assert_eq!(names.class(255), None);
|
||||
if ClientLocale::RuRu.pack_folder(&data).is_none() {
|
||||
crate::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
|
||||
return;
|
||||
}
|
||||
let mut russian = Chain::open_localized(&data, ClientLocale::RuRu).unwrap();
|
||||
let names = CharacterNames::load(&mut russian).unwrap();
|
||||
assert_eq!(names.race(1), Some("Человек"));
|
||||
assert_eq!(names.class(1), Some("Воин"));
|
||||
assert_eq!(names.race(6), Some("Таурен"));
|
||||
assert_eq!(names.class(11), Some("Друид"));
|
||||
for id in 1..=8 {
|
||||
assert!(names
|
||||
.race(id)
|
||||
.unwrap()
|
||||
.chars()
|
||||
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
|
||||
}
|
||||
for id in [1, 2, 3, 4, 5, 7, 8, 9, 11] {
|
||||
assert!(names
|
||||
.class(id)
|
||||
.unwrap()
|
||||
.chars()
|
||||
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@ pub use benilla_blp::BlpTexels;
|
||||
|
||||
mod chain;
|
||||
pub use chain::{Chain, ChainEntry};
|
||||
mod locale;
|
||||
pub use locale::ClientLocale;
|
||||
mod character_names;
|
||||
pub use character_names::CharacterNames;
|
||||
/// Loose `.tga` art, as addon folders ship it.
|
||||
mod tga;
|
||||
pub use tga::tga_to_rgba;
|
||||
@@ -20,7 +24,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 character_names characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
|
||||
- **benilla-m2** (lib.rs): error model skin tests track
|
||||
- **benilla-mpq** (lib.rs): crypto
|
||||
- **benilla-protocol** (lib.rs): auth events guid messages wire world
|
||||
@@ -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