cooldowns survive a relog, the MOTD prints, and lost channels come back

- a cooldown still running when you log out and back in keeps sweeping instead of vanishing, and another character no longer inherits the previous session's cooldowns (2116)
- the server's Welcome and Patch 1.12 lines print at login in system yellow, and a character whose General, Trade and LocalDefense had gone silent for good gets them back at the next login (2119, 2120)
- the stock Interface Options window loads hidden beside ours, so pfUI's options skin finds the real thing; the Auto-join Guild Recruitment Channel row has a real store, and a /reload no longer trips on its camera dropdowns (2115)
- the combat log words a power gain on the display scale, so MikScrollingBattleText shows +1 Rage where it showed +10; a 1.12 predicate answers 1 or nil rather than a Lua boolean, the test ColorPickerPlus makes (2117, 2118)
- a Lua error quotes a program element with a backquote the way 5.0 does, so AceLibrary's argument checks report their caller instead of raising inside the error path (2122)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:50:37 +07:00
co-authored by Claude Fable 5.1
parent c775181933
commit d49fe51e0e
62 changed files with 2473 additions and 704 deletions
@@ -205,14 +205,17 @@ fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml");
// The options window: `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared there, as the
// reference declares them in UIOptionsFrame_Init (1938) — and the manifest loads it before
// the bars.
// The dialog engine — the keybindings page registers its two confirms into its table (1960).
super::test_ui::load_ui(s, r"Interface\FrameXML\BasicControls.xml"); // `TEXT`
super::test_ui::load_ui(s, r"Interface\FrameXML\LocaleProperties.lua"); // `GetText`
super::test_ui::load_ui(s, r"Interface\FrameXML\StaticPopup.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
// `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared by `UIOptionsFrame_Init` — the
// reference's own home for them, and off the chain since 2115 (they were our
// `OptionsFrame.xml`'s from 1938 until then). The manifest loads this before the bars and
// before our window, whose Action Bars rows capture the value as their Defaults; so does this.
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrame.lua");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIOptionsFrame.xml");
super::test_ui::load_ui(s, "ScrollTemplates.xml");
super::test_ui::load_ui(s, "KeyBindingsPage.xml");
super::test_ui::load_ui(s, "OptionsFrame.xml");
@@ -307,6 +310,79 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Why a restarted `GetTime` clock reads on the bar as "no cooldown at all"** (decision 2116),
/// through the shipped `Cooldown.lua`: `CooldownFrame_SetTimer`'s only gate is
/// `start > 0 and duration > 0 and enable > 0`, and its `else` branch is `this:Hide()`. So the
/// SAME running cooldown draws or vanishes purely on which clock its start was converted against.
///
/// This is the observable half of the relog bug: the store held the cooldown (the press was still
/// refused), the feed pushed a triple every frame, and the button showed nothing — because the VM
/// had been rebuilt and its clock had gone back to zero, putting every already-running cooldown's
/// start behind the new epoch.
#[test]
fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // GetTime == 10 — a clock that restarted ten seconds ago
// A 10-minute cooldown armed 30 s ago, converted against that restarted clock: start = −20 s.
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((-20_000, 600_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert_eq!(
super::test_ui::cooldown_play(&s, "ActionButton1Cooldown"),
None,
"the stock `start > 0` guard hides the pane outright — 9.5 minutes still to run and the \
button shows nothing"
);
// The same cooldown on a clock that never restarted: GetTime 100, armed at 70. The sweep is
// exactly where it belongs.
s.tick(90.0);
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((70_000, 600_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert_eq!(
super::test_ui::cooldown_play(&s, "ActionButton1Cooldown"),
Some((0, 50)),
"30 s of 600 s elapsed ⇒ sequence 0 scrubbed to 5 %: 50 ms of the 1000 ms sweep"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pie paints OVER its button's art. The Cooldown child is born at button-level+1, and the
/// draw key's LEVEL term outranks 0884's bucket-wide layer term — so the sweep quad must sort
/// after the icon (BACKGROUND) and after the button's own special textures. A regression here is
@@ -1505,6 +1581,11 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
// `UIOptionsFrame_Init`'s uvars and `UIOptionsFrameCheckButtons`, which
// `MultiActionBars.xml` below writes into at its load — the reference's own l.21 seat,
// ahead of our window and ahead of the bars (2115).
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
+141 -5
View File
@@ -56,6 +56,7 @@ fn install_boot_vm(world: &mut World) {
return;
}
};
seed_vm_clock(world, &mut script);
install_addon_asset_resolvers(world, &mut script);
load_global_strings(world, &script);
load_emote_tokens(world, &script);
@@ -67,6 +68,39 @@ fn install_boot_vm(world: &mut World) {
world.insert_non_send_resource(script);
}
/// **`GetTime()` is the PROCESS's clock, not the VM's** (decision 2116) — so a VM built now starts
/// 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
/// 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
/// (`CooldownInfo::ui_triple` and its kin, the aura feed's `expirationTime`) suddenly sat in the
/// past. Stock `Cooldown.lua`'s `CooldownFrame_SetTimer` gates on `start > 0` and takes the `else`
/// branch — `this:Hide()` — for anything else, so after a relog **every cooldown still running
/// drew nothing** while the store, and therefore the cast validator, still held it: no sweep on the
/// button and a "Spell is not ready yet" on the press.
///
/// `Time<Real>` is the right clock because it is the very one [`UiClock`] already anchors on:
/// `elapsed` is measured at `last_update()`, which is [`UiClock::anchor`], so the pair written here
/// is atomic in exactly the sense that resource's doc requires. A world with no `Time<Real>` (a
/// bare test world) starts at zero, which is what it did before this existed.
fn seed_vm_clock(world: &mut World, script: &mut UiScript) {
let (anchor, elapsed) = world
.get_resource::<Time<bevy::time::Real>>()
.map_or_else(Default::default, |t| {
(t.last_update(), t.elapsed_secs_f64())
});
script.set_now(elapsed);
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
*clock = UiClock {
anchor: anchor.unwrap_or_else(std::time::Instant::now),
ui_now: elapsed,
};
}
}
/// Wire up the halves of `Interface\AddOns\` **art and fonts** (decisions 1322, 2103): the sprite
/// decoder's **loose-file root** (so addon-shipped BLP/TGA files render at all — the store's
/// [`benilla_assets::WorldAssets::set_loose_addon_root`]), the VM's **texture probe** (so the
@@ -329,6 +363,27 @@ 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);
// **The CVar table goes in BEFORE the UI loads**, for the same reason and with a shipped-file
// consumer rather than an addon one (decision 2115): the reference's own `UIOptionsFrame.xml`
// — hidden, on the manifest for the addons that name it — reads `cameraSmoothStyle` and
// `cameraSmoothTrackingStyle` inside its two camera dropdowns' `OnLoad`, and a nil there is a
// concat error, not a default.
//
// The seed normally belongs to `cvars::sync_cvars`, a per-VM `Update` claim (1291), and on a
// FIRST login that has run many frames earlier. **The window is a VM replacement**: a
// `/reloadui` builds a fresh VM and loads the whole interface inside one call, before `Update`
// gets a turn — so this edge would load a client with no CVar table at all. Nothing had ever
// noticed, because until now no interface file read a CVar at load.
//
// The two lines are `sync_cvars`'s own first two, in its own order, and the order is
// load-bearing: the saved config goes in FIRST so registration starts each key at the player's
// value rather than the factory one (1291) — reversed, a reload would quietly reset every
// knobless CVar to its default. `sync_cvars` still does its full knob-derived pass on the next
// `Update`; both calls are idempotent, and a re-register never clobbers a live value.
if let Some(persist) = world.get_resource::<crate::cvars::CvarPersist>() {
script.set_cvar_saved_base(persist.saved_base());
}
script.register_cvars(crate::cvars::registered_pairs());
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
@@ -377,6 +432,16 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// 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 chat cache restores HERE — after every chat frame exists, before anything prints into
// one** (decision 2119). 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),
// and of the `UPDATE_CHAT_COLOR` burst, whose `WHISPER`→`REPLY` mirror repaints every line
// already in the window that was printed with no explicit colour (`ChatTypeInfo["REPLY"].id`
// is 0, and so is such a line's). As an `Update` system both landed too late: the login MOTD
// was routed to a window registered for nothing, and every Ace2 addon's `Print` at
// `PLAYER_LOGIN` came out whisper-pink. Placed after `load_ingame_ui` so an ADDON's chat frame
// is registered too, and before `finish_ui_load` so the burst precedes `PLAYER_LOGIN`.
crate::ui_chat::restore_chat_looks(world, &mut script);
// 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
@@ -569,8 +634,14 @@ pub(crate) fn end_ui_session(world: &mut World) {
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
// and the VM-relative clock the cooldown conversions run through.
// existing: a hovered frame id, the minimap's extracted hole, and a payload the cursor is
// carrying.
//
// **[`UiClock`] is NOT one of them, and used to be** (decision 2116). It reads like a fact
// about the dying VM — it is the `GetTime` leg of the conversion pair — but `GetTime` is the
// reference's OS tick count, not a per-VM clock, so zeroing it here restarted every cooldown
// and aura conversion at the character screen. [`seed_vm_clock`] writes the pair for the VM
// that replaces this one, moments below.
//
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
@@ -588,9 +659,6 @@ pub(crate) fn end_ui_session(world: &mut World) {
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
minimap.0 = None;
}
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
*clock = UiClock::default();
}
install_boot_vm(world);
}
@@ -826,4 +894,72 @@ mod tests {
assert_eq!(vm.session(), session, "the same VM, no session moved");
assert!(world.get_non_send_resource::<ParkedBootVm>().is_none());
}
/// **A rebuilt VM inherits the running `GetTime()` clock, and so does the conversion pair**
/// (decision 2116) — the bug the director reported as *cooldowns are lost on relog*.
///
/// The reference's `GetTime` is `KERNEL32!GetTickCount` × 0.001 (`0x515ea0` → `0x42c010` →
/// `0x42b790`), an OS clock that cannot restart — which is why stock `Cooldown.lua` can gate
/// on `start > 0`. Ours lives in the VM, and since 1290/1291 the VM dies at the character
/// screen; before this, `end_ui_session` zeroed [`UiClock`] too, so on the way back in every
/// cooldown that was already running converted to a NEGATIVE start,
/// `CooldownFrame_SetTimer` took its `else` branch and hid the sweep — while the store, and
/// therefore the cast validator, still held the cooldown.
///
/// Against the old shape the two assertions read *"the new VM's GetTime clock restarted at 0
/// instead of the process's 90 s"* and *"a cooldown armed before the rebuild derived start
/// -30000 ms; stock Cooldown.lua hides anything but `start > 0`"*.
#[test]
fn a_rebuilt_vm_inherits_the_running_gettime_clock() {
use std::time::{Duration, Instant};
let mut world = World::new();
world.init_resource::<UiClock>();
// The process has been up 90 s — the clock `Time<Real>` keeps and the one the dying VM
// is on, which are the same clock.
let startup = Instant::now() - Duration::from_secs(90);
let mut time = Time::<bevy::time::Real>::new(startup);
// `Time<Real>` measures `elapsed` from its FIRST update, not from `startup` — so the app's
// own clock is "since frame one", and the test's has to be seeded the same way.
time.update_with_instant(startup);
time.update_with_instant(startup + Duration::from_secs(90));
world.insert_resource(time);
let mut dying = UiScript::new().unwrap();
dying.set_now(90.0);
world.insert_non_send_resource(dying);
// The session never loaded an in-game UI, so the shutdown tail (which writes the player's
// four files) is skipped: this test is about the VM swap and nothing else.
world.init_resource::<PendingEntryUiLoad>();
end_ui_session(&mut world);
let vm = world
.get_non_send_resource::<UiScript>()
.expect("the session end installs a fresh boot VM");
assert_eq!(
vm.now(),
90.0,
"the new VM's GetTime clock restarted at {} instead of the process's 90 s",
vm.now()
);
// …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 {
start: startup + Duration::from_secs(60),
remaining_ms: 570_000,
duration_ms: 600_000,
enabled: true,
};
let triple = armed
.ui_triple(clock.anchor, clock.ui_now)
.expect("a running cooldown pushes a triple");
assert_eq!(
triple,
(60_000, 600_000, true),
"a cooldown armed before the rebuild derived start {} ms; stock Cooldown.lua hides \
anything but `start > 0`",
triple.0
);
}
}
@@ -207,6 +207,18 @@ end
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// **The client's CVar table first, because the interface reads CVars AT LOAD** (decision
// 2115). The app registers `crate::cvars::REGISTERED` at startup, long before world entry, so
// in the running client this call finds every name already there and only refreshes its
// default — it never clobbers a live value ([`benilla_ui::script::UiScript::register_cvars`]).
// What it buys is that a **probe** VM is the same client: `UiScript::new()` carries only
// `benilla-ui`'s own CVars, and the stock `UIOptionsFrame.xml`'s two camera dropdowns read
// theirs inside their own `OnLoad`
// (`getglobal("OPTION_TOOLTIP_CAMERA"..UIDropDownMenu_GetSelectedID(this))`, which is nil and
// then a concat error when `GetCVar("cameraSmoothStyle")` answers nil). Both of those CVars
// 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
+11 -2
View File
@@ -235,14 +235,23 @@ pub(crate) struct UiInput;
/// shape) re-measures the tick→feed scheduling gap every frame and wobbles the derived start by
/// that jitter (±12 ms observed live), turning every running cooldown into a per-frame "changed"
/// triple — the diff churn 0375 existed to kill.
///
/// **Both legs run on the PROCESS's clock and neither restarts with the VM** (decision 2116). A
/// rebuilt VM (any logout/login, any `ReloadUI`) is handed the running clock by
/// [`lifecycle::seed_vm_clock`], which writes this pair in the same breath — the reference's
/// `GetTime` is `KERNEL32!GetTickCount`, an OS clock, and stock `Cooldown.lua` gates on
/// `start > 0`, so a clock that went back to zero at the character screen pushed every already-
/// running cooldown into the past and hid its sweep.
#[derive(Resource)]
pub(crate) struct UiClock {
/// The `Instant` leg: `Time<Real>::last_update()` at the tick that produced [`Self::ui_now`].
pub(crate) anchor: std::time::Instant,
/// The `GetTime` leg: the VM clock's value after that tick (seconds).
/// The `GetTime` leg: the clock's value after that tick (seconds since this process started).
pub(crate) ui_now: f64,
}
/// The pre-boot pair, replaced by [`lifecycle::seed_vm_clock`] the moment the first VM exists —
/// `init_resource` needs it, nothing else should build one.
impl Default for UiClock {
fn default() -> Self {
Self {
@@ -473,7 +482,7 @@ pub(crate) use lifecycle::{
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
#[cfg(test)]
use lifecycle::load_ingame_ui_on_world_entry;
pub(crate) use lifecycle::load_ingame_ui_on_world_entry;
use lifecycle::shutdown_on_exit;
#[cfg(test)]
pub(crate) use lifecycle::{
@@ -39,10 +39,14 @@ fn load_action_bar(s: &UiScript) {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
// The stock multibar file's OnLoad writes `UIOptionsFrameCheckButtons`, which our options
// window publishes — the reference's own load order (UIOptionsFrame.xml l.21 before
// MultiActionBars.xml l.39), and the manifest's since 1938.
// The stock multibar file's OnLoad writes `UIOptionsFrameCheckButtons`, whose home is the
// reference's own hidden Interface Options window (2115) — the reference's own load order
// (UIOptionsFrame.xml l.21 before MultiActionBars.xml l.39), and the manifest's since 1938.
// That window is also where `ALWAYS_SHOW_MULTIBARS` is declared and where the load arm this
// file exercises lives.
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
@@ -912,14 +916,16 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
// counts nobody raised below zero. (Ours used to guard the no-op; the guard went with the
// file, 1938.)
// The load arm, both ways: off touches no count, on opens the wells.
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" OptionsFrame_ApplySavedSettings()")
.unwrap();
// The LOAD arm is the reference's own VARIABLES_LOADED arm now (`UIOptionsFrame.lua`
// l.218-220), off the chain since 2115 — not our retired `OptionsFrame_ApplySavedSettings`.
s.run("ALWAYS_SHOW_MULTIBARS = \"0\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "a load with the option off left the count alone");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\" OptionsFrame_ApplySavedSettings()")
.unwrap();
s.run("ALWAYS_SHOW_MULTIBARS = \"1\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
assert!(well(&s), "a load with the option on opens the wells");
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
@@ -61,6 +61,13 @@ fn harness_on(mut s: UiScript) -> UiScript {
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
// The reference's own Interface Options window, hidden — this kit's source of truth for
// `UIOptionsFrameCheckButtons`/`UIOptionsFrameSliders`, which our own file used to
// transcribe and 2115 deleted. Its widget kit and the video window's shared helpers come
// with it, in the manifest's own order.
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"ScrollTemplates.xml", // the Keybindings page's faux-scroll kit
"KeyBindingsPage.xml", // the Keybindings body's templates + script (1008)
"OptionsFrame.xml",
@@ -688,7 +695,7 @@ fn a_slider_rows_readout_sits_on_its_labels_line() {
/// The Audio harness: the real registered CVar set on the table before the XML loads, exactly
/// the app's boot order (register → seed → load → select).
fn audio_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let s = UiScript::new().unwrap();
s.register_cvars(crate::cvars::registered_pairs());
s
}
@@ -884,6 +891,13 @@ fn actionbars_harness() -> UiScript {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
// The reference's own Interface Options window BEFORE ours, as the manifest has it
// (2115) — `UIOptionsFrame_Init` is what assigns `LOCK_ACTIONBAR` and
// `ALWAYS_SHOW_MULTIBARS`, and this page's rows capture those at their own OnLoad as
// the Defaults value. It is also the home of `UIOptionsFrameCheckButtons`, which
// `MultiActionBars.xml` below writes into at its load.
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
],
@@ -3642,7 +3656,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() {
let s = chat_harness();
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();
assert_eq!(
@@ -3651,7 +3665,10 @@ fn a_saved_hover_delay_is_applied_when_the_variables_land() {
(0.2, 0.15),
"the bare assignment changes nothing on its own — that is why the row has an applyFunc"
);
s.run("OptionsFrame_ApplySavedSettings()").unwrap();
// The reference's own VARIABLES_LOADED arm, off the chain since 2115 — this row's apply is
// `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)` at `UIOptionsFrame.lua:216`, and the event is
// what runs it. (It used to be our own `OptionsFrame_ApplySavedSettings` walk.)
s.fire_event("VARIABLES_LOADED", vec![]);
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
@@ -4285,6 +4302,18 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
"PetMeleeDamage",
"`combat_text::law::PET_MELEE_DAMAGE`, a const bool — see CombatDamage",
),
// …and the SLIDERS' half, which this census was blind to until 2115 extended it from
// `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` as well. One row, and it is not
// cosmetic: `UIOptionsFrame_Load` does `slider:SetValue(GetCVar(value.cvar))`, and
// `Slider:SetValue` is a shape-A binding (`0x790980`, wow-re `numeric-arg-coercion-law.md`)
// that RAISES on a nil in the reference too — so this is what stops the stock window's
// `_Load()` running clean, and pfUI's GVAR path with it.
(
"cameraYawMoveSpeed",
"the Mouse Look Speed slider — benilla's mouse-look yaw rate is not a CVar-driven knob \
yet; its sibling `cameraYawSmoothSpeed` (auto-follow) is registered and drives \
`follow.yaw_speed`, and this one has no counterpart in `player::camera`",
),
];
/// **Every CVar the reference's own options table names is registered here, or listed above with
@@ -4298,16 +4327,22 @@ const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker() {
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
// until the stock window went on the manifest and `UIOptionsFrameSliders` turned out to name a
// CVar nothing here registers — a row the check could not see because it only read one table.
let named: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for key, v in pairs(UIOptionsFrameCheckButtons) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
for _, v in ipairs(UIOptionsFrameSliders) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
table.sort(out) \
return out",
)
.expect("read UIOptionsFrameCheckButtons");
.expect("read UIOptionsFrameCheckButtons and UIOptionsFrameSliders");
assert!(
named.len() >= 28,
"the transcription lost rows: only {} cvar entries",
@@ -32,10 +32,6 @@ use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// The manifest prefix these two files sit on: fonts, `UIParent` (the parent every addon passes),
/// `HideUIPanel` (the close button's OnClick), the tooltip (the options widgets' hover) and the
/// scroll kit (`ScrollFrame_OnLoad`), then the two files under test in manifest order.
@@ -57,7 +53,13 @@ fn harness() -> UiScript {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
"OptionsFrameTemplates.xml",
// `UIOptionsCheckButtonTemplate`'s home. It was ours, in a one-template
// `OptionsFrameTemplates.xml`, until 2115 put the reference's own hidden Interface
// Options window on the manifest — the template's actual home, and the whole point of
// that record: an addon that names it gets the reference's own declaration. The dropdown
// kit comes with it because the window has four.
r"Interface\FrameXML\UIDropDownMenu.xml",
r"Interface\FrameXML\UIOptionsFrame.xml",
] {
load_xml(&s, file);
}
@@ -434,59 +436,18 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **No invented names.** Every `virtual="true"` template these two files declare is a name the
/// real 1.12.1 client has, read from `reference/1.12-globals.tsv`.
///
/// Scoped to these two files on purpose: the rest of `assets/ui` is full of deliberately
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `BenillaScriptLogRowTemplate`,
/// `OptionsRedButtonTemplate`), and a whole-tree sweep would be asserting something else. These
/// two files make the opposite claim — *these are the reference's own names, which is why an addon
/// can find them* — so that claim is the one worth gating. A template renamed to something
/// plausible-but-absent here is a template no addon will ever name.
#[test]
fn every_template_these_files_declare_is_a_real_1_12_name() {
let _data = benilla_formats::wow_data_or_skip!();
let tsv =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../reference/1.12-globals.tsv");
let text = std::fs::read_to_string(&tsv).unwrap_or_else(|e| {
panic!(
"reading {}: {e} — regenerate with scripts/gen-reference-globals.py",
tsv.display()
)
});
let known: std::collections::HashSet<&str> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
let mut checked = 0;
// OUR files only. This reads the source tree directly, so it cannot name a chain entry — and
// it has no reason to: asserting that the reference's own file declares 1.12 names is
// tautological. What it guards is us inventing one (decision 1841).
// Our remaining file only. `UIPanelTemplates.xml` was ours until 1846 put the whole kit on the
// chain; what is left to guard is the one template we still declare.
for file in ["OptionsFrameTemplates.xml"] {
let src = std::fs::read_to_string(ui_dir().join(file)).unwrap();
let doc = benilla_ui::framexml::parse(&src).unwrap();
for item in &doc.items {
let benilla_ui::framexml::TopLevel::Template(el) = item else {
continue;
};
let name = el.name().expect("every template here is named");
assert!(
known.contains(name),
"{file} declares '{name}', which is not a 1.12 global — \
a template the reference does not have is one no addon can name"
);
checked += 1;
}
}
assert!(
checked >= 1,
"only {checked} templates swept — the sweep, not the files, is what broke"
);
}
// **RETIRED with its subject (decision 2115).** `every_template_these_files_declare_is_a_real_1_12_name`
// swept the `virtual="true"` templates of the files we shipped under a reference name and required
// each to be a real 1.12 global (`reference/1.12-globals.tsv`) — the guard against inventing a
// plausible-but-absent template name that no addon could ever reach for (decision 1841). 1846 took
// `UIPanelTemplates.xml` to the chain and left it one subject, `OptionsFrameTemplates.xml`'s single
// `UIOptionsCheckButtonTemplate`; 2115 took that one too, by loading the reference's own
// `UIOptionsFrame.xml`. Nothing under `assets/ui` now declares a template under a reference name —
// what is left there is deliberately benilla-shaped (`BenillaScrollBarTemplate`,
// `OptionsCheckboxRowTemplate`, `BenillaScriptLogRowTemplate`), which is a different claim and the
// sweep's own doc said so. This is 1751 §5 working as written: a drift instrument loses its subject
// as the copies retire. `the_options_check_button_resolves_its_whole_inheritance_chain` above still
// proves the template resolves — off the chain now.
/// **`PanelTemplates_TabResize`'s `tab` argument is OPTIONAL, and omitting it means `this`.**
///
@@ -1575,6 +1575,46 @@ mod tests {
"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.",
),
(
"OptionsFrame.lua",
"GetCurrentResolution",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetGamma",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetRefreshRates",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"GetScreenResolutions",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"RestartGx",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"RestoreVideoDefaults",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"SetGamma",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
(
"OptionsFrame.lua",
"SetScreenResolution",
"one of the VIDEO options window's nine engine verbs, none built and none intended for now (decision 2115). `OptionsFrame.lua` is on the manifest for its last fifty lines — the seven shared `OptionsFrame_Enable/Disable*` helpers plus `PlayClickSound`, which the stock `UIOptionsFrame.lua` calls and nothing else defines. Its own window's XML is deliberately NOT loaded (the resolution dropdown's OnLoad alone needs `GetCurrentResolution` and `GetScreenResolutions`, and eighteen of its checkboxes are 2004 hardware toggles), so every caller of this verb is a function no frame reaches. benilla's own Graphics page is `OptionsFrame.xml`'s. Closed by building the video window, not by a stub (1203).",
),
];
let mut missing: Vec<(String, String)> = Vec::new();
+415 -1
View File
@@ -7,13 +7,24 @@
//! invisible to the chain's own files too, because a Lua caller that uses one value of two never
//! notices the second.
//!
//! **Arity is only half a shape.** A binding that answers the right *number* of values of the
//! wrong *kind* is the same silent break one level down, and it is the shape the 1.12 API is most
//! exposed to: the reference has no boolean-returning query at all — every `Is*`/`Can*`/`Has*` row
//! in the table is `(nil) | (number)`, because the client pushes `1` and `nil`, never `true` and
//! `false`. An addon writing `if IsShiftKeyDown() == 1` — and 87 corpus addons compare returns to
//! `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
//! reason it can be enforced instead of remembered:
//!
//! * `arity_conf = exact` — sound. This is the column to gate on.
//! * `kinds_conf` — trustworthy only where an independent push count agreed; not gated here.
//! * `kinds_conf = agree` — the row's independent `lua_push*` trace matched `eax` at every `ret`,
//! which is the table's own statement that the kinds are trustworthy. Gated too, by
//! [`every_query_binding_answers_the_reference_s_return_kinds`]; a row that did not agree is
//! advisory and this module never reads its kinds at all.
//! * `argc_conf` — **never** gate on it. It is a deliberate over-flagging superset: any binding
//! whose direct callee reads a Lua index is marked `lower-bound`, so a `lower-bound` row says
//! nothing about the real argument count.
@@ -54,6 +65,36 @@
/// an entry needs an arity, a reason, and a dispatched question.
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
/// The **kind** gate's own shrinking list, same rules as [`NOT_YET_ASSERTED`]: a name, the wrong
/// kinds it currently answers, and the dispatched question that will settle it. An entry whose
/// binding has come into agreement fails the gate, so it cannot outlive its fix.
///
/// Both entries are edge-case *values* the shapes table states the kind of and the recorded wow-re
/// notes state no value for, dispatched into wow-re as one batched question (decision 2118):
///
/// * `GetSendMailItem` — slots 3 and 4 are `number` on every alternative including the
/// `(nil,nil,…)` empty one, so the no-attachment leg pushes two numbers. Which two is a byte
/// question; ours answers `(nil, nil, 1, nil)` and the slot-4 nil is provably wrong.
const KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[(
"GetSendMailItem",
"nil,nil,number,nil",
"the reference's no-attachment leg pushes numbers in slots 3 and 4; the two values are dispatched into wow-re (GetSendMailItem 0x4ae590)",
)];
/// The widget kind gate's shrinking list — see [`KINDS_NOT_YET_ASSERTED`].
///
/// `GetFont`'s height slot is `number` in the reference's single alternative, and ours answers nil
/// on a FontString that has never been given a font. wow-re's `font-object-lua-surface.md` §9.3
/// records that the FontString reads back through its resolved `CGxFont`, so a NULL one yields nil
/// for the path and the flags — but it does not say what the *height* slot then carries, and that
/// is the value the corpus does arithmetic on (`aux-addon`'s font-resize wheel handler,
/// `tabs/search/frame.lua:481`). Dispatched.
const WIDGET_KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[(
"GetFont",
"nil,nil,string",
"the reference's height slot is a number even with no CGxFont resolved; the value is dispatched into wow-re (CSimpleFontString::GetFont 0x79d400)",
)];
/// The widget half's own shrinking list — see [`NOT_YET_ASSERTED`], same rules. Also empty:
/// `GetFogColor` came off it with 1845, which found the fog colour was a packed `0xAARRGGBB` dword
/// all along.
@@ -67,6 +108,9 @@ struct Row {
name: String,
table_va: String,
arity: usize,
/// The `kinds` column, verbatim — `(number,string?)`, or several `|`-separated alternatives.
/// Empty when the row's `kinds_conf` is not `agree`; see [`KindGate`].
kinds: String,
}
fn rows() -> Vec<Row> {
@@ -87,6 +131,14 @@ fn rows() -> Vec<Row> {
name: f[0].to_string(),
table_va: f[3].to_string(),
arity: f[7].parse().ok()?,
// ADVISORY unless `kinds_conf == agree` — the table's own column contract. A row
// that did not agree carries no kinds here at all, so nothing downstream can read
// one by accident.
kinds: if f.len() > 10 && f[10] == "agree" {
f[9].to_string()
} else {
String::new()
},
})
})
.collect()
@@ -343,3 +395,365 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
mismatches.join("\n ")
);
}
/// Split a `kinds` cell into its alternatives, each one a per-slot kind list.
///
/// `(number,string?)` is one alternative of two slots; `(nil) | (number)` is two of one. An
/// arity-0 row is `()` and yields nothing, which is how the kind gates skip the verbs that answer
/// no values at all.
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
cell.split('|')
.map(|alt| {
alt.trim()
.trim_start_matches('(')
.trim_end_matches(')')
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect::<Vec<String>>()
})
.filter(|a| !a.is_empty())
.collect()
}
/// Does one observed Lua `type()` satisfy one reference slot?
///
/// Two entries in the vocabulary assert less than they look like they do, and both are the table's
/// own doing rather than ours:
///
/// * **`string?` is string-or-nil.** `lua_pushstring 0x6f3890` begins `test edx,edx ; jne` and
/// tail-jumps to `lua_pushnil` when the `char*` is NULL, so a push instruction is not proof that
/// a string reached Lua. The harvester emits plain `string` only where the pointer is a constant
/// resolving to a readable literal; everything else is `string?`. This is *why* a miss path can
/// show a fixed arity with no kind alternation and still answer nil in that slot.
/// * **`any` / `value`** are the slots the walk could not narrow. They accept everything.
///
/// Everything else is exact — and note what is *absent* from the vocabulary across all 1503 rows
/// the table calls trustworthy: two `boolean` slots in the entire client, `IsPetAttackActive` and
/// Lua's own `rawequal`. A 1.12 predicate answers `1` or `nil`.
fn slot_accepts(want: &str, got: &str) -> bool {
match want {
"any" | "value" => true,
"string?" => got == "string" || got == "nil",
w => w == got,
}
}
/// The Lua helper both kind gates probe through: answer the `type()` of every value a call
/// returned, in order, comma-joined. A table constructor would swallow the trailing nils, so the
/// count has to come from the vararg itself.
///
/// It reads `arg.n`, not `select('#', ...)`, and that is not a style choice: **`...` as a value is
/// a syntax error in this VM**, because 5.0 has no `TK_DOTS` arm in `simpleexp` and the parser
/// patch reproduces that (1192; `lua50::tests::the_vararg_expression_is_a_syntax_error_as_it_is_on_1_12`).
/// The 5.0 spelling is the implicit `arg` table, which is what all 177 stock FrameXML files use.
const KIND_HELPER: &str = "function PGKinds(...) \
local o = {} for i = 1, arg.n do o[i] = type(arg[i]) end \
return table.concat(o, ',') end";
/// Every registered global whose return *kinds* the reference states trustworthily, answered by
/// this client (decision 2118).
///
/// Same three narrowings as the arity gate, for the same reasons: query verbs only, a call that
/// raises is skipped, and a name registered from two tables is read at its non-glue row. A fourth
/// is specific to kinds — a row whose observed arity already disagrees is left to the arity gate,
/// because comparing a 3-tuple against a 4-slot reference reports the same fault twice.
#[test]
fn every_query_binding_answers_the_reference_s_return_kinds() {
let all = rows();
let mut by_name: std::collections::HashMap<&str, Vec<&Row>> = std::collections::HashMap::new();
for r in &all {
by_name.entry(&r.name).or_default().push(r);
}
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
s.run(KIND_HELPER).expect("kind helper");
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for (name, rs) in &by_name {
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let candidates: Vec<&&Row> = if rs.len() == 1 {
rs.iter().collect()
} else {
rs.iter().filter(|r| r.table_va != GLUE_TABLE).collect()
};
let Some(first) = candidates.first() else {
continue;
};
if candidates.iter().any(|r| r.kinds != first.kinds) {
continue;
}
let want = kind_alternatives(&first.kinds);
if want.is_empty() {
continue;
}
let call = if name.starts_with("Unit") {
format!(r#"{name}("player")"#)
} else {
format!("{name}()")
};
let probe = format!(
"if type({name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({call}) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
// An arity fault is the arity gate's to report; reporting it here too would double-count.
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
checked += 1;
let agrees = want.iter().any(|alt| {
alt.len() == got.len()
&& alt
.iter()
.zip(&got)
.all(|(w, g)| slot_accepts(w.as_str(), g))
});
let observed = got.join(",");
if !agrees {
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
assert_eq!(
*known, observed,
"{name} is on the not-yet-asserted list at stale kinds — it now answers ({observed})"
);
continue;
}
mismatches.push(format!(
"{name}: answers ({observed}), reference states {}",
first.kinds
));
} else {
assert!(
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
);
}
}
// A floor, not a target — see the arity gate's. Raise it when coverage rises; never lower it
// to make a change fit.
assert!(
checked >= 150,
"the kind gate measured only {checked} bindings — it has stopped covering anything"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed bindings answer values of the wrong kind:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// The widget half of the kind gate — same rules, probed on an instance of each method's own
/// registrar class (see [`WIDGET_PROBES`]).
#[test]
fn every_widget_method_answers_the_reference_s_return_kinds() {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-shapes.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(KIND_HELPER).expect("kind helper");
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for (table, kind, name) in WIDGET_PROBES {
if s.run(&format!(
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
))
.is_ok()
{
made.insert(table, (*name).to_string());
}
}
for (table, expr) in REGION_PROBES {
let var = format!("PGR{}", made.len());
if s.run(&format!("{var} = {expr}")).is_ok() {
made.insert(table, var);
}
}
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for line in text
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
{
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 11 || f[4] != "widget" || f[8] != "exact" || f[10] != "agree" {
continue;
}
let (name, table) = (f[0], f[3]);
if !["Get", "Is", "Has", "Can", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let Some(obj) = made.get(table) else { continue };
let want = kind_alternatives(f[9]);
if want.is_empty() {
continue;
}
let probe = format!(
"if type({obj}.{name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({obj}:{name}()) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
checked += 1;
let agrees = want.iter().any(|alt| {
alt.len() == got.len()
&& alt
.iter()
.zip(&got)
.all(|(w, g)| slot_accepts(w.as_str(), g))
});
let observed = got.join(",");
if !agrees {
if let Some((_, known, _)) = WIDGET_KINDS_NOT_YET_ASSERTED
.iter()
.find(|(n, ..)| *n == name)
{
assert_eq!(
*known, observed,
"{name} is on the widget not-yet-asserted list at stale kinds — it now answers ({observed})"
);
continue;
}
mismatches.push(format!(
"{obj}:{name} answers ({observed}), reference states {}",
f[9]
));
}
}
assert!(
checked >= 40,
"the widget kind gate measured only {checked} methods — it has stopped covering anything"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed widget methods answer values of the wrong kind:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// Argument tuples the boolean gate tries, in order, until one call returns.
///
/// The kind gate probes a binding with nothing (or a unit token) and skips the ones that raise,
/// which leaves half the query surface unmeasured — the reference gates its arguments and raises
/// on a missing one (1834/1836). A *boolean* answer is wrong whatever the arguments are, so this
/// gate can afford a ladder the kind gate cannot: a wrong-but-plausible argument can change which
/// kind tuple comes back, and would make the kind gate cry wolf, but it can never make `1` into
/// `true`.
const ARG_LADDER: &[&str] = &["", "\"player\"", "1", "1, 1", "\"player\", 1"];
/// **1.12 has no boolean query.** Across all 1503 rows this table calls trustworthy, exactly two
/// slots are `boolean`: `IsPetAttackActive` and Lua's own `rawequal`. Everything else that reads
/// as a predicate — every `Is*`, `Can*`, `Has*` — is `(nil) | (number)`, because the client pushes
/// `1` and `nil`.
///
/// The distinction is invisible to `if x then` and decisive to everything else: `x == 1`,
/// `tostring(x)`, a value stored in a saved variable, arithmetic, a table key. `ColorPickerPlus`
/// writes `if IsShiftKeyDown() == 1 then` (decision 2118), and no gate we had could see it.
#[test]
fn no_query_binding_answers_a_lua_boolean() {
// The reference's own two, by name — the list may only shrink.
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
let all = rows();
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
s.run(KIND_HELPER).expect("kind helper");
let mut names: Vec<&str> = all
.iter()
.map(|r| r.name.as_str())
.filter(|n| {
["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| n.starts_with(p))
})
.filter(|n| !REFERENCE_BOOLEANS.contains(n))
.collect();
names.sort_unstable();
names.dedup();
let mut checked = 0usize;
let mut booleans: Vec<String> = Vec::new();
for name in names {
for args in ARG_LADDER {
let probe = format!(
"if type({name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({name}({args})) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
checked += 1;
if got.split(',').any(|k| k == "boolean") {
booleans.push(format!("{name}({args}) answers ({got})"));
}
break;
}
}
assert!(
checked >= 300,
"the boolean gate measured only {checked} bindings — it has stopped covering anything"
);
assert!(
booleans.is_empty(),
"{} bindings answer a Lua boolean; 1.12 pushes 1/nil and only {REFERENCE_BOOLEANS:?} are \
boolean in the reference:\n {}",
booleans.len(),
booleans.join("\n ")
);
}
@@ -1528,3 +1528,183 @@ fn the_shipped_manifest_opens_the_macro_icon_picker() {
"the picker is up"
);
}
/// **The reference's own Interface Options window is on the manifest, and it stays HIDDEN**
/// (decision 2115) — the whole shape of that record, asserted in one place.
///
/// Four claims, and each one has a way to fail that nothing else here would catch:
///
/// 1. **`UIOptionsFrame` is the reference's frame, not an alias onto ours.** The alias
/// (`UIOptionsFrame = OptionsFrame`) is what this replaces, and under it every one of these
/// reads would have succeeded while pointing at the window the player opens — pfUI's skin does
/// `UIOptionsFrame:SetWidth(1024)`. So the test asks for the reference's own CHILDREN, which an
/// alias could never grow.
/// 2. **It is never shown.** `hidden="true"` is the stock file's own attribute and nothing of ours
/// may `Show()` it.
/// 3. **`UIOptionsFrameCheckButtons` carries the five rows `MultiActionBars.lua:10` writes** —
/// which IS the load-order proof. That file writes them at its own load, under the reference's
/// own comment *"Hack to get around load order dependencies"*, so the rows are there only if
/// the options window's manifest row sits above the bars', as `FrameXML.toc` l.21 vs l.39 has
/// it. Reorder the manifest and this is what goes red.
/// 4. **Ours is still the player's.** `GameMenuButtonOptions` opens `OptionsFrame`.
#[test]
fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefive".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// 1 · the reference's own frame, by the children only it declares.
for name in [
"UIOptionsFrame",
"UIOptionsFrameTitle",
"UIOptionsFrameTab1",
"UIOptionsFrameTab2",
"UIOptionsFrameOkay",
"UIOptionsFrameCancel",
"UIOptionsFrameDefaults",
"UIOptionsFrameResetTutorials",
"UIOptionsFrameCheckButton1",
"UIOptionsFrameCheckButton69",
"UIOptionsFrameSlider1",
"UIOptionsFrameSlider4",
"UIOptionsFrameClickCameraDropDown",
"UIOptionsFrameCameraDropDown",
"UIOptionsFrameTargetofTargetDropDown",
"UIOptionsFrameCombatTextDropDown",
"BasicOptions",
"BasicOptionsGeneral",
"BasicOptionsDisplay",
"BasicOptionsCamera",
"BasicOptionsHelp",
"AdvancedOptions",
"AdvancedOptionsActionBars",
"AdvancedOptionsChat",
"AdvancedOptionsRaid",
"AdvancedOptionsCombatText",
] {
assert!(
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
.unwrap(),
"{name} — pfUI's options-interface skin walks every one of these"
);
}
assert!(
s.eval::<bool>("return UIOptionsFrame ~= OptionsFrame")
.unwrap(),
"the alias is gone: the stock frame is its own frame, not ours under a second name"
);
// 2 · …and hidden, by its own file's attribute.
assert!(
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
"the stock window must never be on screen"
);
// 3 · the order proof.
let rows: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for _, k in ipairs({ \"SHOW_MULTIBAR1_TEXT\", \"SHOW_MULTIBAR2_TEXT\", \
\"SHOW_MULTIBAR3_TEXT\", \"SHOW_MULTIBAR4_TEXT\", \"ALWAYS_SHOW_MULTIBARS_TEXT\" }) do \
local row = UIOptionsFrameCheckButtons[k] \
if row and row.func and row.setFunc then table.insert(out, k) end \
end \
return out",
)
.expect("UIOptionsFrameCheckButtons is a table with rows");
assert_eq!(
rows.len(),
5,
"MultiActionBars.lua:10 writes five rows into UIOptionsFrameCheckButtons at ITS load, so \
the options window's manifest row must sit above the bars' — got {rows:?}"
);
// 4 · ours is still the window the ESC menu opens.
assert_eq!(
s.eval::<String>(
"return GameMenuButtonOptions:GetScript(\"OnClick\") and \"bound\" or \"\""
)
.unwrap(),
"bound"
);
s.run("GameMenuButtonOptions:Click()").unwrap();
assert!(
s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
"the player's Options button opens OUR window"
);
assert!(
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
"…and never the stock one"
);
}
/// **pfUI's `UIOptionsFrame_Save()` path runs clean, and `_Load()` stops at exactly one thing**
/// (decision 2115).
///
/// pfUI's `modules/gui.lua` l.146-148 wraps a GVAR checkbox's write in
/// `UIOptionsFrame_Load()` … `UIOptionsFrame_Save()`, so both are reached at runtime by a real
/// addon and both had to be more than nil.
///
/// `_Save` runs clean. `_Load` does not, and the failure is **pinned rather than hidden** because
/// it names a real gap this record surfaced: `UIOptionsFrameSliders` row 3 is
/// `cameraYawMoveSpeed`, which `cvars::REGISTERED` does not carry, and `_Load` does
/// `slider:SetValue(GetCVar(value.cvar))` — `Slider:SetValue` is a shape-A binding (`0x790980`,
/// wow-re `numeric-arg-coercion-law.md`) that raises on a nil in the reference too. The row is
/// listed in [`super::options_tests`]'s unbacked census with its blocker; the day something
/// registers it, THIS assertion goes red and gets deleted, which is the point of writing it as an
/// expectation rather than an ignore.
#[test]
fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefive".into()),
level: 60,
..Default::default()
}),
);
assert!(super::load_default_ui(&s).is_empty());
// The Okay path, whole: 69 check buttons, four sliders, four dropdowns, the multibar toggles
// and the combat-text tail. Driven through the reference's OWN caller — `UIOptionsFrameOkay`'s
// `<OnClick>` (xml l.1205-1209) — rather than as a bare call, and that is not ceremony: the
// `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act is `this.hasDispellable = nil`
// (`BuffFrame.lua:266`), so the reference's ambient handler global has to be set. It is, at
// every real call site: the Okay button here, and pfUI's own checkbox on its GVAR path.
s.run("UIOptionsFrameOkay:Click()")
.expect("the stock Okay button's own handler");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
"…and the window it hides on the way out was hidden to begin with"
);
let err = s
.run("this = UIOptionsFrameOkay UIOptionsFrame_Load()")
.expect_err("_Load stops at the one unregistered slider CVar — see the doc comment");
let err = err.to_string();
assert!(
err.contains("SetValue"),
"the ONLY thing that may stop _Load is slider 3's nil CVar; got: {err}"
);
assert!(
s.eval::<bool>(r#"return GetCVar("cameraYawMoveSpeed") == nil"#)
.unwrap(),
"…and that is the cause: cameraYawMoveSpeed is the unregistered one"
);
}
+79 -3
View File
@@ -119,6 +119,13 @@ pub(super) fn load_ui_no_warnings(s: &UiScript, entry: &str) -> usize {
}
fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bool) -> usize {
// **A kit's VM is the client's VM, and that includes the CVar table** (decision 2115). The app
// registers `crate::cvars::REGISTERED` at startup, before any interface file loads;
// `UiScript::new()` carries only `benilla-ui`'s own. The stock `UIOptionsFrame.xml` reads two
// camera CVars inside its dropdowns' `OnLoad` and raises on a nil, so a kit that skips this is
// measuring a client that does not exist. Idempotent, and it never clobbers a value a test set
// first — `register_cvars` only refreshes an existing slot's default.
s.register_cvars(crate::cvars::registered_pairs());
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
if path.to_ascii_lowercase().ends_with(".lua") {
@@ -129,6 +136,17 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
let doc = benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
// Seated BEFORE the load, not after it like the two below: `MultiActionBarFrame_OnLoad`
// indexes `UIOptionsFrameCheckButtons` from inside this very load walk, where the micro row's
// and UIParent's callees only run later (decision 2115).
if path
.rsplit('/')
.next()
.is_some_and(|l| l.eq_ignore_ascii_case("MultiActionBars.xml"))
{
s.run(MULTI_ACTION_BAR_STAND_INS)
.expect("the multibar stand-ins");
}
let report = benilla_ui::loader::load_in(s, &doc, &path, &provider);
assert!(
report.errors.is_empty(),
@@ -143,6 +161,10 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
if leaf.eq_ignore_ascii_case("UIParent.xml") && super::reference_ui::is_chain_entry(&path) {
s.run(UIPARENT_STAND_INS).expect("the UIParent stand-ins");
}
if leaf.eq_ignore_ascii_case("UIOptionsFrame.xml") {
s.run(UIOPTIONS_STAND_INS)
.expect("the stock options window's stand-ins");
}
if no_warnings {
assert!(
report.warnings.is_empty(),
@@ -279,6 +301,60 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
end
"#;
/// **What a kit owes the stock `MultiActionBars.xml`.** Its `MultiActionBarFrame_OnLoad`
/// (`MultiActionBars.lua:10`, under the reference's own comment *"Hack to get around load order
/// dependencies"*) writes five rows into `UIOptionsFrameCheckButtons` — a table whose home is
/// `UIOptionsFrame.xml`, which the reference's toc seats at l.21, eighteen rows above this one.
/// The shipped manifest has that order (decision 2115) and needs nothing here; a KIT is a prefix
/// of the manifest and dozens of them load the bars without any options window.
///
/// `or {}` rather than a fresh table, and seated at load rather than on first use, for the reason
/// 1988 gives: a table, like a function, is a plain global write, so a real declaration that lands
/// afterwards simply wins — and in manifest order it never lands afterwards, which is exactly what
/// the shipped `UIOptionsFrameCheckButtons` order proof asserts. A kit that loads both in manifest
/// order therefore gets the real table with the bars' five rows in it, which is the client's own
/// state.
/// **What a kit owes the stock `UIOptionsFrame.xml`.** Its `UIOptionsFrame_OnEvent`'s
/// VARIABLES_LOADED arm (`UIOptionsFrame.lua` l.193-227) is the reference's own load-time ladder —
/// the one decision 2115 retired our re-expression of — and it calls six functions unguarded:
/// `BuffButtons_UpdatePositions`, `FCF_Set_SimpleChat`, `FCF_Set_ChatLocked`,
/// `SetChatMouseOverDelay`, `MultiActionBar_ShowAllGrids` and
/// `RaidOptionsFrame_UpdatePartyFrames`. In the shipped manifest that is fine twice over: the
/// buff bar loads far above this row, the chat and raid files far below it, and the event does not
/// fire until every one of them has loaded. A KIT is a prefix of the manifest and fires the event
/// itself, so it needs the names to exist.
///
/// Functions, so load-time seating is safe and the kit's order does not matter: a later chunk's
/// `function X()` is a plain global write that overwrites the stand-in, unlike a frame's
/// non-overwriting publish (1988's reasoning, and its precedent).
pub(super) const UIOPTIONS_STAND_INS: &str = r#"
BuffButtons_UpdatePositions = BuffButtons_UpdatePositions or function() end
FCF_Set_SimpleChat = FCF_Set_SimpleChat or function() end
FCF_Set_NormalChat = FCF_Set_NormalChat or function() end
FCF_Set_ChatLocked = FCF_Set_ChatLocked or function() end
SetChatMouseOverDelay = SetChatMouseOverDelay or function() end
MultiActionBar_ShowAllGrids = MultiActionBar_ShowAllGrids or function() end
RaidOptionsFrame_UpdatePartyFrames = RaidOptionsFrame_UpdatePartyFrames or function() end
-- …and the one its own CHECK BUTTONS reach: three of them register VARIABLES_LOADED for
-- themselves (xml l.373, 925, 993), and CheckButton43's arm calls `PartyFrame.lua`'s
-- `UpdatePartyMemberBackground`. The other two call the file's own dropdown loaders.
UpdatePartyMemberBackground = UpdatePartyMemberBackground or function() end
"#;
pub(super) const MULTI_ACTION_BAR_STAND_INS: &str = r#"
UIOptionsFrameCheckButtons = UIOptionsFrameCheckButtons or {}
-- The five ROWS it writes into, not just the table: the reference's hack assigns
-- `UIOptionsFrameCheckButtons["SHOW_MULTIBAR1_TEXT"].setFunc`, which needs the row to exist.
-- Empty rows on purpose — the real ones carry `index = 33..36, 40`, and a stand-in that
-- restated those numbers would be a transcription of the reference's table in a test helper,
-- which is the thing 2115 deleted from `OptionsFrame.xml`. Any kit that reads an index has the
-- real window loaded and therefore the real table.
for _, key in ipairs({ "SHOW_MULTIBAR1_TEXT", "SHOW_MULTIBAR2_TEXT", "SHOW_MULTIBAR3_TEXT",
"SHOW_MULTIBAR4_TEXT", "ALWAYS_SHOW_MULTIBARS_TEXT" }) do
UIOptionsFrameCheckButtons[key] = UIOptionsFrameCheckButtons[key] or {}
end
"#;
pub(super) const MICRO_BUTTON_STAND_INS: &str = r#"
local real = UpdateMicroButtons
function UpdateMicroButtons()
@@ -513,10 +589,10 @@ pub(super) const CHARACTER_UI: &[&str] = &[
// The reference's own window tab (`CharacterFrameTabButtonTemplate`), whose `<OnShow>`
// fits each tab to its text — it needs the `UIPanelTemplates` pair above it (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
// The four options templates off the chain, then ours for the one it does not carry
// (`UIOptionsCheckButtonTemplate` — decision 1841).
// The four options templates off the chain — ReputationFrame's detail check boxes inherit
// `OptionsCheckButtonTemplate`. Ours beside it is gone with 2115: its one template,
// `UIOptionsCheckButtonTemplate`, comes off the chain out of `UIOptionsFrame.xml` now.
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
"OptionsFrameTemplates.xml", // ReputationFrame's detail check boxes
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
"Interface\\FrameXML\\PetPaperDollFrame.xml",
@@ -287,7 +287,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() {
let mut s = harness(&[
let s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",