Compare commits

...

2 Commits

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

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

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

Windows validation: all scripts/gates.sh gates green (2963 seconds), with and without vanilla data, player build tests and engine boot checks. 31 tests skipped only for the absent addon corpus. No declared account for live login; other platforms were not built.
2026-10-10 15:48:17 +03:00
96 changed files with 3203 additions and 525 deletions
+11
View File
@@ -68,6 +68,17 @@ benilla builds and runs on macOS, Linux and Windows. You need:
Xcode command line tools, on Linux the ALSA and udev development packages and pkg-config, on
Windows the MSVC build tools that the Rust installer sets up.
This fork also reads community **ruRU language packs for 1.12.1** from `Data/ruRU`.
Keep the base MPQs in `Data` and the Russian `patch-*.MPQ` files in that subfolder;
`WOW_DATA` points to `Data`, not to `ruRU`. An installed pack selects Russian automatically.
`WOW_LOCALE=ruRU` requires the pack, `WOW_LOCALE=enUS` selects the English base chain, and
`WOW_LOCALE=auto` restores automatic selection. Restart the client after changing the language.
The selected chain supplies DBC names, interface strings, fonts, textures and audio; missing
localized files fall back to the base chain. Benilla's own settings and menu captions also have
Russian translations. The login sends the selected locale to the server; server-supplied quest,
NPC and item text requires translations on that server. Packs for later expansions are unsupported.
`moonwell-deploy stage` preserves an installed `ruRU` archive folder in the distribution.
```sh
WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla
```
@@ -39,7 +39,7 @@
-- space: a width or height of 0 is no size in 1.12's layout, not a flat rect.
-- Literal: 1.12 has no GAMEMENU_OPTIONS; its OPTIONS_MENU is "Options Menu".
GameMenuButtonOptions:SetText("Options")
GameMenuButtonOptions:SetText(BenillaUiText("Options"))
GameMenuButtonOptions:ClearAllPoints()
GameMenuButtonOptions:SetPoint("TOP", GameMenuFrame, "TOP", 0, -32)
GameMenuButtonOptions:SetScript("OnClick", function()
@@ -66,12 +66,12 @@
local editMode = CreateFrame("Button", "BenillaGameMenuButtonEditMode", GameMenuFrame,
"GameMenuButtonTemplate")
editMode:SetPoint("TOP", GameMenuButtonKeybindings, "TOP", 0, -19)
editMode:SetText("Edit Mode")
editMode:SetText(BenillaUiText("Edit Mode"))
editMode:Disable()
local support = CreateFrame("Button", "BenillaGameMenuButtonSupport", GameMenuFrame,
"GameMenuButtonTemplate")
support:SetPoint("TOP", editMode, "BOTTOM", 0, 0)
support:SetText("Support")
support:SetText(BenillaUiText("Support"))
support:Disable()
GameMenuButtonMacros:SetPoint("TOP", support, "BOTTOM", 0, 0)
@@ -471,7 +471,7 @@
}
-- Reset To Default confirms, as the era's does; our string, since 1.12 has none.
StaticPopupDialogs["BENILLA_CONFIRM_RESET_KEYBINDINGS"] = {
text = "Are you sure you want to reset all keybindings to their defaults?",
text = BenillaUiText("Are you sure you want to reset all keybindings to their defaults?"),
button1 = BenillaKeyBindings_String("OKAY", "Okay"),
button2 = BenillaKeyBindings_String("CANCEL", "Cancel"),
OnAccept = function()
@@ -491,7 +491,7 @@
for i = 1, GetNumBindings() do
local command = GetBinding(i)
if not BenillaKeyBindings_IsHeader(command) then
local tag = string.upper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local tag = BenillaUiUpper(BenillaKeyBindings_String("BINDING_NAME_" .. command, command))
local s = BenillaOptionsSearch_Score(tag, words)
if s then
table.insert(out, { score = s, index = i })
+30 -10
View File
@@ -110,13 +110,15 @@
BenillaOptionsFrame:SetScale(k)
end
-- The era tab width, label + 40, measured after OnShow sets the scale.
-- The era tab width, label + 40, measured after the window's OnShow sets the scale:
-- the tabs' own OnShows run before it, children first.
function BenillaOptionsTab_FitWidth(tab)
tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40)
end
-- 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)
@@ -179,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)
@@ -323,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)
@@ -433,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 = {
@@ -492,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")
@@ -632,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)
@@ -669,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
@@ -1341,9 +1362,6 @@
<NormalFont inherits="GameFontHighlightSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<Scripts>
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
</Scripts>
</Button>
<Button name="$parentAddOnsTab">
<Size><AbsDimension x="87" y="37"/></Size>
@@ -1371,7 +1389,6 @@
<!-- No addon list behind it: disabled, in the era's unselected gold. -->
<Scripts>
<OnLoad>this:Disable()</OnLoad>
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
</Scripts>
</Button>
@@ -3068,6 +3085,8 @@
<!-- Controls by default; selection persists and the scale re-fits, as the era's. -->
<OnShow>
BenillaOptionsFrame_UpdateScale()
BenillaOptionsTab_FitWidth(BenillaOptionsFrameGameTab)
BenillaOptionsTab_FitWidth(BenillaOptionsFrameAddOnsTab)
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
if UpdateMicroButtons then UpdateMicroButtons() end
</OnShow>
@@ -3076,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)
@@ -232,8 +232,9 @@
</Scripts>
</Button>
<Frame name="BenillaScriptLogFrame" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
as the stock panels are, so it takes the UI scale with them. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -369,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.
+12 -4
View File
@@ -1358,15 +1358,23 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-chat-tabhover select failed: {e}");
}
script.resolve();
// Each in the root's units the cursor is fed in: the frame's own times its scale.
let expr = match mode.as_str() {
"2" | "3" => "return ChatFrame2Tab:GetCenter()",
"2" | "3" => {
"local x, y = ChatFrame2Tab:GetCenter() \
local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k"
}
"0" => {
"return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2"
"local k = ChatFrame2:GetEffectiveScale() \
return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k"
}
// 9: park far away, so the dock conceals itself.
"9" => "return 2000, 2000",
_ => "return ChatFrame1Tab:GetCenter()",
_ => {
"local x, y = ChatFrame1Tab:GetCenter() \
local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k"
}
};
let centre: Result<(f32, f32), _> = script.eval(expr);
match centre {
+3 -3
View File
@@ -550,13 +550,13 @@ struct ProbeDrag {
to: (f32, f32),
}
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
/// [`crate::ui_script::input`] feeds the cursor in.
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective
/// scale: the edges answer in the frame's own units, the cursor is fed in the root's.
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
let read = |edge: &str| {
script
.eval::<f32>(&format!(
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
))
.ok()
};
+1 -1
View File
@@ -553,7 +553,7 @@ fn draw_bubble(
border_color: [1.0; 4],
};
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
for p in pieces(up, &bd) {
for p in pieces(up, &bd, 1.0) {
// Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners.
let rect = Rect::new(
p.corners[0][0],
+21
View File
@@ -149,6 +149,27 @@ pub(crate) fn table_from_chain(chain: &mut benilla_formats::Chain) -> GlueString
mod tests {
use super::*;
#[test]
fn the_russian_pack_localizes_glue_captions_as_utf8() {
let data = benilla_formats::wow_data_or_skip!();
let locale = benilla_formats::ClientLocale::RuRu;
if locale.pack_folder(&data).is_none() {
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let mut chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
let table = table_from_chain(&mut chain);
for key in ["LOGIN", "ACCOUNT_NAME", "PASSWORD"] {
let caption = table.get(key).expect("localized glue caption");
assert!(
caption
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)),
"{key}: {caption}"
);
}
}
#[test]
fn parses_assignments_and_skips_the_rest() {
let src = r#"
+11
View File
@@ -312,8 +312,19 @@ fn launch(build: BuildId, extend: Option<Extension<'_>>) -> AppExit {
// absent and terrain loads fail gracefully.
match benilla_formats::wow_data() {
Some(data_dir) => {
let locale = match benilla_formats::ClientLocale::for_install(&data_dir) {
Ok(locale) => locale,
Err(e) => {
eprintln!("benilla: {e:#}");
return AppExit::error();
}
};
if let Err(e) = benilla_assets::register_mpq_source(&mut app, &data_dir) {
eprintln!("benilla-assets: mpq:// source unavailable ({e:#})");
// A selected pack must not degrade into an English login with no client data.
if locale == benilla_formats::ClientLocale::RuRu {
return AppExit::error();
}
}
}
None => eprintln!(
+5
View File
@@ -147,6 +147,7 @@ pub(super) struct Attempt<'w> {
submit: Res<'w, LoginSubmit>,
abandon: Res<'w, LoginAbandon>,
realmlist: Res<'w, crate::realmlist::Realmlist>,
assets: Option<Res<'w, benilla_assets::WorldAssets>>,
}
impl Attempt<'_> {
@@ -161,6 +162,10 @@ impl Attempt<'_> {
user: user.to_string(),
pass: pass.to_string(),
host: self.realmlist.address().to_string(),
locale: self.assets.as_ref().map_or(*b"enUS", |assets| {
use benilla_assets::LockRecover;
assets.chain.lock_recover().locale().auth_tag()
}),
generation: self.abandon.0.load(Ordering::SeqCst),
});
}
+4 -5
View File
@@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = (
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
ResMut<'w, MinimapBlipHover>,
Res<'w, crate::ui_script::UiScaleCvar>,
Res<'w, crate::ui_party::GroupState>,
TrackedCandidates<'w, 's>,
Query<'w, 's, (&'static ObjectStore, &'static Guid), With<SelfPlayer>>,
@@ -200,11 +199,11 @@ pub(super) struct BlipCtx {
pub(super) wz: f32,
/// The cursor in the quads' y-down logical px.
pub(super) cursor: Option<Vec2>,
/// The cursor in y-up UI space; the reference seats the blip tooltip at the cursor, its exact
/// offset untraced.
/// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its
/// exact offset untraced.
pub(super) cursor_ui: Option<Vec2>,
/// Window px per UI unit: everything else here is window px, while Lua
/// ([`Minimap:PingLocation`](super::ping)) speaks UI units.
/// Window px per root unit: everything else here is window px, while Lua
/// ([`Minimap:PingLocation`](super::ping)) hands root units.
pub(super) seam: f32,
}
+4 -5
View File
@@ -351,7 +351,6 @@ fn emit_minimap(
unit_pos,
window,
mut blip_hover,
ui_scale,
group,
tracked,
self_store,
@@ -621,7 +620,7 @@ fn emit_minimap(
}
let win = window.iter().next();
let cursor = win.and_then(|w| w.cursor_position());
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0));
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
// The pan origin snapped to a half-logical-pixel grid: every blip offset and rim bearing
// derives from `wx`/`wy`, so the layer steps together instead of rewriting the batch mesh
// every frame while walking. Blip world positions stay exact.
@@ -637,11 +636,11 @@ fn emit_minimap(
wy: (wy / q).round() * q,
wz: wow[2],
cursor,
// The cursor in UI space (y-up, ÷ the seam scale): the tooltip anchor resolves in
// 768-virtual units, not window px.
// The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves
// in them, not window px.
cursor_ui: cursor
.zip(win)
.map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)),
.map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)),
seam,
}
});
+10 -9
View File
@@ -5,9 +5,10 @@
//! [`drive_minimap_ping`] republishes against the live view radius, so the marker follows the pan
//! and the zoom with no second copy to fall out of step.
//!
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in UI units from the
//! centre; the renderer converts it with the geometry of the frame it draws: UI units × the seam
//! scale = window px, ÷ `px_per_yd` = yards.
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in the minimap's units
//! from the centre, which the engine hands over in root units; the renderer converts them with
//! the geometry of the frame it draws: root units × the seam scale = window px, ÷ `px_per_yd` =
//! yards.
//! - Across: our ping sends `MSG_MINIMAP_PING` (raw world floats, relayed to the rest of the group
//! only, `GroupHandler.cpp:384-391`); a member's seats the same way. A ping seats locally at
//! click time, so a solo ping works.
@@ -59,8 +60,8 @@ impl MinimapPing {
}
}
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative UI
/// units (x right, y up), `seam` window px per UI unit ([`crate::ui_script::seam_scale`]), `ctx`
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative root
/// units (x right, y up), `seam` window px per root unit ([`crate::ui_script::seam_scale`]), `ctx`
/// the map as drawn this frame. The inverse of [`BlipCtx::offset`]: screen right is −WoW y, screen
/// up +WoW x. `None` outside the disc, the stock test (`Minimap.lua:135`) in yards.
fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> {
@@ -193,13 +194,13 @@ mod tests {
}
}
/// The click is in UI units and `px_per_yd` in window px: at 0.9 uiScale on a 1080p window the
/// seam is ≈1.27, and a conversion that skips it seats every ping ≈27 % too far out.
/// The click is in root units and `px_per_yd` in window px: on a 1080p window the seam is
/// ≈1.41, and a conversion that skips it seats every ping ≈41 % too far out.
#[test]
fn a_click_converts_through_the_seam_scale() {
let c = ctx();
let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p
// 20 UI units right → 20·seam px → yards west (−y).
let seam = 1080.0 / 768.0;
// 20 root units right → 20·seam px → yards west (−y).
let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc");
let expect_yd = 20.0 * seam / c.px_per_yd;
assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click");
+3 -1
View File
@@ -64,6 +64,8 @@ pub(crate) struct LoginRequest {
/// The realmlist to dial, `host[:port]`, per attempt so an edit mid-dial cannot retarget the
/// connection in flight.
pub(crate) host: String,
/// The mounted client language captured at submit time, in canonical ASCII tag order.
pub(crate) locale: [u8; 4],
pub(crate) generation: u64,
}
@@ -355,7 +357,7 @@ fn run(
// Logon: the dial and the SRP6 exchange against realmd.
let mut logon = {
stage(LoginStage::Connecting);
match benilla_protocol::logon(&req.host, &req.user, &req.pass) {
match benilla_protocol::logon_with_locale(&req.host, &req.user, &req.pass, req.locale) {
Ok(l) => l,
Err(e) => {
if canceled() {
@@ -248,6 +248,7 @@ fn an_empty_account_parks_at_select_without_creating_a_character() {
user: USER.into(),
pass: PASS.into(),
host: realmd,
locale: *b"enUS",
generation: 0,
})
.unwrap();
+3 -3
View File
@@ -113,9 +113,9 @@ pub(super) fn refresh_hud_snapshot(
/// clear). The always-up world-state readout (`WorldStateAlwaysUpFrame`, stock
/// `WorldStateFrame.xml`) shares the band, and the dev pill is the one that yields.
///
/// The layout answers in UI units, a screen `768/uiScale` units tall whatever the window, while
/// the pill draws in window px, so the chunk returns a fraction of the screen and the caller
/// scales it by the window height. Reading an edge settles the layout, one graph solve at most.
/// The layout answers in `UIParent`'s units, a screen `768/uiScale` units tall whatever the
/// window, while the pill draws in window px, so the chunk returns a fraction of the screen and the
/// caller scales it by the window height. Reading an edge settles the layout, one graph solve at most.
pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 {
// `WorldStateAlwaysUpFrame` is always shown; the readout occupies its `AlwaysUpFrame<n>` rows,
// built on demand and hidden when empty, so the claim is the lowest shown row's bottom.
+2 -2
View File
@@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels(
walk.at = Some(zone_id);
}
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML,
/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick.
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML
/// and the addons in one call, ahead of the walk's first tick.
pub(crate) fn seed_zone_channel_catalog(
world: &mut World,
script: &mut benilla_ui::script::UiScript,
+3 -2
View File
@@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) {
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ChatWindowFile>()
// The restore runs from the world-entry UI load, not here: its events must precede the
// first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees.
// The restore runs from the world-entry UI load, not here: its events must follow
// `VARIABLES_LOADED` inside the load, where the reference registers its reader
// (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`.
.add_systems(
Update,
// After the tick: every write it watches is Lua's.
+8 -3
View File
@@ -2397,8 +2397,9 @@ fn the_free_professions_line_is_printed_once() {
// ───────────── the chat cache restores inside the login ─────────
/// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR`
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`.
/// The probe is a loose addon, because the entry load builds the VM it runs on.
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`,
/// which the load arms and the world-enter cascade fires. The probe is a loose addon, because the
/// entry load builds the VM it runs on.
#[test]
fn the_chat_cache_restore_is_finished_before_player_login() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -2485,6 +2486,10 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
1,
));
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
// The cascade, as the unit feed runs it once the player is in the world.
world
.non_send_resource_mut::<benilla_ui::script::UiScript>()
.fire_world_enter();
let read = |expr: &str| -> String {
world
@@ -2512,7 +2517,7 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
read("ChatOrderProbe.loginRegistered")
);
// The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`),
// the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`).
// the chat-cache burst (`0x4900d6`), the arm (`0x49011d`), `PLAYER_LOGIN` (`0x490959`).
assert_eq!(
read("ChatOrderProbe.order"),
"VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ",
+10 -3
View File
@@ -1692,9 +1692,10 @@ mod tests {
);
}
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and
/// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from
/// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`.
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`),
/// fires `VARIABLES_LOADED` at `0x4900b2` and arms `PLAYER_LOGIN` (`0x49011d`), which the
/// world-enter cascade fires: on a fresh login from the player's create (`0x5deb60` →
/// `0x4908c0`), on a `/reload` from `0x490168`.
#[test]
fn the_ui_init_events_fire_in_the_reference_order() {
let _l = crate::local_state::test_env::ENV_LOCK
@@ -1728,6 +1729,12 @@ mod tests {
let failures = load_third_party(&mut script, None, &[], true);
assert!(failures.is_empty(), "load errors: {failures:?}");
crate::ui_script::finish_ui_load(&mut script);
assert_eq!(
event_log(&script),
vec!["ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil"],
"the load only arms PLAYER_LOGIN"
);
script.fire_world_enter();
assert_eq!(
event_log(&script),
@@ -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(
+23 -11
View File
@@ -425,7 +425,11 @@ pub(super) fn tick_script(
mut last_seam: Local<f32>,
// The `scale_factor` measures were last answered under; 0 until the first frame.
mut last_dpi: Local<f32>,
ui_scale: Res<super::UiScaleCvar>,
// The dial, `UIParent`'s scale in the VM, and the automatic scale's last display.
(ui_scale, mut auto_cache): (
Res<super::UiScaleCvar>,
Option<ResMut<super::UiAutoScaleCache>>,
),
mut ui_cost: ResMut<super::UiFrameCost>,
mut pass: ResMut<super::UiPassState>,
) {
@@ -442,9 +446,9 @@ pub(super) fn tick_script(
};
let (w, h) = (window.width(), window.height());
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
// scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h, ui_scale.0);
// caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in
// it: quads scale ×s out, and the mouse and the measures ÷s in.
let s = super::seam_scale(h);
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
// advances do not rescale, and the size snap moves a string's width by several percent,
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
@@ -486,8 +490,17 @@ pub(super) fn tick_script(
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A resize re-runs what the interface computed from the old screen size.
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
if resized {
let display = (w.round() as u32, h.round() as u32);
let mut none = None;
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.apply_ui_scale(display, cache);
}
let rescaled = script.set_ui_scale(ui_scale.0);
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
if resized || rescaled {
super::manifest::on_screen_resized(&script);
}
script.tick(time.delta_secs());
@@ -1713,9 +1726,10 @@ mod clip_plumb_tests {
);
}
/// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves.
/// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip
/// at dial 0.5: the root stays 768 units tall.
#[test]
fn the_uiscale_dial_scales_the_extracted_rects() {
fn the_uiscale_dial_leaves_a_parentless_frame_alone() {
let mut app = app_with_scrolled_marker();
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
app.update();
@@ -1726,12 +1740,10 @@ mod clip_plumb_tests {
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
.expect("the colored marker quad extracted");
// WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
// and y-down through the 768 window the top is 50 and the bottom 150.
assert_eq!(
marker.clip,
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
"dial 0.5 moves nothing outside UIParent"
);
}
@@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> {
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
/// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px.
pub scale: f32,
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
/// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in
/// root units.
pub font_scale: f32,
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
pub caret_pinned: bool,
@@ -387,7 +388,7 @@ mod tests {
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
/// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale.
#[test]
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
assert_eq!(shadow_offset_px(1.0), 1.0);
@@ -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]) {
+2 -6
View File
@@ -94,8 +94,6 @@ pub(super) fn feed_ui_input(
),
// The characters the active layout makes, which name a key for a keyboard frame.
layout: Res<crate::bindings::LayoutNames>,
// The uiScale dial folded into the seam scale.
ui_scale: Res<super::UiScaleCvar>,
) {
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
let world_pick = pointer.world_pick();
@@ -139,10 +137,8 @@ pub(super) fn feed_ui_input(
.or_else(crate::target::hover_probe_point)
.filter(|_| !synthetic)
{
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
// units under uiScale: flip through the window height, then undo the extract seam's scale.
let s = super::seam_scale(window.height(), ui_scale.0);
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
// The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it.
let Vec2 { x, y } = super::window_to_ui(window.height(), cursor);
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let metering =
@@ -492,16 +492,19 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"the window still shows — Show() runs before OnShow raises"
);
// Two raises: `InspectFrame` and `InspectPaperDollFrame` each run their own `OnShow` under
// their own pcall, the first dying at the portrait's gate, the second at `UnitLevel`'s.
// Two raises: `InspectPaperDollFrame` and `InspectFrame` each run their own `OnShow` under
// their own pcall, the page first, since the show cascade notifies children before their
// parent (`0x76aed5`, then `0x76aef5`): the page dies at `UnitLevel`'s gate, the window at
// the portrait's.
let errors = s.errors();
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
assert!(
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"the portrait's gate, before the name line is ever reached: {errors:?}"
errors[0].contains(r#"Usage: UnitLevel("unit")"#),
"the paper doll's own handler, dying at its first getter: {errors:?}"
);
assert!(
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
"and the paper doll's own handler, dying at its first getter: {errors:?}"
errors[1].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"then the window's, at the portrait's gate, before the name line is ever reached: \
{errors:?}"
);
}
+96 -36
View File
@@ -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}");
@@ -230,26 +233,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
);
}
/// Seat the VM's screen size and text measurer for the load, under the current window and
/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM
/// answers 1024×768 until then, and a value computed once at load keeps it: stock
/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and
/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on
/// the first `Update`) the per-frame pass seats the measurer.
/// Seat the VM's screen size and text measurer for the load, under the current window: the
/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768
/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the
/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again
/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no
/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer.
fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let (w, h) = {
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world)
.map_or((0.0, 0.0), |win| (win.width(), win.height()))
};
let s = super::seam_scale(h, ui_scale);
let s = super::seam_scale(h);
if w > 0.0 && h > 0.0 {
// The VM lives in 768-tall virtual space, so logical size over the seam scale is its
// screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid
// out yet.
// The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No
// `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet.
script.set_screen_size(w / s, h / s);
}
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
@@ -258,12 +257,29 @@ fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
super::extract::seat_text_measurer(script, atlas, s);
}
/// Which `"player"` a UI load runs its files under, by the edge that runs it.
#[derive(Clone, Copy, PartialEq, Eq)]
enum LoadSeat {
/// World entry: `UI_Init` runs before `CMSG_PLAYER_LOGIN` goes out (the load at `0x46c236`,
/// the send in a later step at `0x46c272`), so no player object exists and the roster's
/// record stands in ([`seat_from_roster`]).
Roster,
/// `/reload`: the player object stays in the world through the teardown and the load, so the
/// live one is seated ([`crate::ui_unit::live_player_seat`]), the roster's if none exists.
Live,
}
/// Load the in-game UI for this session onto a VM built for it, once per world entry. The
/// reference builds the UI in `UI_Init` (`0x48fbf0`, re-making the Lua state at `0x48fe97`) and
/// destroys it at `0x490bd0`, so every login runs every addon's file scope afresh. Safe on the
/// state edge because the initial transition runs after `PostStartup`: a capture booting straight
/// into `InWorld` still loads after [`benilla_assets::AssetSet::Open`].
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
load_ingame_ui_seated(world, LoadSeat::Roster);
}
/// [`load_ingame_ui_on_world_entry`] under the `"player"` its edge has.
fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
// The parked VM goes back into the world, where the CVar fold reads it; a reload has none
// parked and uses the live one.
unpark_boot_vm(world);
@@ -322,19 +338,31 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
// The player too, before the addon walk. The record first: the reference's
// `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The
// snapshot stands in for the descriptor until that streams in.
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. Then
// the unit: the live player on a `/reload`, else the roster's stand-in until the descriptor
// streams in.
if let Some(record) = world
.get_resource::<crate::char_select::Roster>()
.and_then(record_from_roster)
{
script.set_player_record(record);
}
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
match (seat == LoadSeat::Live)
.then(|| live_player_seat(world))
.flatten()
{
script.set_unit("player", Some(seat));
Some(live) => {
script.set_unit("player", Some(live.state));
script.set_player_xp(live.xp.0, live.xp.1);
}
None => {
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
{
script.set_unit("player", Some(seat));
}
}
}
// The addon version gate, read from the persisted base the fold above just made current, so a
// character-screen "Load out of date AddOns" click counts. A bare test world is check on.
@@ -358,6 +386,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// text width, `UIPanelTemplates.xml:370-373`), which answers 0 with no measurer.
seat_raster_seam_for_load(world, &mut script);
// Read out of the world first: the bracket below borrows `world` for the chat-cache restore.
let ui_scale = world
.get_resource::<super::UiScaleCvar>()
.map_or(1.0, |c| c.0);
let zoom = {
let z = world.resource::<crate::minimap::MinimapZoom>();
(z.outdoor, z.inside)
@@ -392,6 +423,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|script| crate::vplates::seat_legacy_settings(script, &legacy_plates),
|script| {
crate::ui_chat::restore_chat_looks(world, script);
// The UI scale lands on `UIParent` once the files, the addons and
// `VARIABLES_LOADED` are done, after the chat reader (`0x4900d6`) and before the
// `PLAYER_LOGIN` arm (`0x49011d`), through the display handler (`0x49010e` →
// `0x492e90`). So every `OnLoad` sees `UIParent` at scale 1 and the root's size.
let display = {
let mut q =
world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
q.single(world).map_or((0, 0), |w| {
(w.width().round() as u32, w.height().round() as u32)
})
};
let mut none = None;
let mut cache = world.get_resource_mut::<super::UiAutoScaleCache>();
let cache = cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
script.seat_ui_scale(ui_scale, display, cache);
},
);
});
@@ -418,6 +464,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
}
}
/// The live player's seat off the world, through the unit feed's own builder; `None` before our
/// own create. A world without the feed's resources (a bare test world) has none to read.
fn live_player_seat(world: &mut World) -> Option<crate::ui_unit::LivePlayerSeat> {
world
.run_system_cached(crate::ui_unit::live_player_seat)
.unwrap_or_else(|e| {
warn!("ui_script: no live player to seat for the load ({e}); the roster stands in");
None
})
}
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s 2/3 scale, as `ui_unit::snapshot`
/// maps the descriptor's; the record and the seat share it.
fn roster_sex(gender: u8) -> u8 {
@@ -648,11 +705,13 @@ pub(crate) fn end_ui_session(world: &mut World) {
pub(crate) struct ReloadUiPending(pub(crate) bool);
/// Run a pending `ReloadUI()`: the reference's teardown and rebuild (`0x495664 call 0x490bd0`,
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then [`load_ingame_ui_on_world_entry`]
/// without leaving the world, so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt`
/// before the rebuild reads it. `PLAYER_ENTERING_WORLD` then refires from [`crate::ui_unit`]'s
/// feed on the new VM. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the
/// request is dropped.
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then the load without leaving the world,
/// so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it.
/// The load runs under the live player, which never left. `UI_Init` then enters the world-enter
/// cascade itself (`0x490168`), firing the `PLAYER_LOGIN` it armed and `PLAYER_ENTERING_WORLD`;
/// ours fire from [`crate::ui_unit`]'s feed on the new VM's first `Update`, once it has re-seated
/// the player. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the request
/// is dropped.
pub(crate) fn run_pending_reload(world: &mut World) {
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
return;
@@ -667,7 +726,7 @@ pub(crate) fn run_pending_reload(world: &mut World) {
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
load_ingame_ui_seated(world, LoadSeat::Live);
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
// `SetActiveMover`, which only the player's create runs.
@@ -710,13 +769,13 @@ pub(crate) fn shutdown_on_exit(
/// saved file and fires `VARIABLES_LOADED` (`0x4900b2` → `0x4913b0`), and arms `PLAYER_LOGIN`
/// (`[0xb4e260]`, set at `0x49011d`). The world-enter cascade `0x4908c0` fires `PLAYER_LOGIN` if
/// armed (read at `0x49094b`, fired at `0x490959`, disarmed at `0x49095e`), then
/// `PLAYER_ENTERING_WORLD` (`0x49096a`).
/// `PLAYER_ENTERING_WORLD` (`0x49096a`): [`UiScript::fire_world_enter`].
///
/// On a `/reload`, `UI_Init` enters the cascade itself (`0x490168`). On a fresh login that call is
/// skipped (`0x490166 je`, the active-player GUID still 0) and the cascade runs from the local
/// player's own create (`0x5deb60 call 0x4908c0`). This fires `PLAYER_LOGIN` at the end of the
/// load on both, the `/reload` shape; `PLAYER_ENTERING_WORLD` fires from [`crate::ui_unit`] once
/// the self descriptor lands.
/// player's own create (`0x5deb60 call 0x4908c0`). Either way the player object exists when it
/// runs, so this only arms, and [`crate::ui_unit`]'s feed fires both once it has seated the
/// player.
#[cfg(test)]
pub(crate) fn finish_ui_load(script: &mut UiScript) {
finish_ui_load_with(script, |_| {}, |_| {});
@@ -724,10 +783,10 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
/// [`finish_ui_load`] with its two host seams. `host_settings` runs between the saved-variables
/// chunk and `VARIABLES_LOADED`, carrying over a setting `config.toml` held before it moved into
/// the saved variables. `between`
/// is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst, after
/// `VARIABLES_LOADED` and before `PLAYER_LOGIN`, where the reference registers its reader
/// (`0x4900d6` → `0x498a20`). On a fresh login the reference's cache (`0x5afe50`) defers that
/// the saved variables. `between` runs after `VARIABLES_LOADED` and before the `PLAYER_LOGIN` arm
/// (so before the event too): the chat-cache restore's `UPDATE_CHAT_WINDOWS` +
/// `UPDATE_CHAT_COLOR` burst, where the reference registers its reader (`0x4900d6` → `0x498a20`),
/// then the UI scale (`0x49010e`). On a fresh login the reference's cache (`0x5afe50`) defers that
/// reader until the server's account data reconciles (`0x5afa42`/`0x5afd01`), after `UI_Init` has
/// returned. Either way a `VARIABLES_LOADED` handler sees the boot chat colours, not the file's.
pub(crate) fn finish_ui_load_with(
@@ -736,11 +795,12 @@ pub(crate) fn finish_ui_load_with(
between: impl FnOnce(&mut UiScript),
) {
// Still the load edge, so still bounded: re-armed so the saved variables and every
// `PLAYER_LOGIN` handler get the full allowance; the entry edge disarms it after.
// `VARIABLES_LOADED` handler get the full allowance; the entry edge disarms it after. The
// world-enter events fire later, unbounded, as every event the feeds fire is.
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
crate::ui_saved::load_saved_variables(script, host_settings);
between(script);
script.fire_event("PLAYER_LOGIN", vec![]);
script.arm_player_login();
}
/// Run the patch chain's `Interface\FrameXML\GlobalStrings.lua`, the first file in the reference's
+14 -11
View File
@@ -68,13 +68,13 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
.map_err(|e| e.to_string())
}
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
/// What a screen-size or UI-scale change re-runs, from `extract::tick_script`: anchors follow the
/// screen, but not a size or seat computed from the old `GetScreenHeight()`. The managed pass
/// re-wraps the open bags, whose columns `updateContainerFrameAnchors` lays out by it.
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the
/// engine's resizable window, and changes nothing the stock UI does at a fixed size.
/// size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would
/// stop short of the new screen. Host-side, not in the layer: it adapts the engine's resizable
/// window, and changes nothing the stock UI does at a fixed size.
pub(super) fn on_screen_resized(script: &UiScript) {
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
@@ -85,11 +85,14 @@ pub(super) fn on_screen_resized(script: &UiScript) {
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
/// the stock OnLoad leaves them.
/// the stock OnLoad leaves them. The OnLoads read the screen root's size, `UIParent` being still at
/// scale 1 then, and their frames hang off the root, so this re-reads the root:
/// `GetScreenWidth()` times `UIParent`'s scale.
const FULLSCREEN_QUADS_RESEAT: &str = r#"
local rootScale = UIParent and UIParent:GetEffectiveScale() or 1
if BlackoutWorld then
local width = GetScreenWidth()
local height = GetScreenHeight()
local width = GetScreenWidth() * rootScale
local height = GetScreenHeight() * rootScale
if ( width / height < 4 / 3 ) then
width = width * 1.25
height = height * 1.25
@@ -98,8 +101,8 @@ if BlackoutWorld then
BlackoutWorld:SetHeight( height )
end
if UpperBlackBar and LowerBlackBar then
local width = GetScreenWidth()
local height = GetScreenHeight()
local width = GetScreenWidth() * rootScale
local height = GetScreenHeight() * rootScale
local barWidth, blackBarHeight = 1024, 128
if ( width / height > 4 / 3 ) then
local desiredHeight = width / 2
+50 -19
View File
@@ -1,8 +1,9 @@
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the
//! mouse ÷s in, and extraction flips to y-down window px.
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
use bevy::prelude::*;
@@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
}
}
/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so
/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0.
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
/// `Default` 1.0.
#[derive(Resource)]
pub(crate) struct UiScaleCvar(pub(crate) f32);
@@ -213,8 +216,17 @@ impl Default for UiScaleCvar {
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
#[derive(Resource, Default)]
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
fn default_ui_scale() -> f32 {
std::env::var("WOW_UI_SCALE")
@@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 {
.unwrap_or(DEFAULT_UI_SCALE)
}
/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of
/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit).
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
pub(crate) fn seam_scale(window_h: f32) -> f32 {
if window_h > 0.0 {
window_h / 768.0 * ui_scale
window_h / 768.0
} else {
1.0
}
}
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
let s = seam_scale(window_h);
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
}
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
pub(crate) struct UiScriptPlugin;
@@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin {
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
app.insert_resource(UiScaleCvar(default_ui_scale()))
.init_resource::<UiAutoScaleCache>()
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
.init_resource::<UiCostWanted>()
@@ -600,9 +621,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
},
]);
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
// 70% XP, set before `PLAYER_ENTERING_WORLD` so the bar's first update reads it.
// 70% XP, set before the world-enter events so the bar's first update reads it.
script.set_player_xp(4200, 6000);
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
script.fire_world_enter();
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
@@ -706,6 +727,9 @@ mod chat_resize_tests;
#[cfg(test)]
mod dropdown_tests;
#[cfg(test)]
mod ui_scale_tests;
#[cfg(test)]
mod action_bar_tests;
@@ -986,19 +1010,26 @@ mod world_map_tests;
#[cfg(test)]
mod seam_scale_tests {
use super::seam_scale;
use super::{seam_scale, window_to_ui};
use bevy::math::Vec2;
#[test]
fn seam_scale_is_the_768_base_times_the_dial() {
fn seam_scale_is_the_768_tall_root() {
// Identity at the design height, proportional elsewhere.
assert_eq!(seam_scale(768.0, 1.0), 1.0);
assert_eq!(seam_scale(1536.0, 1.0), 2.0);
// The dial multiplies it.
assert_eq!(seam_scale(768.0, 0.9), 0.9);
// The reference's pixel-perfect setting: uiScale = 768/screenH → 1 px per UI unit.
assert!((seam_scale(1080.0, 768.0 / 1080.0) - 1.0).abs() < 1e-6);
assert_eq!(seam_scale(768.0), 1.0);
assert_eq!(seam_scale(1536.0), 2.0);
// A degenerate (pre-winit) window is identity, never a division blow-up.
assert_eq!(seam_scale(0.0, 0.9), 1.0);
assert_eq!(seam_scale(0.0), 1.0);
}
/// `GetCursorPosition` answers root units whatever the dial: the bottom of a 1080-tall window
/// is 0 and its top 768, its middle column `768·a/2`.
#[test]
fn the_cursor_reaches_the_vm_in_root_units() {
let at = window_to_ui(1080.0, Vec2::new(960.0, 108.0));
assert!((at.x - 960.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
assert!((at.y - 972.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
assert_eq!(window_to_ui(1080.0, Vec2::new(0.0, 1080.0)), Vec2::ZERO);
}
}
@@ -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
+25 -1
View File
@@ -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") {
@@ -660,6 +680,8 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `CharacterFrame.xml`'s tab template, for the hidden parent `PaperDollFrame` needs below.
r"Interface\FrameXML\CharacterFrameTemplates.xml",
// The dialog engine, after the `UIPanelCloseButton` it inherits.
r"Interface\FrameXML\StaticPopup.xml",
// `ContainerFrameItemButton_OnClick` hides `StackSplitFrame` on every plain click
@@ -683,7 +705,9 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\ItemButtonTemplate.xml",
// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate`, and its OnLoad
// (`PaperDollItemSlotButton_OnLoad`) gives each bag button its inventory-slot id, 20..23.
// `CharacterFrame.xml` stays out: a missing `parent=` only warns.
// Its hidden parent `CharacterFrame` comes first: a parentless `PaperDollFrame` would be
// visible at the end of its load and run its OnShow, which reaches for the Honor tab.
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
// The stock bag bar, with `BagSlotButtonTemplate` and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
@@ -0,0 +1,272 @@
//! The UI scale through the real UI pass: `uiScale` is `UIParent`'s own frame scale (`0x494550`
//! calls `0x76ac10` on the frame named `"UIParent"`), and the screen root under it keeps scale 1
//! and is 768 units tall, so a frame outside `UIParent` is not scaled by it.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::extract::{paint_script, tick_script};
use crate::portrait::PortraitImages;
use crate::ui_pass::{UiQuad, UiQuads};
/// A `UIParent` filling the root with a red 100-unit square inside it, 10 units in from its top
/// left, and a green 100-unit square at the root's bottom left with no parent.
const TREE: &str = r#"
local ui = CreateFrame("Frame", "UIParent")
ui:SetPoint("BOTTOMLEFT", 0, 0)
ui:SetPoint("TOPRIGHT", 0, 0)
local inside = CreateFrame("Frame", "Inside", UIParent)
inside:SetPoint("TOPLEFT", 10, -10)
inside:SetWidth(100); inside:SetHeight(100)
local red = inside:CreateTexture(nil, "ARTWORK")
red:SetTexture(1, 0, 0)
red:SetAllPoints()
local outside = CreateFrame("Frame", "Outside")
outside:SetPoint("BOTTOMLEFT", 0, 0)
outside:SetWidth(100); outside:SetHeight(100)
local green = outside:CreateTexture(nil, "ARTWORK")
green:SetTexture(0, 1, 0)
green:SetAllPoints()
"#;
/// Both halves of the UI pass over [`TREE`] on a `w`×`h` window, the dial at `ui_scale`.
fn app(w: u32, h: u32, ui_scale: f32) -> App {
let script = UiScript::new().unwrap();
script.run(TREE).unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.init_resource::<UiQuads>();
app.init_resource::<super::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<super::UiFrameCost>();
app.init_resource::<super::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<super::UiClock>();
app.insert_resource(super::UiScaleCvar(ui_scale));
app.world_mut().spawn((
Window {
resolution: UVec2::new(w, h).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, (tick_script, paint_script).chain());
app.update();
app
}
fn eval(app: &App, chunk: &str) -> f64 {
app.world()
.non_send_resource::<UiScript>()
.eval::<f64>(chunk)
.unwrap()
}
/// The window-px rect of the quad tinted `rgb`, y-down.
fn quad(app: &App, rgb: [f32; 3]) -> Rect {
let quads = &app.world().resource::<UiQuads>().quads;
let q: &UiQuad = quads
.iter()
.find(|q| q.color[..3] == rgb)
.expect("the marker quad extracted");
q.rect
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-3
}
fn rect_close(got: Rect, want: Rect) -> bool {
close(got.min.x, want.min.x)
&& close(got.min.y, want.min.y)
&& close(got.max.x, want.max.x)
&& close(got.max.y, want.max.y)
}
/// `UIParent:GetScale()` and `GetEffectiveScale()` answer the UI scale: it is `UIParent`'s own
/// scale, set through the worker Lua's `SetScale` reaches (`0x494550` → `0x76ac10`).
#[test]
fn uiparent_answers_the_ui_scale() {
let app = app(1024, 768, 0.9);
assert!(close(eval(&app, "return UIParent:GetScale()") as f32, 0.9));
assert!(close(
eval(&app, "return UIParent:GetEffectiveScale()") as f32,
0.9
));
assert!(close(
eval(&app, "return Inside:GetEffectiveScale()") as f32,
0.9
));
assert_eq!(eval(&app, "return Outside:GetEffectiveScale()"), 1.0);
}
/// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the 768-tall root by
/// `UIParent`'s scale (`0x494590`), so they answer in `UIParent`'s units.
#[test]
fn the_screen_size_answers_in_uiparent_units() {
let app = app(1024, 768, 0.9);
assert!(close(
eval(&app, "return GetScreenHeight()") as f32,
768.0 / 0.9
));
assert!(close(
eval(&app, "return GetScreenWidth()") as f32,
1024.0 / 0.9
));
}
/// A frame with no parent hangs off the root, which keeps scale 1: one unit is `windowH/768` px
/// whatever the dial says.
#[test]
fn a_parentless_frame_is_not_scaled_by_the_ui_scale() {
let app = app(1280, 1024, 0.9);
let s = 1024.0 / 768.0;
let got = quad(&app, [0.0, 1.0, 0.0]);
assert!(
rect_close(got, Rect::new(0.0, 1024.0 - 100.0 * s, 100.0 * s, 1024.0)),
"got {got:?}"
);
}
/// Inside `UIParent` nothing moves: the dial scales the subtree exactly as it scaled the seam.
#[test]
fn a_frame_inside_uiparent_lands_where_the_dial_put_it() {
let app = app(1280, 1024, 0.9);
let s = 1024.0 / 768.0 * 0.9;
let got = quad(&app, [1.0, 0.0, 0.0]);
assert!(
rect_close(got, Rect::new(10.0 * s, 10.0 * s, 110.0 * s, 110.0 * s)),
"got {got:?}"
);
}
/// Whether `frame` is `UIParent` or hangs below it.
fn under_ui_parent(s: &UiScript, frame: benilla_ui::widget::FrameHandle) -> bool {
let mut at = Some(frame);
while let Some(h) = at {
if s.frame_name(h).as_deref() == Some("UIParent") {
return true;
}
at = s.frame_parent(h);
}
false
}
/// The whole stock interface, every frame shown: scaling `UIParent` by `S` in the 768-tall root
/// must place every quad of its subtree, and every hit, exactly where enlarging the root by `1/S`
/// with `UIParent` at scale 1 placed them, the seam having been the old home of the UI scale. A
/// frame-local length the engine forgets to scale (a border, an inset, a thumb) shows up here.
#[test]
fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
let _data = benilla_formats::wow_data_or_skip!();
const S: f32 = 0.9;
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
let mut script = UiScript::new().expect("VM");
script.set_screen_size(w, h);
let failures = super::load_default_ui(&script);
assert!(
failures.is_empty(),
"default UI failed to load: {failures:?}"
);
// Every frame shown, its parents first, so the most geometry the stock files declare draws.
script
.run(
r#"
local f = EnumerateFrames()
while f do
pcall(f.Show, f)
f = EnumerateFrames(f)
end
"#,
)
.unwrap();
let _ = script.take_errors();
// The old home: a root `1/S` larger, `UIParent` at scale 1.
script.set_screen_size(w / S, h / S);
script.run("UIParent:SetScale(1)").unwrap();
script.resolve();
let old = script.extract();
let grid: Vec<(f32, f32)> = (0..=40)
.flat_map(|i| (0..=24).map(move |j| (w * i as f32 / 40.0, h * j as f32 / 24.0)))
.collect();
let old_hits: Vec<_> = grid
.iter()
.map(|&(x, y)| script.hit_test_frame(x / S, y / S))
.collect();
// The reference's: a 768-tall root, `UIParent` at scale `S`.
script.set_screen_size(w, h);
assert!(script.set_ui_scale(S));
script.resolve();
let new = script.extract();
let new_hits: Vec<_> = grid
.iter()
.map(|&(x, y)| script.hit_test_frame(x, y))
.collect();
assert_eq!(old.len(), new.len(), "the same quads draw");
let close = |a: f32, b: f32| (a - b).abs() < 0.02;
let mut moved = Vec::new();
for (a, b) in old.iter().zip(&new) {
assert_eq!(a.target, b.target, "the same paint order");
let Some(frame) = script.target_frame(b.target) else {
continue;
};
if !under_ui_parent(&script, frame) {
continue;
}
let same_rect = match (a.rect, b.rect) {
(Some(ra), Some(rb)) => {
close(ra.left * S, rb.left)
&& close(ra.right * S, rb.right)
&& close(ra.top * S, rb.top)
&& close(ra.bottom * S, rb.bottom)
}
(None, None) => true,
_ => false,
};
if !same_rect || !close(a.scale * S, b.scale) {
moved.push(format!(
"{:?} {:?}: {:?}×{S} vs {:?}",
script.target_owner_name(b.target),
std::mem::discriminant(&b.content),
a.rect,
b.rect
));
}
}
assert!(
moved.is_empty(),
"{} quads inside UIParent moved, first: {:#?}",
moved.len(),
&moved[..moved.len().min(20)]
);
let inside = |hit: Option<benilla_ui::widget::FrameHandle>| {
hit.is_none_or(|h| under_ui_parent(&script, h))
};
let mut rehit = Vec::new();
for ((&at, a), b) in grid.iter().zip(&old_hits).zip(&new_hits) {
if inside(*a) && inside(*b) && a != b {
rehit.push(format!(
"{at:?}: {:?} vs {:?}",
a.and_then(|h| script.frame_name(h)),
b.and_then(|h| script.frame_name(h))
));
}
}
assert!(
rehit.is_empty(),
"{} hits inside UIParent changed, first: {:#?}",
rehit.len(),
&rehit[..rehit.len().min(20)]
);
}
@@ -773,6 +773,28 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
// ───────────── The login one-shots wait for the in-game UI ─────────────
/// An app running the unit feed after `Startup`, with what the feed reads; the receiver is the
/// wire, kept alive beside it.
fn unit_feed_app() -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
let mut app = App::new();
app.add_plugins(crate::ui_unit::UiUnitPlugin);
app.init_resource::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
app.add_message::<crate::net::WorldEnterCascadeMessage>();
super::setup_script(app.world_mut());
(app, rx)
}
/// The unit feed's login one-shots (`PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and
/// `UPDATE_EXHAUSTION`) wait for the in-game UI instead of reaching a VM with no frames.
#[test]
@@ -781,22 +803,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("oneshot");
let mut app = App::new();
app.add_plugins(crate::ui_unit::UiUnitPlugin);
app.init_resource::<super::AddOnIdentity>();
app.init_resource::<crate::minimap::MinimapZoom>();
app.init_resource::<super::ReloadUiPending>();
app.init_resource::<crate::target::Selection>();
app.init_resource::<crate::names::NameCache>();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(crate::net::NetCommands(tx));
app.init_resource::<crate::net::Reputations>();
app.init_resource::<crate::ui_party::GroupState>();
app.init_resource::<crate::ui_chat::ChatLog>();
app.init_resource::<crate::ui_guild::GuildState>();
app.add_message::<crate::creature_anim::SwingImpact>();
super::setup_script(app.world_mut());
let (mut app, _rx) = unit_feed_app();
// A frame registered as FrameXML registers, in the VM that exists before the entry load.
app.world()
@@ -1576,3 +1583,143 @@ fn a_login_and_a_reload_load_without_the_lost_target_sound() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ───────────── PLAYER_LOGIN fires from the world-enter cascade ─────────────
/// Records what file scope and the two cascade events see of `"player"`, in order.
const LOGIN_PROBE_LUA: &str = "\
LoginProbe = { order = \"\", fileExists = tostring(UnitExists(\"player\")),
fileLevel = UnitLevel(\"player\") }
local f = CreateFrame(\"Frame\")
f:RegisterEvent(\"PLAYER_LOGIN\")
f:RegisterEvent(\"PLAYER_ENTERING_WORLD\")
f:SetScript(\"OnEvent\", function()
if event == \"PLAYER_LOGIN\" then
LoginProbe.order = LoginProbe.order .. \"LOGIN:\" .. tostring(UnitExists(\"player\"))
.. \":\" .. UnitLevel(\"player\") .. \":\" .. UnitXP(\"player\") .. \" \"
else
LoginProbe.order = LoginProbe.order .. \"PEW \"
end
end)
";
const LOGIN_PROBE_TOC: &str = "## Interface: 11200\nLoginProbe.lua\n";
/// Our own player as its create lands: level 17, 1234 of 5000 XP.
fn spawn_self_player(world: &mut World) -> Entity {
use benilla_protocol::messages::{ObjectFields, ObjectType};
// `PLAYER_XP` and `PLAYER_NEXT_LEVEL_XP`, 716 and 717 in the 1.12 descriptor.
let fields = ObjectFields::from_pairs(&[
(benilla_protocol::field::FIELD_UNIT_LEVEL, 17),
(716, 1234),
(717, 5000),
])
.into_created(ObjectType::Player);
world
.spawn((
crate::net::SelfPlayer,
crate::net::Guid(1),
crate::net::ObjectStore(fields),
))
.id()
}
/// A probe global off the live VM, as a string.
fn login_probe(app: &App, field: &str) -> String {
app.world()
.non_send_resource::<benilla_ui::script::UiScript>()
.eval::<Option<String>>(&format!("return tostring(LoginProbe.{field})"))
.ok()
.flatten()
.unwrap_or_default()
}
/// `PLAYER_LOGIN` has one fire site, `0x490959` in the world-enter cascade `0x4908c0`, which the
/// UI load only arms (`0x49011d`). A fresh login skips the load's own entry (`0x490166 je`) and
/// runs the cascade from the player's create (`0x5deb60`), so its handlers see the player, and
/// `PLAYER_ENTERING_WORLD` follows at once (`0x49096a`). A worldport loads no UI, so its cascade
/// finds the arm spent and fires 272 alone.
#[test]
fn player_login_fires_from_the_world_entry_with_the_player_seated() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("login", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
let (mut app, _rx) = unit_feed_app();
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(roster_named("Probelogin", 1));
super::load_ingame_ui_on_world_entry(app.world_mut());
assert_eq!(
login_probe(&app, "fileExists"),
"nil",
"a fresh login's file scope runs before the player object exists"
);
assert_eq!(
login_probe(&app, "order"),
"",
"the load arms PLAYER_LOGIN; nothing fires it before the player is in the world"
);
let me = spawn_self_player(app.world_mut());
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW ",
"PLAYER_LOGIN sees the player's own unit, then PLAYER_ENTERING_WORLD fires at once"
);
app.update();
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
// A cross-map worldport: the player object is destroyed and created again.
app.world_mut().despawn(me);
app.update();
spawn_self_player(app.world_mut());
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW PEW ",
"a worldport re-fires PLAYER_ENTERING_WORLD and not PLAYER_LOGIN"
);
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
/// On a `/reload` the player object stays in the world: `UI_Init` runs its file scope against it
/// and enters the cascade itself (`0x490168`), so `PLAYER_LOGIN` then `PLAYER_ENTERING_WORLD` fire
/// once each on the new VM, both seeing the player.
#[test]
fn a_reload_fires_player_login_once_against_the_live_player() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("relogin", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
let (mut app, _rx) = unit_feed_app();
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(roster_named("Probelogin", 1));
super::load_ingame_ui_on_world_entry(app.world_mut());
spawn_self_player(app.world_mut());
app.update();
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
reload(app.world_mut(), crate::char_select::ClientState::InWorld);
assert_eq!(
(
login_probe(&app, "fileExists"),
login_probe(&app, "fileLevel")
),
("1".to_string(), "17".to_string()),
"the reloaded file scope runs against the live player"
);
app.update();
app.update();
assert_eq!(
login_probe(&app, "order"),
"LOGIN:1:17:1234 PEW ",
"the new VM gets PLAYER_LOGIN then PLAYER_ENTERING_WORLD, once each"
);
drop(app);
let _ = std::fs::remove_dir_all(&tmp);
}
+44 -4
View File
@@ -146,14 +146,14 @@ pub(crate) struct TextEngine {
/// the database that can draw a character, so a system face would silently stand in for a client
/// face. A character no client or addon face carries draws nothing, as in the reference, and
/// measures 0. The locale only picks which language's family name a face answers to.
fn client_font_system() -> FontSystem {
FontSystem::new_with_locale_and_db("en-US".to_string(), fontdb::Database::new())
fn client_font_system(locale: benilla_formats::ClientLocale) -> FontSystem {
FontSystem::new_with_locale_and_db(locale.language_tag().to_string(), fontdb::Database::new())
}
impl TextEngine {
/// Register the client TTFs off the patch chain; `None`, and no text, without Friz Quadrata.
fn load(world_assets: &WorldAssets, images: &Assets<Image>, dpi: f32) -> Option<Self> {
let mut font_system = client_font_system();
let mut font_system = client_font_system(world_assets.chain.lock_recover().locale());
let mut faces: Vec<Face> = Vec::new();
let mut path_to_face = HashMap::new();
for &path in CLIENT_FONTS {
@@ -603,7 +603,15 @@ fn read_font_bytes(source: &FontSource, path: &str) -> Option<Vec<u8>> {
pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
let data = benilla_formats::wow_data()?;
let chain = benilla_formats::open_chain(&data).ok()?;
let mut font_system = client_font_system();
test_engine_from_chain(chain, dpi)
}
#[cfg(test)]
pub(super) fn test_engine_from_chain(
chain: benilla_formats::Chain,
dpi: f32,
) -> Option<TextEngine> {
let mut font_system = client_font_system(chain.locale());
let mut faces: Vec<Face> = Vec::new();
let mut path_to_face = HashMap::new();
for &path in CLIENT_FONTS {
@@ -810,6 +818,38 @@ mod differential_tests {
mod ppem_tests {
use super::*;
#[test]
fn the_russian_pack_rasterizes_cyrillic_in_every_client_face() {
let data = benilla_formats::wow_data_or_skip!();
let locale = benilla_formats::ClientLocale::RuRu;
if locale.pack_folder(&data).is_none() {
benilla_formats::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let chain = benilla_formats::Chain::open_localized(&data, locale).unwrap();
let mut e = test_engine_from_chain(chain, 1.0).expect("localized client fonts");
for &path in TEST_FACES {
let face = e.face_for(Some(path));
assert_eq!(e.faces[face].path, path);
e.ensure_str(face, 16, 0, "АаЯяЁё");
for ch in "АаЯяЁё".chars() {
let shaped = e
.char_cell(face, 16, ch)
.expect("a Cyrillic character shaped");
assert!(!shaped.glyphs.is_empty(), "{path}: {ch}");
for glyph in &shaped.glyphs {
assert_ne!(glyph.glyph_id, 0, "{path}: {ch} must not be .notdef");
let cell = e.cell(face, 16, 0, glyph.glyph_id).expect("Cyrillic ink");
assert!(cell.px_w > 0.0 && cell.px_h > 0.0, "{path}: {ch}");
}
}
}
assert!(
e.substituted.is_empty(),
"Cyrillic uses the selected client faces"
);
}
fn engine_or_skip() -> Option<TextEngine> {
match test_engine(1.0) {
Some(e) => Some(e),
+6 -5
View File
@@ -52,11 +52,12 @@ pub(crate) fn fontstring_em(height: Option<f32>) -> Option<f32> {
height.map(|h| h.min(FONTSTRING_EM_CAP))
}
/// The drawn pixel height of a UI FontString times the seam scale `s = windowH/768 × uiScale`
/// (`crate::ui_script::seam_scale`), under the reference's two size regimes (getter `0x7727b0`):
/// one-to-one, the default, draws the raster size ([`FONTSTRING_EM_CAP`] applied in units), and a
/// `SetTextHeight` size (`text_height`) draws uncapped, the reference magnifying the raster to it
/// (`0x771600` clears bit `0x200`; the drawn em is `round(size/768·deviceH)`).
/// The drawn pixel height of a UI FontString at `s`, the seam scale `windowH/768`
/// (`crate::ui_script::seam_scale`) times the owner's effective scale, the UI scale included,
/// under the reference's two size regimes (getter `0x7727b0`): one-to-one, the default, draws the
/// raster size ([`FONTSTRING_EM_CAP`] applied in units), and a `SetTextHeight` size
/// (`text_height`) draws uncapped, the reference magnifying the raster to it (`0x771600` clears bit
/// `0x200`; the drawn em is `round(size/768·deviceH)`).
pub(crate) fn drawn_px(font_height: Option<f32>, text_height: Option<f32>, s: f32) -> Option<f32> {
match text_height {
Some(t) => Some(t * s),
+5 -5
View File
@@ -114,8 +114,6 @@ fn drive_mouseover_tooltip(
go_inputs: crate::target::lock::GoLockInputs,
// The known-spell set the resolver's SKILL arm scans.
player_actions: Res<crate::ui_action::PlayerActions>,
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut memo: HoverMemo,
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type
// reads.
@@ -229,15 +227,17 @@ fn drive_mouseover_tooltip(
.templates
.get(guid)
.is_some_and(|t| t.floating_tooltip);
// Window px to the VM's y-up UI units, as the input seam converts.
// Window px to the VM's y-up root units, as the input seam converts.
let cursor_ui = cursor_seated
.then(|| {
window.iter().next().and_then(|w| {
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
w.cursor_position()
.or_else(crate::target::hover_probe_point)
.map(|c| (c.x / s, (w.height() - c.y) / s))
.map(|c| {
let at = crate::ui_script::window_to_ui(w.height(), c);
(at.x, at.y)
})
})
})
.flatten();
@@ -882,7 +882,6 @@ fn mouseover_app() -> (App, Entity, Entity) {
let mut app = App::new();
let (tx, _rx) = crossbeam_channel::unbounded();
app.insert_resource(NetCommands(tx))
.insert_resource(crate::ui_script::UiScaleCvar(1.0))
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<NameCache>()
+93 -22
View File
@@ -101,7 +101,7 @@ struct UnitFeedMemo {
/// The lazy caches' landing counters: their per-frame `&mut` misses would trip `is_changed`.
names_generation: gate::Watch,
guild_generation: gate::Watch,
/// Whether `PLAYER_ENTERING_WORLD` has fired for this world entry.
/// Whether the world-enter events have fired for this world entry.
entered_world: bool,
/// Per token, the last snapshot pushed.
last: HashMap<String, UnitState>,
@@ -1194,6 +1194,75 @@ pub(crate) fn fire_transitions(
}
}
/// `"player"`'s snapshot off our own descriptor: no reaction (the unit is ours), the raid icon,
/// both faction-group spellings and the guild. One builder for [`feed_units`] and for the seat a
/// `/reload`'s load runs under ([`live_player_seat`]).
fn player_snapshot(
store: &ObjectStore,
guid: u64,
tables: &SnapshotTables,
names: &NameCache,
commands: &NetCommands,
group: &crate::ui_party::GroupState,
factions: Option<&Factions>,
guild: &mut crate::ui_guild::GuildState,
) -> UnitState {
let name = names
.resolve_unit(guid, Some(store), commands)
.map(str::to_string);
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
s.is_player = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions);
s.faction_group_localized = faction_group_localized(store, factions);
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
s.guild = crate::ui_guild::unit_guild(&store.0, guild, commands);
s
}
/// `UnitXP("player")` and `UnitXPMax("player")` off our own descriptor, zero while unsent.
fn xp_pair(store: &ObjectStore) -> (u32, u32) {
(
store.0.player_xp().unwrap_or(0),
store.0.player_next_level_xp().unwrap_or(0),
)
}
/// The live player a UI load seats before any file runs: its snapshot and XP pair, as
/// [`feed_units`] pushes them.
pub(crate) struct LivePlayerSeat {
pub(crate) state: UnitState,
pub(crate) xp: (u32, u32),
}
/// [`LivePlayerSeat`] for an exclusive edge, `None` before our own create. A `/reload` loads with
/// the player object still in the world, so `UI_Init`'s file scope, `ADDON_LOADED` and
/// `VARIABLES_LOADED` read it there as `PLAYER_LOGIN` does (`0x490168`).
pub(crate) fn live_player_seat(
tables: SnapshotTables,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
names: Res<NameCache>,
commands: Res<NetCommands>,
group: Res<crate::ui_party::GroupState>,
factions: Option<Res<Factions>>,
mut guild: ResMut<crate::ui_guild::GuildState>,
) -> Option<LivePlayerSeat> {
let (store, guid) = self_q.iter().next()?;
Some(LivePlayerSeat {
state: player_snapshot(
store,
guid.0,
&tables,
&names,
&commands,
&group,
factions.as_deref(),
&mut guild,
),
xp: xp_pair(store),
})
}
fn feed_units(
script: Option<NonSendMut<UiScript>>,
tables: SnapshotTables,
@@ -1280,17 +1349,16 @@ fn feed_units(
// A missing unit is `None`, which `set_unit` clears; a name miss lands on a later frame.
let self_pair = self_q.iter().next();
let player = self_pair.map(|(store, guid)| {
let name = names
.resolve_unit(guid.0, Some(store), &commands)
.map(str::to_string);
let mut s = snapshot(store, guid.0, name, 0, chr, types);
s.is_player = true;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
s
player_snapshot(
store,
guid.0,
&tables,
&names,
&commands,
&group,
factions.as_deref(),
&mut guild,
)
});
// Log once when our template names no side, almost always GM mode (vmangos forces template
// 35, group mask 0): every `UnitFactionGroup` surface loses its side, as in the reference.
@@ -1390,12 +1458,11 @@ fn feed_units(
}
}
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the `PLAYER_ENTERING_WORLD` fire so its
// handlers read real values. The event fires on first sight too, at login and after a
// `/reload`, where the reference's field watchers stay silent.
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the world-enter events so `PLAYER_LOGIN` and
// `PLAYER_ENTERING_WORLD` handlers read real values. The event fires on first sight too, at
// login and after a `/reload`, where the reference's field watchers stay silent.
if let Some((store, _)) = self_q.iter().next() {
let xp = store.0.player_xp().unwrap_or(0);
let next = store.0.player_next_level_xp().unwrap_or(0);
let (xp, next) = xp_pair(store);
if memo.last_xp != Some((xp, next)) {
gate.audit("feed_units", "the XP pair");
memo.last_xp = Some((xp, next));
@@ -1404,7 +1471,7 @@ fn feed_units(
}
}
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the entering-world fire
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the world-enter events
// as the reference's descriptor is. Below `0x5ee990`'s local-GUID gate (`0x5eea93`) each arm
// tests its own bits: `PLAYER_UPDATE_RESTING` on either edge of `0x20` (`0x5eead0`, fire
// `0x5eeaf2`), `PLAYTIME_CHANGED` on `0x3000` (`0x5eeb65`, fire `0x5eeb6f`).
@@ -1495,12 +1562,16 @@ fn feed_units(
}
}
// `PLAYER_ENTERING_WORLD` once per world entry, after our descriptor lands, as the reference's.
// At world exit, re-arm it and forget the player-global memos so the next character seeds them.
// The world-enter cascade's events once per world entry, after our descriptor lands and the
// pushes above seat it: the reference runs the cascade from the local player's own create
// (`0x5deb60 call 0x4908c0`) and, on a `/reload`, from `UI_Init` (`0x490168`), the player in
// the world both times. `PLAYER_LOGIN` only when this VM's load armed it, so a worldport's
// re-entry fires `PLAYER_ENTERING_WORLD` alone. At world exit, re-arm the entry and forget the
// player-global memos so the next character seeds them.
if self_pair.is_some() {
if !memo.entered_world {
gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm");
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
gate.audit("feed_units", "the world-enter events");
script.fire_world_enter();
memo.entered_world = true;
}
} else if memo.entered_world {
+3 -5
View File
@@ -710,7 +710,6 @@ fn dev_map_jump(
buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
script: Option<NonSendMut<UiScript>>,
data: Option<Res<WorldMapUiData>>,
net: Res<crate::net::NetCommands>,
@@ -729,10 +728,9 @@ fn dev_map_jump(
let Some(cursor) = window.cursor_position() else {
return;
};
// Window px (y-down) to the VM's y-up 768-unit space, as the pointer feed converts them.
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
else {
// Window px (y-down) to the VM's y-up root units, as the pointer feed converts them.
let at = crate::ui_script::window_to_ui(window.height(), cursor);
let Some((u, v)) = script.world_map_uv_at(at.x, at.y) else {
return;
};
let (c, z, direct) = script.world_map_selection();
+4 -8
View File
@@ -308,7 +308,6 @@ fn drive_vplates(
net_commands: Res<NetCommands>,
// `None` in a run with no UI VM.
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
@@ -374,10 +373,8 @@ fn drive_vplates(
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
let scale = window.map_or(1.0, |w| w.scale_factor());
// Everything below is in window px, divided by this once for the widget layer's units.
let seam = window.map_or(1.0, |w| {
crate::ui_script::seam_scale(w.height(), ui_scale.0)
});
// Everything below is in window px, divided by this once for the widget layer's root units.
let seam = window.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
let mut states: Vec<PlateState> = Vec::new();
// Plates seat in squared-distance order from the gx point (0.4, 0.3), Y mirrored from the
@@ -541,9 +538,8 @@ fn drive_vplates(
}
// ── The plate's state, handed to the widget layer ──
//
// The reference's plates sit outside the uiScale cascade, but benilla folds uiScale into
// one global seam, so dividing by it here keeps plate pixels uiScale-blind.
// The raid-target index is 1-based here, 0-based for the atlas cell.
// The plates hang off `WorldFrame`, outside `UIParent`'s scale, so root units are their
// own. The raid-target index is 1-based here, 0-based for the atlas cell.
let mark = group.raid_target_index(guid.0);
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
let y_units = (viewport.y - plate.min.y) / seam;
+5 -10
View File
@@ -374,10 +374,9 @@ fn only_the_bids_pane_gets_its_page_without_being_opened() {
assert!(s.take_errors().is_empty());
}
/// The reference's show cascade `0x76ae10` is post-order: a frame marks itself visible
/// (`0x76ae7b`), walks its children re-reading live links, and fires its own `OnShow` last
/// (`0x76aef5`). benilla's is pre-order, a gap in the engine; for this window both orders reach
/// the same state, so only the `ShowOrder` assertions are benilla's order.
/// The show cascade `0x76ae10` is post-order: a frame marks itself visible (`0x76ae7b`), walks its
/// children re-reading live links, and fires its own `OnShow` last (`0x76aef5`), so the pane the
/// XML leaves shown is notified before the window.
#[test]
fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -408,11 +407,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
};
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane",
"OURS, and a known deviation: the reference's cascade is post-order, \"pane,window\""
);
assert_eq!(order(&s), "pane,window", "children first, the window last");
assert_eq!(
page(&s),
"0",
@@ -431,7 +426,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane,window",
"pane,window,window",
"the reopen notifies the window and not the pane it left hidden"
);
assert_eq!(page(&s), "0", "kept from the first open, not re-assigned");
+1 -1
View File
@@ -71,7 +71,7 @@ impl MpqAssetReader {
/// Open the patch chain from a `Data` directory or a single `.MPQ`.
pub fn open(data_dir: &Path) -> Result<Self> {
Ok(Self {
chain: Arc::new(Chain::open(data_dir)?),
chain: Arc::new(Chain::open_client(data_dir)?),
})
}
}
+163 -2
View File
@@ -8,7 +8,7 @@ use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use benilla_mpq::Archive;
use crate::VANILLA_BASE_ORDER;
use crate::{ClientLocale, VANILLA_BASE_ORDER};
/// One entry from a chain listing: an internal path and its uncompressed size.
pub struct ChainEntry {
@@ -20,6 +20,7 @@ pub struct ChainEntry {
pub struct Chain {
/// Ascending priority: later archives win.
archives: Vec<Archive>,
locale: ClientLocale,
}
/// `patch-?.MPQ` as the reference's `FindFirstFileW` glob matches it (template `0x82edbc`, wrapper
@@ -93,7 +94,36 @@ impl Chain {
Archive::open(path).with_context(|| format!("opening MPQ {}", path.display()))?,
);
}
Ok(Self { archives })
Ok(Self {
archives,
locale: ClientLocale::EnUs,
})
}
/// Open the runtime chain with `$WOW_LOCALE`, or the installed ruRU pack when unset.
/// [`Self::open`] stays the unlocalized reference chain for extraction and reference tests.
pub fn open_client(path: &Path) -> Result<Self> {
Self::open_localized(path, ClientLocale::for_install(path)?)
}
/// Mount the selected language's archives after the vanilla chain. Missing files fall through
/// to the base archives; an explicitly selected missing or corrupt pack is an error.
pub fn open_localized(path: &Path, locale: ClientLocale) -> Result<Self> {
let mut chain = Self::open(path)?;
if locale == ClientLocale::RuRu && path.is_dir() {
let folder = crate::locale::locale_folder(path, locale)
.ok_or_else(|| anyhow!("ruRU language pack not found in {}", path.display()))?;
let overlay = Self::open(&folder)
.with_context(|| format!("opening {} language pack", locale.code()))?;
chain.archives.extend(overlay.archives);
}
chain.locale = locale;
Ok(chain)
}
/// The language mounted for this chain, also used by Lua and authentication.
pub fn locale(&self) -> ClientLocale {
self.locale
}
/// The highest-priority archive with an entry for `name`, a delete marker included, as a
@@ -178,6 +208,137 @@ impl Chain {
mod tests {
use super::*;
struct TestInstall(std::path::PathBuf);
impl TestInstall {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("benilla-locale-{}-{id}", std::process::id()));
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(root.join("RUrU")).unwrap();
// Valid empty V1 archives: the tables point at EOF and have zero entries.
let mut header = b"MPQ\x1a".to_vec();
for field in [32u32, 32, 3 << 16, 32, 32, 0, 0] {
header.extend_from_slice(&field.to_le_bytes());
}
for name in [
"dbc.MPQ",
"patch-2.MPQ",
"RUrU/patch-1.MPQ",
"RUrU/patch-3.MPQ",
] {
std::fs::write(root.join(name), &header).unwrap();
}
Self(root)
}
}
impl Drop for TestInstall {
fn drop(&mut self) {
for name in [
"dbc.MPQ",
"patch-2.MPQ",
"RUrU/patch-1.MPQ",
"RUrU/patch-3.MPQ",
] {
let _ = std::fs::remove_file(self.0.join(name));
}
let _ = std::fs::remove_dir(self.0.join("RUrU"));
let _ = std::fs::remove_dir(&self.0);
}
}
#[test]
fn language_archives_mount_after_base_patches_and_english_is_isolated() {
let install = TestInstall::new();
assert_eq!(
ClientLocale::select(&install.0, None).unwrap(),
ClientLocale::RuRu
);
assert_eq!(
ClientLocale::select(&install.0, Some("enUS")).unwrap(),
ClientLocale::EnUs
);
let russian = Chain::open_localized(&install.0, ClientLocale::RuRu).unwrap();
let mounted: Vec<_> = russian
.archives
.iter()
.map(|a| {
a.path()
.strip_prefix(&install.0)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert_eq!(
mounted,
[
"dbc.MPQ",
"patch-2.MPQ",
"RUrU/patch-1.MPQ",
"RUrU/patch-3.MPQ"
]
);
assert_eq!(russian.locale(), ClientLocale::RuRu);
let english = Chain::open_localized(&install.0, ClientLocale::EnUs).unwrap();
assert_eq!(english.archives.len(), 2);
assert_eq!(english.locale(), ClientLocale::EnUs);
}
#[test]
fn a_missing_or_corrupt_selected_language_is_not_silently_english() {
let install = TestInstall::new();
std::fs::write(install.0.join("RUrU/patch-1.MPQ"), b"corrupt").unwrap();
assert!(Chain::open_localized(&install.0, ClientLocale::RuRu).is_err());
assert!(Chain::open_localized(&install.0, ClientLocale::EnUs).is_ok());
for name in ["RUrU/patch-1.MPQ", "RUrU/patch-3.MPQ"] {
std::fs::remove_file(install.0.join(name)).unwrap();
}
std::fs::remove_dir(install.0.join("RUrU")).unwrap();
assert!(Chain::open_localized(&install.0, ClientLocale::RuRu).is_err());
assert_eq!(
ClientLocale::select(&install.0, None).unwrap(),
ClientLocale::EnUs
);
}
#[test]
fn the_installed_russian_pack_localizes_spell_names_and_keeps_base_fallbacks() {
let data = crate::wow_data_or_skip!();
if crate::locale::locale_folder(&data, ClientLocale::RuRu).is_none() {
crate::skipped_optional("no ruRU language pack", &[data.join("ruRU")]);
return;
}
let mut russian = Chain::open_localized(&data, ClientLocale::RuRu).unwrap();
let mut english = Chain::open(&data).unwrap();
let names = crate::load_spell_catalog(&mut russian).expect("localized Spell.dbc layout");
let base_names = crate::load_spell_catalog(&mut english).unwrap();
let localized = &names.get(133).expect("Fireball").name;
assert!(localized
.chars()
.any(|c| ('\u{0400}'..='\u{04ff}').contains(&c)));
assert_ne!(localized, &base_names.get(133).unwrap().name);
let icons = "DBFilesClient\\SpellIcon.dbc";
assert_eq!(russian.read(icons).unwrap(), english.read(icons).unwrap());
for file in [
"Fonts\\FRIZQT__.TTF",
"Interface\\FrameXML\\GlobalStrings.lua",
] {
let archive = russian.find_file_archive(file).expect("localized resource");
assert!(archive
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.eq_ignore_ascii_case("ruRU"));
assert_ne!(russian.read(file).unwrap(), english.read(file).unwrap());
}
}
fn owned(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
+12
View File
@@ -128,6 +128,18 @@ pub fn skipped(what: &str, looked_in: &[PathBuf]) {
what,
looked_in,
);
log_skip(what);
}
/// A test requiring optional data (such as a language pack) records a skip even when the base
/// install is required. A pack that exists but fails to open must still fail its test.
#[doc(hidden)]
pub fn skipped_optional(what: &str, looked_in: &[PathBuf]) {
skipped_under(false, what, looked_in);
log_skip(what);
}
fn log_skip(what: &str) {
// `$BENILLA_SKIP_LOG`: libtest hides the line above, so the gate counts skips from this file.
if let Some(log) = std::env::var_os("BENILLA_SKIP_LOG").filter(|p| !p.is_empty()) {
use std::io::Write;
+3 -1
View File
@@ -13,6 +13,8 @@ pub use benilla_blp::BlpTexels;
mod chain;
pub use chain::{Chain, ChainEntry};
mod locale;
pub use locale::ClientLocale;
/// Loose `.tga` art, as addon folders ship it.
mod tga;
pub use tga::tga_to_rgba;
@@ -20,7 +22,7 @@ pub use tga::tga_to_rgba;
mod install;
pub use install::{
addon_corpus, addon_corpus_candidates, candidates, project_folder, set_project_folder, skipped,
wow_data,
skipped_optional, wow_data,
};
mod characters;
pub use characters::{
+124
View File
@@ -0,0 +1,124 @@
//! The language of the client data, Lua UI and realmd challenge.
//!
//! Deviation: the community 1.12 ruRU pack lives in `Data/ruRU` and overlays the vanilla chain.
//! Its DBCs keep the 1.12 layout and put Russian strings in slot zero, like the base tables.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// The supported language packs. English reads the original vanilla chain unchanged.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ClientLocale {
#[default]
EnUs,
RuRu,
}
impl ClientLocale {
/// The spelling returned by `GetLocale` and sent to realmd.
pub const fn code(self) -> &'static str {
match self {
Self::EnUs => "enUS",
Self::RuRu => "ruRU",
}
}
/// The four-character auth tag, before the protocol reverses its byte order.
pub const fn auth_tag(self) -> [u8; 4] {
match self {
Self::EnUs => *b"enUS",
Self::RuRu => *b"ruRU",
}
}
/// The optional language-pack folder under `Data`, found case-insensitively.
pub fn pack_folder(self, data: &Path) -> Option<PathBuf> {
locale_folder(data, self)
}
/// The language tag used for localized font family names.
pub const fn language_tag(self) -> &'static str {
match self {
Self::EnUs => "en-US",
Self::RuRu => "ru-RU",
}
}
/// Parse an explicit language; unknown values fail rather than silently loading another one.
pub fn from_code(code: &str) -> Result<Self> {
if code.eq_ignore_ascii_case("enUS") {
Ok(Self::EnUs)
} else if code.eq_ignore_ascii_case("ruRU") {
Ok(Self::RuRu)
} else {
bail!("unsupported client locale {code:?}; expected enUS, ruRU or auto");
}
}
/// Runtime selection: `$WOW_LOCALE=enUS|ruRU|auto`; unset or `auto` uses an installed ruRU
/// folder, otherwise English. Selection is made before any client resources load.
pub fn for_install(data: &Path) -> Result<Self> {
let selected = std::env::var("WOW_LOCALE").context("reading WOW_LOCALE");
match selected {
Ok(value) => Self::select(data, Some(&value)),
Err(_) if std::env::var_os("WOW_LOCALE").is_none() => Self::select(data, None),
Err(error) => Err(error),
}
}
pub(crate) fn select(data: &Path, value: Option<&str>) -> Result<Self> {
match value {
Some(code) if !code.eq_ignore_ascii_case("auto") => Self::from_code(code),
_ => Ok(if locale_folder(data, Self::RuRu).is_some() {
Self::RuRu
} else {
Self::EnUs
}),
}
}
}
/// Find the locale directory case-insensitively, including on a case-sensitive filesystem.
pub(crate) fn locale_folder(data: &Path, locale: ClientLocale) -> Option<PathBuf> {
let mut folders: Vec<_> = std::fs::read_dir(data)
.ok()?
.filter_map(Result::ok)
.filter(|entry| {
entry.path().is_dir()
&& entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(locale.code())
})
.map(|entry| entry.path())
.collect();
folders.sort();
folders.into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_is_explicit_and_unknown_values_are_errors() {
let absent = Path::new("/no-client-data-here");
assert_eq!(
ClientLocale::select(absent, None).unwrap(),
ClientLocale::EnUs
);
assert_eq!(
ClientLocale::select(absent, Some("auto")).unwrap(),
ClientLocale::EnUs
);
assert_eq!(
ClientLocale::select(absent, Some("RURU")).unwrap(),
ClientLocale::RuRu
);
for invalid in ["", "frFR", "../ruRU"] {
assert!(ClientLocale::select(absent, Some(invalid)).is_err());
}
assert_eq!(ClientLocale::RuRu.auth_tag(), *b"ruRU");
}
}
+26 -1
View File
@@ -69,6 +69,17 @@ pub fn write_logon_challenge(
w: &mut impl Write,
account_name: &str,
build: u16,
) -> std::io::Result<()> {
write_logon_challenge_with_locale(w, account_name, build, LOCALE_EN_US.to_be_bytes())
}
/// The challenge with the mounted client's locale, a four-character tag such as `ruRU`.
/// realmd reverses the country bytes before matching the locale (`AuthSocket::ReadChallengeRequest`).
pub fn write_logon_challenge_with_locale(
w: &mut impl Write,
account_name: &str,
build: u16,
locale: [u8; 4],
) -> std::io::Result<()> {
// Everything after the 2-byte size field, assembled first so we can prefix its length.
let mut body = Vec::with_capacity(34 + account_name.len());
@@ -79,7 +90,7 @@ pub fn write_logon_challenge(
body.extend_from_slice(&build.to_le_bytes());
body.extend_from_slice(&PLATFORM_X86.to_le_bytes());
body.extend_from_slice(&client_os().to_le_bytes());
body.extend_from_slice(&LOCALE_EN_US.to_le_bytes());
body.extend_from_slice(&u32::from_be_bytes(locale).to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // utc_timezone_offset
body.extend_from_slice(&Ipv4Addr::LOCALHOST.octets()); // client_ip_address (network order)
body.push(account_name.len() as u8);
@@ -289,6 +300,20 @@ pub fn read_realm_list(r: &mut impl Read) -> Result<Vec<RealmInfo>> {
mod tests {
use super::*;
#[test]
fn the_logon_challenge_carries_the_selected_locale_in_wire_order() {
let mut english = Vec::new();
write_logon_challenge(&mut english, "PROBE", 5875).unwrap();
let mut explicit = Vec::new();
write_logon_challenge_with_locale(&mut explicit, "PROBE", 5875, *b"enUS").unwrap();
assert_eq!(explicit, english);
let mut russian = Vec::new();
write_logon_challenge_with_locale(&mut russian, "PROBE", 5875, *b"ruRU").unwrap();
assert_eq!(&russian[21..25], b"URur");
assert_eq!(&russian[..21], &english[..21]);
assert_eq!(&russian[25..], &english[25..]);
}
#[test]
fn os_tags_reverse_to_the_names_servers_match_on() {
let spelled = |tag: u32| {
+18 -2
View File
@@ -165,6 +165,17 @@ impl Logon {
/// The full SRP6 logon against a vanilla `realmd`, then the realm list.
pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
logon_with_locale(host, username, password, *b"enUS")
}
/// The SRP6 logon with the client's selected four-character locale tag, used by the server for
/// localized world responses. [`logon`] retains the English default for protocol instruments.
pub fn logon_with_locale(
host: &str,
username: &str,
password: &str,
locale: [u8; 4],
) -> Result<Logon> {
let (host, port) = host_port(host, AUTH_PORT);
let username_n =
@@ -179,8 +190,13 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
let mut stream = dial(host, port)?;
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
.context("sending logon challenge")?;
auth::write_logon_challenge_with_locale(
&mut stream,
&username.to_uppercase(),
CLIENT_BUILD,
locale,
)
.context("sending logon challenge")?;
let reply =
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
let server_public_key = PublicKey::from_le_bytes(reply.server_public_key)
+12 -4
View File
@@ -315,8 +315,10 @@ fn element_from_node(node: roxmltree::Node) -> Element {
/// attributes override or extend the template's (case-insensitively). The reference merges
/// nothing: `LoadXML` re-enters itself on the one object against each template, the most distant
/// first, then the instance (`0x76985c`; `<Frames>` repeat the walk, `0x76a060`), and reads `name`
/// and `id` from the instance alone (`PreLoadXML 0x769770`). Here `name` and `virtual` merge like
/// any attribute. A cycle is skipped with a warning.
/// and `id` from the instance alone (`PreLoadXML 0x769770`; a region's factory reads its `name`
/// once, outside the walk, `0x6f2751`/`0x6f27e1`). So here `name` and `id` come from the element
/// alone, and an unnamed instance stays unnamed; `virtual` merges like any attribute, read only at
/// registration. A cycle is skipped with a warning.
pub fn expand(
element: &Element,
templates: &HashMap<&str, &Element>,
@@ -390,9 +392,15 @@ fn expand_inner(
}
}
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one.
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one. `base`'s `name` and `id`
/// are dropped: they belong to the node they are written on.
fn merge(base: &Element, over: &Element) -> Element {
let mut attrs = base.attrs.clone();
let mut attrs: Vec<(String, String)> = base
.attrs
.iter()
.filter(|(k, _)| !k.eq_ignore_ascii_case("name") && !k.eq_ignore_ascii_case("id"))
.cloned()
.collect();
for (k, v) in &over.attrs {
if let Some(slot) = attrs.iter_mut().find(|(ek, _)| ek.eq_ignore_ascii_case(k)) {
slot.1 = v.clone();
+11 -2
View File
@@ -642,7 +642,7 @@ impl Loader<'_> {
}
/// Everything a frame element does to an existing frame, from `LoadXML` attributes to its
/// `OnLoad` (last, bottom-up, `0x76a060`); shared by [`Self::materialize`] and
/// `OnLoad` (bottom-up, `0x76a060`) and the post-load OnShow; shared by [`Self::materialize`] and
/// [`apply_template`]. `$parent` is `self_name` in this frame's contents and `parent_name` in
/// its own anchors (`0x76c5b0`).
pub(super) fn decorate(
@@ -655,6 +655,9 @@ impl Loader<'_> {
) {
// This frame's deferred anchors drain before its OnLoad; a nested frame drains its own.
let deferred_mark = self.deferred_anchors.len();
// 1c · the pre-load hook opens the frame's load window (`0x7697c2`): until the post-load
// hook, its own OnShow and OnHide fire nothing.
crate::script::visibility::set_load_window(self.lua(), wrapper, true);
// 2 · LoadXML attributes (`0x769820`).
self.apply_attrs(el, wrapper, dbg_name);
// 3 · <Size> and 4 · <Anchors> (the CLayoutFrame geometry base, `0x767800`).
@@ -681,7 +684,9 @@ impl Loader<'_> {
// 6 · <Scripts> handlers (`0x769ef0`); OnLoad is captured to fire bottom-up below.
let onload = self.apply_scripts(el, wrapper, dbg_name);
// 7 · nested <Frames>, whose OnLoads fire before ours (`0x76a060`).
// 7 · the post-load hook (`0x76a2f0`): close the window (`0x76a2ff`), then the nested
// <Frames>, whose OnLoads fire before ours (`0x76a060`).
crate::script::visibility::set_load_window(self.lua(), wrapper, false);
for frames_el in children_named(el, "Frames") {
for child in &frames_el.children {
let expanded = self.expand(child);
@@ -717,6 +722,8 @@ impl Loader<'_> {
if let Some(func) = onload {
self.fire_onload(wrapper, &func, dbg_name);
}
// 9 · and its OnShow, if it is visible now (`0x76a3b3`).
crate::script::visibility::post_load_show(self.lua(), wrapper);
}
/// Apply one anchor, deferring it (when `may_defer`) while its named `relativeTo` is unbuilt.
@@ -898,5 +905,7 @@ pub(super) fn font_object_from_element(el: &Element) -> FontObject {
}
}
#[cfg(test)]
mod template_name_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,87 @@
//! `name=` and `id=` belong to the node they are written on: `PreLoadXML 0x769770` reads them from
//! the instance before `LoadXML` walks its templates (`0x76985c`), and the region factories read
//! `name` once, outside that walk (`0x6f2751`, `0x6f27e1`).
use crate::framexml;
use crate::loader::load;
use crate::script::UiScript;
const DOC: &str = r#"<Ui>
<Button name="TmpCloseTemplate" id="3" virtual="true">
<Size><AbsDimension x="32" y="32"/></Size>
</Button>
<Texture name="TmpTexTemplate" virtual="true">
<Color r="1" g="0" b="0" a="1"/>
</Texture>
<Frame name="Holder">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture inherits="TmpTexTemplate"/>
</Layer>
</Layers>
<Frames>
<Button inherits="TmpCloseTemplate"/>
<Button name="$parentClose" inherits="TmpCloseTemplate"/>
</Frames>
</Frame>
</Ui>"#;
fn loaded() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let report = load(&s, &framexml::parse(DOC).unwrap(), &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s
}
/// Stock `UIOptionsFrame.xml:1477`'s unnamed `<Button inherits="UIPanelCloseButton">` is the
/// shape: in 1.12.1 the template's name is no global and the button answers no name.
#[test]
fn an_unnamed_instance_takes_neither_its_templates_name_nor_its_id() {
let s = loaded();
assert!(
s.eval::<bool>("return TmpCloseTemplate == nil").unwrap(),
"the unnamed instance published the template's name as a global"
);
assert!(s
.eval::<bool>("return ({Holder:GetChildren()})[1]:GetName() == nil")
.unwrap());
assert_eq!(
s.eval::<i64>("return ({Holder:GetChildren()})[1]:GetID()")
.unwrap(),
0
);
}
#[test]
fn a_named_instance_keeps_its_own_name_and_takes_no_template_id() {
let s = loaded();
assert_eq!(
s.eval::<String>("return HolderClose:GetName()").unwrap(),
"HolderClose"
);
assert_eq!(s.eval::<i64>("return HolderClose:GetID()").unwrap(), 0);
}
#[test]
fn an_unnamed_region_takes_no_name_from_its_template() {
let s = loaded();
assert!(s.eval::<bool>("return TmpTexTemplate == nil").unwrap());
assert!(s
.eval::<bool>("return ({Holder:GetRegions()})[1]:GetName() == nil")
.unwrap());
}
/// `CreateFrame` builds its node with the `name` it was given (`0x70622d`), nil included.
#[test]
fn create_frame_with_no_name_stays_unnamed_under_a_named_template() {
let s = loaded();
assert!(s
.eval::<bool>(
"local b = CreateFrame('Button', nil, Holder, 'TmpCloseTemplate') \
return b:GetName() == nil and TmpCloseTemplate == nil"
)
.unwrap());
}
+106
View File
@@ -2470,6 +2470,112 @@ mod chunk_name_tests {
}
}
/// The post-load hook's OnShow (`0x76a2f0`), for a frame visible at the end of its load.
mod post_load_show_tests {
use crate::framexml;
use crate::loader::*;
use crate::script::UiScript;
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(text: &str) -> framexml::ParsedDocument {
framexml::parse(text).expect("valid FrameXML")
}
/// The post-load hook `0x76a2f0` builds the `<Frames>` children (`0x76a36f`, their own hooks
/// inside), fires OnLoad (`0x76a387`), then fires OnShow (`0x76a3b3`) when the frame is visible
/// (`0x76a38c`) and has the handler (`0x76a396`): a frame visible at the end of its load gets
/// one OnShow, after its OnLoad and its children's.
#[test]
fn a_frame_visible_at_the_end_of_its_load_gets_one_onshow_after_its_onload() {
let s = UiScript::new().unwrap();
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Born">
<Scripts>
<OnLoad>Log("load")</OnLoad>
<OnShow>Log("show")</OnShow>
</Scripts>
<Frames>
<Frame name="BornChild">
<Scripts>
<OnLoad>Log("child-load")</OnLoad>
<OnShow>Log("child-show")</OnShow>
</Scripts>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"child-load,child-show,load,show"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The post-load OnShow is keyed on the frame's visibility at `0x76a38c`, and nothing records
/// an OnShow already given: a frame first shown by its own OnLoad gets that `Show()`'s OnShow
/// and then the hook's, while a frame still hidden gets none.
#[test]
fn a_frame_shown_by_its_onload_gets_two_onshows_and_a_hidden_one_none() {
let s = UiScript::new().unwrap();
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="ShownByLoad" hidden="true">
<Scripts>
<OnLoad>Log("a-load") this:Show() Log("a-load-end")</OnLoad>
<OnShow>Log("a-show")</OnShow>
</Scripts>
</Frame>
<Frame name="StaysHidden" hidden="true">
<Scripts>
<OnLoad>Log("b-load")</OnLoad>
<OnShow>Log("b-show")</OnShow>
<OnHide>Log("b-hide")</OnHide>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"a-load,a-show,a-load-end,a-show,b-load"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The post-load OnShow is the script alone (`0x76a3b3` calls the runner `0x702690`, not the
/// notify slot `+0x30`), so an `autoFocus` EditBox born visible runs its OnShow and still takes
/// no keyboard: the focus is the EditBox notify's (`0x77a750`), which only a transition reaches.
#[test]
fn the_post_load_onshow_runs_the_script_without_the_editbox_autofocus() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<EditBox name="BornBox" autoFocus="true">
<Size><AbsDimension x="100" y="20"/></Size>
<Scripts><OnShow>BOX_SHOWN = true</OnShow></Scripts>
</EditBox>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(s.eval::<bool>("return BOX_SHOWN == true").unwrap());
assert_eq!(s.focused_editbox_name(), None);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
#[test]
fn join_ref_resolves_relative_framexml_paths() {
use super::join_ref;
+44 -15
View File
@@ -123,18 +123,25 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
/// and background repeat the art more or less often than at scale 1.
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
let mut out = Vec::with_capacity(9);
let e = bd.edge_size;
let e = bd.edge_size * scale;
// The texcoord divisor, unscaled.
let e_uv = bd.edge_size;
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
// ── The background piece ──
if bd.has_bg() {
let (il, ir, it, ib) = (
bd.insets.left,
bd.insets.right,
bd.insets.top,
bd.insets.bottom,
bd.insets.left * scale,
bd.insets.right * scale,
bd.insets.top * scale,
bd.insets.bottom * scale,
);
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
@@ -146,7 +153,11 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
];
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
let uvs = if bd.tile {
let period = if bd.tile_size != 0.0 { bd.tile_size } else { e };
let period = if bd.tile_size != 0.0 {
bd.tile_size
} else {
e_uv
};
let bg_w = (r - ir) - (l + il);
let bg_h = (t - it) - (b + ib);
let (wt, ht) = (bg_w / period, bg_h / period);
@@ -165,8 +176,8 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
// ── The 8 border pieces ──
if bd.has_border() {
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
let w_run = ((r - l) / e - 2.0).max(0.0);
let h_run = ((t - b) / e - 2.0).max(0.0);
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
out.push(BackdropPiece {
@@ -311,7 +322,7 @@ mod tests {
fn piece_geometry_200x100_edge16_inset5() {
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
assert_eq!(ps.len(), 9);
// Symmetric insets hide the bottom-right quirk.
@@ -365,7 +376,7 @@ mod tests {
},
..tooltip_backdrop(16.0, 0.0)
};
let bg = pieces(frame, &bd)[0];
let bg = pieces(frame, &bd, 1.0)[0];
assert_eq!(bg.corners[3], [3.0, 9.0]);
// BR takes the top inset, 7, not the bottom's 9.
assert_eq!(bg.corners[2], [196.0, 7.0]);
@@ -378,7 +389,7 @@ mod tests {
// 20/16 - 2 = -0.75.
let frame = Rect::new(0.0, 0.0, 20.0, 20.0);
let bd = tooltip_backdrop(16.0, 5.0);
let ps = pieces(frame, &bd);
let ps = pieces(frame, &bd, 1.0);
let left = ps[1]; // LEFT edge
assert_eq!(left.uvs[3][1], 0.0, "h_run clamped to 0"); // BL v
let top = ps[3]; // TOP edge
@@ -388,16 +399,34 @@ mod tests {
#[test]
fn presence_gates_pieces() {
let frame = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(pieces(frame, &Backdrop::default()).is_empty());
assert!(pieces(frame, &Backdrop::default(), 1.0).is_empty());
let bg_only = Backdrop {
bg_file: Some("bg".into()),
..Default::default()
};
assert_eq!(pieces(frame, &bg_only).len(), 1);
assert_eq!(pieces(frame, &bg_only, 1.0).len(), 1);
let border_only = Backdrop {
edge_file: Some("edge".into()),
..Default::default()
};
assert_eq!(pieces(frame, &border_only).len(), 8);
assert_eq!(pieces(frame, &border_only, 1.0).len(), 8);
}
/// A half-scale frame: the geometry halves, but `0x77f0c0` divides the scaled rect by the
/// unscaled sizes, so a 100-wide (on screen) edge repeats `100/16 - 2` times and the 96-wide
/// background `96/16` times.
#[test]
fn a_scaled_frames_texcoords_repeat_by_the_unscaled_sizes() {
let frame = Rect::new(0.0, 0.0, 50.0, 100.0);
let bd = tooltip_backdrop(16.0, 4.0);
let ps = pieces(frame, &bd, 0.5);
let bg = ps[0];
assert_eq!(bg.corners[0], [2.0, 48.0], "inset 4 units, 2 on screen");
assert_eq!(bg.uvs[2], [6.0, 46.0 / 16.0]);
// TOP, slice 2, rotated: `u` holds the run.
let top = ps[3];
assert_eq!(top.corners[0][1], 50.0);
assert_eq!(top.corners[2][1], 42.0, "8 thick on screen");
assert_eq!(top.uvs[0][1], 100.0 / 16.0 - 2.0);
}
}
+27 -5
View File
@@ -11,16 +11,16 @@ const VERSION: &str = "1.12.1";
const BUILD: &str = "5875";
const BUILD_DATE: &str = "Sep 19 2006";
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
pub(super) fn install(lua: &Lua, locale: &str) -> mlua::Result<()> {
let g = lua.globals();
// `GetLocale()` (glue `0x46ce40`, in-game `0x48d8b0`) returns one string, `enUS`, as benilla
// is enUS-only. `IsMacClient()` (`0x48c980`) returns one nil, the PC build's answer, which
// takes FrameXML's PC key-label arm.
// `GetLocale()` (glue `0x46ce40`, in-game `0x48d8b0`) returns the mounted client language.
let locale = locale.to_string();
g.set(
"GetLocale",
lua.create_function(|lua, ()| lua.create_string("enUS"))?,
lua.create_function(move |lua, ()| lua.create_string(&locale))?,
)?;
// `IsMacClient()` (`0x48c980`) is the PC build's nil answer, used for key labels.
g.set("IsMacClient", lua.create_function(|_, ()| Ok(Value::Nil))?)?;
// The PC build's other two platform answers: `IsWindowsClient()` (`0x48c960`) pushes the
// number 1 and `IsLinuxClient()` (`0x48c990`) one nil. Arguments are ignored.
@@ -312,6 +312,28 @@ mod tests {
);
}
#[test]
fn locale_is_available_before_addons_run_and_is_specific_to_each_vm() {
let russian = UiScript::new_with_locale("ruRU").unwrap();
russian
.run("if GetLocale() == 'ruRU' then LocaleProbe = 'Русский' end")
.unwrap();
assert_eq!(
russian.eval::<String>("return LocaleProbe").unwrap(),
"Русский"
);
assert_eq!(russian.arity("GetLocale()").unwrap(), 1);
let english = UiScript::new().unwrap();
assert_eq!(
english.eval::<String>("return GetLocale()").unwrap(),
"enUS"
);
assert_eq!(
russian.eval::<String>("return GetLocale()").unwrap(),
"ruRU"
);
}
/// The reference's arities: `GetLocale` (`0x48d8b0`) one string, `IsMacClient` (`0x48c980`)
/// one nil.
#[test]
@@ -798,6 +798,20 @@ mod tests {
}
}
/// The markers are textures of the picker, sized in its units, so a half-scale picker draws
/// them at half size: 5 and 24 on screen.
#[test]
fn the_markers_scale_with_the_picker() {
let mut s = picker();
s.run("TestPicker:SetScale(0.5); TestPicker:SetColorRGB(1, 0, 0)")
.unwrap();
s.resolve();
let wheel = marker_rect(&s, 5.0).expect("the wheel marker, 10 units at half scale");
assert!((wheel.top - wheel.bottom - 5.0).abs() < 0.01, "{wheel:?}");
let value = marker_rect(&s, 24.0).expect("the value marker, 48 units at half scale");
assert!((value.top - value.bottom - 7.0).abs() < 0.01, "{value:?}");
}
/// The extracted rect of the marker `width` wide: the two share one BLP and differ by size, 10
/// for the wheel's and 48 for the strip's.
fn marker_rect(s: &UiScript, width: f32) -> Option<crate::layout::Rect> {
@@ -64,7 +64,7 @@ fn caret(lua: &Lua, h: FrameHandle) -> bool {
placed
}
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via `0x77dd5f`), `x` the caret's advance
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in the box's own units via `0x77dd5f`), `x` the caret's advance
/// along its line, `y` minus the row index times the row pitch, `w` the constant 4.0 and `h` the
/// line height. The arguments are computed only for a box with the script (`0x77dd8c`), from the
/// box's advance table, which the installed font engine measures here when the text or font
@@ -103,6 +103,14 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
let Some((x, y, pitch)) = args else {
return false;
};
// The advance table is in root units (measured at the box's scale, divided by the seam
// alone); the handler gets the box's own, the reference dividing by its effective scale
// (`0x77db23 fdiv [esi+0x7c]`).
let k = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
crate::script::object::eff_scale(&model, h)
};
let (x, y, pitch) = (x / k, y / k, pitch / k);
if let Err(e) = event::fire_widget_handler(
lua,
id,
@@ -509,3 +509,49 @@ fn on_cursor_changed_reports_the_first_row_at_plus_zero() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// [`Mono`] as the host answers a scaled box: glyphs 7 and lines 14 in the box's own units, the
/// table in root units (measured at the box's scale, divided by the seam alone).
struct ScaledMono;
impl TextMeasure for ScaledMono {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
Mono.measure(req)
}
fn editbox_advances(
&mut self,
req: &EditBoxAdvanceRequest,
) -> Option<(Vec<f32>, Vec<usize>, f32)> {
let (cum, rows, pitch) = Mono.editbox_advances(req)?;
let k = req.scale;
Some((cum.into_iter().map(|a| a * k).collect(), rows, pitch * k))
}
}
/// `OnCursorChanged` answers in the box's own units: the reference divides by its effective scale
/// (`0x77db23 fdiv [esi+0x7c]`), so a half-scale box reports what a full-scale one does.
#[test]
fn on_cursor_changed_answers_in_the_boxs_own_units() {
let mut s = script();
s.set_text_measurer(Box::new(ScaledMono));
logged_box(&mut s);
s.run(
r#"
E:SetScale(0.5)
E:SetScript("OnCursorChanged", function()
table.insert(LOG, format("cursor@%g,%g,%g", arg1, arg2, arg4))
end)
E:SetMultiLine(true)
E:SetText("abc\nd")
"#,
)
.unwrap();
s.tick(0.016);
assert_eq!(
take_log(&s),
"early update cursor@7,-14,14 text:abc\nd late",
"one glyph along the second row, a row pitch down, one pitch tall"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
-70
View File
@@ -10,7 +10,6 @@ use mlua::{Function, Lua, Table, Value};
use super::{Model, ScriptValue, REG_SCRIPTS};
use crate::script::object::frame_wrapper;
use crate::widget::{ButtonState, FrameHandle};
/// `UiScript::fire_event` for engine code that holds only the Lua context.
pub(super) fn fire_global(lua: &Lua, event: &str, args: &[ScriptValue]) {
@@ -108,75 +107,6 @@ pub(super) fn fire_size_changes(lua: &Lua) {
}
}
/// Fire `OnShow`/`OnHide` for the frames whose effective visibility just changed, recording
/// errors in [`Model::errors`]. A shown `toplevel` frame raises here, after the subtree has
/// propagated and before its `OnShow` (`0x76ae10` at `0x76aee0`), so a show that does not come
/// through Lua `Show` raises too.
pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
// Resolve (handle, id, now-visible?) under one short borrow, then fire with no borrow held.
let items: Vec<(FrameHandle, u32, bool)> = {
let mut model = lua.app_data_mut::<Model>().expect("model");
changed
.into_iter()
.filter_map(|h| {
let vis = model.arena.frame(h)?.effective_visible;
Some((h, model.frame_id(h), vis))
})
.collect()
};
// Hiding the hovered frame, directly or by an ancestor, fires its `OnLeave` inside the hide and
// before its `OnHide` (`0x764ba0`'s tail in `0x76ad50`, the leave at `0x764cce`), clears the
// hover and the drag-arm (`+0x100`/`+0x104`) and arms the re-pick; a show arms it too
// (`0x764b8d`). This leave is how a tooltip closes with its window; the reference has no other.
let left: Option<(FrameHandle, u32)> = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let hidden_hover = model
.mouseover
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
// The leave is a virtual call (`0x764cce`, `[vtable+0x50]`): a disabled Button's own
// `0x7794e0` skips its `OnLeave`, while the hover and the drag-arm still clear.
let notified = super::button::hover_notify_runs(&model, hidden_hover);
if let Some(m) = hidden_hover {
model.mouseover = None;
if model.drag.as_ref().is_some_and(|d| d.source == m) {
model.drag = None;
}
}
model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis);
hidden_hover
.filter(|_| notified)
.map(|m| (m, model.frame_id(m)))
};
if let Some((_, oid)) = left {
if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
}
for (h, id, visible) in items {
if visible {
let mut model = lua.app_data_mut::<Model>().expect("model");
super::object::toplevel::raise_on_show(&mut model, h);
} else {
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled
// or locked, before the base notify, so a button hidden while held comes back unpushed.
let mut model = lua.app_data_mut::<Model>().expect("model");
super::button::edge(&mut model, h, ButtonState::on_hide);
}
let name = if visible { "OnShow" } else { "OnHide" };
if let Err(e) = fire(lua, id, name, None, Vec::new()) {
lua.app_data_mut::<Model>()
.expect("model")
.record_script_error(e.to_string());
}
// The EditBox show/hide overrides (`0x81c910` `+0x30`/`+0x34`) call the base notify first,
// so this runs after the `fire`: an `autoFocus` box takes a free focus, a hidden box
// releases its own.
super::editbox::visibility_focus(lua, h, visible);
}
}
/// Whether a handler is bound under `script`, without firing it: the keyboard walk consumes on
/// the slot's presence (`0x76b7d0`). A failed lookup reads absent, so the gate never raises.
pub(super) fn has_widget_handler(lua: &Lua, id: u32, script: &str) -> bool {
+11 -4
View File
@@ -130,9 +130,10 @@ impl UiScript {
owner_frame.map(|f| &f.kind_state)
{
if sl.thumb == Some(rh) {
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight`, as the drag
// reads them (`slider::thumb_extent`), not its authored `<Size>`.
let tsize = super::region::virtual_span(&model, rh);
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight` at the
// slider's scale, as the drag reads them (`slider::thumb_extent`), not
// its authored `<Size>`.
let tsize = super::region::screen_span(&model, rh);
rect = rect
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
thumb_fill = true;
@@ -145,7 +146,13 @@ impl UiScript {
if let Some(crate::widget::KindState::ColorSelect(cs)) =
owner_frame.map(|f| &f.kind_state)
{
let tsize = model.region_data.get(&rh).and_then(|d| d.size);
// The markers' authored size, in the picker's units.
let scale = owner_frame.map_or(1.0, |f| f.effective_scale);
let tsize = model
.region_data
.get(&rh)
.and_then(|d| d.size)
.map(|(w, h)| (w * scale, h * scale));
if cs.wheel_thumb == Some(rh) {
rect = cs
.wheel
+2 -1
View File
@@ -1239,7 +1239,8 @@ impl UiScript {
let Some(bd) = model.backdrops.get(&fh) else {
return;
};
for piece in backdrop::pieces(fr, bd) {
let scale = crate::script::object::eff_scale(model, fh);
for piece in backdrop::pieces(fr, bd, scale) {
// The piece's screen bounding box: the render is axis-aligned, so the reference's slant
// under unequal top and bottom insets is not drawn (stock insets are symmetric).
let xs = piece.corners.map(|c| c[0]);
+8 -5
View File
@@ -114,13 +114,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
"PingLocation",
// Centre-relative offsets in UI units, x right and y up, parked for the app to resolve in
// the frame it draws the map; one pending click, not one per widget.
// Centre-relative offsets in the minimap's own units, x right and y up, which the
// reference reads against the widget's own size (`0x4eeca0`); parked in screen units, its
// effective scale applied, for the app to resolve in the frame it draws the map. One
// pending click, not one per widget.
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
with_minimap(lua, &this, |_| ())?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.minimap_ping_request = Some((x, y));
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let s = super::object::eff_scale(&model, h);
model.minimap_ping_request = Some((x * s, y * s));
Ok(())
})?,
)?;
+10 -2
View File
@@ -130,6 +130,7 @@ mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod ui_errors;
mod ui_scale;
pub use tooltip_unit::TooltipTint;
mod trade;
mod tradeskill;
@@ -137,6 +138,7 @@ mod trainer;
mod types;
mod unit;
mod video_pairs;
pub(crate) mod visibility;
mod weapon_enchant;
mod who_sort;
mod worldmap;
@@ -487,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
@@ -500,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.
@@ -724,7 +731,8 @@ impl UiScript {
self.model_mut().minimap_ping = ping;
}
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in UI units, x right, y up.
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in the screen root's units,
/// x right, y up.
pub fn take_minimap_ping_request(&mut self) -> Option<(f32, f32)> {
self.model_mut().minimap_ping_request.take()
}
+25 -3
View File
@@ -228,8 +228,11 @@ pub(crate) struct Model {
pub(crate) diagnostics: super::diagnostics::DiagnosticLog,
/// Non-fatal warnings for the host, such as a dropped `inherits=` template.
pub(crate) warnings: Vec<String>,
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames:
/// 768 tall at scale 1, whatever the UI scale ([`super::ui_scale`]).
pub(crate) screen: Rect,
/// The host's `uiScale`, which [`super::UiScript::apply_ui_scale`] makes `UIParent`'s scale.
pub(crate) ui_scale_request: Option<f32>,
/// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver
/// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives.
@@ -346,6 +349,9 @@ pub(crate) struct Model {
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
pub(crate) session_requests: Vec<session::SessionRequest>,
/// `PLAYER_LOGIN`'s one-shot, the reference's `[0xb4e260]`: armed by the UI load, spent by
/// the next world-enter cascade ([`super::UiScript::fire_world_enter`]).
pub(crate) player_login_armed: bool,
/// `TogglePVP` calls, a count because `CMSG_TOGGLE_PVP` has no body.
pub(crate) pvp_toggles: u32,
/// The player's private honor fields; before the first push the six self getters read zeros.
@@ -916,8 +922,9 @@ pub(crate) struct Model {
/// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`.
pub(crate) cursor_pos: (f32, f32),
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in UI units,
/// `GetCursorPosition`'s space, not the window pixels of the app's minimap geometry.
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in the screen
/// root's units, `GetCursorPosition`'s space, not the window pixels of the app's minimap
/// geometry.
pub(crate) minimap_ping_request: Option<(f32, f32)>,
/// The ping's offsets as fractions of the minimap's side, updated by the app every frame.
/// `GetPingPosition()` always answers both, from statics never cleared (`0x4eefd0`).
@@ -1103,6 +1110,7 @@ impl Model {
warnings: Vec::new(),
// 1024x768 until the host calls `set_screen_size`; y-up `[bottom, left, top, right]`.
screen: Rect::new(0.0, 0.0, 768.0, 1024.0),
ui_scale_request: None,
units_by_lower: HashMap::new(),
units_by_guid: HashMap::new(),
player_auras: Vec::new(),
@@ -1159,6 +1167,7 @@ impl Model {
follow_requests: Vec::new(),
camera_view_requests: Vec::new(),
session_requests: Vec::new(),
player_login_armed: false,
pvp_toggles: 0,
honor: None,
inspect_honor: None,
@@ -1494,6 +1503,19 @@ impl Model {
id
}
/// `CSimpleFrame::SetScale` (`0x76ac10`): store `h`'s own scale, cascade the effective scale
/// down its subtree, and re-lay out and re-measure on a change, since the owner's effective
/// scale is in every descendant FontString's measure key. Lua `SetScale` and the UI scale
/// ([`super::ui_scale`]) both come here, as both reach `0x76ac10` in the reference.
pub(crate) fn set_frame_scale(&mut self, h: FrameHandle, scale: f32) {
let changed = self.arena.frame(h).is_some_and(|f| f.scale != scale);
self.arena.set_scale(h, scale);
if changed {
self.touch_layout();
self.touch_measure_all();
}
}
/// A layout write that names no node. The resolve's tooltip pre-pass must not call this, or
/// the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`.
pub(crate) fn touch_layout(&mut self) {
+19 -16
View File
@@ -154,11 +154,12 @@ pub struct NamePlateClick {
pub button: String,
}
/// What a sync fires outside the model borrow: `OnShow`/`OnHide` for frames whose visibility
/// flipped and `OnValueChanged` for moved bars (pfUI hooks it, `nameplates.lua:393`).
/// What a sync runs outside the model borrow: the plates' `Show`/`Hide`, whose transitions fire
/// handlers, and `OnValueChanged` for moved bars (pfUI hooks it, `nameplates.lua:393`).
#[derive(Default)]
struct SyncEffects {
visibility: Vec<FrameHandle>,
/// `(plate, shown)` in the order the sync issued them.
visibility: Vec<(FrameHandle, bool)>,
values: Vec<(FrameHandle, f32)>,
}
@@ -247,7 +248,7 @@ impl UiScript {
};
sync(&mut model, world, geometry, states)
};
super::event::fire_visibility_changes(lua, effects.visibility);
apply_visibility(lua, effects.visibility);
for (bar, value) in effects.values {
super::statusbar::fire_engine_value_changed(lua, bar, value);
}
@@ -304,7 +305,7 @@ impl UiScript {
}
effects
};
super::event::fire_visibility_changes(lua, effects.visibility);
apply_visibility(lua, effects.visibility);
}
/// How many plate widgets the pool holds.
@@ -315,6 +316,13 @@ impl UiScript {
}
}
/// Run the plates' `Show`/`Hide` a sync issued, each a full transition with its handlers.
fn apply_visibility(lua: &mlua::Lua, verbs: Vec<(FrameHandle, bool)>) {
for (frame, shown) in verbs {
super::visibility::set_shown(lua, frame, shown);
}
}
/// Record a click on `id` if it is a live plate; every button click comes here.
pub(super) fn note_click(lua: &mlua::Lua, id: u32, button: &str) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -368,7 +376,7 @@ fn sync(
let free = (0..model.nameplates.plates.len())
.find(|i| model.nameplates.plates[*i].last.is_none());
let i = free.unwrap_or_else(|| {
let plate = Plate::create(model, world, &mut effects);
let plate = Plate::create(model, world);
model.nameplates.plates.push(plate);
model.nameplates.plates.len() - 1
});
@@ -396,7 +404,7 @@ fn sync(
impl Plate {
/// Build one plate: the Button, its six regions in the reference's order, and the health bar.
/// Written once, so a region an addon blanks stays blank.
fn create(model: &mut Model, world: FrameHandle, effects: &mut SyncEffects) -> Plate {
fn create(model: &mut Model, world: FrameHandle) -> Plate {
// Anonymous, as the reference's name is zeroed (`0x76c50b`): plate addons reject a named
// `WorldFrame` child.
let frame = model.arena.create(FrameKind::Button, None, Some(world));
@@ -479,9 +487,8 @@ impl Plate {
}
// Born retired and hidden: addons tell a live plate from a pooled one by `IsShown()`.
effects
.visibility
.extend(model.arena.set_shown(frame, false));
// Silent, since nothing can have set a handler on a frame not yet handed out.
model.arena.set_shown(frame, false);
model
.nameplates
@@ -625,9 +632,7 @@ impl Plate {
}
if last.is_none() {
effects
.visibility
.extend(model.arena.set_shown(frame, true));
effects.visibility.push((frame, true));
}
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
model.arena.set_alpha(frame, state.alpha);
@@ -722,9 +727,7 @@ impl Plate {
return;
}
let frame = model.nameplates.plates[i].frame;
effects
.visibility
.extend(model.arena.set_shown(frame, false));
effects.visibility.push((frame, false));
model.nameplates.plates[i].last = None;
}
}
+4 -13
View File
@@ -352,19 +352,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let create_frame = lua.create_function(create_frame)?;
lua.globals().set("CreateFrame", create_frame)?;
// `GetScreenWidth`/`GetScreenHeight`: the screen root's size in UI units.
// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`): the screen root's size divided by
// `UIParent`'s scale (`GetUiScale`, `0x494590`), so they answer in `UIParent`'s units.
lua.globals().set(
"GetScreenWidth",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.width())
Ok(model.screen.width() / super::ui_scale::ui_parent_scale(&model))
})?,
)?;
lua.globals().set(
"GetScreenHeight",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.screen.height())
Ok(model.screen.height() / super::ui_scale::ui_parent_scale(&model))
})?,
)?;
@@ -396,16 +397,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `GetCursorPosition()`: the last cursor position the host fed, in screen UI units, y up, as
// the reference returns it; a caller in a scaled frame divides by its `GetEffectiveScale()`.
lua.globals().set(
"GetCursorPosition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model");
Ok(model.cursor_pos)
})?,
)?;
// The modifier keys the app feeds each frame before any mouse event
// ([`UiScript::set_modifiers`]), answered 1 or nil as in 1.12, never a boolean: an addon
// testing `IsShiftKeyDown() == 1` (`ColorPickerPlus.lua:121`) reads `true` as not held.
@@ -5,7 +5,7 @@ use mlua::{Lua, Table, Value};
use crate::script::region_map::{set_shared, Side};
use crate::order::Strata;
use crate::script::{event, Backdrop, Insets, Model};
use crate::script::{visibility, Backdrop, Insets, Model};
use crate::widget::FrameKind;
use super::{decode_id, draw_layer_from_str, frame_handle_of, frame_wrapper, strata_from_str};
@@ -248,8 +248,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
// `SetParent` (`0x7a1550`): a frame, a name (`0x76c760`) or an explicit nil; an absent argument
// raises like an unknown name (`0x6f3400` gives -1), and so does a cycle (`0x87cb14`). As in
// the reference, hide fires before show, between `reparent_begin` and `reparent_finish`,
// since `fire_visibility_changes` reads each frame's live state for the direction.
// the reference, the hide transition runs between `reparent_begin` and `reparent_finish`, and
// the show transition after.
set_shared(
lua,
m,
@@ -330,24 +330,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)));
}
}
let hidden = {
let was_visible = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.reparent_begin(h, new_parent)
};
// None: the same parent, a total no-op (`0x76ab20`), not even a layout touch.
let Some(hidden) = hidden else { return Ok(()) };
let was_visible = !hidden.is_empty();
event::fire_visibility_changes(lua, hidden);
let shown = {
let Some(was_visible) = was_visible else {
return Ok(());
};
if was_visible {
visibility::hide(lua, h);
}
{
let mut model = lua.app_data_mut::<Model>().expect("model");
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
model.arena.reparent_finish(h, new_parent);
// A reparent moves the subtree's effective scale, a layout input and part of every
// descendant FontString's measure key.
model.touch_layout_reparent(h);
model.touch_measure_all();
shown
};
event::fire_visibility_changes(lua, shown);
}
if was_visible {
visibility::show(lua, h);
}
Ok(())
},
)?;
@@ -414,14 +418,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetScale",
lua.create_function(|lua, (this, scale): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let changed = model.arena.frame(h).is_some_and(|f| f.scale != scale);
model.arena.set_scale(h, scale);
if changed {
model.touch_layout();
// The owner's effective scale is in every descendant FontString's measure key.
model.touch_measure_all();
}
lua.app_data_mut::<Model>()
.expect("model")
.set_frame_scale(h, scale);
Ok(())
})?,
)?;
@@ -789,11 +788,7 @@ fn strata_name(s: Strata) -> &'static str {
fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.arena.set_shown(h, shown)
};
event::fire_visibility_changes(lua, changed);
visibility::set_shown(lua, h, shown);
Ok(())
}
@@ -197,11 +197,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})
})?;
// GetEffectiveScale(): parent scale times own scale, from a root of 1: `uiScale` is applied at
// the raster seam (a screen `768/uiScale` units tall), so every coordinate Lua sees,
// `GetCursorPosition()` included, is already in UI units. The reference instead makes
// `uiScale` `UIParent`'s own scale (`0x494550` calls `SetScale`), so there
// `UIParent:GetEffectiveScale()` answers it.
// GetEffectiveScale(): parent scale times own scale (`0x76ac90`), from a root of 1. The UI
// scale is `UIParent`'s own (`0x494550`), so `UIParent:GetEffectiveScale()` answers it and a
// frame outside `UIParent` does not carry it.
m.set(
"GetEffectiveScale",
lua.create_function(|lua, this: Table| {
+14 -9
View File
@@ -22,8 +22,9 @@ use super::{button, cursor, editbox, event, Model, UiScript};
/// each release only has to hit the frame.
pub(super) const DOUBLE_CLICK_SECS: f64 = 0.300;
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor is in UI units, as 1.12
/// answers it; the engine's y-up UI space is 1:1 logical pixels, so no scale factor applies.
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor (`0x48b820`) answers in the
/// screen root's units, y up, with no scale divided out: a caller divides by its frame's
/// `GetEffectiveScale()`, which carries the UI scale inside `UIParent`.
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
lua.globals().set(
"GetCursorPosition",
@@ -107,7 +108,7 @@ impl UiScript {
// tested, so the reticle is placed through it.
&& !model.nameplates.vetoes(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
point_in_rect(hit_rect(&model, fh, *r), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
@@ -124,9 +125,10 @@ impl UiScript {
// The wheel flag alone: the wheel index is separate from the mouse's, so the wheel
// passes a frame that only takes the mouse, as a scroll pane's chrome does.
model.arena.is_mouse_wheel_enabled(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
&& model
.resolved
.get(&fh)
.is_some_and(|r| point_in_rect(hit_rect(&model, fh, *r), x, y))
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
})?;
@@ -628,9 +630,12 @@ pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
x >= r.left && x <= r.right && y >= r.bottom && y <= r.top
}
/// A frame's mouse rect: its resolved rect shrunk by its `[left, right, top, bottom]` hit-rect
/// insets (y-up); an over-shrunk rect collapses to empty rather than inverting.
fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect {
/// A frame's mouse rect (`0x76b580`): its resolved rect shrunk by its `[left, right, top, bottom]`
/// hit-rect insets times its effective scale (y-up), the insets being in the frame's own units; an
/// over-shrunk rect collapses to empty rather than inverting.
fn hit_rect(model: &Model, fh: FrameHandle, r: Rect) -> Rect {
let s = super::object::eff_scale(model, fh);
let [left, right, top, bottom] = model.arena.hit_rect_insets(fh).map(|i| i * s);
Rect {
left: r.left + left,
right: (r.right - right).max(r.left + left),
+12
View File
@@ -684,3 +684,15 @@ pub(super) fn virtual_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
_ => (aw, ah),
}
}
/// [`virtual_span`] on screen: the region's own units times its owner's effective scale, as the
/// layout sizes a region (`0x76ac90`). For the parts the engine seats itself, with no anchors: a
/// slider's thumb, a colour picker's markers.
pub(super) fn screen_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
let (w, h) = virtual_span(model, rh);
let s = model
.arena
.region(rh)
.map_or(1.0, |r| super::object::eff_scale(model, r.owner));
(w * s, h * s)
}
+24 -1
View File
@@ -1,6 +1,7 @@
//! The session verbs: the game menu's Logout and Exit Game, their dialogs' answers, `ReloadUI`
//! and the cinematic pair. Each queues a [`SessionRequest`] the app turns into a packet or an
//! exit; the server decides whether a logout is instant or a 20-second countdown.
//! exit; the server decides whether a logout is instant or a 20-second countdown. Beside them,
//! the world-enter cascade's two events and the `PLAYER_LOGIN` arm a UI load sets.
use mlua::Lua;
@@ -41,6 +42,28 @@ impl super::UiScript {
pub fn queue_session_request(&mut self, request: SessionRequest) {
self.model_mut().session_requests.push(request);
}
/// Arm `PLAYER_LOGIN`, the UI load's last step: `UI_Init` sets the one-shot `[0xb4e260]` at
/// `0x49011d`, unconditionally, and [`Self::fire_world_enter`] spends it. The flag lives in
/// this VM, so each load arms its own and a worldport, which loads nothing, re-arms nothing.
pub fn arm_player_login(&mut self) {
self.model_mut().player_login_armed = true;
}
/// The world-enter cascade's events (`0x4908c0`): `PLAYER_LOGIN` (270, `0x490959`) only if a
/// load armed it (read at `0x49094b`, cleared at `0x49095e`), then `PLAYER_ENTERING_WORLD`
/// (272, `0x49096a`) every time. The cascade runs with the player object in the world, so its
/// caller seats `"player"` first, and inside its own sound-suppression bracket (`0x4908d5` to
/// `0x490a56`), so a handler's `PlaySound` is dropped.
pub fn fire_world_enter(&mut self) {
let armed = std::mem::take(&mut self.model_mut().player_login_armed);
self.push_sound_suppression();
if armed {
self.fire_event("PLAYER_LOGIN", Vec::new());
}
self.fire_event("PLAYER_ENTERING_WORLD", Vec::new());
self.pop_sound_suppression();
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
+26 -7
View File
@@ -263,8 +263,9 @@ pub(super) fn thumb_rect(r: Rect, thumb_size: (f32, f32), vertical: bool, fracti
/// The thumb region's own width and height, as `0x789ba0` reads them through the texture's
/// geometry slots (`[vtable+0x1c]`/`+0x20`, `0x770720`/`0x770790`): the authored span, else the
/// art's texel span, else 0. `None` without a thumb region: `0x789ba0` gates the value math on
/// `+0x328`, so a thumbless slider captures a press but never moves.
/// art's texel span, else 0, in the slider's units, unscaled, though the track it is subtracted
/// from is the scaled rect (`0x789bd4`–`0x789bfe`). `None` without a thumb region: `0x789ba0`
/// gates the value math on `+0x328`, so a thumbless slider captures a press but never moves.
fn thumb_extent(model: &Model, thumb: Option<crate::widget::RegionHandle>) -> Option<(f32, f32)> {
Some(super::region::virtual_span(model, thumb?))
}
@@ -303,18 +304,36 @@ pub(super) fn begin_drag(
});
return None;
};
let trect = thumb_rect(r, size, vertical, fraction);
// The thumb as drawn, at the slider's scale, decides whether the press is on it.
let drawn = thumb.map_or(size, |t| super::region::screen_span(model, t));
let trect = thumb_rect(r, drawn, vertical, fraction);
let on_thumb = point_in_rect(trect, x, y);
// Distances from the track's leading edge, where the thumb sits at `min`: `top - y` on a
// vertical track in this y-up arena, `x - left` on a horizontal one.
let (cursor, thumb_lead, thumb_len) = if vertical {
(r.top - y, r.top - trect.top, trect.top - trect.bottom)
let (cursor, thumb_lead, drawn_len, own_len) = if vertical {
(
r.top - y,
r.top - trect.top,
trect.top - trect.bottom,
size.1,
)
} else {
(x - r.left, trect.left - r.left, trect.right - trect.left)
(
x - r.left,
trect.left - r.left,
trect.right - trect.left,
size.0,
)
};
// Off the thumb the cursor takes its centre, half its own unscaled length (`0x789bf5`).
let grab_offset = if on_thumb {
slider_grab(cursor, thumb_lead, drawn_len)
} else {
own_len * 0.5
};
model.slider_drag = Some(SliderDrag {
slider: h,
grab_offset: slider_grab(cursor, thumb_lead, thumb_len),
grab_offset,
});
if on_thumb {
return None; // no value change from the grab itself
-2
View File
@@ -239,8 +239,6 @@ end
function getglobal(name) return _G[name] end
function setglobal(name, value) _G[name] = value end
-- ── GetLocale: benilla ships/reads enUS data only (the 5875 MPQs + Spell.dbc enUS column) ──────
function GetLocale() return "enUS" end
-- ── NOT here: wipe / tostringall / strsplit / strjoin / strconcat / strtrim ────────────────────
-- Six 2.0+ names benilla used to define. 1.12 has none of them (absent from
@@ -216,3 +216,48 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
// Stored as a byte: 0.5 reads back as 128/255.
assert_eq!(a, 128.0 / 255.0);
}
/// The pieces are textures anchored and sized in the frame's own units (`0x77e8d0`), so the layout
/// scales them by its effective scale like any region: a half-scale frame's border is half as
/// thick and its background inset half as deep.
#[test]
fn a_scaled_frames_backdrop_scales_with_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Half")
f:SetScale(0.5)
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
f:SetWidth(200); f:SetHeight(100)
f:SetBackdrop({
bgFile = "bg", edgeFile = "edge", tile = false, edgeSize = 16,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
"#,
)
.unwrap();
s.resolve();
let rects: Vec<crate::layout::Rect> = s
.extract()
.into_iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.map(|q| q.rect.expect("piece rect"))
.collect();
assert_eq!(rects.len(), 9);
// The frame on screen: left 50, top 550, 100 × 50.
let bg = rects[0];
assert_eq!(
(bg.left, bg.right, bg.bottom, bg.top),
(52.0, 148.0, 502.0, 548.0),
"the insets are 4 units of a half-scale frame"
);
// Every border piece is 16 units, 8 on screen, thick.
for r in &rects[1..] {
assert_eq!(
r.width().min(r.height()),
8.0,
"a border piece {r:?} is edgeSize units of a half-scale frame"
);
}
}
@@ -430,3 +430,37 @@ fn an_empty_scroll_child_is_reported() {
);
assert!(s.eval::<bool>("return Hollow ~= nil").unwrap());
}
/// `CreateFrame` builds through the same instantiator as the XML, `0x6ee280` (called at
/// `0x70628b`), so its post-load hook `0x76a2f0` fires a visible frame's template OnShow once,
/// after its OnLoad (`0x76a3b3`); one created under a hidden parent gets none.
#[test]
fn a_runtime_template_fires_its_onshow_after_its_onload_when_visible() {
let s = script();
register(
&s,
r#"<Ui>
<Frame name="ShowProbeTemplate" virtual="true">
<Scripts>
<OnLoad>table.insert(LOG, this:GetName() .. ":load")</OnLoad>
<OnShow>table.insert(LOG, this:GetName() .. ":show")</OnShow>
</Scripts>
</Frame>
</Ui>"#,
);
s.run(
r#"
LOG = {}
CreateFrame("Frame", "Seen", nil, "ShowProbeTemplate")
local hidden = CreateFrame("Frame", "HiddenHost")
hidden:Hide()
CreateFrame("Frame", "Unseen", hidden, "ShowProbeTemplate")
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"Seen:load,Seen:show,Unseen:load"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -330,3 +330,49 @@ fn an_all_events_listener_runs_after_the_events_own() {
assert_eq!(s.eval::<String>("return order[2]").unwrap(), "all");
assert_eq!(s.eval::<i64>("return table.getn(order)").unwrap(), 2);
}
/// The world-enter cascade (`0x4908c0`) fires `PLAYER_LOGIN` (`0x490959`) only while a load's arm
/// (`0x49011d`) is unspent, clearing it (`0x49095e`), then `PLAYER_ENTERING_WORLD` (`0x49096a`)
/// every time, inside its own sound bracket (`0x4908d5` to `0x490a56`).
#[test]
fn the_world_enter_cascade_spends_the_login_arm_once_and_plays_nothing() {
let mut s = script();
s.run(
r#"
order = ""
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:SetScript("OnEvent", function()
order = order .. event .. " "
PlaySound("igMainMenuOpen")
end)
"#,
)
.unwrap();
s.fire_world_enter();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"PLAYER_ENTERING_WORLD ",
"no load armed it"
);
s.run(r#"order = """#).unwrap();
s.arm_player_login();
s.fire_world_enter();
s.fire_world_enter();
assert_eq!(
s.eval::<String>("return order").unwrap(),
"PLAYER_LOGIN PLAYER_ENTERING_WORLD PLAYER_ENTERING_WORLD ",
"the arm is one-shot"
);
assert!(
s.take_sounds().is_empty(),
"the cascade's handlers are silent"
);
s.run(r#"PlaySound("igMainMenuOpen")"#).unwrap();
assert_eq!(
s.take_sounds().len(),
1,
"and the bracket closed behind them"
);
}
@@ -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(
@@ -253,6 +253,27 @@ fn hit_rect_insets_shrink_the_mouse_rect_only() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The hit rect is the rect less each inset times the frame's effective scale (`0x76b580`), so a
/// half-scale frame's 20-unit header is 10 on screen.
#[test]
fn hit_rect_insets_scale_with_the_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Frame", "HalfHit")
a:SetScale(0.5)
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true)
a:SetHitRectInsets(0, 0, 20, 0)
"#,
)
.unwrap();
s.resolve();
// On screen the frame is 50 × 50 from the origin, its header the top 10.
assert!(s.hit_test(25.0, 45.0).is_none(), "inside the scaled header");
assert!(s.hit_test(25.0, 38.0).is_some(), "below the scaled header");
}
#[test]
fn hit_rect_insets_default_to_zero() {
let mut s = script();
@@ -282,3 +282,18 @@ fn frame_effective_alpha_reads_the_shown_frames_alpha() {
"hidden through the parent"
);
}
/// `Minimap_OnClick` divides the cursor by the minimap's effective scale and hands
/// `PingLocation` offsets in the minimap's own units; the reference reads them against the
/// widget's own size (`0x4eeca0`), so they reach the host scaled back to screen units.
#[test]
fn ping_location_reaches_the_host_in_screen_units() {
let mut s = UiScript::new().unwrap();
s.run(
r#"m = CreateFrame("Minimap", "HalfMinimap")
m:SetScale(0.5)
m:PingLocation(10, -20)"#,
)
.unwrap();
assert_eq!(s.take_minimap_ping_request(), Some((5.0, -10.0)));
}
@@ -92,8 +92,9 @@ fn a_visible_reparent_refires_onhide_then_onshow() {
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(
log,
vec!["hide:RTOld", "show:RTNew", "kidshow"],
"OnHide observes the OLD parent, OnShow the new, and the refire walks the subtree"
vec!["hide:RTOld", "kidshow", "show:RTNew"],
"OnHide observes the OLD parent, OnShow the new, and the refire walks the subtree, the \
child notified before its parent (`0x76aed5`, then `0x76aef5`)"
);
assert_eq!(
s.eval::<i64>("return RTMover:GetFrameLevel()").unwrap(),
@@ -628,3 +628,37 @@ fn a_step_before_any_range_is_the_whole_call() {
let (min, max): (f32, f32) = s.eval("return SlNoRange:GetMinMaxValues()").unwrap();
assert_eq!((min, max), (0.0, 0.0), "no range was pushed");
}
/// The thumb is a texture of the slider, sized in its units, so it draws at the slider's
/// effective scale; the drag reads its unscaled size against the scaled track, as the bytes do.
#[test]
fn a_scaled_sliders_thumb_scales_with_it() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local sl = CreateFrame("Slider", "SlHalf")
sl:SetScale(0.5)
-- The render test's scrollbar at half scale: on screen 8 wide, 50 tall, from (50, 50).
sl:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
sl:SetWidth(16); sl:SetHeight(100)
sl:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
local t = sl:GetThumbTexture()
t:SetWidth(16); t:SetHeight(16)
sl:SetMinMaxValues(0, 100)
sl:SetValue(0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
thumb_rect(&s.extract(), "Knob"),
(50.0, 58.0, 92.0, 100.0),
"a 16-unit thumb of a half-scale slider is 8 on screen, flush at the track top"
);
// The drag (`0x789ba0`) takes the thumb's own unscaled 16 from the scaled 50-tall track: a
// press 40 below the track top is (40 - 8) of the 34 travel.
s.mouse_button(54.0, 60.0, "LeftButton", true);
let v = s.eval::<f64>("return SlHalf:GetValue()").unwrap();
assert!((v - 100.0 * 32.0 / 34.0).abs() < 1e-3, "value {v}");
}
@@ -679,3 +679,48 @@ fn minimap_blip_tooltip_shows_and_fades() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// A cursor-seated plate sits on the cursor under the UI scale: the cursor is in root units and
/// the anchor offset in the tooltip's own, so the cursor arm divides by its effective scale
/// (`0x530b20`).
#[test]
fn a_cursor_seated_plate_lands_on_the_cursor_under_the_ui_scale() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local up = CreateFrame("Frame", "UIParent")
up:SetPoint("BOTTOMLEFT", 0, 0); up:SetPoint("TOPRIGHT", 0, 0)
CreateFrame("GameTooltip", "GameTooltip", UIParent)
"#,
)
.unwrap();
assert!(s.set_ui_scale(0.9));
let at = |s: &mut crate::script::UiScript| -> (f64, f64) {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.iter()
.map(|r| (r.id, 80.0, 10.0, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
s.eval::<(f64, f64)>(
"local k = GameTooltip:GetEffectiveScale() \
local x = GameTooltip:GetCenter() \
return x * k, GameTooltip:GetBottom() * k",
)
.unwrap()
};
assert!(s.minimap_tooltip("Stormwind", 400.0, 300.0, false));
let (x, y) = at(&mut s);
assert!(
(x - 400.0).abs() < 1e-3 && (y - 300.0).abs() < 1e-3,
"({x}, {y})"
);
s.world_tooltip_move(200.0, 100.0);
let (x, y) = at(&mut s);
assert!(
(x - 200.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3,
"({x}, {y})"
);
}
@@ -64,3 +64,151 @@ fn tick_runs_onupdate_only_when_effectively_visible() {
s.tick(0.25); // visible again → fires
assert_eq!(s.eval::<i64>("return ticks").unwrap(), 2);
}
/// The show cascade `0x76ae10` walks its children (`0x76aed5`) before its own notify
/// (`0x76aef5`), and the hide cascade `0x76ad50` likewise (`0x76adee`, then `0x76adfd`): both are
/// post-order, depth-first in child-link order.
#[test]
fn a_cascade_notifies_children_before_their_parent() {
let s = script();
s.run(
r#"
LOG = {}
local function log(tag) return function() table.insert(LOG, tag) end end
local p = CreateFrame("Frame", "PostP")
local c1 = CreateFrame("Frame", "PostC1", p)
local g = CreateFrame("Frame", "PostG", c1)
local c2 = CreateFrame("Frame", "PostC2", p)
for f, tag in pairs({ [p] = "p", [c1] = "c1", [g] = "g", [c2] = "c2" }) do
f:SetScript("OnShow", log(tag .. "+"))
f:SetScript("OnHide", log(tag .. "-"))
end
p:Hide()
p:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"g-,c1-,c2-,p-,g+,c1+,c2+,p+"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The child walk follows live links and each child's show re-checks its own gate when the walk
/// reaches it (`0x76ae1d`): a sibling an earlier sibling's OnShow hides gets no OnShow, and, never
/// having become visible, no OnHide either.
#[test]
fn a_sibling_hidden_by_an_earlier_siblings_onshow_gets_no_onshow() {
let s = script();
s.run(
r#"
LOG = {}
local p = CreateFrame("Frame", "LiveP")
local c1 = CreateFrame("Frame", "LiveC1", p)
local c2 = CreateFrame("Frame", "LiveC2", p)
p:Hide()
c1:SetScript("OnShow", function() table.insert(LOG, "c1+") LiveC2:Hide() end)
c2:SetScript("OnShow", function() table.insert(LOG, "c2+") end)
c2:SetScript("OnHide", function() table.insert(LOG, "c2-") end)
p:SetScript("OnShow", function() table.insert(LOG, "p+") end)
p:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"c1+,p+"
);
assert!(!s.eval::<bool>("return LiveC2:IsVisible() == 1").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A sibling an earlier sibling's OnHide hides runs its whole hide, OnHide included, nested in that
/// handler (`0x7758bb`); when the parent's walk reaches it, it is already invisible and the hide
/// gate (`0x76ad5b`) returns without a second OnHide. The parent's own OnHide comes last.
#[test]
fn a_sibling_hidden_by_an_earlier_siblings_onhide_is_notified_once_inside_it() {
let s = script();
s.run(
r#"
LOG = {}
local p = CreateFrame("Frame", "NestP")
local c1 = CreateFrame("Frame", "NestC1", p)
local c2 = CreateFrame("Frame", "NestC2", p)
c1:SetScript("OnHide", function()
table.insert(LOG, "c1-")
NestC2:Hide()
table.insert(LOG, "c1-end")
end)
c2:SetScript("OnHide", function() table.insert(LOG, "c2-") end)
p:SetScript("OnHide", function() table.insert(LOG, "p-") end)
p:Hide()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"c1-,c2-,c1-end,p-"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A child's OnShow that hides the parent: the parent's hide cascade runs nested (the child's
/// OnHide, then the parent's), the next sibling then fails its parent gate (`0x76ae35`) and is
/// never shown, and the parent's own OnShow still fires last, since nothing between the walk and
/// `0x76aef5` re-checks.
#[test]
fn a_parent_hidden_by_a_childs_onshow_stops_the_walk_but_still_gets_its_onshow() {
let s = script();
s.run(
r#"
LOG = {}
local p = CreateFrame("Frame", "StopP")
local c1 = CreateFrame("Frame", "StopC1", p)
local c2 = CreateFrame("Frame", "StopC2", p)
p:Hide()
c1:SetScript("OnShow", function() table.insert(LOG, "c1+") StopP:Hide() end)
c1:SetScript("OnHide", function() table.insert(LOG, "c1-") end)
c2:SetScript("OnShow", function() table.insert(LOG, "c2+") end)
p:SetScript("OnShow", function() table.insert(LOG, "p+") end)
p:SetScript("OnHide", function() table.insert(LOG, "p-") end)
p:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
"c1+,c1-,p-,p+"
);
assert!(!s.eval::<bool>("return StopC2:IsVisible() == 1").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The Lua `Show` writes the shown bit and dispatches the cascade unconditionally (`0x7757f1`,
/// `0x7757fb`), so a frame whose bit is already set but which is not visible (a `SetParent` from a
/// hidden parent to a shown one skips the show half, `0x76abe3`) becomes visible on `Show()`.
#[test]
fn show_on_a_shown_but_invisible_frame_makes_it_visible() {
let s = script();
s.run(
r#"
SHOWS = 0
local hidden = CreateFrame("Frame", "StaleHidden")
hidden:Hide()
local shown = CreateFrame("Frame", "StaleShown")
local f = CreateFrame("Frame", "Stale", hidden)
f:SetScript("OnShow", function() SHOWS = SHOWS + 1 end)
f:SetParent(shown)
"#,
)
.unwrap();
assert!(
!s.eval::<bool>("return Stale:IsVisible() == 1").unwrap(),
"the reparent from an invisible parent runs no show half"
);
s.run("Stale:Show()").unwrap();
assert!(s.eval::<bool>("return Stale:IsVisible() == 1").unwrap());
assert_eq!(s.eval::<i64>("return SHOWS").unwrap(), 1);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+2 -6
View File
@@ -355,13 +355,9 @@ fn now(lua: &Lua) -> f64 {
crate::script::clock::now(lua)
}
/// Shows or hides through the arena and fires the visibility events.
/// Shows or hides as the Lua verbs do, handlers included.
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.arena.set_shown(h, shown)
};
event::fire_visibility_changes(lua, changed);
crate::script::visibility::set_shown(lua, h, shown);
}
/// The default line colour, gold `0xffffd200` (stored at `0xc0d3e8` by `0x528e50`). The zone
+18 -4
View File
@@ -350,8 +350,8 @@ impl super::UiScript {
if let Ok(t) = tip_mut(&mut model, h) {
t.owner = Some(root);
}
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let same = input.anchors.len() == 1
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
@@ -440,10 +440,10 @@ impl super::UiScript {
{
let mut model = self.model_mut();
clear_content(&mut model, h);
let input = model.layout_inputs.entry(h).or_default();
// Anchor only: the frame's own clamp flag (`Frame::clamped_to_screen`, G flags bit 4)
// slides a plate near the window edge back on screen.
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
// Compare-then-touch, so a still cursor does not dirty the layout.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
@@ -497,8 +497,8 @@ impl super::UiScript {
return;
}
let root_id = model.frame_id(root);
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch: this runs on every pointer event.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
@@ -559,3 +559,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
Ok(())
}
/// The plate's BOTTOM on the cursor: `(ui_x, ui_y)` is in the screen root's units, and an anchor
/// offset is in the tooltip's own, so it is divided by the tooltip's effective scale, the UI scale
/// included, as the cursor arm `0x530b20` does ([`super::tooltip`]'s `cursor_anchor`).
fn cursor_anchor_at(model: &Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) -> Anchor {
let s = super::object::eff_scale(model, h);
Anchor::new(
Point::Bottom,
root_id,
Point::BottomLeft,
ui_x / s,
ui_y / s,
)
}
+221
View File
@@ -0,0 +1,221 @@
//! The UI scale: `UIParent`'s own frame scale, never the screen's. The screen root keeps scale 1
//! and is 768 units tall at every window size; `uiScale` reaches only `UIParent`'s subtree,
//! through the worker Lua's `SetScale` calls (`0x494550` → `0x76ac10` on the frame named
//! `"UIParent"`, looked up at `0x49006a`), so a frame with no parent, or under `WorldFrame`, draws
//! at scale 1.
use super::{Model, UiScript};
/// `SetUiScale`'s floor (`0x494550`: `[0x804584]`, below which `0x494563` stores 0.64).
pub(crate) const UI_SCALE_FLOOR: f32 = 0.64;
/// `GetUiScale` (`0x494590`): `UIParent`'s effective scale (`[UIParent+0x7c]`), else 1.0 when there
/// is no `UIParent`. `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the root by
/// it, and nothing else reads it. A zero scale reads 1, as the reference's writer `0x768360`
/// refuses one.
pub(crate) fn ui_parent_scale(model: &Model) -> f32 {
model
.arena
.lookup("UIParent")
.map_or(1.0, |h| super::object::eff_scale(model, h))
}
/// The cap both `uiScale` legs apply before the sink (`0x490770`, and the display handler's ON leg
/// `0x492e90`): below a 4:3 aspect `a` the value is capped at `0.75·a` (`a·3 ≥ 4` skips the cap, so
/// 4:3 itself is never capped).
pub(crate) fn capped_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
if aspect * 3.0 < 4.0 {
ui_scale.min(aspect * 0.75)
} else {
ui_scale
}
}
/// `SetUiScale`'s floor (`0x494550`): above [`UI_SCALE_FLOOR`] the value stands, else (NaN
/// included) the floor.
pub(crate) fn floored_ui_scale(v: f32) -> f32 {
if v > UI_SCALE_FLOOR {
v
} else {
UI_SCALE_FLOOR
}
}
/// What the CVar callback applies (`0x490770` → `0x494550`): capped, then floored.
pub(crate) fn effective_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
floored_ui_scale(capped_ui_scale(ui_scale, aspect))
}
/// The automatic scale `0x492f70` computes for a `w`×`h` display: 1.0, or `768/h` above 768 tall
/// (`0x492fbc cmp esi,0x300`), under the same narrow-aspect cap, then raised to 0.9 unless above it
/// (`0x493005`–`0x493012`).
pub(crate) fn auto_ui_scale(w: u32, h: u32) -> f32 {
let mut v = if h > 768 { 768.0 / h as f32 } else { 1.0 };
if h > 0 {
v = capped_ui_scale(v, w as f32 / h as f32);
}
if v > 0.9 {
v
} else {
0.9
}
}
impl UiScript {
/// The host's `uiScale`, compared first like [`Self::set_screen_size`]: a new value applies at
/// once through the CVar's change callback (`0x490770`: capped, floored at 0.64), and `true`
/// says it reached a `UIParent`. The same value again does nothing, so an addon's own
/// `UIParent:SetScale` holds until the next change.
pub fn set_ui_scale(&mut self, ui_scale: f32) -> bool {
let applied = {
let mut model = self.model_mut();
if model.ui_scale_request == Some(ui_scale) {
return false;
}
model.ui_scale_request = Some(ui_scale);
let aspect = model.screen.width() / model.screen.height();
model
.arena
.lookup("UIParent")
.map(|h| (h, effective_ui_scale(ui_scale, aspect)))
};
let Some((ui_parent, v)) = applied else {
return false;
};
self.model_mut().set_frame_scale(ui_parent, v);
true
}
/// Record `ui_scale` without applying it, then apply it through the display handler, as the UI
/// load does once its files and `VARIABLES_LOADED` are done (`0x49010e` → `0x492e90`): the
/// CVar's own callback found no `UIParent` when it fired at registration.
pub fn seat_ui_scale(
&mut self,
ui_scale: f32,
display: (u32, u32),
auto_cache: &mut Option<(u32, u32)>,
) -> bool {
self.model_mut().ui_scale_request = Some(ui_scale);
self.apply_ui_scale(display, auto_cache)
}
/// The display handler `0x492e90` on the last [`Self::set_ui_scale`], for the load and a
/// display change. Its ON leg caps the value and hands it to the sink only at 0.64 or more
/// (`0x492efd fcomp [0x804584]; test ah,1; jne 0x492f4a`); below, it falls to the automatic
/// scale `0x492f70(w, h, force = 0)` ([`auto_ui_scale`]), which does nothing at all when the
/// display is the one it last ran for (`auto_cache`, the reference's `[0xb4e304]`/`[0xb4e308]`,
/// which outlive the UI). `display` is the window in logical px, the host's unit for the
/// reference's device size. `true` when a scale reached `UIParent`.
pub fn apply_ui_scale(
&mut self,
display: (u32, u32),
auto_cache: &mut Option<(u32, u32)>,
) -> bool {
let applied = {
let model = self.model_ref();
let Some(ui_scale) = model.ui_scale_request else {
return false;
};
let aspect = model.screen.width() / model.screen.height();
let v = capped_ui_scale(ui_scale, aspect);
let v = if v >= UI_SCALE_FLOOR {
v
} else {
let (w, h) = display;
if w > 0 && h > 0 && *auto_cache == Some(display) {
return false;
}
*auto_cache = Some(display);
floored_ui_scale(auto_ui_scale(w, h))
};
model.arena.lookup("UIParent").map(|h| (h, v))
};
let Some((ui_parent, v)) = applied else {
return false;
};
self.model_mut().set_frame_scale(ui_parent, v);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_callback_ladder_caps_below_four_thirds_and_floors_at_the_sink() {
// 4:3 and wider: the dial as set.
assert_eq!(effective_ui_scale(0.9, 4.0 / 3.0), 0.9);
assert_eq!(effective_ui_scale(0.9, 16.0 / 9.0), 0.9);
assert_eq!(effective_ui_scale(1.2, 16.0 / 9.0), 1.2);
// 5:4 caps a full dial at 0.9375 and leaves 0.9 alone.
assert_eq!(effective_ui_scale(1.0, 1.25), 0.9375);
assert_eq!(effective_ui_scale(0.9, 1.25), 0.9);
// The floor, on both sides of it.
assert_eq!(effective_ui_scale(0.5, 16.0 / 9.0), UI_SCALE_FLOOR);
assert_eq!(effective_ui_scale(0.65, 16.0 / 9.0), 0.65);
// A square window caps at 0.75, a narrow one bottoms out at the floor.
assert_eq!(effective_ui_scale(0.9, 1.0), 0.75);
assert_eq!(effective_ui_scale(0.9, 0.5), UI_SCALE_FLOOR);
}
#[test]
fn the_scale_lands_on_uiparent_alone_and_only_on_a_change() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
assert!(!s.set_ui_scale(0.9), "no UIParent yet: nothing to scale");
s.run(r#"CreateFrame("Frame", "UIParent") CreateFrame("Frame", "Loose")"#)
.unwrap();
assert!(!s.set_ui_scale(0.9), "the same value is no change");
assert!(
s.apply_ui_scale((1024, 768), &mut None),
"the load edge applies what was set"
);
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.9);
assert_eq!(s.eval::<f32>("return Loose:GetScale()").unwrap(), 1.0);
// An addon's own scale holds until the dial moves.
s.run("UIParent:SetScale(0.7)").unwrap();
assert!(!s.set_ui_scale(0.9));
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.7);
assert!(s.set_ui_scale(0.8));
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.8);
}
/// The automatic scale (`0x492f70`): 1.0 up to 768 tall, `768/h` above it, never under 0.9.
#[test]
fn the_auto_scale_follows_the_height_and_stops_at_nine_tenths() {
assert_eq!(auto_ui_scale(1024, 768), 1.0);
assert_eq!(auto_ui_scale(1280, 800), 0.96);
assert_eq!(auto_ui_scale(1920, 1080), 0.9);
// A tall narrow display: capped, then raised to 0.9.
assert_eq!(auto_ui_scale(600, 700), 0.9);
}
/// The display handler (`0x492e90`) never floors: a dial capped below 0.64 falls to the
/// automatic scale, which skips a display it already ran for; the CVar callback (`0x490770`)
/// floors instead.
#[test]
fn below_the_floor_the_display_handler_falls_to_the_auto_scale() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(r#"CreateFrame("Frame", "UIParent")"#).unwrap();
let scale = |s: &UiScript| s.eval::<f32>("return UIParent:GetScale()").unwrap();
let mut cache = None;
assert!(s.seat_ui_scale(0.5, (1280, 800), &mut cache));
assert_eq!(scale(&s), 0.96, "the load took the auto scale for 1280×800");
assert_eq!(cache, Some((1280, 800)));
// The same display again: the auto path does nothing at all.
s.run("UIParent:SetScale(0.7)").unwrap();
assert!(!s.apply_ui_scale((1280, 800), &mut cache));
assert_eq!(scale(&s), 0.7);
// A new display runs it.
assert!(s.apply_ui_scale((1920, 1080), &mut cache));
assert_eq!(scale(&s), 0.9);
// The CVar callback floors.
assert!(s.set_ui_scale(0.55));
assert_eq!(scale(&s), UI_SCALE_FLOOR);
// At the floor or above, the display handler hands the value to the sink.
assert!(s.seat_ui_scale(0.8, (1920, 1080), &mut cache));
assert_eq!(scale(&s), 0.8);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! The show and hide transitions with their notifies (`0x76ae10`, `0x76ad50`). Each marks its
//! frame before anything runs, walks the live child list re-gating each child as the walk reaches
//! it, and fires its own notify last, so handlers run inside the walk, children before their
//! parent, and a frame an earlier handler hid is never notified.
use mlua::{Lua, Table, Value};
use super::{event, Model};
use crate::widget::{ButtonState, ChildWalk, FrameHandle};
/// The Lua `Show`/`Hide` (`0x775750`/`0x775810`): write the shown bit (`0x7757f1`/`0x7758b1`),
/// then dispatch the transition (`0x7757fb`/`0x7758bb`), which gates itself, so `Show()` on a
/// frame already shown but not visible still runs.
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
match model_mut(lua).arena.frame_mut(h) {
Some(f) => f.shown = shown,
None => return,
}
if shown {
show(lua, h);
} else {
hide(lua, h);
}
}
/// The show transition (`0x76ae10`, slot `+0x88`): the frame's own step, its children's
/// transitions in link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`).
pub(super) fn show(lua: &Lua, h: FrameHandle) {
{
let mut model = model_mut(lua);
if !model.arena.show_step(h) {
return;
}
// Entering the mouse index schedules a hover re-pick (`0x764b8d`).
model.hover_repick = true;
}
walk_children(lua, h, show);
super::object::toplevel::raise_on_show(&mut model_mut(lua), h);
notify(lua, h, true);
}
/// The hide transition (`0x76ad50`, slot `+0x84`): the frame's own step with its hover leave,
/// its children's transitions in link order, then its own OnHide (`0x76adfd`).
pub(super) fn hide(lua: &Lua, h: FrameHandle) {
let left = {
let mut model = model_mut(lua);
if !model.arena.hide_step(h) {
return;
}
leave_if_hovered(&mut model, h)
};
if let Some(id) = left {
let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]);
record(lua, fired);
}
walk_children(lua, h, hide);
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled or
// locked, before the base notify, so a button hidden while held comes back unpushed.
super::button::edge(&mut model_mut(lua), h, ButtonState::on_hide);
notify(lua, h, false);
}
/// Run `step` on each child of `parent` as a live walk reaches it
/// ([`crate::widget::WidgetArena::next_child`]), with no borrow held across the step.
fn walk_children(lua: &Lua, parent: FrameHandle, step: fn(&Lua, FrameHandle)) {
let mut walk = ChildWalk::default();
loop {
let next = lua
.app_data_ref::<Model>()
.expect("model app_data")
.arena
.next_child(parent, &mut walk);
let Some(child) = next else { break };
step(lua, child);
}
}
/// The hide's de-registration (`0x764920`, at `0x76ad9d`, before the child walk) ends a hover on
/// this frame: the removal tail (`0x764ba0`) clears the hover, schedules a re-pick (`0x764cbb`)
/// and calls the leave notify with 1 (`0x764cce`), whose base `0x76b6f0` clears the drag-arm
/// (`+0x100`/`+0x104`), so the hovered frame's OnLeave runs before its own OnHide and its
/// ancestors'.
/// The leave is a virtual call: a disabled Button's own `0x7794e0` skips its OnLeave. This leave is
/// how a tooltip closes with its window; the reference has no other. The id whose OnLeave fires.
fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option<u32> {
if model.mouseover != Some(h) {
return None;
}
let notified = super::button::hover_notify_runs(model, Some(h));
model.mouseover = None;
if model.drag.as_ref().is_some_and(|d| d.source == h) {
model.drag = None;
}
model.hover_repick = true;
notified.then(|| model.frame_id(h))
}
/// The notify slot (`+0x30` OnShow, `+0x34` OnHide): the base fires the script unless the frame is
/// in its load window (`0x76b260`/`0x76b290`), and the EditBox's overrides then take or drop the
/// focus (`0x77a750`/`0x77a780`, base first).
fn notify(lua: &Lua, h: FrameHandle, visible: bool) {
let id = {
let mut model = model_mut(lua);
match model.arena.frame(h) {
Some(f) if f.load_window => None,
Some(_) => Some(model.frame_id(h)),
None => return,
}
};
if let Some(id) = id {
let script = if visible { "OnShow" } else { "OnHide" };
let fired = event::fire_widget_handler(lua, id, script, Vec::new());
record(lua, fired);
}
super::editbox::visibility_focus(lua, h, visible);
}
/// Open or close a frame's load window: the pre-load hook's `+0x114 = 1` (`0x7697c2`) and the
/// post-load hook's first write, `+0x114 = 0` (`0x76a2ff`).
pub(crate) fn set_load_window(lua: &Lua, wrapper: &Table, open: bool) {
let Ok(h) = super::object::frame_handle_of(lua, wrapper) else {
return;
};
if let Some(f) = model_mut(lua).arena.frame_mut(h) {
f.load_window = open;
}
}
/// The post-load hook's OnShow (`0x76a38c`–`0x76a3b3`), after the children and the OnLoad: the
/// script alone, through the runner and not the notify slot, so no raise and no EditBox focus,
/// when the frame is visible, has the handler and is out of its load window. It has no OnHide
/// counterpart, and it fires whatever OnShows the frame already had.
pub(crate) fn post_load_show(lua: &Lua, wrapper: &Table) {
let Ok(h) = super::object::frame_handle_of(lua, wrapper) else {
return;
};
let id = {
let mut model = model_mut(lua);
match model.arena.frame(h) {
Some(f) if f.effective_visible && !f.load_window => model.frame_id(h),
_ => return,
}
};
let fired = event::fire_widget_handler(lua, id, "OnShow", Vec::new());
record(lua, fired);
}
fn model_mut(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
lua.app_data_mut::<Model>().expect("model app_data")
}
/// A handler error is recorded in [`Model::errors`], never propagated to the verb.
fn record(lua: &Lua, fired: mlua::Result<()>) {
if let Err(e) = fired {
model_mut(lua).record_script_error(e.to_string());
}
}
+13
View File
@@ -162,6 +162,10 @@ pub struct Frame {
pub shown: bool,
/// `shown` and every ancestor effectively visible (`+0xd4`).
pub effective_visible: bool,
/// The frame's own load window (`+0x114`): set by the pre-load hook (`0x7697c2`), cleared as
/// the post-load hook starts (`0x76a2ff`). While it is set the frame's own OnShow and OnHide
/// fire nothing (`0x76b270`/`0x76b2a0`); it still transitions and walks its children.
pub load_window: bool,
/// `EnableMouse`: the mouse bit (bit 2) of the input mask `[frame+0xcc]`.
pub mouse_enabled: bool,
/// `EnableMouseWheel`, or an `<OnMouseWheel>` script at XML load: the mask's wheel bit (3).
@@ -228,6 +232,14 @@ pub struct Region {
pub detached: bool,
}
/// A cursor over a frame's child list for [`WidgetArena::next_child`]: the child last returned and
/// where it stood, so a walk that a handler interrupts resumes from the list as it is now.
#[derive(Clone, Copy, Debug, Default)]
pub struct ChildWalk {
at: usize,
last: Option<FrameHandle>,
}
// ── The arena ────────────────────────────────────────────────────────────────────────────────
/// The frame arena: frames, regions, the name registry and the propagation mutators. The screen
@@ -424,6 +436,7 @@ impl WidgetArena {
effective_alpha,
shown,
effective_visible: shown && parent_visible,
load_window: false,
mouse_enabled: mouse_enabled_by_ctor(kind),
// The WorldFrame ctor takes the wheel (`0x481b1f`); the reference's ScrollFrame and
// ScrollingMessageFrame ctors do not, and a stock one takes it from `<OnMouseWheel>`.
+96 -65
View File
@@ -2,51 +2,88 @@
use crate::order::Strata;
use super::{FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
use super::{ChildWalk, FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
impl WidgetArena {
// ── Visibility ───────────────────────────────────────────────────────────────────────────────
/// Set `shown` and propagate effective visibility down the subtree (show `0x76ae10`, hide
/// `0x76ad50`), returning the frames that changed, a node before its descendants, for the
/// caller to fire `OnShow`/`OnHide`. The reference notifies post-order (`0x76aef5`) and
/// re-reads live links, so a frame a handler hides mid-cascade fails its gate (`0x76ae1d`) and
/// is never notified; this list is a snapshot that still holds it.
/// The show transition's own step (`0x76ae10` up to its child walk): nothing unless the frame
/// is shown (`0x76ae1d`), its parent is visible or absent (`0x76ae35`) and it is not visible
/// yet (`0x76ae43`); otherwise it marks itself visible (`0x76ae7b`), before any child or
/// handler runs, and relinks at its bucket's tail (`0x764780`). Whether it transitioned.
pub fn show_step(&mut self, h: FrameHandle) -> bool {
match self.frame(h) {
Some(f) if f.shown && !f.effective_visible => {}
_ => return false,
}
if !self.parent_visible(h) {
return false;
}
self.frame_mut(h).unwrap().effective_visible = true;
self.resequence_to_tail(h);
true
}
/// The hide transition's own step (`0x76ad50` up to its child walk): nothing unless the frame
/// is visible (`0x76ad5b`); otherwise it marks itself invisible (`0x76ad93`). The shown bit is
/// not consulted. Whether it transitioned.
pub fn hide_step(&mut self, h: FrameHandle) -> bool {
match self.frame_mut(h) {
Some(f) if f.effective_visible => {
f.effective_visible = false;
true
}
_ => false,
}
}
/// The next child of `parent` in a live walk of its child list (`0x76aec7`, `0x76ade0`). The
/// reference reads the next link from the current node after the call returns, so a child a
/// handler adds or removes later in the list is seen. A walked child that was itself moved
/// out leaves the walk at the child now in its place; the reference reads a stale link there.
pub fn next_child(&self, parent: FrameHandle, walk: &mut ChildWalk) -> Option<FrameHandle> {
let children = &self.frame(parent)?.children;
let at = match walk.last {
None => 0,
Some(last) if children.get(walk.at) == Some(&last) => walk.at + 1,
Some(last) => children
.iter()
.position(|&c| c == last)
.map_or(walk.at, |i| i + 1),
};
let next = *children.get(at)?;
walk.at = at;
walk.last = Some(next);
Some(next)
}
/// The Lua `Show`/`Hide` (`0x7757f1`/`0x7757fb`, `0x7758b1`/`0x7758bb`) with no handler to
/// fire: write the shown bit, then run the transition, which gates itself. Returns the frames
/// that changed in the order their notifies run, children before their parent. A caller with
/// handlers walks with [`Self::show_step`], [`Self::hide_step`] and [`Self::next_child`].
pub fn set_shown(&mut self, h: FrameHandle, shown: bool) -> Vec<FrameHandle> {
let mut changed = Vec::new();
match self.frame_mut(h) {
Some(f) if f.shown != shown => f.shown = shown,
_ => return changed,
if let Some(f) = self.frame_mut(h) {
f.shown = shown;
self.cascade_silent(h, shown, &mut changed);
}
let parent_visible = self.parent_visible(h);
self.propagate_visible(h, parent_visible, &mut changed);
changed
}
fn propagate_visible(
&mut self,
h: FrameHandle,
parent_visible: bool,
changed: &mut Vec<FrameHandle>,
) {
let (shown, old_ev, children) = {
let f = self.frame(h).expect("live node in propagation");
(f.shown, f.effective_visible, f.children.clone())
fn cascade_silent(&mut self, h: FrameHandle, show: bool, changed: &mut Vec<FrameHandle>) {
let stepped = if show {
self.show_step(h)
} else {
self.hide_step(h)
};
let new_ev = shown && parent_visible;
if new_ev == old_ev {
// No change here means none below: prune, as the client's recursion does.
if !stepped {
return;
}
self.frame_mut(h).unwrap().effective_visible = new_ev;
if new_ev {
// Re-added at its bucket's tail, as the client does, and each descendant after it.
self.resequence_to_tail(h);
let mut walk = ChildWalk::default();
while let Some(c) = self.next_child(h, &mut walk) {
self.cascade_silent(c, show, changed);
}
changed.push(h);
for c in children {
self.propagate_visible(c, new_ev, changed);
}
}
// ── Strata ───────────────────────────────────────────────────────────────────────────────────
@@ -202,18 +239,20 @@ impl WidgetArena {
// ── Reparenting ──────────────────────────────────────────────────────────────────────────────
/// Phase 1 of `SetParent` (`0x76ab10`): the guards and the hide half. `None` when the parent
/// is unchanged (`0x76ab20` skips everything), the frame is dead, or the move would cycle (the
/// Lua binding raises first, `0x7a177f`); otherwise the frames that lost effective visibility,
/// in pre-order, whose `OnHide` runs under the old parent (`0x76ab41`).
/// Phase 1 of `SetParent` (`0x76ab10`): the guards. `None` when the parent is unchanged
/// (`0x76ab20` skips everything), the frame is dead, or the move would cycle (the Lua binding
/// raises first, `0x7a177f`); otherwise whether the frame is visible (`0x76ab2b`), in which
/// case the caller runs its hide transition under the old parent (`0x76ab41`) before
/// [`Self::reparent_finish`], and its show transition after it (`0x76abfd`).
///
/// Deviation: the frame is still in the old parent's child list during `OnHide`, unlike the
/// reference, because a handler that itself reparents must not find the lists half-spliced.
/// reference (`0x76ab34` unlinks first), because a handler that itself reparents must not find
/// the lists half-spliced.
pub fn reparent_begin(
&mut self,
h: FrameHandle,
new_parent: Option<FrameHandle>,
) -> Option<Vec<FrameHandle>> {
) -> Option<bool> {
self.frame(h)?;
let new_parent = new_parent.filter(|&p| self.frame(p).is_some());
if let Some(np) = new_parent {
@@ -221,32 +260,22 @@ impl WidgetArena {
return None;
}
}
if self.frame(h).unwrap().parent == new_parent {
let f = self.frame(h).unwrap();
if f.parent == new_parent {
return None;
}
let mut hidden = Vec::new();
if self.frame(h).unwrap().effective_visible {
self.propagate_visible(h, false, &mut hidden);
}
Some(hidden)
Some(f.effective_visible)
}
/// Phase 2 of `SetParent` (`0x76ab10`): relink, strata := the parent's (`0x76ab5a`, MEDIUM for
/// none), level := the parent's + 1 without propagating (`0x76ab65`, 0 for none), scale
/// re-inherited, then the show half, returning the frames that became visible; alpha is
/// untouched. `h`'s own children keep their levels, so one can end up below `h`.
/// `was_visible` is whether phase 1 hid anything (the reference's `ebx` at `0x76ab2b`); the
/// show half runs only then (`0x76abfd`), so a hidden frame moved under a visible parent keeps
/// a stale effective visibility until something shows it.
pub fn reparent_finish(
&mut self,
h: FrameHandle,
new_parent: Option<FrameHandle>,
was_visible: bool,
) -> Vec<FrameHandle> {
let mut shown = Vec::new();
/// re-inherited; alpha is untouched. `h`'s own children keep their levels, so one can end up
/// below `h`. The show half is the caller's, and runs only when phase 1 found the frame
/// visible (`0x76abe3`), so a hidden frame moved under a visible parent keeps a stale
/// effective visibility until something shows it.
pub fn reparent_finish(&mut self, h: FrameHandle, new_parent: Option<FrameHandle>) {
if self.frame(h).is_none() {
return shown; // died inside an OnHide: nothing to move
return; // died inside an OnHide: nothing to move
}
let new_parent = new_parent.filter(|&p| self.frame(p).is_some());
// Relink from the current parent: an `OnHide` may have reparented it, and the reference's
@@ -274,25 +303,27 @@ impl WidgetArena {
self.set_frame_level(h, plevel, false);
let ps = self.parent_effective_scale(h);
self.propagate_scale(h, ps);
if was_visible && self.parent_visible(h) {
self.propagate_visible(h, true, &mut shown);
}
shown
}
/// Both reparent phases in one call, hide changes first, for a caller with no `OnHide` to fire.
/// `SetParent` whole, for a caller with no handler to fire: the frames that lost visibility,
/// then those that gained it, each half in notify order.
pub fn set_parent(
&mut self,
h: FrameHandle,
new_parent: Option<FrameHandle>,
) -> Vec<FrameHandle> {
let Some(hidden) = self.reparent_begin(h, new_parent) else {
let Some(was_visible) = self.reparent_begin(h, new_parent) else {
return Vec::new();
};
let was_visible = !hidden.is_empty();
let shown = self.reparent_finish(h, new_parent, was_visible);
[hidden, shown].concat()
let mut changed = Vec::new();
if was_visible {
self.cascade_silent(h, false, &mut changed);
}
self.reparent_finish(h, new_parent);
if was_visible {
self.cascade_silent(h, true, &mut changed);
}
changed
}
/// `Region:SetParent` for a texture or font string; `None` detaches. The client re-links in
+5 -4
View File
@@ -34,7 +34,8 @@ fn mid_tree_hide_blocks_a_shown_grandchild() {
assert!(a.frame(grand).unwrap().effective_visible);
let changed = a.set_shown(child, false);
assert_eq!(changed, vec![child, grand]); // pre-order
// Notify order: post-order, children before their parent (`0x76adee`, then `0x76adfd`).
assert_eq!(changed, vec![grand, child]);
assert!(a.frame(root).unwrap().effective_visible);
assert!(!a.frame(child).unwrap().effective_visible);
assert!(!a.frame(grand).unwrap().effective_visible);
@@ -44,7 +45,7 @@ fn mid_tree_hide_blocks_a_shown_grandchild() {
);
let changed = a.set_shown(child, true);
assert_eq!(changed, vec![child, grand]);
assert_eq!(changed, vec![grand, child]);
assert!(a.frame(grand).unwrap().effective_visible);
}
@@ -58,10 +59,10 @@ fn hidden_grandchild_stays_hidden_when_ancestor_reshows() {
a.set_shown(grand, false);
let changed = a.set_shown(root, false);
// grand is already invisible, so the hide does not report it.
assert_eq!(changed, vec![root, child]);
assert_eq!(changed, vec![child, root]);
let changed = a.set_shown(root, true);
assert_eq!(changed, vec![root, child]);
assert_eq!(changed, vec![child, root]);
assert!(a.frame(child).unwrap().effective_visible);
assert!(!a.frame(grand).unwrap().effective_visible);
}
+6 -3
View File
@@ -6,7 +6,7 @@ use bevy::render::renderer::RenderDevice;
use crate::art_scope::{ArtScope, ArtSlot};
use benilla_assets::{AssetSet, RenderConfig, WorldAssets};
use benilla_formats::open_chain;
use benilla_formats::Chain;
/// Opens the patch chain at startup and inserts the shared [`WorldAssets`] and [`RenderConfig`].
pub(crate) struct AssetPlugin;
@@ -65,8 +65,11 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
.unwrap_or(1);
commands.insert_resource(RenderConfig { unload_budget });
match open_chain(&data) {
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)),
match Chain::open_client(&data) {
Ok(chain) => {
info!("client data: locale {}", chain.locale().code());
commands.insert_resource(WorldAssets::open(chain, light_buf));
}
Err(e) => error!("failed to open client data: {e:#}"),
}
}
+137 -8
View File
@@ -53,8 +53,8 @@ fn valid_auth_host(host: &str) -> bool {
})
}
fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
ensure!(data.is_dir(), "vanilla Data directory was not found");
fn mounted_archives(data: &Path) -> Result<Vec<std::path::PathBuf>> {
ensure!(data.is_dir(), "Data directory was not found");
let mut names = HashSet::new();
let mut files = Vec::new();
for entry in fs::read_dir(data)? {
@@ -82,13 +82,27 @@ fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
);
files.push(entry.path());
}
files.sort();
Ok(files)
}
fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
let files = mounted_archives(data)?;
let names: HashSet<_> = files
.iter()
.map(|p| {
p.file_name()
.unwrap()
.to_string_lossy()
.to_ascii_lowercase()
})
.collect();
for base in BASE_ARCHIVES {
ensure!(
names.contains(*base),
"missing vanilla base archive: {base}"
);
}
files.sort();
// Modified installs can mimic vanilla archive names; check the winning 5875 Spell.dbc too.
let mut spell_archives: Vec<_> = files.iter().collect();
// Match benilla-formats' vanilla mount order, including speech2's final priority.
@@ -133,6 +147,44 @@ fn validate_data(data: &Path) -> Result<Vec<std::path::PathBuf>> {
Ok(files)
}
/// Only mounted archives from the optional 1.12 Russian overlay; unrelated locale files stay out.
fn russian_archives(data: &Path) -> Result<Vec<std::path::PathBuf>> {
let mut folders = Vec::new();
for entry in fs::read_dir(data)? {
let entry = entry?;
if entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case("ruRU")
&& entry.path().is_dir()
{
folders.push(entry.path());
}
}
ensure!(folders.len() <= 1, "duplicate ruRU language folders");
let Some(folder) = folders.first() else {
return Ok(Vec::new());
};
let archives = mounted_archives(folder)?;
ensure!(
!archives.is_empty(),
"ruRU language pack has no mounted archives"
);
for archive in &archives {
let archive = benilla_mpq::Archive::open(archive)?;
if archive.contains(r"DBFilesClient\Spell.dbc") {
let bytes = archive.read_file(r"DBFilesClient\Spell.dbc")?;
ensure!(
bytes.len() >= 20
&& &bytes[..4] == b"WDBC"
&& u32::from_le_bytes(bytes[8..12].try_into().unwrap()) == 173,
"ruRU Spell.dbc is not the vanilla 5875 layout"
);
}
}
Ok(archives)
}
fn ensure_outside(input: &Path, output: &Path) -> Result<()> {
let input = input.canonicalize()?;
let absolute = if output.is_absolute() {
@@ -143,12 +195,26 @@ fn ensure_outside(input: &Path, output: &Path) -> Result<()> {
let parent = absolute
.parent()
.context("output needs a parent directory")?;
fs::create_dir_all(parent)?;
let output = if absolute.exists() {
absolute.canonicalize()?
} else {
parent
// Resolve through the nearest existing ancestor without creating anything in an input.
let mut existing = parent;
while !existing.exists() {
existing = existing
.parent()
.context("output has no existing ancestor")?;
}
let suffix = parent.strip_prefix(existing)?;
ensure!(
!suffix
.components()
.any(|c| matches!(c, std::path::Component::ParentDir)),
"a nonexistent output parent cannot contain .."
);
existing
.canonicalize()?
.join(suffix)
.join(absolute.file_name().context("output needs a filename")?)
};
ensure!(
@@ -174,8 +240,10 @@ pub fn pack(source: &Path, output: &Path) -> Result<()> {
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF-8 patch name"))?;
ensure!(patch.file_type()?.is_dir() && patch_name(&name),
"unsupported vanilla patch source: {name}; locale subdirectories are not supported by the client");
ensure!(
patch.file_type()?.is_dir() && patch_name(&name),
"unsupported vanilla patch source: {name}; expected a Data/patch-? source directory"
);
let target = output.join("Data").join(format!("{name}.MPQ"));
ensure!(
!target.exists(),
@@ -274,6 +342,7 @@ pub fn validate_distribution(root: &Path) -> Result<()> {
"MoonWell binary is not a native executable"
);
validate_data(&root.join("Data"))?;
russian_archives(&root.join("Data"))?;
ensure!(
root.join("realmlist.wtf").is_file(),
"realmlist.wtf is missing"
@@ -294,7 +363,12 @@ pub fn stage(
"auth host must be hostname or IPv4[:port]"
);
let archives = validate_data(data)?;
let localized = russian_archives(data)?;
ensure_outside(data, output)?;
if let Some(archive) = localized.first() {
// A locale directory may point outside Data; it remains a read-only input as well.
ensure_outside(archive.parent().context("language pack directory")?, output)?;
}
ensure_outside(binary, output)?;
if let Some(source) = source {
ensure_outside(source, output)?;
@@ -311,7 +385,7 @@ pub fn stage(
),
"use a MoonWell player executable or .app"
);
fs::create_dir(output)?;
fs::create_dir_all(output)?;
if binary.is_dir() {
for entry in WalkDir::new(binary).follow_links(false) {
let entry = entry?;
@@ -350,6 +424,16 @@ pub fn stage(
fs::copy(&archive, dest)?;
}
}
if !localized.is_empty() {
let locale_data = target_data.join("ruRU");
fs::create_dir(&locale_data)?;
for archive in localized {
fs::copy(
&archive,
locale_data.join(archive.file_name().expect("archive name")),
)?;
}
}
fs::write(
output.join("realmlist.wtf"),
format!("set realmlist {host}\n"),
@@ -414,6 +498,10 @@ mod tests {
crate::vanilla_mpq::normalize(&data.join(name)).unwrap();
}
let original = fs::read(data.join("patch-m.mpq")).unwrap();
let locale = data.join("RURU");
fs::create_dir(&locale).unwrap();
fs::copy(data.join("dbc.mpq"), locale.join("patch-1.MPQ")).unwrap();
fs::write(locale.join("notes.txt"), b"not a mounted archive").unwrap();
let source = temp.path().join("src");
fs::create_dir_all(source.join("Data/patch-M")).unwrap();
fs::write(source.join("Data/patch-M/Test.txt"), b"custom").unwrap();
@@ -422,6 +510,11 @@ mod tests {
let out = temp.path().join("out");
stage(&binary, &data, &out, Some(&source), "127.0.0.1:3724").unwrap();
assert_eq!(fs::read(data.join("patch-m.mpq")).unwrap(), original);
assert_eq!(
fs::read(out.join("Data/ruRU/patch-1.MPQ")).unwrap(),
fs::read(locale.join("patch-1.MPQ")).unwrap()
);
assert!(!out.join("Data/ruRU/notes.txt").exists());
let archive = benilla_mpq::Archive::open(out.join("Data/patch-M.MPQ")).unwrap();
assert_eq!(archive.read_file("Test.txt").unwrap(), b"custom");
assert_eq!(
@@ -450,4 +543,40 @@ mod tests {
.unwrap();
assert!(validate_data(temp.path()).is_err());
}
#[test]
fn a_wrath_spell_table_in_a_russian_overlay_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let locale = temp.path().join("ruRU");
fs::create_dir(&locale).unwrap();
let mut spell = [0u8; 20];
spell[..4].copy_from_slice(b"WDBC");
spell[8..12].copy_from_slice(&234u32.to_le_bytes());
let archive = locale.join("patch-1.MPQ");
ArchiveBuilder::new()
.version(FormatVersion::V1)
.add_file_data(spell.to_vec(), r"DBFilesClient\Spell.dbc")
.build(&archive)
.unwrap();
crate::vanilla_mpq::normalize(&archive).unwrap();
assert!(russian_archives(temp.path())
.unwrap_err()
.to_string()
.contains("5875 layout"));
}
#[test]
fn rejecting_an_output_inside_an_input_creates_no_directories() {
let temp = tempfile::tempdir().unwrap();
let input = temp.path().join("install");
fs::create_dir(&input).unwrap();
assert!(ensure_outside(&input, &input.join("scratch/out")).is_err());
assert!(!input.join("scratch").exists());
let outside = temp.path().join("new-parent/out");
ensure_outside(&input, &outside).unwrap();
assert!(
!outside.parent().unwrap().exists(),
"validation is read-only"
);
}
}
+2 -1
View File
@@ -106,7 +106,7 @@
- **benilla-adt** (lib.rs): combined_alpha
- **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim creature_type cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui
- **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories cfg_configs chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen locale lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_creature_type race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map
- **benilla-m2** (lib.rs): error model skin tests track
- **benilla-mpq** (lib.rs): crypto
- **benilla-protocol** (lib.rs): auth events guid messages wire world
@@ -304,6 +304,7 @@
- `WOW_LIVE_SHOT_AT` — benilla-app/src/capture/live_shot.rs
- `WOW_LIVE_SHOT_COUNT` — benilla-app/src/capture/live_shot.rs
- `WOW_LIVE_SHOT_EVERY` — benilla-app/src/capture/live_shot.rs
- `WOW_LOCALE` — benilla-formats/src/locale.rs
- `WOW_LOGIN_SHOT` — benilla-world/src/bgwin.rs
- `WOW_LOGIN_SHOT_OUT` — benilla-app/src/login/screen.rs
- `WOW_LOGIN_SMOKE` — benilla-app/src/char_select/mod.rs, benilla-app/src/login/mod.rs, benilla-app/src/login/smoke.rs, benilla-world/src/bgwin.rs