Merge upstream main (samwhosung/benilla fd386e75) into lighting

71 upstream commits: the repo-wide comment rewrite, the hover highlight as the
scene ambient, M2 Mod/Mod2x batches drawing the bare texel, test and contribution
changes. Conflicts were comment-vs-code in 36 files; where upstream changed only
comments our code was kept, and wow_model.wgsl carries both our lighting lanes
and upstream's new unlit/Mod/Mod2x paths.

Follow-ups folded in:
- our nine Advanced Graphics / water / fog options tests take upstream's
  wow_data_or_skip! guard
- PostUpdate ambiguity ceiling re-measured under upstream's new census: 466,
  with the per-family breakdown in the comment
- LIGHTING.md: new upstream base

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 00:18:30 +01:00
co-authored by Claude Opus 5.5
1454 changed files with 92823 additions and 127116 deletions
@@ -12,6 +12,7 @@ fn action_ids(s: &mut UiScript) -> Vec<u32> {
/// slots drawing no icon, and a physical click queuing the right UseAction id.
#[test]
fn shipped_action_bar_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
@@ -226,6 +227,7 @@ fn load_action_bar(s: &UiScript) {
/// `ACTIONBAR_UPDATE_STATE` checks the ring, and `IsUsableAction`'s OOM pair blue-tints the icon.
#[test]
fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -355,6 +357,7 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
/// start behind the new epoch.
#[test]
fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -424,6 +427,7 @@ fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() {
/// under the icon), which is exactly why the order is pinned end-to-end through the real XML.
#[test]
fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -496,6 +500,7 @@ fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() {
/// `ui_models`' own tests.)
#[test]
fn a_cooldown_count_addons_hook_leaves_the_sweep_running() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -612,6 +617,7 @@ const COOLDOWN_COUNT_HOOK: &str = r#"
/// cooldown came out at `button + 3` — over the count text the hook hangs at `button + 2`.
#[test]
fn a_cooldown_count_draws_over_the_bonus_bars_sweep() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -688,6 +694,7 @@ fn a_cooldown_count_draws_over_the_bonus_bars_sweep() {
/// registration set is consulted.
#[test]
fn a_right_click_on_an_action_button_uses_the_action() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -740,6 +747,7 @@ fn a_right_click_on_an_action_button_uses_the_action() {
/// unit tests directly — the modifier-key mirror gating `PickupAction` vs `UseAction`, end to end.
#[test]
fn shift_click_picks_up_not_uses() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -799,6 +807,7 @@ fn shift_click_picks_up_not_uses() {
/// way to rearrange it at all.
#[test]
fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -873,6 +882,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
/// the one gesture (0218 §4: "a drag-swap is two sends, never atomic").
#[test]
fn drag_drop_onto_another_button_hops_the_displaced_action() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -945,6 +955,7 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() {
/// the only order the runtime can produce.
#[test]
fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -1046,6 +1057,7 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
/// macro on the bar was nameless.
#[test]
fn macro_name_line_follows_get_action_text_through_the_xml() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{MacroState, MacroView};
let mut s = UiScript::new().unwrap();
@@ -1319,6 +1331,7 @@ fn shipped_bag_frame_drives_end_to_end() {
/// a usable/cooldown event with empties on the bar — and asserts no empty well gains a solid.
#[test]
fn state_events_leave_empty_wells_untinted() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -1389,6 +1402,7 @@ fn state_events_leave_empty_wells_untinted() {
/// never the colour).
#[test]
fn an_occupied_slot_going_empty_leaves_no_white_plate() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
@@ -1461,6 +1475,7 @@ fn an_occupied_slot_going_empty_leaves_no_white_plate() {
/// the visible bar shows.
#[test]
fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -1523,6 +1538,7 @@ fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() {
/// forever, where nil fails loudly. Pinned so a later "completeness" pass cannot quietly add it.
#[test]
fn the_reference_action_bar_constants_are_defined() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_action_bar(&s);
@@ -1578,6 +1594,7 @@ fn the_reference_action_bar_constants_are_defined() {
/// full thing, or 48 `inherits=` sites across four files silently lose their handlers.
#[test]
fn both_reference_action_button_templates_are_inheritable() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_action_bar(&s);
@@ -1662,6 +1679,7 @@ fn both_reference_action_button_templates_are_inheritable() {
/// every extra bar ships off and nothing has claimed a page yet.
#[test]
fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
@@ -1837,6 +1855,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
/// pressed the instant you enter stealth already drives the stealth page.
#[test]
fn bonus_bar_slides_up_with_sound_and_down_without() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
@@ -2059,6 +2078,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
/// quirk (1524).
#[test]
fn bonus_bar_turnaround_continues_from_position() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
@@ -2141,6 +2161,7 @@ fn bonus_bar_turnaround_continues_from_position() {
/// actually draws, which separates them — the insets are behaviour here, not decoration.
#[test]
fn the_page_arrows_do_not_steal_each_other_s_clicks() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
+352 -97
View File
@@ -27,14 +27,13 @@
//!
//! ## The load order is the reference's, not a topological sort
//!
//! `AddOn_Load 0x51f240` is **recursive**, and wow-5875-re has it byte-verified
//! (`system/ui/ui.md`, the SavedVariables §5 quad): for each addon, in order —
//! `AddOn_Load 0x51f240` is **recursive**: for each addon, in order —
//! **OptionalDeps first (failures ignored)** → **RequiredDeps (a failure ABORTS this addon's
//! load)** → **its own `.toc`-listed files, in listed order**. We match that shape, because a
//! pre-sorted flat list cannot express "a missing hard dependency drops exactly this addon and
//! nothing else".
//!
//! **Our own interface is not an addon for lifecycle purposes.** The same source records that
//! **Our own interface is not an addon for lifecycle purposes.** In the reference,
//! *FrameXML does not go through `AddOn_Load`, so it gets no `ADDON_LOADED`* — and `benilla.toc`
//! is excluded exactly as FrameXML is. Structurally, not by a filter: it loads through
//! [`super::manifest::load_ingame_ui`], while [`Walk`] only ever walks what [`discover`] found.
@@ -47,7 +46,7 @@
//! key-binding table the Key Bindings window edits, so an addon's binding is a row like any
//! other; the app's dispatch runs its Lua body ([`crate::bindings`]).
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use bevy::prelude::*;
@@ -187,11 +186,10 @@ impl Addon {
///
/// `path` is the entry already resolved into the install's path space, which is the only thing
/// the client names a file chunk after: `"@%s"` (`0x8716e0`) over the resolved path, built by
/// `0x704bc0` for every `.lua` the loader touches (wow-re
/// `ui/scratch/include-lua-dispatch.md` §7). So an addon's file is `Interface\AddOns\<Folder>
/// \<File>` and a chain file is `Interface\FrameXML\…` — the second being both what the real
/// client names it and what keeps FrameXML *out* of the `\AddOns\` pattern the Ace2 family
/// matches against, because FrameXML is not an addon.
/// `0x704bc0` for every `.lua` the loader touches. So an addon's file is
/// `Interface\AddOns\<Folder>\<File>` and a chain file is `Interface\FrameXML\…` — the second
/// being both what the real client names it and what keeps FrameXML *out* of the `\AddOns\`
/// pattern the Ace2 family matches against, because FrameXML is not an addon.
///
/// **One rule for both doors, which is the point** (decision 2155). `Loader::run` names a
/// `<Script file=>` chunk by exactly this rule over exactly this space, so a manifest entry and
@@ -297,10 +295,10 @@ impl Addon {
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
// `FrameXML_Debug`'s trace lines, if the player turned it on (2160). Log-only, and
// never retained: the reference files them into the same per-document record as the
// errors, but at severity 0 and *where that record surfaces is an open question in
// wow-re* (`framexml-debug-trace-flag.md`, and `xml-template-name-lookup.md` §9 for
// severity 1). So they go where a trace can go without claiming a surface we have not
// derived — and the switch that produces them is off unless an addon asks.
// errors, but at severity 0 (`0x6ee2bc`), and *where that record surfaces is an open
// question*, for severity 1 as well. So they go where a trace can go without claiming a
// surface we have not derived — and the switch that produces them is off unless an
// addon asks.
for t in &report.traces {
info!("ui_script({}/{file}): {t}", self.name);
}
@@ -387,16 +385,15 @@ fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
/// an addon's file, `Interface/FrameXML/…` for one that walked out of the AddOns tree. The
/// reference has exactly one such space — `0x647e60` answers a name from a hash index of the
/// install tree keyed by **install-root-relative path**, and then, attempt #4, from the MPQ chain
/// (wow-re `ui/scratch/include-lua-dispatch.md` §4.1, VERIFIED; loose *before* archive, which is
/// the order below). Two spaces is what this client had, and it cost three separate things:
/// (loose *before* archive, which is the order below). Two spaces is what this client had, and it
/// cost three separate things:
///
/// - `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml`, a `.toc` line in `Auctioneer` and
/// in `BeanCounter`, collapses to `Interface/AddOns/Blizzard_AuctionUI/…` — a folder that is a
/// `.pub` decoy on disk with the real files inside `patch.MPQ`. Only the chain leg has it.
/// - `..\..\FrameXML\Fonts.xml`, a `.toc` line in `JIM_toolbox` and in `SpecialTalentUI`,
/// collapses out of the AddOns tree entirely, to `Interface/FrameXML/Fonts.xml`. Same leg.
/// (wow-re records both by name as **RESOLVING** on the real client —
/// `ui/scratch/xml-toc-path-resolution.md` §5 cases 1 and 2.)
/// (Both **RESOLVE** on the real client: `0x6ede10` collapses the `..` before the open.)
/// - and the one that was invisible: with the addon space rooted at the AddOns folder, a
/// `<Script file=>` chunk was NAMED after that space — `@AtlasLoot\Core\AtlasLoot.lua` — while
/// a `.toc`-listed one was named `@Interface\AddOns\AtlasLoot\…`. See [`Addon::chunk_name`].
@@ -504,13 +501,13 @@ fn chain_addons() -> Vec<Addon> {
/// Every registered addon, in the reference's own **registration order**: the archive pass first,
/// then the loose folders (decision 2175).
///
/// **Two passes, archive before loose, and the first pass wins a duplicate.** Byte-read in wow-re
/// `system/ui/scratch/addon-registry-scan-and-order.md` §1–§5: `AddOn_ScanAddOnDir 0x51c760` runs
/// **Two passes, archive before loose, and the first pass wins a duplicate.**
/// `AddOn_ScanAddOnDir 0x51c760` runs
/// `0x401470` over each mounted archive's `(listfile)` (`0x648fb0`, textual line order) and only
/// then `0x42ad10`'s `FindFirstFileW` walk of the loose directories; both funnel into
/// `0x51c9b0`, which probes the name hash and **returns immediately on a hit** (`0x51ca10`), and
/// links each new record at the TAIL (`0x521ad0` mode 2 — a tail insert with no comparison of any
/// kind, so registry order *is* registration order). §10 measures the consequence on a stock
/// kind, so registry order *is* registration order). Measured on a stock
/// install: the twelve `Blizzard_*` `.toc`s are the only ones in any archive, so Blizzard occupies
/// registry positions 1..12 and third-party always follows.
///
@@ -521,11 +518,12 @@ fn chain_addons() -> Vec<Addon> {
///
/// The load walk barely notices (all twelve are `## LoadOnDemand: 1`, so `0x51f600` skips them),
/// but the registry order is what the Lua index space is built from, and it is what decides which
/// addon's copy of a shared global wins — the last writer, per §9.
/// addon's copy of a shared global wins — the last writer, in the one shared Lua state
/// (`0x7040d0`).
fn discover() -> Vec<Addon> {
// PASS 1 — the archive. `BLIZZARD_ADDONS` is in ascending name order, which is also the order
// the shipped `patch.MPQ` listfile happens to carry those twelve lines in (§10, measured). The
// note is explicit that a re-implementation may NOT rely on a listfile being sorted, so this is
// the shipped `patch.MPQ` listfile happens to carry those twelve lines in (measured). A
// re-implementation may NOT rely on a listfile being sorted, so this is
// our deterministic choice for a set we enumerate ourselves, not a claim about the format.
let mut found = chain_addons();
// PASS 2 — the loose folders, minus anything the archive already registered.
@@ -541,11 +539,10 @@ fn discover() -> Vec<Addon> {
/// **The reference's directory-listing order, as a sort key** — the one thing 2166 §7 left open.
///
/// The reference's loose-folder pass is `0x42ad10`'s kernel32 `FindFirstFileW`/`FindNextFileW`
/// walk (wow-re `system/ui/scratch/addon-registry-scan-and-order.md`), and that walk **imposes no
/// ordering of its own**: the client never sorts, so its walk order is whatever the host
/// filesystem hands back. wow-re states the consequence outright — *"a re-implementation cannot be
/// walk, and that walk **imposes no ordering of its own**: the client never sorts, so its walk
/// order is whatever the host filesystem hands back. So a re-implementation cannot be
/// order-identical to the reference, because the reference's order is not a property of the
/// reference."*
/// reference.
///
/// So there is no order to copy, and exactly one order worth choosing: **NTFS's**. It is the order
/// every 1.12 install actually walked in, because that is the filesystem the client shipped on, and
@@ -564,8 +561,8 @@ fn discover() -> Vec<Addon> {
/// positions move** — `Fubar_*` interleaves with `FuBar_*` instead of following it wholesale,
/// `oRA2` climbs 31 places, `zBar` overtakes `Zorlen`, and `_LazyPig`/`_Nameplates` fall from the
/// middle to dead last. Since the client runs every addon in one Lua state and adds no versioning
/// (wow-re §9: *which provider of a shared library wins is exactly "whichever addon's files run
/// last"*), that reshuffle decides which copy of `Tablet-2.0` a corpus of seventy providers ends up
/// (`0x7040d0`: which provider of a shared library wins is exactly whichever addon's files run
/// last), that reshuffle decides which copy of `Tablet-2.0` a corpus of seventy providers ends up
/// with.
///
/// The corpus corroborates the mechanism from the other side: the `!`-prefix convention
@@ -773,16 +770,21 @@ pub(crate) struct InstalledAddOn {
pub(crate) url: Option<String>,
pub(crate) dependencies: Vec<String>,
/// `## Interface` as the version gate reads it (decision 1292): the leading integer, `0`
/// when absent — and now ENFORCED by the load walk when `checkAddonVersion` is on,
/// superseding 1191 §6's report-only interim (the RE answer it was waiting for landed:
/// wow-re `addon-version-gate.md`).
/// when absent — and now ENFORCED by the load walk when `checkAddonVersion` is on
/// (`0x51e876`), superseding 1191 §6's report-only interim.
pub(crate) interface: u32,
/// `## LoadOnDemand: 1` — shown as a status hint rather than a checkbox state, because a
/// LoadOnDemand addon is not "off", it is waiting for a `LoadAddOn` call (1191 §6).
/// Read by the char-select AddOns screen's rows (`char_select::addons`).
pub(crate) load_on_demand: bool,
/// From this character's enable file; an addon the file never mentions is **enabled** (1191 §7).
pub(crate) enabled: bool,
/// `## DefaultState:` — what this addon is for a character with no opinion, and the tie-break
/// when the realm's characters disagree.
///
/// **This row carries no enable bit**, deliberately (decision 2311). It used to, read out of
/// one character's file with "absent means enabled" baked in — which is the bug: an absent
/// row is not enabled, it is [`EnableStore::enabled_for`]'s question, and the store needs the
/// realm's whole character list to answer it. Metadata here, state there.
pub(crate) default_state: bool,
}
impl InstalledAddOn {
@@ -814,14 +816,13 @@ impl InstalledAddOn {
/// The array's *membership* needs nothing here: it excludes the addons the server hid, which on a
/// stock install is Blizzard's twelve, and this walk is the player's own folder only — they were
/// never in it.
pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAddOn> {
let disabled = disabled_set(identity);
pub(crate) fn installed_rows() -> Vec<InstalledAddOn> {
// The player's own folder only: the glue's list is what a player can toggle, and the
// reference's Blizzard addons are not in it.
let mut rows: Vec<InstalledAddOn> = discover_folder()
.into_iter()
.map(|addon| InstalledAddOn {
enabled: !disabled.contains(&addon.name.to_ascii_lowercase()),
default_state: addon.toc.default_state(),
title: addon.toc.directive("Title").map(str::to_owned),
notes: addon.toc.directive("Notes").map(str::to_owned),
url: addon.toc.directive("URL").map(str::to_owned),
@@ -843,18 +844,131 @@ pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAdd
rows
}
/// The lowercased names this character has turned off.
fn disabled_set(identity: Option<&(String, String)>) -> HashSet<String> {
enable_state_path(identity)
.and_then(|p| std::fs::read(p).ok())
.map(|b| {
parse_enable_state(&benilla_ui::source::decode(&b))
.into_iter()
.filter(|(_, on)| !on)
.map(|(n, _)| n.to_ascii_lowercase())
.collect()
})
.unwrap_or_default()
// ── The enable store — the reference's `ADDONSTATELIST` ──────────────────────────────────────────
/// **One node per character on the realm's character list, each holding the explicit rows that
/// character's `AddOns.txt` carried** — the reference's `ADDONSTATELIST` (anchor `0xbe1bd0`, head
/// `ds:0xbe1bd8`), which `AddOnList_LoadCharacter 0x51ebe0` fills one node per character at
/// char-list population.
///
/// The node set is the **char-list, in wire order, rebuilt whole** (decision 2316): the per-record
/// callback `0x472300` is handed to an enumerator that walks every `SMSG_CHAR_ENUM` record with no
/// filter and no early-out, and its driver destroys every existing node first (`0x51f0b0(NULL)`).
///
/// **A node with no file is EMPTY, not absent**, and the difference is the whole point: an empty
/// node contributes no opinion to the aggregate, but it is still a character whose enable bit has
/// to be answered — and the reference answers it from the *other* characters, never with a bare
/// "enabled". That was the bug this type was written for (decision 2311): benilla resolved an
/// absent row as enabled, so creating a character re-enabled every addon the player had just
/// turned off, on every character at once.
#[derive(Default)]
pub(crate) struct EnableStore {
/// `(character, explicit rows)` in character-list order; the map is the node's enable hash,
/// keyed lowercased because every compare in the reference is `SStrCmpI`.
nodes: Vec<(String, HashMap<String, bool>)>,
}
impl EnableStore {
/// Load a node per character, each from its own `AddOns.txt`
/// (`benilla-config/addons/<Realm>-<Char>.txt`, decision 1191 §7). A character with no file
/// still gets its node.
pub(crate) fn load(realm: &str, characters: &[String]) -> Self {
let nodes = characters
.iter()
.map(|character| {
let id = (realm.to_string(), character.clone());
let rows = enable_state_path(Some(&id))
.and_then(|p| std::fs::read(p).ok())
.map(|b| parse_enable_state(&benilla_ui::source::decode(&b)))
.unwrap_or_default();
let hash = rows
.into_iter()
// Last line wins, like the reference's hash insert (`0x51eeef`).
.map(|(name, on)| (name.to_ascii_lowercase(), on))
.collect();
(character.clone(), hash)
})
.collect();
Self { nodes }
}
/// `0x51e470(addon, NULL, useDefault = 0)` — the **explicit-only** aggregate. A node with no
/// entry for this addon is not counted at all (`0x51e5df je 0x51e60a`).
///
/// `Some(v)` is the reference's 2/0 (every counted node agrees on `v`); `None` folds its two
/// *undecided* returns together — the mixed `1` and the `total == 0` epilogue — because both
/// resolve the same way one level up, through `DefaultState`.
fn aggregate(&self, addon: &str) -> Option<bool> {
let key = addon.to_ascii_lowercase();
let mut total = 0usize;
let mut on = 0usize;
for (_, hash) in &self.nodes {
if let Some(&v) = hash.get(&key) {
total += 1;
on += usize::from(v);
}
}
match (total, on) {
(0, _) => None,
(_, 0) => Some(false),
(t, o) if o == t => Some(true),
_ => None,
}
}
/// **The bit a character actually gets** — `0x51e470(addon, character, useDefault = 1)`
/// lowered to a bool (a single-character query returns only 0 or 2).
///
/// Three cases, and the reference distinguishes the last two (decision 2316, which corrects
/// 2311's reading of them as one):
///
/// * **their file has an explicit row** → that row, and nothing else is consulted;
/// * **they have a node but no row for this addon** → [`Self::aggregate`], the explicit-only
/// fold over the character list (`0x51e5f0`'s self-recursion), falling to `## DefaultState`
/// only where that is undecided;
/// * **no node carries their name at all** → `## DefaultState`, *not* the aggregate. The walk
/// compares the name at every node and advances past each mismatch
/// (`0x51e55a jne 0x51e611`, bypassing the stop-check at `0x51e60a`), so it exhausts with
/// `total == 0` and takes that epilogue. A character the list does not carry inherits
/// nothing. `None` — nobody picked yet — is this case too.
///
/// That third arm is unreachable from our own callers: every panel column is a node, and
/// [`store_nodes`] seats the loading character. It is written faithfully anyway, because the
/// next caller would otherwise find the wrong branch sitting here and have no way to know.
pub(crate) fn enabled_for(
&self,
addon: &str,
default_state: bool,
character: Option<&str>,
) -> bool {
let Some(node) = character.and_then(|c| self.node(c)) else {
return default_state;
};
match node.get(&addon.to_ascii_lowercase()) {
Some(&explicit) => explicit,
None => self.aggregate(addon).unwrap_or(default_state),
}
}
fn node(&self, character: &str) -> Option<&HashMap<String, bool>> {
self.nodes
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(character))
.map(|(_, hash)| hash)
}
}
/// The store's node set for a load: the realm's character list, with the loading character
/// appended when the caller had no list to give (a test, a capture, a `.go`-style entry) — so a
/// one-identity caller still gets its own file honoured, and nothing else.
fn store_nodes(identity: Option<&(String, String)>, roster: &[String]) -> Vec<String> {
let mut names = roster.to_vec();
if let Some((_, character)) = identity {
if !names.iter().any(|n| n.eq_ignore_ascii_case(character)) {
names.push(character.clone());
}
}
names
}
/// Write a character's enable state — the AddOns screen's write (decision 1197) and, since 2139,
@@ -863,10 +977,10 @@ fn disabled_set(identity: Option<&(String, String)>) -> HashSet<String> {
/// **Merges rather than replaces, because that is what the reference's writer structurally IS.**
/// `0x51ef20` walks the in-memory enable **hash** and emits one `"%s: %s\r\n"` line per entry
/// (`0x853968`), and that hash is built by the reader `0x51ebe0` from `AddOns.txt` itself — which
/// "creates entries for every line, both states", strdup'ing each name as written. So a row for an
/// creates entries for every line, both states, strdup'ing each name as written. So a row for an
/// addon that is not installed right now was loaded, is never removed, and is written straight back
/// out; and the spelling that survives is the FILE's, not any folder's, because the folder list
/// never enters the hash at all (wow-5875-re `system/ui/scratch/addon-enable-store.md` §5/§6).
/// never enters the hash at all.
///
/// Both halves matter and both were divergent on the logout path, which used to rebuild the file
/// from `addon_enable_states()` — the installed-folder registry. One logout erased
@@ -932,9 +1046,19 @@ pub(super) fn save_addon_variables(script: &mut UiScript, identity: Option<&(Str
continue;
}
let Some(dir) = dir else { continue };
let body = script.saved_variables_text_for(names);
let path = dir.join(format!("{name}.lua"));
match crate::local_state::write_atomic(&path, &format!("{SAVED_HEADER}{body}")) {
if script.saved_file_held(&path) {
// It failed to load this session: its globals are the addon's defaults, and
// writing them would replace the player's file with them.
warn!(
"ui_script: {} did not load this session — left as it is",
path.display()
);
continue;
}
let mut body = SAVED_HEADER.as_bytes().to_vec();
body.extend(script.saved_variables_bytes_for(names));
match crate::local_state::write_atomic_bytes(&path, &body) {
Ok(()) => info!("ui_script: wrote {}", path.display()),
Err(e) => warn!("ui_script: cannot write {}: {e}", path.display()),
}
@@ -967,6 +1091,7 @@ const SAVED_HEADER: &str = "\
pub(super) fn load_third_party(
script: &mut UiScript,
identity: Option<&(String, String)>,
roster: &[String],
version_check: bool,
) -> Vec<String> {
let addons = discover();
@@ -976,15 +1101,19 @@ pub(super) fn load_third_party(
let mut infos: Vec<_> = addons.iter().map(info_for).collect();
// The chain-sourced rows read through the reference's own store (1957).
script.set_addon_chain_reader(Box::new(super::reference_ui::read));
if let Some(text) = enable_state_path(identity).and_then(|p| std::fs::read_to_string(p).ok()) {
for (name, enabled) in parse_enable_state(&text) {
if let Some(i) = infos
.iter_mut()
.find(|i| i.name.eq_ignore_ascii_case(&name))
{
i.enabled = enabled;
}
}
// Every addon's enable bit, through the reference's own query (decision 2311): this
// character's explicit `AddOns.txt` row when they have one, else what the realm's other
// characters agree on, else the manifest's `## DefaultState`. Reading an absent row as a
// bare "enabled" is what re-enabled every addon the moment a character was created.
let store = EnableStore::load(
identity
.map(|(realm, _)| realm.as_str())
.unwrap_or_default(),
&store_nodes(identity, roster),
);
let character = identity.map(|(_, c)| c.as_str());
for (info, addon) in infos.iter_mut().zip(addons.iter()) {
info.enabled = store.enabled_for(&info.name, addon.toc.default_state(), character);
}
let disabled_names: HashSet<String> = infos
.iter()
@@ -1072,7 +1201,7 @@ impl Walk {
/// Load one addon by name, its dependencies first. `Err` means "this addon did not load", and
/// is what a hard dependency's failure propagates.
///
/// The order inside is `AddOn_Load 0x51f240`'s, byte-verified in wow-5875-re (`system/ui/ui.md`):
/// The order inside is `AddOn_Load 0x51f240`'s:
///
/// > OptionalDeps (failures ignored) → RequiredDeps (a failure aborts) → **this addon's own
/// > `.toc`-listed files, in listed order** (`0x51f3fa`) → `Bindings.xml` (`0x51f400`) → the
@@ -1206,8 +1335,8 @@ impl Walk {
mod tests {
/// **The walk order is NTFS's, not byte order** — the fact 2166 §7 left open, pinned here.
///
/// The reference's loose pass is `FindFirstFileW` and sorts nothing (wow-re
/// `addon-registry-scan-and-order.md`), so its order is the host filesystem's; NTFS's `$I30`
/// The reference's loose pass is `FindFirstFileW` and sorts nothing
/// (`0x42ad10`), so its order is the host filesystem's; NTFS's `$I30`
/// collation is the one we adopt, because it is the order the entire vanilla corpus was
/// written against. Two rules distinguish it from `str`'s own `Ord`, and this asserts both:
/// **case folds away** (so a lowercase-initial name sorts among its letter's block rather
@@ -1328,7 +1457,7 @@ mod tests {
// No `## Title` at all — sorts under its folder name, the comparator's own fallback.
write_addon(&home, "bravo", "## Interface: 11200\n", &[]);
let rows = installed(None);
let rows = installed_rows();
assert_eq!(
rows.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
vec!["Zulu", "bravo", "Mike", "Alpha"],
@@ -1454,7 +1583,7 @@ mod tests {
"benilla.toc lists a Blizzard addon eagerly; the reference loads every one on demand"
);
// The glue's list is the player's folder alone.
assert!(installed(None)
assert!(installed_rows()
.iter()
.all(|a| !a.name.starts_with("Blizzard_")));
}
@@ -1597,10 +1726,10 @@ mod tests {
/// `IsAddOnLoadOnDemand(nil)` and AtlasLoot's AceDB keyed itself on a raw traceback. 80 of the
/// 219-addon corpus ship at least one `<Script file=>`.
///
/// The reference names both the same way and the note says why: a `.toc` line resolves against
/// `Interface\AddOns\<Addon>\` and a bare `<Script file=X>` against `dirname(referrer)` —
/// the same directory — and the chunk name is `"@%s"` over the resolved path in both cases
/// (wow-re `ui/scratch/xml-toc-path-resolution.md` §1, `include-lua-dispatch.md` §7).
/// The reference names both the same way: a `.toc` line resolves against
/// `Interface\AddOns\<Addon>\` and a bare `<Script file=X>` against `dirname(referrer)`
/// (`0x6ee07b`) — the same directory — and the chunk name is `"@%s"` over the resolved path in
/// both cases (`0x704bc0`).
#[test]
fn an_xml_referenced_lua_is_named_like_a_toc_listed_one() {
let tmp =
@@ -2010,8 +2139,8 @@ mod tests {
/// **`benilla` never appears in an `ADDON_LOADED`.**
///
/// FrameXML does not go through `AddOn_Load` and gets no such event (wow-5875-re
/// `system/ui/ui.md`), and our own interface is FrameXML's counterpart. An addon that watches
/// In the reference, FrameXML does not go through `AddOn_Load` and gets no such event, and
/// our own interface is FrameXML's counterpart. An addon that watches
/// `ADDON_LOADED` to detect *another* addon would otherwise see a name no reference client
/// ever sends.
///
@@ -2102,7 +2231,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script.run("EventLog = {}").unwrap();
let failures = load_third_party(&mut script, None, true);
let failures = load_third_party(&mut script, None, &[], true);
assert!(failures.is_empty(), "load errors: {failures:?}");
crate::ui_script::finish_ui_load(&mut script);
@@ -2175,7 +2304,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
let failures = load_third_party(&mut script, None, true);
let failures = load_third_party(&mut script, None, &[], true);
assert!(
failures.iter().any(|f| f.contains("NeedsOld")),
"the gated dependency is the dependent's failure: {failures:?}"
@@ -2199,7 +2328,7 @@ mod tests {
// Force-load: the same folder, the checkbox's other state — everything loads.
let mut open = UiScript::new().unwrap();
let failures = load_third_party(&mut open, None, false);
let failures = load_third_party(&mut open, None, &[], false);
assert!(failures.is_empty(), "force-load load errors: {failures:?}");
assert_eq!(
open.eval::<bool>(
@@ -2273,7 +2402,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
let failures = load_third_party(&mut script, None, true);
let failures = load_third_party(&mut script, None, &[], true);
assert!(failures.is_empty(), "load errors: {failures:?}");
// Discovery saw it at all — the `.toc` decoded rather than read as absent.
@@ -2365,8 +2494,7 @@ mod tests {
/// **The client registers `GetAddOnInfo` twice, as two different functions**, and this test
/// used to assert the wrong one. It was written off `Interface\GlueXML\AddonList.lua`, which
/// describes glue's `0x46d460`: eight values, `url` at slot 4, `newVersion` at 8. The in-game
/// binding `0x48e390` answers seven, with **`enabled`** at slot 4 and no `url` at all
/// (wow-re `system/ui/scratch/addon-version-gate.md`).
/// binding `0x48e390` answers seven, with **`enabled`** at slot 4 and no `url` at all.
///
/// Its own doc comment named the failure it then failed to catch — *"a row that shifts by one
/// puts an addon's notes in its URL field, which nothing would notice"*. The row was shifted at
@@ -2394,7 +2522,7 @@ mod tests {
&[],
);
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, None, true);
let _ = load_third_party(&mut script, None, &[], true);
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
// Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers
@@ -2496,7 +2624,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
let id = ("Realm".to_string(), "Char".to_string());
let failures = load_third_party(&mut script, Some(&id), true);
let failures = load_third_party(&mut script, Some(&id), &[], true);
assert!(
failures.is_empty(),
@@ -2554,7 +2682,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
let _ = load_third_party(&mut script, None, &[], true);
// Discovered and described, but NOT run.
// **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve
@@ -2650,7 +2778,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
let _ = load_third_party(&mut script, None, &[], true);
// The dependency loaded at startup and the registry knows it — the stamp this rests on.
assert_eq!(
@@ -2681,8 +2809,8 @@ mod tests {
/// **A `.toc` line naming a file the package does not ship does not stop a demand load**
/// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk.
///
/// The reference logs `Couldn't open %s` and carries on (wow-re
/// `xml-toc-path-resolution.md` §4). Ours sent the miss to the **script-error** channel
/// The reference logs `Couldn't open %s` and carries on
/// (`0x6edaa0`). Ours sent the miss to the **script-error** channel
/// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale:
/// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an
/// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session
@@ -2704,7 +2832,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None, true);
let _ = load_third_party(&mut script, None, &[], true);
assert_eq!(
script
@@ -2766,7 +2894,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
let id = ("Realm".to_string(), "Char".to_string());
let _ = load_third_party(&mut script, Some(&id), true);
let _ = load_third_party(&mut script, Some(&id), &[], true);
for (call, want) in [
("LoadAddOn('NoSuchAddon')", "MISSING"),
@@ -2788,6 +2916,133 @@ mod tests {
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A character with no enable file does not load what the realm has unanimously turned
/// off** — the world-entry half of the director's 2026-09-17 report (decision 2311).
///
/// The glue list showing a new character's addons as off would be worth nothing if the world
/// then loaded them anyway, so the load walk resolves through the same [`EnableStore`]: the
/// reference's `AddOn_CanLoad` check 3 is `0x51e470(name, character, useDefault = 1)`, which
/// for a character with no explicit entry takes the explicit-only aggregate over the realm's
/// other characters.
///
/// The control is the second addon: one character left it on, so the roster does **not** agree
/// about it, and the newcomer gets its manifest's `## DefaultState` instead — enabled. Without
/// that half the test would pass on a client that simply disabled everything for new
/// characters.
#[test]
fn a_character_with_no_file_inherits_the_rosters_unanimous_disable() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("inherit");
write_addon(
&home,
"Shunned",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "ShunnedRan = true")],
);
write_addon(
&home,
"Contested",
"## Interface: 11200\nran.lua\n",
&[("ran.lua", "ContestedRan = true")],
);
// Two characters who have played and saved: both turned `Shunned` off, they disagree
// about `Contested`.
for (who, contested) in [("Onemage", false), ("Onerogue", true)] {
let id = ("Realm".to_string(), who.to_string());
write_enable_state(
Some(&id),
&[("Shunned".into(), false), ("Contested".into(), contested)],
);
}
let roster = [
"Onemage".to_string(),
"Onerogue".to_string(),
"Freshling".to_string(),
];
// …and the character just created, who has no file of their own.
let fresh = ("Realm".to_string(), "Freshling".to_string());
assert!(
!enable_state_path(Some(&fresh)).unwrap().exists(),
"the premise: a newly created character has written nothing"
);
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&fresh), &roster, true);
assert_eq!(
script.eval::<bool>("return ShunnedRan == nil").ok(),
Some(true),
"the unanimous disable reaches the character who never expressed one"
);
assert_eq!(
script.eval::<bool>("return ContestedRan == true").ok(),
Some(true),
"…and a contested addon falls to its `## DefaultState`, which is enabled"
);
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// **A name the character list does not carry inherits nothing** — the third arm of
/// `0x51e470(addon, character, useDefault = 1)`, and the one 2311 got wrong (decision 2316).
///
/// 2311 read "no explicit row" and "no node" as one case and sent both to the aggregate. The
/// reference splits them: the walk compares the name at every node and advances past each
/// mismatch (`0x51e55a jne 0x51e611`, bypassing the stop-check), so an unknown name exhausts
/// the list with `total == 0` and takes the `DefaultState ? 2 : 0` epilogue. Only a character
/// that *has* a node inherits.
///
/// Unreachable from our own callers — every panel column is a node, and `store_nodes` seats
/// the loading character — so this is the falsifier standing in for the caller that does not
/// exist yet. `Known`, who has a node and no row, is the control that keeps the two arms
/// honestly distinguishable: both addons are unanimous, so an implementation that collapsed
/// the cases would answer `false` for `Stranger` too.
#[test]
fn an_unknown_character_takes_the_manifest_default_not_the_aggregate() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let (home, _h) = hermetic_root("unknown-char");
// One unanimous `disabled`, and one addon whose manifest ships `DefaultState: disabled`
// so the epilogue's two outcomes are told apart rather than both reading `true`.
write_addon(&home, "Shunned", "## Interface: 11200\n", &[]);
write_addon(
&home,
"OptIn",
"## Interface: 11200\n## DefaultState: disabled\n",
&[],
);
for who in ["Onemage", "Onerogue"] {
let id = ("Realm".to_string(), who.to_string());
write_enable_state(
Some(&id),
&[("Shunned".into(), false), ("OptIn".into(), true)],
);
}
let roster = [
"Onemage".to_string(),
"Onerogue".to_string(),
"Known".to_string(),
];
let store = EnableStore::load("Realm", &roster);
// `Known` has a node and no rows: both addons inherit their unanimous aggregate.
assert!(!store.enabled_for("Shunned", true, Some("Known")));
assert!(store.enabled_for("OptIn", false, Some("Known")));
// `Stranger` is on no node: neither aggregate reaches them — each addon answers with its
// own manifest default, which is the opposite verdict in both cases.
assert!(store.enabled_for("Shunned", true, Some("Stranger")));
assert!(!store.enabled_for("OptIn", false, Some("Stranger")));
// …and `None` (nobody picked yet) is the same epilogue.
assert!(store.enabled_for("Shunned", true, None));
assert!(!store.enabled_for("OptIn", false, None));
let _ = std::fs::remove_dir_all(home.parent().unwrap());
}
/// Enable state survives the session: `DisableAddOn` from Lua is written back in the
/// reference's own `AddOns.txt` format, and read back as a disable next time.
#[test]
@@ -2807,7 +3062,7 @@ mod tests {
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&id), true);
let _ = load_third_party(&mut script, Some(&id), &[], true);
script.run("DisableAddOn('Drop')").unwrap();
save_enable_state(&script, Some(&id));
@@ -2828,7 +3083,7 @@ mod tests {
// A fresh session reads it back and the addon stays off.
let mut next = UiScript::new().unwrap();
let _ = load_third_party(&mut next, Some(&id), true);
let _ = load_third_party(&mut next, Some(&id), &[], true);
assert_eq!(
next.eval::<bool>("return DropRan == nil").ok(),
Some(true),
@@ -2879,7 +3134,7 @@ mod tests {
// ── session one: defaults, then the addon changes them ──
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let failures = load_third_party(&mut script, Some(&id), true);
let failures = load_third_party(&mut script, Some(&id), &[], true);
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(
script.eval::<i64>("return KeeperSawAtEvent").ok(),
@@ -2894,7 +3149,7 @@ mod tests {
// ── session two: a fresh VM reads them back ──
let mut next = UiScript::new().unwrap();
next.set_screen_size(1024.0, 768.0);
let failures = load_third_party(&mut next, Some(&id), true);
let failures = load_third_party(&mut next, Some(&id), &[], true);
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(
next.eval::<i64>("return KeeperDB.count").ok(),
@@ -2923,8 +3178,8 @@ mod tests {
/// `0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB indent, a trailing comma on
/// every entry, and the file split by scope. `["t"]`'s `[1] = 1,` is the reference's shape for
/// a list too — its writer emits a bracketed key for every table shape and never a bare
/// positional entry (wow-re `system/ui/scratch/lua-table-storage-and-next-order.md` §Q5) —
/// and it reloads in index order because of what 1.12's *parser* does with it (decision 2111).
/// positional entry — and it reloads in index order because of what 1.12's *parser* does
/// with it (decision 2111).
///
/// Asserted as bytes rather than by re-reading, because "it round-trips through our own
/// loader" is exactly the check that passes for a private format. The reference's own client
@@ -2945,7 +3200,7 @@ mod tests {
);
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&id), true);
let _ = load_third_party(&mut script, Some(&id), &[], true);
script
.run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5")
.unwrap();
@@ -3007,7 +3262,7 @@ mod tests {
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, Some(&id), true);
let _ = load_third_party(&mut script, Some(&id), &[], true);
crate::ui_script::shutdown_ui_state(&mut script, Some(&id), true);
let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap();
@@ -3048,7 +3303,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, Some(&id), true);
let _ = load_third_party(&mut script, Some(&id), &[], true);
script.eval::<()>("DisableAddOn('MyAddon')").unwrap();
save_enable_state(&script, Some(&id));
@@ -3132,7 +3387,7 @@ mod tests {
AddonOrder = AddonOrder .. arg1 .. ',' end)",
)
.unwrap();
let _ = load_third_party(&mut script, None, true);
let _ = load_third_party(&mut script, None, &[], true);
// Neither ran at startup — the walk skips LoadOnDemand, which is why a cycle among them
// is never observed until something demand-loads one.
@@ -590,12 +590,13 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// At a vendor, a bag hover shows the engine-truth sell-price money row (SellPrice × stack, wow-re
/// tooltip-money.md 0x52b650@0x52e376) — or the ITEM_UNSELLABLE "No sell price" line — and arms
/// the pouch cursor (ShowContainerSellCursor → Buy over a Point base, cursor-system.md §7);
/// At a vendor, a bag hover shows the engine-truth sell-price money row (SellPrice × stack,
/// 0x52b650@0x52e376) — or the ITEM_UNSELLABLE "No sell price" line — and arms
/// the pouch cursor (ShowContainerSellCursor 0x4fa460 → Buy over a Point base);
/// leaving resets it.
#[test]
fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{MerchantState, ScriptValue, UiCursorMode};
let mut s = UiScript::new().unwrap();
@@ -734,6 +735,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
/// `this.readable → ShowInspectCursor()`); a plain item leaves the base cursor; leaving resets.
#[test]
fn readable_letter_hover_shows_the_inspect_magnifier() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::UiCursorMode;
let mut s = UiScript::new().unwrap();
@@ -1837,10 +1839,9 @@ fn a_key_dropped_on_the_button_files_itself() {
);
// And it does not RAISE either, which is the half this test could not assert until the bytes
// came back. `AddMessage`'s text is fetched through `lua_isstring 0x6f3510` whose failure edge
// is a silent jump to the function's own epilogue — no line, no error (wow-5875-re
// `addmessage-text-gate-silent-skip.md`, 4-worker cross-check). Ours took an mlua `String` and
// raised, which put a script-error dialog on an ordinary player gesture; the fix is in
// `messageframe/mod.rs::message_text` and its own test carries the full law.
// is a silent jump to the function's own epilogue (`0x79562c`) — no line, no error. Ours took
// an mlua `String` and raised, which put a script-error dialog on an ordinary player gesture;
// the fix is in `messageframe/mod.rs::message_text` and its own test carries the full law.
//
// So a full keyring is a DEAD CLICK on a real 1.12 client, and now here.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -16,53 +16,19 @@
//! The director saw the first (their `Bagnon_Core` loaded at startup, so `currentPlayer` was
//! captured nil). Fix only that and you get the second. So the tests below pin **both**, and the
//! headline is a quad count, not an error count: [`bagnon_draws_a_slot_for_every_bag_slot`] is
//! green only when 16 item-slot quads actually reach the render list.
//! green only when every live slot's quad actually reaches the render list — the backpack's 16
//! and, since the reference's own `ContainerFrame.lua` runs off the chain (1751), the keyring's 12
//! beside them (Bagnon's default bag set is `{-2, 0, 1, 2, 3, 4}`, `Bagnon.lua:25`).
//!
//! Nothing from the corpus is committed, and every test here skips cleanly on a machine without
//! it — the `ui_chat::ace_gate_tests` rule.
use std::path::{Path, PathBuf};
use std::path::Path;
use benilla_formats::addon_corpus_or_skip as corpus_or_skip;
use benilla_ui::script::{AddOnInfo, ContainerSlot, ContainerState, QuadContent, UiScript};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root, or `None` — **a skip, never a failure**: it is third-party content that is
/// deliberately not in this repo.
fn corpus() -> Option<PathBuf> {
corpus_candidates().into_iter().find(|c| c.is_dir())
}
macro_rules! corpus_or_skip {
() => {
match corpus() {
Some(root) => root,
None => {
eprintln!(
"skipping: no vanilla addon corpus — looked in {:?} (set $BENILLA_ADDON_CORPUS)",
corpus_candidates()
);
return;
}
}
};
}
/// The corpus **and** a client install — for the tests whose subject is a global that comes from
/// the reference file this client SOURCES off the player's own patch chain rather than shipping
/// ([`super::reference_ui`], decision 1234).
@@ -177,6 +143,29 @@ fn roster() -> crate::char_select::Roster {
crate::char_select::Roster::with_pending_pick(vec![row], 7)
}
/// The `"player"` snapshot the self descriptor builds when it lands — what `ui_unit::feed_units`
/// pushes (its `"player"` leg) and fires `PLAYER_ENTERING_WORLD` off. It is the roster seat plus
/// the fields only the descriptor can say: the unit EXISTS, at the roster's level, with health
/// and a live object. [`super::seat_from_roster`] deliberately leaves those at nil/0 (decision
/// 2263: the reference's `UnitExists("player")` is nil and `UnitLevel("player")` is 0 before the
/// object exists), so the seat can no longer stand in for the descriptor the way it did from 1230
/// until then — a fixture that never pushes this stays in the pre-object window for good, which
/// is a state the real client is never in when a player opens their bags.
fn descriptor_snapshot(seat: &benilla_ui::script::UnitState) -> benilla_ui::script::UnitState {
benilla_ui::script::UnitState {
exists: true,
has_object: true,
is_connected: true,
guid: 7,
level: 60,
health: 4_000,
max_health: 4_000,
power_type: 1, // a Warrior's rage
max_power: 1_000,
..seat.clone()
}
}
/// A VM shaped like the director's session: our whole interface, their installed addons walked in
/// dependency order the way `addons::load_third_party` walks them, and a real backpack.
fn seat(root: &Path, seat: Seat) -> UiScript {
@@ -191,6 +180,10 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
s.set_realm_name("Harness");
let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
if seat == Seat::BeforeAddons {
// The real entry load seeds the player RECORD beside the seat (`lifecycle.rs`, decisions
// 2261/2263); this one push does both, by `set_unit`'s keep-in-step rule — a `"player"`
// push that carries a name seeds the record — so `UnitName("player")` is "Harness" at
// addon file scope, which is what sends Bagnon down its LIVE path.
s.set_unit("player", Some(player.clone()));
}
let failures = super::load_default_ui(&s);
@@ -239,12 +232,14 @@ fn seat(root: &Path, seat: Seat) -> UiScript {
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN"] {
s.fire_event(event, Vec::new());
}
// The self descriptor's arrival: `ui_unit::feed_units` pushes the player and fires
// PLAYER_ENTERING_WORLD off it. In the `AfterAddons` arm this is the FIRST time the VM has
// ever had a `"player"` — which is the state this file exists to reproduce.
if seat == Seat::AfterAddons {
s.set_unit("player", Some(player));
}
// The self descriptor's arrival: `ui_unit::feed_units` pushes the snapshot the descriptor
// builds and fires PLAYER_ENTERING_WORLD off it — in BOTH arms, because that is the world
// every real Bagnon open happens in. It matters: the reference's `GetKeyRingSize`
// (`ContainerFrame.lua:773-786`) reads `UnitLevel("player")`, and a fixture left in the
// pre-object window (level 0, the roster seat's answer since 2263) sizes the keyring at 4
// where the level-60 session sizes it at 12. In the `AfterAddons` arm this is the FIRST time
// the VM has ever had a `"player"` — which is the state this file exists to reproduce.
s.set_unit("player", Some(descriptor_snapshot(&player)));
s.fire_event("PLAYER_ENTERING_WORLD", Vec::new());
for _ in 0..10 {
s.tick(0.1);
@@ -366,6 +361,7 @@ fn click(s: &mut UiScript, name: &str, button: &str) {
/// director was on.
#[test]
fn bagnon_draws_a_slot_for_every_bag_slot() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::BeforeAddons);
@@ -381,17 +377,24 @@ fn bagnon_draws_a_slot_for_every_bag_slot() {
.unwrap(),
"the window must be on screen"
);
// 28 = the backpack's 16 + the keyring's 12. Bagnon's default bag set carries the keyring
// (`Bagnon.lua:25`, `{-2, 0, 1, 2, 3, 4}`), and its size is the reference's own
// `GetKeyRingSize` ladder off `UnitLevel("player")` (`ContainerFrame.lua:773-786`: 4, 8 at
// 40, 12 at 50, 16 only above 60) — 12 for the fixture's level-60 Warrior. The four bag slots
// hold nothing, so they add nothing.
assert_eq!(
s.eval::<i64>("return Bagnon.size").unwrap(),
16,
"Bagnon must size itself off the LIVE backpack (GetContainerNumSlots(0) == 16), not off \
Bagnon_Forever's empty offline cache — nil UnitName('player') is what sent it there"
28,
"Bagnon must size itself off the LIVE bags (GetContainerNumSlots(0) == 16 plus \
GetKeyRingSize() == 12 at level 60), not off Bagnon_Forever's empty offline cache — nil \
UnitName('player') is what sent it there"
);
assert_eq!(
bagnon_slot_quads(&mut s),
16,
"sixteen bag slots must actually be DRAWN — the director saw a title and a gold line and \
nothing else, while every column of the addon survey called this addon fine"
28,
"every live slot must actually be DRAWN (16 backpack + 12 keyring) — the director saw a \
title and a gold line and nothing else, while every column of the addon survey called \
this addon fine"
);
assert!(
s.errors().is_empty(),
@@ -408,6 +411,7 @@ fn bagnon_draws_a_slot_for_every_bag_slot() {
/// drawing slots, the headline above has stopped testing the thing it was written for.
#[test]
fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::AfterAddons);
s.run("ToggleBackpack()").expect("ToggleBackpack");
@@ -447,6 +451,7 @@ fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() {
/// on screen at all — and it is what the identity fix alone would have produced.
#[test]
fn the_item_button_helpers_paint_a_slots_icon_and_count() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -733,6 +738,7 @@ fn dragging_a_bagnon_slot_picks_the_item_up() {
/// ```
#[test]
fn the_backpack_button_still_hovers_with_bagnon_holding_its_script() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
@@ -761,10 +767,11 @@ fn the_backpack_button_still_hovers_with_bagnon_holding_its_script() {
}
/// **Bug 3.** `Bagnon_Forever/database/ui.lua:61` sizes its character dropdown from
/// `button:GetTextWidth()` on a **CheckButton** — a real 1.12 Button method (wow-re
/// `widget-api-batch-benilla.md` Q8 lists it present, and `GetStringWidth` absent, on Button).
/// `button:GetTextWidth()` on a **CheckButton** — a real 1.12 Button method (`0x782290`, and
/// `GetStringWidth` is absent on Button).
#[test]
fn a_button_reports_its_own_label_width() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
@@ -827,6 +834,7 @@ fn a_button_reports_its_own_label_width() {
/// we had never declared, so every one of the 36 buttons warned and the child came out bare.
#[test]
fn the_reference_cooldown_template_resolves() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
@@ -855,12 +863,15 @@ fn the_reference_cooldown_template_resolves() {
/// does.
#[test]
fn the_whole_bagnon_window_survives_being_used() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
// 1 · Every slot, hovered and left again — the OnEnter/OnUpdate/OnLeave loop on both an
// occupied and an empty slot.
for i in 1..=16 {
// occupied and an empty slot, across every bag the window lays out (the keyring's slots
// come first in Bagnon's default order, the backpack's after them).
let size = s.eval::<i64>("return Bagnon.size").expect("Bagnon.size");
for i in 1..=size {
let name = format!("BagnonItem{i}");
if s.eval::<bool>(&format!("return getglobal(\"{name}\") ~= nil"))
.unwrap_or(false)
@@ -886,7 +897,8 @@ fn the_whole_bagnon_window_survives_being_used() {
// 3 · Bagnon's OWN bag buttons (the row it just revealed — `BagnonBags0..4` plus the
// keyring's `BagnonBags-2`, from `Bagnon.xml`'s `$parent<id>` naming): hover, click and
// drag each. `BagnonBag_OnClick` is `PutItemInBackpack`/`PutItemInBag`, `BagnonBag_OnDrag`
// is `PickupBagFromSlot` — three engine verbs, none of which existed here.
// is `PickupBagFromSlot` — three engine verbs that did not exist here when this test was
// written and are `script/cursor/bag_verbs.rs` now.
for id in ["0", "1", "2", "3", "4", "-2"] {
let name = format!("BagnonBags{id}");
if !s
@@ -906,8 +918,9 @@ fn the_whole_bagnon_window_survives_being_used() {
s.mouse_button(x + 12.0, y + 12.0, "LeftButton", false);
}
// 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, guarded in our own
// MoneyFrame.xml and *not* guarded in Bagnon's copy of the same handler.
// 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, which Bagnon's own
// `BagnonFrameMoney_OnClick` (`Frame.lua:537-543`) calls unguarded, exactly as the
// reference's `MoneyFrame.xml` coin buttons do.
for coin in ["Gold", "Silver", "Copper"] {
let name = format!("BagnonMoneyFrame{coin}Button");
if s.eval::<bool>(&format!(
@@ -949,32 +962,16 @@ fn the_whole_bagnon_window_survives_being_used() {
//
// To retire an entry: implement it and delete the line. To add one: you had better be able to
// say why, in the same sentence.
const KNOWN_GAPS: &[(&str, &str)] = &[
// The bag-BUTTON cursor trio (`Bag.lua:199-215` — Bagnon's own bag row, click and drag).
// Three 1.12 engine bindings benilla does not have: `PutItemInBackpack 0x4c8f70`,
// `PutItemInBag 0x4c8f00`, `PickupBagFromSlot 0x4c8fa0` (wow-re `system/ui/ledger.tsv`
// l.1962-1964 has the registrations verified; the DECOMPILATION on disk is partial —
// Ghidra removed unreachable blocks from two of the three — so what each one actually
// DOES is not read).
//
// **Deliberately not guessed.** The open question is whether `PutItemInBag(inv)`
// auto-stores the held item into that bag's CONTENTS or places it onto the equipment slot,
// and those are different opcodes. Writing a cursor transition on the wrong one moves a
// player's items to the wrong place, which is the most expensive kind of wrong here
// (the contract §4), so an RE cross-check is dispatched into wow-5875-re and these land on
// its verdict, not before.
("PutItemInBackpack", "Bagnon_Core\\core\\Bag.lua:202"),
("PutItemInBag", "Bagnon_Core\\core\\Bag.lua:204"),
("PickupBagFromSlot", "Bagnon_Core\\core\\Bag.lua:214"),
// Money on the cursor. `Frame.lua:537-543` — Bagnon's money frame calls this UNGUARDED
// where benilla's own `MoneyFrame.xml:411` guards its copy of the same reference handler.
// It is the head of a whole feature, not a missing line: the reference's
// `CoinPickupFrame.lua`/`.xml` need `GetCursorMoney`, `PickupPlayerMoney`, a MONEY cursor
// payload (wow-re `cursor-dragdrop-payload.md` row 2 — benilla's cursor has Item/Spell/
// Action only) and a keyboard-focused frame with `OnChar`/`OnKeyDown`, which this engine
// deliberately does not have (see `EnableKeyboard` in the same landing's report).
("OpenCoinPickupFrame", "Bagnon_Core\\core\\Frame.lua:543"),
];
//
// **Empty, and that is the list's best state, not a reason to delete it.** Four rows sat here
// when it was written, and each retired the way the rule above says: the bag-BUTTON cursor
// trio (`PutItemInBackpack`, `PutItemInBag`, `PickupBagFromSlot` — `Bag.lua:199-215`) once
// `script/cursor/bag_verbs.rs` bound all three; and money on
// the cursor (`OpenCoinPickupFrame`, `Frame.lua:537-543`) when the money kit went stock and
// the reference's own `CoinPickupFrame.xml` joined the manifest with the money cursor under
// it (decision 1962). With nothing on the list, "unexplained" is every error, which is the
// assertion this test was always reaching for.
const KNOWN_GAPS: &[(&str, &str)] = &[];
let raised = s.errors();
let unexplained: Vec<&String> = raised
@@ -1057,6 +1054,7 @@ fn text_quad(s: &mut UiScript, owner: &str) -> QuadContent {
/// asserted as such — a future "fix" that adds one would be a divergence, not an improvement.
#[test]
fn a_stack_count_wears_the_font_object_its_font_attr_names() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon_stacked(&root);
@@ -1107,10 +1105,11 @@ fn a_stack_count_wears_the_font_object_its_font_attr_names() {
/// `<Gradient>`, and dropped it in silence, so the white art rendered untinted.
///
/// `<Gradient>` survives beside a `file=` where `<Color>` does not, and that asymmetry is the
/// point: they land in different fields (vertex colours `+0xb8` vs the texture `+0xcc` —
/// wow-re `texture-color-composition.md` §1-2).
/// point: they land in different fields (vertex colours `+0xb8`, written by `0x77f910`, vs the
/// texture `+0xcc`, written by `0x770360`).
#[test]
fn a_texture_gradient_tints_the_art_it_sits_on() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon_stacked(&root);
s.run("BagnonDBUI_ShowCharacterList(Bagnon)")
@@ -1161,6 +1160,7 @@ fn a_texture_gradient_tints_the_art_it_sits_on() {
/// The assertion is the FIRST open, which is the half that was broken.
#[test]
fn a_money_frame_is_the_right_width_on_the_first_open() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::BeforeAddons);
@@ -1225,6 +1225,7 @@ fn resolved_font(s: &UiScript, lua_expr: &str) -> (String, String) {
/// zero against nine controls each returning one); it is a later-client idiom.
#[test]
fn a_button_state_font_takes_the_font_attribute_not_just_inherits() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon_stacked(&root);
s.run("BagnonDBUI_ShowCharacterList(Bagnon)")
@@ -1258,6 +1259,7 @@ fn a_button_state_font_takes_the_font_attribute_not_just_inherits() {
/// reference does not.
#[test]
fn a_fontheight_with_no_font_attr_beside_it_is_never_read() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let s = open_bagnon_stacked(&root);
assert_eq!(
@@ -1298,6 +1300,7 @@ impl benilla_ui::script::TextMeasure for BlockFont {
/// name plus the addon's own 40px of checkbox and padding.
#[test]
fn the_character_dropdown_is_as_wide_as_the_names_in_it() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
s.set_text_measurer(Box::new(BlockFont));
@@ -1352,18 +1355,26 @@ fn the_character_dropdown_is_as_wide_as_the_names_in_it() {
/// (`ui_items::feed::tests::an_absent_self_player_is_no_source_never_an_empty_bag_burst`).
#[test]
fn bagnon_forevers_records_survive_the_logout_boundary() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::BeforeAddons);
// The first-login scan (PLAYER_LOGIN, inside `seat`) recorded the live backpack: 16 slots,
// the pouch in slot 1, in Bagnon_Forever's own short-link shape.
//
// **`16,1,` — the 1 is the reference's, not a stray.** The recorder's count is
// `GetInventoryItemCount("player", ContainerIDToInventoryID(0))`, and `ContainerIDToInventoryID`
// has no backpack arm (`0x4f94e0`: `id - 1 < 4` → `+20`), so bag 0 asks inventory slot 19 —
// the tabard slot. `GetInventoryItemCount 0x4c8680` pushes **1** for an empty slot
// (`0x4c8797`), and 1 for a
// worn tabard's stack count too, so the real client writes this row as `16,1,` either way.
let record = "local r = BagnonForeverData[GetRealmName()][UnitName('player')] \
return tostring(r[0] and r[0].s), tostring(r[0] and r[0][1])";
let (size, item) = s
.eval::<(String, String)>(record)
.expect("the record reads");
assert_eq!(
size, "16,0,",
size, "16,1,",
"the login scan records the backpack's size row"
);
assert_eq!(
@@ -1415,6 +1426,7 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
None,
Default::default(),
Vec::new(),
Vec::new(),
);
s.fire_event("PLAYER_LEAVING_WORLD", Vec::new());
s.fire_event("PLAYER_LOGOUT", Vec::new());
@@ -1422,7 +1434,7 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
.eval::<(String, String)>(record)
.expect("the record reads");
assert_eq!(
size, "16,0,",
size, "16,1,",
"the logout boundary must not erase the size row"
);
assert_eq!(item, "4496", "the logout boundary must not erase the slot");
@@ -1441,7 +1453,10 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
/// here with a real mouse click, an equipped bag in slot 2, and Bagnon fully hooked.
#[test]
fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
let root = corpus_or_skip!();
// The install too: "is a native bag window open" is asked of the reference's own `IsBagOpen`
// scan (`ContainerFrame.lua:177`, off the chain) — the windows are RECYCLED (1751), so there
// is no frame to name, and `bag_tests.rs` asks the same way.
let root = corpus_and_install_or_skip!();
let mut s = open_bagnon(&root);
// An equipped bag behind CharacterBag1Slot (bagId 2): the inventory surface (the bag ITEM in
@@ -1489,8 +1504,7 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
"the slot click must reach Bagnon's ToggleBag override and close its window"
);
assert!(
!s.eval::<bool>("return BenillaBagFrame2:IsVisible() or false")
.unwrap(),
!super::test_ui::bag_open(&s, 2),
"the native bag window must NOT open — the click belongs to the addon's override"
);
@@ -1508,8 +1522,7 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
"the second click re-opens Bagnon through the same override"
);
assert!(
!s.eval::<bool>("return BenillaBagFrame2:IsVisible() or false")
.unwrap(),
!super::test_ui::bag_open(&s, 2),
"the native window stays shut on the re-open too"
);
assert!(
@@ -1517,29 +1530,38 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() {
.unwrap(),
"the slot button stays unlit with an addon holding the bags (the ref scan is native-only)"
);
// Nothing is filtered out any more: the bag-button cursor trio the round-trip reaches
// (`PutItemInBag` on the click) is built (`script/cursor/bag_verbs.rs`), so a raise on it is
// a real one.
let raised = s.errors();
let unexplained: Vec<&String> = raised
.iter()
.filter(|e| !e.contains("PutItemIn") && !e.contains("PickupBagFromSlot"))
.collect();
assert!(
unexplained.is_empty(),
"the click round-trip raised: {unexplained:#?}"
raised.is_empty(),
"the click round-trip raised: {raised:#?}"
);
}
/// **Bug 7 — the director's unlit backpack button.** Bagnon lights `MainMenuBarBackpackButton`
/// from its window's OnShow/OnHide (`Bagnon.lua:33/39` — `SetChecked(1/0)`), and OnShow dispatch
/// is synchronous (wow-re `onshow-onhide-dispatch-order.md`), so whoever writes checked AFTER the
/// toggle wins. Two of our seats overwrote it: the button's old click tail re-derived checked
/// from the NATIVE backpack window (the ref's own scan — which leaves the button unlit on the
/// real client's click path too, a ref wart our stated divergence closes), and the `B` binding
/// ran the BUTTON handler instead of the ref's bare `ToggleBackpack()` body, dragging that tail
/// onto the one path the real client lights. Now: a real click on the button ends lit while
/// Bagnon's window is open and unlit when it closes, and the bare-global path (the binding's
/// body) does the same.
/// **Bug 7 — the director's unlit backpack button — and where it stands since the bag bar went
/// stock.** Bagnon lights `MainMenuBarBackpackButton` from its window's OnShow/OnHide
/// (`Bagnon.lua:33/39` — `SetChecked(1/0)`), and OnShow dispatch is synchronous
/// (`0x775750` → `0x76ae10`), so whoever writes checked AFTER the toggle wins. On the
/// reference's own click path the stock `BackpackButton_OnClick` tail
/// (`MainMenuBarBagButtons.lua:55-69`) runs last: it re-derives checked by scanning the NATIVE
/// container frames, finds none for bag 0 while an addon holds the bags, and writes 0 — so the
/// real client leaves the button unlit after a click with Bagnon, a wart of its own. Our own
/// `BagFrame.xml` once closed that wart on the director's call (Bug 7: an auto-toggle undo in
/// place of the scan, a stated divergence); decision 1783 sourced the stock file unedited and the
/// divergence went with it, unrecorded — and this suite, skipping at every land from 08-30
/// (2329), never said so.
///
/// What this pins is the client as it is, which is the reference: the bare `ToggleBackpack()`
/// path (the `B` binding's body) ends lit, because nothing runs after OnShow there; a real click
/// ends **unlit** on open as well as on close, because the stock tail is the last word. Decision
/// 2332 retired Bug 7's divergence on record (1751 §2 reserves shims for engine differences, and
/// this is a look); if the director ever wants it lit, the second assertion below flips back and
/// a shim in `ContainerFrameAdapters.xml` carries the why.
#[test]
fn the_backpack_button_lights_while_bagnon_holds_the_bags() {
fn the_backpack_button_lit_state_with_bagnon_holding_the_bags() {
benilla_formats::wow_data_or_skip!();
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
@@ -1568,7 +1590,9 @@ fn the_backpack_button_lights_while_bagnon_holds_the_bags() {
"closed ⇒ unlit (Bagnon's OnHide write is the last word)"
);
// And the second click: open again, lit again — the director's report, closed.
// And the second click: open again — and UNLIT, the stock tail's native scan overwriting
// Bagnon's OnShow write, as the reference does. Bug 7's "lit" is the assertion this replaces;
// it comes back with the shim if the director asks for it (the doc above).
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
@@ -1577,8 +1601,9 @@ fn the_backpack_button_lights_while_bagnon_holds_the_bags() {
"the second click re-opens Bagnon"
);
assert!(
s.eval::<bool>("return MainMenuBarBackpackButton:GetChecked() and true or false")
!s.eval::<bool>("return MainMenuBarBackpackButton:GetChecked() and true or false")
.unwrap(),
"open ⇒ lit (Bagnon's OnShow write is the last word)"
"open by click ⇒ unlit: the stock BackpackButton_OnClick tail's native scan is the last \
word, as on the reference (Bug 7's divergence retired with the stock bag bar, 1783)"
);
}
@@ -23,6 +23,7 @@ fn basic_controls() -> UiScript {
/// reason the frame has to exist rather than the function being a stub that prints somewhere.
#[test]
fn message_shows_the_script_errors_dialog_carrying_its_text() {
benilla_formats::wow_data_or_skip!();
let s = basic_controls();
assert!(
!s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap(),
@@ -57,6 +58,7 @@ fn message_shows_the_script_errors_dialog_carrying_its_text() {
/// is replaceable — which is the entire point for the two corpus addons that replace it.
#[test]
fn error_message_returns_its_argument_and_can_be_replaced() {
benilla_formats::wow_data_or_skip!();
let s = basic_controls();
assert_eq!(
s.eval::<String>(r#"return _ERRORMESSAGE("boom")"#).unwrap(),
@@ -82,6 +84,7 @@ fn error_message_returns_its_argument_and_can_be_replaced() {
/// test used to be).
#[test]
fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
benilla_formats::wow_data_or_skip!();
let mut s = basic_controls();
assert!(
s.eval::<bool>("return geterrorhandler() == _ERRORMESSAGE")
@@ -120,6 +123,7 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
/// `TEXT` is the identity function, and 26 corpus addons call it because shipped FrameXML does.
#[test]
fn text_is_the_identity_function_the_corpus_expects() {
benilla_formats::wow_data_or_skip!();
let s = basic_controls();
assert_eq!(
s.eval::<String>(r#"return TEXT("Level")"#).unwrap(),
@@ -133,6 +137,7 @@ fn text_is_the_identity_function_the_corpus_expects() {
/// name — `MyDialogButton`, never `DialogBoxFrameButton`.
#[test]
fn a_dialog_box_from_the_template_names_and_closes_the_callers_frame() {
benilla_formats::wow_data_or_skip!();
let s = basic_controls();
s.run(
r#"MyDialog = CreateFrame("Frame", "MyDialog", UIParent, "DialogBoxFrame")
@@ -30,6 +30,7 @@ fn setup() -> UiScript {
/// this wiring the click produced no dialog and no packet at all (B249).
#[test]
fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event(
"CONFIRM_BINDER",
@@ -58,6 +59,7 @@ fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
/// no decline opcode), so the only observable is that no confirm was queued.
#[test]
fn declining_sends_nothing() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
@@ -77,6 +79,7 @@ fn declining_sends_nothing() {
/// nothing; the frame it goes false, the dialog hides itself — with no packet either way.
#[test]
fn leaving_the_innkeepers_range_hides_the_confirm() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Kharanos".into())]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
@@ -309,6 +309,7 @@ const RAISABLE_BARS: &[&str] = &[
/// eventually wired), and re-runs the pass between combinations exactly as a live bar change does.
#[test]
fn no_bottom_band_frame_overlaps_a_raised_bar() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -457,6 +458,7 @@ fn overlaps(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool {
/// is the test that says so out loud rather than the change slipping through green.
#[test]
fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference_does() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -723,8 +723,8 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
// The direct probe, and its exact shape is the point. `BuffButtonTempEnchant` inherits
// `BuffButtonTemplate` and blanks its three aura handlers with `<OnLoad>`↵`</OnLoad>` — a
// WHITESPACE body, which 1.12 compiles into a valid empty function rather than storing nil
// (`SetScript 0x7025c0` tests `text[0]`, and nothing trims — wow-5875-re
// `xml-script-empty-element.md`). So each handler is still a FUNCTION here, and it is the
// (`SetScript 0x7025c0` tests `text[0]`, and nothing trims). So each handler is still a
// FUNCTION here, and it is the
// reference's own no-op rather than `BuffButton_OnLoad`.
//
// Asserting `== nil` instead would be asserting a bug: an addon reading
@@ -1009,11 +1009,11 @@ fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
/// synthetic-template tests cannot reach.
///
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder, wow-re
/// §3-BUFF-TIME-FORMAT) over whatever `GlobalStrings.lua` the player's install carries; this runs
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder) over whatever
/// `GlobalStrings.lua` the player's install carries; this runs
/// that file into a real VM exactly as the boot's `load_global_strings` does, then asserts the
/// wording that comes back. Every expectation below is a reading the PREVIOUS two-arm formatter got
/// wrong, so this is the carve's whole delta in one place. Skips without client data.
/// wrong, so this is the whole delta in one place. Skips without client data.
#[test]
fn the_duration_line_reads_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
+13 -1
View File
@@ -50,6 +50,7 @@ fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQ
#[test]
fn cast_fills_then_completes_green_and_fades() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
@@ -99,6 +100,7 @@ fn cast_fills_then_completes_green_and_fades() {
#[test]
fn a_hit_pushes_the_bar_back_it_does_not_cancel() {
benilla_formats::wow_data_or_skip!();
// Pushback (`SMSG_SPELL_DELAYED` → `SPELLCAST_DELAYED`): a hit while casting must slide the
// bar's window out (the spark jumps back and it keeps running), never hide or fail it —
// decision 0256's open item, the "disappears on a hit" report.
@@ -145,6 +147,7 @@ fn a_hit_pushes_the_bar_back_it_does_not_cancel() {
#[test]
fn failed_cast_turns_red_holds_then_fades() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
@@ -179,6 +182,7 @@ fn failed_cast_turns_red_holds_then_fades() {
#[test]
fn channel_counts_down_not_up() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
// SPELLCAST_CHANNEL_START(ms, name) — args reversed vs START, per the reference contract.
// The name is whatever `ui_cast::channel_start_args` composed; for all but nine of the 323
@@ -261,6 +265,7 @@ fn channel_counts_down_not_up() {
/// `SPELLCAST_CHANNEL_STOP` by hand, which is the *interrupt* timing, not this one.
#[test]
fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"SPELLCAST_CHANNEL_START",
@@ -316,6 +321,7 @@ const FILL: &str = "UI-StatusBar";
/// smear over the bar from the first frame — invisible to every state assertion above.
#[test]
fn the_flash_stays_hidden_for_the_whole_cast() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
@@ -343,6 +349,7 @@ fn the_flash_stays_hidden_for_the_whole_cast() {
/// finished.
#[test]
fn the_flash_blooms_from_zero_only_on_completion() {
benilla_formats::wow_data_or_skip!();
const REF_TICK: f32 = 1.0 / 30.0; // one reference tick of wall clock
let mut s = harness();
s.fire_event(
@@ -373,6 +380,7 @@ fn the_flash_blooms_from_zero_only_on_completion() {
/// A failed cast never flashes — the reference only arms the ramp on STOP/CHANNEL_STOP.
#[test]
fn a_failed_cast_never_flashes() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
@@ -395,6 +403,7 @@ fn a_failed_cast_never_flashes() {
/// border art's own layer, where declaration order put the fill on top.)
#[test]
fn the_fill_draws_beneath_the_border_art() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
@@ -411,11 +420,12 @@ fn the_fill_draws_beneath_the_border_art() {
);
}
/// A StatusBar fill CROPs its texture — it never squeezes it (wow-re `nameplate-vkey.md`). At a
/// A StatusBar fill CROPs its texture — it never squeezes it (`0x770410`). At a
/// fraction f the quad is f·width wide AND samples u ∈ [0, f], so `UI-StatusBar`'s left-to-right
/// ramp keeps its true gradient at every fill level.
#[test]
fn the_fill_crops_its_texture_rather_than_stretching_it() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
@@ -460,6 +470,7 @@ fn bottom(s: &UiScript, name: &str) -> f64 {
/// (base + bottomEither/bottomLeft + pet, and chat's bottomLeft-and-pet +23 extra).
#[test]
fn managed_positions_track_the_bottom_bar_stack() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -553,6 +564,7 @@ fn managed_positions_track_the_bottom_bar_stack() {
/// "exists" and still break every addon that drives the bar through it.
#[test]
fn the_casting_bar_publishes_its_status_bar_alias() {
benilla_formats::wow_data_or_skip!();
let s = harness();
assert!(
s.eval::<bool>("return CastingBarFrameStatusBar ~= nil")
+29 -3
View File
@@ -67,6 +67,7 @@ fn close(a: f32, b: f32) -> bool {
#[test]
fn injected_lines_render_in_the_pinned_colors() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
// The app's feed passes 0..1 floats from the pinned type→color table; the seam quantizes them.
// SAY white (FFFFFF), SYSTEM yellow (FFFF00 — the GM-feedback color), LOOT green (00AA00).
@@ -104,6 +105,7 @@ fn injected_lines_render_in_the_pinned_colors() {
#[test]
fn newest_line_sits_at_the_bottom() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.add_chat_message("ChatFrame1", "older", 1.0, 1.0, 1.0);
s.add_chat_message("ChatFrame1", "newer", 1.0, 1.0, 1.0);
@@ -124,11 +126,12 @@ fn newest_line_sits_at_the_bottom() {
/// The whole fade round trip as it reaches a real chat window: a line ramps down, a scroll brings
/// it back to full, the scrolled-up view then holds it there, and returning to the bottom lets it
/// ramp again. The re-arm half is `msgframe-fade-rearm-law.md` — every scroll entry reaches
/// ramp again. The re-arm half: every scroll entry reaches
/// `0x788b80` or the relayout's `0x788af0`; before it, a faded-out chat could not be recovered by
/// any input the client offers (director-reported, 2026-08-29).
#[test]
fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.run("ChatFrame1:SetTimeVisible(0); ChatFrame1:SetFadeDuration(4)")
.unwrap();
@@ -160,6 +163,7 @@ fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
#[test]
fn input_editbox_enter_drains_the_typed_line() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"), "the edit box focuses");
assert!(s.has_keyboard_focus(), "focus gates the world's keys");
@@ -184,6 +188,7 @@ fn input_editbox_enter_drains_the_typed_line() {
#[test]
fn input_escape_closes_without_submitting() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("hello");
@@ -198,6 +203,7 @@ fn input_escape_closes_without_submitting() {
/// leave LEFT/RIGHT consumed-but-dead here, and an unpushed history would leave Up/Down empty.
#[test]
fn chat_box_arrows_edit_and_history_recalls() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{EditAction, EditUnit};
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
@@ -265,6 +271,7 @@ fn chat_box_arrows_edit_and_history_recalls() {
/// untouched (a silent item dismissal would be a destroy — only the world-drop popup offers that).
#[test]
fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{
ContainerSlot, ContainerState, SpellBookState, SpellSlotView, SpellTabView,
};
@@ -380,11 +387,12 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
/// `ChatTypeInfo` carries the shipped default chat colors twice: once in
/// `Interface\FrameXML\ChatFrame.xml` for addons to read, and once in
/// [`crate::ui_chat::default_color`] for our own feed to render. Both are the same wow-re byte
/// table (`chat-color-table.md`, the static registry at `.rdata 0x804710`) — so this is the gate
/// [`crate::ui_chat::default_color`] for our own feed to render. Both are the same reference byte
/// table (the static registry at `.rdata 0x804710`) — so this is the gate
/// that makes the duplication safe: every kind we model must agree to the byte, and the table's shape (`sticky`/`id`) must be the reference's.
#[test]
fn chat_type_info_matches_the_host_color_table() {
benilla_formats::wow_data_or_skip!();
use crate::ui_chat::{default_color, ChatEventKind as K};
/// Each modeled kind and its `ChatTypeInfo` key — the reference's own spellings.
@@ -453,6 +461,7 @@ fn chat_type_info_matches_the_host_color_table() {
/// extras. An addon reads `.sticky` and `.id` as often as it reads the color.
#[test]
fn chat_type_info_has_the_references_shape() {
benilla_formats::wow_data_or_skip!();
let s = chat_frame();
let count: i64 = s
@@ -518,6 +527,7 @@ fn chat_type_info_has_the_references_shape() {
/// a matching table key fails here instead of shipping a name nothing can resolve.
#[test]
fn fired_event_names_are_all_chat_type_info_keys() {
benilla_formats::wow_data_or_skip!();
use crate::ui_chat::{event_name, ChatEventKind as K};
let s = chat_frame();
@@ -544,6 +554,7 @@ fn fired_event_names_are_all_chat_type_info_keys() {
/// guard (`LazyPig.lua:1992`), so the constant without the frames is worse than neither.
#[test]
fn every_window_num_chat_windows_promises_is_a_real_frame() {
benilla_formats::wow_data_or_skip!();
let s = chat_frame();
assert_eq!(s.eval::<i64>("return NUM_CHAT_WINDOWS").unwrap(), 7);
for i in 1..=7 {
@@ -561,6 +572,7 @@ fn every_window_num_chat_windows_promises_is_a_real_frame() {
/// `getglobal(...):IsVisible()` that used to die at i=3.
#[test]
fn the_lazypig_window_walk_survives_all_seven_indices() {
benilla_formats::wow_data_or_skip!();
let s = chat_frame();
let visible: i64 = s
.eval(
@@ -587,6 +599,7 @@ fn the_lazypig_window_walk_survives_all_seven_indices() {
/// tabs (1575), so the Outfitter walk below is still safe.
#[test]
fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
benilla_formats::wow_data_or_skip!();
let s = chat_frame();
for i in 1..=2 {
let docked: bool = s
@@ -626,6 +639,7 @@ fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
/// frame will disagree about the same window. The drift guard between the Rust table and the XML.
#[test]
fn get_chat_window_info_shown_matches_the_shipped_frames() {
benilla_formats::wow_data_or_skip!();
let s = chat_frame();
for i in 1..=7 {
let agrees: bool = s
@@ -650,6 +664,7 @@ fn get_chat_window_info_shown_matches_the_shipped_frames() {
/// in ChatFrame3 and nowhere near the window the player is reading.
#[test]
fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.add_chat_message("ChatFrame1", "a real line", 1.0, 1.0, 1.0);
s.run("ChatFrame3:AddMessage('Radar: debug', 1, 1, 0)")
@@ -683,6 +698,7 @@ fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
/// reaches it, since both pass `DEFAULT_CHAT_FRAME`.
#[test]
fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
benilla_formats::wow_data_or_skip!();
let s = chat_frame();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
let (one, two): (bool, bool) = (
@@ -733,6 +749,7 @@ fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
/// next tick overwrites 0.42 with 0 and this goes red.
#[test]
fn an_idle_dock_stops_rewriting_the_tab_alpha_every_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0); // far from the dock: no hover, so `reveal` stays 0
for _ in 0..8 {
@@ -758,6 +775,7 @@ fn an_idle_dock_stops_rewriting_the_tab_alpha_every_frame() {
/// cursor over the dock for longer than `CHAT_TAB_SHOW_DELAY` fades the selected tab to full.
#[test]
fn hovering_the_dock_still_reveals_the_tabs() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
@@ -798,6 +816,7 @@ fn hovering_the_dock_still_reveals_the_tabs() {
/// 0.5s toggle; and scrolling back while the flash is LIT still hides it and zeroes the timer.
#[test]
fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
for t in ["L0", "L1", "L2"] {
s.add_chat_message("ChatFrame1", t, 1.0, 1.0, 1.0);
@@ -866,6 +885,7 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
/// dead driver. So this drives the clock and nothing else.
#[test]
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
let (x, y): (f32, f32) = s
.eval(
@@ -912,6 +932,7 @@ fn selecting_the_combat_log_keeps_the_dock_driver_running() {
/// `reveal` never decayed either, so the chat box and both tabs stayed lit after the cursor left.
#[test]
fn the_dock_still_fades_out_with_the_combat_log_selected() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
let (x, y): (f32, f32) = s
.eval(
@@ -955,6 +976,7 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
/// button, which only happens if window 2 both HAS the button and runs `ChatFrame_OnUpdate`.
#[test]
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for i in 0..40 {
@@ -1064,6 +1086,7 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
/// the chat window shows the tabs and the buttons but no plate.
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
@@ -1109,6 +1132,7 @@ fn a_glass_windows_plate_fades_in_on_every_hover() {
/// The same law under the WHOLE shipped manifest, driven the way the app drives it.
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
@@ -1207,6 +1231,7 @@ fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
/// then a repair of nothing.
#[test]
fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
benilla_formats::wow_data_or_skip!();
fn drive(s: &mut UiScript) -> (f64, f64, f64) {
let (x, y): (f32, f32) = s
.eval(
@@ -1312,6 +1337,7 @@ fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
/// broken — which is exactly how this shipped.
#[test]
fn the_stock_afk_and_dnd_commands_reach_the_wire() {
benilla_formats::wow_data_or_skip!();
use crate::net::ChatKind;
use crate::ui_chat::edit::SendType;
@@ -2,9 +2,9 @@
//!
//! `CinematicFrame` is fullscreen, keyboard-enabled, and carries an `OnKeyDown` that answers
//! ESCAPE. In the reference that is enough to swallow **every** key while it is up, because the
//! key-down walk's gate is EXISTENCE, not handling (wow-re `ui/scratch/frame-key-script-delivery.md`
//! §3, VERIFIED): a shown keyboard frame with the slot set consumes the key whatever its script
//! does with it, and a 1.12 handler has no way to signal "not handled" (§3.1).
//! key-down walk's gate is EXISTENCE, not handling (`0x76b7d0`): a shown keyboard frame with the
//! slot set consumes the key whatever its script does with it, and a 1.12 handler has no way to
//! signal "not handled" (`0x76ba25`).
//!
//! The reference's own Lua is the proof, and it is why these tests exist: that same `OnKeyDown` has
//! to call `RunBinding("SCREENSHOT")` **by hand** to get one key back. It would not need to if
@@ -51,6 +51,7 @@ fn start_cinematic(s: &mut UiScript) {
/// cannot walk during a cinematic.
#[test]
fn a_playing_cinematic_swallows_the_movement_keys() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
// Before it starts, the frame is hidden and declines: the world keeps its keys.
@@ -84,6 +85,7 @@ fn a_playing_cinematic_swallows_the_movement_keys() {
/// never that they fall through.
#[test]
fn escape_is_consumed_and_acted_on_rather_than_falling_through() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
start_cinematic(&mut s);
@@ -132,6 +134,7 @@ fn the_host_has_a_reference_name_for_the_keys_it_now_delivers() {
/// ever asked about keys the frame *swallows*.
#[test]
fn the_screenshot_key_is_handed_back_to_its_binding() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
// The binding table the passthrough reads: `GetBindingKey("SCREENSHOT")` has to answer, or the
// arm is skipped and the test proves nothing.
@@ -183,6 +186,7 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
/// `IsVisible` the honest question again, so the check is an assertion instead of a log.
#[test]
fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
let visible = |s: &UiScript, f: &str| {
s.eval::<i64>(&format!("return {f}:IsVisible() and 1 or 0"))
@@ -235,6 +239,7 @@ fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
/// silent about the hide.
#[test]
fn the_screenshot_confirmation_shows_during_a_cinematic() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -44,9 +44,10 @@ fn picker() -> UiScript {
///
/// It is emphatically **not** the identity, and not a simple quantize either: `SetColorRGB` rounds
/// half-up into bytes and stores HSV floats, while every read path floors, so 9.75 % of colours come
/// back a step low on one channel (wow-re `system/ui/scratch/colorselect-color-law.md`, exhaustive
/// over all 256³). `benilla_ui::script::colorselect`'s own tests pin that law against wow-re's
/// measured witnesses; these tests use it as a given so they stay about the Dewdrop plumbing.
/// back a step low on one channel (`SetColorRGB 0x78eae0`, the read-back floor `0x7bbec0`;
/// measured exhaustively over all 256³). `benilla_ui::script::colorselect`'s own tests pin that law
/// against the reference's measured witnesses; these tests use it as a given so they stay about the
/// Dewdrop plumbing.
fn after_round_trip(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
let mut cs = benilla_ui::widget::ColorSelectState::default();
cs.set_rgb(r, g, b);
@@ -336,8 +336,7 @@ fn combat_text_master_toggle_unregisters() {
/// fast arm into a plain `lua_gettable(LUA_GLOBALSINDEX)` — so whatever is in the global when the
/// world-entry load edge closes is what the numbers draw in. Stock `Fonts.xml` puts Friz there;
/// MikScrollingBattleText and pfUI put their own face there at `ADDON_LOADED`, which the reference
/// reads because `0x6c8470` runs *after* the addon load, not at CRT init as three wow-re notes
/// had it.
/// reads because `0x6c8470` runs *after* the addon load, not at CRT init.
///
/// Skips without client data.
#[test]
+2 -2
View File
@@ -8,7 +8,7 @@
//! machine's source tree. On a player's machine every one of those files resolves to nothing and
//! the client boots with no interface at all: not a crash, just an empty screen and one log line
//! per manifest entry that nobody sees.
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — the contract's hard rule is
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — `docs/METHOD.md`'s hard rule is
//! untouched), so it can simply be part of the program.
//!
//! ## Resolution order — the same in every build
@@ -71,7 +71,7 @@ fn read_source_tree(req: &str) -> Option<String> {
///
/// It exists because a number taken from the corpus harness is meaningless without it. In a dev
/// build [`read`] prefers the SOURCE TREE, so editing an `assets/ui` file changes what a survey
/// loads **with no rebuild** — and this repo's worktrees are routinely shared by several agents at
/// loads **with no rebuild** — and a tree is routinely edited by several agents at
/// once. Three separate measurements this arc were taken across a moving tree and one of them
/// landed a wrong attribution in a decision record: 87/218 was credited to a single table when a
/// controlled A/B later put it at 75, with the other twelve belonging to a neighbour's uncommitted
@@ -28,6 +28,7 @@ fn setup() -> UiScript {
/// the Repop intent, and PLAYER_ALIVE (the release landing) hides it.
#[test]
fn death_popup_counts_down_and_release_queues_repop() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
@@ -80,6 +81,7 @@ fn death_popup_counts_down_and_release_queues_repop() {
/// "Reincarnate" default, and clicking it queues `UseSoulstone` — while button1 still releases.
#[test]
fn death_popup_offers_the_self_resurrect_and_spends_it() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
@@ -119,6 +121,7 @@ fn death_popup_offers_the_self_resurrect_and_spends_it() {
/// `DisplayButton2` nor `OnShow` per tick).
#[test]
fn death_popup_button2_is_decided_at_show_time() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: Some(300.0),
@@ -159,6 +162,7 @@ fn death_popup_button2_is_decided_at_show_time() {
/// DEATH_RELEASE_NOTIMER text and never counts down.
#[test]
fn death_popup_no_timer_shows_the_static_text() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
release_remaining: None,
@@ -180,6 +184,7 @@ fn death_popup_no_timer_shows_the_static_text() {
/// name in, gates Accept behind the recovery delay, and Accept/Decline queue their intents.
#[test]
fn resurrect_request_picks_variant_and_answers() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
// No sickness + timer, delay already elapsed: RESURRECT_NO_SICKNESS with an armed Accept.
s.set_death(DeathUiState {
@@ -251,6 +256,7 @@ fn resurrect_request_picks_variant_and_answers() {
/// and keeps the dialog, the second queues AcceptXPLoss; walking out of range auto-hides.
#[test]
fn xp_loss_two_step_confirm_then_range_hide() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 Minutes".into()),
@@ -320,6 +326,7 @@ fn xp_loss_two_step_confirm_then_range_hide() {
/// instance.
#[test]
fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 Minutes".into()),
@@ -373,6 +380,7 @@ fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
/// false for it; decision 0308 §1).
#[test]
fn the_ghost_predicates() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_unit(
"player",
@@ -405,6 +413,7 @@ fn the_ghost_predicates() {
/// variant is the buttonless notice.
#[test]
fn corpse_range_events_drive_recover_corpse() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_death(DeathUiState {
recovery_delay: 2.0,
@@ -465,6 +474,7 @@ fn corpse_range_events_drive_recover_corpse() {
/// WorldMapFrame.lua:443-452 law the map's update block branches on.
#[test]
fn corpse_map_position_binding() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
@@ -135,6 +135,7 @@ fn drop_in_world(s: &mut UiScript, item_id: u32, name: &str, quality: u32) {
/// `DeleteCursorItem()` — the item leaves the cursor and queues its wire destroy.
#[test]
fn delete_item_confirm_shows_the_real_strings_and_yes_deletes() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
pick_up_and_drop_in_world(&mut s);
@@ -261,6 +262,7 @@ fn desaturated(s: &mut UiScript, button: &str) -> bool {
/// this mainly proves the popup itself closes.)
#[test]
fn escape_closes_the_delete_confirm_popup() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
pick_up_and_drop_in_world(&mut s);
@@ -280,6 +282,7 @@ fn escape_closes_the_delete_confirm_popup() {
/// DIFFERENT visible dialog is untouched by cursor traffic.
#[test]
fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
pick_up_and_drop_in_world(&mut s);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
@@ -316,6 +319,7 @@ fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
/// focused, and OKAY **disabled** until the confirm word is typed.
#[test]
fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
@@ -354,6 +358,7 @@ fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() {
/// passes, and backing away from the word disables it again. Then OKAY destroys.
#[test]
fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
@@ -408,6 +413,7 @@ fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
/// only while OKAY is enabled, so a reflexive Enter over a half-typed word does nothing at all.
#[test]
fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
@@ -437,6 +443,7 @@ fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
/// left over from last time would be armed before the player read the dialog).
#[test]
fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
@@ -476,6 +483,7 @@ fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() {
/// cancel here, which the earlier form of this test pinned.
#[test]
fn escape_in_the_typed_confirms_box_is_swallowed_as_the_reference_leaves_it() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
assert_eq!(
@@ -502,6 +510,7 @@ fn escape_in_the_typed_confirms_box_is_swallowed_as_the_reference_leaves_it() {
/// destroy must not grow a field to type into. The control for every test above.
#[test]
fn the_plain_arm_shows_no_edit_box() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
pick_up_and_drop_in_world(&mut s); // Tough Jerky, quality 1
@@ -278,6 +278,7 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
/// is what the message frame's `OnHyperlinkClick` calls with the full markup.
#[test]
fn ctrl_clicking_a_chat_link_previews_it() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
@@ -412,6 +413,7 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
/// BOTH mouse edges, so one tap is 0.06 (decision 0638 §3).
#[test]
fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
@@ -41,6 +41,7 @@ fn load_dropdown_kit(s: &UiScript) {
/// `DropDownList1:Hide()` on its own.
#[test]
fn hiding_the_parent_list_closes_an_open_submenu() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
@@ -70,6 +71,7 @@ fn hiding_the_parent_list_closes_an_open_submenu() {
/// The divergence from the reference is in this file, so it is fixed and falsified in this file.
#[test]
fn hiding_a_list_clears_its_open_menu_registry_entry() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
@@ -110,6 +112,7 @@ fn hiding_a_list_clears_its_open_menu_registry_entry() {
/// equals some number: a literal would pass a number check and still be wrong.
#[test]
fn the_default_text_height_is_derived_from_button1_not_a_literal() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
@@ -141,6 +144,7 @@ fn the_default_text_height_is_derived_from_button1_not_a_literal() {
/// `GameFontHighlightSmall` by name because that is what `UIDropDownMenuButtonTemplate` declares.
#[test]
fn a_button_label_reports_the_font_object_its_button_set() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
@@ -27,6 +27,7 @@ fn setup() -> UiScript {
/// Accept queues `AcceptDuel()`.
#[test]
fn the_challenge_popup_shows_the_name_and_accept_queues_the_accept() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event(
"DUEL_REQUESTED",
@@ -54,6 +55,7 @@ fn the_challenge_popup_shows_the_name_and_accept_queues_the_accept() {
/// engine routes an escape through OnCancel.
#[test]
fn decline_and_escape_both_cancel() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("DUEL_REQUESTED", vec![ScriptValue::Str("Twomage".into())]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
@@ -69,6 +71,7 @@ fn decline_and_escape_both_cancel() {
/// StaticPopup engine's per-tick branch, and `DUEL_INBOUNDS` takes it away.
#[test]
fn out_of_bounds_counts_down_and_inbounds_dismisses_it() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("DUEL_OUTOFBOUNDS", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
@@ -100,6 +103,7 @@ fn out_of_bounds_counts_down_and_inbounds_dismisses_it() {
/// leave a stale popup or fire a second cancel.
#[test]
fn finishing_sweeps_both_dialogs_silently() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("DUEL_REQUESTED", vec![ScriptValue::Str("Twomage".into())]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
@@ -123,6 +127,7 @@ fn finishing_sweeps_both_dialogs_silently() {
/// their argument through so the app can resolve it.
#[test]
fn the_era_globals_queue_their_intents() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("AcceptDuel(); CancelDuel(); StartDuel('Onerogue'); StartDuelUnit('target')")
.unwrap();
@@ -115,8 +115,8 @@ fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
);
assert_eq!(cell_color(&mut s, WEAPON_CELL), Some(RED), "broken → red");
// Damaged legs (3 points left — the byte law's ABSOLUTE 1..=5, wow-re inventory-alert-law
// `cmp [D+0xa0],5`): every body piece shows, legs yellow, the un-alerted head the faded
// Damaged legs (3 points left — the byte law's ABSOLUTE 1..=5, `cmp [D+0xa0],5` at
// `0x4c8012`): every body piece shows, legs yellow, the un-alerted head the faded
// white(0.5); the weapon glyph (repaired now) hides.
let mut inv: InventorySlots = Default::default();
inv[7] = slot(39, Some((3, 25)));
@@ -162,7 +162,7 @@ fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The FLAGS bits (wow-re inventory-alert-law): `0x10` forces red regardless of durability;
/// The FLAGS bits (`0x4c7faa`, `0x4c7fc8`): `0x10` forces red regardless of durability;
/// `0x08` (wrapped gift) silences the region entirely. And the client's 12th region — low ammo
/// (carried count <= 20 → 3) — answers through `GetInventoryAlertStatus(12)` even though the
/// 1.12 FrameXML never reads it.
@@ -1,5 +1,5 @@
//! The two enchant-apply confirms (decision 0928, EnchantConfirm.xml): the Lua wiring between the
//! events `ui_action::targeting`'s item-bind gate fires and the shared StaticPopup engine, driven
//! events `spell::targeting`'s item-bind gate fires and the shared StaticPopup engine, driven
//! exactly as that gate drives it.
//!
//! What these pin is the seam, not the gate: that `BIND_ENCHANT` and `REPLACE_ENCHANT` reach a
@@ -32,6 +32,7 @@ fn setup() -> UiScript {
/// — which on the app side re-enters `0x495d60` with the confirmed flag, not a send.
#[test]
fn the_bind_confirm_shows_and_its_okay_queues_bind_enchant() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("BIND_ENCHANT", vec![]);
assert!(
@@ -52,6 +53,7 @@ fn the_bind_confirm_shows_and_its_okay_queues_bind_enchant() {
/// — the fire site pushes the enchant already on the item first, then the one about to land.
#[test]
fn the_replace_confirm_names_the_old_enchant_first() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event(
"REPLACE_ENCHANT",
@@ -73,6 +75,7 @@ fn the_replace_confirm_names_the_old_enchant_first() {
/// returned before `BindTarget` and the targeting word is still standing.
#[test]
fn declining_either_confirm_queues_nothing() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("BIND_ENCHANT", vec![]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
@@ -92,6 +95,7 @@ fn declining_either_confirm_queues_nothing() {
/// anything, so a cancelled or replaced cast must not leave a live Yes button behind.
#[test]
fn a_changed_pending_cast_dismisses_both() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("BIND_ENCHANT", vec![]);
s.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
@@ -139,9 +139,8 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
);
// **ESC does NOT drop the cursor's item.** Our retired ladder opened with
// `if CursorHasItem() then ClearCursor() end`; the reference's `ToggleGameMenu`
// (`UIParent.lua:1465-1497`) has no such arm, and wow-re's cursor carve places the
// ESC→ClearCursor wiring in FrameXML rather than the engine
// (`ui/scratch/cursor-dragdrop-payload.md`) — so in 1.12 the held item survives the key
// (`UIParent.lua:1465-1497`) has no such arm, and the ESC→ClearCursor wiring is inferred to
// live in FrameXML rather than the engine — so in 1.12 the held item survives the key
// (1988).
assert!(
s.cursor_item().is_some(),
@@ -203,6 +202,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
/// to eat, opens the menu (one eater per press, the 0449 law).
#[test]
fn escape_closes_the_options_window_before_opening_the_menu() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -526,6 +526,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
/// `Hide`, never `HideUIPanel`, because these frames hold no panel slot.
#[test]
fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -19,7 +19,8 @@
//!
//! ## What is compared, and what the table can and cannot say
//!
//! The oracle is wow-re's two byte-level censuses, vendored by `scripts/gen-reference-events.py`.
//! The oracle is two byte-level censuses of the reference, vendored by
//! `scripts/gen-reference-events.py`.
//! Its `conf` column carries the gate rule per row, exactly as `1.12-shapes.tsv` does:
//!
//! * `exact` — every producer that reaches the event has a known shape. **This is the column this
@@ -53,6 +53,7 @@ fn exp_bar_harness() -> UiScript {
/// transparent and the hover silently never fires, which no tooltip assertion alone would catch.
#[test]
fn the_xp_bar_takes_the_mouse_and_explains_itself() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.resolve();
@@ -106,6 +107,7 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
/// pool whose span runs past the level's end hides the tick but keeps the rested blue.
#[test]
fn the_exhaustion_tick_marks_where_rested_runs_out() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
@@ -113,8 +115,8 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// The binding trio underneath (the app-feed shape the XML consumes) — the byte-verified
// contract (wow-re rested-xp-bindings.md, decision 1087).
// The binding trio underneath (the app-feed shape the XML consumes) — the reference's
// contract (`0x48d350`, `0x48d3f0`, `0x516ea0`; decision 1087).
let (id, name, mult) = s
.eval::<(i64, String, f64)>("return GetRestState()")
.unwrap();
@@ -220,6 +222,7 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
/// hides it — the ref's own load order, ending hidden.
#[test]
fn the_max_level_rail_replaces_the_xp_bar_at_60() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
@@ -271,6 +274,7 @@ fn the_max_level_rail_replaces_the_xp_bar_at_60() {
/// re-stamp can lift a lower layer over them.
#[test]
fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.set_player_xp(300, 400);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
@@ -307,6 +311,7 @@ fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
#[test]
fn the_xp_bar_numerals_show_on_hover() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.set_player_xp(1234, 5678);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
@@ -342,6 +347,7 @@ fn the_xp_bar_numerals_show_on_hover() {
/// held-open newbie tip, once (the handshake is consumed).
#[test]
fn the_rest_state_line_joins_the_held_open_tooltip() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
@@ -11,10 +11,7 @@
//! (`hsv_to_rgb` scales all three outputs by `v` and by nothing else, so `rgb(h, s, v)` really is
//! `v · rgb(h, s, 1)`).
//!
//! Everything below is byte-verified in wow-re's `system/ui/scratch/colorselect-drawn-appearance.md`
//! (a §5 cross-check dispatched from this repo for exactly this window: six workers in three pairs,
//! one of each briefed cold, plus the orchestrator's own contiguous derivation). What it settled,
//! and what each fact cost to get wrong:
//! The reference's drawn appearance, and what each fact cost to get wrong:
//!
//! * **The disc is a 128×128 generated ARGB texture** (`0x78b580`, 16384 dwords into a
//! function-local static, uploaded once per process), drawn unmodulated — not a vertex-coloured
@@ -25,7 +22,7 @@
//! * **The texel lattice is integers**, `X, Y ∈ [−64, 63]`, drawn iff `X² + Y² ≤ 0x1000` —
//! boundary *inclusive* — and outside is `0x00000000`, a hard alpha cut with no mask and no
//! feathering. `S = √(X²+Y²)/64`; `H = (atan2(Y, X) + π)·180/π`, the exact inverse of the pick
//! law (`colorselect-color-law.md` §5), which is what makes clicking a texel select the colour
//! law (`0x78bd80`), which is what makes clicking a texel select the colour
//! that texel shows. Row 0 is `Y = +63`: the top.
//! * **The strip is an 8×8 solid texture of `HSV(H, S, 1)` times a vertical vertex gradient**,
//! black at the bottom and white at the top (`0x78b8a0` + `0x78b92a call 0x77f910`, and
@@ -140,8 +137,8 @@ mod tests {
}
/// The one property the disc must have: the colour a texel *shows* is the colour a click on
/// that texel *selects*. The right-hand side here is the pick law from wow-re
/// `colorselect-color-law.md` §5, which `benilla-ui`'s own `wheel_hs` transcribes — if the two
/// that texel *selects*. The right-hand side here is the reference's pick law
/// (`0x78bd80`), which `benilla-ui`'s own `wheel_hs` transcribes — if the two
/// ever part company, one of these sides moves and this fails.
#[test]
fn every_texel_shows_the_colour_a_click_on_it_would_pick() {
@@ -170,8 +167,9 @@ mod tests {
}
/// The disc's cardinal points, spelled out — this is what pins the *orientation* of the whole
/// image, and it is the half a sign error would silently mirror. wow-re read it off the fill
/// loop: **red LEFT, chartreuse BOTTOM, cyan RIGHT, violet TOP**, hue rising counter-clockwise.
/// image, and it is the half a sign error would silently mirror. Off the reference's fill loop
/// (`0x78b580`): **red LEFT, chartreuse BOTTOM, cyan RIGHT, violet TOP**, hue rising
/// counter-clockwise.
#[test]
fn the_discs_cardinal_hues_are_the_pick_laws() {
let (w, h, rgba) = wheel_pixels();
+17 -22
View File
@@ -574,9 +574,9 @@ pub(super) fn tick_script(
};
let (w, h) = (window.width(), window.height());
// The 768-virtual UI space (decision 0582 — byte law: the client's FrameXML space is ALWAYS
// 768 units tall, every aspect, mapped to the window; wow-re ui.md's converter identity
// `f(screenH) = 768` + the caret `H_px/192` law), times the uiScale dial (decision 0584 —
// the VM's screen is `768/uiScale` units tall, so a dial below 1 shrinks everything). The VM
// 768 units tall, every aspect, mapped to the window; the converter identity `f(screenH) = 768`
// (`0x41ad10`) + the caret `H_px/192` law (`0x77b8c0`)), times the uiScale dial (decision 0584
// — the VM's screen is `768/uiScale` units tall, so a dial below 1 shrinks everything). The VM
// lives entirely in that space: this seam scales quads ×s on the way out, mouse ÷s on the
// way in (input.rs), and measures ÷s on the way back. At a 768-tall window with the dial at
// 1 the whole pipeline is bit-identical to the pre-virtual behavior.
@@ -650,16 +650,10 @@ 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 the bottom-stack manage pass (decision 1499). Anchors follow the new
// screen rect by themselves; what does not is a seat somebody COMPUTED from the old
// height — the open-bag stack starts a fresh column when the current one would run off
// the top, and that decision is made from `GetScreenHeight()` at layout time. Without
// this, dragging the window smaller leaves the bag columns wrapped for the old height
// until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
// which is in-game UI, and this system also runs on the glue screens.
// A resize re-runs everything the interface COMPUTED from the old screen size — see
// [`super::manifest::on_screen_resized`].
if resized {
let _ = script
.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
super::manifest::on_screen_resized(&script);
}
script.tick(time.delta_secs());
}
@@ -928,7 +922,7 @@ pub(super) fn paint_script(
script.set_editbox_advances(req.id, req.key, cum, rows, cell_h / s);
}
}
// The focused edit box's text-UI geometry (RF-0082 leaves caret/highlight geometry to the
// The focused edit box's text-UI geometry (the engine leaves caret/highlight geometry to the
// host): which Text quad is the box's, the scroll window to draw, and the caret/selection
// x-spans within it — advance-derived engine-side (the blink phase too, `0x77a790`'s 0.5 s
// law in the engine tick), matched in the Text arm below.
@@ -1460,7 +1454,7 @@ fn convert_entry(
} => {
use crate::portrait::PortraitSource;
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
// device-pixel size, the render law's unit ladder off it, and where the composite
// device-pixel size, the reference's unit ladder off it, and where the composite
// goes (its rect, paint key, alpha and clip). The request is idempotent, so the
// memoized conversion may re-publish it freely; which panes draw is the engine's
// paint list, and the quad itself is the renderer's per-frame output
@@ -1481,18 +1475,19 @@ fn convert_entry(
crate::ui_models::TileRequest {
path: path.to_string(),
size_px,
// `1 model unit = 1280 · modelScale · layoutScale` FrameXML units (§3).
// `1 model unit = 1280 · modelScale · layoutScale` FrameXML units
// (`0x76d1a0`).
px_per_unit: 1280.0 * model_scale * layout * s * dpi,
// `SetPosition` is in layout units: `768 · √(a²+1)` FrameXML per unit.
pos_px_per_unit: 768.0 * diag * layout * s * dpi,
// A particle's half-extent: eye space, neither scale (§6).
// A particle's half-extent: eye space, neither scale (`0x7b2a50`).
star_px_per_unit: 768.0 * diag * s * dpi,
facing,
position: Vec3::new(position.0, position.1, position.2),
// The PERSPECTIVE leg's root (decision 2027), which is in model units,
// not pixels: `T(pos · layoutScale) · R(facing) · S(s)` with
// `s = G48·(5/3)·modelScale·layoutScale` — and `G48·(5/3)` is exactly
// `√((4/3)²+1)/√(a²+1)`, the 4:3 renormalizer (camera-law §11.2). The
// `√((4/3)²+1)/√(a²+1)`, the 4:3 renormalizer (`0x80655c`). The
// camera is carried through the same matrix, so both terms cancel for
// framing; they are here because the record's near/far are NOT scaled
// with them, and because the geometry has to be drawn somewhere.
@@ -1504,7 +1499,7 @@ fn convert_entry(
icon: icon.clone(),
rect,
z_key: eq.z,
// The instance draws at the widget's OWN alpha (render law §4.4).
// The instance draws at the widget's OWN alpha (`0x76d120`).
alpha: own_alpha,
clip,
},
@@ -1652,7 +1647,7 @@ fn convert_entry(
// used to fall through to `color.unwrap_or(WHITE)` with a `None` texture, which
// `ui_pass` renders as the shared 1×1 white image tinted white: an opaque white
// rectangle at the region's rect, which is how B221's macro icons reached the
// director's screen. wow-re settles what the reference does: `TextureCreate` does
// director's screen. What the reference does: `TextureCreate` does
// build an 8×8 placeholder, but `CSimpleTexture::SetTexture` (`0x770200`) checks the
// status severity and at ≥2 releases it and returns **without touching the widget's
// texture** — the widget keeps what it had, and Lua gets `nil`. Nothing goes white.
@@ -2584,7 +2579,7 @@ mod extract_gate_tests {
/// half that can silently rot: the shader's `select(a, k, premultiplied)` is only correct if
/// exactly the render-target quads carry the flag. Drop it and every additive effect the pane
/// draws over EMPTY space is multiplied by its own zero alpha again — the R14 pauldrons' fire
/// gone, a weapon glow chopped at the model's silhouette (Goudy, `#bugs` 2026-07-27).
/// gone, a weapon glow chopped at the model's silhouette.
#[test]
fn a_booth_bake_quad_is_flagged_premultiplied_and_a_plain_one_is_not() {
let mut app = app_with_marker();
@@ -2696,7 +2691,7 @@ mod extract_gate_tests {
before - 1,
"the cleared marker's quad is gone from the stitched list"
);
// The follow-up (0 → 1 quads) splices against the RE-DERIVED spans.
// The follow-up (0 → 1 quads) splices against the re-derived spans.
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
@@ -2782,7 +2777,7 @@ mod extract_gate_tests {
);
// The reference must share the app's HISTORY, not just its end state: `Show()` on a
// hidden frame re-stacks it to the tail of its draw bucket — the client's own
// `effective_visible_show 0x76ae10` re-adding it to the level's intrusive list — so B
// effective-visibility show `0x76ae10` re-adding it to the level's intrusive list — so B
// draws above C afterwards. (The splice got that right on its own; this reference did
// not, which is how the difference surfaced.)
let mut reference = app_from_script(&format!("{BUILD}\nhidden:Hide()\nhidden:Show()"));
@@ -332,7 +332,7 @@ pub(super) fn emit(
}
// The probe's seat line: the drawn INK rows (glyph-quad union, logical px, relative to the
// rect top) — the measurable half of the vertical-seat law
// (`fontstring-vertical-placement.md`): compare `ink` against the law's `d + ascender` seat
// (`0x5d1360`): compare `ink` against the law's `d + ascender` seat
// when hunting a vertical offset. Fill quads only (the shadow pass above would smear the
// bounds one px down-right).
let vpl = style.world_seat && benilla_assets::trace::enabled_for("vpl");
@@ -382,7 +382,7 @@ pub(super) fn emit(
#[allow(clippy::cast_precision_loss)]
let top = top + ui.caret_row as f32 * cell_h;
// caret_x is engine UI units (÷scale advances); the 1-px bar width stays device-thin
// (the client's 4-unit caret is a named residual, ui.md's caret law).
// (the client's 4-unit caret, `0x77b8c0`, is a named residual).
let cx = x0 + ui.caret_x * host.scale;
out.push(UiQuad {
rect: Rect::new(cx, top, cx + 1.0, top + cell_h),
@@ -11,6 +11,7 @@ use super::test_ui::load_ui as load_xml;
#[test]
fn a_started_fade_ramps_off_uiparents_tick_and_completes() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
@@ -67,6 +68,7 @@ fn a_started_fade_ramps_off_uiparents_tick_and_completes() {
/// twelve faux lists shipped unable to scroll (1868), so this runs a real flash to completion.
#[test]
fn a_flash_alternates_then_stops() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
@@ -78,6 +78,7 @@ fn harness() -> UiScript {
/// wear instead of a `Benilla*` one.)
#[test]
fn a_fresh_faux_frame_loads_at_the_top_with_no_range() {
benilla_formats::wow_data_or_skip!();
let s = harness();
assert_eq!(s.eval::<i64>("return TestScroll.offset").unwrap(), 0);
let (lo, hi) = s
@@ -98,6 +99,7 @@ fn a_fresh_faux_frame_loads_at_the_top_with_no_range() {
/// row, and `FauxScrollFrame_Update` returns the reference's `showScrollBar`.
#[test]
fn more_rows_than_fit_raise_the_bar_over_the_overflow_range() {
benilla_formats::wow_data_or_skip!();
let s = harness();
let shown = s
.eval::<i64>("return FauxScrollFrame_Update(TestScroll, 12, 5, 16)")
@@ -139,6 +141,7 @@ fn more_rows_than_fit_raise_the_bar_over_the_overflow_range() {
/// in five slots shows nothing, and there is no off-by-one that leaves a dead bar up.
#[test]
fn a_list_that_exactly_fits_shows_no_bar() {
benilla_formats::wow_data_or_skip!();
let s = harness();
let shown = s
.eval::<Option<i64>>("return FauxScrollFrame_Update(TestScroll, 5, 5, 16)")
@@ -176,6 +179,7 @@ fn a_list_that_exactly_fits_shows_no_bar() {
/// this test used to read a clamped offset immediately.
#[test]
fn a_shrinking_list_clamps_the_offset_back_into_range() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("FauxScrollFrame_Update(TestScroll, 20, 5, 16)")
.unwrap();
@@ -238,6 +242,7 @@ fn a_shrinking_list_clamps_the_offset_back_into_range() {
/// it leaves (ref UIPanelTemplates.lua l.205-223).
#[test]
fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
benilla_formats::wow_data_or_skip!();
let s = harness();
s.run(
"FauxScrollFrame_Update(TestScroll, 12, 5, 16, \"TestRow\", 280, 300, TestHighlight, 276, 296)",
@@ -280,6 +285,7 @@ fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
/// value model in one test (pixels on the bar, rows in the offset, `floor(v/step + 0.5)` between).
#[test]
fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("TestRepaints = 0 function TestUpdate() TestRepaints = TestRepaints + 1 end")
.unwrap();
@@ -354,6 +360,7 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
/// → `FauxScrollFrame_OnVerticalScroll`'s `floor(v / step + 0.5)`, the reference's path end to end.
#[test]
fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
load_inline(
&s,
@@ -416,6 +423,7 @@ fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
/// on that fallback, so the no-argument path is what this test drives.
#[test]
fn scrolling_edit_helpers_answer_bare_calls_from_a_handler() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "ScrollTemplates.xml");
@@ -1,7 +1,7 @@
//! **The reference's frame FLAGS are the test oracle** — the flag half of decision 0675's "the
//! reference file is the test oracle", and the gate for the class decision 1739 closed.
//!
//! `framexml_diff` guards the *numbers* a transcribed window carries. Nothing guarded its *flags*,
//! A window's numbers are the stock file's own now. Nothing guarded its *flags*,
//! and they went quietly wrong at scale: on 2026-08-30 a sweep found **47 frames** the reference
//! marks `toplevel` and ours did not (so no window ever came to the front), **23** the reference
//! makes mouse-interactive and ours did not (so a click on a window's own background fell through
@@ -14,8 +14,8 @@
//! **Our side is read from the ENGINE, never from our XML.** An attribute-vs-attribute diff is
//! precisely what let the class hide, twice over: benilla renames templates (`ChatFrameTemplate` →
//! `BenillaChatFrameTemplate`), so a name-keyed text diff reports a gap of zero on a window that is
//! entirely missing the flag; and the `<Scripts>` **auto-enable** law (wow-re
//! `ui/scratch/scripts-auto-enable.md` §1, VERIFIED — an `<OnEnter>`/`<OnLeave>`/`<OnMouseDown>`/
//! entirely missing the flag; and the `<Scripts>` **auto-enable** law (an
//! `<OnEnter>`/`<OnLeave>`/`<OnMouseDown>`/
//! `<OnMouseUp>`/`<OnDragStart>` reaches the same enable primitive `0x76af00(2,-1)` the attribute
//! does) makes `enableMouse=` a poor proxy for whether the frame actually takes the mouse. Asking
//! the loaded engine — `IsToplevel()`, `IsMouseEnabled()`, `GetID()`, `GetParent()` — is immune to
@@ -71,11 +71,10 @@
//! can hide a divergence, never invent one — so nothing it misses turns into a false failure. It
//! would be a `KNOWN` entry's reason if any frame still needed one.
//!
//! The whole module skips cleanly with no install — `_extracted_framexml/` is a gitignored
//! Blizzard asset, like every other client-data test here.
//! The whole module skips cleanly with no install — the reference corpus is read off the
//! player's own patch chain, like every other client-data test here.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::collections::{HashMap, HashSet};
use benilla_ui::framexml::{self, Element, TopLevel};
@@ -104,7 +103,7 @@ const FRAME_TAGS: &[&str] = &[
];
/// The five `<Scripts>` handler names that auto-enable the MOUSE kind, and only those five
/// (wow-re `ui/scratch/scripts-auto-enable.md` §1's kind-2 OR-chain, `0x769fb7`..`0x76a022`).
/// (the kind-2 OR-chain, `0x769fb7`..`0x76a022`).
/// `OnDragStop`/`OnReceiveDrag` bind a slot and trip no enable — they are deliberately absent.
const MOUSE_HANDLERS: &[&str] = &[
"OnEnter",
@@ -232,34 +231,54 @@ const KNOWN: &[Known] = &[
// window is the reference's own now, scroll children and all.
];
/// The extracted reference FrameXML directory, or `None` when the install isn't there.
fn reference_dir() -> Option<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../WoW/_extracted_framexml");
dir.is_dir().then_some(dir)
/// Every reference FrameXML document, read off the player's own patch chain in the client's
/// load order: the files `FrameXML.toc` lists, and what each `<Include>`s, each once. `None`
/// without an install. Off the chain, never an extracted folder, so the gate is armed on any
/// machine with the install rather than on the one that happened to extract it.
fn reference_documents() -> Option<Vec<framexml::ParsedDocument>> {
let data = benilla_formats::wow_data()?;
let chain = benilla_formats::open_chain(&data).ok()?;
let toc = chain.read("Interface\\FrameXML\\FrameXML.toc").ok()?;
let toc = benilla_ui::toc::Toc::parse(&benilla_ui::source::decode(&toc));
let mut queue: Vec<String> = toc
.files
.iter()
.filter(|f| f.to_ascii_lowercase().ends_with(".xml"))
.map(|f| format!("Interface\\FrameXML\\{f}"))
.collect();
let mut seen = HashSet::new();
let mut docs = Vec::new();
while !queue.is_empty() {
let path = queue.remove(0);
if !seen.insert(path.to_ascii_lowercase()) {
continue;
}
let Ok(bytes) = chain.read(&path) else {
continue;
};
// Blizzard ships a UTF-8 BOM on some of these and stray high bytes in comments; the parse
// is what matters, so read lossily rather than refusing the file.
let text = String::from_utf8_lossy(&bytes);
let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
continue;
};
let dir = path.rsplit_once('\\').map(|(d, _)| d).unwrap_or("");
for item in &doc.items {
if let TopLevel::Include(file) = item {
queue.push(format!("{dir}\\{file}"));
}
}
docs.push(doc);
}
Some(docs)
}
/// Every named *frame* element in the reference corpus, keyed by name — templates and instances
/// alike, nested `<Frames>` included, `$parent`-relative names excluded (they repeat across
/// templates and name nothing on their own).
fn reference_frames() -> Option<HashMap<String, Element>> {
let dir = reference_dir()?;
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")))
.collect();
paths.sort();
let mut out: HashMap<String, Element> = HashMap::new();
for path in paths {
// Blizzard ships a UTF-8 BOM on some of these and stray high bytes in comments; the parse
// is what matters, so read lossily rather than refusing the file.
let bytes = std::fs::read(&path).ok()?;
let text = String::from_utf8_lossy(&bytes);
let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
continue;
};
for doc in reference_documents()? {
for item in &doc.items {
if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
collect_named(el, &mut out);
@@ -315,24 +334,8 @@ fn collect_nesting(
/// The reference's nesting table, built over the same corpus [`reference_frames`] reads.
fn reference_nesting() -> Option<HashMap<String, Option<String>>> {
let dir = reference_dir()?;
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")))
.collect();
paths.sort();
let mut out: HashMap<String, Option<String>> = HashMap::new();
for path in paths {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let text = String::from_utf8_lossy(&bytes);
let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
continue;
};
for doc in reference_documents()? {
for item in &doc.items {
if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
collect_nesting(el, None, &mut out);
@@ -396,7 +399,7 @@ fn declares_handler(
}
/// Whether the reference's frame of this name **takes the mouse** once loaded — the three ways
/// `0x76af00(2, -1)` is reached, per `scripts-auto-enable.md` §1.3: the widget's own ctor, the
/// `0x76af00(2, -1)` is reached: the widget's own ctor, the
/// `enableMouse` attribute, or an auto-enabling `<Scripts>` handler.
fn reference_takes_mouse(name: &str, frames: &HashMap<String, Element>) -> bool {
let Some(el) = frames.get(name) else {
@@ -436,14 +439,8 @@ fn describe(frame: &str, flag: Flag, ours: &str, theirs: &str) -> String {
/// `parent="UIParent"` back on `BlackoutWorld` and it names that.
#[test]
fn the_shipped_frames_carry_the_references_flags() {
// Two gates, because the corpus and the manifest are two assets: the extracted reference
// dir below, and the chain `load_default_ui` reads — under `WOW_DATA=` (1451) the first is
// still there and the second is not.
let _data = benilla_formats::wow_data_or_skip!();
let Some(reference) = reference_frames() else {
eprintln!("skipping: no extracted reference FrameXML (WoW/_extracted_framexml)");
return;
};
let reference = reference_frames().expect("the reference FrameXML off the player's chain");
let nesting = reference_nesting().expect("the same corpus the frames came from");
assert!(
reference.len() > 500,
@@ -1,171 +0,0 @@
//! **The reference file is the test oracle** (decision 0675) — the shared half of it.
//!
//! A transcribed window's geometry errors are invisible to every behavioural test: the frames
//! load, the buttons click, the lists populate, and only the *look* is wrong. 0675 built the
//! detector for `FriendsFrame` after two rounds of the director's eye caught what a one-number
//! diff would have caught on its first run, and closed with "for the next transcribed window,
//! write the diff test first". This is that scraper, lifted out of `friends_tests` so the next
//! window costs a call rather than a copy (`QuestTimerFrame` is the first to pay the lower price).
//!
//! Deliberately narrow: it does not try to understand the XML, only to notice that a number moved.
//! Two properties make it a guard rather than a comfort, and both are the caller's to keep —
//! **known-benign differences are an explicit list, never a pattern**, so a new difference cannot
//! hide inside a tolerance; and **it must be verified to fail** (perturb one number by 1 px and
//! watch it name the element).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The extracted reference FrameXML file of that name, or `None` when the install isn't there
/// (these tests skip cleanly without it — it is a gitignored Blizzard asset).
pub(super) fn reference(file: &str) -> Option<PathBuf> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../WoW/_extracted_framexml")
.join(file);
path.is_file().then_some(path)
}
/// Named element → the `<AbsDimension>` pairs inside it, up to the next named element.
///
/// Hand-rolled rather than a regex: this crate has no regex dependency, and adding one for a scan
/// of two files is a worse trade than twenty lines of `find`.
///
/// `$parent*` names repeat across templates (every row template has a `$parentText`), so a bare
/// name would compare one template's column against another's. Each is qualified by the template
/// it lives in — the nearest preceding real name.
fn scrape(text: &str) -> Vec<(String, Vec<(f32, f32)>)> {
const TAGS: &[&str] = &[
"<Button name=\"",
"<Frame name=\"",
"<EditBox name=\"",
"<ScrollFrame name=\"",
"<FontString name=\"",
"<Texture name=\"",
"<CheckButton name=\"",
];
// Every (offset, name) where a named element starts, in document order.
let mut marks: Vec<(usize, String)> = Vec::new();
for tag in TAGS {
let mut from = 0;
while let Some(hit) = text[from..].find(tag) {
let at = from + hit;
let name_start = at + tag.len();
let Some(len) = text[name_start..].find('"') else {
break;
};
marks.push((at, text[name_start..name_start + len].to_string()));
from = name_start + len;
}
}
marks.sort_by_key(|(at, _)| *at);
let mut owner = String::new();
marks
.iter()
.enumerate()
.map(|(i, (at, name))| {
let end = marks.get(i + 1).map_or(text.len(), |(next, _)| *next);
let key = if let Some(child) = name.strip_prefix("$parent") {
format!("{owner}/{child}")
} else {
owner = name.clone();
name.clone()
};
(key, dimensions(&text[*at..end]))
})
.collect()
}
/// Every `<AbsDimension x=".." y=".."/>` in `chunk`, in order.
fn dimensions(chunk: &str) -> Vec<(f32, f32)> {
const OPEN: &str = "<AbsDimension x=\"";
let mut out = Vec::new();
let mut rest = chunk;
while let Some(hit) = rest.find(OPEN) {
rest = &rest[hit + OPEN.len()..];
let Some((x, tail)) = rest.split_once('"') else {
break;
};
let Some(y_at) = tail.find("y=\"") else { break };
let Some((y, tail)) = tail[y_at + 3..].split_once('"') else {
break;
};
if let (Ok(x), Ok(y)) = (x.parse(), y.parse()) {
out.push((x, y));
}
rest = tail;
}
out
}
/// Assert every element benilla's `ours` file shares with the reference `reference` file carries
/// the reference's `<AbsDimension>` numbers.
///
/// - `ours` is a path under `assets/ui/`; names are matched to the reference's after stripping the
/// house `Benilla` prefix (a window whose frames keep the bare reference names — decision 0591
/// §3 — matches unchanged).
/// - `expected` lists the *deliberate* deviations by their REFERENCE name. Every entry earns a
/// comment at the call site naming its reason; that is what keeps this a guard.
/// - `min_compared` fails the test if fewer elements paired up than that, so a scrape or a naming
/// change that quietly stops comparing anything is caught rather than reported as a pass.
///
/// Elements ours has and the reference does not (our own template faces) are skipped, not failed.
pub(super) fn assert_geometry_matches(
ours: &str,
reference: &Path,
expected: &[&str],
min_compared: usize,
) {
assert_geometry_matches_text(
ours,
&std::fs::read_to_string(reference).unwrap(),
expected,
min_compared,
);
}
/// [`assert_geometry_matches`] against reference TEXT rather than a file on disk.
///
/// The split exists because not every reference file is *on* disk: `_extracted_framexml/` holds
/// FrameXML only, and a window whose specification is partly a `Blizzard_*` **addon** — the raid
/// grid is the first, decision 1549 — has to read its half straight out of the patch chain
/// (`benilla_formats::open_chain`, the same door `mpqx` uses). Reading it rather than requiring
/// someone to have extracted it first is what keeps that half of the diff ARMED instead of
/// skipped, and it writes nothing into the install.
pub(super) fn assert_geometry_matches_text(
ours: &str,
reference_text: &str,
expected: &[&str],
min_compared: usize,
) {
let ours_text = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(ours),
)
.unwrap();
let theirs: HashMap<String, Vec<(f32, f32)>> = scrape(reference_text).into_iter().collect();
let mut compared = 0;
let mut drifted = Vec::new();
for (name, dims) in scrape(&ours_text) {
let key = name.replace("Benilla", "");
let Some(ref_dims) = theirs.get(&key) else {
continue; // ours alone (the local template faces) — nothing to compare against
};
compared += 1;
if dims != *ref_dims && !expected.contains(&key.as_str()) {
drifted.push(format!("{key}: ours {dims:?} != ref {ref_dims:?}"));
}
}
assert!(
compared >= min_compared,
"only {compared} elements of {ours} matched by name (expected >= {min_compared}) — the \
scrape or the naming broke"
);
assert!(
drifted.is_empty(),
"{ours} geometry differs from the reference FrameXML:\n {}",
drifted.join("\n ")
);
}
@@ -124,7 +124,7 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
/// `FriendsFrameTab1..4` inherit `FriendsFrameTabTemplate`, which inherits
/// `CharacterFrameTabButtonTemplate` (the reference's own file, on the chain since 1993). The
/// middle template declares an `<OnClick>` and nothing else, and handler replacement is **per
/// handler name** (wow-re `template-onload-replacement-law.md`) — so the base template's
/// handler name** (`0x76a0d0`) — so the base template's
/// `<OnShow>` fit still runs, two hops down. That is the arrangement this pins: a row of tabs
/// still wearing the base template's authored 115 would mean the OnShow was lost on the way.
#[test]
@@ -784,8 +784,8 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
.contains(&SocialRequest::SelectFriend(2)));
}
/// **B363 — the who list reaches its last rows.** Liho's `/who` found 49, showed 17, and the knob
/// travelled while the rows stayed. On the stock window the mechanism is a one-row loss:
/// **B363 — the who list reaches its last rows.** The symptom: a `/who` of 49 shows 17, and the
/// knob travels while the rows stay. On the stock window the mechanism is a one-row loss:
/// `WhoListScrollFrame` is 287 tall (stock `FriendsFrame.xml` l.1661) against seventeen rows of
/// sixteen, so the child's overflow past the frame, `n × 16 − 287`, sits fifteen pixels under the
/// bar's `(n − 17) × 16`, and an engine that clamped `SetVerticalScroll` into that overflow
@@ -884,9 +884,9 @@ fn the_column_headers_sort_by_their_own_keys() {
/// **The Show Offline Members checkbox EXISTS.** The reference declares it `virtual="true"` inside
/// a `<Frames>` block, which reads like "this is a template, do not build it" — and is not: the
/// `virtual` attribute is only consulted by the top-level file loader (wow-5875-re rf24,
/// `0x6ede10`), while a `<Frames>` child goes straight to the instantiator via `LoadChildFrames`
/// (rf26). So the box is a real frame in the reference and must be one here.
/// `virtual` attribute is only consulted by the top-level file loader (`0x6ede10`), while a
/// `<Frames>` child goes straight to the instantiator via `LoadChildFrames 0x76a060`. So the box
/// is a real frame in the reference and must be one here.
///
/// Its click also DROPS THE SELECTION before re-filtering: the roster is about to be re-ordered,
/// so index 7 will not be the member index 7 was.
@@ -281,7 +281,7 @@ fn the_bar_takes_the_fraction_and_the_faction_colour() {
let _data = benilla_formats::wow_data_or_skip!();
let s = shown_honor_page();
// Against the binding's own arithmetic, not against `191/255`. The client MULTIPLIES by the
// f32 nearest 1/255 (`0x3B808081`, wow-re `honor-panel-law.md` `0x51aace`) rather than
// f32 nearest 1/255 (`0x3B808081`, `0x51aace`) rather than
// dividing, and the two answers differ in the eighth decimal — a tolerance loose enough to
// accept both would stop pinning the correction the moment it was made.
let want = 191.0 * f64::from(f32::from_bits(0x3B80_8081));
+127 -14
View File
@@ -13,6 +13,7 @@ use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
use crate::bindings::WheelNotches;
use crate::textinput::{self, keymap, HostClipboard};
/// The pointer-side state [`feed_ui_input`] reads and writes, as one
@@ -58,6 +59,28 @@ impl PointerFeed<'_> {
}
}
/// The frame's wheel travel into the pane under the cursor's `OnMouseWheel` — **one call per whole
/// notch**, `arg1 = ±1`.
///
/// The travel is normalised to lines first ([`crate::bindings::wheel_lines`]) and the fraction
/// carried in `notches`: a trackpad reports a gesture as a `Pixel` trickle across many frames, and
/// passing each frame's raw delta fired the handler once per frame — the stock handlers act on
/// the sign alone (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call), so a gentle
/// swipe slammed the pane to its end. A mouse wheel's `Line` notch still fires once, as before.
fn feed_wheel(
script: &mut UiScript,
notches: &mut WheelNotches,
x: f32,
y: f32,
scroll: &AccumulatedMouseScroll,
) {
let whole = notches.feed(crate::bindings::wheel_lines(scroll.unit, scroll.delta.y));
let step = whole.signum() as f32;
for _ in 0..whole.unsigned_abs() {
script.mouse_wheel(x, y, step);
}
}
/// Feed the window's cursor + buttons + wheel + keyboard into the UI engine (after
/// [`super::extract::tick_script`] has resolved this frame's rects), firing
/// OnEnter/OnLeave/OnClick/OnMouseWheel and the EditBox
@@ -73,7 +96,9 @@ pub(super) fn feed_ui_input(
// winit has actually created the surface.
window: Query<(&Window, Option<&bevy::window::RawHandleWrapper>), With<PrimaryWindow>>,
buttons: Res<ButtonInput<MouseButton>>,
scroll: Res<AccumulatedMouseScroll>,
// The frame's wheel travel, and the fraction of a notch carried between frames
// ([`feed_wheel`]) — one param for clippy's argument ceiling.
(scroll, mut notches): (Res<AccumulatedMouseScroll>, ResMut<WheelNotches>),
// One [`PointerFeed`] (clippy's argument ceiling): the hover + click-consumed outputs this
// pass writes, the world pick that routes the world-click payload legs (decision 0571), and
// the payload-held mirror written for the Send-side world-click consumers.
@@ -219,9 +244,7 @@ pub(super) fn feed_ui_input(
script.mouse_button(x, y, name, false);
}
}
if scroll.delta.y != 0.0 {
script.mouse_wheel(x, y, scroll.delta.y);
}
feed_wheel(&mut script, &mut notches, x, y, &scroll);
} else if !synthetic {
// The OS pointer left the window: leave whatever frame was hovered (once, on the
// Some→None transition) and — every frame it stays outside — clear any armed press/drag
@@ -239,7 +262,7 @@ pub(super) fn feed_ui_input(
capture.typing = script.has_keyboard_focus();
// The per-key half is this frame's alone (a frame's existence gate ate THIS key).
capture.consumed.clear();
// ── The alt-arrow exemption (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED) ─────────
// ── The alt-arrow exemption ────────────────────────────────────────────────────────────────
// A focused EditBox in alt-arrow mode (`ignoreArrows` in XML, `SetAltArrowKeyMode` in Lua)
// does NOT consume LEFT/UP/RIGHT/DOWN unless ALT is held: the reference's handler returns 0
// at `0x77b1c4` and the strata walk carries the key down to `CGWorldFrame`, which runs its
@@ -287,7 +310,7 @@ pub(super) fn feed_ui_input(
// `frame_key_input` walks in the engine's own order and declines at a focused box, so the
// box's editing keys can never be stolen by a frame below it; a `true` here means a frame
// consumed the key, which suppresses BOTH the chord below and the key's binding (the
// reference's existence gate, wow-re `frame-key-script-delivery.md` §3).
// reference's existence gate, `0x76b7d0`).
let frame_named = match ev.key_code {
KeyCode::Backspace => Some("BACKSPACE"),
KeyCode::Delete => Some("DELETE"),
@@ -310,12 +333,12 @@ pub(super) fn feed_ui_input(
}
// EVERY OTHER KEY reaches a keyboard frame by name too. The reference's key-down walk
// carries the whole key table — its `arg1` comes from the *same* table the keybinding
// chord uses (wow-re `frame-key-script-delivery.md` §4.2), which is [`chord::key_token`]
// here — and the frame's gate is EXISTENCE, not handling: a shown keyboard frame with an
// `OnKeyDown` swallows the key whatever its script does with it (§3, §3.1). This host used
// to feed only the ten names above, so `CinematicFrame` — fullscreen, keyboard-enabled, an
// `OnKeyDown` that answers ESCAPE — consumed ESC and let W straight through, and the player
// walked around underneath their own intro cinematic.
// chord uses (`0x4b66b0`), which is [`chord::key_token`] here — and the frame's gate is
// EXISTENCE, not handling: a shown keyboard frame with an `OnKeyDown` swallows the key
// whatever its script does with it (`0x76b7d0`, `0x76ba25`). This host used to feed only
// the ten names above, so `CinematicFrame` — fullscreen, keyboard-enabled, an `OnKeyDown`
// that answers ESCAPE — consumed ESC and let W straight through, and the player walked
// around underneath their own intro cinematic.
//
// The reference's own Lua is the proof this is the law and not an over-reading: that
// handler has to call `RunBinding("SCREENSHOT")` **by hand** to get one key back. It would
@@ -368,8 +391,8 @@ pub(super) fn feed_ui_input(
// because `arrows_fall_through` exempts exactly these four from the typing gate.
keymap::Chord::Edit(_) => {}
// The clipboard trio needs the OS pasteboard, so it resolves here against the held
// [`HostClipboard`]: copy/cut pull the selection out of the box (RF-0082 §4
// `0x77e1d0` — selection required; a password box yields its mask run, never the
// [`HostClipboard`]: copy/cut pull the selection out of the box
// (`0x77e1d0` — selection required; a password box yields its mask run, never the
// real text), paste sanitizes+inserts.
keymap::Chord::Copy => {
if let Some(text) = script.editbox_copy() {
@@ -417,3 +440,93 @@ pub(super) fn feed_ui_input(
// bare-key window toggles — moved into the command registry (`crate::bindings::commands`,
// decision 0997): one chord→command table, rebindable, with 0585's modifier law enforced once
// in the dispatch instead of per branch here.
#[cfg(test)]
mod tests {
use super::*;
use bevy::input::mouse::MouseScrollUnit;
/// A bare VM with one wheel-enabled pane at the screen's centre whose `OnMouseWheel` counts
/// its calls and sums its `arg1`s.
fn wheel_pane() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local f = CreateFrame("Frame", "WheelProbe")
f:SetWidth(200) f:SetHeight(200) f:SetPoint("CENTER", nil, "CENTER", 0, 0)
f:EnableMouseWheel(true)
f:SetScript("OnMouseWheel", function()
WheelCalls = (WheelCalls or 0) + 1
WheelSum = (WheelSum or 0) + arg1
end)
f:Show()
"#,
)
.unwrap();
s.resolve();
s
}
fn calls(s: &UiScript) -> (i64, f64) {
s.eval::<(Option<i64>, Option<f64>)>("return WheelCalls, WheelSum")
.map(|(c, v)| (c.unwrap_or(0), v.unwrap_or(0.0)))
.unwrap()
}
fn frame_of(unit: MouseScrollUnit, dy: f32) -> AccumulatedMouseScroll {
AccumulatedMouseScroll {
unit,
delta: Vec2::new(0.0, dy),
}
}
/// **A gentle trackpad swipe is not a notch a frame.** Ten frames of a small `Pixel` delta —
/// a tenth of a line apiece at Bevy's factor, one line in total — must fire the pane's
/// `OnMouseWheel` at most the ONE notch they add up to. Fed raw, it fired ten times, and a
/// stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call off the sign alone.
#[test]
fn a_trackpad_trickle_fires_only_the_notches_it_adds_up_to() {
let mut s = wheel_pane();
let mut notches = WheelNotches::default();
let step = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR / 10.0;
for _ in 0..10 {
feed_wheel(
&mut s,
&mut notches,
512.0,
384.0,
&frame_of(MouseScrollUnit::Pixel, step),
);
}
let (n, sum) = calls(&s);
assert!(
n <= 1,
"ten frames adding up to one line fired {n} wheel calls (sum {sum})"
);
assert!(sum <= 1.0, "…and they may move at most one notch: {sum}");
}
/// A mouse wheel's notch arrives as one `Line` delta and fires exactly once, `arg1 = 1`.
#[test]
fn a_line_notch_fires_once() {
let mut s = wheel_pane();
let mut notches = WheelNotches::default();
feed_wheel(
&mut s,
&mut notches,
512.0,
384.0,
&frame_of(MouseScrollUnit::Line, 1.0),
);
assert_eq!(calls(&s), (1, 1.0));
feed_wheel(
&mut s,
&mut notches,
512.0,
384.0,
&frame_of(MouseScrollUnit::Line, -1.0),
);
assert_eq!(calls(&s), (2, 0.0), "and the other way is a notch down");
}
}
@@ -244,8 +244,8 @@ fn inspect_unit_refuses_out_of_range() {
/// The two verified thresholds and their **different operators** — `CanInspect` refuses only on a
/// strict `100.0 < d²` (so exactly 100.0 is IN range), while `CheckInteractDistance` admits only on
/// a strict `d² < table[type-1]` (so exactly 100.0 is OUT). That asymmetry is the binary's own
/// (wow-re §5: `test ah,0x41; jne` vs `test ah,0x5; jp`), and it is the kind of detail a rewrite
/// silently normalizes — hence a test on the boundary itself.
/// (`test ah,0x41; jne` at `0x48a28b` vs `test ah,0x5; jp` at `0x48bb0a`), and it is the kind of
/// detail a rewrite silently normalizes — hence a test on the boundary itself.
#[test]
fn range_predicates_transcribe_the_verified_thresholds() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -280,7 +280,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
"the same distance is out of range for the 10-yard type"
);
// **A token the object manager holds no unit for answers nil — the null-object arm** (report
// B316, wow-re `dist2-null-unit-arm.md` VERIFIED). This is the party member outside the local
// B316, `0x48babe`). This is the party member outside the local
// area: the roster wire gives them a GUID, so the token resolves, and the object lookup then
// misses silently. It used to answer 1, which lit every distance row for exactly the member
// who was furthest away.
@@ -533,7 +533,7 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
/// reference's own behaviour** (decision 1834 settled what 1832 had to leave open).
///
/// `InspectFrame_OnHide` sets `this.unit = nil`, so a re-open runs `InspectFrame_OnShow` with no
/// token. wow-re censused the whole binding table: `SetPortraitTexture 0x519ef0` gates its unit
/// token. In the reference, `SetPortraitTexture 0x519ef0` gates its unit
/// argument at `0x519fb4` with `lua_isstring` and raises `Usage: SetPortraitTexture(texture,
/// "unit")`, and `luaL_error` does not return — so the reference raises here too, on its own
/// shipped code. The widget clear sits six instructions PAST the gate, so the portrait is not even
@@ -544,7 +544,7 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
/// abandons the handler the same way. So: the window shows, the portrait and name do not update,
/// and there is exactly one error — which is what this now asserts.
///
/// One correction from that census worth keeping: stock 1.12 ships **no** inspect keybind (0 of 234
/// One correction worth keeping: stock 1.12 ships **no** inspect keybind (0 of 234
/// `Binding name=` entries) and no caller of `ToggleInspect` outside the addon's own tab buttons,
/// which are children of the hidden `InspectFrame`. So on the stock UI's own paths this is
/// unreachable — only an addon or a macro gets here. It is still ours to reproduce.
@@ -96,6 +96,7 @@ const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
#[test]
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
assert!(s
@@ -191,6 +192,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
#[test]
fn the_capture_flow_binds_steals_and_refuses_like_112() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
@@ -300,6 +302,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
#[test]
fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
@@ -348,6 +351,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
#[test]
fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
// The ladder (the ESC binding's own body): the options rung hides the window — since 1008
@@ -400,6 +404,7 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
#[test]
fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run(r#"BINDING_NAME_JUMP = "Jump""#).unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -463,6 +468,7 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
#[test]
fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// ref UIParent.lua:1819 transcribed (UIParent.xml): one modifier abbreviates…
assert_eq!(
@@ -519,6 +525,7 @@ fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
/// own -32 — anchoring it to body-right hung the bar on the window border.
#[test]
fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
// Every section open: 100+ flat rows — the list overflows its 19 slots and the bar shows.
@@ -543,8 +550,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
// `FauxScrollFrameTemplate` carries a real `<ScrollChild>` which `FauxScrollFrame_Update` sizes
// to `numItems * valueStep` (~5275px with every section open), our old template had none, and
// the range is measured by unioning the scroll child's whole SUBTREE — the reference's own
// `0x786f80` recursion, which "re-enters itself for each shown child frame" with no clip or
// ScrollFrame exception (wow-re `system/ui/ui.md`, decision 1338). The reference would measure
// `0x786f80` recursion, which re-enters itself for each shown child frame with no clip or
// ScrollFrame exception (decision 1338). The reference would measure
// the same; it simply never nests a faux list inside a real-scroll page, which our options
// window is alone in doing.
//
@@ -694,6 +701,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
/// path a player actually finds a binding by — paints the row live with that key on its capsule.
#[test]
fn the_pet_lane_is_registered_under_the_action_bar_header() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
+69 -58
View File
@@ -13,6 +13,7 @@ use bevy::prelude::*;
use benilla_assets::LockRecover;
use benilla_ui::script::UiScript;
use super::manifest::silenced_ui_load;
use super::{
load_font_registry, load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock,
UiKeyboardCapture,
@@ -45,8 +46,7 @@ pub(crate) fn setup_script(world: &mut World) {
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
/// wow-re's §5 for this change (2277).
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there (2277).
fn install_boot_vm(world: &mut World) {
let mut script = match UiScript::new() {
Ok(s) => s,
@@ -72,7 +72,7 @@ fn install_boot_vm(world: &mut World) {
/// it where the process already is, and [`UiClock`] is re-anchored to match in the same breath.
///
/// The reference's `GetTime` (`0x515ea0`) reads `KERNEL32!GetTickCount` and scales by 0.001 (the
/// thunk `0x42c010` → `0x42b790`, wow-re's `core` boundary row): an OS clock, with no relationship
/// thunk `0x42c010` → `0x42b790`): an OS clock, with no relationship
/// to the Lua VM, that cannot restart. Ours is a Lua global the VM owns, and since 1290/1291 the VM
/// is destroyed and rebuilt at every logout/login and every `ReloadUI` — so without this seed the
/// clock went back to zero on each of those, and every host value already converted onto it
@@ -276,8 +276,7 @@ fn unpark_boot_vm(world: &mut World) {
/// 2226). It said `UI_Init 0x48fbf0` fires the world-enter cascade from inside itself, so a UI-less
/// client never sees a unit event; that is 1348's reading and the bytes refute it, because the
/// cascade call at `0x490168` is gated on an active-player GUID that is still 0/0 on a fresh login
/// (see [`finish_ui_init`]). The conclusion survives, on four independent structural grounds, all
/// re-verified in wow-5875-re:
/// (see [`finish_ui_init`]). The conclusion survives, on four independent structural grounds:
///
/// - World entry is a **category-5** callee (`0x420d63`), dispatched after the same iteration's
/// **category-6** inbound drain (`0x420d55`) has already returned.
@@ -457,6 +456,14 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
let identity = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity);
// …and the realm's whole character list, which is the enable store's node set: the reference
// populates one `ADDONSTATELIST` node per character at char-list time, and an addon this
// character has never had an opinion about is resolved from what the others said
// (decision 2311).
let roster: Vec<String> = world
.get_resource::<crate::char_select::Roster>()
.map(|r| r.chars.iter().map(|c| c.name.clone()).collect())
.unwrap_or_default();
// **The Lua index space, before a single addon file runs** (decision 2175). The reference has
// the array in hand well before `UI_Init 0x48fbf0` reaches the addon walk — `SMSG_ADDON_INFO`
// lands during the handshake — so an addon reading `GetNumAddOns()` at file scope sees a
@@ -576,47 +583,58 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// through its whole load edge and only converged a frame later off whatever poll the caller
// had written to survive it.
seat_raster_seam_for_load(world, &mut script);
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
let zoom = world.resource::<crate::minimap::MinimapZoom>();
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
// always win and nothing can ever be remembered.
//
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`** —
// the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst
// (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It is the
// sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat frame for
// any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it landed after
// the session's first chat had already been routed, and the login MOTD went to a window
// registered for nothing.
// The plate pair is read out of the world FIRST: the second closure borrows `world` for the
// chat-cache restore, and a `Copy` of two bools costs nothing next to fighting that borrow.
// Absent in a bare test world, where "both off" is also the resource's own default.
// The minimap zoom pair and the plate pair are read out of the world FIRST: the bracket's body
// borrows `world` for the chat-cache restore, and two `Copy` pairs cost nothing next to
// fighting that borrow. The plates are absent in a bare test world, where "both off" is also
// the resource's own default.
let zoom = {
let z = world.resource::<crate::minimap::MinimapZoom>();
(z.outdoor, z.inside)
};
let plates = world
.get_resource::<crate::vplates::VPlateMode>()
.copied()
.unwrap_or_default();
finish_ui_load_with(
&mut script,
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update` feed
// beside it, is what fixes the bug: `UIParent_OnEvent`'s first `UpdateNameplates()` runs
// inside the `VARIABLES_LOADED` fired at the end of this very call.
|script| crate::vplates::push_plate_globals(script, plates),
|script| {
crate::ui_chat::restore_chat_looks(world, script);
},
);
// **The whole load edge is silent** — the TOC walk, every addon, the saved variables and
// `PLAYER_LOGIN`, the span `0x48fbf0` brackets itself across (`0x48fbfa` → `0x49016d`). Both
// login and `ReloadUI` come through here; see [`silenced_ui_load`].
silenced_ui_load(&mut script, |script| {
let _ = load_ingame_ui(script, identity.as_ref(), &roster, version_check);
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two
// live zoom indices from the persisted CVars now, before anything reads them — the
// reference's own minimap reset path copying each CVar object's int into its live index
// (decision 1131). Once only: from here the widget's index is the live truth and
// `Minimap:SetZoom` writes the CVar back. Startup always precedes this state edge (1038),
// so the knob is already loaded.
script.set_minimap_zoom(zoom.0, zoom.1);
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults,
// before any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own
// load order (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the
// defaults always win and nothing can ever be remembered.
//
// **And the chat cache restores inside it, between `VARIABLES_LOADED` and `PLAYER_LOGIN`**
// — the reference's own slot for the reader's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR`
// burst (`0x4900d6`, after `0x4900b2` and before `0x490959`; decisions 2119 and 2125). It
// is the sole firer of `UPDATE_CHAT_WINDOWS`, which is the only thing that registers a chat
// frame for any `CHAT_MSG_*` (ref `ChatFrame.lua` l.1261-1273) — as an `Update` system it
// landed after the session's first chat had already been routed, and the login MOTD went to
// a window registered for nothing.
finish_ui_load_with(
script,
// `NAMEPLATES_ON` / `FRIENDNAMEPLATES_ON` (2132). This seat, and not the `Update`
// feed beside it, is what fixes the bug: `UIParent_OnEvent`'s first
// `UpdateNameplates()` runs inside the `VARIABLES_LOADED` fired at the end of this
// very call.
|script| crate::vplates::push_plate_globals(script, plates),
|script| {
crate::ui_chat::restore_chat_looks(world, script);
},
);
});
// **Say it out loud when an addon didn't load** (decision 1495). Every failure the walk found
// is retained now, but a log nobody knows to open does not fix silence — and silence is the
// actual defect B293 reports: *"there are a lot of addons that still doesn't work"*, with
// nothing on screen to say which or why. Counted off the retained log rather than the walk's
// actual defect: addons that do not load, with nothing on screen to say which or why. Counted
// off the retained log rather than the walk's
// `failures` vec so the number matches what `/errors` will show: the log deduplicates, and one
// broken addon that fails four files should not read as four broken addons. The VM is fresh
// per world entry, so every `Load` row here is this load's.
@@ -755,8 +773,7 @@ pub(crate) fn seat_from_roster(
// lua_pushnil`. `UnitLevel 0x517fc0` likewise carries no `"player"` compare at all and
// reaches its total-miss arm `0x51813e push 0; push 0` — the **number 0**, not nil. The
// two misses are deliberately asymmetric and a client that modelled "no data" uniformly
// would be wrong on one of them (wow-re `ui/scratch/unit-verbs-before-player-object.md`
// §2-§3; it also corrected that repo's own `main`, which had published the opposite).
// would be wrong on one of them.
//
// This seat is no longer what makes the player *answerable* — the record above is — so it
// no longer has to claim a unit exists in order to deliver a name. What it still carries
@@ -846,20 +863,17 @@ pub(crate) fn arm_leaving_world_on_self_create(
}
}
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
/// carves as (`system/ui/ui.md`):
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail is:
///
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
/// > `_G` (`0x490cba` → `0x490ce0`)
///
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 2277). `0x490c97` is the frame-script owner's
/// **The last step is not "destroy the Lua state"** (2277). `0x490c97` is the frame-script owner's
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census misses it.
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
/// screen gets its own.
@@ -953,7 +967,7 @@ pub(crate) fn end_ui_session(world: &mut World) {
// that lands in an engine-side store that survives), before the VM is replaced. The next
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
crate::cvars::fold_dying_vm_cvars(world);
// The chat cache, on the same terms and for the same reason (decision NNNN): it composes the
// The chat cache, on the same terms and for the same reason (decision 2184): it composes the
// player's file out of the DYING VM, and `/reload` never crosses the `OnExit(InWorld)` edge
// its flush used to hang on — so a window moved in the last second before a reload was
// written nowhere and re-read stale from disk.
@@ -1070,9 +1084,7 @@ pub(crate) fn shutdown_on_exit(
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
///
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
/// straight-line address order:
/// **The order is the reference's**. Inside `UI_Init 0x48fbf0`, in straight-line address order:
///
/// | | |
/// |---|---|
@@ -1086,7 +1098,7 @@ pub(crate) fn shutdown_on_exit(
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
///
/// **The third row does not run on a fresh login, and this doc used to say it did** (corrected
/// 2226; the claim came from 1348 and was re-checked in wow-5875-re against the bytes). The call
/// 2226; the claim came from 1348 and was re-checked against the bytes). The call
/// at `0x490168` is gated three instructions earlier:
///
/// ```text
@@ -1106,8 +1118,8 @@ pub(crate) fn shutdown_on_exit(
/// resolved.** Our `finish_ui_init` fires the cascade inside the load, which matches the reload
/// case and not the fresh-login one; whether to split those is its own change with its own
/// measurements, and 2226 (which was about the VM's identity, not the cascade's timing) is not it.
/// wow-re's own `DEFERRED:` entry — which of `0x4908c0`'s two data-dependent entries fires first on
/// a real login — is still open, so the target shape is not yet known.
/// Which of `0x4908c0`'s two data-dependent entries fires first on a real login is still open, so
/// the target shape is not yet known.
///
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
@@ -1130,8 +1142,7 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
/// The reference's login is `FrameXML → addons + ADDON_LOADED (0x4900a3) → VARIABLES_LOADED
/// (0x4900b2) → the chat-cache reader's burst (0x4900d6, firing synchronously through the
/// register-or-fire-now trampoline 0x498a20) → PLAYER_LOGIN (0x490959) → PLAYER_ENTERING_WORLD
/// (0x49096a)` — byte-derived in wow-re `system/ui/scratch/login-chat-colour-pipeline.md`, §5
/// cross-checked. 2119 put the restore ahead of `VARIABLES_LOADED`, which is one step too early:
/// (0x49096a)`. 2119 put the restore ahead of `VARIABLES_LOADED`, which is one step too early:
/// an addon reading its chat colours out of a `VARIABLES_LOADED` handler would see the file's
/// values where the reference shows it the boot ones.
///
@@ -1402,7 +1413,7 @@ mod tests {
// …and the pair every `Instant`→`GetTime` conversion runs through moved with it: a
// 10-minute cooldown armed 30 s before the rebuild still derives its real start.
let clock = world.resource::<UiClock>();
let armed = crate::cooldowns::CooldownInfo {
let armed = crate::spell::cooldowns::CooldownInfo {
start: startup + Duration::from_secs(60),
remaining_ms: 570_000,
duration_ms: 600_000,
+16 -4
View File
@@ -90,6 +90,7 @@ fn coin_and_two_items() -> LootState {
/// close button releases through OnHide → CloseLoot.
#[test]
fn shipped_loot_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -283,6 +284,7 @@ fn shipped_loot_frame_drives_end_to_end() {
/// rare — but the branch must exist, and this proves it fires the right kit and only then.
#[test]
fn loot_empty_roll_plays_the_empty_open_kit() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -324,6 +326,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
/// the next ordinary open resets the skull (the ref re-stamps TargetDead at every show, l.133).
#[test]
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -375,6 +378,7 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
/// pager on page 2 — the real shipped XML driving the render, not just the arithmetic.
#[test]
fn shipped_loot_frame_pages_five_items() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -472,6 +476,7 @@ fn shipped_loot_frame_pages_five_items() {
/// the synthetic stand-in the merchant panel test uses.
#[test]
fn shipped_loot_pushed_to_center_by_merchant() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -549,6 +554,7 @@ fn shipped_loot_pushed_to_center_by_merchant() {
/// frames (`SetFrameStrata 0x76a470` is a whole-subtree cascade).
#[test]
fn the_loot_window_draws_over_the_party_frames() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -602,6 +608,7 @@ fn the_loot_window_draws_over_the_party_frames() {
/// unmodified click still loots — the regression that would otherwise ship silently.
#[test]
fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
benilla_formats::wow_data_or_skip!();
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -750,6 +757,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
/// out and an epic asks. The misspelling is the reference's own (`LootFrame.lua:3`).
#[test]
fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -881,7 +889,7 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
);
}
/// **The raid arm of the master-loot menu** (decision 1675) — the half the wow-re §5 corrected.
/// **The raid arm of the master-loot menu** (decision 1675).
///
/// In a raid the candidate array is not the wire order: the client files each candidate into its
/// own subgroup's five-slot block, leaving holes. `GroupLootDropDown_Initialize` reads those holes
@@ -890,6 +898,7 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
/// the menu that comes out, which a densely-packed list could not produce.
#[test]
fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{PartyState, RaidMemberInfo};
let mut s = UiScript::new().unwrap();
@@ -973,6 +982,7 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
/// take. Cancel sends nothing at all, and `LOOT_CLOSED` takes the dialog down with the window.
#[test]
fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -1060,6 +1070,7 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
/// would sit there doing nothing (decision 1744).
#[test]
fn a_row_click_takes_rather_than_continues() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -1098,6 +1109,7 @@ fn a_row_click_takes_rather_than_continues() {
/// `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s `for i = -1, 6` exists to provide.
#[test]
fn loot_row_awaiting_its_template_opens_clean() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -1173,7 +1185,7 @@ fn loot_row_awaiting_its_template_opens_clean() {
// It is a REOPEN and not a repaint on purpose. The app fires `LOOT_UPDATE` when an open
// window's content changes, and `LOOT_UPDATE` is not a 1.12 event — it appears nowhere in the
// reference's FrameXML and `LootFrame_OnLoad` does not register it — so nothing in the stock
// file listens. What the real client does when a template arrives mid-window is out at wow-re
// file listens. What the real client does when a template arrives mid-window is still open
// (the arrival callback `0x4c2ac0` is recorded as clearing the pending flag and nothing else);
// this test deliberately asserts only what is settled.
s.fire_event("LOOT_CLOSED", vec![]);
@@ -1209,13 +1221,13 @@ fn loot_row_awaiting_its_template_opens_clean() {
/// `<PushedTexture>` and no `<HighlightTexture>` at all: no Quickslot border on the icons, and
/// nothing to light under the mouse. The gate is wrong about the reference: `CLootButton`'s
/// geometry vtable differs from `CSimpleButton`'s in exactly one slot — the destructor thunk — and
/// `LoadXML` is not it, so `0x7788c0` parses a `<LootButton>` element verbatim (wow-re
/// `ui/scratch/lootbutton-widget-type.md` §4).
/// `LoadXML` is not it, so `0x7788c0` parses a `<LootButton>` element verbatim.
///
/// Asserted through the ENGINE's hover path rather than off the state: what a player sees is the
/// emitted quad, and the quad is what was missing.
#[test]
fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -641,8 +641,8 @@ fn the_tab_highlight_is_exactly_its_tab() {
// **The two tabs carry DIFFERENT highlight widths, and that is the reference's,
// not a loader bug.** Exactly one `<OnLoad>` runs — the most-derived one; the
// template's is installed and then *destroyed*, because `SetScript` releases the
// slot's single ref before it even looks at the new body (wow-re
// `template-onload-replacement-law.md`). So a tab gets whichever formula ITS OWN
// slot's single ref before it even looks at the new body (`0x7025ec`). So a tab
// gets whichever formula ITS OWN
// body ends with:
//
// * tab 2's body is just `PanelTemplates_TabResize(-15, nil, nil, 150)`, whose
+104 -17
View File
@@ -150,6 +150,69 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
.map_err(|e| e.to_string())
}
/// **What a screen-size change re-runs** — called from `extract::tick_script`'s resize arm, the
/// frame [`UiScript::set_screen_size`] reports a change on. Anchors follow the new screen rect by
/// themselves; what does not is a size or seat somebody COMPUTED from the old one.
///
/// The bottom-stack manage pass (decision 1499): the open-bag stack starts a fresh column when
/// the current one would run off the top, and that decision is made from `GetScreenHeight()` at
/// layout time. Without this, dragging the window smaller leaves the bag columns wrapped for the
/// old height until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
/// which is in-game UI, and the resize arm also runs on the glue screens.
///
/// **And the three full-screen quads the stock files size once** — a stated repair of a reference
/// gap, in the same posture as [`install_durability_reseat`]: nothing of Blizzard's is edited.
/// `WorldMapFrame_OnLoad` sizes `BlackoutWorld` and `CinematicFrame_OnLoad` sizes
/// `UpperBlackBar`/`LowerBlackBar` from `GetScreenWidth()`/`GetScreenHeight()` at load, and no
/// stock handler touches them again (no `DISPLAY_SIZE_CHANGED` listener) — the reference could
/// afford that because its screen changed only across a restart. 2242 fixed the load edge; this
/// is the other edge: after a window resize or a `uiScale` change the blackout stopped short of
/// the new screen and the world showed through beside the map. [`FULLSCREEN_QUADS_RESEAT`] is the
/// two OnLoads' own arithmetic re-run, so a quad after a resize is exactly what a fresh load at
/// the new size would have made. A stateless chunk rather than an installed hook, so a `ReloadUI`
/// has nothing to re-install and nothing can stack.
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) {
error!("ui_script: full-screen quad re-seat: {e}");
}
}
/// `WorldMapFrame.lua` `WorldMapFrame_OnLoad` l.19-28 and `CinematicFrame.lua`
/// `CinematicFrame_OnLoad` l.5-19, verbatim in their arithmetic, each existence-guarded (the
/// glue screens have neither). One addition to the letterbox: the stock OnLoad does nothing below
/// 4:3, which leaves the bars at `CinematicFrame.xml`'s declared `1024 x 128` — so a resize to
/// below 4:3 puts that declared size back, rather than keeping a wide screen's recompute.
const FULLSCREEN_QUADS_RESEAT: &str = r#"
if BlackoutWorld then
local width = GetScreenWidth()
local height = GetScreenHeight()
if ( width / height < 4 / 3 ) then
width = width * 1.25
height = height * 1.25
end
BlackoutWorld:SetWidth( width )
BlackoutWorld:SetHeight( height )
end
if UpperBlackBar and LowerBlackBar then
local width = GetScreenWidth()
local height = GetScreenHeight()
local barWidth, blackBarHeight = 1024, 128
if ( width / height > 4 / 3 ) then
local desiredHeight = width / 2
if ( desiredHeight > height ) then
desiredHeight = height
end
barWidth = width
blackBarHeight = ( height - desiredHeight ) / 2
end
UpperBlackBar:SetHeight( blackBarHeight )
UpperBlackBar:SetWidth( barWidth )
LowerBlackBar:SetHeight( blackBarHeight )
LowerBlackBar:SetWidth( barWidth )
end
"#;
/// **A stated divergence from the reference, installed rather than edited in** (1751 window 4).
///
/// The reference seats `DurabilityFrame` 20 further in when one of its three side glyphs is up
@@ -186,6 +249,34 @@ if DurabilityFrame and not DurabilityFrame.benillaReseat then
end
"#;
/// **The UI load's sound-suppression bracket — the one site both load paths go through.**
///
/// `CGGameUI::Initialize 0x48fbf0` brackets ITSELF in the counted suppression scope —
/// `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on exit — across the TOC walk,
/// Bindings.xml, the AddOns, the saved variables and the world-enter cascade it calls at
/// `0x490168` (`PLAYER_LOGIN`), so both of its callers (login `0x48f681` and `/reloadui`
/// `0x495669`) load without a sound. The only reader of that depth is `PlaySoundByName
/// 0x458030`, which drops the call outright.
///
/// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
/// really does fire at load (the frame ships shown and its OnLoad hides it) and really does call
/// `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033 reached
/// the right rule from the director's ear; this is the binary agreeing.
///
/// One function rather than a push/pop pair at each caller because the production edge
/// ([`super::load_ingame_ui_on_world_entry`]) spent its life unbracketed while the tests' whole-
/// manifest load was — every `/reload` played the lost-target click. Generic over the borrow so
/// the `&UiScript` test loader and the owning production edge share it.
pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
script: &mut S,
body: impl FnOnce(&mut S) -> R,
) -> R {
script.borrow().push_sound_suppression();
let out = body(script);
script.borrow().pop_sound_suppression();
out
}
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader.
/// This is our content (MIT/Apache), committed and **compiled into the binary**
/// ([`super::content`], decision 1175); a dev build still prefers the copy on disk, so editing a
@@ -219,20 +310,12 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// have been registered here with real consumers since 1493/1502; the probes simply never had
// them, and 218 tests found that out the hour this row went on the manifest.
script.register_cvars(crate::cvars::registered_pairs());
// **Silent, the way the client's own load is.** `0x48fbf0` brackets ITSELF in the counted
// sound-suppression scope — `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on
// exit — across the TOC walk, Bindings.xml and the AddOns, so both of its callers (login
// `0x48f681` and `/reloadui` `0x495669`) load without a sound. The only reader of that depth
// is `PlaySoundByName 0x458030`, which drops the call outright.
//
// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
// really does fire at load (the frame ships shown and its OnLoad hides it) and really does
// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033
// reached the right rule from the director's ear; this is the binary agreeing.
script.push_sound_suppression();
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
script.pop_sound_suppression();
// Silent, the way the client's own load is — see [`silenced_ui_load`].
let mut failures = silenced_ui_load(&mut &*script, |script| {
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
failures
});
// **A handler that raised DURING the walk is a load failure.** The loader's own report holds
// the raises it dispatched itself (a `<Script file=>` chunk, an OnLoad); a raise one call
// deeper — an OnLoad that `Show()`s a frame whose OnShow indexes a global its file has not
@@ -296,15 +379,17 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
/// The reference does the same at `CGGameUI::Initialize 0x48fbf0`, reached only from world entry
/// (`0x401570` ← `0x46c236`), and loads its addons from that same function (`0x4900a3` →
/// `0x51f600`); its glue screens run GlueXML with their own `GlueFonts.xml` registry, which is why
/// the reference has no equivalent of our shared-atlas coupling (wow-5875-re, 1051).
/// the reference has no equivalent of our shared-atlas coupling (1051).
///
/// Addons load **after** the built-in interface, not interleaved with it: an addon may reference
/// our templates and globals (that is the point of 1178's seam), and nothing of ours may depend on
/// an addon.
///
/// `identity` is `(realm, character)`, which names this character's AddOn enable-state file — the
/// reference keys `AddOns.txt` per character too. `None` (no pick yet, a capture) means every
/// discovered addon is enabled, the same answer an absent file gives.
/// reference keys `AddOns.txt` per character too — and `roster` is every character on that realm's
/// list, which is the enable store's node set (decision 2311: an addon this character has no row
/// for is resolved from what the *other* characters said, never from a bare "enabled"). `None`
/// with an empty roster is the no-pick case: every addon falls to its own `## DefaultState`.
///
/// `version_check` is the persisted `checkAddonVersion` — the *Load out of date AddOns* toggle,
/// inverted — resolved by the caller because at load time this VM's own CVar table does not
@@ -314,6 +399,7 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
pub(crate) fn load_ingame_ui(
script: &mut UiScript,
identity: Option<&(String, String)>,
roster: &[String],
version_check: bool,
) -> Vec<String> {
// The whole load edge runs bounded (decision 1306): the reference files sourced off the
@@ -332,6 +418,7 @@ pub(crate) fn load_ingame_ui(
failures.extend(super::addons::load_third_party(
script,
identity,
roster,
version_check,
));
failures
@@ -48,6 +48,7 @@ fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Re
/// intent, and MERCHANT_CLOSED hiding it through HideUIPanel, vacating the left slot.
#[test]
fn shipped_merchant_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The named virtual Font objects the re-skinned rows/title inherit through — loaded first at
@@ -338,6 +339,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
/// → Hide() fires OnHide. Nothing queues at load (the frame is authored hidden="true").
#[test]
fn merchant_show_hide_plays_open_and_close_kits() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::MERCHANT_UI {
@@ -528,6 +530,7 @@ fn vendor_opens_and_closes_all_equipped_bags() {
/// doesn't clear the vendor it just re-opened to.
#[test]
fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::MERCHANT_UI {
@@ -571,6 +574,7 @@ fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
/// with no buy-price line (the price is on the row). Leaving the row hides it all.
#[test]
fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
@@ -824,6 +828,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
/// queue the BuybackItem/RepairAllItems intents the app drains.
#[test]
fn merchant_tabs_drive_buyback_page_and_repair_pair() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::MERCHANT_UI {
@@ -954,6 +959,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
/// configuration the app does not have.
#[test]
fn merchant_tabs_fit_their_labels() {
benilla_formats::wow_data_or_skip!();
/// `2 * $parentLeft:GetWidth()` — the template's two 20-unit end slices.
const SIDES: f64 = 40.0;
let mut s = UiScript::new().unwrap();
@@ -992,6 +998,7 @@ fn merchant_tabs_fit_their_labels() {
/// the frame's `OnUpdate` (a `tick`) exactly as the app does, since the coin is re-armed per frame.
#[test]
fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::UiCursorMode;
let mut s = UiScript::new().unwrap();
@@ -1088,6 +1095,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
/// ellipsis. If this FAILS, the bug is structural and reproduced here; if it PASSES, it is live-only.
#[test]
fn trade_recipient_money_renders_the_digit_not_ellipsis() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::MERCHANT_UI {
@@ -1146,6 +1154,7 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
/// right-button guard, l.332-333) a CTRL-held right-click buys nothing at all.
#[test]
fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
benilla_formats::wow_data_or_skip!();
const WATER_LINK: &str = "|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -1179,7 +1188,7 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
num_available: -1,
item_id: 159,
stats: None,
// Fed exactly as `ui_merchant.rs` builds it off the row's template answer.
// Fed exactly as `ui_merchant` builds it off the row's template answer.
link: Some(WATER_LINK.into()),
max_stack: Some(1),
}],
@@ -305,7 +305,7 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
}
/// `Minimap:GetPingPosition()` answers **two numbers always** (the reference recomputes them
/// from statics nothing clears — wow-re `minimap-ping-law.md`), and the stock
/// from statics nothing clears — `0x4eefd0`), and the stock
/// `Minimap_OnUpdate` leans on exactly that: while its 5 s timer runs it multiplies the answer
/// with no nil test. The pair is the app's last publish, `(0, 0)` before any.
#[test]
@@ -448,7 +448,7 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
/// **The ping's pixels are the stock `<Model>`'s own file** (decision 2008): shown by
/// `MINIMAP_PING` with the file's facts landed, the extract publishes ONE tile request for the
/// pane — at its device size, the render law's unit ladder, and the composite's rect/key/alpha
/// pane — at its device size, the reference's unit ladder, and the composite's rect/key/alpha
/// — and pushes no quad of its own. Once the renderer has handed back a cell, the cell draws as
/// one premultiplied quad over the pane's rect **on the very next frame, with nothing else in
/// the interface moving** (decision 2023): the composite is the renderer's per-frame output in
+8 -11
View File
@@ -180,7 +180,7 @@ pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// **Who owns this frame's keys** — written by [`feed_ui_input`], read by the binding dispatch and the
/// dev keyboard readers, all of which are ordered after `UiInput` so they see this frame's value and
/// not last frame's. The app-side twin of the client's `DAT_00cf4dc8 != 0` gate (RF-0082 §1).
/// not last frame's. The app-side twin of the client's `DAT_00cf4dc8 != 0` gate.
///
/// **Two fields because the reference has two mechanisms, and collapsing them into one boolean was
/// bug 2196.** A focused EditBox swallows *every* key for as long as it holds focus
@@ -189,7 +189,7 @@ pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// and nothing more. Neither releases anything already held: the only things that clear the
/// reference's direction bits are the OS **window deactivate** (`0x514490`, whose sole caller
/// `0x493058` hangs off the WM_ACTIVATE callback slot) and the world-enter cascade (`0x5144c0`).
/// A UI focus change clears nothing — wow-re `rf79-autorun-cancel-set.md`, and the reason holding
/// A UI focus change clears nothing — the reason holding
/// W keeps you running while you type or read the map. See `decisions/2196`.
#[derive(Resource, Default)]
pub(crate) struct UiKeyboardCapture {
@@ -197,7 +197,7 @@ pub(crate) struct UiKeyboardCapture {
/// the alt-arrow one below). Whole-frame, because there is at most one focused box.
pub(crate) typing: bool,
/// The keys a shown keyboard-enabled **frame** consumed this frame (decision 1319's existence
/// gate, wow-re `frame-key-script-delivery.md` §3) — `WorldMapFrame`'s fullscreen `OnKeyDown`,
/// gate, `0x76b7d0`) — `WorldMapFrame`'s fullscreen `OnKeyDown`,
/// `CinematicFrame`, the stack-split spinner. **Per key, not per frame**: the map eating its
/// own `M` must not also suppress an unrelated binding, and — the bug this list exists for —
/// must not be mistaken for a text box taking focus.
@@ -209,7 +209,7 @@ pub(crate) struct UiKeyboardCapture {
/// (`ignoreArrows` / `SetAltArrowKeyMode`) and ALT is not held, so the reference's own key
/// handler declines LEFT/UP/RIGHT/DOWN at `0x77b1c4` and the strata walk carries them down to
/// `CGWorldFrame`, which runs their bindings. That is what lets you turn while the chat box
/// has focus (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED).
/// has focus.
///
/// It is a whole-frame flag rather than a per-key one because both of its terms are:
/// there is at most one focused box, and ALT is read off the same modifier mirror. Only the
@@ -384,8 +384,8 @@ impl Default for UiScaleCvar {
/// director's eye — the taste call this dial exists for).
///
/// **It also happens to be what the reference itself lands on above ~853 px tall**, which we did
/// not know when it was chosen (wow-re `system/ui/scratch/modelframe-uiscale-law.md`, out of the
/// autocast-shine thread): `0x492f70` computes `max((H > 768) ? 768/H : 1.0, 0.9)` and is called
/// not know when it was chosen: `0x492f70` computes
/// `max((H > 768) ? 768/H : 1.0, 0.9)` and is called
/// **both** on a mode set and from the `useUiScale`-OFF leg (`0x4908ad`) — and OFF is the shipped
/// default (`0x8430c0` = `"0"`). So the reference is 1.0 at 768 and below, 0.96 at 1280×800, and
/// **0.9 at 1080p and 1440p**. Our flat 0.9 therefore agrees with it on every modern window and
@@ -457,6 +457,7 @@ impl Plugin for UiScriptPlugin {
// the hover recorder is armed; the producer owns the resource so any minimal app that
// runs the UI pass (the extract tests) has it.
.init_resource::<UiFrameCost>()
.init_resource::<crate::bindings::WheelNotches>()
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
.init_resource::<SyntheticPointer>()
@@ -932,7 +933,7 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont {
#[cfg(test)]
pub(crate) mod test_ui;
/// The chat loader's two login events (wow-re chat-cache-grammar.md §8; `ui_chat::settings`):
/// The chat loader's two login events (`0x498a60`; `ui_chat::settings`):
/// `UPDATE_CHAT_WINDOWS` once, then `UPDATE_CHAT_COLOR` for every registry entry. The reference's
/// `FloatingChatFrame_Update` docks, hides and colours the windows on the first and
/// `ChatFrame_OnEvent` fills `ChatTypeInfo` from the second, so a test VM that loads the
@@ -1178,10 +1179,6 @@ mod duel_tests;
#[cfg(test)]
mod enchant_confirm_tests;
/// The shared reference-geometry diff (decision 0675) every transcribed window's test calls.
#[cfg(test)]
mod framexml_diff;
/// Its FLAG twin (decision 1739): the whole-tree sweep for `toplevel`/mouse/`id` against the
/// reference, read off the loaded engine rather than off our XML.
#[cfg(test)]
@@ -74,6 +74,7 @@ fn shown(s: &UiScript, frame: &str) -> (bool, bool, bool) {
/// `MoneyFrame_SetType("PLAYER")`.
#[test]
fn a_money_frame_loads_as_the_player_purse_and_splits_the_denominations() {
benilla_formats::wow_data_or_skip!();
let s = harness(12_345); // 1g 23s 45c
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
@@ -103,6 +104,7 @@ fn a_money_frame_loads_as_the_player_purse_and_splits_the_denominations() {
/// take the mouse (`canPickup`), and how they collapse.
#[test]
fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
benilla_formats::wow_data_or_skip!();
let s = harness(12_345);
// PLAYER: canPickup, so all three coins are mouse-enabled.
@@ -151,6 +153,7 @@ fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
/// STATIC does not, so the same amount is gold alone.
#[test]
fn showsmallercoins_is_what_keeps_the_zero_coins_visible() {
benilla_formats::wow_data_or_skip!();
let s = harness(50_000); // exactly 5g
assert_eq!(coins(&s, "TestPurse"), ("5".into(), "0".into(), "0".into()));
@@ -182,6 +185,7 @@ fn showsmallercoins_is_what_keeps_the_zero_coins_visible() {
/// frame's own width is recomputed from the coins that survived the collapse.
#[test]
fn update_paints_a_static_frame_by_name_and_resizes_it() {
benilla_formats::wow_data_or_skip!();
let s = harness(0);
s.run("this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil")
.unwrap();
@@ -222,6 +226,7 @@ fn update_paints_a_static_frame_by_name_and_resizes_it() {
/// constants (`MONEY_ICON_WIDTH` / `MONEY_BUTTON_SPACING`), selected purely by `this.small`.
#[test]
fn the_large_template_measures_with_the_nineteen_pixel_icon() {
benilla_formats::wow_data_or_skip!();
let s = harness(20_304);
// One 8px digit + the 19px icon — the same digit sum as the small template, over the other
// icon constant. That the two differ ONLY by the icon is what this test is for.
@@ -249,6 +254,7 @@ fn the_large_template_measures_with_the_nineteen_pixel_icon() {
/// it had.
#[test]
fn an_unknown_money_type_leaves_the_frame_on_the_one_it_had() {
benilla_formats::wow_data_or_skip!();
let s = harness(12_345);
s.run("this = TestPurse MoneyFrame_SetType(\"NOT_A_TYPE\") this = nil")
.unwrap();
@@ -269,6 +275,7 @@ fn an_unknown_money_type_leaves_the_frame_on_the_one_it_had() {
/// and not the icons" is actually observable.
#[test]
fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(12_345); // 1g 23s 45c — three distinguishable digit strings
s.run("SetMoneyFrameColor(\"TestPurse\", 1.0, 0.1, 0.1)")
.unwrap();
@@ -327,6 +334,7 @@ fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
/// their engine getters hold (`GetSendMailMoney`/`GetSendMailCOD`, built by 1962).
#[test]
fn the_money_type_table_carries_all_seven_reference_types() {
benilla_formats::wow_data_or_skip!();
let s = harness(12_345);
for t in [
"PLAYER",
@@ -84,6 +84,7 @@ fn show_bars(s: &UiScript, bars: &[u32]) {
/// about what a bar looks like once the player has asked for it.
#[test]
fn shipped_multibars_drive_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -264,9 +265,11 @@ fn shipped_multibars_drive_end_to_end() {
/// The stance bar (StanceBar.xml) through the REAL shipped XML: hidden at zero forms, sized to
/// the pushed list (buttons past numForms hide), the checked ring on the active form, the 0.4
/// grey on a not-castable one, a click queuing the form's spell id, and an emptied push hiding
/// the whole frame again — the wow-re shapeshift-bar-api mechanism driven end to end.
/// the whole frame again — the reference's form list (`0xb71100`) and its four bindings driven
/// end to end.
#[test]
fn shipped_stance_bar_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
@@ -461,6 +464,7 @@ fn shipped_stance_bar_drives_end_to_end() {
/// past it, so nothing else showed the bug.
#[test]
fn multibar_hover_renders_the_buttons_own_action() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
@@ -581,6 +585,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
/// Since 1500 "hidden" is where they START rather than where they stay — see the toggle tests.
#[test]
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_action_bar(&s);
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
@@ -632,7 +637,7 @@ fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
/// **Every extra bar is down until its own toggle says otherwise, and `MultiBarLeft` needs two.**
///
/// The four bits of `PLAYER_FIELD_BYTES` byte 2 map to bars 1-4 at the FrameXML layer (the binary
/// is bar-agnostic — wow-5875-re `system/ui/scratch/action-bar-toggles.md`), and a fresh
/// is bar-agnostic — `SetActionBarToggles 0x4e76e0` packs four unnamed bits), and a fresh
/// character's byte is 0, which is the whole of "off by default": nothing here fakes a default,
/// the bars simply have nothing telling them to show.
///
@@ -642,6 +647,7 @@ fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
/// hanging off a bar that is not on screen.
#[test]
fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -729,6 +735,7 @@ fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
/// frames drawing straight through a bar the player just asked for (decision 1499's screenshot).
#[test]
fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -793,6 +800,7 @@ fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
/// which was only ever right because visibility was static.
#[test]
fn viewable_action_bar_pages_follow_the_bar_toggles() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -859,6 +867,7 @@ fn viewable_action_bar_pages_follow_the_bar_toggles() {
/// can ever nest" stopped being true the moment this switch existed.
#[test]
fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -958,12 +967,13 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
/// DIVERGENCES, chosen in 1782. In stock 1.12.1 the dim is fanned to every button and then never
/// cleared by anything: `SetNormalTexture(path)` reuses the region and writes only its texture
/// handle, and a 3-argument `SetVertexColor` is `SetVertexColor(r, g, b, currentAlpha)` — so the
/// first spell anyone picks up leaves every border at alpha 0x80 for the session (wow-re
/// `button-state-texture-path-setter.md` §2/§7, byte-verified). That is a stuck state keyed to
/// first spell anyone picks up leaves every border at alpha 0x80 for the session (the reuse leg
/// `0x778f10`, the alpha-keeping `SetVertexColor` `0x79abd0`). That is a stuck state keyed to
/// nothing the player can see. We take the look the dim was written for and let go of it with the
/// payload, which is what the last two assertions here pin.
#[test]
fn a_held_payload_ghosts_the_empty_wells_it_opens() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -1088,10 +1098,10 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
);
// What happens to that ring NEXT is decision 1782's open question, deliberately not asserted:
// the reference's `SetVertexColor(1, 1, 1)` in `ActionButton_UpdateUsable` keeps the alpha
// already on the region (byte-pinned, wow-re button-state-texture-path-setter.md §7 — the
// "stuck dim" a 1.12 player sees after a drag), while this engine's three-argument call still
// resets it to 1.0 until the director calls 1782. A test that pinned either outcome would be
// enshrining a divergence as fidelity.
// already on the region (`SetVertexColor` `0x79abd0` — the "stuck dim" a 1.12 player sees
// after a drag), while this engine's three-argument call still resets it to 1.0 until the
// director calls 1782. A test that pinned either outcome would be enshrining a divergence as
// fidelity.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1099,14 +1109,15 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
///
/// The whole round trip, over the REAL bindings (`benilla_ui::script::action_bar_toggles`): the
/// row's setter writes a global, re-derives the bars and posts `CMSG_SET_ACTIONBAR_TOGGLES` with
/// the WHOLE byte — bits `0x01/0x02/0x04/0x08`, verified against the 1.12.1 binary (wow-5875-re
/// `417c2d31`). Coming back, the server's descriptor push is the only thing that moves the getter,
/// and `UIParent.xml`'s `PLAYER_ENTERING_WORLD` arm reads it exactly once as the seed.
/// the WHOLE byte — bits `0x01/0x02/0x04/0x08` (`SetActionBarToggles 0x4e76e0`). Coming back,
/// the server's descriptor push is the only thing that moves the getter, and `UIParent.xml`'s
/// `PLAYER_ENTERING_WORLD` arm reads it exactly once as the seed.
///
/// Every `Set` is one packet, deliberately (the binding gates nothing), which is why the drain is a
/// list and each step below checks the packet it just caused.
#[test]
fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -1194,6 +1205,7 @@ fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
/// this file drives the real binding.
#[test]
fn the_shipped_setter_passes_exactly_four_arguments() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
@@ -1245,6 +1257,7 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
/// here rather than assumed.
#[test]
fn the_stance_bar_sits_where_the_pass_puts_it() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
@@ -1317,6 +1330,7 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
/// set once at load.
#[test]
fn the_stance_shelf_follows_the_bottom_left_bar() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
@@ -1438,6 +1452,7 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
/// exercised 2 and 3 forms and only ever read `IsShown()`, which is exactly why it shipped.
#[test]
fn the_stance_shelf_is_as_long_as_the_form_count() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
@@ -1554,6 +1569,7 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
/// slot has an action.
#[test]
fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The real command set, so MULTIACTIONBAR1BUTTONn is bindable at all (it ships unbound —
@@ -1663,6 +1679,7 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
/// the end caps (atlas crops of `ShapeshiftBarEnds`).
#[test]
fn the_middle_strip_tiles_along_its_length_only() {
benilla_formats::wow_data_or_skip!();
use super::extract::tiling_axes;
use crate::ui_pass::UvRect;
use benilla_ui::script::{ShapeshiftFormView, TexCoords};
@@ -1729,7 +1746,7 @@ fn the_middle_strip_tiles_along_its_length_only() {
/// alone, both end caps down.
///
/// The reference fires `UPDATE_SHAPESHIFT_FORMS` for exactly one thing: the form LIST changed
/// (learn / unlearn / rank — wow-re `shapeshift-bar-api.md`, the fires at `0x4b28ff`/`0x4b2e43`).
/// (learn / unlearn / rank — the fires at `0x4b28ff`/`0x4b2e43`).
/// Our feed fired it for any change in the pushed view — a stance switch, a castable flip, the
/// shared 1 s category cooldown arming and then EXPIRING. Every fire runs the stock
/// `ShapeshiftBar_Update` (BonusActionBarFrame.lua l.170): `ShapeshiftBarMiddle:Show()`
@@ -1745,6 +1762,7 @@ fn the_middle_strip_tiles_along_its_length_only() {
/// one the switch really carries and reading the checked ring.
#[test]
fn a_forms_state_change_leaves_the_shelf_down_over_the_raised_bar() {
benilla_formats::wow_data_or_skip!();
use crate::ui_shapeshift::{push_forms, FormsEdge, StanceMemory};
use benilla_ui::script::ShapeshiftFormView;
@@ -104,6 +104,7 @@ fn harness_on(mut s: UiScript) -> UiScript {
/// igMainMenuOption kit.
#[test]
fn the_menu_options_button_swaps_the_menu_for_the_options_window() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
let _ = s.take_sounds();
@@ -133,6 +134,7 @@ fn the_menu_options_button_swaps_the_menu_for_the_options_window() {
/// label — including the one key whose label differs from it (ActionBars → "Action Bars").
#[test]
fn controls_is_the_default_category_and_the_title_reads_it() {
benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -159,6 +161,7 @@ fn controls_is_the_default_category_and_the_title_reads_it() {
/// survives a close/reopen (the OnShow re-applies the last seat, not the default).
#[test]
fn clicking_a_row_moves_the_selection_and_the_page_title() {
benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -195,6 +198,7 @@ fn clicking_a_row_moves_the_selection_and_the_page_title() {
/// the guard itself is pinned by `the_defaults_button_is_armed_by_rows_not_by_a_category` below.
#[test]
fn the_close_button_hides_the_window() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -223,6 +227,7 @@ fn the_close_button_hides_the_window() {
/// (.196,.388,.8) while the locked gold stands — 1.14's LockHighlight guard.
#[test]
fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Pin the window at scale 1 so row rects and the pointer share coordinates (the fit
// clamp stays out of the way at 1024x768: both ratios sit above 1).
@@ -336,6 +341,7 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
/// would smear across the rope's inner half, which is exactly the mistake this pins.
#[test]
fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -404,6 +410,7 @@ fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
/// (the one-layout-law pin) with the page view restored.
#[test]
fn search_reflows_live_rows_and_restores_the_page() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
@@ -499,6 +506,7 @@ fn search_reflows_live_rows_and_restores_the_page() {
/// first within the group — "master volume" seats Master above the token-matched children.
#[test]
fn the_phrase_match_outranks_its_words() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"master volume\")")
@@ -524,6 +532,7 @@ fn the_phrase_match_outranks_its_words() {
/// the box) and a no-match query (the section-header "no results" line, nothing chained).
#[test]
fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -584,6 +593,7 @@ fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
/// dragging (0250 §5's capture began), and the stepper buttons no longer exist.
#[test]
fn a_track_press_seats_the_thumb_and_keeps_dragging() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.set_cvar_host("MasterVolume", "0.1");
s.run("ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
@@ -660,6 +670,7 @@ fn a_track_press_seats_the_thumb_and_keeps_dragging() {
/// window has: a label/value line, and the groove still riding 3 units high of the row.
#[test]
fn a_slider_rows_readout_sits_on_its_labels_line() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
for (page, rows) in [
@@ -918,6 +929,7 @@ fn actionbars_harness() -> UiScript {
/// Leaving the page hides it and puts Defaults back to sleep.
#[test]
fn the_audio_page_reads_the_cvar_table_on_select() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("MusicVolume", "0.7");
s.set_cvar_host("EnableMusic", "0");
@@ -973,6 +985,7 @@ fn the_audio_page_reads_the_cvar_table_on_select() {
/// (the page reading the table) queues nothing.
#[test]
fn a_slider_move_snaps_and_writes_the_cvar() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
@@ -1009,6 +1022,7 @@ fn a_slider_move_snaps_and_writes_the_cvar() {
/// 1.12 dependency holds: Enable All Sound off greys exactly the Enable Ambience row.
#[test]
fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
@@ -1059,6 +1073,7 @@ fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
/// exactly two rows in `SoundOptionsFrame_UpdateDependencies` and never grew a third.
#[test]
fn the_background_sound_row_boots_off_and_writes_the_era_cvar() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
@@ -1098,6 +1113,7 @@ fn the_background_sound_row_boots_off_and_writes_the_era_cvar() {
/// the era per-page reset, on the one page with rows.
#[test]
fn defaults_resets_the_audio_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("MusicVolume", "0.9");
s.set_cvar_host("EnableMusic", "0");
@@ -1141,6 +1157,7 @@ fn defaults_resets_the_audio_page_to_registered_defaults() {
/// Audio's body takes over when clicked.
#[test]
fn the_graphics_page_reads_the_cvar_table_on_select() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
@@ -1218,6 +1235,7 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
/// inverted by a later edit, and until this test there was no coverage of the row at all.
#[test]
fn the_display_mode_dropdown_maps_its_entries_to_the_gx_window_polarity() {
benilla_formats::wow_data_or_skip!();
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowDisplayMode";
let mut s = audio_harness();
s.set_cvar_host("gxWindow", "0");
@@ -1279,6 +1297,7 @@ fn the_display_mode_dropdown_maps_its_entries_to_the_gx_window_polarity() {
/// and wgpu can — so the click commits, like every other checkbox here.
#[test]
fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("gxVSync", "0");
let mut s = harness_on(s);
@@ -1342,6 +1361,7 @@ fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
/// never did was move the slider, which is the only thing a player does. So this one drags.
#[test]
fn the_brightness_slider_writes_through_its_engine_pair_and_survives_a_reopen() {
benilla_formats::wow_data_or_skip!();
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowBrightness";
let s = audio_harness();
let mut s = harness_on(s);
@@ -1407,6 +1427,7 @@ fn the_brightness_slider_writes_through_its_engine_pair_and_survives_a_reopen()
/// and writes nothing back (0959's out-of-range law).
#[test]
fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
benilla_formats::wow_data_or_skip!();
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowWorldDetail";
let mut s = audio_harness();
s.set_cvar_host("WorldDetail", "0");
@@ -1572,6 +1593,7 @@ fn a_world_entry_leaves_the_saved_nameplate_setting_alone() {
#[test]
fn the_nameplates_page_toggles_the_unit_name_cvars() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
@@ -1660,6 +1682,7 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
/// committed value clears the pending without a commit (era's IsModified).
#[test]
fn the_ui_scale_slider_defers_to_the_apply_button() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
@@ -1749,6 +1772,7 @@ fn the_ui_scale_slider_defers_to_the_apply_button() {
/// could not tell what it did.
#[test]
fn the_render_scale_row_shows_a_percentage_and_defers_to_apply() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("renderScale", "1");
let mut s = harness_on(s);
@@ -1820,6 +1844,7 @@ fn the_render_scale_row_shows_a_percentage_and_defers_to_apply() {
/// only when the window hides — the reopened window reads the committed truth.
#[test]
fn a_pending_ui_scale_survives_the_page_switch_and_dies_on_hide() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
@@ -1873,6 +1898,7 @@ fn a_pending_ui_scale_survives_the_page_switch_and_dies_on_hide() {
/// write is what moves the far-clip wall and the residency window together.
#[test]
fn the_terrain_distance_slider_snaps_to_the_1_12_grid_and_writes_live() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
@@ -1913,6 +1939,7 @@ fn the_terrain_distance_slider_snaps_to_the_1_12_grid_and_writes_live() {
/// dies with it (the default write supersedes what Apply would have committed).
#[test]
fn defaults_resets_the_graphics_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
@@ -1976,6 +2003,7 @@ const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
// MONKEY (volumetric fog): exercise translations and numeric writes through the real UI.
#[test]
fn volumetric_fog_dropdown_is_localised_and_live() {
benilla_formats::wow_data_or_skip!();
// MONKEY (volumetric fog): validate the authored table and row even on a
// data-free checkout; the complete window additionally needs stock FrameXML.
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
@@ -2029,6 +2057,7 @@ fn volumetric_fog_dropdown_is_localised_and_live() {
#[test]
fn water_quality_writes_numeric_tiers_with_localised_labels() {
benilla_formats::wow_data_or_skip!();
for (locale, labels, lava_label) in [
("enUS", ["Classic", "Enhanced", "High"], "Lava Glow"),
("ruRU", ["Классическое", "Улучшенное", "Высокое"], "Свечение лавы"),
@@ -2056,6 +2085,7 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
#[test]
fn advanced_graphics_fire_spell_and_lava_sliders_read_and_write_the_full_range() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("fireLightGain", "3.25");
s.set_cvar_host("spellLightGain", "3.25");
@@ -2083,6 +2113,7 @@ fn advanced_graphics_fire_spell_and_lava_sliders_read_and_write_the_full_range()
#[test]
fn advanced_graphics_selects_its_locale_at_load_including_yards() {
benilla_formats::wow_data_or_skip!();
for (locale, title, distance) in [
("enUS", "Advanced Graphics", "80 yd"),
("ruRU", "Расширенная графика", "80 ярд."),
@@ -2117,6 +2148,7 @@ fn advanced_graphics_selects_its_locale_at_load_including_yards() {
/// CVar, which nothing else in this file would catch.
#[test]
fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("shadowMapSize", "4096");
s.set_cvar_host("interiorShadows", "0");
@@ -2202,6 +2234,7 @@ fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
/// spelling, and nothing else queued.
#[test]
fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
@@ -2235,6 +2268,7 @@ fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host()
/// beside the label would climb as the world got lighter, under a label that says "Darkness".
#[test]
fn the_darkness_sliders_read_out_so_that_left_is_darker() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
@@ -2279,6 +2313,7 @@ fn the_darkness_sliders_read_out_so_that_left_is_darker() {
/// children where they were left.
#[test]
fn the_advanced_graphics_masters_grey_what_they_gate() {
benilla_formats::wow_data_or_skip!();
let s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
@@ -2326,6 +2361,7 @@ fn the_advanced_graphics_masters_grey_what_they_gate() {
/// page is open. Without the poll the player would pick a preset and watch nothing below it move.
#[test]
fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
@@ -2375,6 +2411,7 @@ fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
/// `deselectOnClick` is "0", the 1.12 interface panel's own arm), the plain flags direct.
#[test]
fn the_controls_page_reads_flags_with_the_sticky_inversion() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("deselectOnClick", "0");
s.set_cvar_host("autoLootDefault", "1");
@@ -2419,6 +2456,7 @@ fn the_controls_page_reads_flags_with_the_sticky_inversion() {
/// Sticky Targeting writes the CVar inverted both ways.
#[test]
fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert!(
@@ -2463,6 +2501,7 @@ fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
/// autoLootDefault "0"), the rows follow, and only the moved values queue.
#[test]
fn defaults_resets_the_controls_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("deselectOnClick", "0");
s.set_cvar_host("autoLootDefault", "1");
@@ -2515,6 +2554,7 @@ fn settle(s: &mut UiScript) {
/// rect until scrolled to.
#[test]
fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// On NAMEPLATES, not the default Controls page: since 2180 seated the four camera toggles and
@@ -2661,6 +2701,7 @@ fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
/// keybindings_tests, where the harness has a real binding registry to overflow the list with.)
#[test]
fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"e\")")
@@ -2792,6 +2833,7 @@ fn scroll_into_view(s: &mut UiScript, frame: &str) {
/// property the 1.12 panel's own `getglobal("OPTION_TOOLTIP_"..key)` lookup buys.
#[test]
fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
// the client's does (1968) — a harness that reads its rect needs one; the fixed-width
@@ -2868,6 +2910,7 @@ fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
/// hover driven without the outgoing row's OnLeave (1054) must still put the neighbour away.
#[test]
fn a_row_with_no_1_12_string_raises_no_plate() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Sticky's own description.\"")
.unwrap();
@@ -3124,6 +3167,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
/// line instead of a plate, which no key-mapping check would ever see.
#[test]
fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name (the real texts are the data test's job).
s.run(
@@ -3234,6 +3278,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
/// effect is the per-type `show` flag and the scroll function.
#[test]
fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
@@ -3331,6 +3376,7 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
/// wake is the master's own click rather than the load: the same rule, entered from the other end.
#[test]
fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
@@ -3388,6 +3434,7 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
/// Defaults lands a player back on a stock 1.12 combat-text family.
#[test]
fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
benilla_formats::wow_data_or_skip!();
let s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
@@ -3454,6 +3501,7 @@ fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
/// text comes up with the player's choice rather than CombatText.xml's shipped default.
#[test]
fn what_the_combat_page_writes_survives_a_restart() {
benilla_formats::wow_data_or_skip!();
let s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
@@ -3466,7 +3514,7 @@ fn what_the_combat_page_writes_survives_a_restart() {
s.run("BenillaOptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
.unwrap();
let saved = s.saved_variables_text();
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
assert!(
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
"the toggle is in the saved text:\n{saved}"
@@ -3528,6 +3576,7 @@ fn what_the_combat_page_writes_survives_a_restart() {
/// (including the shift-click that deliberately still works); this owns the wire between them.
#[test]
fn the_action_bars_page_locks_the_real_bar() {
benilla_formats::wow_data_or_skip!();
let mut s = actionbars_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowActionBars:Click()")
@@ -3592,6 +3641,7 @@ fn the_action_bars_page_locks_the_real_bar() {
/// questgiver's instant-text arm, live on the very next show.
#[test]
fn the_interface_page_writes_the_three_stock_globals() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -3665,6 +3715,7 @@ fn the_interface_page_writes_the_three_stock_globals() {
/// * the picker's five values are the reference's own strings, in its own order.
#[test]
fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -3772,6 +3823,7 @@ fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
/// a second click inside that round trip would otherwise compute the wrong direction.
#[test]
fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -3850,6 +3902,7 @@ fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
/// tutorial.
#[test]
fn show_tutorials_reads_the_bank_and_writes_through_clear_and_reset() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
// A bank with unacknowledged bits: tutorials are enabled.
s.set_tutorial_bank(Some(vec![0x00; 32]));
@@ -3908,6 +3961,7 @@ fn show_tutorials_reads_the_bank_and_writes_through_clear_and_reset() {
/// has dismissed — an option that silently undoes hours of play.
#[test]
fn a_no_op_write_does_not_re_arm_the_tutorials() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.set_tutorial_bank(Some(vec![0x00; 32])); // enabled
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -3932,6 +3986,7 @@ fn a_no_op_write_does_not_re_arm_the_tutorials() {
/// no-op default must not queue a packet that would turn the preference on its head.
#[test]
fn defaults_sends_a_flip_only_for_the_row_that_moved() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -3971,6 +4026,7 @@ fn defaults_sends_a_flip_only_for_the_row_that_moved() {
/// property worth holding: the capture follows the definer wherever the definer stands.
#[test]
fn defaults_on_the_interface_page_restores_the_definers_own_assignment() {
benilla_formats::wow_data_or_skip!();
let s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -4009,6 +4065,7 @@ fn defaults_on_the_interface_page_restores_the_definers_own_assignment() {
/// player's choice instead of QuestLogFrame.xml's shipped one.
#[test]
fn what_the_interface_page_writes_survives_a_restart() {
benilla_formats::wow_data_or_skip!();
let s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -4016,7 +4073,7 @@ fn what_the_interface_page_writes_survives_a_restart() {
s.run("BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
.unwrap();
let saved = s.saved_variables_text();
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
assert!(
saved.contains("AUTO_QUEST_WATCH = \"0\""),
"the toggle is in the saved text under the reference's name:\n{saved}"
@@ -4066,6 +4123,7 @@ fn what_the_interface_page_writes_survives_a_restart() {
/// other one: the registrations were never armed at all, and only the walk can arm them.
#[test]
fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
// What the saved chunk does, verbatim: assign over the file-scope default, then the event.
s.run("SHOW_COMBAT_TEXT = \"1\"").unwrap();
@@ -4103,6 +4161,7 @@ fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
/// only which of them a fresh install opens on.
#[test]
fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
benilla_formats::wow_data_or_skip!();
let gap = |s: &mut UiScript| -> f64 {
s.resolve();
s.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
@@ -4138,7 +4197,7 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
// Restart: the fresh tree comes up on the shipped geometry, the chunk replaces the value, and
// VARIABLES_LOADED is what puts the bar where the value says.
let saved = s.saved_variables_text();
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
assert!(
saved.contains("SHOW_BUFF_DURATIONS = \"1\""),
"the switch is in the saved text:\n{saved}"
@@ -4174,6 +4233,7 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
/// rather than through a stand-in page — there is no longer one to borrow.
#[test]
fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -4236,6 +4296,7 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
/// (l.337). So a fresh client shows the box **unchecked**, and ticking it turns the filter off.
#[test]
fn the_disable_spam_filter_row_is_inverted() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
@@ -4277,6 +4338,7 @@ fn the_disable_spam_filter_row_is_inverted() {
/// column-C chain rather than joining the Chat page.
#[test]
fn the_profanity_filter_row_sits_on_the_interface_page_and_writes_its_cvar() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
@@ -4303,6 +4365,7 @@ fn the_profanity_filter_row_sits_on_the_interface_page_and_writes_its_cvar() {
#[test]
fn the_chat_page_toggles_the_chat_bubble_cvars() {
benilla_formats::wow_data_or_skip!();
// No host override: since 1804 the registered pair IS bubbles-on / party-off, so the page's
// read below is of the shipped table rather than of a value this test planted.
let mut s = harness_on(audio_harness());
@@ -4376,6 +4439,7 @@ fn the_chat_page_toggles_the_chat_bubble_cvars() {
/// 1136's rule for when a row needs one, seen from both sides on one page).
#[test]
fn the_chat_page_writes_the_hover_delay_global_and_the_loot_spam_cvar() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
@@ -4459,6 +4523,7 @@ fn the_chat_page_writes_the_hover_delay_global_and_the_loot_spam_cvar() {
/// fresh VM has re-run `ChatFrame.xml`'s file-scope `"0"`.
#[test]
fn a_saved_hover_delay_is_applied_when_the_variables_land() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_harness();
// What the saved-variables chunk does: assign the global, then the window's VARIABLES_LOADED.
s.run("REMOVE_CHAT_DELAY = \"1\"").unwrap();
@@ -4492,6 +4557,7 @@ fn a_saved_hover_delay_is_applied_when_the_variables_land() {
/// here is the uppercase display name and not the CVar's own spelling.
#[test]
fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
// The bar needs a real span before it decides anything about its numerals (its update bails
// on valueMax == 0 and hides the strip instead).
@@ -4549,6 +4615,7 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
/// `LookConfig::sensitivity`.
#[test]
fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("mousespeed", "1.25");
let mut s = harness_on(s);
@@ -4607,6 +4674,7 @@ fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
/// rather than down it.
#[test]
fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
@@ -4679,12 +4747,13 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
/// Controls-page dropdown, and the setting that decides whether the camera returns to behind the
/// character at all. What is pinned here is the trap: the reference's own dropdown writes `1/2/3`,
/// but the ENGINE's tables are indexed `0 = Never · 1 = Smart · 2 = Always`, and `3` is not a style
/// — the validator accepts it while the terrain-tilt consumer indexes off the end of its table
/// (wow-re `camera-smooth-style.md` §2/§4). So our entries carry the engine's numbers in the
/// — the validator (`0x50c060`) accepts it while the terrain-tilt consumer (`0x50dbc0`) indexes
/// off the end of its table. So our entries carry the engine's numbers in the
/// reference's display order, a stray `3` still reads as Never rather than as the numerically
/// nearest "Always", and the plate follows the SELECTION the way that dropdown's own does.
#[test]
fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
@@ -4814,6 +4883,7 @@ fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
/// `MultiActionBar_Update`, which reaches `UIParent_ManageFramePositions`.
#[test]
fn the_action_bars_page_toggles_the_real_bars() {
benilla_formats::wow_data_or_skip!();
let mut s = actionbars_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowActionBars:Click()")
@@ -4992,6 +5062,7 @@ impl benilla_ui::script::TextMeasure for SteppedFont {
/// (64 and 76 here); on the show, behind the window's own OnShow, they measure at the drawn 0.78.
#[test]
fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_text_measurer(Box::new(SteppedFont(6.0)));
let mut s = harness_on(s);
@@ -5062,6 +5133,7 @@ fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
/// seated `AtlasMeasurer` for certain, whatever order the boot took.
#[test]
fn without_a_seated_measurer_the_same_fit_reads_zero() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(UiScript::new().unwrap());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
@@ -5138,6 +5210,7 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
/// us and shipping one was somebody re-reading a decision record from months ago.
#[test]
fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// The table as the file declares it: `name` (the display key) -> its `cvar`, if it has one.
// **BOTH of the reference's option tables** (2115). The checkbuttons were the whole census
@@ -5222,6 +5295,7 @@ fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker
/// player change and benilla does not, and it costs a stated reason at the row.
#[test]
fn every_registered_reference_cvar_has_a_row_on_our_own_window() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// Both of the reference's own option tables, exactly as the registration census reads them.
@@ -5328,6 +5402,7 @@ const UNREACHABLE_REFERENCE_CVARS: &[(&str, &str)] = &[];
/// `cameraWaterCollision` a player who wants them OFF had no way to say so at all.
#[test]
fn the_four_camera_toggles_read_their_shipped_defaults_and_write_on_the_click() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
@@ -5376,6 +5451,7 @@ fn the_four_camera_toggles_read_their_shipped_defaults_and_write_on_the_click()
/// kept floating.
#[test]
fn the_pet_damage_box_and_the_look_slider_each_write_their_unnamed_twin() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
@@ -5428,6 +5504,7 @@ fn the_pet_damage_box_and_the_look_slider_each_write_their_unnamed_twin() {
/// combat text also turns off damage numbers, which is the opposite of what `CombatDamage` does.
#[test]
fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_alone() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
@@ -5479,6 +5556,7 @@ fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_
/// (l.715-719 — there is no follow to set a speed for).
#[test]
fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
@@ -296,7 +296,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Rows 1-2 carry the quest titles (active first, matching the order the menu supplied them —
// benilla's seam already flattens available/active into one ordered list, ui_gossip.rs), row 3
// benilla's seam already flattens available/active into one ordered list, `ui_gossip`), row 3
// the option, row 4+ hidden.
let (r1_text, r1_vis, r2_vis, r3_text, r3_vis, r4_vis, r5_text, r5_vis, r6_vis): (
String,
@@ -1271,8 +1271,8 @@ fn a_dead_player_opens_only_whiledead_windows() {
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the refused window took no slot"
);
// ...and the refusal is heard: NotWhileDeadError (the binary's 0x48d340 — push 0x7e, wow-re
// cross-checked) queued the catalog row's key for the app to resolve and toast.
// ...and the refusal is heard: NotWhileDeadError (the binary's 0x48d340 — push 0x7e) queued
// the catalog row's key for the app to resolve and toast.
assert_eq!(
s.take_ui_errors(),
vec!["ERR_PLAYER_DEAD"],
@@ -61,6 +61,7 @@ fn bar_quad(s: &mut UiScript) -> (usize, [f32; 4], Option<[f32; 4]>) {
/// grey column into the slot rather than floating it above.
#[test]
fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
s.resolve();
@@ -90,6 +91,7 @@ fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
/// would paint over the metal surround that frames the 10-wide slot.
#[test]
fn the_meter_paints_under_the_bar_art_it_shows_through() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
let (meter, _, _) = bar_quad(&mut s);
@@ -128,6 +130,7 @@ fn the_meter_paints_under_the_bar_art_it_shows_through() {
/// — color is the entire readout, which is why this test asserts the tint and the rect separately.
#[test]
fn the_meter_tints_by_latency_on_the_reference_thresholds() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
// The first tick polls immediately (updateInterval starts at 0), so an unmeasured connection
@@ -197,6 +200,7 @@ fn poll_beat(s: &mut UiScript) {
/// arithmetic on them can't error.
#[test]
fn get_net_stats_reports_the_pushed_latency() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
assert_eq!(
s.eval::<(f64, f64, f64)>("return GetNetStats()").unwrap(),
@@ -229,6 +233,7 @@ fn get_net_stats_reports_the_pushed_latency() {
/// tooltip that is quietly one line short.
#[test]
fn hovering_the_meter_shows_the_live_latency() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[r"Interface\FrameXML\UIParent.xml"]);
s.set_latency_ms(Some(42));
// 1.12 ships detailed tips ON — `SHOW_NEWBIE_TIPS = "1"` is UIOptionsFrame_Init's (ref
@@ -309,6 +314,7 @@ fn hovering_the_meter_shows_the_live_latency() {
/// the bar's end-to-end test pins, kept here so the two move together.
#[test]
fn the_meter_adds_its_two_frames_to_the_bar() {
benilla_formats::wow_data_or_skip!();
let s = harness(&[]);
for name in [
"MainMenuBarPerformanceBarFrame",
@@ -128,6 +128,7 @@ fn texture_rect(
/// painting what they should, and hidden again when the pet goes.
#[test]
fn the_shipped_pet_bar_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -249,6 +250,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
/// `IsPetAttackActive` fork — the one branch that makes the Attack button a toggle).
#[test]
fn clicks_route_through_the_attack_toggle_fork() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -310,6 +312,7 @@ fn clicks_route_through_the_attack_toggle_fork() {
/// director; decision 1027 diverged from it and 1030 put it back.
#[test]
fn a_click_drops_the_ring_and_the_repaint_restores_it() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -353,6 +356,7 @@ fn a_click_drops_the_ring_and_the_repaint_restores_it() {
/// looks like, and collapsing it to "hide the bar" would lose the state entirely.
#[test]
fn a_disabled_bar_greys_rather_than_hides() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -429,6 +433,7 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
/// is the base position, so a pass that never fired would leave it exactly where the bug was.
#[test]
fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
benilla_formats::wow_data_or_skip!();
let (low_shelf, low_top) = pet_bar_row(false);
let (high_shelf, high_top) = pet_bar_row(true);
@@ -454,6 +459,7 @@ fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
/// exists, and the reason an unnamed button's `Hide()` is conditional.
#[test]
fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -507,6 +513,7 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
/// reference leaves it (l.253-255).
#[test]
fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -556,6 +563,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
/// puts shift above the left/right split, so it never toggles autocast by accident.
#[test]
fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -598,6 +606,7 @@ fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
/// one worth pinning. Read as "ordinary pets differ from clicks", this test would be lying.
#[test]
fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
@@ -661,7 +670,7 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
/// A hunter bar hovered through the REAL gesture, with the real binding registry and the real CVar
/// table behind it — `mouse_move` runs the shipped `<OnEnter>` with `this` bound, which is the only
/// way this fork gets exercised the way nazriel exercised it.
/// way this fork gets exercised the way a player's hover exercises it.
///
/// Buttons are 30 px chained +8 from the bar's own origin: button 1 spans x[72,102] y[56,86] ⇒
/// centre (87,71), button 4's left = 72 + 3·38 ⇒ centre (201,71) — the geometry
@@ -692,8 +701,7 @@ fn tooltip_line1(s: &UiScript) -> String {
/// **B230 — Attack/Follow/Stay name their keybinding; the pet's own spells do not.**
///
/// nazriel: *"On 1.12 when you hover over Attack, Follow, Stay pet action buttons they show the
/// keybinding - which is not the case with Benilla"*. The reference agrees with him and is
/// On 1.12, hovering Attack, Follow or Stay shows the button's keybinding in its tooltip. That is
/// narrower than "the pet bar": `PetActionButton_OnEnter` (PetActionBarFrame.lua l.285-305) forks
/// on `isToken or UberTooltips == "0"`, and only THAT branch concatenates
/// `NORMAL_FONT_COLOR_CODE.." ("..GetBindingText(GetBindingKey("BONUSACTIONBUTTON"..id), "KEY_")..")"`.
@@ -705,6 +713,7 @@ fn tooltip_line1(s: &UiScript) -> String {
/// which is what the stock `MicroButtonTooltipText` — the same idea, the other shape — produces).
#[test]
fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
benilla_formats::wow_data_or_skip!();
let mut s = hovered_pet_bar();
s.mouse_move(ATTACK_BUTTON.0, ATTACK_BUTTON.1);
@@ -742,6 +751,7 @@ fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
/// deliberate unbind to see, which is exactly what this does.
#[test]
fn an_unbound_token_row_renders_the_references_empty_parentheses() {
benilla_formats::wow_data_or_skip!();
let mut s = hovered_pet_bar();
s.run(r#"SetBinding("CTRL-1")"#).unwrap();
assert!(
@@ -770,6 +780,7 @@ fn an_unbound_token_row_renders_the_references_empty_parentheses() {
/// at all because these Lua sites read it (the honest-tree rule's live-consumer half).
#[test]
fn ubertooltips_off_takes_the_spells_through_the_token_branch_and_moves_the_plate() {
benilla_formats::wow_data_or_skip!();
let mut s = hovered_pet_bar();
s.set_cvar_engine("UberTooltips", "0");
@@ -2,7 +2,7 @@
//! `PetFrame` over synthetic `"pet"` snapshots and the events the app's feed fires.
//!
//! The frame's whole job is to be right about **which unit an event names**, and that is what most
//! of these test: `UNIT_PET` names the OWNER (`arg1 == "player"`, wow-re §9), every other `UNIT_*`
//! of these test: `UNIT_PET` names the OWNER (`arg1 == "player"`, `0x4bc84f`), every other `UNIT_*`
//! names the pet itself, and a frame that mixes the two repaints off the player's health.
use benilla_ui::script::{
@@ -132,6 +132,7 @@ fn pet_buff(spell_id: u32, name: &str, count: u8) -> AuraState {
/// `UNIT_PET` is the only wire either edge has, which is why it carries them both.
#[test]
fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
@@ -169,6 +170,7 @@ fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
/// this drives each event with the WRONG token and asserts nothing moved.
#[test]
fn the_frame_answers_only_the_events_that_name_its_own_unit() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
@@ -226,6 +228,7 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
/// because it means the bar comes back only through a value change, never through `PetFrame.lua`.
#[test]
fn a_powerless_pet_wears_the_no_mana_plate() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
const PLAIN: &str = "Interface\\TargetingFrame\\UI-SmallTargetingFrame";
@@ -262,6 +265,7 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
/// repaints the rest of the frame (they are the ref's own early returns).
#[test]
fn the_attack_overlay_follows_its_own_two_events() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
@@ -318,6 +322,7 @@ fn the_attack_overlay_follows_its_own_two_events() {
/// house at all; the old assertion was reading a FontString our transcription invented.
#[test]
fn the_debuff_row_fills_from_the_pets_own_auras() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
@@ -366,6 +371,7 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
/// the app answers in `target::click::target_unit_requests`' `"pet"` arm.
#[test]
fn left_clicking_the_pet_frame_targets_it() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
@@ -395,6 +401,7 @@ fn left_clicking_the_pet_frame_targets_it() {
/// a queued token and no error.
#[test]
fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
@@ -489,6 +496,7 @@ fn stats(
/// branch ran** — `PET_HAPPINESS2` can only come from the `happiness == 2` arm.
#[test]
fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
@@ -533,9 +541,10 @@ fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
/// **Bucket 0 keeps the icon up.** The reference hides on `not happiness`, and `0` is truthy in
/// Lua — so a client that folded bucket 0 into nil would hide a frame the reference shows. This is
/// the trap wow-re calls out by name, tested where it would actually bite: in the frame.
/// the `GetPetHappiness 0x4be900` trap, tested where it would actually bite: in the frame.
#[test]
fn happiness_bucket_zero_keeps_the_icon_showing() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
@@ -560,6 +569,7 @@ fn happiness_bucket_zero_keeps_the_icon_showing() {
/// frame-wide update, so a happiness tick must not be a full repaint.
#[test]
fn unit_happiness_repaints_only_the_icon() {
benilla_formats::wow_data_or_skip!();
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
@@ -600,6 +610,7 @@ fn unit_happiness_repaints_only_the_icon() {
/// the mana bar and simply covers the rail.
#[test]
fn the_pet_art_paints_over_the_bars() {
benilla_formats::wow_data_or_skip!();
let s = load_pet_frame();
let level: (i64, i64, i64) = s
.eval(
@@ -484,11 +484,10 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
// **The pane's scene draws AFTER the icon, and that is the reference's own order.**
// `PetPaperDollPetInfo` is a sibling frame of `PetModelFrame` (same parent, so the same
// `(strata, level)` bucket) and its happiness art is a BACKGROUND texture on it; a model's
// scene drains out of that bucket's ARTWORK batch, last (wow-re
// `ui/scratch/model-frame-draw-order.md`: `0x76d160` registers the render callback only for
// layer 2, and `0x76fb00` drains quads, then text, then callbacks). The icon stays visible
// because a model pane is not an opaque quad — the bake is transparent everywhere the pet
// is not, here as in the client. This assertion is the ordering, not the pixels.
// scene drains out of that bucket's ARTWORK batch, last (`0x76d160` registers the render
// callback only for layer 2, and `0x76fb00` drains quads, then text, then callbacks). The icon
// stays visible because a model pane is not an opaque quad — the bake is transparent everywhere
// the pet is not, here as in the client. This assertion is the ordering, not the pixels.
assert!(
pane.z > icon.z,
"the pane's scene draws out of the ARTWORK batch, after a sibling's BACKGROUND art \
@@ -42,6 +42,7 @@ fn confirm(s: &mut UiScript, who: &str, quest: &str) {
/// right at a glance.
#[test]
fn the_confirm_names_the_player_first_and_the_quest_second() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
confirm(&mut s, "Thrall", "Escort Duty");
assert!(
@@ -59,6 +60,7 @@ fn the_confirm_names_the_player_first_and_the_quest_second() {
/// Yes queues exactly one `ConfirmAcceptQuest()` and closes the popup.
#[test]
fn yes_answers_once() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
confirm(&mut s, "Thrall", "Escort Duty");
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
@@ -75,6 +77,7 @@ fn yes_answers_once() {
/// latch is cleared by the next thing that touches it. Same for ESC.
#[test]
fn no_and_escape_send_nothing() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
confirm(&mut s, "Thrall", "Escort Duty");
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
@@ -92,6 +95,7 @@ fn no_and_escape_send_nothing() {
/// stale popup would send the wrong quest id.
#[test]
fn a_second_confirm_replaces_the_first() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
confirm(&mut s, "Thrall", "Escort Duty");
confirm(&mut s, "Jaina", "Deeper Still");
@@ -15,6 +15,7 @@ use super::test_ui::load_ui as load_xml;
/// Same pair as gossip — questgiver and gossip are the same "list" surface to the client.
#[test]
fn questgiver_show_hide_plays_open_and_close_kits() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -86,6 +87,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
/// stays open is silent).
#[test]
fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -185,6 +187,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
/// have and a split the ref's own function cannot be written against.
#[test]
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -288,6 +291,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
/// the highlight and arms `GetQuestReward`'s 1-based→0-based conversion.
#[test]
fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -368,6 +372,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
/// v1 layout never carried) — a plain client-side close, no `DeclineQuest()` call (ref l.748-752).
#[test]
fn greeting_goodbye_button_closes_the_window() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -426,6 +431,7 @@ fn greeting_goodbye_button_closes_the_window() {
/// The write-on arm is `write_on_still_fades_when_instant_text_is_off`'s subject.
#[test]
fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -555,6 +561,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
/// objectives/rewards block FADES in over QUESTINFO_FADE_IN rather than snapping.
#[test]
fn write_on_still_fades_when_instant_text_is_off() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -633,6 +640,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
/// update it too (the 0112-era capture showed a permanently blank bar).
#[test]
fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -713,6 +721,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
/// 6 px/char × 14 px/line measure fake as the gossip row test.
#[test]
fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Stock sizes each row at show time — `SetHeight(GetTextHeight() + 2)` right after SetText
@@ -818,6 +827,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
/// it closes the giver window, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_without_selecting_the_choice() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -71,6 +71,7 @@ fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
QuestLogState {
num_quests: entries.len() as u32,
entries,
hidden_quest_ids: Vec::new(),
}
}
@@ -180,30 +181,3 @@ fn a_world_entry_paints_a_quest_already_running() {
// gap reads 89 through the `−1`.
assert_eq!(row_text(&s, 1), "1 Min 29 Secs ");
}
/// **The reference file is the test oracle** (decision 0675). Scrapes both this window and the
/// extracted reference `QuestTimerFrame.xml` for `<AbsDimension>` pairs per named element and
/// asserts every shared element's numbers match — the guard that catches a transcription whose
/// frames all load, click and populate while every number is wrong.
///
/// Verified to fail: perturbing `QuestTimer1`'s TOP offset from -30 to -29 reports
/// `QuestTimer1: ours [(140.0, 16.0), (0.0, -29.0)] != ref [(140.0, 16.0), (0.0, -30.0)]`.
#[test]
fn the_window_geometry_matches_the_reference_framexml() {
let _data = benilla_formats::wow_data_or_skip!();
let Some(reference) =
super::framexml_diff::reference("Interface\\FrameXML\\QuestTimerFrame.xml")
else {
eprintln!("skipping: no extracted FrameXML");
return;
};
// No exemptions: this window's frames keep the reference's bare names (decision 0591 §3 — the
// manage pass and the ref's own row lookup both resolve by literal name), and every number in
// it is the reference's. If an entry ever needs to go here, it names its reason.
super::framexml_diff::assert_geometry_matches(
"Interface\\FrameXML\\QuestTimerFrame.xml",
&reference,
&[],
22,
);
}
@@ -80,6 +80,7 @@ fn eight_entries() -> QuestLogState {
QuestLogState {
num_quests: 8,
entries,
hidden_quest_ids: Vec::new(),
}
}
@@ -167,7 +168,8 @@ fn shipped_questlog_frame_drives_end_to_end() {
);
// Row 1 carries entry 1's title (indented), the count line reads "Quests: |cffffffff8/20|r", and nothing having been
// selected, the first non-header entry auto-selects (pin §2's SetFirstValidSelection).
// selected, the first non-header entry auto-selects (`QuestLog_SetFirstValidSelection`,
// `QuestLogFrame.lua:295`).
assert!(s
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
.unwrap()
@@ -473,7 +475,7 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
/// Shift-clicking a quest with zero objectives (ref `QUEST_WATCH_NO_OBJECTIVES`) and shift-clicking
/// past `GetNumQuestWatches() >= 5` (ref `QUEST_WATCH_TOO_MANY`) both refuse the watch and put the
/// ref's red line on the errors frame through its own `UIErrorsFrame:AddMessage`; the click still
/// selects the row exactly like any other click (pin §5).
/// selects the row exactly like any other click.
#[test]
fn watch_guards_no_op_without_erroring() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -586,7 +588,7 @@ fn watch_guards_no_op_without_erroring() {
/// The auto quest watch (ref `AUTO_QUEST_WATCH` default-on, QuestLogFrame.lua:702-786 —
/// "Quests are automatically watched for 5 minutes when you achieve a quest objective."): the
/// engine's per-quest `BENILLA_QUEST_PROGRESS(logIndex)` arms a 300 s timed watch (the native
/// QUEST_WATCH_UPDATE carries the WATCH-LIST index per the §5-verified byte law — repaint only,
/// QUEST_WATCH_UPDATE carries the WATCH-LIST index from `0x4df880` — repaint only,
/// the shipped 1.12 auto-watch chain being broken at that seam), fresh progress
/// re-arms it, and expiry (the watch frame's OnUpdate) unwatches and hides the empty HUD.
#[test]
@@ -1065,6 +1067,7 @@ fn overflowing_entry() -> QuestLogState {
.collect();
QuestLogState {
num_quests: 1,
hidden_quest_ids: Vec::new(),
entries: vec![QuestLogEntryView {
quest_id: 1,
title: "A Very Long Quest".into(),
@@ -1232,7 +1235,7 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
);
}
/// Pin §3/§5's doNotScroll asymmetry (QUESTLOG-PIN.md, `QuestLogFrame.lua:348/455-457`): a manual
/// The reference's doNotScroll asymmetry (`QuestLogFrame.lua:348/455-457`): a manual
/// reselect always snaps the detail pane's scroll back to the top, but the `QUEST_LOG_UPDATE`
/// data-refresh path must NOT — a quest updating while already being read shouldn't yank the
/// reader's scroll position out from under them.
@@ -1293,7 +1296,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
10.0,
"QUEST_LOG_UPDATE's data-refresh path must not yank the scroll position (pin §5)"
"QUEST_LOG_UPDATE's data-refresh path must not yank the scroll position"
);
// A manual reselect (the row-click path — no doNotScroll) DOES reset it.
@@ -1856,7 +1859,7 @@ fn share_quest_click_queues_the_selected_quests_id() {
/// It is correct, and the reading was wrong about our own engine: the title FontString carries a
/// DECLARED height (`<ButtonText …><AbsDimension x="0" y="10"/>`), which arms both overflow
/// regimes — the line stack and the height-gated ellipsis — so the paint is one truncated line,
/// which is the reference's own result (wow-re `fontstring-overflow.md`: the ellipsis gate is
/// which is the reference's own result (`0x771ec0`'s ellipsis gate is
/// `boxW > 0 && boxH > 0`, and only an AUTO-height FontString escapes it). The render half is
/// pinned on the real font in `ui_text::layout`'s
/// `a_capped_quest_log_title_ellipsizes_on_one_line`; this is the geometry half — that the row
@@ -37,27 +37,12 @@ use std::path::{Path, PathBuf};
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root **and** a Quiver in it, or `None` — a skip, never a failure. Quiver is a live
/// third-party addon (`github.com/SabineWren/Quiver`) whose shipped file is a generated bundle, so
/// a machine that wants this test builds it into the corpus once.
/// a machine that wants this test builds it into the corpus once. The corpus itself resolves
/// through `benilla_formats::addon_corpus` — the one resolver, whose doc carries the incident.
fn quiver_root() -> Option<PathBuf> {
corpus_candidates()
benilla_formats::addon_corpus_candidates()
.into_iter()
.find(|c| c.join("Quiver").join("Quiver.toc").is_file())
}
@@ -67,10 +52,10 @@ macro_rules! quiver_or_skip {
match quiver_root() {
Some(root) => root,
None => {
eprintln!(
"skipping: no Quiver in the addon corpus — looked in {:?} \
(set $BENILLA_ADDON_CORPUS; the folder needs Quiver.toc + Quiver.bundle.lua)",
corpus_candidates()
benilla_formats::skipped(
"no Quiver in the addon corpus (set $BENILLA_ADDON_CORPUS; the folder needs \
Quiver.toc + Quiver.bundle.lua)",
&benilla_formats::addon_corpus_candidates(),
);
return;
}
@@ -217,6 +202,7 @@ fn seat_a_hunter(root: &Path) -> UiScript {
/// again even if it left a different error behind.
#[test]
fn quiver_publishes_its_global_functions_for_a_hunter() {
benilla_formats::wow_data_or_skip!();
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
@@ -264,6 +250,7 @@ fn quiver_publishes_its_global_functions_for_a_hunter() {
/// the test that names a *new* wall the moment one appears, instead of reporting a nil field.
#[test]
fn quiver_survives_a_hunter_session_start_without_raising() {
benilla_formats::wow_data_or_skip!();
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
@@ -337,6 +324,7 @@ fn shot_state(s: &mut UiScript) -> (bool, bool, f64, f64) {
/// forever for a shot it is never told about — which is a bar pinned at 100%.
#[test]
fn auto_shot_bar_saturates_when_no_ammo_lock_event_ever_arrives() {
benilla_formats::wow_data_or_skip!();
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
@@ -368,6 +356,7 @@ fn auto_shot_bar_saturates_when_no_ammo_lock_event_ever_arrives() {
/// the addon, on `SetWidth`, or on the standing-still check.
#[test]
fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() {
benilla_formats::wow_data_or_skip!();
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
@@ -451,6 +440,7 @@ fn draws(s: &mut UiScript, leaf: &str) -> bool {
/// dependency of ours, and a break in any of them shows up here as a missing quad.
#[test]
fn aspect_tracker_draws_the_icon_for_an_active_aspect() {
benilla_formats::wow_data_or_skip!();
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
@@ -492,6 +482,7 @@ fn aspect_tracker_draws_the_icon_for_an_active_aspect() {
/// Hawk is up" and failed — the addon was right and the test was wrong.
#[test]
fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
benilla_formats::wow_data_or_skip!();
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
@@ -531,6 +522,7 @@ fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
/// spent**, which is the half that was missing (decision 1509).
#[test]
fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
benilla_formats::wow_data_or_skip!();
use crate::ui_items::feed::{apply_container_source, FeedMemory};
use benilla_ui::script::{ContainerSlot, ContainerState};
use std::collections::HashMap;
@@ -565,6 +557,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
quiver(200),
Default::default(),
Vec::new(),
Vec::new(),
);
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
@@ -580,6 +573,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
quiver(199),
Default::default(),
Vec::new(),
Vec::new(),
);
s.tick(0.1);
@@ -259,7 +259,7 @@ mod tests {
/// Strip what is not code, before any call census over a FrameXML file.
///
/// Both `Name(` and `:Name(` counted calls inside comments until decision 1800: the round that
/// built `PickupMerchantItem` also asked wow-re for `ShowInventorySellCursor`, which
/// built `PickupMerchantItem` also asked about `ShowInventorySellCursor`, which
/// [`chain_gap_report`] had named as `PaperDollFrame.xml`'s last engine gap — and the answer
/// was that stock `PaperDollFrame.lua:754-756` has the call **commented out**, all three
/// lines. A real binding, never called, blocking a window that was not blocked.
@@ -433,7 +433,7 @@ mod tests {
// Order still decides, and it is still the whole rule — so it is asserted directly rather
// than through whichever window happens to collide this month. Two chunks, the same name,
// and the later one stands; `publish_global`'s non-overwriting rule (RF-0023) applies to
// and the later one stands; `publish_global`'s non-overwriting rule (`0x701bd0`) applies to
// FRAMES, never to a plain Lua global, and confusing the two has produced confident wrong
// diagnoses before.
s.run("function _order_probe() return 1 end").unwrap();
@@ -482,7 +482,7 @@ mod tests {
///
/// A candidate whose frame NAMES our own shipped file already owns produces failures that are
/// artefacts of the probe, not of the window. `publish_global` is deliberately non-overwriting
/// (RF-0023), so the second frame to claim a name gets a wrapper that `_G` never points at —
/// (`0x701bd0`), so the second frame to claim a name gets a wrapper that `_G` never points at —
/// and any reference body using the `getglobal(this:GetName())` idiom then reads a DIFFERENT
/// table than the `this` it just wrote to.
///
@@ -998,11 +998,11 @@ mod tests {
// wrong (1801). So this one runs the load itself — the same fresh-VM-plus-manifest
// pass the readiness probe does — and reports it in the same row.
// **A `LOAD:` line for a window we ALSO ship is suspect**, and the reason is the same
// one `chain_readiness_report` carries: `publish_global` is non-overwriting (RF-0023),
// so loading the stock file on top of our identically-named one leaves every colliding
// frame's global pointing at OUR frame while the stock file's handlers run against
// THEIRS. A field set on `this` in an OnLoad is then invisible to `getglobal(name)`,
// and the failure looks like a bug in whatever read it back.
// one `chain_readiness_report` carries: `publish_global` is non-overwriting
// (`0x701bd0`), so loading the stock file on top of our identically-named one leaves
// every colliding frame's global pointing at OUR frame while the stock file's handlers
// run against THEIRS. A field set on `this` in an OnLoad is then invisible to
// `getglobal(name)`, and the failure looks like a bug in whatever read it back.
//
// Worked example, because it cost an hour: stock `TradeFrame.xml` reported
// `MoneyFrame.xml:525: attempt to index local 'info'`. Nothing was wrong with our
@@ -1523,7 +1523,7 @@ mod tests {
(
"SkillFrame.xml",
"BuySkillTier",
"a 5875 binding (wow-re `bindings.md`: marshals and delegates to a C++ \
"a 5875 binding (`0x4d3e50`: marshals and delegates to a C++ \
method/net-send) of the pre-1.12 skill-point purchase UI. the detail bar's LearnSkillButton calls it, and \
that button shows only while `UnitCharacterPoints`'s second value or a row's \
step/rank cost is non-zero — which no 1.12 server sends. Unreachable until the \
@@ -1532,7 +1532,7 @@ mod tests {
(
"SkillFrame.xml",
"AddSkillUp",
"a 5875 binding (wow-re `bindings.md`: marshals and delegates to a C++ \
"a 5875 binding (`0x4d3c30`: marshals and delegates to a C++ \
method/net-send) of the pre-1.12 skill-point purchase UI. the detail bar's RightArrow calls it, and \
that button shows only while `UnitCharacterPoints`'s second value or a row's \
step/rank cost is non-zero — which no 1.12 server sends. Unreachable until the \
@@ -1541,7 +1541,7 @@ mod tests {
(
"SkillFrame.xml",
"RemoveSkillUp",
"a 5875 binding (wow-re `bindings.md`: marshals and delegates to a C++ \
"a 5875 binding (`0x4d3c70`: marshals and delegates to a C++ \
method/net-send) of the pre-1.12 skill-point purchase UI. the detail bar's LeftArrow calls it, and \
that button shows only while `UnitCharacterPoints`'s second value or a row's \
step/rank cost is non-zero — which no 1.12 server sends. Unreachable until the \
@@ -1593,8 +1593,8 @@ mod tests {
(
"StaticPopup.xml",
"ReplaceTradeEnchant",
"a registered 1.12 binding whose body is uncarved (wow-re `bindings.md`, structural row only); a \
wow-re orchestrator is out on it and it is built when the carve lands (1960). Reached by TRADE_REPLACE_ENCHANT's Accept, an event this engine does not fire yet.",
"a registered 1.12 binding (`0x48d330`) whose body is not yet known; it is built \
once it is (1960). Reached by TRADE_REPLACE_ENCHANT's Accept, an event this engine does not fire yet.",
),
];
@@ -2331,8 +2331,8 @@ mod tests {
"TRADE_REQUEST",
"UIParent.lua — the trade ASK dialog, and the one entry on this list that is \
UNPRODUCEABLE rather than unbuilt: the 5875 client registers the event and \
signals it from NOWHERE (a whole-image census, wow-re \
ui/scratch/incoming-trade-request-law.md §3), so StaticPopupDialogs[\"TRADE\"] is \
signals it from NOWHERE (a whole-image census: no signal site passes its id, \
`0x11d`), so StaticPopupDialogs[\"TRADE\"] is \
dead code THERE too. benilla wired the dialog up once and took it back out — \
decision 1764. Producing this would be a divergence, not a fix",
),
@@ -2341,7 +2341,6 @@ mod tests {
"UIParent.lua — the enchant-replacement confirm inside a trade; benilla's trade \
feed does not derive it",
),
("BAG_OPEN", "ContainerFrame.lua"),
(
"CLOSE_WORLD_MAP",
"WorldMapFrame.lua — the engine-side close the reference fires when the map is \
@@ -2363,8 +2362,8 @@ mod tests {
(
"SHOW_COMPARE_TOOLTIP",
"PaperDollFrame.lua — the second `TRADE_REQUEST` (decision 1764): event 377 is \
registered in 5875 and signalled from NOWHERE (zero fire sites in wow-re's own \
census, `merchant-compare-item-law.md` §8), so this listener is dead code THERE \
registered in 5875 and signalled from NOWHERE (zero fire sites in the whole \
image), so this listener is dead code THERE \
too. benilla fired it from 0283 until 2202, then drove the plates itself on a \
shift-held hover until 2210; both were supersets. Nothing in this engine seats a \
shopping plate now — the reference's own callers do (`MerchantFrame.xml:63-80`, \
@@ -537,11 +537,11 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
/// gates its hide-old step on the new state having a texture, so the `UI-CheckBox-Up` box stays up
/// and the row reads "a greyed **At War** beside an empty box". Ours resolved the shown texture as
/// a pure function of the state, hid it, and left a bare grey label with nothing beside it — which
/// is what MarcusAga photographed on Ironforge.
/// is how the Ironforge row drew.
///
/// Three rows, because the tick is the half that made the report confusing: a peace-forced faction
/// that is NOT at war shows the empty box (the shot), a peace-forced faction that IS at war shows
/// the box plus its grey `DisabledCheckedTexture` (*"the tick renders, however"*), and a
/// the box plus its grey `DisabledCheckedTexture` (the tick still draws), and a
/// toggleable one is the control that must not move.
#[test]
fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
@@ -156,6 +156,7 @@ fn tooltip_change_costs_a_whole_ui_solve() {
/// about the second solve is tooltip-sized, and nothing about it is cheap.
#[test]
fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
install_changing_tooltip(&s, "GateOwner", "gate_change");
@@ -215,6 +216,7 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
/// backtracing the touch sites on a live run.
#[test]
fn the_settled_shipped_ui_costs_no_gate_walk_on_a_quiet_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
// Ticked frames, so the shipped UI's own OnUpdate handlers run — the whole point (see
// `app_frame_ticked`). A couple of frames of grace first: `settled_default_ui` stops as soon
@@ -272,6 +274,7 @@ fn the_settled_shipped_ui_costs_no_gate_walk_on_a_quiet_frame() {
/// walks was exactly that, so the old counter under-reported the bug by a third.
#[test]
fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
@@ -356,6 +359,7 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
/// everything else, and only a ticked frame can see that.
#[test]
fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
@@ -424,6 +428,7 @@ fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
/// skipped as well as the ones it resolved.
#[test]
fn a_tooltip_content_change_solves_a_tooltip_sized_scope() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
install_changing_tooltip(&s, "ScopeSizeOwner", "scope_size_change");
@@ -457,6 +462,7 @@ fn a_tooltip_content_change_solves_a_tooltip_sized_scope() {
/// bug, and a stale rect that one does carry is the bug in the form the screen would show it.
#[test]
fn a_scoped_resolve_reproduces_the_whole_graph_solve() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
install_changing_tooltip(&s, "ScopeOwner", "scope_change");
@@ -530,6 +536,7 @@ fn measure_sweep_steady_state_cost() {
/// structural) and the counter is proved able to move.
#[test]
fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
@@ -601,6 +608,7 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
/// is big enough for the derivation to matter.
#[test]
fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
@@ -676,6 +684,7 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
/// idiom, not the gesture.
#[test]
fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
@@ -752,6 +761,7 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
/// addons. `derives/frame` read 0.88 with Bagnon and 0.00 without.
#[test]
fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
@@ -50,6 +50,7 @@ fn alpha(s: &UiScript) -> f64 {
/// captured cannot contain it.
#[test]
fn the_capture_is_asked_for_silently_and_only_the_answer_speaks() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
assert!(!shown(&s), "the status line starts hidden");
@@ -76,6 +77,7 @@ fn the_capture_is_asked_for_silently_and_only_the_answer_speaks() {
/// engine is asked — otherwise that shot would have "Screen Captured" printed across it.
#[test]
fn a_second_press_inside_the_fade_clears_the_line_before_capturing() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
s.tick(0.5);
@@ -97,6 +99,7 @@ fn a_second_press_inside_the_fade_clears_the_line_before_capturing() {
/// The 1.5 s fade: alpha falls with elapsed time and the frame takes itself off screen at the end.
#[test]
fn the_line_fades_out_over_the_reference_s_second_and_a_half() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
@@ -114,6 +117,7 @@ fn the_line_fades_out_over_the_reference_s_second_and_a_half() {
/// The failure path says so, in the reference's own words.
#[test]
fn a_failed_capture_shows_the_failure_string() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("SCREENSHOT_FAILED", Vec::new());
assert!(shown(&s));
+12 -13
View File
@@ -15,9 +15,9 @@
//! `1` — reads a `true` as "not held". So the kinds column is gated as well, wherever the table
//! says it is trustworthy (decision 2118).
//!
//! The table is wow-re's `re/audit/binding-shapes.tsv`, vendored: 1722 rows over all 82 registrar
//! tables, one per registered binding, generated and differentially tested against that repo's
//! other harvester. It carries the gate rule **per row** rather than in prose, which is the whole
//! The table is a harvest of the reference binary, vendored: 1722 rows over all 82 registrar
//! tables, one per registered binding, generated and differentially tested against a second
//! harvester. It carries the gate rule **per row** rather than in prose, which is the whole
//! reason it can be enforced instead of remembered:
//!
//! * `arity_conf = exact` — sound. This is the column to gate on.
@@ -71,9 +71,9 @@ const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
/// two it held did exactly that, within the day.
///
/// **It is empty.** It held `GetSendMailItem`, whose no-attachment leg the shapes table types as
/// two numbers and no recorded note gave the values of. wow-re answered it at the bytes —
/// `0x4ae590` pushes `(nil, nil, 0, 0)`, and the stray `1` we had was borrowed from
/// `GetAuctionSellItemInfo`'s empty leg — and 2129 folded the verdict back.
/// two numbers without their values. The bytes answer it — `0x4ae590` pushes `(nil, nil, 0, 0)`,
/// and the stray `1` we had was borrowed from `GetAuctionSellItemInfo`'s empty leg — and 2129
/// folded the verdict back.
const KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[];
/// The widget kind gate's shrinking list — see [`KINDS_NOT_YET_ASSERTED`].
@@ -124,12 +124,11 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
return None;
}
// **`agree` is necessary and not sufficient, and the second condition is the table's
// own too** (2150 + wow-re `scratch/binding-kinds-stack-overwrite.md`). The kinds
// come from tracing each binding's `lua_push*` calls; a row whose notes carry
// `delegated-push` has a CALLEE that pushes on its own behalf, which the trace sees
// one level too high up, so its tuple covers the binding's own ops only. `eax` is
// unaffected, so arity stays gated on every row — it is the kinds claim alone that
// narrows.
// own too** (2150). The kinds come from tracing each binding's `lua_push*` calls; a row
// whose notes carry `delegated-push` has a CALLEE that pushes on its own behalf, which
// the trace sees one level too high up, so its tuple covers the binding's own ops only.
// `eax` is unaffected, so arity stays gated on every row — it is the kinds claim alone
// that narrows.
let delegated = f.len() > 11 && f[11].contains("delegated-push");
Some(Row {
name: f[0].to_string(),
@@ -322,7 +321,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
const BASELIB_PROBES: &[(&str, &str)] = &[
("collectgarbage", ""),
("date", ""),
// The five shipped stubs — `xor eax,eax; ret` in the image (`lua-dialect.md` §3a), so calling
// The five shipped stubs — `xor eax,eax; ret` in the image (`0x7027e0`–`0x702820`), so calling
// one is as safe here as it is there, and their `0 exact ()` rows are still worth holding.
("debugbreak", ""),
("debugdump", ""),
@@ -49,6 +49,7 @@ fn every_shipped_ui_xml_parses() {
/// window's entry is covered the moment it is added rather than when someone remembers to test it.
#[test]
fn the_whole_shipped_manifest_loads_without_errors() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -88,6 +89,7 @@ fn the_whole_shipped_manifest_loads_without_errors() {
/// every single launch.
#[test]
fn loading_the_shipped_ui_queues_no_sounds() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -154,6 +156,7 @@ fn pet_bar_vm() -> benilla_ui::script::UiScript {
/// catch a third template added with a borrowed number.
#[test]
fn the_autocast_brackets_reach_each_buttons_corners() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::framexml::{Element, TopLevel};
/// The fraction of `UI-AutoCastableOverlay.blp` the bracket art actually covers, measured off
@@ -252,6 +255,7 @@ fn the_autocast_brackets_reach_each_buttons_corners() {
/// drifting, or being "corrected" back to 1.22 by someone who only read the ref.
#[test]
fn the_shine_panes_ask_for_the_rims_we_meant() {
benilla_formats::wow_data_or_skip!();
let mut found: Vec<(String, f32, f32)> = Vec::new();
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
@@ -530,10 +534,10 @@ fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
}
}
}
// Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980, 4 after 1987;
// the floor follows the census down as windows migrate).
// Never let the sweep pass by matching nothing; the floor follows the census down as windows
// migrate.
assert!(
paths.len() >= 4,
paths.len() >= 3,
"only {} archive paths swept out of the shipped Lua",
paths.len()
);
@@ -577,10 +581,9 @@ fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
/// **Every `text=` in the shipped UI is answerable against the REAL `GlobalStrings.lua`** — the
/// tripwire for the defect that put "CREATE_MACROS" across the macro window's title bar (0991).
///
/// `text=` is a global-string LOOKUP, not a literal (wow-re rf28 l.36/l.115 → `FrameScript_GetText
/// 0x703bf0`). The loader didn't do the lookup at all, so **23 key-shaped values across six
/// windows** were rendering as their own key names — and nothing anywhere said so. Two halves, both
/// needed:
/// `text=` is a global-string LOOKUP, not a literal (`FrameScript_GetText 0x703bf0`). The loader
/// didn't do the lookup at all, so **23 key-shaped values across six windows** were rendering as
/// their own key names — and nothing anywhere said so. Two halves, both needed:
///
/// - a **key-shaped** value (`SCREAMING_SNAKE`) must resolve to a non-empty string, or it reaches a
/// real screen as its own key;
@@ -779,6 +782,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() {
/// churn on every action-bar edit, and a noisy gate is a gate nobody reads.
#[test]
fn every_texture_frame_outranks_its_status_bars() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::order::unpack;
let mut s = benilla_ui::script::UiScript::new().unwrap();
@@ -894,6 +898,7 @@ fn every_texture_frame_outranks_its_status_bars() {
/// screen; this is the structural half of that fix.)
#[test]
fn the_boot_phase_materializes_no_frames() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The font registry is manifest entry 0 and comes off the chain since 1888, so this reads
@@ -922,6 +927,7 @@ fn the_boot_phase_materializes_no_frames() {
/// rebakeable.
#[test]
fn the_font_registry_alone_covers_the_whole_bake_plan() {
benilla_formats::wow_data_or_skip!();
let plan = |whole: bool| -> std::collections::BTreeSet<(String, String, String)> {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -978,6 +984,7 @@ fn the_font_registry_alone_covers_the_whole_bake_plan() {
/// handler that errors on the one event that runs before any window has been shown.
#[test]
fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -1018,6 +1025,7 @@ fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
/// `Tablet-2.0.lua:289` header-size probe, 268 read sites).
#[test]
fn every_shipped_font_object_is_published_as_a_lua_global() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -1107,6 +1115,7 @@ fn every_shipped_font_object_is_published_as_a_lua_global() {
/// geometry and the script the reference confers.
#[test]
fn the_inheritable_reference_templates_confer_their_shape() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -1171,6 +1180,7 @@ fn the_inheritable_reference_templates_confer_their_shape() {
/// it from its keyring fork.
#[test]
fn the_inspect_cursor_pair_takes_both_arms() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -1247,6 +1257,7 @@ fn the_inspect_cursor_pair_takes_both_arms() {
/// stops exactly as the reference's does.)
#[test]
fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -1352,6 +1363,7 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
/// because of it.
#[test]
fn every_declared_parent_really_attaches() {
benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
@@ -1965,7 +1977,7 @@ fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
.unwrap(),
"…so the dropdown is left empty but ENABLED — the greying branch is the driver-quirk one, \
and inventing a 0 to reach it would be inventing a value the binary never produces \
(`IsEnabled` answers a NUMBER — wow-re `binding-shapes.tsv` 0x7800b0)"
(`IsEnabled` answers a NUMBER — `0x7800b0`)"
);
// The optional index argument is tolerated, which is the shape `SetScreenResolution` shares.
assert!(
@@ -2000,7 +2012,8 @@ fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
"`not hasTripleBuffering` must stay FALSE (the dead clause) while `== 1` is also false"
);
// `SetScreenResolution`'s three carved argument behaviours, and the one deliberate divergence.
// `SetScreenResolution`'s three argument behaviours in the reference, and the one deliberate
// divergence.
assert!(
s.eval::<bool>("SetScreenResolution() return GetCVar(\"gxResolution\") == \"1280x720\"")
.unwrap(),
@@ -2073,7 +2086,7 @@ fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
}
/// **The display-brightness pair** (decision 2182) — `GetGamma`/`SetGamma` and the Graphics page
/// row that drives them, held to the reference's own carve.
/// row that drives them, held to the reference's own behaviour.
///
/// The claim that needs an assertion rather than a comment is the **unit**: `0x4891d0` is FSUBR,
/// so `GetGamma()` is `1.0 − gamma` and `SetGamma(v)` writes `gamma := 1.0 − v`. Read as
@@ -2086,9 +2099,6 @@ fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
/// And the second claim: **there is no clamp**, anywhere, in the reference (the positive control
/// is `baseMip`'s validating callback `0x689090`). `SetGamma(5)` writes `"-4.000000"` and the
/// store keeps it — benilla's clamp is at the render consumer, where it cannot lie to `GetCVar`.
///
/// (wow-re `ui/scratch/video-options-verbs.md` §3 and
/// `ffxeffects/scratch/whole-frame-grade-verdict.md` §(a), both VERIFIED.)
#[test]
fn the_display_brightness_pair_speaks_the_reference_slider_unit() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -2198,8 +2208,6 @@ fn the_display_brightness_pair_speaks_the_reference_slider_unit() {
/// exist in the reference, with a capital F, while `value.func` is the lowercase `"farclip"`. The
/// stock client takes the CVar path for far clip **only because `getglobal` is case-sensitive** — so
/// this asserts that too, against a name we DO define.
///
/// (wow-re `ui/scratch/video-options-verbs.md` §5 and §7.7, both VERIFIED.)
#[test]
fn the_video_windows_ten_composed_names_stay_nil() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -2252,7 +2260,7 @@ fn the_video_windows_ten_composed_names_stay_nil() {
/// gap rather than hiding it, and saying in as many words that "the day something registers it,
/// THIS assertion goes red and gets deleted". That day is this change: `UIOptionsFrameSliders`
/// row 3 is `cameraYawMoveSpeed`, `_Load` does `slider:SetValue(GetCVar(value.cvar))`, and
/// `Slider:SetValue` is a shape-A binding (`0x790980`, wow-re `numeric-arg-coercion-law.md`) that
/// `Slider:SetValue` is a shape-A binding (`0x790980`) that
/// raises on a nil in the reference too. All four slider CVars are registered, so the walk reaches
/// its end — and `_SetDefaults`, which does the same through `GetCVarDefault`, with it.
#[test]
@@ -2357,3 +2365,36 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
"_Save should write the slider's 180 and its half; got yaw={yaw} pitch={pitch}"
);
}
/// `assets/ui` does not grow. The interface is the stock 1.12 FrameXML executed off the player's
/// own patch chain; what is left here is the glue and the shims, named below. A new file means a
/// window was authored instead of migrated: point `benilla.toc` at
/// `Interface\FrameXML\<Window>.xml`, delete ours, and build the engine verbs the stock file calls.
#[test]
fn assets_ui_does_not_grow() {
const SHIPPED: &[&str] = &[
"ContainerFrameAdapters.xml",
"GameMenuFrame.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"ScriptLogFrame.xml",
"ScrollTemplates.xml",
"SpellBookAdapters.xml",
"benilla.toc",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut found: Vec<String> = std::fs::read_dir(&dir)
.expect("assets/ui")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| !n.starts_with('.'))
.collect();
found.sort();
let mut shipped: Vec<String> = SHIPPED.iter().map(|s| s.to_string()).collect();
shipped.sort();
assert_eq!(
found, shipped,
"assets/ui changed. It does not grow: a window is migrated, not authored (docs/METHOD.md). \
A file that retired comes off this list; a new one needs a reason this list can name."
);
}
@@ -8,7 +8,7 @@
//! proficiencies, which the server really does report as `1/1`, and the **single-rank** lines
//! (class skills, Dual Wield, racials, the per-mount riding lines), whose `skillMaxRank` the
//! engine overrides to `1` off `SkillRaceClassInfo.flags & 0x400` however high the server's own
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; wow-re `0x4d3610`'s `4d38b1` branch). A
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; `0x4d3610`'s `4d38b1` branch). A
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
//! numberless, exactly as it does in the real client.
//!
@@ -374,11 +374,11 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
// Its label is the CLOSE global string's seat, in the panel-button gold.
// **"Close", not "CLOSE".** An XML `text=` attribute is a GlobalStrings LOOKUP, not a literal
// (`loader::Loader::resolve_text`, wow-re `rf28-typed-widget-loadxml.md` l.36 —
// `FrameScript_GetText 0x703bf0`), and `CLOSE = "Close"` (`GlobalStrings.lua:760`). This test
// read "CLOSE" while the page's list carried no `GlobalStrings.lua`: it was asserting the
// miss-fallback (the raw attribute), which is the REFERENCE's own arm too (`0x778c31` /
// `0x771032`), not the label a client with the strings loaded draws.
// (`loader::Loader::resolve_text` — `FrameScript_GetText 0x703bf0`), and `CLOSE = "Close"`
// (`GlobalStrings.lua:760`). This test read "CLOSE" while the page's list carried no
// `GlobalStrings.lua`: it was asserting the miss-fallback (the raw attribute), which is the
// REFERENCE's own arm too (`0x778c31` / `0x771032`), not the label a client with the strings
// loaded draws.
// [`super::test_ui::CHARACTER_UI`] loads the player's own strings first, as the app does, so
// this is the real label now.
let label = page_quads(&s)
@@ -507,8 +507,8 @@ fn the_expand_tab_fits_its_label_at_load() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **B370 — the list reaches its last rows.** MarcusAga's Skills tab stopped three rows short of
/// the end: the knob mid-track, Maces the last row shown. The reference's own
/// **B370 — the list reaches its last rows.** The symptom: the Skills tab stops three rows short
/// of the end, the knob mid-track, Maces the last row shown. The reference's own
/// `FauxScrollFrame_Update` sizes the bar to `(n − 12) × 15` and the scroll child to `n × 15`, and
/// `SkillListScrollFrame` is 220 tall (stock `SkillFrame.xml` l.468) against twelve rows of
/// fifteen — so the child's overflow past the frame, `n × 15 − 220`, is forty pixels short of
@@ -127,6 +127,7 @@ pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
/// AutoCast Model child) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame.
#[test]
fn shipped_spellbook_loads_clean() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
@@ -166,6 +167,7 @@ fn shipped_spellbook_loads_clean() {
/// bar-to-bar drag uses — packing kind 0x00 (SPELL) with the spell id.
#[test]
fn shipped_spellbook_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
@@ -256,6 +258,7 @@ fn shipped_spellbook_drives_end_to_end() {
/// parked until SMSG_COOLDOWN_EVENT) keeps the widget hidden and dims the icon to 40% (l.361-365).
#[test]
fn shipped_spellbook_shows_the_cooldown_pie() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::QuadContent;
let mut s = spellbook_ui(1024.0, 768.0);
@@ -331,6 +334,7 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
/// finding, which stands). That is the half this still pins.
#[test]
fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
benilla_formats::wow_data_or_skip!();
let mut s = spellbook_ui(640.0, 700.0);
// A one-spell book: slot 5 takes the reference's `id > offset + numSpells` disable path. (A
// book with NO spells is a state no character is ever in — the reference's own
@@ -371,6 +375,7 @@ fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
/// l.132/134/268/296-303/336): 1/"true"/true check; 0/"false"/nil/non-numeric strings uncheck.
#[test]
fn set_checked_uses_blizzard_bool_coercion() {
benilla_formats::wow_data_or_skip!();
let s = spellbook_ui(1024.0, 768.0);
for (arg, want) in [
("1", true),
@@ -438,6 +443,7 @@ fn pet_book() -> benilla_ui::script::PetBookState {
/// the class token's label, and a right-click flips autocast instead of casting.
#[test]
fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
benilla_formats::wow_data_or_skip!();
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
@@ -659,6 +665,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
/// wrong, and the install says it is what 1.12.1 does.
#[test]
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The macro window's own strings (the app runs the real `GlobalStrings.lua`; `macro_tests`'
@@ -27,6 +27,7 @@ fn setup() -> UiScript {
/// (ref StaticPopup_OnUpdate l.1713-1726).
#[test]
fn timeout_expires_into_a_timeout_cancel() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run(
r#"reason = "unset"
@@ -62,6 +63,7 @@ fn timeout_expires_into_a_timeout_cancel() {
/// itself is exercised too.
#[test]
fn start_delay_gates_button1_then_swaps_the_text_in() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run(
r#"StaticPopupDialogs["RECOVER_CORPSE"] = {
@@ -105,6 +107,7 @@ fn start_delay_gates_button1_then_swaps_the_text_in() {
/// the death arc's RESURRECT-cancels-DEATH chain.
#[test]
fn showing_a_dialog_cancels_its_named_victim_with_override() {
benilla_formats::wow_data_or_skip!();
let s = setup();
s.run(
r#"victim_reason = "unset"
@@ -137,6 +140,7 @@ fn showing_a_dialog_cancels_its_named_victim_with_override() {
/// non-escapable entry — the DEATH release popup's law — survives ToggleGameMenu.
#[test]
fn escape_skips_dialogs_without_hide_on_escape() {
benilla_formats::wow_data_or_skip!();
let s = setup();
s.run(
r#"StaticPopupDialogs["TEST_STICKY"] = {
@@ -169,6 +173,7 @@ fn escape_skips_dialogs_without_hide_on_escape() {
/// `timeleft` re-renders "%d %s until release" every tick, minutes above 60 s.
#[test]
fn the_death_countdown_text_rerenders_each_tick() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run(
r#"StaticPopupDialogs["DEATH"] = {
@@ -212,6 +217,7 @@ fn the_death_countdown_text_rerenders_each_tick() {
/// kind has at most one, so its data is never consulted.
#[test]
fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
benilla_formats::wow_data_or_skip!();
let s = setup();
s.run(
r#"
@@ -269,6 +275,7 @@ fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
/// binding this engine answers.
#[test]
fn the_verb_dialogs_open_from_their_events_and_call_their_verbs() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ScriptValue;
let mut s = setup();
load_xml(&s, r"Interface\FrameXML\UIParent.xml"); // the arms
@@ -53,6 +53,7 @@ fn setup() -> UiScript {
/// per-tick branch at all rather than `StaticPopup_Show`'s arguments.
#[test]
fn the_confirm_names_the_summoner_and_accept_queues_the_response() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_SUMMON", Vec::new());
assert!(
@@ -86,6 +87,7 @@ fn the_confirm_names_the_summoner_and_accept_queues_the_response() {
/// engine's shared `StaticPopupTimeUnit`, reached through this dialog's own four-argument format.
#[test]
fn the_countdown_line_switches_to_seconds_and_singularises() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
@@ -117,6 +119,7 @@ fn the_countdown_line_switches_to_seconds_and_singularises() {
/// countdown branch re-reads the getters rather than caching `StaticPopup_Show`'s arguments.
#[test]
fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_summon_confirm(SummonConfirmUiState {
summoner: String::new(),
@@ -151,6 +154,7 @@ fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
/// confirm in the folder. Cancel is never touched.
#[test]
fn combat_locks_accept_without_taking_the_dialog_down() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_unit("player", Some(player(false)));
s.fire_event("CONFIRM_SUMMON", Vec::new());
@@ -198,6 +202,7 @@ fn combat_locks_accept_without_taking_the_dialog_down() {
/// expiry.
#[test]
fn declining_and_expiring_both_send_nothing() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_SUMMON", Vec::new());
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
@@ -32,6 +32,7 @@ fn setup() -> UiScript {
/// parsed as an unknown opcode and dropped.
#[test]
fn the_confirm_shows_and_accept_queues_the_wipe() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
assert!(
@@ -58,6 +59,7 @@ fn the_confirm_shows_and_accept_queues_the_wipe() {
/// raise one, and the engine's `hasMoneyFrame` leg is what shows it.
#[test]
fn the_cost_lands_in_the_money_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
assert!(
!s.eval::<bool>("return StaticPopup1MoneyFrame:IsVisible()")
@@ -100,6 +102,7 @@ fn the_cost_lands_in_the_money_frame() {
/// other), so the only observable is that no confirm was queued.
#[test]
fn declining_sends_nothing() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
@@ -120,6 +123,7 @@ fn declining_sends_nothing() {
/// holds, ticking changes nothing; the frame it goes false, the dialog hides itself.
#[test]
fn leaving_the_trainers_range_hides_the_confirm() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(15_000)]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
@@ -134,6 +134,7 @@ fn size(s: &UiScript, name: &str) -> (f64, f64) {
#[test]
fn a_hostile_target_draws_debuffs_first_with_tint_and_count() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
target(
&mut s,
@@ -214,6 +215,7 @@ fn a_hostile_target_draws_debuffs_first_with_tint_and_count() {
#[test]
fn a_friendly_target_puts_the_buff_row_first() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
target(
&mut s,
@@ -241,6 +243,7 @@ fn a_friendly_target_puts_the_buff_row_first() {
#[test]
fn reaching_the_wrap_shrinks_the_first_row_to_17px() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
let debuffs: Vec<AuraState> = (0..6)
.map(|i| debuff(1000 + i, &format!("D{i}"), 1, None))
@@ -262,6 +265,7 @@ fn reaching_the_wrap_shrinks_the_first_row_to_17px() {
#[test]
fn clearing_the_list_or_the_target_hides_the_buttons() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]);
assert!(shown(&s, "TargetFrameDebuff1"));
@@ -78,6 +78,7 @@ fn menu() -> TaxiUiState {
/// hides it again.
#[test]
fn shipped_taxi_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = taxi_script();
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
@@ -133,6 +134,7 @@ fn shipped_taxi_frame_drives_end_to_end() {
/// refusal is visible through.
#[test]
fn no_single_hop_destination_posts_the_error_and_closes() {
benilla_formats::wow_data_or_skip!();
let mut s = taxi_script();
s.set_taxi(Some(TaxiUiState {
art: "Interface\\TaxiFrame\\TAXIMAP0".into(),
@@ -172,6 +174,7 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
/// the window hides immediately (no one-frame lag) — the Gossip/Trainer close-button precedent.
#[test]
fn close_button_queues_the_intent_and_hides() {
benilla_formats::wow_data_or_skip!();
let mut s = taxi_script();
s.set_taxi(Some(menu()));
seat_flight_master(&mut s, "Dungar Longdrink");
+2 -2
View File
@@ -216,7 +216,7 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
/// answers), then runs the reference's own body. First use rather than load time, and unnamed,
/// because both registries keep the first writer: the arena's name map
/// (`named_registry_is_non_overwriting`) and `_G` itself — a named frame is published
/// non-overwriting (RF-0023: a `lua_gettable` nil check on GLOBALSINDEX, which is why a
/// non-overwriting (`0x701bd0`: a `lua_gettable` nil check on GLOBALSINDEX, which is why a
/// metatable fallback would block the real publish just the same). A stand-in seated before the
/// real declaration shadows the real frame for good; the first cut did exactly that, 126
/// failures, every tabbed panel's `numTabs` read off the stand-in. `fire_chat_login`'s
@@ -268,7 +268,7 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
PETACTIONBAR_XPOS = PETACTIONBAR_XPOS or 36
-- The frames these three read UNGUARDED, seated on the call rather than at load: a frame's
-- publish to _G is non-overwriting (RF-0023), so a stand-in seated before the real file loads
-- publish to _G is non-overwriting (0x701bd0), so a stand-in seated before the real file loads
-- would shadow the real window for good.
local function benilla_seat(names)
for _, name in ipairs(names) do
@@ -57,6 +57,7 @@ fn wolf() -> UnitState {
/// resolves, and its rect IS the screen.
#[test]
fn uiparent_is_a_real_full_screen_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
s.resolve();
let ok: bool = s
@@ -77,6 +78,7 @@ fn uiparent_is_a_real_full_screen_frame() {
/// kept its load-time position instead.
#[test]
fn world_hover_seats_the_default_corner() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
s.set_unit("mouseover", Some(wolf()));
assert!(s.world_tooltip_unit("mouseover"), "the hover shows");
@@ -99,6 +101,7 @@ fn world_hover_seats_the_default_corner() {
/// world mouseover keeps the ramp; this is the unit *frame*.
#[test]
fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
benilla_formats::wow_data_or_skip!();
// The kit + popups precede the unit frames (their DropDown children's OnLoad), app order.
let mut s = harness(&[
// The stock unit frames resolve GlobalStrings at LOAD (`CombatFeedback.lua` l.7-17,
@@ -214,6 +217,7 @@ fn a_pending_name_hover_titles_the_chains_unknownobject() {
/// (the sibling test above).
#[test]
fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[
// The stock unit frames resolve GlobalStrings at LOAD (`CombatFeedback.lua` l.7-17,
// `UnitFrame.lua` l.1-6) and `UnitFrame_OnEnter` passes `PARTY_OPTIONS_LABEL` /
@@ -287,6 +291,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
/// table so the pass means what it says.
#[test]
fn action_button_hover_takes_the_default_corner() {
benilla_formats::wow_data_or_skip!();
let s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
@@ -328,6 +333,7 @@ fn action_button_hover_takes_the_default_corner() {
/// (`script/tooltip/verbs.rs`).
#[test]
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
@@ -408,6 +414,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
/// un-collapsed with it.
#[test]
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
@@ -461,6 +468,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
/// BOTTOMLEFT.
#[test]
fn buff_hover_hangs_below_left_of_the_button() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
@@ -498,7 +506,7 @@ fn buff_hover_hangs_below_left_of_the_button() {
s.resolve();
// Through the template's real `<OnEnter>` — the reference keeps the SetOwner/SetPlayerBuff pair
// inline there rather than in a named function, so the handler body itself is what this drives.
// `this` is set by hand because the engine sets it only when it *fires* a handler (RF-0025);
// `this` is set by hand because the engine sets it only when it *fires* a handler (`0x704d50`);
// calling the compiled function directly does not, and the body reads `this`, not its argument.
s.run("this = BuffButton0; BuffButton0:GetScript(\"OnEnter\")(BuffButton0)")
.unwrap();
@@ -527,6 +535,7 @@ fn buff_hover_hangs_below_left_of_the_button() {
/// cost.
#[test]
fn a_cursor_seated_gameobject_plate_is_owned() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
@@ -551,6 +560,7 @@ fn a_cursor_seated_gameobject_plate_is_owned() {
/// plate is ever shown. With the owner written, so do we.
#[test]
fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
// Questie's hook, in one line: the plate's own show event calls Show() again.
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
@@ -579,6 +589,7 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
/// the failure 2255 had just fixed on the cursor arm.
#[test]
fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None));
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
@@ -593,6 +604,7 @@ fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
/// `GameTooltip:Show()`, and `:Show()` is the existence gate `0x530a80`.
#[test]
fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
.unwrap();
@@ -7,7 +7,7 @@
//! **1.12.1 has no hover compare, and neither does benilla now.** `SHOW_COMPARE_TOOLTIP` (event
//! 377) has zero fire sites in 5875, so `PaperDollFrame.lua:621-640` is dead code there and the
//! vendor row (plus the auction row, 1971) is the only live consumer of the shopping plates
//! (wow-re `merchant-compare-item-law.md` §8). benilla fired that event until 2202 and then drove
//! (`SetMerchantCompareItem 0x536080`). benilla fired that event until 2202 and then drove
//! the plates itself on a shift-held hover until 2210; both were supersets of a client that
//! compares only where its own FrameXML asks. The plates' geometry and lifetime belong to that
//! FrameXML — this engine seats none of them.
@@ -412,7 +412,7 @@ fn shipped_merchant_row_raises_the_compare_plates() {
}
/// An armour template in one `InventoryType` — the CLASS is what the selection law compares by
/// (wow-re `merchant-compare-item-law.md` §3).
/// (the class test at `0x536262` in `SetMerchantCompareItem 0x536080`).
fn armor_template(name: &str, inventory_type: u32) -> ItemTemplateView {
ItemTemplateView {
name: name.into(),
@@ -99,6 +99,7 @@ fn drawn_textures(s: &mut UiScript) -> Vec<String> {
/// itself never publishes a line region.
#[test]
fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// AtlasLoot/Core/AtlasLoot.xml l.576's declaration, renamed.
let report = load_addon_xml(
@@ -168,6 +169,7 @@ fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
/// Strip `<Backdrop>` out of `GameTooltipTemplate` and this is the assertion that goes red.
#[test]
fn an_addon_tooltip_from_the_template_gets_the_plate() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
load_addon_xml(
&s,
@@ -214,6 +216,7 @@ fn an_addon_tooltip_from_the_template_gets_the_plate() {
/// Asserted from both sides: no error, and the tint actually landed.
#[test]
fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
benilla_formats::wow_data_or_skip!();
let s = harness();
let report = load_addon_xml(
&s,
@@ -240,7 +243,7 @@ fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
// The tint the bare call was there to apply (ref-GameTooltip.lua l.79-82's two colours),
// QUANTIZED: the reference's backdrop colour field is a packed `0xAARRGGBB` byte quad and the
// setter converts `×255 + 0.5` through `__ftol` (wow-re `numeric-arg-coercion-law.md` Q4), so
// setter converts `×255 + 0.5` through `__ftol` (`SetBackdropColor 0x777d30`), so
// `0.09` stores as 23 and reads back as `23/255`. This used to compare against `0.09` exactly,
// which was our lossless `[f32; 4]` showing through a store the client cannot make.
let q = |x: f32| f32::from((x * 255.0 + 0.5) as u8) / 255.0;
@@ -266,6 +269,7 @@ fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
/// owns the children, and the plate came along.
#[test]
fn createframe_with_the_template_is_the_same_tooltip() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// BetterCharacterStats/helper.lua l.3, verbatim in shape (its `getglobal(...) or` guard and
// the WorldFrame owner both collapse to this in a fresh VM).
@@ -305,6 +309,7 @@ fn createframe_with_the_template_is_the_same_tooltip() {
/// an explicit `Hide()` between passes (l.1832). Both read `TextRight` as well as `TextLeft`.
#[test]
fn the_scanner_shape_reads_both_columns_and_hides_again() {
benilla_formats::wow_data_or_skip!();
let s = harness();
load_addon_xml(
&s,
@@ -368,6 +373,7 @@ fn the_scanner_shape_reads_both_columns_and_hides_again() {
/// instance, which is where it lived while it was inlined.
#[test]
fn the_status_bar_is_the_templates_because_tipbuddy_anchors_to_it() {
benilla_formats::wow_data_or_skip!();
let s = harness();
let report = load_addon_xml(
&s,
@@ -423,6 +429,7 @@ fn the_status_bar_is_the_templates_because_tipbuddy_anchors_to_it() {
/// would notice.
#[test]
fn an_instance_attribute_beats_the_templates() {
benilla_formats::wow_data_or_skip!();
let s = harness();
load_addon_xml(
&s,
@@ -461,6 +468,7 @@ fn an_instance_attribute_beats_the_templates() {
/// so 30 hidden pairs add no height and no width.
#[test]
fn the_tooltip_line_globals_exist_cold_and_do_not_count_as_lines() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
for name in [
@@ -170,6 +170,7 @@ fn raid(n: usize) -> PartyState {
/// frame, so this is the switch alone.
#[test]
fn the_frame_ships_off_and_the_switch_is_what_shows_it() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
@@ -191,6 +192,7 @@ fn the_frame_ships_off_and_the_switch_is_what_shows_it() {
/// target, a target OF it, a target that is not you, and a target that is alive.
#[test]
fn the_four_unit_gates_each_take_the_frame_down() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
assert!(shown(&mut s));
@@ -240,6 +242,7 @@ fn the_four_unit_gates_each_take_the_frame_down() {
/// [`the_solo_mode_keeps_the_frame_when_a_raid_forms`], which shows the frame first.
#[test]
fn the_five_modes_answer_solo_party_and_raid() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
@@ -290,6 +293,7 @@ fn the_five_modes_answer_solo_party_and_raid() {
/// sixty times a second and still never takes the frame down.
#[test]
fn the_solo_mode_keeps_the_frame_when_a_raid_forms() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"SHOW_TARGET_OF_TARGET_STATE = "3" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#)
@@ -325,6 +329,7 @@ fn the_solo_mode_keeps_the_frame_when_a_raid_forms() {
/// answer one with if it did (ref TargetFrame.xml l.612-625).
#[test]
fn the_frame_paints_its_unit() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
s.tick(0.016);
@@ -379,6 +384,7 @@ fn the_frame_paints_its_unit() {
/// only thing that ever calls `TargetofTarget_CheckDead`.
#[test]
fn the_dead_word_needs_a_connected_corpse() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
s.tick(0.016);
@@ -420,6 +426,7 @@ fn the_dead_word_needs_a_connected_corpse() {
/// reached from the frame's OnUpdate and nowhere else.
#[test]
fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
s.tick(0.016);
@@ -456,6 +463,7 @@ fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
/// reason the frame is clickable.
#[test]
fn the_left_click_targets_the_unit() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"TargetofTarget_OnClick("LeftButton")"#).unwrap();
@@ -481,6 +489,7 @@ fn the_left_click_targets_the_unit() {
/// it appear.
#[test]
fn the_debuff_row_draws_what_the_unit_carries() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
s.set_auras(
@@ -520,6 +529,7 @@ fn the_debuff_row_draws_what_the_unit_carries() {
/// wrapped short behind it.
#[test]
fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
// A hostile target, so the debuffs lead and the buffs hang off them.
s.set_auras("target", Some(vec![debuff(2000, "Sunder", None)]));
@@ -571,6 +581,7 @@ fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
/// runs only while you have a target.
#[test]
fn the_reconcile_takes_the_frame_down_when_the_token_goes_silent() {
benilla_formats::wow_data_or_skip!();
let mut s = load_tot();
switch_on(&mut s);
assert!(shown(&mut s));
@@ -244,6 +244,7 @@ fn menu() -> TrainerState {
/// and hides on TRAINER_CLOSED.
#[test]
fn shipped_trainer_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
// Hidden by default.
@@ -351,6 +352,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
/// the row button's OnClick → Collapse/ExpandTrainerSkillLine(headerIndex).
#[test]
fn clicking_a_header_row_collapses_its_group() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
@@ -388,6 +390,7 @@ fn clicking_a_header_row_collapses_its_group() {
/// its header stays, and the row count falls.
#[test]
fn filter_hides_a_state_keeping_headers() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
@@ -424,6 +427,7 @@ fn filter_hides_a_state_keeping_headers() {
/// have to agree.
#[test]
fn filter_rows_toggle_through_the_dropdown_kit() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
@@ -513,6 +517,7 @@ fn long_menu() -> TrainerState {
#[test]
fn wheel_over_a_row_scrolls_the_list() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu()));
@@ -594,6 +599,7 @@ fn wheel_over_a_row_scrolls_the_list() {
/// (decision 1605).
#[test]
fn a_selected_or_hovered_service_row_paints_its_name_white() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
@@ -657,6 +663,7 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
/// while the arrow BUTTONS keep the ref's `UChatScrollButton` click.
#[test]
fn wheel_scroll_is_silent_but_the_arrows_click() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu())); // 16 rows > 11 visible → the bar + arrows show
@@ -707,6 +714,7 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
/// a number someone has to re-derive.
#[test]
fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu())); // 16 rows > 11 visible → the bar shows
@@ -810,7 +818,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
0,
"the click must write the SAVED global, not just the engine mask"
);
let text = s.saved_variables_text_for(&info().saved_variables);
let text = String::from_utf8(s.saved_variables_bytes_for(&info().saved_variables)).unwrap();
assert!(
text.contains("TRAINER_FILTER_UNAVAILABLE = 0"),
"the file carries the toggle: {text}"
@@ -841,6 +849,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
/// the window re-pushes its saved globals on every show.
#[test]
fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_trainer(Some(menu()));
s.fire_event(
@@ -930,6 +939,7 @@ fn recipe_menu() -> TrainerState {
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
#[test]
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(5000);
s.set_trainer(Some(recipe_menu()));
@@ -1032,6 +1042,7 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
/// collapsed groups, which 1128 recorded as "a collapse does not survive a purchase" — with it.
#[test]
fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
benilla_formats::wow_data_or_skip!();
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
@@ -1085,12 +1096,11 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
// He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as
// the repaint it is; the bought service comes back gray.
// He trains. The spell lands (`SMSG_LEARNED_SPELL`) and the state re-evaluator (2333, the
// reference's `0x4d7d40`) repaints the bought row gray IN PLACE — no second list, so nothing
// that a list packet resets.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
@@ -1161,6 +1171,7 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
/// reason: the packet-vs-repaint decision under test is the app's own.
#[test]
fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_one() {
benilla_formats::wow_data_or_skip!();
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
@@ -1214,13 +1225,12 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
"the Train button bought the selected row"
);
// The app re-asks for the list and marks its answer the repaint it is (B256); the bought
// service comes back gray and, with "already known" off, leaves the list. Cleave slides up into
// row 2 under where the selection used to be.
// The spell lands and the re-evaluator (2333) repaints the bought row gray in place; with
// "already known" off it leaves the list, and Cleave slides up into row 2 under where the
// selection used to be. (Until 2333 this was a second list packet marked as a refresh —
// B256.)
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, warrior!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1265,6 +1275,7 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
/// (`mov ecx,0x136; jmp 0x703e50`). Ours did not, so the checkbox moved and the list did not.
#[test]
fn a_filter_click_repaints_the_rows_the_player_is_looking_at() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
@@ -7,8 +7,8 @@
//! What places the label is the template's `<NormalFont inherits="GameFontNormal"
//! justifyH="LEFT"/>`: `SetText`'s lazy label creation funnels through the adopter
//! `CSimpleButton::SetFontString 0x778d20`, which anchors an unanchored label to the button by the
//! NORMAL embedded font's justify word (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER —
//! wow-re `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5.2, VERIFIED). Decision 1996.
//! NORMAL embedded font's justify word (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER).
//! Decision 1996.
use benilla_ui::script::UiScript;
@@ -37,6 +37,7 @@ fn ui_parent() -> UiScript {
/// bounds are strict, so a cursor exactly on an edge is outside.
#[test]
fn mouse_is_over_is_the_references_own_box_test() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_parent();
// The box is x 200..300, y 300..350.
s.mouse_move(250.0, 325.0);
@@ -100,6 +101,7 @@ fn mouse_is_over_is_the_references_own_box_test() {
/// so repeated calls accumulate.
#[test]
fn raise_and_lower_frame_level_step_the_frames_own_level() {
benilla_formats::wow_data_or_skip!();
let s = ui_parent();
let base: i64 = s.eval("return Box:GetFrameLevel()").unwrap();
@@ -121,6 +123,7 @@ fn raise_and_lower_frame_level_step_the_frames_own_level() {
/// `IgniteStatus` calls it at file scope and dies on `attempt to call global`.
#[test]
fn randomseed_is_a_bare_global_like_random() {
benilla_formats::wow_data_or_skip!();
let s = ui_parent();
// Seeding twice with the same value must produce the same first draw; that is the whole
// contract an addon wants from it.
@@ -138,6 +141,7 @@ fn randomseed_is_a_bare_global_like_random() {
/// laid out answers nil from `GetLeft()`, and `MouseIsOver` must return nil rather than raise.
#[test]
fn mouse_is_over_survives_a_frame_with_no_resolved_rect() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_parent();
s.mouse_move(250.0, 325.0);
s.run(r#"Floating = CreateFrame("Frame", "Floating", UIParent)"#)
@@ -162,6 +166,7 @@ fn mouse_is_over_survives_a_frame_with_no_resolved_rect() {
/// that rather than reading one key.
#[test]
fn raid_class_colors_is_the_references_own_nine() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// RAID_CLASS_COLORS comes off the chain with the font registry since 1888.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -216,6 +221,7 @@ fn raid_class_colors_is_the_references_own_nine() {
/// found rather than the block those four came out of. It now enumerates the block.
#[test]
fn the_font_path_globals_are_the_references_own_four() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// The four font-path globals come off the chain with the registry since 1888.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -294,6 +300,7 @@ fn the_font_path_globals_are_the_references_own_four() {
/// answered nil under the very cursor that had just entered it (decision 1985).
#[test]
fn mouse_is_over_reads_a_scaled_frame_in_its_own_units() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_parent();
s.run(
r#"Scaled = CreateFrame("Frame", "Scaled", UIParent) Scaled:SetWidth(200) Scaled:SetHeight(100)
@@ -48,6 +48,7 @@ fn load_unit_frames(s: &UiScript) {
/// name arriving via UNIT_NAME_UPDATE.
#[test]
fn shipped_unit_frames_drive_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -306,6 +307,7 @@ fn extracted_text_height(s: &mut UiScript, text: &str) -> Option<f32> {
/// touches it (only the player frame registers UNIT_COMBAT in 1.12).
#[test]
fn unit_combat_drives_the_player_hit_indicator() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -408,6 +410,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
/// menu), the full leader set once a party is pushed.
#[test]
fn left_clicking_the_player_frame_targets_self() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The SELF-menu strings the popup rows bake at UnitPopup.xml load arrive with
@@ -528,6 +531,7 @@ fn left_clicking_the_player_frame_targets_self() {
/// unit. The level-1 click was pinned above; this pins the level the marks actually live on.
#[test]
fn raid_mark_clicks_through_the_nested_level() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The menu strings arrive with `load_unit_frames`' own `GlobalStrings.lua`
@@ -644,6 +648,7 @@ fn raid_mark_clicks_through_the_nested_level() {
/// GetQuestGreenRange binding.
#[test]
fn shipped_target_frame_runs_the_level_law() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::PlayerReqState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -742,8 +747,9 @@ fn shipped_target_frame_runs_the_level_law() {
.unwrap();
assert!(ok, "hostile +10: the skull shows ({:?})", s.errors());
// A DEAD mob is NOT a corpse (§5: UnitIsCorpse is a pure TYPEID_CORPSE object check, and
// UnitLevel has no health test) — the ref shows a dead mob's NUMBER, not the skull.
// A DEAD mob is NOT a corpse (UnitIsCorpse `0x5161c0` is a pure TYPEID_CORPSE object check,
// and UnitLevel `0x517fc0` has no health test) — the ref shows a dead mob's NUMBER, not the
// skull.
s.set_unit(
"target",
Some(UnitState {
@@ -835,6 +841,7 @@ fn shipped_target_frame_runs_the_level_law() {
/// UNIT_FACTION edge, not on every repaint.
#[test]
fn pvp_icon_follows_the_three_branch_law() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -944,6 +951,7 @@ fn pvp_icon_follows_the_three_branch_law() {
/// both collapsed into blue.
#[test]
fn flagged_friendly_player_plate_is_green() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1005,6 +1013,7 @@ fn flagged_friendly_player_plate_is_green() {
/// UNIT_CLASSIFICATION_CHANGED alone, with no re-target.
#[test]
fn target_frame_border_follows_the_classification_law() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1135,6 +1144,7 @@ fn target_frame_border_follows_the_classification_law() {
/// level *below* the texture frame, where no layer can lift them back over it.
#[test]
fn the_ring_art_paints_over_the_bars() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1206,6 +1216,7 @@ fn the_ring_art_paints_over_the_bars() {
/// silence — nobody was in a party when the director reported the player frame.
#[test]
fn the_party_art_paints_over_the_bars() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot test's prefix (`loot_tests.rs`): PartyFrame's inline <Script> reads
@@ -1344,6 +1355,7 @@ fn the_party_art_paints_over_the_bars() {
/// lights on the same `UnitHealth(unit) <= 0` test a corpse trips.
#[test]
fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1352,7 +1364,7 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
Some(UnitState {
exists: true,
is_connected: true, // CheckDead's second term — a feign is not a link-drop
name: Some("Nazriel".into()),
name: Some("Corvane".into()),
health,
max_health: 1500,
level: 60,
@@ -1444,6 +1456,7 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
/// ring/swords/disc — resting still wins when both hold — and leaving both states clears the lot.
#[test]
fn the_player_frame_flashes_zzz_while_resting() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1546,6 +1559,7 @@ fn the_player_frame_flashes_zzz_while_resting() {
/// the number rode on the badge (director report, 2026-08-07).
#[test]
fn the_rest_badge_covers_the_level_number() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1616,6 +1630,7 @@ fn the_rest_badge_covers_the_level_number() {
/// no such cut.
#[test]
fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1785,6 +1800,7 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
/// back to its owner's rect and every string would appear to sit in the same box.
#[test]
fn no_two_numeral_strings_overlap_on_any_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -1871,6 +1887,7 @@ fn no_two_numeral_strings_overlap_on_any_frame() {
/// addons and fires events, but never hovers anything.
#[test]
fn the_unit_frame_hover_hooks_carry_the_references_names() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_unit_frames(&s);
@@ -1922,6 +1939,7 @@ fn the_unit_frame_hover_hooks_carry_the_references_names() {
/// sheet's mark order says it should.
#[test]
fn the_raid_mark_helper_maps_each_index_to_its_cell() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_unit_frames(&s);
s.run(r#"RTMark = UIParent:CreateTexture("RTMark", "OVERLAY")"#)
@@ -1963,6 +1981,7 @@ fn the_raid_mark_helper_maps_each_index_to_its_cell() {
/// index is `0` — the player's own seat on that scale — *and* that we are grouped at all.
#[test]
fn the_player_frame_wears_the_leader_and_master_looter_icons() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{PartyMemberInfo, PartyState};
let mut s = UiScript::new().unwrap();
@@ -1972,7 +1991,7 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
"player",
Some(UnitState {
exists: true,
name: Some("Frostshake".into()),
name: Some("Aldwyn".into()),
health: 100,
max_health: 100,
level: 60,
@@ -1994,7 +2013,7 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
let party = |leader_index: u32, master_looter: Option<u32>, method: &str| PartyState {
members: vec![PartyMemberInfo {
name: "Thalyn".into(),
name: "Brisca".into(),
guid: 0x7A17,
}],
leader_index,
@@ -2057,6 +2076,7 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
/// not asserted against; what is asserted is that nothing the reference declares is absent.
#[test]
fn the_unit_frames_publish_every_name_the_reference_declares() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_unit_frames(&s);
@@ -2189,6 +2209,7 @@ fn the_unit_frames_publish_every_name_the_reference_declares() {
/// This drives the real stock file, so it is the symptom itself and not a proxy for it.
#[test]
fn the_target_leader_crown_follows_player_flags_changed() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
@@ -156,7 +156,7 @@ const ELSEWHERE: &[(&str, &str, &str)] = &[
];
/// Pairs nothing fires on the verb's path, each with its reason. A row is a gap with a name,
/// never a reason to stop; the reference's own site is in the table for the grep into wow-re.
/// never a reason to stop; the reference's own site is in the table.
const GAP: &[(&str, &str, &str)] = &[
(
"CancelSkillUps",
@@ -170,15 +170,15 @@ const GAP: &[(&str, &str, &str)] = &[
"ClickTargetTradeButton",
"TRADE_REPLACE_ENCHANT",
"the reference runs the enchant clash check 0x496170 when a spell is on the cursor and \
fires this for the replace dialog (wow-re staticpopup-dialog-bindings.md §3.1); casting \
fires this for the replace dialog; casting \
an enchant onto the partner's slot is not built here — `ui_trade.rs` only mirrors the \
wire's enchant slot — so the verb is the money arm alone",
),
(
"CloseTrade",
"PLAYER_TRADE_MONEY",
"fired only when the trade did not complete (`[0xb71748] == 0`, wow-re \
incoming-trade-request-law.md), zeroing the cancelled offer under a frame TRADE_CLOSED \
"fired only when the trade did not complete (`[0xb71748] == 0`), \
zeroing the cancelled offer under a frame TRADE_CLOSED \
hides; here `TradeSession::begin` resets the whole session at the next trade, so the \
next window opens at zero either way",
),
@@ -199,8 +199,8 @@ const GAP: &[(&str, &str, &str)] = &[
(
"PetDismiss",
"PET_DISMISS_START",
"the worker 0x4bd6e0 sends the dismiss and fires this with a duration (`%d`, 10000 — \
wow-re pet-action-bar-api.md §10.8); no shipped file listens, the dismiss reaches the \
"the worker 0x4bd6e0 sends the dismiss and fires this with a duration (`%d`, 10000); \
no shipped file listens, the dismiss reaches the \
wire through the app's drain, and the pet frame follows the unit's departure",
),
(
@@ -288,7 +288,7 @@ fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() {
///
/// This is the regression the latch design could most easily have caused, so it is pinned rather
/// than argued. The obvious guard for the tail — "is there a player object?" — reads FALSE on a
/// `/logout` by the time the tail runs: `net::apply::session::logged_out` despawns our avatar in
/// `/logout` by the time the tail runs: `net::session::logged_out` despawns our avatar in
/// the same drain that writes `LoggedOutMessage`, `back_on_logout` sets `NextState` off that same
/// message, and `OnExit(InWorld)` does not run until the next frame's `StateTransition`. A
/// predicate would have silenced the event on every logout to fix a quit on a loading screen.
@@ -449,6 +449,7 @@ fn logging_out_leaves_no_in_game_frames_behind() {
/// the world.
#[test]
fn reload_ui_is_a_fresh_login_in_place() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -615,6 +616,7 @@ fn reload_outside_the_world_is_dropped() {
/// entirely off terminal WARN lines because the client showed nothing.
#[test]
fn an_addon_error_while_entering_world_reports_on_screen_and_the_sibling_loads() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -664,6 +666,7 @@ fn an_addon_error_while_entering_world_reports_on_screen_and_the_sibling_loads()
/// loads, and this test FINISHING is the claim — before 1306 it would hang here forever.
#[test]
fn a_looping_addon_cannot_freeze_world_entry() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -935,14 +938,14 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
/// ship — used to `warn!` to the terminal and vanish. Nothing raised, so 1305's dialog could not
/// fire; the walk's failure list was dropped on the floor at `load_ingame_ui_on_world_entry`; and
/// the per-frame drain kept no history. From the player's chair the addon simply was not there and
/// the client said nothing, which is the literal content of *"there are a lot of addons that still
/// doesn't work"*.
/// the client said nothing.
///
/// Three claims, and the third is the one that makes the first two reachable: the failure is
/// **retained**, it is **readable from Lua** (so the window is a view of it, not a second copy),
/// and the player is **told to look**.
#[test]
fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -1117,9 +1120,9 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
// ── B353 · the layout cache is a resident of the shutdown tail ───────────────────────────────
//
// st1rk, 2026-09-01: *"Unlock a chat window (right-click tab → Unlock Window), resize or drag it,
// `/logout` or `/reload`, log back in. It's back at the original size. `benilla-config/layout/`
// isnt created."* The engine seam and the file round trip were already proven by
// The symptom: an unlocked chat window, resized or dragged, comes back at its original size after
// a `/reload` or a relog, and `benilla-config/layout/` is never created. The engine seam and the
// file round trip were already proven by
// [`crate::ui_script::chat_resize_tests::the_geometry_round_trips_through_the_save_file`]; what
// was wrong is which edge writes. [`crate::ui_layout`] hung its saver off `OnExit(InWorld)`, and a
// `/reload` never leaves `InWorld` — [`super::run_pending_reload`] calls the shutdown and the
@@ -1274,7 +1277,7 @@ fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) {
.expect("read the probe window back")
}
/// **The whole loop, on the root that reported it** — st1rk's retest, in one test: place a window,
/// **The whole loop, on the root that reported it**, in one test: place a window,
/// `/reload`, meet a fresh tree that has it on its authored anchors, and let the loader seat the
/// saved geometry back over the top.
///
@@ -1331,6 +1334,7 @@ fn a_placed_window_comes_back_after_a_reload() {
/// file reddens this instead of scrolling past, and closing one means deleting a line here.
#[test]
fn a_clean_world_entry_raises_only_the_warnings_we_have_named() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -1722,3 +1726,63 @@ fn the_entry_load_seeds_a_record_the_feed_cannot_take_away() {
let _ = std::fs::remove_dir_all(&tmp);
}
// ─────────────────── The production load is silent (the UI-load sound bracket) ───────────────────
/// Every kit name the live VM has queued since the last drain.
fn taken_kit_names(world: &mut World) -> Vec<String> {
world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("in-world VM")
.take_sounds()
.into_iter()
.filter_map(|r| match r {
benilla_ui::script::SoundRequest::KitName(n) => Some(n),
_ => None,
})
.collect()
}
/// **A login and a `/reload` load without a sound** — `0x48fbf0` brackets itself in the counted
/// suppression scope (`0x48fbfa` → `0x49016d`) across the TOC walk, the addons, the saved
/// variables and the login cascade, and both of its callers (login `0x48f681`, `/reloadui`
/// `0x495669`) go through it.
///
/// Stock `TargetFrame_OnLoad` → `TargetFrame_Update` → `Hide()` → `TargetFrame_OnHide` really does
/// call `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")` at load; the engine drops it. Before the
/// production edge carried the bracket only the tests' whole-manifest load did, and every
/// `/reload` played the lost-target click.
#[test]
fn a_login_and_a_reload_load_without_the_lost_target_sound() {
let _data = benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("load-silent");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
assert!(
frame_exists(&world, "TargetFrame"),
"the stock target frame loaded"
);
let at_login = taken_kit_names(&mut world);
assert!(
!at_login
.iter()
.any(|n| n == "INTERFACESOUND_LOSTTARGETUNIT"),
"the login load must be silent — the lost-target click was queued: {at_login:?}"
);
reload(&mut world, crate::char_select::ClientState::InWorld);
let at_reload = taken_kit_names(&mut world);
assert!(
!at_reload
.iter()
.any(|n| n == "INTERFACESOUND_LOSTTARGETUNIT"),
"the /reload load must be silent — the lost-target click was queued: {at_reload:?}"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
@@ -52,7 +52,7 @@ fn harness() -> UiScript {
/// button and the elapsed time its one argument. Everything on the sheet — the arrow, the party
/// and raid blips, the battleground teammates and flags, the corpse — is seated here.
/// The map arrow's file facts (`MinimapArrow.m2`: one looping 3.333 s Stand keying no bone; the
/// header box `x ∈ [−0.0127, 0.0135]`, `y ∈ [−0.0118, 0.0145]` — render law §2), handed to the
/// header box `x ∈ [−0.0127, 0.0135]`, `y ∈ [−0.0118, 0.0145]`), handed to the
/// engine the way the app does once the asset lands (2007/2015).
fn arrow_facts(s: &mut UiScript) {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
@@ -915,3 +915,52 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
assert_eq!(saved[0], row(), "unchanged through the round trip");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The full-screen quads follow a resize** — `BlackoutWorld` behind the map, and the cinematic
/// letterbox bars. Stock `WorldMapFrame_OnLoad` and `CinematicFrame_OnLoad` size them ONCE from
/// `GetScreenWidth()`/`GetScreenHeight()` and nothing in FrameXML touches them again (no
/// `DISPLAY_SIZE_CHANGED` listener); 2242 fixed the load edge, and this is the other edge. A window
/// grown after the load left the quad at the old size and the world showed through beside it.
#[test]
fn the_fullscreen_quads_follow_a_resize() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
// A player exists by the time the manifest loads (decision 1848; see the test above).
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(), "manifest load errors: {failures:#?}");
s.resolve();
let width = |s: &UiScript, f: &str| s.eval::<f64>(&format!("return {f}:GetWidth()")).unwrap();
assert_eq!(width(&s, "BlackoutWorld"), 1600.0, "the load edge sized it");
// Grown, still wider than 4:3 so every one of the three takes its recompute branch.
assert!(s.set_screen_size(1920.0, 1080.0), "the size changed");
super::manifest::on_screen_resized(&s);
s.resolve();
let screen = s.eval::<f64>("return GetScreenWidth()").unwrap();
assert_eq!(screen, 1920.0);
for quad in ["BlackoutWorld", "UpperBlackBar", "LowerBlackBar"] {
assert_eq!(
width(&s, quad),
screen,
"{quad} still spans the OLD screen after a resize — the world shows through beside it"
);
}
assert_eq!(
s.eval::<f64>("return BlackoutWorld:GetHeight()").unwrap(),
1080.0,
"…and the blackout is the new height too"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -81,6 +81,7 @@ pub(crate) fn push(s: &mut UiScript, rows: Vec<WorldStateUiView>) {
/// The Eastern Plaguelands readout: two labelled tower counters, each with its faction icon.
#[test]
fn the_tower_counters_draw_with_their_icons() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
assert!(
@@ -122,6 +123,7 @@ fn the_tower_counters_draw_with_their_icons() {
/// are hidden rather than left painting a stale count.
#[test]
fn leaving_the_zone_clears_the_readout() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
push(
@@ -146,7 +148,7 @@ fn leaving_the_zone_clears_the_readout() {
);
}
/// The dynamic icon is a SECOND slot, not a swap (wow-re `worldstate-ui-law.md` §12; decision
/// The dynamic icon is a SECOND slot, not a swap (`GetWorldStateUIInfo 0x4c5a70`; decision
/// 1604). The `Icon` column and the `DynamicIcon` column feed two different regions — a 42x42
/// static slot and a 32x32 button off the row's right edge — and only `uiState == 2`, the
/// flag-taken value, lights the second one. The first pass replaced the static art whenever the
@@ -154,6 +156,7 @@ fn leaving_the_zone_clears_the_readout() {
/// never have moved; both halves of that are pinned here.
#[test]
fn the_dynamic_icon_is_a_second_slot_lit_only_by_the_taken_state() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
@@ -245,6 +248,7 @@ fn the_dynamic_icon_is_a_second_slot_lit_only_by_the_taken_state() {
/// icon is optional, and the row must not collapse when the DBC column is empty.
#[test]
fn a_row_without_an_icon_still_shows_its_text() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
push(
@@ -263,6 +267,7 @@ fn a_row_without_an_icon_still_shows_its_text() {
/// Our transcription had shown the text; the reference's file draws the bar (1972).
#[test]
fn a_capture_point_row_is_a_bar_not_a_line_of_text() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
push(
@@ -289,6 +294,7 @@ fn a_capture_point_row_is_a_bar_not_a_line_of_text() {
/// value — the number `0`, not nil and not ten nils — and a non-number argument raises.
#[test]
fn the_bindings_answer_the_reference_shape() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_world_state_ui(vec![WorldStateUiView {
ui_state: 7,
@@ -363,11 +369,11 @@ fn ink_box(chain: &mut benilla_formats::Chain, path: &str) -> (f32, f32, f32, f3
/// have. Every icon the readout names is a sprite authored into the UPPER-LEFT corner of a
/// power-of-two canvas (`AllianceTower` fills 16x16 of 32x32; `UI-PVP-Alliance` ~40x40 of 64x64),
/// nothing is cropped, and the reference compensates *geometrically* — a 42x42 slot hung 6 units
/// off the row's left edge with the label seated 10 above its centreline (wow-re
/// `worldstate-ui-law.md` §12). Pin the OUTCOME rather than the constants: whatever the numbers,
/// the visible art must sit beside its label and share its line. A snug icon box — the obvious
/// thing to write, and what we shipped — puts the ink up and to the left of its own slot, which is
/// exactly what the director saw. Skips without client data.
/// off the row's left edge with the label seated 10 above its centreline (`WorldStateFrame.xml`).
/// Pin the OUTCOME rather than the constants: whatever the numbers, the visible art must sit beside
/// its label and share its line. A snug icon box — the obvious thing to write, and what we shipped
/// — puts the ink up and to the left of its own slot, which is exactly what the director saw.
/// Skips without client data.
#[test]
fn the_visible_ink_sits_beside_its_label_not_adrift_of_it() {
let data = benilla_formats::wow_data_or_skip!();
@@ -172,7 +172,7 @@ fn abbey_grounds_subzone_hop_shows_no_territory_line() {
);
}
/// The abbey repro, indoor half — the byte-corrected feed (wow-re `zonetext-indoor-bit.md` (d)):
/// The abbey repro, indoor half — the byte-corrected feed (`0x67e670`):
/// crossing the abbey threshold, the zone-name override SKIPS (the whole-WMO name
/// "Northshire Abbey" equals the yard subzone), so the ZONE slot stays "Elwynn Forest" and the
/// group row's name re-populates the SUBZONE ("Main Hall"). ZoneTextFrame's text never changes ⇒
@@ -208,7 +208,7 @@ fn abbey_interior_shows_the_room_in_the_small_line_alone() {
/// The INN case — the override FIRES (the whole-WMO name differs from the street subzone):
/// zone slot = the inn's name, subzone nulls (unnamed group rows). The big splash shows the inn
/// name, and — the FIFO dispatch law (wow-re `event-dispatch-order.md`: SubZoneTextFrame
/// name, and — the FIFO dispatch law (`RegisterEvent 0x702140` tail-appends: SubZoneTextFrame
/// registered second fires LAST, its SetZoneText(1) is the last writer) — the territory line
/// shows under it, exactly as on a NEW_AREA splash.
#[test]